Skip to content

Seatbelt wrap for governed local CLIs: compiled write scopes, proxied egress (#803) - #1656

Merged
currentbits merged 4 commits into
coder/governance-v1-262from
coder/governance-macos-seatbelt-process-wrappe-f8ac1a
Oct 16, 2026
Merged

currentbits merged 4 commits into
coder/governance-v1-262from
coder/governance-macos-seatbelt-process-wrappe-f8ac1a

Conversation

@currentbits

Copy link
Copy Markdown
Owner

Closes #803. Stacked on #1655 (governance v1, #262). The base is coder/governance-v1-262; once #1655 merges, retarget this PR to main before 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:

    • the worktree and its git common dir, with hooks/ carved back out;
    • tmp and /dev;
    • CLI config homes (~/.claude*, ~/.codex, ~/.config, caches, …) and the per-thread CLI homes Solenta already passes (GROK_HOME, CODEX_HOME, KIMI_CODE_HOME, …);
    • write paths ruled allow/ask.

    Write paths ruled deny/block are then carved out again; SBPL's last match wins, which I checked against sandbox-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)_PROXY points 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:

    • an unresolvable path, a glob it can't express safely, a missing 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;
    • the proxy refuses on any decision error.
  • Audit (layer: seatbelt):

    • Every compiled deny rule is tagged (with message "solenta:<threadId>"). A single log stream follower attributes kernel denials, writes audit rows, and adds a transcript event (Seatbelt blocked bash file-write-create …).
    • Proxy denials are audited every time; proxy allows once per host.
    • When the tool layer recently allowed a path or host that Seatbelt then denies, the row is marked rule: disagreement with the reason matrix allowed (<layer>), seatbelt denied: ….
  • electron/sandbox.js stays honest: the badge says Seatbelt: writes confined to the worktree and N write rules, egress via Solenta's proxy … only when seatbeltApplies, the same predicate the runner uses, holds. The list cache keys on the profile policies.

Verified live (this Mac)

  • The real claude CLI (2.1.283) answered a prompt under a compiled profile with the network proxy on. The proxy saw api.anthropic.com, mcp-proxy.anthropic.com and a datadog intake host. A denied host got a 403 at CONNECT, and a direct non-proxy connection was blocked by the kernel.
  • A kernel denial showed up in log stream tagged with the thread id and was parsed by the follower.

Not covered

  • Reads aren't confined. Orchestrator-owned guardrails: protected config, spawn-time hook packs, memory-write scanning #409's secret-path rules still apply only at the tool layer.
  • Network rules are a denylist here. Deny-wins means * deny can't be combined with allowed hosts. Clients that ignore the proxy env are blocked by the kernel.
  • Live-tested CLIs: only Claude, plus a fake CLI through the real runner. codex/grok/kimi/cursor/opencode under a profile with write rules haven't been run live; nested Seatbelt (Codex's own sandbox) works.
  • Out of scope: Linux seccomp.

Tests

  • New:
    • seatbelt.test.js: compile and predicate, the badge, and real sandbox-exec enforcement 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 gets EPERM outside its worktree, and an ungoverned control does not.
  • CI-like env:
    • test:electron: 4851 pass, 0 fail.
    • Renderer: 3103 pass, 0 fail.
    • typecheck and vite build are clean.
  • The darwin cases run on macos-latest and skip on ubuntu/windows.

🤖 Generated with Claude Code

Willem and others added 2 commits October 16, 2026 10:57
#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>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-16T09:06:37.539927Z 94848ff PR opened
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread electron/seatbeltProxy.js
Comment on lines +37 to +38
const user = Buffer.from(m[1], "base64").toString("utf8").split(":")[0];
return user ? decodeURIComponent(user) : null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment thread electron/governance.js
* @returns {{ action: string, rule: string } | null}
*/
function networkVerdict(policy, host) {
const h = String(host || "").toLowerCase().replace(/^\[|\]$/g, "");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

@currentbits
currentbits merged commit f062740 into coder/governance-v1-262 Oct 16, 2026
6 checks passed
currentbits added a commit that referenced this pull request Oct 16, 2026
…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>
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.

1 participant