CB-607: a member holds the operator's ssh-agent socket, which outranks the repo-scoped token CB-302 built #110

Open
opened 2026-08-17 13:28:28 +02:00 by ltms · 2 comments
Owner

Found on 2026-08-17 by CB-596's own gap detector, on the first spawn after it deployed. Not by looking for it — the detector named it:

WARN memberCredentials gap: 2 credential-shaped env var name(s) are on neither known: nor allow:
     — every member pane inherits them UNBLOCKED — [CLAUDE_CODE_MESSAGING_TOKEN, SSH_AUTH_SOCK]

Neither name is in secrets.sh, so no hand-written list in CB-592, CB-593 or CB-596 ever contained them. That is the detector doing exactly the job it was added for, one spawn after it existed.

The problem

A member's worktree pushes over SSH:

$ git -C /Users/dai.ha/LTMS/.bridged-worktrees/0cd00f-1 remote -v
origin  ssh://git@git.ltms.dev:2224/lms/claude-bridge.git (fetch)
origin  ssh://git@git.ltms.dev:2224/lms/claude-bridge.git (push)

So SSH_AUTH_SOCK is required today — without it a member cannot push its branch and the whole CB-302 checkpoint flow stops working. It is allow-listed for that reason.

But that socket is a live handle to the operator's running ssh-agent. Any process that can reach it can ask the agent to sign with every key the agent holds, and authenticate as the operator to every host that trusts those keys. It cannot read the private keys, which is the one mercy here, but it does not need to.

This is strictly more power than the credential it sits next to. CB-302 deliberately introduced WORKER_GITEA_TOKEN — a repo-scoped forge token — precisely so a member could push without holding anything broader. CB-592 then blocked the admin GITEA_ACCESS_TOKEN for the same reason. Meanwhile the agent socket, which reaches further than either, was never on anyone's list because it is not a secret value in a secrets file. It is a socket path, and it does not look like a credential.

That is the general lesson worth keeping: the enumeration was of a file, and the exposure is of an environment. Anything granted by a handle rather than by a value slips through a list built by reading secrets.sh.

The fix

Push over HTTPS with the token the member already holds, then block SSH_AUTH_SOCK.

WORKER_GITEA_TOKEN is already injected, already repo-scoped, and already the thing a member uses to open its PR through the REST API. Using it for the push as well removes the only reason a member needs the agent at all.

Roughly: when provisioning a worktree, set its origin to the HTTPS form and let the token authenticate, rather than inheriting the operator's SSH remote. GitWorktrees provisions the worktree, so that is where the remote is decided.

Acceptance criteria

  1. A member can push its branch and open its PR with no SSH_AUTH_SOCK in its environment. Proved by a live spawn with the variable blocked, not by a unit test on the seam — a test that stubs the push proves nothing about whether the real one authenticates.
  2. SSH_AUTH_SOCK moves from allow: to blocked in memberCredentials, and the matching sentinel goes into secrets.sh's CB-596 block.
  3. The operator's own shell is unaffected — this must not touch how a lead pushes.
  4. The token used for the push is the repo-scoped WORKER_GITEA_TOKEN, never GITEA_ACCESS_TOKEN.
  5. A push failure must say why in the member's report. A member that silently cannot push, after a change that removes its only auth path, is the expensive failure here.

Also in the same warning: CLAUDE_CODE_MESSAGING_TOKEN

Set by Claude Code itself, not by secrets.sh. Allow-listed for now because members are Claude Code and blocking it blind risks breaking them. What it actually grants has not been established — that is a separate, smaller question, and it should be answered before it is either blocked or forgotten. Do not fold it into this ticket's fix.

Milestone

2.0. Nothing here is broken on a single host — members push fine today. This is a scope reduction on a credential that is deliberately allowed and documented as such, in bridged.yaml's allow: comments. It belongs with the other credential-scope work rather than blocking the 1.1 tag.

Not in scope

  • Editing ${SHARED_ENV}/tools/secrets.sh. Operator's file. It gains one line when criterion 2 lands, and that is the operator's to apply.
  • A dedicated per-member SSH key. That is a third design (agent restrictions, key management, rotation) and worth its own ticket if HTTPS turns out not to work.
