Skip to content

Sandboxed sessions: the box - #566

Draft
ghackett wants to merge 6 commits into
mainfrom
sandbox/1-the-box
Draft

ghackett wants to merge 6 commits into
mainfrom
sandbox/1-the-box

Conversation

@ghackett

@ghackett ghackett commented Sep 11, 2026

Copy link
Copy Markdown
Member

First PR of the sandboxed-sessions chain (spec: ~/specs/collins/sandboxed-sessions.md, "PR 1, the box"), based on main after 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 .git and .claude included, so -w launches work), ~/.claude and the toolchain caches shared, the system read-only, ~/.ssh, ~/.config/gh, the keyring and every other checkout absent. $HOME inside 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 --continue of a sandboxed session types the mode again, since the CLI restores none.

  • collins/sandboxplan.py (GTK-free): a port of aibox's plan.rs + repo.rs with the same tables, ordering rule, carried() mask test, protect-check and test cases, plus Collins' additions (the resolved claude directory, sys.prefix off /usr, the shim's package parent, the mcp.json file read-only, the socket file read-write, grants and the workspace itself behind guard_path — every spelling, as written and resolved, so a symlinked ~/.ssh refuses its target too — settings.json / settings.local.json / ~/.claude/plugins / a real-file claude launcher 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>.json mode 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: lstat the destination, O_EXCL | O_NOFOLLOW temp under a random name, rename. The MIT notice is in the header.
  • collins/sandboxrun.py (stdlib only): the typed line starts with python3 <…>/collins/sandboxrun.py <plan> -- claude … (by file, so a checkout works without PYTHONPATH and an installed collins can't shadow it); it execs bwrap --args <fd> with the arguments NUL-separated over a pipe, scrubs with --unsetenv, sets HOME, and refuses rather than runs unsandboxed.
  • SessionOptions.sandbox + sandbox_plan, the wrapper prepended in new_command / resume_command and, for the --continue override, in the tab (which also appends Provider.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.
  • Settings (sandbox_new_sessions with a per-project override and the sidebar project-menu check, sandbox_bypass_permissions, sandbox_share_gh, sandbox_share_ssh, sandbox_settings_editable), the sticky sandboxed_sessions set a resume rebuilds from (a sandboxed fork reports its new id on fork-resolved so its own row is sticky too), sandbox_grants in 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_sandbox is the rule the window applies; the draft record keeps it).
  • /bg and claude attach refused 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_exit falls back to the graceful exit).
  • Tool policy stopgap: 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 next PR's policy (a sandboxed shell page, a sibling that inherits the box and stays inside the workspace) replaces the refusal.
  • Env overrides COLLINS_SANDBOX_HOME and COLLINS_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 new collins-sandboxed-sessions skill.

Departures from the spec, from the validation run and the review

  • Share GitHub CLI login binds ~/.config/gh read-only and hands the token in as GH_TOKEN, read on the host by sandboxrun with gh auth token at 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.
  • Share SSH agent binds the SSH_AUTH_SOCK socket file, not its directory: on a desktop that directory also carries the keyring's control socket.
  • Nothing joins infrastructure_cmdlines() for the two bwrap processes: they carry the CLI's argv, so proctree walks through them to the CLI itself and they are its ancestors, not its descendants (a test in test_proctree pins this).
  • mirror_trust copies hasTrustDialogAccepted for the workspace and its trusted ancestors, since the CLI honours ancestors and trust is usually recorded on the project root.
  • start_session follows 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.
  • A symlinked ~/.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.json is protected only when it exists (bwrap would create it otherwise), a project's own .claude/settings.json sits in the workspace, and the native installer's ~/.local/bin/claude symlink 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.py and test_sandboxrun.py are 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, and check_new_chat, check_worktree_fallback and check_start_session pass headlessly. A smoke run of prepare_launch + sandboxrun through the real bwrap on this machine: pid 2 inside, $HOME the seeded sandbox home, ~/.ssh and ~/.config/gh absent, settings.json read-only, SSH_AUTH_SOCK scrubbed, git status in 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

ghackett and others added 5 commits September 11, 2026 07:42
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
@ghackett

Copy link
Copy Markdown
Member Author

@claude review

@claude

claude Bot commented Sep 11, 2026

Copy link
Copy Markdown

Claude finished @ghackett's task in 5m 26s —— View job


Review: PR #566 — Sandboxed sessions: the box

  • Gather context (PR description, CLAUDE.md, diff stat)
  • Read collins/sandboxplan.py (mount plan, symlink safety, path resolution)
  • Read collins/sandboxrun.py (bwrap exec, env scrubbing)
  • Read collins/terminal.py / collins/providers.py (plan wiring, launch)
  • Read collins/mcptools.py / collins/bgstatus.py (host-tool refusal, /bg block)
  • Read collins/state.py / collins/prefs.py / collins/newchat*.py (settings, UI)
  • Check GPL modification-notice headers on edited pre-fork files
  • Post final review

This is a careful, well-built PR — the mount plan (sandboxplan.py) holds up under close reading: the "masks last" ordering is actually necessary (e.g. .gem/.cargo/.rustup/.gradle/.m2 are shared read-write for toolchains but each embeds a SENSITIVE_HOME credentials file that gets carved back out), guard_path's both-spellings symlink resolution is exercised correctly, the settings-file protection refuses rather than silently leaves a symlinked settings.json unprotected, and the GH-token/SSH-agent shares avoid ever touching disk. I didn't find a security hole in the box itself. GPL modification-notice headers on every touched pre-fork file (app.py, providers.py, terminal.py, window.py, state.py, prefs.py, sidebar.py) are correctly bumped to 2026-09-11.

Two minor findings, neither blocking:

1. A restored draft can silently lose an explicit Sandboxed choice if reopened before the bwrap probe lands.
collins/newchatview.py:288set_sandbox_choice() returns early (without setting _sandbox_touched) when the checkbox isn't visible yet:

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 (window.py:_open_new_chat_draftTerminalTab.restore_new_chat(..., sandbox=record.get("sandbox"))set_sandbox_choice). If the probe hasn't landed yet, the call is a no-op — _sandbox_touched stays False — so when set_sandbox_available() runs later (newchatview.py:274), it falls back to the project's default instead of the draft's saved choice, since it only respects an explicit choice when _sandbox_touched is set:

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 choice even while invisible (e.g. drop the get_visible() guard, or stash it and replay in set_sandbox_available), the same way model()/effort() don't gate on a picker's visibility.
Fix this →

2. Minor style nit: sandboxplan.mirror_trust (collins/sandboxplan.py:822) reaches into trust._ancestors, an underscore-prefixed (private) helper of another module. Not a bug — it's used correctly — but it crosses trust.py's own encapsulation convention; worth a public alias if this cross-module use is meant to stay long-term.

Testing: I wasn't able to run pytest/ruff myself in this review sandbox (command execution required interactive approval I don't have here), so I read the diff and the new tests (test_sandboxplan.py, test_sandboxrun.py) statically rather than executing them — the PR body's reported "3681 passed, ruff clean" is consistent with what the code shows, but I couldn't independently confirm the run.

