Plan: Move fleet-root credentials off the shared claude-runner account (C-R6(b) Option B)
Issue: #4815
Type: Code-change-design (plan only — no credential move in this PR)
Status: Draft — iteration 1
Last updated: 2026-08-01
Author: Claude Opus 4.8 (worker session bms4-cw-1 on bms-4, role dev-coder)
Related:
#4724 (C-R6(b) Option A — --allowedTools allowlist, shipped) ·
#4706 (role-capability enforcement — ROLE_CAP_ENFORCE, the env-level SSH/SOPS withholding this plan completes) ·
#4245 / #4065 (GitHub App-token fleet migration — already covers most of the GitHub-PAT dimension) ·
#2075 / docs/plans/plan-per-role-worker-credentials.md (per-role worker credentials) ·
docs/adr/002-claude-account-topology.md (#4212 — second-account precedent)
Scope discipline. This document is a design/scoping artefact, exactly as the issue requires (“this issue is for scoping/designing the approach, not a same-day credential move”). It contains no secret values — key/file names only. Every execution step is delegated to
sys-admin(OS users,systemd-run, dispatcher deploy) andsecret-manager(gh authlogin switch, any SOPS touch). Adev-coderworker authored it and cannot perform those steps inline.
0. Summary — recommendation
The issue offers three candidate approaches (1: move vps_root_key to a distinct OS user; 2: switch
gh auth to a scoped bot account; 3: per-agent OS-user isolation). Grounding them against the actual
code changes the sequencing, and reveals that half the work is already built or in flight:
| Dimension | Issue option | Reality on the host today | Recommendation |
|---|---|---|---|
| GitHub token | Option 2 (gh auth → AI-Dev-BMS4-1) | The git path already mints 1h App-installation tokens via bms-4/git-credential-gh-token.sh (PAT fallback), migration owned by 4065. What is still radieu’s owner-level PAT is the interactive gh auth login used by telegram-claude-bot’s /ask and the gmail daily agent. | Do the login switch (Phase 0) — it is cheap and standalone — but do NOT re-plan the git-token path; ride 4065. |
| Fleet-root SSH | Option 1 (vps_root_key → distinct OS user) | #4706 already derives ROLE_DENY_SSH and, when ROLE_CAP_ENFORCE=1, unsets SSH_AUTH_SOCK + sets GIT_SSH_COMMAND=/bin/false for SSH-denied roles. Its own guard comment (spawn-worker.sh:651-657) states it cannot revoke on-disk read of ~/.ssh/vps_root_key and defers that to “per-role unix users” as a follow-up. This issue is that follow-up. | Primary work: job-type → OS-user split so only infra-task/infra-alert workers get the key on disk (Phase 1). |
| Always-on agents | Option 3 (per-agent OS user) | telegram-claude-bot + gmail daily agent run as claude-runner (workspace-isolated #3708, but same home dir, same vps_root_key, same age key). | Give them a low-privilege OS user with neither the SSH key nor the age key (Phase 2). |
Why the ordering matters. The failure mode of getting Phase 1 wrong is “every queue worker on
bms-4 loses SSH simultaneously”. So the plan front-loads the two low-risk, independently-shippable
wins (Phase 0 gh login; enabling the already-built #4706 env guard) and gates the OS-user
restructuring behind an explicit human decision (§6 ARCH GATE) and a staged, reversible rollout.
Recommended path, lowest-blast-radius first:
- Phase 0 —
gh authlogin →AI-Dev-BMS4-1bot (Option 2). Contains the GitHub blast radius from “everything the owner can do” to a scoped repo grant. No queue-worker dependency.secret-manager+sys-admin. - Phase 1a — flip
ROLE_CAP_ENFORCE=1(#4706 is coded and dry-run-clean today). Zero new code — closes the env-inherited SSH/SOPS paths for roles that don’t need them. Fully reversible (=0). - Phase 1b — job-type → OS-user split (Option 1): a dedicated
claude-infrauser ownsvps_root_key; the dispatcher routes onlyinfra-task/infra-alert/infra-task-requestto it.claude-runner*workers no longer have the key on disk — closing the residual gap #4706 explicitly left open. - Phase 2 — per-agent low-priv users (Option 3) for
telegram-claude-bot+ gmail daily agent, plussystemd-runPrivateTmp/BindPathshardening as defence-in-depth.
Phases 0, 1a, 1b, 2 are each independently valuable and independently revertible. The issue can be closed after Phase 0+1a+1b land; Phase 2 may be split to a child issue if the OS-user work proves large.
1. Problem restatement (grounded)
docs/ai-risk-data-governance.md C-R6(b) sets the ceiling: an agent invoked as
claude --dangerously-skip-permissions (gmail daily agent) — or any process that reaches the
claude-runner UID through any path (a queue-worker bug, a dependency vuln, the telegram /ask
handler) — gets arbitrary command execution as claude-runner on bms-4. That account’s home holds:
| Credential (name only) | Path | Reach |
|---|---|---|
| Fleet-root SSH key | ~/.ssh/vps_root_key | root SSH to bms-1/2/3/4, vps-i1, vps-h1 |
| SOPS age key | ~/.age/p24-infra-keys.txt | decrypts every secrets/*.env.sops |
| GitHub login | ~/.config/gh/ (active gh auth) | owner-level PAT today |
C-R6(b) is explicit that no in-run mitigation exists for the host dimension: “a file on disk
readable by claude-runner stays readable” — unsetting env vars closes inherited paths only. The
--allowedTools allowlist shipped in #4724 (Option A) closes one agent’s own tool surface but does
nothing for a compromise arriving through a different path. Only three fixes actually reduce C-R6(b),
and the issue names all three; this plan is the design pass for them.
The three durable references agree on the framing:
docs/ai-risk-data-governance.md C-R6(b) · docs/gmail-tools-daily-agent-operations.md §Activation
checklist blocker 3 (Option A/B/C) · docs/priorities.md §P1 (telegram-claude-bot entry).
2. What already exists (do not rebuild)
Concrete, so the plan extends rather than duplicates:
scripts/spawn-worker.sh:648-673— #4706 SSH guard. WhenROLE_CAP_ENFORCE=1and the role’scapabilities.cannotimplies server ops (ROLE_DENY_SSH=1), itunset SSH_AUTH_SOCKandexport GIT_SSH_COMMAND=/bin/false.dev-coder.cannotincludes “SSH into a production server”, so adev-issueworker is already SSH-denied at the env level — but the guard’s own comment (lines 651-657) states it “does NOT revoke read access to an on-disk key under~/.ssh… Closing that residual gap needs per-worker filesystem isolation (systemd-runPrivateTmp/BindPaths, or per-role unix users) and is deliberately out of scope here — tracked as a follow-up indocs/worker-role-scoping-operations.md.” #4815 is that follow-up.scripts/spawn-worker.sh:566-568— #4706 SOPS guard. Symmetric:ROLE_DENY_SOPS=1unsetsSOPS_AGE_KEY_FILE. Same on-disk caveat applies to~/.age/p24-infra-keys.txt.scripts/spawn-worker.sh:333-339— job-type → role map already exists:infra-task|infra-alert|nc-alert-batch → sys-admin,dev-issue|continue-issue → dev-coder,review-* → dev-reviewer,secret-manager → secret-manager. Phase 1b reuses this exact mapping to pick the OS user, so there is no second source of truth for “which job type needs fleet SSH”.scripts/queue-dispatcher-loop.py:1287-1306— spawn path already runssu -s /bin/bash {worker_user} -c '{spawn_script}'and iteratesworker_usersfrom_account_order(server)(todayclaude-runner,claude-runner-2for subscription balancing). The user is already a parameter — Phase 1b adds an infra-vs-general dimension to_account_order, it does not invent a new spawn mechanism.- **
bms-4/git-credential-gh-token.sh+ 4065 — the git-over-HTTPS token path is already App-token-first (1h TTL) with PAT fallback scheduled for removal. Phase 0 only touches the interactivegh authlogin, a different surface. docs/adr/002-claude-account-topology.md+claude-runner-2(ecotrans) — a second independent OS/Claude account on bms-4 is already precedent; Phase 1b/2 add users, not a new topology class.docs/plans/plan-per-role-worker-credentials.md— per-role SOPS files + signing identity; this plan is the OS-user / filesystem complement to that secret-scoping work. Keep them aligned: the OS user a role runs as (here) should be the same axis the per-role SOPS files key on (there).
3. Only infra-* job types genuinely need fleet-root SSH
The load-bearing fact for Phase 1b. From the spawn path, worker prompts, and job-type map:
| Job type(s) | Fleet-root SSH needed? | Why |
|---|---|---|
infra-task, infra-alert, infra-task-request | YES | SSH root@bms-1/2/3, vps-i1 for MongoDB/Docker/systemd/remediation (infra-task-request-worker.md; scripts/remediation/*; spawn-infra-task-worker.sh) |
dev-issue, continue-issue | no | git over HTTPS (gh credential helper); no server shell |
review-pr, review-plan | no | GH API only |
nc-alert-batch, alert-triage-batch | no | local Prometheus/Supabase REST on the host they run on |
secret-manager | no fleet SSH | reads SOPS + distributes to the local env files / Vercel; does not need root SSH to other bare-metal boxes for its core job (verify against worker-secret-manager.md before Phase 1b cutover) |
So a single dedicated OS user (claude-infra, name TBD) can hold vps_root_key, and every other
worker class runs as a user that never has the key on disk. This is Option 1 “done right”: scoped by
the already-existing job-type→role classification rather than by trusting each agent to self-restrict.
4. Design — phase by phase
Phase 0 — gh auth login → AI-Dev-BMS4-1 bot (Option 2 / issue Option 2)
Goal: the active gh auth login on bms-4 stops being radieu’s owner-level PAT.
Owner: secret-manager (mint/store the scoped bot PAT in the correct SOPS file) + sys-admin
(gh auth login on the host as the worker user, or deploy the credential).
Changes:
- Provision/confirm the
AI-Dev-BMS4-1bot account and a scoped fine-grained PAT (repo scope on the repos workers actually touch; noadmin:org/owner scope). Store the key name in a SOPS file per the SOPS map (candidate:secrets/n8n-bms4.env.sops, which already holdsGH_TOKEN; confirm with secret-manager — do not invent a new file without checkingplan-per-role-worker-credentials.md). - Switch the interactive
ghlogin used bytelegram-claude-bot/askand the gmail daily agent to that account. Verifygit-credential-gh-token.shApp-token path is unaffected (it is a separate helper, not theghlogin). Regression risk: any automation relying on owner-level scope via theghCLI login breaks. Auditgh api/ghcalls in dispatcher,github-runnerrole,queue-analyst,n8n-workflow-exportfor scopes the bot PAT lacks (runner registration may needadminon the repo — verify before cutover). Reversible: re-rungh auth loginwith the prior credential.
Phase 1a — flip ROLE_CAP_ENFORCE=1 (enable the already-built #4706 env guard)
Goal: stop env-inherited SSH/SOPS reach for roles that don’t need it, with zero new code.
Owner: sys-admin (set ROLE_CAP_ENFORCE=1 in the worker environment / dispatcher spawn env).
Changes: none to code — #4706 ships dry-run today (spawn-worker.sh:425 logs DRY-RUN … nothing withheld). Flip the flag, watch the [role-cap] … (ENFORCING) log line and mezmo_log role_cap_ssh_withheld events for a cycle.
Regression risk: low but real — if any dev-issue/review worker quietly depended on
SSH_AUTH_SOCK or the age key, it now fails closed. The mapping treats that as a bug to surface, not
to tolerate. Validate on one worker user first.
Reversible: ROLE_CAP_ENFORCE=0.
Phase 1a alone does not close C-R6(b): env withholding ≠ filesystem isolation. It is a cheap, reversible hardening that makes Phase 1b’s blast-radius smaller by ensuring nothing legitimately uses the inherited paths before the on-disk key moves.
Phase 1b — job-type → OS-user split for vps_root_key (Option 1, the core fix)
Goal: vps_root_key exists on disk only under a dedicated user; general workers cannot read it.
Owner: sys-admin (OS users, key relocation, dispatcher deploy) + secret-manager (if the key is
re-sourced from SOPS during the move).
Target state:
- New OS user (candidate
claude-infra) on bms-4 (and vps-i1 where infra workers run). Owns~/.ssh/vps_root_keyat mode0600.claude-runner*home dirs no longer contain the key. scripts/queue-dispatcher-loop.py_account_order()/ the spawn loop (:1287-1306) selects the OS user by the same job-type→role classification already inspawn-worker.sh:333-339:infra-task|infra-alert|infra-task-request → claude-infra; everything else →claude-runner*. (Keep the subscription-balancing account rotation within each class.)spawn-infra-task-worker.sh(the n8n path) targetsclaude-infra.- Age key
~/.age/p24-infra-keys.txt: decide whether it also moves toclaude-infraor stays broadly readable. Recommendation — keep it out ofclaude-runner*too, lettingROLE_DENY_SOPS+ the per-role SOPS files (plan-per-role-worker-credentials.md) govern who decrypts what. Flag for the human decision (§6): this couples #4815 to the per-role-SOPS rollout and may widen scope. Regression risk (highest in the plan): mis-routing a job type that does need SSH to a keyless user strands every such job; conversely, if the subscription-balancing rotation is not preserved per-class, dispatch capacity drops. Mitigation: staged rollout — (i) createclaude-infraand copy the key, keepclaude-runner’s copy; (ii) route onlyinfra-alertto the new user, watch a full cycle; (iii) addinfra-task/infra-task-request; (iv) only then remove the key fromclaude-runner*. Each step is independently revertible until step (iv). Reversible until final key removal: re-add the key toclaude-runner*; restore prior_account_order.
Phase 2 — per-agent low-privilege users for always-on agents (Option 3)
Goal: telegram-claude-bot /ask and the gmail daily agent run as users holding neither
vps_root_key nor the age key.
Owner: sys-admin.
Changes: dedicated OS users for each always-on agent; update their systemd units
(claude-session-manager.service, the gmail agent invocation su -s /bin/bash claude-runner … in
docs/gmail-tools-daily-agent-operations.md). Add systemd-run/unit hardening (PrivateTmp=yes,
ProtectHome=/BindPaths= scoped to the agent’s own workspace) as defence-in-depth so even a shared
user cannot traverse to another agent’s credential files.
Regression risk: agent workspaces, auth caches (~/.claude/.credentials.json), and log paths
(/var/log/p24-infra-workers/… owned by claude-runner) must be re-permissioned for the new users.
Coordinate with ADR 002 account topology (avoid multiplying Claude-account refresh-token copies).
Reversible: revert unit User=/hardening.
5. Compliance & documentation deliverables (for the implementation PRs, not this plan)
- Update
docs/worker-role-scoping-operations.md— replace the “residual gap / follow-up” note with the delivered filesystem isolation. - Update
docs/ai-risk-data-governance.mdC-R6(b) — downgrade the host dimension once Phase 1b+2 land (or record explicit owner acceptance of any residual). - Update
docs/gmail-tools-daily-agent-operations.md§Activation checklist blocker 3 (Option B resolved) anddocs/priorities.md§P1 (human-supervised — flag, do not self-edit priorities). - New/updated ops doc for the
claude-infrauser and per-agent users;dev_r_servicesrow if a new service identity is introduced.
6. ARCH GATE — human decisions required before implementation
Per the issue (“go through the plan workflow — design → review-plan → implement, not straight to
code”) and the CLAUDE.md architecture gate (new architecture), the following need an explicit
decision before any implementation issue is opened:
- Approve the phased sequencing (0 → 1a → 1b → 2) and confirm Phase 0 + 1a may ship immediately (both low-risk, reversible) while Phase 1b awaits this ratification.
- Phase 1b OS-user model: one shared
claude-infrauser for allinfra-*job types (recommended, simplest) vs. per-role users. Confirm the user name and which hosts get it (bms-4 only, or vps-i1 too). - Age-key placement (Phase 1b): does
~/.age/p24-infra-keys.txtalso move offclaude-runner*(couples toplan-per-role-worker-credentials.md, widens scope) or stay for now (narrower, but the SOPS blast-radius half of C-R6(b) remains open)? - Phase 0 bot-PAT scope + SOPS home: confirm
AI-Dev-BMS4-1scopes and the SOPS file, and that no owner-scope automation (runner registration, org ops) depends on the currentghlogin. - Issue split: close #4815 on Phase 0+1a+1b, or keep it open through Phase 2? (Recommend: split Phase 2 to a child issue if the OS-user work is large.)
7. Out of scope (explicit)
- Do not touch the
--allowedToolsallowlists from #4724 — a separate, complementary control that stays regardless (issue non-goal). - Do not re-plan the git-over-HTTPS App-token migration — owned by 4065.
- No credential move happens in this PR. This is the design; execution is delegated to
sys-adminsecret-managervia follow-up implementation issues once §6 is decided.
Role: dev-coder (design/scoping only; execution delegated to sys-admin + secret-manager).
Auto-generated plan — review via /review-plan 4815 before opening implementation issues.