Found on 2026-08-17 by CB-596's own gap detector, on the first spawn after it deployed. Not by looking for it — the detector named it: ``` WARN memberCredentials gap: 2 credential-shaped env var name(s) are on neither known: nor allow: — every member pane inherits them UNBLOCKED — [CLAUDE_CODE_MESSAGING_TOKEN, SSH_AUTH_SOCK] ``` Neither name is in `secrets.sh`, so no hand-written list in CB-592, CB-593 or CB-596 ever contained them. That is the detector doing exactly the job it was added for, one spawn after it existed. ## The problem A member's worktree pushes over SSH: ``` $ git -C /Users/dai.ha/LTMS/.bridged-worktrees/0cd00f-1 remote -v origin ssh://git@git.ltms.dev:2224/lms/claude-bridge.git (fetch) origin ssh://git@git.ltms.dev:2224/lms/claude-bridge.git (push) ``` So `SSH_AUTH_SOCK` is **required today** — without it a member cannot push its branch and the whole CB-302 checkpoint flow stops working. It is allow-listed for that reason. But that socket is a live handle to the operator's running `ssh-agent`. Any process that can reach it can ask the agent to sign with **every key the agent holds**, and authenticate as the operator to every host that trusts those keys. It cannot read the private keys, which is the one mercy here, but it does not need to. **This is strictly more power than the credential it sits next to.** CB-302 deliberately introduced `WORKER_GITEA_TOKEN` — a *repo-scoped* forge token — precisely so a member could push without holding anything broader. CB-592 then blocked the admin `GITEA_ACCESS_TOKEN` for the same reason. Meanwhile the agent socket, which reaches further than either, was never on anyone's list because it is not a secret *value* in a secrets file. It is a socket path, and it does not look like a credential. That is the general lesson worth keeping: **the enumeration was of a file, and the exposure is of an environment.** Anything granted by a handle rather than by a value slips through a list built by reading `secrets.sh`. ## The fix Push over HTTPS with the token the member already holds, then block `SSH_AUTH_SOCK`. `WORKER_GITEA_TOKEN` is already injected, already repo-scoped, and already the thing a member uses to open its PR through the REST API. Using it for the push as well removes the only reason a member needs the agent at all. Roughly: when provisioning a worktree, set its `origin` to the HTTPS form and let the token authenticate, rather than inheriting the operator's SSH remote. `GitWorktrees` provisions the worktree, so that is where the remote is decided. ## Acceptance criteria 1. A member can push its branch and open its PR with **no** `SSH_AUTH_SOCK` in its environment. Proved by a live spawn with the variable blocked, not by a unit test on the seam — a test that stubs the push proves nothing about whether the real one authenticates. 2. `SSH_AUTH_SOCK` moves from `allow:` to blocked in `memberCredentials`, and the matching sentinel goes into `secrets.sh`'s CB-596 block. 3. The operator's own shell is unaffected — this must not touch how a lead pushes. 4. The token used for the push is the repo-scoped `WORKER_GITEA_TOKEN`, never `GITEA_ACCESS_TOKEN`. 5. A push failure must say *why* in the member's report. A member that silently cannot push, after a change that removes its only auth path, is the expensive failure here. ## Also in the same warning: `CLAUDE_CODE_MESSAGING_TOKEN` Set by Claude Code itself, not by `secrets.sh`. Allow-listed for now because members **are** Claude Code and blocking it blind risks breaking them. What it actually grants has not been established — that is a separate, smaller question, and it should be answered before it is either blocked or forgotten. Do not fold it into this ticket's fix. ## Milestone **2.0.** Nothing here is broken on a single host — members push fine today. This is a scope reduction on a credential that is deliberately allowed and documented as such, in `bridged.yaml`'s `allow:` comments. It belongs with the other credential-scope work rather than blocking the 1.1 tag. ## Not in scope - Editing `${SHARED_ENV}/tools/secrets.sh`. Operator's file. It gains one line when criterion 2 lands, and that is the operator's to apply. - A dedicated per-member SSH key. That is a third design (agent restrictions, key management, rotation) and worth its own ticket if HTTPS turns out not to work.
ltms added this to the 2.0 — one operation centre, many hosts milestone 2026-08-17 13:28:28 +02:00
Author
Owner

A methodology note from the #144 work, because it nearly produced a false finding on this
ticket and it changes where the fix belongs.

SSH_AUTH_SOCK does not come from the shell startup files. A sweep of a member login shell
flagged it (and CLAUDE_CODE_MESSAGING_TOKEN), but both came from the parent process
environment — a zsh -l spawned from an existing session inherits that session. Re-running from
a clean parent (env -i HOME=... BRIDGED_MEMBER=1 zsh -l) drops both. So no edit to
secrets.sh, mgnlSecrets.sh or .ltms can fix this ticket.

