diff --git a/sandbox-runtime/.bashrc.example b/sandbox-runtime/.bashrc.example index a38d8ea..ea3ec82 100644 --- a/sandbox-runtime/.bashrc.example +++ b/sandbox-runtime/.bashrc.example @@ -48,18 +48,18 @@ _ccx_run() { mkdir -p "$td" chmod 700 "$td" - # The /**/*.pem deny glob blocks /etc/ssl/cert.pem, breaking every TLS - # stack that reads a CA bundle from disk (cargo, system curl, git-over- - # HTTPS, python/certifi). Stage the public root store at a .crt path the - # glob doesn't match. This copy runs OUTSIDE the sandbox; $td is readable - # inside. Public certs only — no weakening of the private-key globs. If - # network.tlsTerminate is ever enabled, srt's MITM CA must be appended - # to this bundle. - local cabundle="$td/ca-bundle.crt" - cp /etc/ssl/cert.pem "$cabundle" + # No CA-bundle staging: with network.tlsTerminate in the configs, srt + # builds its own trust bundle (MITM CA + host roots) at a path the + # /**/*.pem deny glob doesn't cover, and sets SSL_CERT_FILE / + # CURL_CA_BUNDLE / CARGO_HTTP_CAINFO / GIT_SSL_CAINFO / + # REQUESTS_CA_BUNDLE itself. Don't export those here — wrapper exports + # would shadow srt's and break TLS inside. # gh stores its token in the macOS keychain, which the sandbox denies. Pull # it out here so gh inside the sandbox can authenticate via $GH_TOKEN. + # The configs mask it (credentials.envVars): the sandbox only ever sees a + # fake token; the TLS-terminating proxy swaps in this real one on egress + # to github.com hosts only. local gh_token="" if command -v gh >/dev/null 2>&1; then gh_token=$(gh auth token 2>/dev/null) @@ -81,9 +81,40 @@ _ccx_run() { fi fi - # SSL_CERT_FILE/CURL_CA_BUNDLE cover curl, python, openssl-cli, etc. - # CARGO_HTTP_CAINFO is required separately — cargo's statically-linked - # libcurl ignores SSL_CERT_FILE. GIT_SSL_CAINFO likewise for git. + # Persistent MITM CA: srt mints per-host leaf certs from this CA instead + # of an ephemeral per-session one, so it can be trusted ONCE in the login + # keychain (security add-trusted-cert, see README) — required for tools + # that verify via trustd (gh and other Go binaries) rather than a PEM + # bundle. Generated on first launch; the key never leaves this machine + # and stays sandbox-unreadable (mode 600 + the /**/*.key deny glob). + local cadir="$HOME/.config/srt" + if [[ ! -f "$cadir/mitm-ca.crt" || ! -f "$cadir/mitm-ca.key" ]]; then + mkdir -p "$cadir" + openssl req -x509 -newkey rsa:2048 -sha256 -days 3650 -nodes \ + -keyout "$cadir/mitm-ca.key" -out "$cadir/mitm-ca.crt" \ + -subj "/CN=srt MITM CA ($USER)" \ + -addext "basicConstraints=critical,CA:TRUE" \ + -addext "keyUsage=critical,keyCertSign,cRLSign" 2>/dev/null + chmod 600 "$cadir/mitm-ca.key" + fi + + # Per-launch config copy, for the two settings that can't be static JSON: + # - SSH agent forwarding: SSH inside authenticates through the agent's + # signing socket while raw keys stay unreadable (/**/id_* globs). + # Seatbelt gates Unix sockets via network.allowUnixSockets, and the + # launchd socket path (/var/run/com.apple.launchd.*/Listeners) is + # random per login session. srt realpaths it (/var → /private/var). + # Trade-off: sandboxed code can authenticate as you while the session + # runs — mitigate with ssh-add -c or hardware-backed keys. + # - The CA paths above (tlsTerminate does no tilde expansion, so the + # $HOME-absolute paths can't live in the shared template). + local cfg="$td/settings.json" + jq --arg sock "$SSH_AUTH_SOCK" --arg cadir "$cadir" ' + (if $sock != "" then .network.allowUnixSockets = [$sock] else . end) + | .network.tlsTerminate.caCertPath = ($cadir + "/mitm-ca.crt") + | .network.tlsTerminate.caKeyPath = ($cadir + "/mitm-ca.key") + ' "$settings" > "$cfg" + # pnpm_config_store_dir pins pnpm's global store: its store-selection # heuristic misfires inside the sandbox and silently falls back to a # per-project .pnpm-store/ even when the global store is writable @@ -92,13 +123,9 @@ _ccx_run() { DISABLE_FEEDBACK_COMMAND=1 \ CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY=1 \ GH_TOKEN="$gh_token" \ - SSL_CERT_FILE="$cabundle" \ - CURL_CA_BUNDLE="$cabundle" \ - CARGO_HTTP_CAINFO="$cabundle" \ - GIT_SSL_CAINFO="$cabundle" \ - REQUESTS_CA_BUNDLE="$cabundle" \ + SSH_AUTH_SOCK="$SSH_AUTH_SOCK" \ pnpm_config_store_dir="$HOME/Library/pnpm/store" \ - srt --settings "$settings" -- claude "$@" + srt --settings "$cfg" -- claude "$@" } ccx() { _ccx_run ~/.srt-claude-denyall.json "$@"; } diff --git a/sandbox-runtime/.srt-claude-allowall.json b/sandbox-runtime/.srt-claude-allowall.json index 035d25a..8a1b876 100644 --- a/sandbox-runtime/.srt-claude-allowall.json +++ b/sandbox-runtime/.srt-claude-allowall.json @@ -2,10 +2,29 @@ "network": { "allowedDomains": [], "deniedDomains": [ - "gist.github.com" + "gist.github.com", + "*:25", + "*:587", + "*:465" ], + "deniedDomainReasons": { + "gist.github.com": "Creating gists is blocked by sandbox policy (exfiltration risk). Write to a file in the repo instead.", + "*:25": "Direct SMTP is blocked by sandbox policy.", + "*:587": "Direct SMTP is blocked by sandbox policy.", + "*:465": "Direct SMTP is blocked by sandbox policy." + }, "allowAllDomains": true, - "allowLocalBinding": false + "allowLocalBinding": false, + "tlsTerminate": {} + }, + "credentials": { + "envVars": [ + { + "name": "GH_TOKEN", + "mode": "mask", + "injectHosts": ["github.com", "*.github.com"] + } + ] }, "filesystem": { "denyRead": [ @@ -50,7 +69,7 @@ "~/.cisco", "~/.vpn", - "~/OneDrive - UBC/", + "~/OneDrive - UBC", "~/Documents", "~/Desktop", "~/Downloads", @@ -84,6 +103,14 @@ "~/.claude/CLAUDE.md" ] }, + "ignoreViolations": { + "*": [ + "sysctl-read kern.", + "mach-lookup com.apple.SystemConfiguration.configd", + ".GlobalPreferences", + "Library/Preferences/ByHost" + ] + }, "enableWeakerNetworkIsolation": true, "allowPty": true } diff --git a/sandbox-runtime/.srt-claude-denyall.json b/sandbox-runtime/.srt-claude-denyall.json index cd2754f..82339f6 100644 --- a/sandbox-runtime/.srt-claude-denyall.json +++ b/sandbox-runtime/.srt-claude-denyall.json @@ -2,10 +2,29 @@ "network": { "allowedDomains": [], "deniedDomains": [ - "gist.github.com" + "gist.github.com", + "*:25", + "*:587", + "*:465" ], + "deniedDomainReasons": { + "gist.github.com": "Creating gists is blocked by sandbox policy (exfiltration risk). Write to a file in the repo instead.", + "*:25": "Direct SMTP is blocked by sandbox policy.", + "*:587": "Direct SMTP is blocked by sandbox policy.", + "*:465": "Direct SMTP is blocked by sandbox policy." + }, "allowAllDomains": true, - "allowLocalBinding": false + "allowLocalBinding": true, + "tlsTerminate": {} + }, + "credentials": { + "envVars": [ + { + "name": "GH_TOKEN", + "mode": "mask", + "injectHosts": ["github.com", "*.github.com"] + } + ] }, "filesystem": { "denyRead": [ @@ -74,6 +93,14 @@ "~/.claude/CLAUDE.md" ] }, + "ignoreViolations": { + "*": [ + "sysctl-read kern.", + "mach-lookup com.apple.SystemConfiguration.configd", + ".GlobalPreferences", + "Library/Preferences/ByHost" + ] + }, "enableWeakerNetworkIsolation": true, "allowPty": true } diff --git a/sandbox-runtime/.zshrc.example b/sandbox-runtime/.zshrc.example index fe43aff..fdafabf 100644 --- a/sandbox-runtime/.zshrc.example +++ b/sandbox-runtime/.zshrc.example @@ -44,18 +44,18 @@ function _ccx_run { mkdir -p "$td" chmod 700 "$td" - # The /**/*.pem deny glob blocks /etc/ssl/cert.pem, breaking every TLS - # stack that reads a CA bundle from disk (cargo, system curl, git-over- - # HTTPS, python/certifi). Stage the public root store at a .crt path the - # glob doesn't match. This copy runs OUTSIDE the sandbox; $td is readable - # inside. Public certs only — no weakening of the private-key globs. If - # network.tlsTerminate is ever enabled, srt's MITM CA must be appended - # to this bundle. - local cabundle="$td/ca-bundle.crt" - cp /etc/ssl/cert.pem "$cabundle" + # No CA-bundle staging: with network.tlsTerminate in the configs, srt + # builds its own trust bundle (MITM CA + host roots) at a path the + # /**/*.pem deny glob doesn't cover, and sets SSL_CERT_FILE / + # CURL_CA_BUNDLE / CARGO_HTTP_CAINFO / GIT_SSL_CAINFO / + # REQUESTS_CA_BUNDLE itself. Don't export those here — wrapper exports + # would shadow srt's and break TLS inside. # gh stores its token in the macOS keychain, which the sandbox denies. Pull # it out here so gh inside the sandbox can authenticate via $GH_TOKEN. + # The configs mask it (credentials.envVars): the sandbox only ever sees a + # fake token; the TLS-terminating proxy swaps in this real one on egress + # to github.com hosts only. local gh_token="" if command -v gh >/dev/null 2>&1; then gh_token=$(gh auth token 2>/dev/null) @@ -77,9 +77,40 @@ function _ccx_run { fi fi - # SSL_CERT_FILE/CURL_CA_BUNDLE cover curl, python, openssl-cli, etc. - # CARGO_HTTP_CAINFO is required separately — cargo's statically-linked - # libcurl ignores SSL_CERT_FILE. GIT_SSL_CAINFO likewise for git. + # Persistent MITM CA: srt mints per-host leaf certs from this CA instead + # of an ephemeral per-session one, so it can be trusted ONCE in the login + # keychain (security add-trusted-cert, see README) — required for tools + # that verify via trustd (gh and other Go binaries) rather than a PEM + # bundle. Generated on first launch; the key never leaves this machine + # and stays sandbox-unreadable (mode 600 + the /**/*.key deny glob). + local cadir="$HOME/.config/srt" + if [[ ! -f "$cadir/mitm-ca.crt" || ! -f "$cadir/mitm-ca.key" ]]; then + mkdir -p "$cadir" + openssl req -x509 -newkey rsa:2048 -sha256 -days 3650 -nodes \ + -keyout "$cadir/mitm-ca.key" -out "$cadir/mitm-ca.crt" \ + -subj "/CN=srt MITM CA ($USER)" \ + -addext "basicConstraints=critical,CA:TRUE" \ + -addext "keyUsage=critical,keyCertSign,cRLSign" 2>/dev/null + chmod 600 "$cadir/mitm-ca.key" + fi + + # Per-launch config copy, for the two settings that can't be static JSON: + # - SSH agent forwarding: SSH inside authenticates through the agent's + # signing socket while raw keys stay unreadable (/**/id_* globs). + # Seatbelt gates Unix sockets via network.allowUnixSockets, and the + # launchd socket path (/var/run/com.apple.launchd.*/Listeners) is + # random per login session. srt realpaths it (/var → /private/var). + # Trade-off: sandboxed code can authenticate as you while the session + # runs — mitigate with ssh-add -c or hardware-backed keys. + # - The CA paths above (tlsTerminate does no tilde expansion, so the + # $HOME-absolute paths can't live in the shared template). + local cfg="$td/settings.json" + jq --arg sock "$SSH_AUTH_SOCK" --arg cadir "$cadir" ' + (if $sock != "" then .network.allowUnixSockets = [$sock] else . end) + | .network.tlsTerminate.caCertPath = ($cadir + "/mitm-ca.crt") + | .network.tlsTerminate.caKeyPath = ($cadir + "/mitm-ca.key") + ' "$settings" > "$cfg" + # pnpm_config_store_dir pins pnpm's global store: its store-selection # heuristic misfires inside the sandbox and silently falls back to a # per-project .pnpm-store/ even when the global store is writable @@ -88,13 +119,9 @@ function _ccx_run { DISABLE_FEEDBACK_COMMAND=1 \ CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY=1 \ GH_TOKEN="$gh_token" \ - SSL_CERT_FILE="$cabundle" \ - CURL_CA_BUNDLE="$cabundle" \ - CARGO_HTTP_CAINFO="$cabundle" \ - GIT_SSL_CAINFO="$cabundle" \ - REQUESTS_CA_BUNDLE="$cabundle" \ + SSH_AUTH_SOCK="$SSH_AUTH_SOCK" \ pnpm_config_store_dir="$HOME/Library/pnpm/store" \ - srt --settings "$settings" -- claude "$@" + srt --settings "$cfg" -- claude "$@" } function ccx { _ccx_run ~/.srt-claude-denyall.json "$@" } diff --git a/sandbox-runtime/DETAILS.md b/sandbox-runtime/DETAILS.md index 0949e56..69efbc8 100644 --- a/sandbox-runtime/DETAILS.md +++ b/sandbox-runtime/DETAILS.md @@ -28,13 +28,17 @@ needed and is additive — existing configs are unaffected. ## The fork -Upstream `srt` has two limitations these configs need to work around: +The fork carries five deltas over upstream `srt`. The first two work +around upstream limitations: 1. **No "allow all egress" mode.** The schema rejects `"*"` in `allowedDomains`, and there is no flag to disable network filtering from a config file. Our fork adds an `allowAllDomains: true` schema field that short-circuits the - allowlist **after** `deniedDomains` is checked. + allowlist **after** `deniedDomains` is checked. (As of + `0.0.70-ltic.3` the flag is also honored by the config validator's + `injectHosts`-reachability cross-check, so credential masking with + `allowedDomains: []` loads as written.) 2. **A *glob* `denyRead` inside an `allowRead` region is ignored.** Upstream [PR #311](https://github.com/anthropic-experimental/sandbox-runtime/pull/311) fixed the *literal* case — a literal path in `denyRead` nested under @@ -48,6 +52,28 @@ Upstream `srt` has two limitations these configs need to work around: rules so they win — letting credential globs like `/**/.env*` actually take effect inside an allowed directory. +The third delta (in `src/cli.ts`, no upstream PR) makes denials +*observable*: the CLI enables the seatbelt log monitor (upstream only +collects proxy denials in CLI mode) and streams every violation line to +`~/.local/state/srt/violations-.log`, exporting the path to the +sandboxed child as `$SRT_VIOLATIONS_FILE`. See [the violations +pipeline](#the-violations-pipeline). + +The fourth delta (in `src/utils/config-loader.ts`, no upstream PR) +makes misconfiguration *visible*: upstream's zod schema silently strips +any settings key it doesn't recognize — a typo'd `allowAllDomainz` +loads with zero feedback and runs as a deny-all sandbox. The fork +prints a stderr warning at launch naming every unrecognized key with +its full path (for both `--settings` files and control-fd config +updates). It warns rather than errors, so nothing existing breaks. + +The fifth delta (in `src/sandbox/sandbox-utils.ts`, `0.0.70-ltic.4`, +upstream [PR #452](https://github.com/anthropic-experimental/sandbox-runtime/pull/452)) +makes sandboxed git-over-SSH work on macOS: +when socat is on PATH, `GIT_SSH_COMMAND` uses the auth-capable socat +HTTP CONNECT spelling instead of BSD `nc`, which cannot authenticate +to the session proxy ([details](#ssh)). + The `-ltic.N` version suffix is the quick check that you're on the patched fork and not upstream `srt`: the `ltic-main` branch stamps a distinctive version and reads it from `package.json` at runtime, so @@ -56,14 +82,68 @@ reports a plain version with no `-ltic` suffix. The README's functional check exists because upstream stock `srt` *silently drops* the unknown `allowAllDomains` key during schema -validation — a config that looks accepted can still be deny-all. Two -traps in simpler versions of that check: `curl -sI … | head -1` prints +validation — a config that looks accepted can still be deny-all. (The +fork itself no longer fails this way: delta 4 warns at launch about +any unrecognized key.) One trap in simpler versions of that check: `curl -sI … | head -1` prints the proxy's `HTTP/1.1 200 Connection Established` CONNECT response even -when the request then *fails*, and without `--cacert` curl inside the -sandbox can't read its default CA bundle (`/etc/ssl/cert.pem` is caught -by the `/**/*.pem` deny glob — see [TLS inside the -sandbox](#tls-inside-the-sandbox)) and reports error 77 whether or not -the domain was allowed. The staged `.crt` copy sidesteps both. +when the request then *fails*, so check the real HTTP status instead. +(Historically the check also had to stage a `.crt` CA bundle — curl's +default `/etc/ssl/cert.pem` is caught by the `/**/*.pem` deny glob and +fails with error 77 whether or not the domain was allowed. With +`tlsTerminate` enabled, srt points curl at its own injected bundle, so +that trap is gone — see [TLS inside the +sandbox](#tls-inside-the-sandbox).) + +## The violations pipeline + +Without it, the agent inside the sandbox sees only a generic `403` or +`EPERM` on a policy denial and tends to misread it as a broken tool or +network — a false positive it may burn time retrying. The pipeline +lands each denial, with its configured reason, in the agent's context +right after the failed command: + +1. **srt side (fork delta 3).** Every violation — filesystem (seatbelt + log monitor) and network (proxy denial) — is appended, timestamped + and one per line, to `~/.local/state/srt/violations-.log`. + The path is exported as `$SRT_VIOLATIONS_FILE` to the sandboxed + child, so concurrent sessions are isolated for free; logs older + than 7 days are pruned at startup. +2. **Claude Code side (`srt-violations.mjs`).** A PostToolUse(Bash) + hook reads the file from the offset it last reached (state in + `~/.cache/srt-hook/`) and, if new lines exist, injects them as + `additionalContext` wrapped in a `` block with + a preamble telling the model these are policy blocks, not errors. + When `$SRT_VIOLATIONS_FILE` is unset (any non-sandbox session) the + hook exits immediately. +3. **Config side.** `network.deniedDomainReasons` — keyed by the + **exact** `deniedDomains` entry string — replaces the generic deny + text on the violation line; add a reason for every domain you deny, + and always name the sanctioned alternative (e.g. "write to a file + in the repo instead"). `ignoreViolations` maps command patterns + (`"*"` = all) to substrings of violation lines to suppress; the + four entries in these configs cover benign macOS noise every + process emits (sysctl `kern.*`, configd Mach lookups, preferences + plists). Extend it if recurring non-actionable noise shows up. + +**Security invariant — do not break this.** Violation lines flow into +the agent's context, so `~/.local/state/srt` must be **readable but +never writable** from inside the sandbox — if sandboxed code could +write that file, it would be a prompt-injection channel. Both configs +satisfy this today (denyall: `~/.local` is in `allowRead` while +`allowWrite` grants only `~/.local/state/claude` and +`~/.local/share/uv`; allowall: the path is in neither `denyRead` nor +`allowWrite`). Never add `~/.local/state/srt` — or a parent like +`~/.local` — to `allowWrite`. + +**Known limitations.** Proxy (network) denials appear in the file +instantly; filesystem (seatbelt) denials arrive with ~1–2 s of `log +stream` latency, so a session that exits immediately after a denial +can miss the trailing line (interactive sessions pick it up on the +next Bash command). Attribution is session-global — lines are +timestamped, not tied to a specific command — so correlate by recency. +Violation-line URLs are redacted by srt (query strings collapse to +`?…`, userinfo dropped) so runtime-interpolated secrets can't leak +into the transcript. ## Credential plumbing @@ -123,41 +203,72 @@ Claude out of its own credentials. ## TLS inside the sandbox -**CA-bundle staging.** Every TLS stack that reads its trust roots from a -PEM file on disk — cargo's statically-linked libcurl, the system +**The problem.** Every TLS stack that reads its trust roots from a PEM +file on disk — cargo's statically-linked libcurl, the system `/usr/bin/curl` (LibreSSL backend), Homebrew git's libcurl, python's certifi — defaults to `/etc/ssl/cert.pem` or similar, and every `.pem` file is unreadable inside the sandbox (the `/**/*.pem` deny glob). The symptom is curl error 77: `error setting certificate verify locations`. -`_ccx_run` copies `/etc/ssl/cert.pem` to `$td/ca-bundle.crt` *outside* -the sandbox (the `.crt` name escapes the glob) and points the usual env -vars at it: `SSL_CERT_FILE`, `CURL_CA_BUNDLE`, `CARGO_HTTP_CAINFO` -(cargo ignores the generic vars), `GIT_SSL_CAINFO` (so does git), and -`REQUESTS_CA_BUNDLE`. This stages *public* root certs only — the -private-key globs are untouched. - -**Passthrough, not MITM.** The srt proxy here is a passthrough CONNECT -tunnel — the real upstream certificate (e.g. crates.io's GlobalSign -cert) is presented to the client, so the public root store is -sufficient. If you ever enable `network.tlsTerminate`, append srt's MITM -CA to the staged bundle. - -**Tools that never needed any of this** verify through the macOS trust -store (trustd) instead of a PEM file: `gh` and other Go binaries, -SecureTransport apps, cargo's libgit2 transport, uv's rustls. They work -because of `enableWeakerNetworkIsolation: true` — still required as of -`srt 0.0.62-ltic.1`: with it set to `false`, `gh api` fails the TLS -handshake (`x509: OSStatus -26276`). The newer TLS-proxy hardening -(SKI/AKI leaf extensions, gcloud/Nix CA env injection) did **not** -remove the need for this on macOS. - -**Escape hatch for mTLS / cert-pinned hosts.** If a specific host breaks -because it pins certs or requires mutual TLS, upstream `srt` supports -`network.tlsTerminate.excludeDomains` — a per-host list that skips MITM -termination for those domains (added in + +**Solved natively by `tlsTerminate`.** With `network.tlsTerminate` +enabled (both configs), srt's proxy terminates TLS (MITM): it re-signs +upstream certificates with its own per-session CA, builds a trust +bundle — MITM CA + host roots + any +`network.tlsTerminate.extraCaCertPaths` — at a temp path the `.pem` +glob doesn't cover, and sets `SSL_CERT_FILE`, `CURL_CA_BUNDLE`, +`CARGO_HTTP_CAINFO` (cargo ignores the generic vars), `GIT_SSL_CAINFO` +(so does git), and `REQUESTS_CA_BUNDLE` for the sandboxed process +itself. Nothing to stage, nothing for the wrapper to export — in fact +the wrapper must *not* set those vars, or its values would shadow +srt's and break TLS inside. `extraCaCertPaths` is also the supported +way to trust site-local/internal CAs if that ever comes up. + +*Historical note:* before 0.70, the proxy here ran as a passthrough +CONNECT tunnel and `_ccx_run` staged `/etc/ssl/cert.pem` at a `.crt` +path with the five env vars exported by hand. `tlsTerminate` retired +that hack. + +**Tools that verify via trustd need the CA trusted in the keychain.** +`gh` and other Go binaries, SecureTransport apps, and cargo's libgit2 +transport don't read PEM bundles or the env vars — they ask the macOS +trust store, which knows nothing about srt's MITM CA, so under +`tlsTerminate` they fail with `x509: certificate signed by unknown +authority`. Two pieces fix this (README setup step 4): + +- srt's default MITM CA is *ephemeral per session* — untrustable in + practice. `_ccx_run` therefore generates a **persistent** CA once + (`~/.config/srt/mitm-ca.{crt,key}`, RSA-2048, key mode 600) and + injects its paths via `tlsTerminate.{caCertPath,caKeyPath}` in the + per-launch config copy (those fields do no tilde expansion, hence + the wrapper rather than the template). +- A one-time `security add-trusted-cert -p ssl` marks that CA + SSL-trusted for your user, after which trustd accepts the + proxy-minted leaves. + +Key-custody note: whoever holds `mitm-ca.key` can MITM TLS *for apps +that trust it*. The key is sandbox-unreadable (the `/**/*.key` deny +glob plus, in denyall, `~/.config/srt` not being in `allowRead`), so +only host processes running as you could use it — and those could +install their own CA anyway. This mirrors srt's own Windows +persistent-CA design. + +`enableWeakerNetworkIsolation: true` is still required as of +`srt 0.0.70-ltic.4` — it's what lets sandboxed processes reach trustd +at all: with it set to `false`, `gh api` fails the TLS handshake with +`x509: OSStatus -26276` regardless of keychain trust. It is also +load-bearing for `GH_TOKEN` masking, which happens inside the +terminated TLS stream. + +**Escape hatch for mTLS / cert-pinned hosts.** A host that pins certs +or requires mutual TLS breaks under MITM termination. Upstream `srt` +supports `network.tlsTerminate.excludeDomains` — a per-host list that +skips termination for those domains (added in [#344](https://github.com/anthropic-experimental/sandbox-runtime/pull/344)). -Prefer adding the offending host there over weakening isolation -globally. The configs here don't set it (no known offender). +Prefer adding the offending host there over turning `tlsTerminate` off +globally (which would resurrect the CA-staging problem and disable +token masking). The configs here don't set it (no known offender); +note that excluded hosts also lose token injection, since substitution +happens inside the terminated stream. **In-sandbox fallback for building a bundle:** the system roots keychain is readable inside the sandbox, so a bundle can be rebuilt from within @@ -181,8 +292,8 @@ test fixtures, vendored CA bundles, public certs, certs under private key is a direct exfil path, and most private keys *do* use these extensions. The breadth is the cost of that coverage. The biggest casualty — system CA bundles, which would break cargo/curl/git/python -TLS — is handled automatically by [CA-bundle -staging](#tls-inside-the-sandbox). For some *other* non-secret +TLS — is handled automatically by [srt's injected trust +bundle](#tls-inside-the-sandbox). For some *other* non-secret `.pem`/`.key` a build needs: copy it to a path that doesn't match the glob (rename to `.crt`/`.cert`, or stage it as `cert.pem.txt`) and point the tool there; or drop the two globs from `denyReadAlways` (denyall) / @@ -204,9 +315,11 @@ list does not cover them. Two things had to line up for cargo to work inside the sandbox: -1. **The staged CA bundle.** Cargo's statically-linked libcurl defaults - to the blocked `/etc/ssl/cert.pem` and ignores `SSL_CERT_FILE`, hence - the dedicated `CARGO_HTTP_CAINFO` env var. +1. **A readable CA bundle.** Cargo's statically-linked libcurl defaults + to the blocked `/etc/ssl/cert.pem` and ignores `SSL_CERT_FILE`, so it + needs the dedicated `CARGO_HTTP_CAINFO` env var — which srt sets + itself under `tlsTerminate`, pointing at its injected bundle + ([details](#tls-inside-the-sandbox)). 2. **Write access to its caches:** `~/.cargo/registry`, `~/.cargo/git`, and the `~/.cargo/.package-cache` lock file, which both configs grant. @@ -279,29 +392,93 @@ git config --add credential.helper '!gh auth git-credential' `denyReadAlways` includes `/**/id_*`, which covers every private key file (e.g. `~/.ssh/id_ed25519-github`). SSH needs to read the raw key -bytes for the initial handshake, so any SSH-based operation — git over -SSH, interactive `ssh`, `sftp` — fails with EPERM. There is no `!` -escape from inside the sandbox. Options: - -- **Option A — HTTPS + GH_TOKEN (GitHub only).** For `git push`/`git - pull`, switch to HTTPS and use `gh auth git-credential` as the - credential helper (recipe in the README gotchas). Simpler and scopes - the credential to GitHub HTTPS only. - -- **Option B — SSH agent forwarding (any SSH target).** The SSH agent - holds keys in memory and exposes only a sign-once socket — clients - authenticate through it without reading raw key bytes. To enable: in - `_ccx_run`, capture `$SSH_AUTH_SOCK` *outside* the sandbox, add - `allowRead` for that socket path (on macOS it's something like - `/private/tmp/com.apple.launchd.*/Listeners`), and pass the env var - in. Trade-off: Claude can authenticate as you to any SSH service - reachable over the network, but cannot exfiltrate the key material - itself. - -- **Option C — remove `/**/id_*` from `denyReadAlways`.** Gives Claude - read access to raw private key bytes. This allows key exfiltration - over the network and is not recommended unless the threat model - explicitly accepts it. +bytes for the initial handshake, so key-file auth is impossible inside +the sandbox. Instead, **SSH works through agent forwarding** — the +implemented default: + +The SSH agent holds keys in memory and exposes only a signing socket — +clients authenticate through it without ever reading raw key bytes. +Two pieces make it reachable: + +1. **The env var.** `_ccx_run` passes `$SSH_AUTH_SOCK` through to the + sandboxed process. +2. **A seatbelt allowance for the socket.** Unix-socket connects are + gated by seatbelt *network* rules, not file rules — srt exposes + this as `network.allowUnixSockets` (macOS-only; it emits + `system-socket` + `network-bind`/`network-outbound` rules for the + listed paths). The launchd agent socket lives at + `/var/run/com.apple.launchd./Listeners`, a path that + changes every login session, so it can't be hardcoded in the JSON: + `_ccx_run` injects the live `$SSH_AUTH_SOCK` value into a + per-launch copy of the config (`jq … > "$td/settings.json"`) and + launches srt with that. srt realpaths the entry, so the + `/var → /private/var` symlink is handled. + +Keys must be loaded (`ssh-add`) before launching. + +**`IdentitiesOnly yes` blocks the agent path — add a sandbox-scoped +override.** With `IdentitiesOnly yes` (common in per-host multi-key +setups), ssh offers *only* the listed `IdentityFile` identities — and +inside the sandbox it can read neither the private key nor its `.pub` +sibling (both match the `/**/id_*` globs), so it offers nothing and +auth fails with `Permission denied (publickey)` even though the agent +holds the key. ssh config is first-obtained-value-wins, so a `Match` +block at the **top** of `~/.ssh/config`, keyed on the sandbox-only +`SRT_VIOLATIONS_FILE` env var, relaxes it for sandbox sessions only: + +``` +Match exec "test -n \"$SRT_VIOLATIONS_FILE\"" + IdentitiesOnly no +``` + +Outside the sandbox the variable is unset, the block doesn't match, +and the per-host `IdentitiesOnly yes` behaves exactly as before. + +**The transport: socat through the proxy (requires +`brew install socat`).** Authentication is only half of SSH; the TCP +connection itself must traverse the srt proxy (direct DNS/egress is +blocked — bare `ssh` fails with "Could not resolve hostname"). srt +handles this by setting `GIT_SSH_COMMAND` with a `ProxyCommand`, and +as of `0.0.70-ltic.4` (fork delta 5) the macOS spelling is +`socat - PROXY:localhost:%h:%p,proxyport=,proxyauth=:` — +an HTTP CONNECT tunnel that authenticates to the proxy, same as Linux +has always used. (The previous BSD `nc -X 5` spelling could not speak +SOCKS5 auth, so git-over-SSH always died at the handshake against the +auth-required session proxy; `nc` remains the fallback when socat is +missing, with the old failure mode.) The proxy sniffs for a TLS +ClientHello before MITM-ing, so SSH bytes pass through as an opaque +tunnel: SSH crypto stays end-to-end, the proxy sees only `host:port`, +and host-level `deniedDomains` still apply (the portless +`gist.github.com` entry covers gist-over-SSH). + +For plain `ssh`/`sftp` (outside git), srt only wires the ProxyCommand +into `GIT_SSH_COMMAND` — reuse it from inside the sandbox: +`eval "$GIT_SSH_COMMAND git@github.com"` (the value contains a +single-quoted ProxyCommand, so `eval` it rather than word-splitting). + +**Trade-off:** while a session runs, sandboxed code can authenticate +as you to anything the agent holds keys for — but it cannot +exfiltrate the key material itself (the `/**/id_*` globs still deny +every key file). Mitigations: load keys with `ssh-add -c` so the +agent prompts for confirmation on every signature, or use +hardware-backed keys that require a touch. Note also that SSH traffic +bypasses the srt proxy's visibility and token masking — host-level +`deniedDomains` is the only network lever on that channel (the +`gist.github.com` deny entry is portless, so gist-over-SSH stays +blocked). + +Alternatives: + +- **HTTPS + credential helpers (no agent needed).** For `git + push`/`git pull`, use HTTPS remotes with `gh auth git-credential` / + `glab auth git-credential` (recipe in the README gotchas). Scopes + the credential to that host's HTTPS endpoint, and GitHub traffic + stays visible to the proxy. + +- **Removing `/**/id_*` from `denyReadAlways` — not recommended.** + Gives Claude read access to raw private key bytes, which allows key + exfiltration over the network. Only if the threat model explicitly + accepts it. ## Clipboard and OSC 52 @@ -350,13 +527,35 @@ lands on the clipboard if the **terminal** honors the OSC 52 sequence: ## gh and glab -**`gh` token injection.** `gh` stores its token in the Keychain. +**`gh` token, masked.** `gh` stores its token in the Keychain. `_ccx_run` extracts it via `gh auth token` outside the sandbox and -passes it as `$GH_TOKEN`. This makes read-only `gh` operations work -inside the sandbox (PR lists, issue comments, API calls). Re-auth -flows — `gh auth login`, `gh auth refresh`, `gh auth logout` — still -need to be run *outside* the sandbox, since write access to -`~/.config/gh` is intentionally not granted. +passes it as `$GH_TOKEN` — but the sandbox never sees the real value. +The `credentials.envVars` block masks it: the sandboxed process gets a +structurally valid fake token, and the TLS-terminating proxy swaps the +real one in — in headers *and* request bodies — only on egress to +`github.com` / `*.github.com`. A process that exfiltrates `$GH_TOKEN` +to any other host leaks a worthless sentinel. Caveats: + +- **`injectHosts` is mandatory in these configs.** Its default is + `network.allowedDomains`, which is `[]` here (the fork's + `allowAllDomains` does the allowing) — so omitting it would mask the + token but inject the real one *nowhere*. As of `0.0.70-ltic.3` that + spelling is a load-time error telling you to list the inject hosts + (before, `gh` just silently failed auth). +- **Requires `network.tlsTerminate`** (substitution happens inside the + terminated stream) and `enableWeakerNetworkIsolation: true` (`gh` is + a Go binary and needs trustd on macOS). +- **The `credentials.*` sub-schema is `.strict()`** — a typo'd key + under it is a hard config-load error. (Keys elsewhere are stripped + rather than rejected, but the fork warns about them at launch — + fork delta 4.) +- Tools that bypass the proxy send the fake token and fail GitHub + auth — a functionality caveat, not a leak. + +`gh` operations work inside the sandbox (PR lists, issue comments, API +calls). Re-auth flows — `gh auth login`, `gh auth refresh`, `gh auth +logout` — still need to be run *outside* the sandbox, since write +access to `~/.config/gh` is intentionally not granted. **`glab` (GitLab CLI).** Unlike `gh`, `glab` stores its token in a config file (`~/Library/Application Support/glab-cli`) rather than the @@ -385,7 +584,9 @@ there with a few tweaks to the settings files: - **`/private/tmp` → `/tmp` in `allowWrite`** — Linux has no symlink redirect, so the macOS-only workaround isn't needed. - **Drop `enableWeakerNetworkIsolation` and `allowMachLookup`** — both - are macOS-only and ignored on Linux. + are macOS-only and ignored on Linux. `network.allowUnixSockets` is + also macOS-only (seccomp can't filter by path), so the SSH + agent-forwarding allowance doesn't apply. - **`denyReadAlways` works on Linux for literal paths and narrow globs only.** Bubblewrap doesn't support regex/glob matching, so `srt` expands globs to concrete paths at config-load time. A pattern like @@ -409,20 +610,13 @@ A maintained Linux variant would be a welcome addition to this repo. `srt` has primitives this config doesn't exercise yet. Worth exploring if/when the current posture isn't enough: -- **Native `credentials` masking block.** Upstream `srt` now ships a - first-class `credentials` config block (`files` / `envVars`, each with - `mode: deny | mask`, optional `extract` regex, and per-entry - `injectHosts`). For *file* denies on macOS it's equivalent to what - `denyReadAlways` already does (Seatbelt can't redirect reads, so - `mask` degrades to `deny`), and it takes explicit paths rather than - broad globs — so it's **not** a drop-in replacement for the - credential globs here. Where it adds something this config can't do is - **env-var masking**: substitute a sentinel for a real token in the - environment and only swap the real value back on egress to specific - `injectHosts` (requires `network.tlsTerminate`). Gotcha: the - `credentials.*` sub-schema is `.strict()` — an unknown key under it is - a hard load error (unlike top-level/network/filesystem keys, which are - silently stripped). +- **`credentials.files` masking — deliberately not adopted.** Env-var + masking is in use (see [gh and glab](#gh-and-glab)), but *file* + masking is Linux-only — on macOS Seatbelt can't redirect reads, so + `mask` degrades to plain `deny`, and it takes explicit paths rather + than broad globs. `denyReadAlways` already provides everything it + could offer here, and the Keychain→`~/.claude/.credentials.json` + seeding scheme remains the right approach on a Mac. - **URL/path-level filtering.** Allow/deny today is host-only. `srt`'s MITM TLS termination layer (`src/sandbox/tls-terminate-proxy.ts`) sees full request URLs, so a future schema could block on path @@ -431,7 +625,9 @@ if/when the current posture isn't enough: - **Egress audit logging.** Even with allow-all-domains, the proxy observes every destination and URL passing through. Routing that to a log file outside the sandbox would give an after-the-fact audit - trail of what Claude actually reached out to during a session. + trail of what Claude actually reached out to during a session. (The + [violations pipeline](#the-violations-pipeline) realizes the *denial* + half of this; allowed traffic is still unlogged.) - **External policy via the MITM socket.** `MitmProxyConfigSchema` already supports routing specific domains through an upstream MITM proxy over a Unix socket. A separate policy engine — secret diff --git a/sandbox-runtime/README.md b/sandbox-runtime/README.md index a160a3e..b82bae7 100644 --- a/sandbox-runtime/README.md +++ b/sandbox-runtime/README.md @@ -20,6 +20,7 @@ and deep dives live in [DETAILS.md](DETAILS.md). | `.srt-claude-allowall.json` | Permissive posture — default-allow reads, explicit deny-list for sensitive paths | | `.zshrc.example` | Shell functions (`ccx`, `ccx_permissive`, `srtlog`) for zsh | | `.bashrc.example` | Same functions ported to bash | +| `srt-violations.mjs` | Claude Code hook that surfaces sandbox denials into the agent's context | | `DETAILS.md` | Design notes and deep dives behind the callouts here | ## The two postures @@ -44,8 +45,21 @@ too much friction. - **`allowAllDomains: true`** — no domain allow-list; requires the forked `srt` ([details](DETAILS.md#the-fork)). -- **`deniedDomains: ["gist.github.com"]`** — explicit denies beat - allow-all; closes one easy exfil channel. +- **`deniedDomains`** — explicit denies beat allow-all: + `gist.github.com` closes one easy exfil channel, and the port-scoped + `*:25` / `*:587` / `*:465` entries block direct SMTP (a quiet exfil + protocol) without giving up default-allow. Each entry has a + **`deniedDomainReasons`** string that replaces the generic deny text + on the violation line the agent sees + ([details](DETAILS.md#the-violations-pipeline)). +- **`tlsTerminate: {}`** — srt's MITM proxy terminates TLS, builds its + own trust bundle (MITM CA + host roots), and sets `SSL_CERT_FILE`, + `CURL_CA_BUNDLE`, `CARGO_HTTP_CAINFO`, `GIT_SSL_CAINFO`, + `REQUESTS_CA_BUNDLE` for the sandboxed process — no manual CA + staging needed ([details](DETAILS.md#tls-inside-the-sandbox)). +- **`credentials.envVars` masks `GH_TOKEN`** — the sandbox sees a fake + token; the proxy injects the real one only on egress to + `github.com` / `*.github.com` ([details](DETAILS.md#gh-and-glab)). - **`denyReadAlways`** — credential globs (`/**/.env*`, `/**/*.pem`, `/**/id_*`, …) that deny reads everywhere, even inside `allowRead`'d paths. Fork-only field; patterns need a leading `/` to be global @@ -56,10 +70,14 @@ too much friction. - **`allowWrite`** uses `/private/tmp`, not `/tmp` — Seatbelt doesn't resolve the symlink ([details](DETAILS.md#filesystem-rule-mechanics)). - **`enableWeakerNetworkIsolation: true`** — required for trustd-based - TLS verification (`gh`, Go binaries, etc.) - ([details](DETAILS.md#tls-inside-the-sandbox)). -- Cert-pinned / mTLS hosts can be excluded per-domain via - `network.tlsTerminate.excludeDomains` — not set here + TLS verification (`gh`, Go binaries, etc.), and for the masking + setup above ([details](DETAILS.md#tls-inside-the-sandbox)). +- **`ignoreViolations`** — suppresses benign macOS violation noise + (sysctl `kern.*`, preferences plists, configd lookups) from the + violations the agent sees + ([details](DETAILS.md#the-violations-pipeline)). +- Cert-pinned / mTLS hosts break under TLS termination — exclude them + per-domain via `network.tlsTerminate.excludeDomains`; none set here ([details](DETAILS.md#tls-inside-the-sandbox)). ## Setup @@ -68,10 +86,15 @@ too much friction. Upstream `srt` has no allow-all-egress mode and ignores glob denies inside `allowRead` regions; the fork adds `allowAllDomains` and -`denyReadAlways` to fix both ([details](DETAILS.md#the-fork), upstream -PRs [#283](https://github.com/anthropic-experimental/sandbox-runtime/pull/283) -and [#284](https://github.com/anthropic-experimental/sandbox-runtime/pull/284) — -once merged, plain `npm install -g @anthropic-ai/sandbox-runtime` will do). +`denyReadAlways` to fix both (upstream PRs +[#283](https://github.com/anthropic-experimental/sandbox-runtime/pull/283) +and [#284](https://github.com/anthropic-experimental/sandbox-runtime/pull/284)), +plus three more deltas: the CLI streams sandbox violations to a file +the agent can be shown +([pipeline](DETAILS.md#the-violations-pipeline)), unrecognized +settings keys produce a launch-time warning instead of being silently +ignored, and git-over-SSH works on macOS via an auth-capable socat +ProxyCommand ([details](DETAILS.md#the-fork)). ```bash git clone https://github.com/ubc/sandbox-runtime.git @@ -86,18 +109,25 @@ Verify the install: ```bash which srt # → ~/.nvm/versions/node//bin/srt -srt --version # → 0.0.62-ltic.1 (the -ltic suffix confirms the patched fork) +srt --version # → 0.0.70-ltic.4 (the -ltic suffix confirms the patched fork) +``` + +Also install socat — it's what carries sandboxed SSH through the +authenticated proxy (without it srt falls back to `nc`, which can't +authenticate, and git-over-SSH stays broken): + +```bash +brew install socat ``` Functional check that `allowAllDomains` is honored — stock `srt` -silently drops the unknown key (needs the configs from step 2; the -`--cacert` staging matters, [details](DETAILS.md#the-fork)): +silently drops the unknown key (needs the configs from step 2; +`tlsTerminate` makes srt point curl at its own CA bundle, so no +`--cacert` staging is needed, [details](DETAILS.md#the-fork)): ```bash -mkdir -p /tmp/claude && cp /etc/ssl/cert.pem /tmp/claude/ca-bundle.crt srt --settings ~/.srt-claude-denyall.json -- \ - curl --cacert /tmp/claude/ca-bundle.crt -s -o /dev/null \ - -w '%{http_code}\n' --max-time 5 https://example.com/ + curl -s -o /dev/null -w '%{http_code}\n' --max-time 5 https://example.com/ # 200 → patched srt: allowAllDomains is honored # 000 / hang → stock srt: the connection was blocked at the proxy ``` @@ -133,7 +163,68 @@ printf '#!/usr/bin/env bash\n source ~/.claude-sandbox.bash\n srtlog "$@"\n' > ~ chmod +x ~/.local/bin/ccx ~/.local/bin/ccx_permissive ~/.local/bin/srtlog ``` -### 4. Use it +### 4. Trust the sandbox MITM CA (one-time) + +`tlsTerminate` means srt re-signs upstream certificates. PEM-reading +tools (curl, cargo, git, python) trust the re-signed certs via the env +vars srt sets — but tools that verify through the macOS trust store +(`gh` and other Go binaries) ask trustd, which knows nothing about +srt's CA and fails with `x509: certificate signed by unknown +authority`. Fix: `_ccx_run` generates a **persistent** CA on first +launch (`~/.config/srt/mitm-ca.{crt,key}`) and points srt at it, so +you can trust it once in your login keychain: + +Run `ccx` once (any short session — the wrapper generates the CA on +first launch), then: + +```bash +security add-trusted-cert -p ssl -k ~/Library/Keychains/login.keychain-db ~/.config/srt/mitm-ca.crt +``` + +macOS shows an authorization prompt; approving it adds the CA as +SSL-trusted **for your user only**. The private key stays on this +machine, mode 600, and is unreadable inside the sandbox (the +`/**/*.key` deny glob) — only host processes running as you could +misuse it, and those could install their own CA anyway +([details](DETAILS.md#tls-inside-the-sandbox)). + +### 5. Install the violations hook + +Sandbox denials are logged by the forked `srt` to a file +(`~/.local/state/srt/violations-.log`, advertised via +`$SRT_VIOLATIONS_FILE`). This Claude Code hook feeds new lines into the +agent's context after each Bash command, so a policy block reads as +policy — with its configured reason — instead of a mysterious +403/EPERM ([details](DETAILS.md#the-violations-pipeline)): + +```bash +mkdir -p ~/.claude/hooks +cp srt-violations.mjs ~/.claude/hooks/ +``` + +Then merge into the `hooks` key of `~/.claude/settings.json`: + +```json +"hooks": { + "PostToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "node \"$HOME/.claude/hooks/srt-violations.mjs\"", + "timeout": 10 + } + ] + } + ] +} +``` + +Outside an srt sandbox `$SRT_VIOLATIONS_FILE` is unset and the hook is +a no-op, so it's safe to leave registered globally. + +### 6. Use it ```bash cd ~/src/your-project @@ -153,14 +244,21 @@ the sandbox fails with EPERM and you want to know why. Keychain (unreadable inside) to `~/.claude/.credentials.json`, with the refresh token nulled so only the short-lived access token touches disk ([details](DETAILS.md#credential-plumbing)). -- **Stages a CA bundle** — copies `/etc/ssl/cert.pem` (blocked inside by - the `*.pem` glob) to `$td/ca-bundle.crt` and sets `SSL_CERT_FILE`, - `CURL_CA_BUNDLE`, `CARGO_HTTP_CAINFO`, `GIT_SSL_CAINFO`, - `REQUESTS_CA_BUNDLE` — fixes cargo, curl, git-over-HTTPS, python TLS - ([details](DETAILS.md#tls-inside-the-sandbox)). -- **Injects `GH_TOKEN`** via `gh auth token`, so `gh` works inside - ([details](DETAILS.md#gh-and-glab)). `glab` needs nothing — it reads - its own config file. +- **Extracts `GH_TOKEN`** via `gh auth token` (the Keychain is + unreadable inside), then hands it to srt, which **masks** it: the + sandbox sees a fake token and the proxy injects the real one only + toward GitHub ([details](DETAILS.md#gh-and-glab)). `glab` needs + nothing — it reads its own config file. +- **Maintains the persistent MITM CA** — generates + `~/.config/srt/mitm-ca.{crt,key}` on first launch so srt signs with + the same CA every session (trusted once in the keychain, setup + step 4) ([details](DETAILS.md#tls-inside-the-sandbox)). +- **Forwards the ssh-agent socket** so git-over-SSH and `ssh` work + through the agent while raw private keys stay unreadable: passes + `SSH_AUTH_SOCK` through and writes a per-launch config copy that + injects the session's socket path (`network.allowUnixSockets` — the + launchd path changes every login) and the CA paths + ([details](DETAILS.md#ssh)). - **Pins pnpm's global store** via `pnpm_config_store_dir`, preventing silent per-project `.pnpm-store/` fallback ([details](DETAILS.md#pnpm-and-uv)). @@ -177,10 +275,34 @@ posture it affects. `ccx` ([details](DETAILS.md#credential-plumbing)). - **`.pem`/`.key` files are blocked everywhere — including non-secret - ones.** *(both)* System CA bundles are handled automatically by the - CA staging; for anything else, rename/copy to `.crt` or `.pem.txt` + ones.** *(both)* The system CA bundle is handled automatically: + `tlsTerminate` makes srt inject its own bundle at an allowed path. + For anything else, rename/copy to `.crt` or `.pem.txt` ([details](DETAILS.md#filesystem-rule-mechanics)). +- **Sandbox denials are explained in-session.** *(both)* The + violations hook surfaces each denial — with its configured reason — + into the agent's context after the next Bash command. Filesystem + denials arrive with ~1–2 s of log latency; network denials are + instant ([details](DETAILS.md#the-violations-pipeline)). + +- **Tools that bypass the proxy see a fake `GH_TOKEN` and fail GitHub + auth.** *(both)* That's the masking working — the sentinel is + worthless, so it's a functionality caveat, not a leak. Anything + going through the proxy (gh, git-over-HTTPS, curl) authenticates + fine ([details](DETAILS.md#gh-and-glab)). + +- **`gh` fails with `x509: certificate signed by unknown authority`?** + *(both)* The MITM CA isn't trusted yet — run setup step 4. PEM-based + tools (curl, cargo, git) work either way; only trustd-verifying + tools (Go binaries) need the keychain trust. + +- **Cert-pinned or mTLS hosts fail under TLS termination.** *(both)* + The proxy re-signs certificates with srt's MITM CA, which pinning + rejects. Add the offending host to + `network.tlsTerminate.excludeDomains` rather than turning + termination off ([details](DETAILS.md#tls-inside-the-sandbox)). + - **cargo works inside; `cargo install`, `cargo publish`, and `rustup` run outside.** *(both)* Registry/git caches are writable; `~/.cargo/bin` and `~/.rustup` deliberately aren't @@ -199,12 +321,20 @@ posture it affects. git config --add credential.helper '!gh auth git-credential' ``` -- **All SSH is blocked** — git-over-SSH, `ssh`, `sftp` — because raw - private keys are unreadable. *(both)* Use HTTPS + token (below), or - SSH agent forwarding ([details](DETAILS.md#ssh)). +- **git-over-SSH works — needs `brew install socat` and keys loaded + via `ssh-add`.** *(both)* Auth goes through the forwarded ssh-agent + socket (raw keys stay unreadable; run `ssh-add`, or `ssh-add -c` + for per-use confirmation, before launching) and the connection + tunnels through the proxy via srt's socat `ProxyCommand`. If your + ssh config sets `IdentitiesOnly yes`, add the sandbox-scoped + override from the details page or auth fails despite the agent. + Plain `ssh` outside git: `eval "$GIT_SSH_COMMAND git@github.com"` + ([details](DETAILS.md#ssh)). While a session runs, sandboxed code + can authenticate as you to anything the agent holds keys for — + mitigate with `ssh-add -c` or hardware-backed keys. - **`git push`/`git pull` over HTTPS works with a one-time credential - helper setup.** *(both)* + helper setup** (alternative to SSH remotes). *(both)* ```bash # GitHub — one-time setup per repo git remote set-url origin https://github.com/ORG/REPO.git @@ -245,4 +375,4 @@ posture it affects. - [Using on Linux](DETAILS.md#using-on-linux) — config tweaks for bubblewrap/seccomp. - [Future directions](DETAILS.md#future-directions) — srt primitives - not exercised yet (credential masking, URL filtering, egress audit). + not exercised yet (URL filtering, egress audit, per-tool policies). diff --git a/sandbox-runtime/srt-violations.mjs b/sandbox-runtime/srt-violations.mjs new file mode 100644 index 0000000..ef5a14c --- /dev/null +++ b/sandbox-runtime/srt-violations.mjs @@ -0,0 +1,71 @@ +#!/usr/bin/env node +// PostToolUse(Bash) hook: surface srt sandbox violations to the agent. +// +// When a Claude Code session runs inside an srt sandbox (ccx), the srt CLI +// streams denial lines to $SRT_VIOLATIONS_FILE (host-written, sandbox- +// readable). This hook reads any lines added since its last run and injects +// them as additionalContext, so a command that failed due to sandbox policy +// is explained instead of looking like a broken tool or network. +// +// Outside an srt sandbox $SRT_VIOLATIONS_FILE is unset and the hook is a +// no-op. State (read offset) lives under ~/.cache/srt-hook, which the +// sandbox can write; the violations file itself must NOT be sandbox- +// writable, so forged lines can't be injected from inside. + +import { readFileSync, writeFileSync, mkdirSync, openSync, readSync, fstatSync, closeSync } from 'node:fs' +import { join, basename } from 'node:path' +import { homedir } from 'node:os' + +const file = process.env.SRT_VIOLATIONS_FILE +if (!file) process.exit(0) + +let fd +try { + fd = openSync(file, 'r') +} catch { + process.exit(0) +} + +try { + const size = fstatSync(fd).size + const stateDir = join(homedir(), '.cache', 'srt-hook') + const offsetFile = join(stateDir, basename(file) + '.offset') + + let offset = 0 + try { + offset = parseInt(readFileSync(offsetFile, 'utf8'), 10) || 0 + } catch { + // First run this session: report everything so far. + } + if (offset > size) offset = 0 // File was recreated; start over. + if (size <= offset) process.exit(0) + + const buf = Buffer.alloc(size - offset) + const read = readSync(fd, buf, 0, buf.length, offset) + const fresh = buf.toString('utf8', 0, read).trim() + + mkdirSync(stateDir, { recursive: true }) + writeFileSync(offsetFile, String(offset + read)) + + if (!fresh) process.exit(0) + + const context = + 'This session is running inside an external srt sandbox. The following ' + + 'sandbox denials were recorded since the last command — failures they ' + + 'explain are policy blocks, not tool or network errors, so do not ' + + 'retry the same access; use the suggested alternative or a different ' + + 'approach:\n\n' + + fresh + + '\n' + + process.stdout.write( + JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'PostToolUse', + additionalContext: context, + }, + }), + ) +} finally { + closeSync(fd) +}