Skip to content

feat(sandbox): opt-in ssh-agent socket access for sandboxed ACP agents [BLOCKED: inert at runtime] - #2344

Closed
jonathanKingston wants to merge 3 commits into
mainfrom
claude/ssh-agent-sandbox-carveout
Closed

feat(sandbox): opt-in ssh-agent socket access for sandboxed ACP agents [BLOCKED: inert at runtime]#2344
jonathanKingston wants to merge 3 commits into
mainfrom
claude/ssh-agent-sandbox-carveout

Conversation

@jonathanKingston

@jonathanKingston jonathanKingston commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

⚠️ Do not merge — this does not work at runtime

Verified on macOS by @jonathanKingston and confirmed against the ASRT source: the per-spawn grant never reaches the seatbelt profile. sandbox-manager.js:1296 passes allowUnixSockets: getAllowUnixSockets(), a zero-arg getter reading only the module-level global config, so the customConfig.network.allowUnixSockets this PR supplies is silently dropped. Every neighbouring field (filesystem, credentials, network.allowedDomains, allowPty, git) honours customConfig ?? config; these four do not. @anthropic-ai/sandbox-runtime 0.0.75 is identical, so upgrading does not fix it.

There is no correct app-side per-spawn grant against ASRT as published. Mutating the global for the spawn is ruled out — unrelated sandbox spawns must not inherit key-use authority, and wrapWithSandbox being async means a concurrent spawn would bake the grant into its own profile. The fix belongs upstream; see this comment for the proposed change and the open decision.

Everything below describes the intended design, and the policy/UI half is validated. The runtime half is not.

Closes the commit-signing half of #2320, taking the sandbox carve-out rather than a passphrase-less key.

Why the sandbox is at fault

An ACP agent is spawned inside Copse's project sandbox (acp-client.tsspawnSandboxedAcpSessionHost with acpAgentSandboxOverlay), so the agent and everything it runs — including git commit — inherits the seatbelt profile, which denies connect() to the launchd ssh-agent socket.

A passphrase-protected signing key is only usable through ssh-agent, so ssh-keygen -Y sign falls back to the key file and needs a passphrase it cannot ask for: the commit fails. The history in #2320 is unsigned because agents work around that with --no-gpg-sign, not because signing was skipped silently.

SSH_AUTH_SOCK already reaches the child — envForRendererChildProcess strips only LLM/provider credentials — so it is purely sandbox policy denying the connect.

Design

agentSshAgentSocketAccess (default false) admits exactly the one socket SSH_AUTH_SOCK names, and only when that path is a unix socket right now.

That last check is load-bearing rather than a sanity check: ASRT emits (subpath "<path>") per entry, and a subpath rule over a directory admits every socket beneath it — / would admit all of them. Narrowing to a socket node keeps subpath and literal equivalent. Empty, relative, non-normalised (/a/../b pins one string while the kernel resolves another), missing, or non-socket values grant nothing. allowAllUnixSockets is never set.

Why off by default

An agent socket is a confused-deputy channel. The sandbox still stops the process reading the key, but the socket lets it ask ssh-agent to use that key, and the protocol has no "signing only" scope — so it can sign arbitrary data and authenticate anywhere those keys are trusted. Unlike the auto-run path (allowedDomains: []), an ACP agent's profile is not network-denied. ssh-add -c is the recommended pairing.

Preferred over the passphrase-less key the issue lists alongside it: that is a permanent, global downgrade — a directly usable key on disk for every process, forever, repeated by every contributor. This is scoped to processes Copse spawns and keeps the passphrase intact.

macOS only

mechanism can it name one socket?
macOS seatbelt (subpath …) on network-outbound yes — path-based
Linux seccomp-bpf on socket(AF_UNIX, …) no — seccomp cannot read a path from user memory

Linux's only knob is allowAllUnixSockets, which opens every unix socket in the sandbox to buy one. Not a trade to make silently, so Linux is untouched and the setting is inert there. Windows has no sandbox.

Consent surface

Settings › Permissions, own "Commit signing" fieldset beside the other capability grants, stating what is handed over rather than only that something is. Validated on macOS: both Settings cases pass with the warning visually inspected.

Testing

  • pnpm run check green; 50 focused socket/config tests pass on macOS.
  • Policy tests cover opt-out, Linux, Windows, unset/blank/relative/non-normalised/directory//, plus a resolver test against a real unix socket on disk.
  • tests/e2e/ssh-agent-consent-settings.e2e.ts asserts the control is present and unchecked on a fresh profile and that the load-bearing phrases are on screen.
  • Not validated: the runtime grant — see the banner.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MMrECZ1PfJ58YeXTuaxybc

