Repository navigation
Seatbelt wrap for governed local CLIs: compiled write scopes, proxied egress (#803) - #1656
Conversation
#803) On macOS, a local thread whose agent profile has write or network rules spawns its CLI (every provider) under sandbox-exec. Writes are denied outside the worktree, its git common dir (hooks excluded), tmp, /dev, CLI config homes and allow/ask write paths; deny/block write rules are carved back out. Network rules deny outbound IP except loopback and route HTTP(S)_PROXY through a host-filtering proxy that knows the thread from the proxy URL's userinfo. Every deny rule is tagged solenta:<threadId>, so a log-stream follower audits kernel denials as layer seatbelt and flags matrix-allow / Seatbelt-deny disagreements. Anything that cannot be canonicalized or compiled refuses the run instead of spawning unconfined. sandbox.js reports the wrap with the same predicate the runner uses. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#803) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
…atbelt-process-wrappe-f8ac1a
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 94848ff8b7
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const user = Buffer.from(m[1], "base64").toString("utf8").split(":")[0]; | ||
| return user ? decodeURIComponent(user) : null; |
There was a problem hiding this comment.
Use unforgeable proxy credentials
A governed CLI can forge this Basic identity because the credential is only a thread UUID. Seatbelt deliberately leaves reads unrestricted, so the child can read coder-store.json, obtain another thread ID, and send that ID in Proxy-Authorization; decide() then evaluates the target using the other thread's policy. If that thread has no network policy or a more permissive one, a host denied for the running thread is tunneled. Use a random per-run capability stored only in the proxy's in-memory mapping rather than treating a persisted thread ID as authentication.
Useful? React with 👍 / 👎.
| * @returns {{ action: string, rule: string } | null} | ||
| */ | ||
| function networkVerdict(policy, host) { | ||
| const h = String(host || "").toLowerCase().replace(/^\[|\]$/g, ""); |
There was a problem hiding this comment.
Canonicalize DNS hostnames before matching rules
For a request such as CONNECT evil.test.:443, the terminal dot remains in h, so an exact deny rule for evil.test does not match and the proxy's unmatched-host behavior allows the connection. DNS treats the dotted and undotted names as the same host, making this a direct bypass of network deny rules; normalize fully qualified names by removing terminal dots before applying globRe.
Useful? React with 👍 / 👎.
| } | ||
| } | ||
| const spawn = resolveSpawn(project, binary, args, localCwd, codexWrapEnv); | ||
| const spawn = resolveSpawn(project, binary, args, localCwd, codexWrapEnv, threadId); |
There was a problem hiding this comment.
Pass local CLI homes into the Seatbelt compiler
On a normal local Codex run, codexWrapEnv is unset while the child later receives codexMcpEnv, including the isolated CODEX_HOME created just above. Because only the former reaches compileSeatbelt, that home under the app's user-data directory is absent from the writable paths, so any macOS thread with a write or network rule blocks Codex's state/session writes and may fail to run. The Cursor path has the same mismatch at runner-provider-cursor.js:216 (cursorWrapEnv versus the later cursorEnv); pass the actual child environment to the confinement seam in both cases.
Useful? React with 👍 / 👎.
…atbelt-process-wrappe-f8ac1a
…e gate (#262) (#1655) * Governance v1 IPC contract: policy, agentProfileId, audit types (#262) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Governance v1 UI: profile rule editor, rule count, Advanced audit view (#262) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Governance v1 backend: matrix evaluator, HMAC audit, Claude gate (#262) Per-profile policy (tool/bash/write/network rules, deny-wins) on Claude's can_use_tool and re-checked on the card answer, so an edited command is judged as what runs. Write paths canonicalize via realpath and fail closed. Every governed decision lands in an HMAC-chained JSONL audit under userData. Governed threads keep the CLI asking instead of sending Claude session rules that would bypass the matrix. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Bind composer, Best of N and issue starts through threads.applyAgentProfile (#262) One write instead of setProvider/setReasoningEffort/setPermissionMode, so the thread carries agentProfileId and its policy applies. The picker marks the thread's profile row selected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Dev fixture: applyAgentProfile applies the saved profile; manual picks clear it (#262) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Permission card: show governance layer, hide Accept all on profile.ask (#262) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Governance UI copy: audit covers builtin decisions; modes that skip prompts (#262) Also re-attach setWebSearch's doc comment that applyAgentProfile split off. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Test applyAgentProfile snaps a mode the provider cannot honour (#262) The renderer dropped its own snap when it moved to applyAgentProfile. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Seatbelt wrap for governed local CLIs: compiled write scopes, proxied egress (#803) (#1656) * Seatbelt wrap for governed local CLIs: compiled writes, proxied egress (#803) On macOS, a local thread whose agent profile has write or network rules spawns its CLI (every provider) under sandbox-exec. Writes are denied outside the worktree, its git common dir (hooks excluded), tmp, /dev, CLI config homes and allow/ask write paths; deny/block write rules are carved back out. Network rules deny outbound IP except loopback and route HTTP(S)_PROXY through a host-filtering proxy that knows the thread from the proxy URL's userinfo. Every deny rule is tagged solenta:<threadId>, so a log-stream follower audits kernel denials as layer seatbelt and flags matrix-allow / Seatbelt-deny disagreements. Anything that cannot be canonicalized or compiled refuses the run instead of spawning unconfined. sandbox.js reports the wrap with the same predicate the runner uses. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Seatbelt tests: keep Stores in tmp so exit flushes cannot litter $HOME (#803) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Willem <willem@willems-mbp.home> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Willem <willem@willems-mbp.home> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Closes #803. Stacked on #1655 (governance v1, #262). The base is
coder/governance-v1-262; once #1655 merges, retarget this PR tomainbefore deleting that branch.What it does
On macOS, a local thread whose agent profile has any write or network rule runs its CLI under
sandbox-exec. This applies to every provider. Kimi, Grok--always-approve, Cursor--force, and Claude in acceptEdits/bypass mode never raise a permission prompt, so this is the first layer that confines them. ssh remotes and WSL are never wrapped.Writes: denied everywhere except:
hooks/carved back out;/dev;~/.claude*,~/.codex,~/.config, caches, …) and the per-thread CLI homes Solenta already passes (GROK_HOME,CODEX_HOME,KIMI_CODE_HOME, …);allow/ask.Write paths ruled
deny/blockare then carved out again; SBPL's last match wins, which I checked againstsandbox-exec. Relative globs anchor at the worktree, and absolute glob prefixes are realpath'd.Network: Seatbelt can't match hostnames, so outbound IP is denied except loopback and unix sockets.
HTTP(S)_PROXYpoints at a loopback proxy in main that applies the profile's network rules per host. The thread is identified by the proxy URL's userinfo, and anonymous connections are refused.Fails closed:
sandbox-exec, or a proxy that isn't listening yet all mean the run refuses to start; there is no fallback to an unconfined child;Audit (
layer: seatbelt):(with message "solenta:<threadId>"). A singlelog streamfollower attributes kernel denials, writes audit rows, and adds a transcript event (Seatbelt blocked bash file-write-create …).rule: disagreementwith the reasonmatrix allowed (<layer>), seatbelt denied: ….electron/sandbox.jsstays honest: the badge saysSeatbelt: writes confined to the worktree and N write rules, egress via Solenta's proxy …only whenseatbeltApplies, the same predicate the runner uses, holds. The list cache keys on the profile policies.Verified live (this Mac)
claudeCLI (2.1.283) answered a prompt under a compiled profile with the network proxy on. The proxy sawapi.anthropic.com,mcp-proxy.anthropic.comand a datadog intake host. A denied host got a 403 at CONNECT, and a direct non-proxy connection was blocked by the kernel.log streamtagged with the thread id and was parsed by the follower.Not covered
* denycan't be combined with allowed hosts. Clients that ignore the proxy env are blocked by the kernel.Tests
seatbelt.test.js: compile and predicate, the badge, and realsandbox-execenforcement on darwin.seatbelt-proxy.test.js: CONNECT allow, deny and no-identity cases; plain HTTP; surviving a client reset; the log-line parser.seatbelt-runner.test.js: audit rows, the disagreement flag, refusing on compile failure. On darwin, a fake CLI launched through the real runner getsEPERMoutside its worktree, and an ungoverned control does not.test:electron: 4851 pass, 0 fail.vite buildare clean.macos-latestand skip on ubuntu/windows.🤖 Generated with Claude Code