feat(agent): add PermissionModePlan for interactive read-only planning - #853
feat(agent): add PermissionModePlan for interactive read-only planning#853euxaristia wants to merge 19 commits into
Conversation
|
Warning Review limit reached
Next review available in: 24 minutes Your organization has reached its usage spending cap. Adjust your spending cap in the billing tab. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (25)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…tests string(permissionMode) already yields "plan" / "spec-draft", so the if/else recomputing modeName in the denial message was dead branching on the same values. Also add plan-mode coverage mirroring three of the four existing spec-draft regression tests: advertised tool set, and denied write_file/bash calls. The fourth (submit-and-stop review control) has no plan-mode analog, since plan mode has no submit tool.
… registry omits it request_permissions is dispatched by name in executeToolCall before the registry-based ToolAdvertised gate runs, so that gate only helps when the tool happens to be present in the caller's registry. A plan- or spec-draft-mode registry that simply omits the tool (rather than registering it as denied) let the call fall through to a real turn/session-scoped permission grant, defeating the read-only boundary. Deny it unconditionally at the top of executeRequestPermissions for both read-only modes instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ctive Plan mode promises a read-only turn, but sessionStart/sessionEnd fire on every run and beforeTool/afterTool fire around allowed read calls, and all four execute configured host commands outside the advertised-tool and sandbox gates — so a project hook could mutate the workspace or spawn a process from a session that advertises it cannot. Gate all four dispatch points on the run's permission mode, with a regression test asserting no hook command launches during a plan-mode run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
toolAdvertisedInPlan whitelisted ask_user and update_plan by name alone, so a caller could register a mutating tool under either name and have it advertised and executed in plan mode. Validate every tool against its Safety() instead. Also exclude lsp_navigate, which is marked SideEffectRead but lazily spawns a real language-server process, contradicting plan mode's read-only guarantee. Add regression tests for both.
Plan mode still suppresses executable hooks so a read-only planning turn cannot spawn host processes via session or tool hooks. Spec-draft keeps the existing trust model: project hooks fire when the workspace (or its worktree trust root) is trusted. Unconditionally suppressing hooks in spec-draft broke TestExecSpecWorktreeInheritsTrustEndToEnd for trusted worktrees under --use-spec --worktree.
Expand the name-only spoof regression to both update_plan and ask_user, and add an execution-path denial for lsp_navigate so plan mode cannot spawn language servers even when a call still arrives.
… visibility tool_search resolved and ranked deferred tools by EnabledTools/DisabledTools only, never by the run's permission-mode visibility. tool_search itself is already denied at dispatch in plan/spec-draft (its Safety carries no side effect, so it fails the same SideEffect==Read advertisement gate direct calls use), so this was not reachable through the normal Run() path today. But it is a real landmine: if that outer gate is ever loosened independently (e.g. tool_search's no-side-effect Safety is judged advertisable), the loader had no gate of its own and would hand a deferred write/mutator tool's name, description, and full schema straight to a plan/spec-draft model. Mirror agent.ToolAdvertised's plan/spec-draft branches inside the tools package (toolAdvertisedForPermissionMode, next to the existing toolAllowedByFilters mirror that avoids the same import cycle) and apply it alongside the operator filters in visibleDeferredTools and visibleEagerToolNames. Added unit tests in tool_search_test.go for both modes, and an end-to-end agent test that force-calls tool_search in plan mode and asserts no schema leaks. Verified by reverting tool_search.go and confirming the new tests fail (one shows load_tools resolving to the mutator's name); restored and confirmed they pass. Also confirmed via a temporary probe that if plan mode's outer advertisement gate is loosened, this filter is what actually stops the leak.
Do not advertise or load re-registered control tools by name alone in spec-draft mode. ask_user must be SideEffectRead+Allow and submit_spec must be SideEffectWrite+Allow, matching the real tools. Apply the same filter in tool_search and add spoof regression tests. Refs Gitlawb#642
Address the P1 finding that PermissionModePlan was documented but never selected by /plan, zero exec, or ACP mode selectors. /plan on|off now toggles the session permission mode (restoring the prior mode on off), zero exec --plan selects plan for a run, and ACP advertises plan as a client-selectable mode. Integration coverage for each entry path.
Worktree preparation runs in runExec before the plan permission mode is assigned, so `zero exec --plan --worktree` could still trigger workspace mutation ahead of the read-only gate. Reject the combination during option validation, alongside the existing --use-spec/--skip-permissions- unsafe conflict checks, so no worktree prep can occur. Addresses a coderabbitai finding on PR Gitlawb#642.
The spoofed-control-tool regression only asserted on the description string; Parameters() exposed no distinctive schema marker, so a regression that leaked the schema without the description would still have passed. Add a spoofed_secret property to the test tool's schema and assert it's absent from result.Output alongside the description. Addresses a coderabbitai finding on PR Gitlawb#642.
/plan on only flips the agent permission mode, which gates agent tool calls. Local TUI commands that run entirely inside the TUI process bypass that gate: /rewind restores workspace files from a checkpoint, /export writes a transcript to disk, and /sandbox-setup spawns a native host process. Add a shared plan-mode guard at the start of dispatchCommand (mirroring the existing BTW-unavailable guard) that rejects these three commands while permissionMode is agent.PermissionModePlan, with regression coverage proving each is blocked with no mutation/process spawn in plan mode and unaffected outside it. Addresses a coderabbitai finding on PR Gitlawb#642.
…-only subagent mode
… model field and --plan permission mode conflict
…CP mode changes Parse permissionMode in resolveExecPermissionMode, acquire turnMu.Lock in ACP handleSetMode to serialize mode changes with active turns, and block MCP subcommands in TUI plan mode. Refs Gitlawb#642
…Tool/NewLSPNavigateTool wrappers Those were thin unscoped wrappers around the Scoped variants, deleted upstream in Gitlawb#706 since nothing else called them directly. Only these tests still did; switch to the Scoped calls main's own tests already use.
2fb5cb5 to
b88b4e3
Compare
There was a problem hiding this comment.
Nice piece of work on the core gate I tried hard to break it and couldn't. Four things need fixing before this lands though, and one of them (the hook veto) is a real fail-open.
What I verified holds
The dispatch gate is load-bearing, not decorative. I mutated executeToolCall to drop || permissionMode == PermissionModePlan and five tests went red, including TestPlanModeRejectsNameOnlySpoofedControlTools/update_plan, where a read/allow-shaped spoof registered under a control-tool name actually executed ("spoofed write") — so the Safety-shape check on ask_user/update_plan/submit_spec is doing real work, not just tidying. Stubbing hooksSuppressed to return false and deleting the request_permissions early denial each turned their named test red too.
I also drove it end-to-end against a live model: zero exec --plan with a prompt explicitly instructing it to create a file by any means and to try tool_search and request_permissions. It tried update_plan, glob, list_directory, grep, skill, read_file, ask_user, reported no write path, and the workspace was unchanged. Plan mode's tool list from the built binary is exactly ask_user glob grep list_directory read_file read_minified_file skill update_plan. --use-spec still lists submit_spec, so the spec-draft Safety-shape tightening didn't break that flow.
Package tests: internal/agent, internal/tools, internal/acp, internal/specialist all pass. internal/cli fails only on TestBuildServeScopeKeepsLexicalPaths (Windows symlink privilege, unrelated), internal/tui only on TestGitSweepCmdAgainstRealRepo, which passes in isolation — also unrelated.
Blocking
1. Plan mode silently bypasses beforeTool hook vetoes — internal/agent/loop.go:1791
hooksSuppressed cuts all four dispatch sites, but dispatchBeforeTool isn't only a side-effect vector: a non-zero exit is a veto that blocks the tool. Suppressing it removes an operator's deny gate in the one mode where reading is the only thing the agent does.
I proved it with a throwaway test (deleted): a beforeTool hook matching read_file running a command that exits non-zero, over a workspace containing secret.txt.
- Auto mode:
Error: "read_file" was blocked by hook "zero.veto": go definitely-not-a-go-subcommand: unknown command— the veto works, so the setup is sound. - Same run with
PermissionMode: PermissionModePlan:File: secret.txt (1 lines)\n\n1 | SUPERSECRET.
So a project whose beforeTool hook blocks reads of .env or a secrets directory loses that protection the moment someone types /plan on. This has to fail closed, not open. Simplest correct shape: keep suppressing execution, but if a beforeTool hook is configured whose matcher could match a tool in this run, deny the tool (or refuse to enter plan mode and say why) rather than running it unguarded. afterTool/sessionStart/sessionEnd are advisory and fine to suppress as-is.
2. --permission-mode propagation silently strips swarm members of their write tools — internal/specialist/exec.go:271 and :326
BuildArgs emits --auto <memberAwareAutonomy> and then unconditionally appends --permission-mode <parent mode>. But resolveExecPermissionMode (internal/cli/exec_tools.go:76) short-circuits on --permission-mode and never reads --auto, so the member rung is thrown away. With the built binary:
zero exec --auto member --list-tools
apply_patch ask_user bash edit_file exec_command glob grep list_directory lsp_navigate
read_file read_minified_file request_permissions skill update_plan web_fetch write_file write_stdin
zero exec --auto member --permission-mode auto --list-tools
ask_user exec_command glob grep list_directory lsp_navigate read_file read_minified_file
request_permissions skill update_plan web_fetch write_stdin
internal/swarm/launcher_specialist.go:56 sets MemberAutonomy: true, and a BuildArgs probe confirms parent=auto/member=true emits --auto member ... --permission-mode auto. So every swarm member spawned by a non-unsafe parent loses write_file, edit_file, apply_patch and bash — they can't build anything.
Worth calling out why this slipped through: TestBuildArgsMemberAutonomyEmitsMember asserts the --auto member pair is present in the args, not that the child resolves to MemberAuto. It stays green while the guarantee in its name is broken. Whatever the fix, please add a test that asserts the resolved mode.
Also, Task and the swarm spawn tool are both SideEffectShell, so neither is ever advertised in plan or spec-draft — which means this propagation buys plan mode nothing and only affects the modes it breaks. The narrow fix is to append --permission-mode only when the parent mode is plan or spec-draft.
3. Every --plan combination guard is bypassable via --permission-mode plan — internal/cli/exec_parse.go:455-470
The four guards key off options.plan (the flag), not the resolved mode, and the new --permission-mode flag reaches PermissionModePlan directly. Your own comment on the worktree guard says worktree prep "runs before the plan permission mode is assigned, so it would happen even under --plan's read-only, no-side-effects promise" — and that's exactly what I got. In a scratch git repo:
$ git worktree list
.../wtrepo d4c87ef [master]
$ zero exec --permission-mode plan -w planwt --list-tools
Tools visible to model:
ask_user ... (the plan-mode read-only set)
$ git worktree list
.../wtrepo d4c87ef [master]
C:/Users/.../zero/worktrees/zero-worktree-wtrepo-.../planwt d4c87ef (detached HEAD)
zero exec --plan --worktree wtx correctly errors. Move the checks off the flag and onto the resolved mode (after resolveExecPermissionMode, before worktree prep at exec.go:204), or reject --permission-mode plan from the CLI surface entirely and keep --plan as the only door.
4. --permission-mode ask widens a headless child's tool set — same lines as (2)
An ask-mode parent now spawns --auto low --permission-mode ask. ToolAdvertised returns true for everything in Ask, and headless exec wires no OnPermissionRequest, so no prompt can ever be shown:
zero exec --auto low --enabled-tools write_file,bash,read_file --list-tools -> read_file
zero exec --permission-mode ask --enabled-tools write_file,bash,read_file --list-tools -> bash read_file write_file
The registry still refuses prompt-permission tools without PermissionGranted (internal/tools/registry.go:204), so I did not prove execution — this is exposure, not a hole. But a specialist manifest declaring tools: [write_file, bash] under an ask parent is now one sandbox auto-allow away from running what the parent's own mode never advertised. Restricting the propagation to plan/spec-draft (see 2) fixes this too.
Smaller stuff
internal/acp/agent.go:385—handleSetConfigOptionchanges the mode with noturnMu, whilehandleSetModeat:352now takes it. Both paths are advertised (modeStateandconfigOptionsboth list plan), so the lock covers one of two doors. Also notesess.currentMode()is read once per turn at:259, so a mid-turn flip never touched the running turn — if the lock isn't buying anything, dropping it avoids makingsession/set_modeblock for the full duration of a long turn.internal/cli/exec.go:593— the unsafe warning misattributes the cause.zero exec --permission-mode unsafe -o stream-json --prompt "say hi"emitsUnsafe permissions are active for this run because --auto high was passed.when neither flag was passed.internal/cli/exec_tools.go:90— the error says "Expected plan, spec-draft, auto, ask, or unsafe" but the switch also takesmember,member-auto,member_auto,spec_draft,high.--permission-modeisn't in--help(internal/cli/app.go:1361) or completions (internal/cli/completions.go:27) even though it outranks--auto. If it's meant to be internal plumbing, say so and consider hiding it; if it's public, document it./initin plan mode (internal/tui/init_command.go:17) launches a turn whose entire job is writing AGENTS.md, which plan mode then denies. Either addcommandInittoplanModeCommandUnavailableor have it say why.lsp_navigateis excluded from plan for spawning a language server but is still listed under--use-spec. You call spec-draft deliberately unchanged, so this is just a note, not a request.
Happy to re-review as soon as 1-3 are addressed.
Same note as on #855: everything I found is above in one pass, including the smaller stuff, so there's no second round queued behind this. Two of these are classes your own #857 list names — a guard that fails open, and help text wider than what shipped.
…ards Keep beforeTool deny gates active under plan mode so hooksSuppressed no longer fails open, and stop propagating --permission-mode for auto/ask/member children so swarm members keep write tools. Apply --plan combination rejects to --permission-mode plan as well. Refs Gitlawb#853
|
Addressed review:
Also: ACP set-config lock, unsafe warning attribution, help/completions for |
Vasanthdev2004
left a comment
There was a problem hiding this comment.
Approving. Re-checked on 792599db, and you fixed the fail-open properly rather than taking the cheaper option I offered.
I'd said the hooksSuppressed comment claimed something the code didn't do, and that correcting the comment would be enough. You kept dispatchBeforeTool running and made it veto on non-zero instead — closing the gap rather than documenting it. That's the better call and I'm glad you made it.
TestPlanModeHonorsBeforeToolVeto is a genuinely well-built test. I neutralised the veto at loop.go:1331 and it fails with:
plan mode failed open: beforeTool veto was skipped and secret leaked:
"File: secret.txt (1 lines)\n\n1 | SUPERSECRET"
It asserts the consequence — the secret actually reaching the model — not merely that a hook was dispatched. A test that only checked the call would have passed on a veto whose return value was ignored, which is close to the bug being fixed.
Narrowing --permission-mode propagation to plan/spec-draft is right too: the previous blanket propagation was stripping child write tools via a second flag for auto/ask/member, which is the swarm-member regression I flagged.
internal/agent is green here. The one internal/cli failure is TestBuildServeScopeKeepsLexicalPaths, which needs symlink privilege and fails on origin/main on my box — not yours.
Summary
PermissionModePlan, a read-only agent mode for interactive planning: mutating tools, local commands, executable hooks, andrequest_permissionsare all denied while it's active, including the deferredtool_searchpath so spoofed/gated tools can't leak through--plan, rejected in combination with--worktree), and ACP entry points, with permission mode propagated through exec options and serialized across ACP mode changesTest plan
go test ./internal/agent/... ./internal/cli/... ./internal/tools/...