Skip to content

feat(runner): sandbox Claude Code jobs' commands - #212

Merged
kixelated merged 10 commits into
mainfrom
quest/m0/kix/sandbox
Oct 24, 2026
Merged

kixelated merged 10 commits into
mainfrom
quest/m0/kix/sandbox

Conversation

@kixelated

@kixelated kixelated commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator

Sandboxes the commands of every runner job on a Claude Code account, for Job sandbox. Before this, quest runner ran Claude Code with --permission-mode bypassPermissions, the user's full environment, and nix develop evaluated from the checkout the agent had edited. A prompt-injected agent could read ~/.ssh or the runner's kix token, or commit a shellHook that ran on the host at the next attempt.

What changes

  • OS sandbox for every command (src/runner/sandbox.ts, src/runner/claude.ts). Claude Code runs with its built-in sandbox (bubblewrap on Linux, Seatbelt on macOS) and these settings: failIfUnavailable, allowUnsandboxedCommands: false, and a strict network allowlist. Commands write only to the checkout and a private /tmp/quest-job-* directory, which is both TMPDIR and HOME. They cannot read /home, /root, /run/user, /tmp, the user's home, or the runner home (kix token and account logins). They can read only the checkout, its bare clone, and their temporary directory.
  • Package registries for trusted revisions only. A trusted revision's commands reach registry.npmjs.org, registry.yarnpkg.com, index.crates.io, static.crates.io, pypi.org and files.pythonhosted.org, so its checks can install dependencies. An untrusted revision's commands reach no host. Today kix marks every PR head untrusted and the runner refuses those, so this applies to the jobs that run now. crates.io itself is left out: it serves only the publishing API. The system prompt names the reachable hosts.
  • File tools confined too. The sandbox does not cover Read, Edit or WebFetch, so the agent runs in dontAsk mode. Its allow rules permit only Bash, Edit within the checkout, WebSearch, and the kix MCP tools. WebFetch is disabled.
  • The checkout's Claude config is never loaded. --setting-sources user, together with the existing --strict-mcp-config, keeps out the repository's .claude/settings.json hooks, permissions and sandbox loosening, plus .mcp.json servers. I verified that a checkout's SessionStart hook does run on the host in -p mode otherwise, even in an untrusted workspace. As a result Claude Code no longer reads CLAUDE.md, so the prompt now carries the root CLAUDE.md and lists nested ones alongside AGENTS.md.
  • devShell built from the revision, sourced inside the sandbox (src/runner/cli.ts). The runner runs nix print-dev-env on git+file://<bare clone>?rev=<job revision>, never on the edited checkout. Claude Code sources the result through CLAUDE_ENV_FILE before each command, inside the sandbox, so the shellHook is sandboxed too. Bootstrap jobs source only the private HOME.
  • Minimal agent environment. The Claude Code process keeps only locale, PATH, HOME, shell, proxy and CA variables. Cloud keys, SSH_AUTH_SOCK and similar no longer reach commands.
  • Instruction files read safely. The runner reads AGENTS.md and CLAUDE.md on the host, so it now reads only regular files. A job branch committing AGENTS.md -> ~/.ssh/id_ed25519 would otherwise have put the key into the prompt. A regression test covers it.
  • opencode jobs (from kixelated/quest#216, merged in here). opencode has no sandbox of its own, so its commands still run unconfined. It now enters the devShell built from the job's revision, through the sandbox's env script with the private HOME, instead of nix develop on the checkout. Wrapping opencode serve in sandbox-runtime moves into the sandbox quest, which takes over the note from the deleted opencode quest. The README says opencode jobs are not sandboxed yet.
  • Packaging. On Linux the flake's runner package puts pinned bubblewrap and socat on PATH. quest runner refuses to start on Linux without them.
  • The README documents the boundary and its residual risk. The quest is rewritten to what remains, and the opencode sandbox-runtime note moves to the opencode quest.

Validation

  • just check and just test pass. Runner tests cover the launch flags, the generated settings, the env script, the scrubbed environment, the devShell revision URL, temp cleanup, and the symlinked-AGENTS.md prompt.
  • Live boundary test on Linux with the pinned Claude Code 2.1.291, and again with 2.1.305. It drives the real Claude adapter, openSandbox, and devShell against a hostile repository whose shellHook, .claude/settings.json hook, .mcp.json and excludedCommands try to write to $HOME. Confirmed:
    • No write outside the checkout. Writes to /home fail or land in a throwaway tmpfs.
    • ~/.ssh, ~/.claude/.credentials.json, the runner's login.json and other /tmp files are unreadable.
    • Unlisted hosts get 403 from the proxy, including api.anthropic.com. (This ran before the registry list was added.)
    • The SSH agent and /run/user are gone, and so are AWS_SECRET_ACCESS_KEY and GH_TOKEN.
    • The bare clone's hooks/ and config are read-only.
    • git commit in the checkout works.
    • The shellHook runs sandboxed, and an edited flake.nix in the checkout is ignored.
    • The project hook and MCP server never run.
    • Write and Read outside the checkout are denied, and WebFetch is unavailable.
    • The host checkout is left clean.
  • Registry list. With fresh caches, bun install, cargo fetch and uv pip install succeed through a CONNECT proxy that allows only REGISTRIES. Its log shows they use exactly those hosts, and crates.io and example.com are refused. I could not rerun this under bubblewrap: this session's own sandbox blocks nested user namespaces.
  • Not run: macOS (Seatbelt) and a stock-Ubuntu terminal outside an AppArmor profile that grants user namespaces. Both are listed as remaining work in the quest.

Choices made without asking

  • No Nix daemon in the sandbox, even for trusted revisions. On Linux the sandbox's seccomp filter blocks Unix sockets entirely, and allowUnixSockets has no effect there. allowAllUnixSockets would expose every socket on the filesystem (tmux, VS Code IPC, Docker). Daemon access would also let a command set root-level daemon options whenever the runner user is a Nix trusted user. So nix build/nix flake check fail in jobs and run in CI instead. The system prompt tells the agent so.

  • Not CLAUDE_CODE_SUBPROCESS_ENV_SCRUB. With it, both versions left empty .env, package.json and lockfile mount points in the checkout, which kix would publish. It also stopped denyRead: /tmp from hiding /tmp. The env allowlist does the scrubbing instead.

  • WebFetch off, WebSearch on. WebFetch runs outside the sandbox. WebSearch runs on the provider's side.

  • opencode keeps running jobs, unsandboxed, until it is wrapped. The alternative is to refuse opencode jobs until then, which would disable the adapter that just shipped. The parent session's merge instructions keep the wrapping as remaining work.

Decisions

Answered by the maintainer (comment):

  1. Package registries: ✅ (b) a fixed list (npm, crates.io, PyPI) for trusted revisions only. Untrusted revisions keep no network. Not picked: (a) no network with Nix-provided dependencies only, and (c) a per-project allowlist.
  2. Answer-approval gate: ✅ keep it until the sandbox is verified on macOS. This PR leaves it in place.
  3. Remaining sandbox work stays in the rewritten quest, for /quest-plan to split later.

Residual risk: npm's registry also accepts publishes. A prompt-injected agent holding someone's npm token could publish the checkout there. The README says so.

Follow-ups

  • Split the remaining quest via /quest-plan: trusted classification of maintainer PR heads in kix; the VM build for untrusted heads; macOS verification plus lifting the gate; an Ubuntu AppArmor profile plus a fail-fast sandbox preflight.
  • Allow only reads from the registries (for example GET only), if Claude Code's sandbox settings ever expose request filtering, to close the npm-publish channel.
  • Bump the runner's pinned Claude Code: 2.1.291 works, but 2.1.305 is what the docs describe.

(Written by Claude Opus 5.5)

🤖 Generated with Claude Code

kixelated and others added 2 commits October 23, 2026 23:03
Quest-Claim: bdcb3fa98e2bfc53
Commands write only to the checkout and a private temporary directory,
read no account home, runner state, or shared temp files, and reach no
network host or Nix daemon. The file tools are confined by dontAsk
permission rules, and the checkout's own Claude settings and MCP servers
are never loaded. The runner builds the devShell with nix print-dev-env
from the job's revision and Claude Code sources it inside the sandbox.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated kixelated changed the title Sandbox every runner job feat(runner): sandbox every job's commands Oct 24, 2026
@kixelated

Copy link
Copy Markdown
Collaborator Author

Decisions from the maintainer, answered in a /quest-complete session:

  1. Package registries: (b) ✅ Allow a fixed list of package registries (npm, crates.io, PyPI) for trusted revisions only. Untrusted revisions keep no network.
    Not picked: (a) no network with Nix-provided dependencies only, and (c) a per-project allowlist.
  2. Answer-approval gate: ✅ Keep it until the sandbox is verified on macOS. This PR does not remove it.
  3. Next step: ✅ Implement (1), then mark the PR ready. The rest of the sandbox work stays in the rewritten quest, for /quest-plan to split later: trust classification of PR heads, VM devShell builds for untrusted heads, macOS verification and then lifting the gate, and an Ubuntu AppArmor profile with a startup check. No quests are created for it here.

(Written by Claude Opus 5.5)

kixelated and others added 4 commits October 24, 2026 08:33
A trusted revision's sandboxed commands may now reach npm (also under
yarn's name), crates.io's index and downloads, and PyPI, so its checks
can install dependencies. An untrusted revision's commands still reach
no network host. The system prompt names the reachable hosts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resolves the opencode adapter (#216) against the job sandbox: opencode
now enters the devShell the runner built from the job's revision, through
the sandbox's env script, instead of `nix develop` on the checkout. Its
commands are not yet confined; wrapping `opencode serve` in
sandbox-runtime stays in quest/m0/kix/sandbox.md, which takes over the
note from the deleted opencode quest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated kixelated changed the title feat(runner): sandbox every job's commands feat(runner): sandbox Claude Code jobs' commands Oct 24, 2026
@kixelated
kixelated marked this pull request as ready for review October 24, 2026 16:13
@chatgpt-codex-connector

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

Copy link
Copy Markdown

Codex Review Summary

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

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-24T16:59:40.829560Z 2a612ef New commits
🔒 Security Review ✅ Completed 2026-10-24T17:02:37.208475Z 2a612ef New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

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

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 51c3cbbbeb

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/runner/job.ts Resolved
Comment thread src/runner/cli.ts Resolved
kixelated and others added 3 commits October 24, 2026 09:33
…sandbox only for Claude Code

A cancel, a moved attempt, or a runner shutdown now kills nix print-dev-env,
which runs before there is an agent to stop. The bubblewrap and socat check
runs only when a Claude Code account needs an adapter, including one added
while the runner runs, so opencode-only Linux runners start without them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated

Copy link
Copy Markdown
Collaborator Author

Landing summary (/quest-merge):

  • Codex P2 fixes in 742f833d: a cancel, a moved attempt, or a runner shutdown now aborts nix print-dev-env while the devShell builds. The bubblewrap/socat check runs only when a Claude Code account needs the sandbox, including an account added while the runner runs. Opencode-only Linux runners start without either tool. Both have tests. Codex reviewed fbfded3 with no new findings.
  • Main merged in without conflicts: kixelated/quest#217 and kixelated/quest#208. just check and just test pass on the merged head, and so does quest check from a base build.
  • Maintainer decisions: package registries are reachable for trusted revisions only. The answer-approval gate stays until the sandbox is verified on macOS.
  • opencode stays unsandboxed for now: the maintainer confirmed in the /quest-complete session that opencode jobs keep running unsandboxed until the opencode serve wrapping in quest/m0/kix/sandbox.md ships. The README says so.
  • Remaining sandbox work stays in the rewritten quest, for /quest-plan to split: trust classification of PR heads, VM devShell builds for untrusted heads, macOS verification and then lifting the gate, an Ubuntu AppArmor profile, and the opencode wrapping.

(Written by Claude Opus 5.5)

@kixelated
kixelated enabled auto-merge (squash) October 24, 2026 16:58
@kixelated
kixelated merged commit ce3e32c into main Oct 24, 2026
13 checks passed
@kixelated
kixelated deleted the quest/m0/kix/sandbox branch October 24, 2026 17:00
kixelated added a commit that referenced this pull request Oct 24, 2026
Keep #212's sandbox alongside merge jobs: both imports in the Claude
adapter and the attempt, main's systemPrompt with quest merge among the
skipped hold commands and the CONFLICTS instruction after it, the fake
Claude logging the sandbox settings and the kix tool timeout, and both
launch assertions. Take main's sandbox quest line; the merge-job quest
stays deleted. Drop an unused binding from the submodule snapshot test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant