feat(sandbox): expand default allow list for node package managers#1155
Open
cyrusagent wants to merge 19 commits into
Open
feat(sandbox): expand default allow list for node package managers#1155cyrusagent wants to merge 19 commits into
cyrusagent wants to merge 19 commits into
Conversation
added 2 commits
April 23, 2026 12:03
Grant read access to ~/.gitconfig and ~/.config/gh/hosts.yml, plus read+write access to the caches/stores for npm, yarn, pnpm, bun, deno, and node-gyp (both macOS and Linux layouts). Point TMPDIR at <cyrusHome>/tmp (always inside allowWrite) so Bun and similar tools that need a writable tmp dir work out of the box. CYPACK-1128
PaytonWebber
approved these changes
Apr 23, 2026
The Claude Code native binary unconditionally overrides TMPDIR to its own sandbox-managed path when spawning sandboxed shells, so any TMPDIR we pass via the SDK's env option never reaches Bun. Switch to BUN_TMPDIR, which Bun honors with higher priority than TMPDIR, so our Cyrus-managed tmp dir actually gets used. Also add ~/.config/git to the sandbox allowRead list so git can access its XDG config dir (~/.config/git/config, ~/.config/git/ignore) without noisy permission warnings on every command. CYPACK-1128
Claude Code's sandbox resolves "." into the write allowlist as the session's absolute cwd, but the same expansion does not happen for the read allowlist. That asymmetry left the worktree write-only, so git status, ls, and bun install lifecycle scripts tripped EPERM / CouldntReadCurrentDirectory inside the sandbox. Add session.workspace.path to allowRead explicitly to mirror what we already do for allowWrite. Reported from CYHOST-830. CYPACK-1128
The "." path-prefix only resolves to the session cwd when sandbox settings are declared inside a committed .claude/settings.json file that Claude Code reads from disk. When settings are passed programmatically via the SDK (as we do), "." is not expanded, so including it in allowRead was dead weight. The worktree path is now enumerated explicitly via session.workspace.path, which already works in both contexts. Inline comment explains the gotcha so future readers don't re-add it. CYPACK-1128
Adds ~/.ssh/known_hosts and ~/.config/gh/config.yml to the sandbox allowRead list so git-over-ssh host verification and `gh` user config work without noisy permission errors. Also excludes `bun *`, `npm *`, `pnpm *`, and `yarn *` from the sandbox via excludedCommands. These commands spawn lifecycle scripts, compile native addons, and touch a long tail of paths that are impractical to enumerate. The egress proxy still sees their network traffic; excluding them from the filesystem sandbox is the practical trade-off that makes `install` reliable. CYPACK-1128
getGitMetadataDirectories was only called on the primary workspace path, so secondary repos in a multi-repo session never had their .git/worktrees/<name>/ linked-worktree metadata added to the sandbox allowlist. Git operations against those secondary repos tripped Operation not permitted on that path. Fan getGitMetadataDirectories over every worktree in workspace.repoPaths so each repo's .git and linked-worktree metadata lands in allowedDirectories, in both the issue-creation path and the resume path. CYPACK-1128
Previously the multi-repo fix added each sub-worktree's .git/worktrees/<name>/ path to allowedDirectories, which conflates "dirs the agent CLI can operate in" with "paths the OS sandbox needs access to". Split them: allowedDirectories goes back to just repo/attachment paths, and the sandbox-only git metadata paths ride on a new sandboxGitMetadataDirectories field that feeds only into sandbox.filesystem.allowRead / allowWrite. Also extend allowWrite to include every sub-worktree plus those metadata dirs, so git commits on secondary repos can create index.lock, update HEAD, etc. (fixes the CYHOST-830 symptom of writes being denied to documentation/.git/worktrees/documentation/). CYPACK-1128
# Conflicts: # packages/edge-worker/src/RunnerConfigBuilder.ts
Document the deliberate independence between buildHomeDirectoryDisallowedTools() (tool-permission layer) and buildPackageManagerHomeAllowances() (OS sandbox allowRead) so future editors don't assume changes to one propagate to the other. Also note the SDK's Glob/Grep enumeration limitation: only Read(...) patterns are honored in disallowedTools, so emitting Glob(...)/Grep(...) patterns here would be a no-op. Surfaced during sandbox investigation.
Four related changes surfaced during a sandbox investigation: 1. Detect the Claude Agent SDK FD-3 bash wedge in a PostToolUse hook. When the model emits a parallel Bash batch and one call errors, every subsequent Bash invocation in the same session fails with exit 126 and "/bin/bash: line 4: /proc/self/fd/3: Permission denied". The wedge is reproducible and unaffected by host-side permission knobs (permission mode, autoAllowBashIfSandboxed, allowRead narrowing) — it's a wrapper the SDK constructs after a parallel-batch cancel. Until upstream is fixed, the new BashWedgeDetectorHook scans Bash tool_response for the signature, emits a structured "bash_fd3_wedge_detected" event for telemetry, and surfaces additionalContext on first detection telling the model to stop using Bash for the rest of the session. Subsequent detections in the same session are telemetry-only (firstHit:false). 2. Allow ~/.claude/shell-snapshots in the OS sandbox allowRead list. The SDK's bash wrapper sources a generated snapshot of the host shell's functions/aliases/shopts. Without this entry the source silently fails inside the sandbox and any host-shell function the user expects Claude's commands to inherit is missing — symptom is "the command did the wrong thing" with no diagnostic. Read-only is sufficient. 3. Cross-reference comments between buildPackageManagerHomeAllowances() and buildHomeDirectoryDisallowedTools() so future editors don't assume the OS allowRead list and the tool-permission denylist track each other. They are intentionally independent (different consumers). 4. Only set sandbox.enableWeakerNetworkIsolation on macOS. The flag is a no-op on Linux but on macOS opens access to com.apple.trustd.agent — a documented data-exfiltration path. Now scoped to the platform that actually benefits.
… tests
EgressProxy.handleHttpRequest and handleTlsTermination both produced the
outgoing-request headers via near-identical inline blocks: copy client
headers, strip proxy-connection, optionally rewrite Host, apply per-domain
transforms via getTransformsForDomain. Extracted into a single
buildOutgoingHeaders private method so the merge/strip/override semantics
cannot drift between the two paths. Both call sites now share one
implementation; the security-critical "transform overrides client header"
behavior is now provably identical for HTTP and HTTPS.
Also fixes a subtle resource-cleanup wart in handleTlsTermination: the
per-request MITM HTTPS server's localServer.close() waits indefinitely
for idle keep-alive connections before resolving. Added closeAllConnections()
after close() so MITM resources are freed immediately on tunnel teardown.
Production effect: faster proxy.stop() shutdown when transformed sessions
just finished. Test effect: vitest afterEach hooks no longer time out.
New test file packages/edge-worker/test/EgressProxy.transforms.test.ts
adds 27 learning tests grouped into:
- Header injection through plain HTTP via a real local upstream that
records its received requests: single header, multiple headers, merge
semantics within and across rules (last-wins on conflict), client
header overwrite (security model), proxy-connection strip, body
forwarding, no-transform passthrough.
- Wildcard transform application + matchesPattern edge cases via
observable CONNECT 403 vs not-403: deep subdomains, bare-domain
exclusion, mid-segment single-segment-only matching, exact-only
non-wildcard.
- buildCACertBundle six paths: no cert, proxy-CA path, explicit host
cert merge, NODE_EXTRA_CA_CERTS env fallback, missing file, idempotent
re-bundling.
- TLS termination property verification: domains with transforms get a
proxy-CA-signed fake cert at handshake; allowed-but-not-transformed
domains pass the upstream's cert through; clients that don't trust
the proxy CA cannot complete the MITM handshake (the security
invariant that makes the proxy safe).
- updateNetworkPolicy lifecycle: prior transforms cleared when a domain
is dropped, transform headers replaced when same-domain rules change,
a domain removed from allow becomes 403'd.
Why the TLS tests assert on cert chains rather than receiving the
forwarded response: the proxy uses https.globalAgent for upstream, whose
secureContext doesn't reliably pick up tls.setDefaultCACertificates
mutations within vitest's process state. The cert chain is the MITM
signature anyway — same-process forward is structurally identical to
the HTTP-path tests because of the buildOutgoingHeaders consolidation.
… tests
Extracted the per-session CA env-var block out of
RunnerConfigBuilder.buildSandboxConfig (private) into a top-level exported
buildEgressCaEnv(egressCaCertPath) — same pattern already used by
buildPackageManagerHomeAllowances. The function returns an empty object for
null/undefined/empty input (the systemWideCert: true path, where the OS
cert store handles trust) and otherwise the full set of CA env vars all
pointing at the same path. buildSandboxConfig now just spreads its result
into additionalEnv. Pure SRP — the function has one job, no dependencies,
trivially testable.
New test file packages/edge-worker/test/EgressProxy.cert-trust.test.ts
adds 14 learning tests pinning down the two paths Cyrus can take to make
the egress proxy's MITM cert trusted by sandboxed tools:
1. System-wide install via update-ca-certificates(8). The tests pin the
X.509 properties the script implicitly requires of input certs:
- well-formed PEM (BEGIN/END markers, base64-only body)
- parses as valid X.509
- basicConstraints CA:TRUE (without it, OpenSSL/GnuTLS treat the
cert as a leaf and reject any chain that has it as the root)
- keyUsage with keyCertSign + cRLSign (authorises subordinate cert
signing — the proxy's MITM signing path)
- self-signed (subject DN == issuer DN), required for a root
- currently within validity window
- can sign a server cert that round-trip-verifies under the CA's
public key (exercises the MITM signing path end-to-end)
- private key file is mode 0600 — security invariant: anyone who
can read the key can forge MITM certs trusted by every session
that has the public CA installed.
2. Per-session env vars (the default when systemWideCert is false). The
tests pin buildEgressCaEnv's contract:
- empty result for null/undefined/empty (systemWideCert: true)
- exact set of 9 keys covering Node, OpenSSL, git, Python, curl,
Cargo, AWS, Deno
- single-source-of-truth (all keys point to the same path)
- documents tools that ignore env vars (Bun, .NET, macOS curl) by
pinning the absence of fake CA-env-var entries someone might add
in the mistaken belief that they'd help.
Plus one test for buildCACertBundle confirming a multi-cert PEM bundle
(corporate CA + proxy CA) parses as a chain — the structure
update-ca-certificates accepts when ingesting bundle files.
When sandbox.enabled is true, the egress proxy now MITM-rewrites the
Authorization header on outbound requests to api.github.com (Bearer) and
github.com (Basic with x-access-token user) using the workspace's resolved
GitHub token. Sessions get a sentinel GH_TOKEN ("x-cyrus-brokered") + a
git credential helper that returns the same sentinel; real
GITHUB_TOKEN/GH_TOKEN/GH_ENTERPRISE_TOKEN values in the worktree's .env
are stripped before reaching the agent. Sandboxed gh and git work as
normal but never see a real credential — same model as Cloudflare's
"Outbound Workers TLS auth" feature.
Default-on when sandbox is enabled. Set
sandbox.brokerGitHubCredentials: false to opt out. The schema field is
plain optional (not z.default(true)) so existing literal EdgeWorkerConfig
consumers like apps/f1 don't have to set it; the runtime check is
`!== false`, treating undefined as default-true.
Architecture (new exports in cyrus-edge-worker / cyrus-claude-runner):
- buildGitHubBrokeredPolicy(basePolicy, token) — pure function. Layers the
brokered transforms onto a base NetworkPolicy, brokered rule appended
last so user-supplied Authorization (if any) is overridden while other
user transform headers on the same domain merge through.
- buildGitHubBrokeredEnv({enabled}) — pure function. Emits GH_TOKEN
sentinel + GIT_TERMINAL_PROMPT=0 + GIT_CONFIG_COUNT/KEY/VALUE registering
an inline credential helper for https://github.com (no on-disk git
config write).
- refreshGitHubBrokerPolicy(deps) — pure dispatch helper that EdgeWorker
calls. Resolves the token, idempotently pushes the brokered policy via
egressProxy.updateNetworkPolicy when the token changes, WARNs once when
no token is resolvable.
- ClaudeRunner.stripKeys(env, keysToStrip) — pure filter applied to
repositoryEnv at the env-merge step, gated by AgentRunnerConfig.stripEnvKeys.
Identity-returns the input when the strip set is empty so the
unconditional spread path costs nothing in the common case.
EdgeWorker wiring:
- resolveStableGitHubToken() — App+PAT fallback (no per-event tier);
resolveGitHubToken now delegates to it for that path.
- refreshBrokeredGitHubPolicy() — thin shim over the pure helper.
- 30-min refresh timer started after egressProxy.start(), well under the
1-hour App-token expiry; cleared in stop() and on sandbox-disabled
config reload.
- applySandboxConfigChanges re-pushes the brokered policy after a user
policy update so the user-policy update doesn't blow away the brokered
transforms.
- buildAgentRunnerConfig passes brokerGitHubCredentials = (sandbox enabled
AND not explicitly disabled AND token resolved) so the per-session
env-strip + sentinel kick in only when brokering is actually active.
Tests (49 new across 4 files):
- RunnerConfigBuilder.broker-policy.test.ts (9): brokered transform shape
for both hostnames, base64 round-trip, composition with preset:trusted /
user-supplied transforms / unrelated allow rules, immutability of input.
- RunnerConfigBuilder.broker-env.test.ts (8): GH_TOKEN sentinel format and
identifiability, exact env var key set, credential helper protocol shape,
GITHUB_BROKERED_STRIP_ENV_KEYS coverage, absence of *_FILE variants.
- RunnerConfigBuilder.broker-refresh.test.ts (8): brokering disabled →
short-circuit, no-token-resolvable → WARN-once latch behavior, token
rotation → fresh push, unchanged token → idempotent skip, token
recovery → WARN latch reset, base policy composition.
- claude-runner/strip-keys.test.ts (8): identity for undefined/empty
strip set, single/multiple key removal, missing keys ignored, no input
mutation, case sensitivity, prototype-pollution-style key handling.
- EgressProxy.transforms.test.ts (3 added): sentinel Bearer overwritten
by broker's real Bearer end-to-end, sentinel Basic overwritten with
base64 round-trip verification, brokering applied via updateNetworkPolicy
at runtime preserves the overwrite property.
Out of scope (per the plan): GitHub Enterprise per-repo githubUrl, GitLab
brokering, CYHOST per-event token tier, multi-installation workspaces,
stripping ~/.config/gh/hosts.yml from sandbox allowRead.
Fixes the parallel-Bash-batch FD-3 wedge documented in commit d30f92d ("feat(edge-worker): sandbox hardening and FD-3 bash-wedge detection"). Reproducer: sandbox-experiments/07-parallel-batch-repro.mjs. Before (0.2.117): after a parallel Bash batch where one call errors and the SDK cancels the other, the next external-binary command (e.g. `date`, `true && echo`) fails with `/bin/bash: line 4: /proc/self/fd/3: Permission denied` exit 126, and the Bash tool stays unrecoverable for the rest of the session. After (0.2.123): all post-cancel commands succeed cleanly. Same trigger sequence (curl + python3 in parallel, curl errors with proxy 403, python3 gets cancelled), but the post-cancel bash subsystem stays healthy. | Command | 0.2.117 | 0.2.123 | | ---------------------- | ------- | ------- | | echo hi | OK | OK | | pwd | OK | OK | | date | FAIL | OK | | true && echo all-good | FAIL | OK | The BashWedgeDetectorHook (commit d30f92d) and its `bash_fd3_wedge_detected` telemetry event remain in place as defense in depth — when the SDK fix holds the event will simply stay quiet, which is itself the intended success signal for the Sentry alert in the recommendations. Versions are aligned across all four workspaces that pin the SDK (claude-runner, core, edge-worker, simple-agent-runner) so pnpm resolves a single 0.2.123 instance and avoids the cross-version type-incompatibility error from having two SDKs in the dep graph.
# Conflicts: # packages/claude-runner/package.json
…ction" This reverts commit d30f92d.
Adds a third tier to the workspace-stable GitHub token resolver between the GitHub App installation token and the GITHUB_TOKEN env var fallback. Self-hosted users who already authed via `gh auth login` no longer need to also export GITHUB_TOKEN to make egress proxy credential brokering work — `gh auth token` is consulted automatically. The new GhCliTokenResolver caches the token for 60s so a burst of brokered requests doesn't fork+exec on every call. It also dedupes concurrent in-flight resolutions, returns null on missing/unauthed gh, and exposes invalidate() for explicit cache busting. CYPACK-1128
hosts.yml stores gh OAuth tokens. Sandboxed sessions must never be able to read it — credential brokering (proxy-side header injection) is the supported path for GitHub auth from inside the sandbox, and the agent never needs the raw token. config.yml (gh's non-credential config — UI prefs, default editor) remains allowed. CYPACK-1128
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Assignee: @PaytonWebber (payton)
Summary
Closes CYPACK-1128.
Expands the default sandbox filesystem allow list so that node-based package managers (npm, yarn, pnpm, bun, deno) work out of the box, and points
TMPDIRat a Cyrus-managed path inside the write allowlist so Bun's atomic installs (and any other tool that needs a writable tmp dir) succeed without weakening the overall sandbox.What's allowed
Read-only configs —
~/.gitconfig,~/.config/gh/hosts.yml,~/.npmrc,~/.yarnrc,~/.yarnrc.yml.Read + write (package manager caches, stores, and state) — covers both macOS and Linux layouts; irrelevant paths on a given OS are harmless no-ops:
~/.npm~/.yarn,~/.cache/yarn~/.pnpm-store,~/.local/share/pnpm,~/.cache/pnpm,~/Library/pnpm,~/Library/Caches/pnpm~/.bun~/.deno,~/.cache/deno~/.node-gyp~/.nvm~/.cacheTMPDIR — set to
<cyrusHome>/tmp(added toallowWriteand auto-created at session start). This gives Bun a writable tmp dir on the same filesystem as its install target, which is required for its atomic-rename install path.Implementation
buildPackageManagerHomeAllowances()helper inpackages/edge-worker/src/RunnerConfigBuilder.ts— single source of truth for the defaults, so future additions have one place to live.buildSandboxConfig()now merges those allowances into the sandboxallowRead/allowWritelists and exposesTMPDIRviaadditionalEnvfor every session (not just egress-cert sessions).allowWriteso tools can't mutate~/.gitconfigor the gh hosts file.Changelog
Added a
### Securityentry under## [Unreleased]describing the expanded defaults.Test plan
packages/edge-worker/test/RunnerConfigBuilder.sandbox-allowances.test.tscovers the explicit configs, every package manager's cache dir, and the invariant that read-only configs are not inallowWrite.npm install/pnpm install/bun installand confirms the package managers succeed without escape-hatch permissions.gitandghinside the sandbox continue to work (no regressions from the~/.gitconfig/~/.config/gh/hosts.ymlread allowances).