Closes the commit-signing failure in #2320. An ACP agent is spawned inside
the project sandbox (`acp-client.ts` → `spawnSandboxedAcpSessionHost` with
`acpAgentSandboxOverlay`), so the seatbelt denies `connect()` to the launchd
ssh-agent socket. A passphrase-protected signing key is only usable through
ssh-agent, so `ssh-keygen -Y sign` falls back to the key file and needs a
passphrase it cannot ask for — every agent-authored commit lands unsigned even
with `commit.gpgsign=true`, while the same commit signs fine unsandboxed.

`agentSshAgentSocketAccess` (default false) admits exactly the one socket
`SSH_AUTH_SOCK` names, through ASRT's `network.allowUnixSockets` — which the
macOS backend turns into a `(subpath …)` filter on `network-outbound` plus the
`system-socket` grant `socket(AF_UNIX)` needs. Never a directory, never
`allowAllUnixSockets`.

Off by default on purpose rather than by oversight. An agent socket is a
confused-deputy channel: the sandbox still stops the process reading the private
key, but the socket lets it ask ssh-agent to use that key, and the protocol has
no "commit signing only" scope. Unlike the auto-run profile, an ACP agent's
profile is not network-denied. `ssh-add -c` is the recommended pairing.

Preferred over the passphrase-less-key alternative the issue lists alongside it:
that leaves a directly usable key on disk for every process on the machine,
sandboxed or not, permanently, and every contributor repeats the setup. This
grant is scoped to processes Copse spawns and keeps the passphrase intact.

macOS only. Linux enforces the same boundary with a seccomp-bpf filter on
`socket(AF_UNIX, …)`; seccomp cannot read a socket path from user-space memory,
so its only knob is `allowAllUnixSockets` — every unix socket in the sandbox
(Docker, Gradle, the display server) to buy one. Not a trade to make silently,
so Linux keeps its profile unchanged and the setting is inert there.

An agent that has not opted in gets a byte-identical profile to before: the key
is omitted rather than sent empty, since ASRT leaves unix sockets blocked only
while handed neither key.

The policy is a pure function taking the socket and platform as parameters, so
it is testable without a test-only path into the product. The caller reads the
setting and the spawned child's own `SSH_AUTH_SOCK` — not `process.env` — so
the socket named in the profile is the one the agent will connect to.

Docs: new section in the binding `docs/shell-permissions.md` per AGENTS.md.

No renderer surface in this PR, so no visual evidence is required; the Settings
toggle needs a macOS GUI run and belongs with the end-to-end verification.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMrECZ1PfJ58YeXTuaxybc
@jonathanKingston

Copy link
Copy Markdown
Collaborator Author

Readiness review: the socket carve-out is default-off and scoped to macOS, with its signing/authentication risk documented. I am leaving this draft until there is a discoverable, explicit user opt-in surface and a real macOS signing/socket-access validation; the current writable setting alone does not provide that consent UX.

Readiness review on #2344 asked for a discoverable, explicit user opt-in
before this can leave draft: a writable config key is not consent UX.

Adds the toggle to Settings › Permissions, in its own "Commit signing"
fieldset beside the other capability grants, so the control is found where a
user already goes to decide what an agent may do without asking.

The copy states what is handed over rather than only that something is. A bare
label ("use your ssh-agent") reads like a convenience toggle, so the hint says
the grant lets an agent ask ssh-agent to use any key it holds, for anything;
that the protocol has no "signing only" scope; that it can therefore
authenticate as the user wherever those keys are trusted; that it never gains
read access to the key itself; and that `ssh-add -c` confirms every use. It
also states the macOS-only limitation, so the setting cannot read as broken on
Linux.

Default stays false. The renderer field is declarative (SIMPLE_FIELDS), so it
reads and writes through the same path as every other checkbox.

Visual evidence: tests/e2e/ssh-agent-consent-settings.e2e.ts drives Settings ›
Permissions, asserts the control is present and unchecked on a fresh profile,
asserts the two load-bearing phrases are actually on screen (not merely in the
source), and captures the fieldset. The capture targets the fieldset rather
than the section because the helper re-centres on whatever element it is
given, and the Permissions pane is taller than the dialog.

The real macOS signing / socket-access validation the review also asks for
still needs a Mac and remains outstanding; this commit does not claim it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMrECZ1PfJ58YeXTuaxybc

Copy link
Copy Markdown
Collaborator Author

Pushed the first of your two gates in 830f655. The second is still open and still yours.

Opt-in surface — done

The toggle now lives in Settings › Permissions, in its own "Commit signing" fieldset alongside the other capability grants, so it's found where a user already goes to decide what an agent may do without asking. Agreed that the writable key alone wasn't consent.

The copy states what is handed over, not just that something is — a bare "use your ssh-agent" label reads like a convenience toggle:

Off by default. A passphrase-protected SSH signing key only works through ssh-agent, so without this an agent's commits land unsigned even with commit.gpgsign=true. Turning it on lets an agent ask ssh-agent to use any key it holds, for anything — the agent protocol has no "signing only" scope — so it can also authenticate as you wherever those keys are trusted. It never gains read access to the key itself. Pair it with ssh-add -c to confirm every use. macOS only: on Linux the sandbox cannot admit one socket without admitting them all, so this does nothing there.

The macOS-only line is in the UI deliberately, so the setting can't read as broken on Linux.

Visual evidence

tests/e2e/ssh-agent-consent-settings.e2e.ts2 passing, screenshot committed at tests/e2e/screenshots/ssh-agent-consent-permissions.png. It opens Settings › Permissions, asserts the control is present and unchecked on a fresh profile, asserts both load-bearing phrases ("any key it holds", "ssh-add -c") are on screen rather than merely in source, and captures the fieldset. It frames the fieldset rather than the section because the screenshot helper re-centres on whatever element it's handed, and the Permissions pane is taller than the dialog — a section-level shot lands on the fieldsets above this one.

pnpm run check is green end to end on the new head, and the oracle now counts 237 specs.

Still outstanding — yours

The real macOS signing / socket-access validation. I can't reach seatbelt or a launchd ssh-agent from a Linux container, so this remains written against ASRT's profile generator and your reproduction in #2320. What wants confirming on a Mac, with the setting on:

  1. ssh-add -l succeeds inside a sandboxed agent (currently Operation not permitted).
  2. An agent-authored commit carries a gpgsig header.
  3. With the setting off, both still behave exactly as they do today — the profile is byte-identical, since the config key is omitted rather than sent empty.

Leaving it as draft until you've run that.


Generated by Claude Code

github-actions Bot added a commit that referenced this pull request Sep 5, 2026
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

🖥️ PR preview

@jonathanKingston

Copy link
Copy Markdown
Collaborator Author

The explicit opt-in and broad-key-use warning address the missing consent surface. I am still holding for the stated real macOS socket/signing validation. Two details to include in that pass: (1) the helper currently accepts any absolute SSH_AUTH_SOCK path, while the backend emits a subpath grant; verify/reject directory values so an invalid socket setting cannot widen the grant; (2) the UI should not say commit.gpgsign=true silently produces unsigned commits when signing is unavailable—git normally fails the commit. Prefer wording that signing can fail without agent access.

…copy

Both points from the readiness review on #2344.

**A non-socket SSH_AUTH_SOCK could widen the grant.** The helper accepted any
absolute path, but ASRT emits `(subpath "<path>")` per entry — and a subpath
rule over a *directory* admits every socket beneath it, with `/` admitting all
of them. So a SSH_AUTH_SOCK naming a directory, by misconfiguration or by an
environment someone else influenced, silently granted far more than the one
agent socket this feature exists to admit. This was the opposite of narrow.

The policy now requires the path to be a unix socket, which keeps `subpath`
and `literal` equivalent because a socket has nothing beneath it. It also
rejects a path that is not already normalised: `/a/../b` pins one string in the
profile while the kernel resolves another.

`sshAgentSocketAllowList` stays pure and gains an `isSocket` input;
`resolveSshAgentSocketAllowList` answers it with `statSync` and is what the
call site uses. `statSync` follows symlinks on purpose — the seatbelt matches
the vnode the path resolves to, so what is at the end of the link is what
matters. A missing or unreadable path throws and reads as "not a socket",
which only ever narrows the grant.

**The copy overstated the failure mode.** It said `commit.gpgsign=true`
produces unsigned commits without agent access; git actually fails the commit
rather than quietly writing an unsigned one. The history in #2320 is unsigned
because agents worked around the failure with `--no-gpg-sign`, not because
signing was skipped silently. Settings and `docs/shell-permissions.md` now say
signing fails, and name the workaround as the reason the history looks the way
it does.

