Conversation
|
@claude review |
|
Claude finished @ghackett's task in 6m 23s —— View job Review of #567
SummaryThis is a well-scoped, carefully-reasoned implementation of PR 2 of the sandboxed-sessions chain. The policy design in Two smaller findings: 1. CLAUDE.md pins ruff rules 2. def _open_shell(self, *_args) -> None:
self.get_popover().popdown()
def open_shell() -> bool:
self._on_open_shell()
return GLib.SOURCE_REMOVE
GLib.idle_add(open_shell, priority=GLib.PRIORITY_DEFAULT)
I could not run |
|
🤖 Both addressed in The chip's shell open now waits the dock's own settle ( The blank line is dropped. One correction on the premise, for the record: ruff as this repo pins it (0.16.4, rules |
1cc35c4 to
682f0a8
Compare
The GTK-free half of the chip-and-policy PR (~/specs/collins/ sandboxed-sessions.md, "PR 2"): everything the footer chip, the sandboxed panel shell and the session tools decide, with unit tests. sandboxplan gains the read-back side of a launch: load_plan (the launched plan file, validated through sandboxrun.read_plan plus the inputs slice), plan_reaches (the "inside the workspace or a grant" rule, as written and resolved), derive_plan (the parent's plan re-issued for a sibling's directory: only --chdir moves, so the sibling sees exactly what the parent sees) and SandboxHost, the per-instance host object the tabs get: prepare_launch, the workspace's grants (allow runs the guard first, so a secret is refused whether or not it exists, then "already inside the workspace" and "not a directory"; revoke), plan_stale (the launched plan's policy inputs against what the state would build now) and derive (a sibling's plan file). mcptools: run_tool_call hands every handler a third argument, Collins' own reading of whether the calling tab is sandboxed, and the blanket SANDBOX_HOST_TOOLS refusal goes; tool_shells (a sandboxed session sees only sandboxed shells), sibling_sandboxed (a sandboxed parent's sibling is always sandboxed) and sibling_cwd_refusal carry the policy; terminal_reply takes the shell's kind so a sandboxed shell is named the way its tab is; the three tools' descriptions say what changes inside a box. proctree.inner_shell_pid finds the shell inside a box below the launcher and the two bwrap processes, which is what the foreground test has to compare against (bash inside takes the pty's foreground for itself, and the kernel reports its group in host pids); measured under a real box. providers.sandboxed_shell_argv is the sandboxed shell's argv; panellayout keeps a shell's "sandboxed" flag. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBtUXcSBWv7SCfkzDN6bBd
The widgets and wiring over the previous commit's rules. sandboxchip.SandboxChip is the footer's Sandboxed chip (a shield, a filled-path icon; a MenuButton with a TOP popover, built like the model chip and placed first in the row, shown only once a launch settled with a plan): the launched plan read back, never re-derived (the workspace, the two shares, settings protection), the workspace's grants with a remove button each and an "after restart" tag where the running box lacks one, Allow a directory... through Gtk.FileDialog.select_folder with the guard's verdict as a toast, Restart to apply when the plan the state would build differs from the launched one, and Sandboxed shell. The chip takes callables and knows no tab; a new "toast" signal on the tab reaches the window's toast overlay. TerminalTab.restart_sandboxed asks the CLI to exit, nudges it as the window's close does, answers the worktree keep/remove dialog, and once the shell has the terminal back types the launch command again with a fresh plan (the command building is now _launch_command, shared with _finish_spawn; a session id wins over a --continue override so a resolved tab resumes itself). Never for a fork. _sandbox_options adopts a plan the options already carry (a sibling's derived one) instead of writing its own. terminal.SANDBOX_PLANNER becomes terminal.SANDBOX_HOST. PanelTerminal(plan_lookup=...) is the sandboxed shell: the same page class and page_kind, so history, busy checks and layout persistence hold; it spawns python3 sandboxrun.py <plan> -- $SHELL with a queued cd to the agent's directory, is titled "Sandboxed shell N", wears the shield, saves "sandboxed": true, compares the foreground against the shell inside the box, and starts nothing when there is no plan. Ctrl+J never binds to one; the strip's tab menu offers New sandboxed shell while there is a plan; open_shell_page and open_sandboxed_shell open one beside the last shell or in a strip of its own. app.py: SandboxHost installed at startup; read_terminal and run_in_terminal filter through tool_shells and open a sandboxed shell for a sandboxed caller; start_session derives the parent's plan for a sandboxed parent's sibling (refusing a cwd outside the box), decides the sibling's sandbox through sibling_sandboxed, and releases a derived plan no tab adopted. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBtUXcSBWv7SCfkzDN6bBd
scripts/check_sandbox_policy.py drives a real App through a fake bubblewrap (COLLINS_BWRAP records the --args payload and execs the command; exit 0 for the probe): the chip on the sandboxed tab, both terminal tools from the box reaching only a Sandboxed shell, Ctrl+J keeping the user's shell, the layout round trip, a grant refused by the guard or recorded and making the launched plan stale, the chip's Restart to apply and the relaunch in the same shell binding the grant, the remove button, and a sibling derived inside the workspace or refused outside it. Staged under ~/.cache with HOME moved into the scratch tree (/tmp is shared into every box; a real home would get the RW_HOME_ALWAYS directories). features.md, the README line and releases.md describe the chip, the grants, the restart, the sandboxed shell and the tool policy; the sandboxed-sessions skill gets the host object, the chip, the restart, the shell and the policy, the terminal-tab, session-mcp-tools and panel-dock skills their pointers, AGENTS.md's feature map the new module. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBtUXcSBWv7SCfkzDN6bBd
The per-launch plan lives beside the socket under $XDG_RUNTIME_DIR, and mcptools.runtime_dir falls back to the temp directory when there is no runtime dir to be had -- a Collins started outside a desktop login session, and CI. But /tmp is shared read-write into every box, so a plan there is inside the sandbox: the protect-check refused every launch with "the plan directory must stay outside the sandbox, but /tmp carries it", and check_sandbox_policy.py failed on CI alone (it passed locally, where a login session sets XDG_RUNTIME_DIR). plan_dir falls back to $XDG_STATE_HOME/collins/sandbox/<app id> instead, which no box carries -- the state directory is protected wholesale already. Plans there outlive a reboot rather than dying with it, which the sweep at startup covers; each is still mode 0600 and unlinked when its tab's shell exits. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bcw8i8YAH16Uripfc8GLKg
Six findings from the review of this PR. Restart to apply is no longer offered where it wouldn't restart anything. can_restart_sandboxed refused only a fork; a tab whose id the transcript resolver hadn't bound yet fell through _launch_command to new_command, so the chip's button would have exited the conversation and started a fresh session in its place. A tab running a plan adopted from another session (a start_session sibling) is refused too: its box was built for its parent's workspace, and the rebuild takes this tab's own cwd, which would silently narrow it. The chip says so in a caption where the row would be, so the "after restart" tags above it aren't left unexplained. _launch_command takes the override first again, as _finish_spawn did before this branch; only the restart puts a resolved id ahead of a --continue. A tab handed both (check_notifications' stand-in, which means to run `sleep 300`) was typing claude --resume instead. A sandboxed panel shell now records the plan its box was built from (PanelTerminal.sandbox_plan). --die-with-parent ties that box to the shell's own pty, not to the session, so a Restart to apply left a shell running in the pre-restart box -- holding a directory the user may have revoked in the very change they restarted for -- and the agent could still reach it. mcptools.tool_shells takes the session's current plan and hands over only the shells that run in the box the session runs in; the tab notes the change in the ones it leaves behind, which stay on screen for the user. _mcp_start_session releases the sibling's derived plan file on the permission_mode and model refusals, as its other refusals already did: a plan file describes a box in full and nothing was going to launch from that one. PanelStrip always calls its shell factory with sandboxed=, rather than only when asking for a sandboxed shell: a persisted layout carrying "sandboxed": true restored into a strip whose factory took no argument raised mid-restore. A factory that can't build one has to say so -- a plain shell under that title would run unconfined -- so the four check scripts' fakes take and ignore it. The e2e check covers the resume branch of the restart (its CLI stand-in writes no transcript, so the tab had no id to resume and the old code's new_command path was what it exercised), the caption in place of the button, and the shell left in the old box leaving the agent's reach. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bcw8i8YAH16Uripfc8GLKg
Review follow-up. Opening a panel page is dock surgery, and the rule is that an unbidden one waits a beat rather than riding the idle cascade of the event that asked for it -- that cascade is what segfaulted GTK's Wayland backend from the footer chip before. The chip's Sandboxed shell row was scheduling its open with idle_add; it now uses the dock's own settle delay, like _on_hub_pr_attached and _autodock_attachments. It still lands after the popover's focus restore, which is what the idle was for. Also drops a third blank line before a test in test_providers.py. ruff as this repo configures it doesn't flag E303 (the whitespace rules are preview-only there, and `ruff check collins/ tests/` passes either way), but two is what the rest of the file uses. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bcw8i8YAH16Uripfc8GLKg
The check stages under ~/.cache/collins-e2e, which outlives the run, and removed its tree only after a clean app.run(): an exception while staging, or the 90 s watchdog's os._exit, left one behind per attempt. An atexit hook now clears it on every Python exit, and the watchdog clears it before os._exit, which skips atexit. Raised by the review of the packaging PR against check_sandbox_launch.py, which has the same shape. The sandboxed-sessions skill no longer calls the footer chip the next PR: this is the PR that adds it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y5MdxvbYLLCkXT1AkVaCQS
682f0a8 to
d20de31
Compare
|
🤖 Two updates since the reply above, now that the stack has been rebased onto the updated 566:
CI is green on |
0497754 dropped a blank line in tests/test_providers.py on 2026-09-12 and left its notice at 2026-09-11. Found by auditing every pre-fork file the sandboxed-sessions stack changes against main; the others match the commit that last touched them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y5MdxvbYLLCkXT1AkVaCQS
Stacked on #566 (
sandbox/1-the-box); PR 2 of the sandboxed-sessions chain (~/specs/collins/sandboxed-sessions.md, "PR 2, the chip and the policy"): the footer chip with the per-workspace grants and Restart to apply, the sandboxed panel shell, and the policy for what a sandboxed session may ask Collins to do throughrun_in_terminal,read_terminalandstart_session.The chip
A Sandboxed chip (a shield, a filled-path icon in
data/icons/…/actions/) leads the footer of a sandboxed tab and is absent everywhere else. It is aGtk.MenuButtonwith a TOP popover built like the model chip (collins/sandboxchip.py, which knows no tab or app: it takes callables), filled on every show from the plan the session was launched with (sandboxplan.load_plan, never re-derived): the workspace, whether the GitHub CLI login and the SSH agent went in, whether~/.claude/settings.jsonis protected. Under that, the workspace's allowed directories from state.json, each with a remove button and an "after restart" tag when the running box lacks it, Allow a directory… throughGtk.FileDialog.select_folder(the guard's verdict — a secret, an ancestor of one,$HOME,/, Collins' own state, the sandbox home, the workspace itself, not a directory — goes out as a toast, as does a success, since the modal chooser closes the popover), Restart to apply when the plan the state would build now differs from the launched one on the policy inputs (grants, the two shares, settings protection —SandboxHost.plan_stale), and Sandboxed shell.Restart to apply is offered only where it would restart something: never on a fork (the tab holds its origin's id), never before the transcript resolver has bound an id (a rebuild would replace the conversation with a fresh session), and never on a tab running a plan adopted from another session (a
start_sessionsibling: its box was built for its parent's workspace, and a rebuild takes this tab's own cwd, which would narrow it). Where the row can't be offered and the plan is stale the chip says so, so the "after restart" tags above it aren't left unexplained. It isTerminalTab.restart_sandboxed: the CLI's exit keystroke, a poll that re-nudges as the window's graceful close does and answers the worktree keep/remove dialog, then — once the shell has the terminal back — the launch command typed again with a fresh plan, the old one released: the same shell, tab and row. The command building is now_launch_command, shared with_finish_spawn; a resolved session id wins over a--continueoverride so a resolved tab resumes itself. Never for a fork tab (it holds its origin's id; a resume would fork it again).The sandboxed shell
PanelTerminal(number, plan_lookup=…)is the same page class withsandboxedTrue:page_kindstaysshell, so history, busy checks, close confirmations and layout persistence hold (page_stateadds"sandboxed": true, whichpanellayoutkeeps and the dock's restore hands back tostrip.new_shell(sandboxed=True)). It spawnspython3 sandboxrun.py <plan> -- $SHELL— the launcher the session's own typed line starts with, so the shell runs in exactly the session's box — with a queuedcdto the agent's directory when it lies inside the workspace, is titled Sandboxed shell N on the dock-wide numbering and wears the shield. Busy detection compares the pty's foreground against the shell inside the box (proctree.inner_shell_pid, the first descendant below the launcher and the twobwraps): bash inside takes the foreground for itself and the kernel reports its group in host pids — measured under a real box on this machine (idle at the prompt, busy duringsleep, idle after Ctrl+C). A shell keeps the box it spawned in (sandbox_plan, the plan file it launched through):--die-with-parentties that box to the shell's own pty rather than to the session, so a Restart to apply leaves it running in the pre-restart box — which may still hold a directory the user revoked in the very change they restarted for. The tab says so in its scrollback andmcptools.tool_shellsstops handing it to the agent, so it stays the user's and leaves the box's reach. Ctrl+J never binds to one; the strip's tab menu offers New sandboxed shell while there is a plan; with no plan (a layout restored before the launch settled, or into an unsandboxed tab) nothing is spawned and the next show retries.The policy
mcptools.run_tool_callnow hands every handler a third argument,sandboxed— Collins' own reading of the calling tab, never the caller's word — and the blanketSANDBOX_HOST_TOOLSrefusal from PR 1 goes.read_terminalandrun_in_terminalfilter throughmcptools.tool_shells: a sandboxed session sees only sandboxed shells (named Sandboxed shell N in the reply) andrun_in_terminalopens one when none is idle — the user's own Ctrl+J shell is never typed into or read from inside a box.start_sessiondecides the sibling's sandbox throughmcptools.sibling_sandboxed(a sandboxed parent's sibling is always sandboxed; the unit test says an unsandboxed sibling from a sandboxed parent is never possible, in those words) and, for a sandboxed parent, derives the sibling's plan from the parent's launched one (sandboxplan.derive_plan: only--chdirmoves, so the sibling sees exactly what the parent sees, with the grants, shares and settings protection as launched); a cwd outside the plan's workspace or grants is refused with the reason (sandboxplan.plan_reaches). The derived plan travels inSessionOptions.sandbox_plan, the sibling's tab adopts and releases it, and a spawn that never made a tab releases it.bypassPermissionsis granted to a sibling, explicit or inherited, only when it is sandboxed — which under this rule is exactly when the parent is.sandboxplan.SandboxHost(app_id, state)replacesterminal.SANDBOX_PLANNERas the tabs' host object (prepare_launch,grants/allow/revoke,plan_stale,derive).Tests
Unit:
load_plan/plan_reaches/derive_planand the host's allow, revoke, stale and derive (test_sandboxplan); the handler flag,tool_shells,sibling_sandboxed, the reply's shell kind (test_mcptools);inner_shell_pidover a real process tree (test_proctree); the layout flag (test_panellayout); the shell argv (test_providers). E2E:scripts/check_sandbox_policy.py— a real App and a session launched sandboxed through a fakeCOLLINS_BWRAPthat records the--argspayload and execs the command: the chip, both terminal tools from the box, Ctrl+J, the layout round trip, a grant refused / recorded / stale / restarted-into / revoked, a sibling derived inside and refused outside (78 checks, 17 s). Staged under~/.cache/collins-e2ewithHOMEmoved into the scratch tree.python3 -m pytest tests/ -q: 3697 passed.ruff check collins/ tests/: clean. The e2e check passes under the headless display.Deviations from the spec
$XDG_STATE_HOME/collins/sandbox/<app id>when there is noXDG_RUNTIME_DIR(a Collins started outside a desktop login session, and CI):mcptools.runtime_dirfalls back to the temp directory, which every box shares read-write, and a plan the box can reach is exactly what the protect-check refuses — it refused every launch there, which is what the e2e check caught on CI alone.🤖 Generated with Claude Code
https://claude.ai/code/session_01GBtUXcSBWv7SCfkzDN6bBd