Conversation
collins/sandboxplan.py is a GTK-free port of aibox's plan.rs and repo.rs (MIT, notice kept in the header): the same tables, the same ordering rule (overlay home first, masks last), the same carried() test deciding which secrets to mask, the same refusal of a plan that would carry a protected path, and the same cases in tests/test_sandboxplan.py. Collins' additions: the resolved claude binary's directory, sys.prefix when not under /usr, the shim's package parent, the MCP config dir read-only and the socket file read-write, the sandbox home under ~/.local/share/collins/sandbox-home seeded once from ~/.claude.json, grants from state behind guard_sensitive, ~/.claude/settings.json and settings.local.json bound read-only over themselves unless the switch says otherwise, and the two shares as plan inputs. The output is a JSON document (bwrap_args, unsetenv, setenv, gh_token, workspace, inputs, notes) of validated absolute paths, written per launch under $XDG_RUNTIME_DIR/collins/<app id>/sandbox/<uuid>.json mode 0600. prepare_launch creates the RW_HOME_ALWAYS directories, seeds and secures the home and mirrors folder trust (hasTrustDialogAccepted for the workspace and its trusted ancestors) before writing the plan; a probe (bwrap --unshare-user --unshare-pid ... /bin/true) runs once per launch on a thread and is cached. collins/sandboxrun.py is the stdlib-only launcher the typed command starts with: it reads and validates the plan, feeds bubblewrap its arguments NUL-separated over a pipe (--args <fd>), applies the environment scrub with --unsetenv and HOME with --setenv, and hands the GitHub token in as GH_TOKEN from `gh auth token` when the plan asks for it (never on disk, never on the command line). It refuses rather than runs the command unsandboxed. COLLINS_BWRAP and COLLINS_SANDBOX_HOME are the test overrides. mcptools grows package_parent() (shared by the shim config and the plan) and inherited_permission_mode(sandboxed=) lifts the bypass cap for a sibling that will run inside a box. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBtUXcSBWv7SCfkzDN6bBd
SessionOptions gains sandbox and sandbox_plan; Provider.sandbox_prefix prepends `python3 -m collins.sandboxrun <plan> --` in new_command, resume_command and continue_command, and ClaudeProvider never attaches a sandboxed session (a daemon-hosted job runs outside any box). TerminalTab writes the plan at spawn through terminal.SANDBOX_PLANNER (set by the app, like providers.MCP_CONFIG_PATH) once the cwd is settled (a recreated worktree included), unlinks it when the shell exits, and degrades to an unsandboxed launch that says so - dropping a bypass mode the box justified - when no box can be built. tab.sandboxed is the launch record the /bg and attach guards read. state: sandbox_new_sessions with project_sandbox overrides (sandbox_for_project), sandbox_bypass_permissions, sandbox_share_gh, sandbox_share_ssh, sandbox_settings_editable; the sticky sandboxed_sessions set (written when a sandboxed launch resolves its id, carried by forward_session, read by open_session for a resume) and sandbox_grants per workspace (no UI yet). The new-chat screen grows a Sandboxed checkbox beside New git worktree, visible only when sandboxplan.available(); send-requested and new-chat-send carry it as a second bool, the draft record keeps it, and newchat.effective_sandbox mirrors effective_worktree. A sandboxed launch defaults to --permission-mode bypassPermissions (a setting). The sidebar project menu gets New sessions are sandboxed; Preferences gets a Sandbox group after Session behavior with the five switches and a status row that says whether bubblewrap was found and user namespaces work. /bg is refused for sandboxed tabs (bgstatus.BLOCK_SANDBOXED, the quit queue skips them, background_exit falls back to the graceful exit). start_session follows the project's sandbox default and grants bypass only to a sandboxed sibling. The two bwrap processes carry the CLI's argv, so proctree walks through them to the CLI itself; nothing joins infrastructure_cmdlines (test in test_proctree). The two e2e checks that emit send-requested by hand pass the new argument. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBtUXcSBWv7SCfkzDN6bBd
A Sandboxed sessions block under Starting sessions in features.md with the boundary stated plainly (filesystem and unix sockets, not IP; ~/.claude shared and writable with settings.json protected; what the two shares hand over; the CLI's own sandbox turning itself off inside), the Preferences group, a README feature line and aibox in the credits, the releases.md UNRELEASED entry, the state-table rows and env overrides in CLAUDE.md with a feature-map row, and a new collins-sandboxed-sessions skill holding the design and the measured facts, with pointers from the terminal-tab, sessions-and-sidebar, composer and MCP-tools skills. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBtUXcSBWv7SCfkzDN6bBd
The sandbox home is the agent's $HOME and shared by every box, so a path under it is attacker-controlled: seed_home and mirror_trust now lstat their destination, refuse anything but a regular file, and write a fresh O_EXCL|O_NOFOLLOW temp under a random name before the rename, so a planted `.claude.json` or `.claude.json.tmp` symlink never turns into a host write. Until the policy PR lands, run_in_terminal, read_terminal and start_session are refused from a sandboxed tab (mcptools.SANDBOX_HOST_TOOLS, checked in run_tool_call after identity): each reaches the host, and a box under bypassPermissions has no prompt in between. The workspace goes through the grant guard too (a secret, an ancestor of one, $HOME or above it is never a workspace), and guard_path checks every spelling — as written and resolved, against the home as written and resolved, and against the target of a symlinked secret — so a `~/.ssh` that points into a dotfiles checkout refuses the checkout as well; grants are kept as written rather than realpath'd first. Only the mcp.json file is bound, never its directory, which for a generated app id is the runtime dir holding the plan files (every e2e instance was refused a plan). The typed line names sandboxrun.py by file, since the tab's shell has no PYTHONPATH for a checkout and an installed collins would shadow it. A symlinked settings.json refuses the plan instead of aborting bwrap; ~/.claude/plugins and a real-file claude launcher are pinned on the same switch, and the docs state the hook surface the box does not bound. A sandboxed fork reports the forked id on "fork-resolved" so its own row is sticky; a resume or --continue of a sandboxed session types --permission-mode again (Provider.session_flags — the CLI restores none). The header's /bg button is hidden on a sandboxed tab, the SSH share binds the socket file rather than its directory, the UI reads the probe's cached verdict and the app puts the checkbox on screens built before it landed, effective_sandbox is the rule the window applies, the dead PROTECTED_REL is gone, and stale plan files are swept at startup. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBtUXcSBWv7SCfkzDN6bBd
check_token_use_prefs.py walks prefslayout.GROUPS through a HEADINGS table to read every group's title off the Preferences page; the new "sandbox" group had no entry, so the check died with a KeyError on e2e shard 1. Add the heading. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBtUXcSBWv7SCfkzDN6bBd
|
@claude review |
|
Claude finished @ghackett's task in 5m 26s —— View job Review: PR #566 — Sandboxed sessions: the box
This is a careful, well-built PR — the mount plan ( Two minor findings, neither blocking: 1. A restored draft can silently lose an explicit Sandboxed choice if reopened before the bwrap probe lands. def set_sandbox_choice(self, choice: bool | None) -> None:
if choice is None or not self._sandbox.get_visible():
return
self._sandbox_touched = True
...A draft's checkbox starts invisible until the app's async probe result arrives ( def set_sandbox_available(self, available: bool, default: bool) -> None:
self._sandbox.set_visible(bool(available))
if not self._sandbox_touched:
self._sandbox.set_active(bool(default) and bool(available))Practically this needs the user to reopen a draft within the probe's ~tens-of-ms window right after launch, so it's a narrow race — but a fix is cheap: keep the incoming 2. Minor style nit: Testing: I wasn't able to run Everything else — the |
A draft reopened before the bwrap probe's verdict landed had no Sandboxed box to show, so set_sandbox_choice dropped the saved choice without marking the box touched, and the verdict then put the project's default over it. A draft re-saved in that window, or on a machine with no box at all, lost the slot as well, since sandbox_choice read a hidden box as None. Both keep the choice now. Nothing launches boxed off a hidden box: sandbox() and newchat.effective_sandbox still read it as off. check_new_chat drives both orders on standalone screens (three of its four new checks fail without the fix). trust._ancestors becomes trust.ancestors: mirror_trust walks the same chain from sandboxplan, so it is part of the module's surface now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y5MdxvbYLLCkXT1AkVaCQS
|
🤖 Answering the 1. A restored draft losing its Sandboxed choice before the probe lands — valid, fixed in 2. Testing — For completeness, the fixes from the two internal review passes that ran before this review (several appear in its "everything else checked out" list) are all on the branch, landed in
|
First PR of the sandboxed-sessions chain (spec:
~/specs/collins/sandboxed-sessions.md, "PR 1, the box"), based onmainafter the PR 0 spike landed. Grants exist in state with no UI yet; the footer chip, the sandboxed shell page and the tool policy are the next PR, packaging and the e2e checks the one after.What it does
A session can run inside a bubblewrap filesystem sandbox: the workspace read-write (the enclosing repository's
.gitand.claudeincluded, so-wlaunches work),~/.claudeand the toolchain caches shared, the system read-only,~/.ssh,~/.config/gh, the keyring and every other checkout absent.$HOMEinside is~/.local/share/collins/sandbox-home/, seeded once from~/.claude.json, with folder trust mirrored in before every launch. Inside, the session starts with--permission-mode bypassPermissions(a setting), which is the point — and a resume or--continueof a sandboxed session types the mode again, since the CLI restores none.collins/sandboxplan.py(GTK-free): a port of aibox'splan.rs+repo.rswith the same tables, ordering rule,carried()mask test, protect-check and test cases, plus Collins' additions (the resolvedclaudedirectory,sys.prefixoff/usr, the shim's package parent, themcp.jsonfile read-only, the socket file read-write, grants and the workspace itself behindguard_path— every spelling, as written and resolved, so a symlinked~/.sshrefuses its target too —settings.json/settings.local.json/~/.claude/plugins/ a real-fileclaudelauncher bound read-only over themselves unless the switch says otherwise, the two shares). JSON output with validated absolute paths; the plan file lives under$XDG_RUNTIME_DIR/collins/<app id>/sandbox/<uuid>.jsonmode 0600, is unlinked when the tab's shell exits, and stale ones are swept at startup. Every write into the sandbox home (the agent's$HOME, shared by every box) is symlink-safe:lstatthe destination,O_EXCL | O_NOFOLLOWtemp under a random name, rename. The MIT notice is in the header.collins/sandboxrun.py(stdlib only): the typed line starts withpython3 <…>/collins/sandboxrun.py <plan> -- claude …(by file, so a checkout works without PYTHONPATH and an installed collins can't shadow it); it execsbwrap --args <fd>with the arguments NUL-separated over a pipe, scrubs with--unsetenv, setsHOME, and refuses rather than runs unsandboxed.SessionOptions.sandbox+sandbox_plan, the wrapper prepended innew_command/resume_commandand, for the--continueoverride, in the tab (which also appendsProvider.session_flags, the permission mode, after the options settle); the tab writes the plan at spawn once the cwd is settled and degrades to an unsandboxed launch that says so (dropping a bypass mode) when no box can be built.sandbox_new_sessionswith a per-project override and the sidebar project-menu check,sandbox_bypass_permissions,sandbox_share_gh,sandbox_share_ssh,sandbox_settings_editable), the stickysandboxed_sessionsset a resume rebuilds from (a sandboxed fork reports its new id onfork-resolvedso its own row is sticky too),sandbox_grantsin state, the Preferences group after Session behavior with a status row, the new-chat Sandboxed checkbox beside New git worktree (visible only when the bwrap probe passes — the UI reads the cached verdict and the app puts the box on screens built before it landed;newchat.effective_sandboxis the rule the window applies; the draft record keeps it)./bgandclaude attachrefused for sandboxed sessions (bgstatus.BLOCK_SANDBOXED; the header button is hidden on a sandboxed tab since the refusal never clears; the quit queue skips them;background_exitfalls back to the graceful exit).run_in_terminal,read_terminalandstart_sessionare refused from a sandboxed tab (mcptools.SANDBOX_HOST_TOOLS, checked inrun_tool_callafter identity) — each reaches the host, and a box under bypassPermissions has no prompt in between. The next PR's policy (a sandboxed shell page, a sibling that inherits the box and stays inside the workspace) replaces the refusal.COLLINS_SANDBOX_HOMEandCOLLINS_BWRAP; docs (features.md block with a plain statement of what the box does not bound, README line and credits, releases.md, the state table and feature map in CLAUDE.md) and a newcollins-sandboxed-sessionsskill.Departures from the spec, from the validation run and the review
~/.config/ghread-only and hands the token in asGH_TOKEN, read on the host bysandboxrunwithgh auth tokenat launch (never written to disk). A read-write bind alone yields "token in default is invalid" wherever gh keeps the token in the keyring, which the box cannot reach.SSH_AUTH_SOCKsocket file, not its directory: on a desktop that directory also carries the keyring's control socket.infrastructure_cmdlines()for the two bwrap processes: they carry the CLI's argv, soproctreewalks through them to the CLI itself and they are its ancestors, not its descendants (a test intest_proctreepins this).mirror_trustcopieshasTrustDialogAcceptedfor the workspace and its trusted ancestors, since the CLI honours ancestors and trust is usually recorded on the project root.start_sessionfollows the project's sandbox default for a sibling spawned from an unsandboxed tab; from a sandboxed tab it is refused for now (above). Bypass is granted to a sibling only when it is sandboxed.~/.claude/settings.json(a dotfiles setup) refuses the plan rather than aborting bwrap or leaving the file unprotected; the edit switch lifts it.settings.local.jsonis protected only when it exists (bwrap would create it otherwise), a project's own.claude/settings.jsonsits in the workspace, and the native installer's~/.local/bin/claudesymlink sits in a shared tree — the box bounds the filesystem, not the hook surface, and the docs say so.Testing
python3 -m pytest tests/ -q(3681 passed;test_sandboxplan.pyandtest_sandboxrun.pyare new, including a real box over a real plan that skips where the probe fails, planted-symlink cases for the sandbox-home writes, symlinked secrets and settings, a generated app id, and the launcher run as a bare script with no PYTHONPATH),ruff check collins/ tests/clean, andcheck_new_chat,check_worktree_fallbackandcheck_start_sessionpass headlessly. A smoke run ofprepare_launch+sandboxrunthrough the real bwrap on this machine: pid 2 inside,$HOMEthe seeded sandbox home,~/.sshand~/.config/ghabsent,settings.jsonread-only,SSH_AUTH_SOCKscrubbed,git statusin the workspace working. No screenshots: the only new UI is a checkbox, a menu item and a Preferences group.🤖 Generated with Claude Code
https://claude.ai/code/session_01GBtUXcSBWv7SCfkzDN6bBd