Tests: three cases for the new refusals (directory, `/`, non-normalised) plus
one driving the resolver against a real unix socket on disk — admitting the
socket and refusing both the directory holding it and a missing path.

Visual evidence re-captured with the corrected wording; both cases still pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMrECZ1PfJ58YeXTuaxybc

Copy link
Copy Markdown
Collaborator Author

Both fixed in 5d335fd. The first one was a real hole, not a hardening nit — thank you.

(1) A non-socket SSH_AUTH_SOCK widened the grant

You're right, and it inverted the whole point of the feature. allowUnixSockets entries become (subpath "<path>"), so a directory value admitted every socket beneath it, and / would have admitted all of them. The helper only checked isAbsolute, so it would have accepted either.

The policy now requires the path to be a unix socket. That keeps subpath and literal equivalent, because a socket node has nothing beneath it — so the emitted rule means what the setting claims. It also rejects a path that isn't already normalised, since /a/../b pins one string in the profile while the kernel resolves another.

Structure: sshAgentSocketAllowList stays pure and takes an isSocket input; resolveSshAgentSocketAllowList answers it with statSync and is what the call site uses. statSync follows symlinks deliberately — the seatbelt matches the vnode the path resolves to, so what's at the end of the link is what matters. A missing or unreadable path throws and reads as "not a socket", which only ever narrows.

Four new tests: directory, /, non-normalised path, plus one driving the resolver against a real unix socket on disk — admitting the socket, refusing the directory holding it, and refusing a missing path.

(2) The copy overstated the failure mode

Also right — git fails the commit rather than quietly writing an unsigned one. The unsigned history in #2320 comes from agents working around that with --no-gpg-sign, not from silent skipping. Settings and docs/shell-permissions.md now read:

…so without this an agent's signed commits fail outright — git stops rather than quietly writing an unsigned commit, and an agent that works around it with --no-gpg-sign leaves the history unsigned instead.

Naming the workaround seemed worth the extra clause, since it's what explains the history you actually observed.

State

pnpm run check is green apart from one pre-existing, environment-dependent failure I should flag rather than hide: execution-root-watcher › "reports failure for a root that cannot be watched" fails on this branch and on a clean checkout alike, because the container runs as root and an unreadable directory is still watchable. Unrelated to this diff and it passes on CI. Unit total 8267/8269.

Visual evidence re-captured with the corrected wording — both cases still pass.

Point (3), the real macOS socket/signing validation, is still yours and still what this is held on. The socket-node check adds one thing worth exercising in that pass: with the setting on and SSH_AUTH_SOCK pointed at a directory rather than a socket, the profile should carry no allowUnixSockets entry at all.


Generated by Claude Code

github-actions Bot added a commit that referenced this pull request Sep 5, 2026
@jonathanKingston

Copy link
Copy Markdown
Collaborator Author

macOS readiness result on 5d335fd: the 50 focused socket/config tests pass; build and both Settings Electron cases pass, with the corrected warning visually inspected. Directory rejection and the signing-copy findings are resolved.

New blocking runtime finding: the opt-in does not reach the actual seatbelt profile. In the installed @anthropic-ai/sandbox-runtime 0.0.74, wrapWithSandbox calls wrapCommandWithSandboxMacOS with allowUnixSockets: getAllowUnixSockets(), which reads only the global manager config. The per-spawn customConfig.network.allowUnixSockets supplied by this PR is ignored. acquireSandboxNetworkScope also reconstructs the global network config without socket grants.

Reproduced on this Mac using a disposable ed25519 key, two private ssh-agent processes, and a temporary Git repository; no user keys or real agent were used. Removed the private-key file after loading the agents. An unsandboxed signed commit verifies; default-off correctly refuses signing; opt-in via the PR-style per-spawn overlay still fails with No private key found for public key. As a diagnostic control in the isolated probe process only, placing the exact socket grant in the global config makes the sandboxed commit sign and verify; a second unlisted socket stays blocked and a directory-valued path is refused.

Keeping this draft: please implement/test a true per-spawn runtime grant. Do not simply lift the socket into the shared global config for an agent lifetime, because unrelated sandbox spawns must not inherit key-use authority. The disposable probe and logs are preserved with the local review report.

Copy link
Copy Markdown
Collaborator Author

Confirmed from the ASRT source, and it's worse than "one call site". Thank you for running this — I validated the emitted profile shape and never checked whether the config reached the emitter, which is precisely the gap a real run finds and a source read of the generator alone does not.

Your finding, traced