Everything else — the /bg/attach refusal ordering, the SANDBOX_HOST_TOOLS gate landing after identity resolution but before the handler, the sticky sandboxed_sessions forward-chain, the resume/--continue wrapper wiring in terminal.py/providers.py, and the docs/CLAUDE.md updates — all checked out.

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
@ghackett

Copy link
Copy Markdown
Member Author

🤖 Answering the @claude review above, point by point. Everything here was checked against the branch as it stands, now at 30ef674 (CI green, e2e included).

1. A restored draft losing its Sandboxed choice before the probe lands — valid, fixed in 30ef674. The race was real, and a little wider than described: sandbox_choice() also read a hidden box as None, so a draft re-saved in that window lost its slot too, and so did a draft reopened on a machine with no bwrap at all — despite the docstring promising it kept the choice "for a machine that can". set_sandbox_choice and sandbox_choice now keep the choice whether or not the box is shown, and set_sandbox_available keeps a touched box as before, so the late verdict no longer puts the project's default over it. Nothing launches boxed off a hidden box: sandbox() and newchat.effective_sandbox (what Send and a sibling's default go through) both still read it as off. scripts/check_new_chat.py now drives both orders on standalone screens — a draft ticking a hidden box on a default-off project, and clearing one on a default-on project, each followed by the verdict landing. Three of those four new checks fail without the fix. The composer/new-chat and sandboxed-sessions skills and newchat.draft_record's docstring now say how this box differs from the worktree one.

2. mirror_trust reaching into trust._ancestors — agreed. The cross-module use is meant to stay, so trust._ancestors is now the public trust.ancestors (same commit); trust.is_trusted and sandboxplan.mirror_trust are its only callers.

Testingpython3 -m pytest tests/ -q: 3681 passed; ruff check collins/ tests/: clean; check_new_chat.py under the headless display: 73 passed.

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 9996b93, each with a test:

  • Symlink-follow writes in seed_home / mirror_trust — both write through _replace_private (lstat the destination, a fresh O_EXCL | O_NOFOLLOW temp, rename). Tests: test_seed_home_never_follows_a_planted_symlink, test_mirror_trust_never_writes_through_a_planted_symlink.
  • MCP escape through the host shell toolsrun_in_terminal / read_terminal / start_session are refused from a sandboxed tab (mcptools.SANDBOX_HOST_TOOLS, checked in run_tool_call after identity). Test: test_run_tool_call_refuses_host_reaching_tools_from_a_sandboxed_tab. 567 replaces this stopgap with the tool policy.
  • The unguarded workspacebuild_plan holds the workspace to guard_path. Tests: test_refuses_a_secret_as_a_workspace, test_refuses_home_and_root_as_a_workspace.
  • Grants realpath'd before the guard — grants are kept as written and guarded in every spelling. Tests: test_a_symlinked_grant_to_a_secret_is_never_bound, test_guard_path_sees_through_symlinks.
  • Generated app ids refusing every plan — only the mcp.json file is bound, never its directory. Test: test_a_generated_app_id_builds_a_plan.
  • The unimportable typed wrapper — the typed line names the launcher by file (providers.sandboxrun_path()). Tests: test_sandbox_prefix_names_the_launcher_by_file, test_the_module_runs_end_to_end_with_a_fake_bwrap.
  • A symlinked settings.json — it refuses the plan rather than aborting bwrap. Test: test_a_symlinked_settings_json_refuses_the_plan.
  • Forks not sticky — a sandboxed fork tab resolves in _fork_resolve mode and MainWindow._on_fork_resolved adds the forked id to the sticky set. test_sandboxed_sessions_are_sticky_and_follow_forwards covers the set; the tab-side resolve has no e2e of its own.
  • Resume losing --permission-modeProvider.session_flags types it after --resume and --continue. Test: test_resume_keeps_the_permission_mode_of_a_sandboxed_session.

@ghackett
ghackett added this pull request to stack #569 September 13, 2026 18:48
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.

1 participant