Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
c71594e
feat(agent): add PermissionModePlan for interactive read-only planning
euxaristia Jul 10, 2026
8a3997e
fix(agent): drop redundant modeName branch, add plan mode regression …
euxaristia Jul 14, 2026
cd0925f
fix(agent): deny request_permissions in plan/spec-draft even when the…
euxaristia Jul 18, 2026
fa21746
fix(agent): suppress executable hooks while plan/spec-draft mode is a…
euxaristia Jul 18, 2026
886a004
fix(agent): close plan-mode tool advertisement bypass
euxaristia Jul 19, 2026
8806da4
fix(agent): keep trust-gated hooks in spec-draft mode
euxaristia Jul 19, 2026
e835ae7
fix(agent): cover plan-mode spoof and lsp_navigate denials
euxaristia Jul 20, 2026
8998af0
fix(agent): filter tool_search deferred candidates by plan/spec-draft…
euxaristia Jul 22, 2026
e001af9
fix(agent): require Safety for spec-draft ask_user/submit_spec
euxaristia Jul 22, 2026
2423a80
fix(agent): wire plan mode into TUI, CLI, and ACP entry points
euxaristia Jul 22, 2026
2852d07
fix(cli): reject --plan combined with --worktree
euxaristia Jul 22, 2026
ca92b53
test(tools): assert spoofed tool schema doesn't leak via tool_search
euxaristia Jul 22, 2026
d1b65a6
fix(tui): gate local mutating commands behind plan mode
euxaristia Jul 22, 2026
94d1e69
fix(agent,specialist): layer plan mode system prompt and enforce read…
euxaristia Jul 23, 2026
2084f87
Fix jatmn review findings for PR 642
euxaristia Jul 24, 2026
4c96105
fix(agent,cli,tui): resolve CodeRabbit review comments on active turn…
euxaristia Jul 24, 2026
1430bf1
fix(agent): propagate permission mode to exec options and serialize A…
euxaristia Jul 31, 2026
b88b4e3
fix(agent,tui): update tests off removed tools.CoreTools/NewWriteFile…
euxaristia Jul 31, 2026
792599d
fix(agent): fail-closed beforeTool vetoes and permission-mode plan gu…
euxaristia Aug 1, 2026
5f4eea9
fix(agent): address CodeRabbit findings for plan-mode advertisement a…
euxaristia Aug 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions internal/acp/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -349,9 +349,11 @@ func (a *Agent) handleSetMode(_ context.Context, params json.RawMessage) (any, e
if sess == nil {
return nil, RPCError(codeInvalidParams, "unknown session: "+p.SessionID)
}
sess.turnMu.Lock()
defer sess.turnMu.Unlock()
mode := agent.PermissionMode(p.ModeID)
switch mode {
case agent.PermissionModeAuto, agent.PermissionModeAsk:
case agent.PermissionModeAuto, agent.PermissionModeAsk, agent.PermissionModePlan:
sess.setMode(mode)
(&notifier{conn: a.conn, sessionID: sess.id}).currentMode(string(mode))
return SetSessionModeResult{}, nil
Expand Down Expand Up @@ -381,9 +383,13 @@ func (a *Agent) handleSetConfigOption(_ context.Context, params json.RawMessage)
return nil, err
}
case configIDMode:
// Same turnMu as handleSetMode so the two advertised mode doors (session
// set_mode and set_config_option) serialize mode flips consistently.
sess.turnMu.Lock()
defer sess.turnMu.Unlock()
mode := agent.PermissionMode(p.Value)
switch mode {
case agent.PermissionModeAuto, agent.PermissionModeAsk:
case agent.PermissionModeAuto, agent.PermissionModeAsk, agent.PermissionModePlan:
sess.setMode(mode)
(&notifier{conn: a.conn, sessionID: sess.id}).currentMode(string(mode))
case agent.PermissionModeUnsafe:
Expand Down Expand Up @@ -439,13 +445,16 @@ func (a *Agent) handleCancel(_ context.Context, params json.RawMessage) {
// ---- advertising helpers ----

func (a *Agent) modeState(s *acpSession) *SessionModeState {
// Only auto/ask are offered over ACP; Unsafe is gated to the operator (see
// handleSetMode) so a client can't grant itself no-prompt host access.
// auto/ask/plan are offered over ACP; Unsafe is gated to the operator (see
// handleSetMode) so a client can't grant itself no-prompt host access. Plan
// only narrows what a client can do (read-only, no write/shell tools), so
// unlike Unsafe there is no elevation risk in letting a client select it.
return &SessionModeState{
CurrentModeID: string(s.currentMode()),
AvailableModes: []SessionMode{
{ID: string(agent.PermissionModeAuto), Name: "Auto", Description: "Run safe tools automatically; ask before risky ones."},
{ID: string(agent.PermissionModeAsk), Name: "Ask", Description: "Ask before every tool that changes state."},
{ID: string(agent.PermissionModePlan), Name: "Plan", Description: "Read-only planning; write and shell tools are hidden."},
},
}
}
Expand Down Expand Up @@ -516,6 +525,7 @@ func (a *Agent) configOptions(s *acpSession) []SessionConfigOption {
Options: []SessionConfigOptionValue{
{Value: string(agent.PermissionModeAuto), Name: "Auto", Description: "Run safe tools automatically; ask before risky ones."},
{Value: string(agent.PermissionModeAsk), Name: "Ask", Description: "Ask before every tool that changes state."},
{Value: string(agent.PermissionModePlan), Name: "Plan", Description: "Read-only planning; write and shell tools are hidden."},
},
}}
}
Expand Down
57 changes: 57 additions & 0 deletions internal/acp/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,32 @@ func TestACPSetModeUpdatesSession(t *testing.T) {
if got := configured.ConfigOptions[1].CurrentValue; got != string(agent.PermissionModeAuto) {
t.Fatalf("configured mode = %q", got)
}
// Plan is accepted: it only narrows capability (read-only), so unlike Unsafe
// there is no elevation risk in letting a client select it.
if err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: newRes.SessionID, ModeID: string(agent.PermissionModePlan)}, &SetSessionModeResult{}); err != nil {
t.Fatalf("set_mode plan: %v", err)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Configuration path is a separate contract from MethodSessionSetMode: the
// mode option is advertised via configOptions and applied by handleSetConfigOption.
var planConfigured SetSessionConfigOptionResult
if err := h.client.Call(ctx, MethodSessionSetConfigOption, SetSessionConfigOptionParams{
SessionID: newRes.SessionID, ConfigID: configIDMode, Value: string(agent.PermissionModePlan),
}, &planConfigured); err != nil {
t.Fatalf("set_config_option plan: %v", err)
}
if got := planConfigured.ConfigOptions[1].CurrentValue; got != string(agent.PermissionModePlan) {
t.Fatalf("configured mode after plan = %q, want plan", got)
}
hasPlanOption := false
for _, opt := range planConfigured.ConfigOptions[1].Options {
if opt.Value == string(agent.PermissionModePlan) {
hasPlanOption = true
break
}
}
if !hasPlanOption {
t.Fatalf("config mode options missing plan: %#v", planConfigured.ConfigOptions[1].Options)
}
// Unsafe must be rejected over ACP — a client can't self-grant no-prompt host access.
if err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: newRes.SessionID, ModeID: string(agent.PermissionModeUnsafe)}, &SetSessionModeResult{}); err == nil {
t.Fatal("expected Unsafe mode to be rejected over ACP")
Expand All @@ -403,6 +429,37 @@ func TestACPSetModeUpdatesSession(t *testing.T) {
}
}