acp-client.ts:492 uses wrapWithSandboxArgv. On macOS that delegates straight through:

const wrapped = await wrapWithSandbox(command, binShell, customConfig, abortSignal, options)

so customConfig is carried down. But at the seatbelt call (sandbox-manager.js:1296):

allowUnixSockets: getAllowUnixSockets(),      // function getAllowUnixSockets() { return config?.network?.allowUnixSockets }

config is the module-level global set only by initialize(). Every neighbouring field honours the per-spawn channel — customConfig?.filesystem ?? config?.filesystem, customConfig?.credentials ?? config?.credentials, customConfig?.network?.allowedDomains ?? …, allowPty, gitSafeDirectories — and the four socket/binding fields are the ones that don't. So the overlay is dropped exactly as your probe showed.

Three things that change the options:

  1. 0.0.75 is byte-identical here. I pulled it: same getter at 1088, same call at 1296. Upgrading does not fix it.
  2. The same shape hits allowAllUnixSockets, allowLocalBinding and allowMachLookup. That is why acquireSandboxNetworkScope exists — the repo already works around this gap for domains and local binding by mutating the global for the spawn's lifetime. Sockets are the case where that workaround is not acceptable, which is your point exactly.
  3. There is no other per-spawn lever. WrapWithSandboxOptions is only commandId / commandText; initialize() is the sole writer of the global and is process-wide.

Why I am not shipping a workaround

Agreed on not lifting it into the global — and independently, it can't be made safe here. wrapWithSandbox is async, so a concurrent sandbox spawn wrapping inside that window bakes the socket grant into its profile. Making it safe would mean serialising every sandbox wrap in the app, which is a far bigger change than this feature justifies, for a race that would be silent when it went wrong.

So there is no correct app-side per-spawn grant against ASRT as published. The fix belongs upstream, and it is the two-line change that makes these match their neighbours:

function getAllowUnixSockets(customConfig) {
  return customConfig?.network?.allowUnixSockets ?? config?.network?.allowUnixSockets
}

…threaded at the call site, same for allowAllUnixSockets (and arguably allowLocalBinding / allowMachLookup, which would let acquireSandboxNetworkScope stop widening globally for the bridge too).

What I'd like you to decide

  1. Upstream and wait — file against @anthropic-ai/sandbox-runtime, keep this draft until a release lands. Cleanest; unknown timeline.
  2. pnpm patch as a stopgap — same change vendored locally, re-validated on every ASRT bump. Unblocks the feature now, but it means carrying a local patch on the component that is the containment boundary, and that is your call to make, not mine.

I'll hold here rather than pick. Marking the PR body so nobody reads the current description as "this works" — it does not, at runtime, today.

Two smaller things from your run, noted and unchanged: the 50 focused tests, both Settings cases, directory rejection and the signing copy are all confirmed resolved.


Generated by Claude Code

@jonathanKingston jonathanKingston changed the title feat(sandbox): opt-in ssh-agent socket access for sandboxed ACP agents feat(sandbox): opt-in ssh-agent socket access for sandboxed ACP agents [BLOCKED: inert at runtime] Sep 5, 2026
@jonathanKingston

Copy link
Copy Markdown
Collaborator Author

Status after the broader audit: do not merge this branch. It is 26 commits behind and its advertised runtime path remains inert. The narrower #2383 now carries the required per-spawn ASRT patch, limits the socket grant to Copse native git_commit rather than the whole ACP agent, resolves the public signing identity without exposing the private-key path, and has focused unit/Electron evidence.

Treat #2383 as the fix path and this PR as superseded; close this draft once #2383 is green/merged. No additional issue or duplicate investigation is needed because #2320 and #2383 already preserve the problem and resolution trail.

Copy link
Copy Markdown
Collaborator Author

Acknowledged — #2383 is the fix path.

Agreed on the boundary: scoping the socket to the native git_commit spawn is strictly narrower than the whole-agent grant this PR proposed — an ACP agent holding the socket can drive it for anything, a single commit subprocess much less so — and the pinned per-spawn patch is what makes the grant actually reach the seatbelt, which is exactly what this branch could not do.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

Closing as superseded — #2383's gate is green on 0263e667 (CI Passed success; all eight e2e shards, check, build, precheck, deploy, screenshot-artifacts green).

#2383 carries the working fix for #2320: the pinned per-spawn sandbox-runtime patch that makes the allowUnixSockets grant actually reach the seatbelt — the half this branch could not supply — scoped to Copse's native git_commit spawn rather than the whole ACP agent.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants