feat(sandbox): opt-in ssh-agent socket access for sandboxed ACP agents [BLOCKED: inert at runtime] - #2344
feat(sandbox): opt-in ssh-agent socket access for sandboxed ACP agents [BLOCKED: inert at runtime]#2344jonathanKingston wants to merge 3 commits into
Conversation
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
|
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
|
Pushed the first of your two gates in 830f655. The second is still open and still yours. Opt-in surface — doneThe 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:
The macOS-only line is in the UI deliberately, so the setting can't read as broken on Linux. Visual evidence
Still outstanding — yoursThe 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:
Leaving it as draft until you've run that. Generated by Claude Code |
🖥️ PR preview
|
|
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
|
Both fixed in 5d335fd. The first one was a real hole, not a hardening nit — thank you. (1) A non-socket
|
|
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. |
|
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
const wrapped = await wrapWithSandbox(command, binShell, customConfig, abortSignal, options)so allowUnixSockets: getAllowUnixSockets(), // function getAllowUnixSockets() { return config?.network?.allowUnixSockets }
Three things that change the options:
Why I am not shipping a workaroundAgreed on not lifting it into the global — and independently, it can't be made safe here. 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 What I'd like you to decide
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 |
|
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 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. |
|
Acknowledged — #2383 is the fix path.
Agreed on the boundary: scoping the socket to the native Generated by Claude Code |
|
Closing as superseded — #2383's gate is green on #2383 carries the working fix for #2320: the pinned per-spawn sandbox-runtime patch that makes the Generated by Claude Code |
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.ts→spawnSandboxedAcpSessionHostwithacpAgentSandboxOverlay), so the agent and everything it runs — includinggit commit— inherits the seatbelt profile, which deniesconnect()to the launchd ssh-agent socket.A passphrase-protected signing key is only usable through ssh-agent, so
ssh-keygen -Y signfalls 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_SOCKalready reaches the child —envForRendererChildProcessstrips only LLM/provider credentials — so it is purely sandbox policy denying the connect.Design
agentSshAgentSocketAccess(defaultfalse) admits exactly the one socketSSH_AUTH_SOCKnames, 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 keepssubpathandliteralequivalent. Empty, relative, non-normalised (/a/../bpins one string while the kernel resolves another), missing, or non-socket values grant nothing.allowAllUnixSocketsis 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 -cis 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
(subpath …)onnetwork-outboundsocket(AF_UNIX, …)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 checkgreen; 50 focused socket/config tests pass on macOS./, plus a resolver test against a real unix socket on disk.tests/e2e/ssh-agent-consent-settings.e2e.tsasserts the control is present and unchecked on a fresh profile and that the load-bearing phrases are on screen.🤖 Generated with Claude Code
https://claude.ai/code/session_01MMrECZ1PfJ58YeXTuaxybc