// TestACPPlanModeWiresPermissionModeIntoAgentOptions confirms selecting "plan"
// over ACP actually reaches agent.Options.PermissionMode for the next turn —
// the same gap this test's TUI counterpart covers for /plan on.
func TestACPPlanModeWiresPermissionModeIntoAgentOptions(t *testing.T) {
deps := testDeps(t)
var captured agent.Options
deps.RunAgent = func(_ context.Context, _ string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
captured = opts
return agent.Result{FinalAnswer: "ok"}, nil
}

h := newHarness(t, deps)
defer h.stop()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

var newRes NewSessionResult
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir(), McpServers: []McpServer{}}, &newRes); err != nil {
t.Fatalf("session/new: %v", err)
}
if err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: newRes.SessionID, ModeID: string(agent.PermissionModePlan)}, &SetSessionModeResult{}); err != nil {
t.Fatalf("set_mode plan: %v", err)
}
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{SessionID: newRes.SessionID, Prompt: []ContentBlock{TextBlock("plan it out")}}, &PromptResult{}); err != nil {
t.Fatalf("session/prompt: %v", err)
}
if captured.PermissionMode != agent.PermissionModePlan {
t.Fatalf("agent.Options.PermissionMode = %q, want plan", captured.PermissionMode)
}
}

// TestACPRunTurnWiresSandboxAndScopedRegistry proves the sandbox engine and the
// scoped registry from BuildWorkspace actually reach agent.Options — i.e. ACP
// shell tools run confined, not unconfined on the host.
Expand Down
95 changes: 95 additions & 0 deletions internal/agent/deferred_loop_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -797,3 +797,98 @@ func TestDisabledToolSearchFallsBackToEager(t *testing.T) {
t.Fatalf("deferred tool must be callable under eager fallback, got status=%s output=%q", result.Status, result.Output)
}
}

// fakeDeferredMutatorTool is a deferred-eligible tool with mutating Safety
// (SideEffectWrite), standing in for a real write/mutator MCP tool that would
// be hidden behind tool_search once deferral activates.
type fakeDeferredMutatorTool struct{ name string }

func (t fakeDeferredMutatorTool) Name() string { return t.name }
func (t fakeDeferredMutatorTool) Description() string { return "mutates the workspace, deferred" }
func (t fakeDeferredMutatorTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", AdditionalProperties: false}
}
func (t fakeDeferredMutatorTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectWrite, Permission: tools.PermissionPrompt, Reason: "mutates"}
}
func (t fakeDeferredMutatorTool) Run(_ context.Context, _ map[string]any) tools.Result {
return tools.Result{Status: tools.StatusOK, Output: "mutated"}
}
func (t fakeDeferredMutatorTool) Deferred() bool { return true }

// TestPlanModeToolSearchNeverLeaksDeferredMutatorSchema guards the concern
// raised in PR #642 review (jatmn, P2): that tool_search filters deferred
// candidates only by EnabledTools/DisabledTools, not by plan-mode visibility,
// so a plan-mode model could call `tool_search select:<deferred write tool>`
// and receive that tool's full schema even though a direct call to it is
// correctly denied the following turn.
//
// tool_search's own Safety is SideEffectNone, and ToolAdvertisedForPermissionMode (the
// same gate executeToolCall uses to deny a direct call) requires
// SideEffect==Read to advertise a tool in plan mode. That means tool_search
// itself is never advertised, never activates deferral (loaderUsable in
// partitionToolsCached requires ToolAdvertised(loader, permissionMode)), and
// is denied at dispatch like any other hidden tool if a stale/adversarial
// call reaches it anyway — so a deferred mutator's schema can never reach the
// model through tool_search while in plan mode. This test pins that
// end-to-end: tool_search is absent from the advertised tool list, and a
// forced call to it is denied before rendering any tool schema.
func TestPlanModeToolSearchNeverLeaksDeferredMutatorSchema(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewReadFileTool(root))
registry.Register(fakeDeferredMutatorTool{name: "mcp__srv__mutate"})
registry.Register(fakeDeferredMutatorTool{name: "mcp__srv__mutate2"})
registry.Register(tools.NewToolSearchTool(registry))

provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
{ // turn 1: force a call to tool_search even though it should not be advertised.
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "c1", ToolName: tools.ToolSearchToolName},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "c1", ArgumentsFragment: `{"query":"select:mcp__srv__mutate"}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "c1"},
{Type: zeroruntime.StreamEventDone},
},
{ // turn 2: final answer.
{Type: zeroruntime.StreamEventText, Content: "done"},
{Type: zeroruntime.StreamEventDone},
},
}}

result, err := Run(context.Background(), "plan", provider, Options{
Registry: registry,
PermissionMode: PermissionModePlan,
DeferThreshold: 2, // 2 deferred mutators registered => eligible for deferral.
MaxTurns: 2,
})
if err != nil {
t.Fatal(err)
}

// tool_search (and the deferred mutators) must not be advertised in plan
// mode's turn 1 tool list at all.
for _, def := range provider.requests[0].Tools {
if def.Name == tools.ToolSearchToolName {
t.Fatalf("plan mode must not advertise tool_search, got %#v", provider.requests[0].Tools)
}
if def.Name == "mcp__srv__mutate" || def.Name == "mcp__srv__mutate2" {
t.Fatalf("plan mode must not advertise a deferred mutator, got %#v", provider.requests[0].Tools)
}
}

// The forced call must be denied outright, never a loaded-schema result:
// the tool result must not mention the mutator's name or carry a
// load_tools signal.
var toolMessage string
for _, message := range result.Messages {
if message.Role == zeroruntime.MessageRoleTool {
toolMessage = message.Content
break
}
}
if !strings.Contains(toolMessage, "not available in plan mode") {
t.Fatalf("expected tool_search call denied in plan mode, got %q", toolMessage)
}
if strings.Contains(toolMessage, "mcp__srv__mutate") {
t.Fatalf("denial must not leak the deferred mutator's name/schema, got %q", toolMessage)
}
}
68 changes: 50 additions & 18 deletions internal/agent/loop.go
Original file line number Diff line number Diff line change
Expand Up @@ -1095,12 +1095,13 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
}, nil
}
tool, toolFound := registry.Get(call.Name)
if permissionMode == PermissionModeSpecDraft && toolFound && !ToolAdvertised(tool, permissionMode) {
if (permissionMode == PermissionModeSpecDraft || permissionMode == PermissionModePlan) && toolFound && !ToolAdvertised(tool, permissionMode) {
modeName := string(permissionMode)
return ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Status: tools.StatusError,
Output: `Error: Tool "` + call.Name + `" is not available in spec-draft mode.`,
Output: `Error: Tool "` + call.Name + `" is not available in ` + modeName + ` mode.`,
DenialReason: DenialFiltered,
}, nil
}
Expand Down Expand Up @@ -1777,9 +1778,31 @@ func toolResultFromPrePermissionReject(call ToolCall, result tools.Result) ToolR
}
}

// hooksSuppressed reports whether advisory (non-veto) hooks must not run for
// this run's permission mode. Plan mode promises a read-only turn, but
// sessionStart/sessionEnd/afterTool hooks execute configured host commands
// outside the advertised-tool and sandbox gates, so dispatching them would let
// merely starting a plan session or finishing a read mutate the workspace.
//
// beforeTool is intentionally NOT suppressed: a non-zero exit is a deny gate,
// and skipping it fails open (operators who block secret-file reads via
// beforeTool would lose that protection under /plan on). See dispatchBeforeTool.
//
// Spec-draft keeps the existing trust-gated hook model: project hooks still
// fire when the workspace (or its worktree trust root) is trusted. That is
// intentional; trust inheritance for --use-spec --worktree is covered by
// TestExecSpecWorktreeInheritsTrustEndToEnd.
func hooksSuppressed(options Options) bool {
return options.PermissionMode == PermissionModePlan
}

// dispatchBeforeTool runs configured beforeTool hooks for a tool call. A hook
// that exits non-zero vetoes the call: the returned bool is true and the tool
// must not run. A nil dispatcher (no hooks wired) is a no-op.
//
// Unlike advisory hooks, beforeTool still runs under plan mode. Suppressing it
// would fail open: a project policy that blocks reads of secrets via
// beforeTool would silently stop applying the moment permission mode is plan.
func dispatchBeforeTool(ctx context.Context, options Options, call ToolCall, args map[string]any) (hooks.DispatchOutcome, bool) {
if options.Hooks == nil {
return hooks.DispatchOutcome{}, false
Expand All @@ -1804,7 +1827,7 @@ func dispatchBeforeTool(ctx context.Context, options Options, call ToolCall, arg
// returns any advisory output (e.g. a formatter or vet result) to surface back
// to the model. afterTool hooks never block. A nil dispatcher is a no-op.
func dispatchAfterTool(ctx context.Context, options Options, call ToolCall, args map[string]any, result tools.Result) string {
if options.Hooks == nil {
if options.Hooks == nil || hooksSuppressed(options) {
return ""
}
outcome := options.Hooks.Dispatch(ctx, hooks.DispatchInput{
Expand All @@ -1828,7 +1851,7 @@ func dispatchAfterTool(ctx context.Context, options Options, call ToolCall, args
// model turn. Lifecycle hooks are advisory: dispatcher failures are audited but
// never block the run.
func dispatchSessionStart(ctx context.Context, options Options) {
if options.Hooks == nil {
if options.Hooks == nil || hooksSuppressed(options) {
return
}
options.Hooks.Dispatch(ctx, hooks.DispatchInput{
Expand All @@ -1849,7 +1872,7 @@ func dispatchSessionStart(ctx context.Context, options Options) {
// dispatchSessionEnd runs configured sessionEnd hooks once when the agent run
// exits, including early error returns. Lifecycle hooks are advisory.
func dispatchSessionEnd(ctx context.Context, options Options, result Result, runErr error) {
if options.Hooks == nil {
if options.Hooks == nil || hooksSuppressed(options) {
return
}
payload := map[string]any{
Expand Down Expand Up @@ -2148,6 +2171,20 @@ type requestPermissionsArgs struct {
}

func executeRequestPermissions(ctx context.Context, call ToolCall, args map[string]any, permissionMode PermissionMode, options Options) (ToolResult, error) {
// request_permissions is dispatched by name above, before the registry-based
// ToolAdvertised gate runs, so a read-only mode's registry omitting this tool
// (rather than registering it as denied) must not fall through to a real
// grant. Deny it here unconditionally for spec-draft/plan, independent of
// whether the caller's registry happens to contain the tool.
if permissionMode == PermissionModeSpecDraft || permissionMode == PermissionModePlan {
return ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Status: tools.StatusError,
Output: `Error: Tool "` + call.Name + `" is not available in ` + string(permissionMode) + ` mode.`,
DenialReason: DenialFiltered,
}, nil
}
parsed, err := parseRequestPermissionsArgs(args)
if err != nil {
return ToolResult{
Expand Down Expand Up @@ -3146,11 +3183,17 @@ func propertyToRuntimeMap(property tools.PropertySchema) map[string]any {
}

func ToolAdvertised(tool tools.Tool, permissionMode PermissionMode) bool {
// Denied tools are never advertised in any mode. Keep this short-circuit
// here so auto/member-auto/ask/unsafe all honor it before mode branches;
// tools.ToolAdvertisedForPermissionMode repeats it for the tool_search path
// which never enters this function.
if tool.Safety().Permission == tools.PermissionDeny {
return false
}
if permissionMode == PermissionModeSpecDraft {
return toolAdvertisedInSpecDraft(tool)
// plan/spec-draft policy lives in tools so tool_search and the dispatch
// gate cannot drift. PermissionDeny was already checked above.
if permissionMode == PermissionModeSpecDraft || permissionMode == PermissionModePlan {
return tools.ToolAdvertisedForPermissionMode(tool, string(permissionMode))
}
if permissionMode == PermissionModeAuto {
return tool.Safety().Permission == tools.PermissionAllow || tool.Safety().AdvertiseInAuto
Expand All @@ -3173,17 +3216,6 @@ func ToolAdvertised(tool tools.Tool, permissionMode PermissionMode) bool {
return true
}

func toolAdvertisedInSpecDraft(tool tools.Tool) bool {
switch tool.Name() {
case "ask_user", "submit_spec":
return true
case "update_plan":
return false
}
safety := tool.Safety()
return safety.SideEffect == tools.SideEffectRead && safety.Permission == tools.PermissionAllow
}

func stopReasonFromToolResult(result ToolResult) StopReason {
if result.Meta == nil {
return ""
Expand Down
Loading
Loading