Skip to content

feat(agent): add PermissionModePlan for interactive read-only planning - #853

Open
euxaristia wants to merge 19 commits into
Gitlawb:mainfrom
euxaristia:feat/agent-plan-mode
Open

feat(agent): add PermissionModePlan for interactive read-only planning#853
euxaristia wants to merge 19 commits into
Gitlawb:mainfrom
euxaristia:feat/agent-plan-mode

Conversation

@euxaristia

Copy link
Copy Markdown
Contributor

Summary

  • Adds PermissionModePlan, a read-only agent mode for interactive planning: mutating tools, local commands, executable hooks, and request_permissions are all denied while it's active, including the deferred tool_search path so spoofed/gated tools can't leak through
  • Wired into TUI, CLI (--plan, rejected in combination with --worktree), and ACP entry points, with permission mode propagated through exec options and serialized across ACP mode changes
  • Layers a plan-mode system prompt on top of the existing prompt rather than replacing it, and enforces read-only mode on subagents spawned during planning

Test plan

  • go test ./internal/agent/... ./internal/cli/... ./internal/tools/...

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@euxaristia, you've reached your PR review limit, so we couldn't start this review.

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 @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 2ea61451-834e-485c-bc67-6ee246d9db06

📥 Commits

Reviewing files that changed from the base of the PR and between 8e26679 and 792599d.

📒 Files selected for processing (25)
  • internal/acp/agent.go
  • internal/acp/agent_test.go
  • internal/agent/deferred_loop_test.go
  • internal/agent/loop.go
  • internal/agent/loop_test.go
  • internal/agent/system_prompt.go
  • internal/agent/types.go
  • internal/cli/app.go
  • internal/cli/completions.go
  • internal/cli/exec.go
  • internal/cli/exec_parse.go
  • internal/cli/exec_plan_test.go
  • internal/cli/exec_tools.go
  • internal/specialist/exec.go
  • internal/specialist/exec_test.go
  • internal/tools/tool_search.go
  • internal/tools/tool_search_test.go
  • internal/tui/commands.go
  • internal/tui/commands_test.go
  • internal/tui/model.go
  • internal/tui/model_test.go
  • internal/tui/plan_command.go
  • internal/tui/plan_mode_test.go
  • internal/tui/theme.go
  • internal/tui/view.go

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

euxaristia and others added 18 commits July 31, 2026 15:54
…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.
… 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.
@euxaristia
euxaristia force-pushed the feat/agent-plan-mode branch from 2fb5cb5 to b88b4e3 Compare July 31, 2026 20:09

@Vasanthdev2004 Vasanthdev2004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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 planinternal/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:385handleSetConfigOption changes the mode with no turnMu, while handleSetMode at :352 now takes it. Both paths are advertised (modeState and configOptions both list plan), so the lock covers one of two doors. Also note sess.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 making session/set_mode block 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" emits Unsafe 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 takes member, member-auto, member_auto, spec_draft, high.
  • --permission-mode isn'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.
  • /init in plan mode (internal/tui/init_command.go:17) launches a turn whose entire job is writing AGENTS.md, which plan mode then denies. Either add commandInit to planModeCommandUnavailable or have it say why.
  • lsp_navigate is 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
@euxaristia

Copy link
Copy Markdown
Contributor Author

Addressed review:

  1. beforeTool fail-closed in plan mode — advisory hooks stay suppressed; dispatchBeforeTool still runs and vetoes on non-zero (TestPlanModeHonorsBeforeToolVeto).
  2. Swarm/specialist permission-mode — only propagate --permission-mode for plan/spec-draft; auto/ask/member/unsafe no longer strip child write tools via a second flag.
  3. --permission-mode plan shares --plan combination guards (use-spec, skip-permissions-unsafe, worktree).
  4. Headless ask no longer widens child tool set via propagated --permission-mode ask.

Also: ACP set-config lock, unsafe warning attribution, help/completions for --permission-mode, /init blocked in plan mode.

@Vasanthdev2004 Vasanthdev2004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

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.

2 participants