Skip to content

feat(sandbox): expand default allow list for node package managers#1155

Open
cyrusagent wants to merge 19 commits into
mainfrom
cypack-1128
Open

feat(sandbox): expand default allow list for node package managers#1155
cyrusagent wants to merge 19 commits into
mainfrom
cypack-1128

Conversation

@cyrusagent
Copy link
Copy Markdown
Contributor

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 TMPDIR at 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 — ~/.npm
  • yarn (classic + berry) — ~/.yarn, ~/.cache/yarn
  • pnpm — ~/.pnpm-store, ~/.local/share/pnpm, ~/.cache/pnpm, ~/Library/pnpm, ~/Library/Caches/pnpm
  • bun — ~/.bun
  • deno — ~/.deno, ~/.cache/deno
  • node-gyp — ~/.node-gyp
  • nvm — ~/.nvm
  • generic XDG — ~/.cache

TMPDIR — set to <cyrusHome>/tmp (added to allowWrite and 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

  • New buildPackageManagerHomeAllowances() helper in packages/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 sandbox allowRead/allowWrite lists and exposes TMPDIR via additionalEnv for every session (not just egress-cert sessions).
  • The read-only configs are deliberately excluded from allowWrite so tools can't mutate ~/.gitconfig or the gh hosts file.

Changelog

Added a ### Security entry under ## [Unreleased] describing the expanded defaults.

Test plan

  • New unit test packages/edge-worker/test/RunnerConfigBuilder.sandbox-allowances.test.ts covers the explicit configs, every package manager's cache dir, and the invariant that read-only configs are not in allowWrite.
  • Run a real sandboxed session that does npm install / pnpm install / bun install and confirms the package managers succeed without escape-hatch permissions.
  • Confirm git and gh inside the sandbox continue to work (no regressions from the ~/.gitconfig / ~/.config/gh/hosts.yml read allowances).

Tip: I will respond to comments that @ mention @cyrusagent on this PR. You can also submit a review with all your feedback at once, and I will automatically wake up to address each comment.

Cyrus 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
Cyrus and others added 17 commits April 23, 2026 12:33
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
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
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.

2 participants