That does not weaken this ticket — it relocates the fix. env -i deliberately does not model
production. In production the daemon runs inside the operator's session and does inherit
SSH_AUTH_SOCK, then hands its environment to every pane it starts. The exposure is real; the
shell files are simply not the source.

So the fix has to be at the spawn boundary, where the launcher builds the child environment
— the same conclusion #144 (CB-633) reached from the opposite direction, after a shell-file
guard was defeated by source ordering. Two tickets, two different failures, one answer: a
control that lives in a sourced file cannot hold, and a control applied where the child
environment is constructed can.

Practical consequence: do these two together. An allow-list at the spawn boundary closes
this ticket as a side effect, because SSH_AUTH_SOCK is not on any allow-list a member needs.
Fixing them separately means building the same mechanism twice.

When testing either one, state which parent you used. A sweep run from an inherited environment
over-reports, and a sweep run under env -i under-reports the production case. Neither is wrong;
saying which one you ran is what makes the number mean something.

A methodology note from the #144 work, because it nearly produced a false finding on this ticket and it changes where the fix belongs. **`SSH_AUTH_SOCK` does not come from the shell startup files.** A sweep of a member login shell flagged it (and `CLAUDE_CODE_MESSAGING_TOKEN`), but both came from the *parent* process environment — a `zsh -l` spawned from an existing session inherits that session. Re-running from a clean parent (`env -i HOME=... BRIDGED_MEMBER=1 zsh -l`) drops both. So no edit to `secrets.sh`, `mgnlSecrets.sh` or `.ltms` can fix this ticket. **That does not weaken this ticket — it relocates the fix.** `env -i` deliberately does not model production. In production the daemon runs inside the operator's session and *does* inherit `SSH_AUTH_SOCK`, then hands its environment to every pane it starts. The exposure is real; the shell files are simply not the source. So the fix has to be at the **spawn boundary**, where the launcher builds the child environment — the same conclusion #144 (CB-633) reached from the opposite direction, after a shell-file guard was defeated by source ordering. Two tickets, two different failures, one answer: a control that lives in a sourced file cannot hold, and a control applied where the child environment is constructed can. Practical consequence: **do these two together.** An allow-list at the spawn boundary closes this ticket as a side effect, because `SSH_AUTH_SOCK` is not on any allow-list a member needs. Fixing them separately means building the same mechanism twice. When testing either one, state which parent you used. A sweep run from an inherited environment over-reports, and a sweep run under `env -i` under-reports the production case. Neither is wrong; saying which one you ran is what makes the number mean something.
Author
Owner

Correction, 2026-08-28 — the premise behind sshAuthSock: block is false on this host. See #184.

This ticket, and the config comment it produced, said the ssh-agent is the only ssh credential a member could reach, so blanking SSH_AUTH_SOCK stops a member signing with the operator's keys.

Measured today: it does not. A member spawned under policy: allow-list, with SSH_AUTH_SOCK confirmed empty, pushed to the forge over SSH successfully.

~/.ssh/config is only Include lines. ssh -G git.ltms.dev resolves an IdentityFile under the shared-env directory, and that file exists, is readable by this user, and has no passphrase. A member runs as the same OS user, so it reads the key.

Keeping the block is still right — it costs nothing and closes the agent path. But it is not a control, and the decision recorded here should not be cited as one. Details, evidence and the isolation question are in #184.

**Correction, 2026-08-28 — the premise behind `sshAuthSock: block` is false on this host. See #184.** This ticket, and the config comment it produced, said the ssh-agent is the only ssh credential a member could reach, so blanking `SSH_AUTH_SOCK` stops a member signing with the operator's keys. Measured today: it does not. A member spawned under `policy: allow-list`, with `SSH_AUTH_SOCK` confirmed **empty**, **pushed to the forge over SSH successfully**. `~/.ssh/config` is only `Include` lines. `ssh -G git.ltms.dev` resolves an `IdentityFile` under the shared-env directory, and that file exists, is readable by this user, and has **no passphrase**. A member runs as the same OS user, so it reads the key. Keeping the block is still right — it costs nothing and closes the agent path. But it is not a control, and the decision recorded here should not be cited as one. Details, evidence and the isolation question are in #184.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: fleet/fleetd#110