From ca607d412731b3e086c280e21c4b0e8fe20a6d2e Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Fri, 10 Jul 2026 10:52:19 -0400 Subject: [PATCH 1/3] feat(agent): add PermissionModePlan for interactive read-only planning --- internal/agent/loop.go | 25 +++++++++++++++++++++++-- internal/agent/types.go | 6 ++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/internal/agent/loop.go b/internal/agent/loop.go index e6c70086..aa40246d 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -996,12 +996,18 @@ 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) + if permissionMode == PermissionModePlan { + modeName = "plan" + } else { + modeName = "spec-draft" + } 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 } @@ -2958,6 +2964,9 @@ func ToolAdvertised(tool tools.Tool, permissionMode PermissionMode) bool { if permissionMode == PermissionModeSpecDraft { return toolAdvertisedInSpecDraft(tool) } + if permissionMode == PermissionModePlan { + return toolAdvertisedInPlan(tool) + } if permissionMode == PermissionModeAuto { return tool.Safety().Permission == tools.PermissionAllow || tool.Safety().AdvertiseInAuto } @@ -2990,6 +2999,18 @@ func toolAdvertisedInSpecDraft(tool tools.Tool) bool { return safety.SideEffect == tools.SideEffectRead && safety.Permission == tools.PermissionAllow } +// toolAdvertisedInPlan mirrors toolAdvertisedInSpecDraft: the agent may only +// read the workspace, ask the user, and shape the plan with update_plan. No +// mutating tool is advertised, so plan mode stays strictly read-only. +func toolAdvertisedInPlan(tool tools.Tool) bool { + switch tool.Name() { + case "ask_user", "update_plan": + return true + } + safety := tool.Safety() + return safety.SideEffect == tools.SideEffectRead && safety.Permission == tools.PermissionAllow +} + func stopReasonFromToolResult(result ToolResult) StopReason { if result.Meta == nil { return "" diff --git a/internal/agent/types.go b/internal/agent/types.go index 465d4a0f..d9f272be 100644 --- a/internal/agent/types.go +++ b/internal/agent/types.go @@ -25,6 +25,12 @@ const ( PermissionModeAsk PermissionMode = "ask" PermissionModeUnsafe PermissionMode = "unsafe" PermissionModeSpecDraft PermissionMode = "spec-draft" + // PermissionModePlan is an interactive, read-only planning mode toggled from + // the TUI with /plan. It applies to the CURRENT session (unlike spec-draft, + // which drafts in a separate session): the agent may inspect the workspace + // and shape the plan with update_plan/ask_user, but no mutating tool is + // advertised, so it cannot write files, run shell, or implement while planning. + PermissionModePlan PermissionMode = "plan" // PermissionModeMemberAuto is a headless mode for swarm/specialist MEMBERS: it // advertises the in-workspace mutators a member needs to build (write/edit + // shell) on top of the Auto set, while the sandbox engine still gates them at From 70ce0cc4d697ad645a6f144671409d50d0c0cd0f Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:01:16 -0400 Subject: [PATCH 2/3] fix(agent): drop redundant modeName branch, add plan mode regression 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. --- internal/agent/loop.go | 5 -- internal/agent/loop_test.go | 115 ++++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 5 deletions(-) diff --git a/internal/agent/loop.go b/internal/agent/loop.go index aa40246d..e7c7077b 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -998,11 +998,6 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal tool, toolFound := registry.Get(call.Name) if (permissionMode == PermissionModeSpecDraft || permissionMode == PermissionModePlan) && toolFound && !ToolAdvertised(tool, permissionMode) { modeName := string(permissionMode) - if permissionMode == PermissionModePlan { - modeName = "plan" - } else { - modeName = "spec-draft" - } return ToolResult{ ToolCallID: call.ID, Name: call.Name, diff --git a/internal/agent/loop_test.go b/internal/agent/loop_test.go index 485918f5..35c834b9 100644 --- a/internal/agent/loop_test.go +++ b/internal/agent/loop_test.go @@ -3249,6 +3249,121 @@ func TestSpecDraftDeniesBashToolCalls(t *testing.T) { } } +func TestPlanModeAdvertisesOnlySafeTools(t *testing.T) { + root := t.TempDir() + registry := tools.NewRegistry() + for _, tool := range tools.CoreTools(root) { + registry.Register(tool) + } + provider := &mockProvider{ + turns: [][]zeroruntime.StreamEvent{{ + {Type: zeroruntime.StreamEventText, Content: "done"}, + {Type: zeroruntime.StreamEventDone}, + }}, + } + + _, err := Run(context.Background(), "plan", provider, Options{ + Registry: registry, + PermissionMode: PermissionModePlan, + }) + if err != nil { + t.Fatal(err) + } + names := map[string]bool{} + for _, definition := range provider.requests[0].Tools { + names[definition.Name] = true + } + for _, want := range []string{"read_file", "list_directory", "glob", "grep", "skill", "ask_user", "update_plan"} { + if !names[want] { + t.Fatalf("plan mode tools missing %q from %#v", want, names) + } + } + for _, denied := range []string{"write_file", "edit_file", "apply_patch", "bash", "web_fetch"} { + if names[denied] { + t.Fatalf("plan mode advertised denied tool %q in %#v", denied, names) + } + } +} + +func TestPlanModeDeniesHiddenToolCalls(t *testing.T) { + root := t.TempDir() + registry := tools.NewRegistry() + registry.Register(tools.NewWriteFileTool(root)) + provider := providerCallingWriteFileThenAnswer("done") + + result, err := Run(context.Background(), "plan", provider, Options{ + Registry: registry, + PermissionMode: PermissionModePlan, + MaxTurns: 2, + }) + + if err != nil { + t.Fatal(err) + } + if result.FinalAnswer != "done" { + t.Fatalf("expected final answer after denial, got %q", result.FinalAnswer) + } + var denied string + for _, message := range result.Messages { + if message.Role == zeroruntime.MessageRoleTool { + denied = message.Content + break + } + } + if !strings.Contains(denied, "not available in plan mode") { + t.Fatalf("expected plan mode denial, got %q", denied) + } + if _, err := os.Stat(filepath.Join(root, "notes.txt")); !os.IsNotExist(err) { + t.Fatalf("write_file should not have written notes.txt, stat err=%v", err) + } +} + +func TestPlanModeDeniesBashToolCalls(t *testing.T) { + root := t.TempDir() + registry := tools.NewRegistry() + registry.Register(tools.NewBashTool(root)) + provider := &mockProvider{ + turns: [][]zeroruntime.StreamEvent{ + { + {Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "bash"}, + {Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"printf ran > ran.txt"}`}, + {Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"}, + {Type: zeroruntime.StreamEventDone}, + }, + { + {Type: zeroruntime.StreamEventText, Content: "done"}, + {Type: zeroruntime.StreamEventDone}, + }, + }, + } + + result, err := Run(context.Background(), "plan", provider, Options{ + Registry: registry, + PermissionMode: PermissionModePlan, + MaxTurns: 2, + }) + + if err != nil { + t.Fatal(err) + } + if result.FinalAnswer != "done" { + t.Fatalf("expected final answer after denial, got %q", result.FinalAnswer) + } + var denied string + for _, message := range result.Messages { + if message.Role == zeroruntime.MessageRoleTool { + denied = message.Content + break + } + } + if !strings.Contains(denied, "not available in plan mode") { + t.Fatalf("expected plan mode bash denial, got %q", denied) + } + if _, err := os.Stat(filepath.Join(root, "ran.txt")); !os.IsNotExist(err) { + t.Fatalf("bash should not have written ran.txt, stat err=%v", err) + } +} + func TestRunStopsWhenSubmitSpecReturnsReviewControl(t *testing.T) { root := t.TempDir() registry := tools.NewRegistry() From f2790229fc1a6d34508210770c59a8bf5047d058 Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Thu, 16 Jul 2026 11:15:12 -0400 Subject: [PATCH 3/3] refactor(agent): drop unreachable Result.Truncated helper The deadcode lint in the Security and code health job flags Result.Truncated as unreachable. Production callers in the CLI and TUI use TruncationNotice() instead, so remove the helper and update the two tests that asserted on it to rely on FinishReason and TruncationNotice. --- internal/agent/loop_test.go | 5 +---- internal/agent/types.go | 7 ------- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/internal/agent/loop_test.go b/internal/agent/loop_test.go index 35c834b9..ba82e841 100644 --- a/internal/agent/loop_test.go +++ b/internal/agent/loop_test.go @@ -808,9 +808,6 @@ func TestRunReportsTruncationFinishReason(t *testing.T) { if result.FinishReason != zeroruntime.FinishReasonLength { t.Fatalf("FinishReason = %q, want %q", result.FinishReason, zeroruntime.FinishReasonLength) } - if !result.Truncated() { - t.Fatal("Truncated() = false, want true for a length-capped response") - } if result.TruncationNotice() == "" { t.Fatal("TruncationNotice() empty for a truncated response") } @@ -828,7 +825,7 @@ func TestRunNormalCompletionIsNotTruncated(t *testing.T) { if err != nil { t.Fatal(err) } - if result.Truncated() || result.TruncationNotice() != "" { + if result.TruncationNotice() != "" { t.Fatalf("normal completion reported as truncated: reason=%q", result.FinishReason) } } diff --git a/internal/agent/types.go b/internal/agent/types.go index d9f272be..a8053923 100644 --- a/internal/agent/types.go +++ b/internal/agent/types.go @@ -348,13 +348,6 @@ type Result struct { IncompleteReason string } -// Truncated reports whether the final response ended abnormally (cut off at the -// output token cap or withheld by a content filter) rather than completing -// naturally. Callers can use it to warn the user that FinalAnswer is incomplete. -func (result Result) Truncated() bool { - return result.FinishReason != "" -} - // TruncationNotice returns a user-facing warning when the final response was // truncated, or "" for a normal completion. Shared by the CLI and TUI so the // wording stays consistent.