Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
20 changes: 18 additions & 2 deletions internal/agent/loop.go
Original file line number Diff line number Diff line change
Expand Up @@ -898,12 +898,13 @@
}, 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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

can you check the CI/Security & code health comments

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@euxaristia please check bro

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed the deadcode finding for Result.Truncated from the Security and code health job in 0b71ef6. The helper had no production callers, the CLI and TUI both use TruncationNotice() instead, so I removed it and updated the two tests that asserted on it. I reran the same command CI uses (go run golang.org/x/tools/cmd/deadcode@v0.46.0 -test=false ./...) on the branch and the finding is gone. The remaining findings in that job are pre-existing on main and not introduced by this PR, including the other unreachable funcs in internal/agent.

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 @@ -2691,7 +2692,7 @@
// (tool_search's discovery text still shrinks as tools load, so keeping it and
// the loaded tail AFTER the eager block preserves the eager tools' cache across a
// load; fully stabilizing the loader's own description is a scoped follow-up.)
eager := make([]zeroruntime.ToolDefinition, 0, len(visible))

Check failure on line 2695 in internal/agent/loop.go

View workflow job for this annotation

GitHub Actions / Security & code health

unreachable func: partitionTools
loadedTail := make([]zeroruntime.ToolDefinition, 0)
var hiddenTools []tools.Tool
for _, tool := range visible {
Expand Down Expand Up @@ -2857,6 +2858,9 @@
if permissionMode == PermissionModeSpecDraft {
return toolAdvertisedInSpecDraft(tool)
}
if permissionMode == PermissionModePlan {
return toolAdvertisedInPlan(tool)
}
if permissionMode == PermissionModeAuto {
return tool.Safety().Permission == tools.PermissionAllow || tool.Safety().AdvertiseInAuto
}
Expand Down Expand Up @@ -2889,6 +2893,18 @@
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 ""
Expand Down
120 changes: 116 additions & 4 deletions internal/agent/loop_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -725,9 +725,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")
}
Expand All @@ -745,7 +742,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)
}
}
Expand Down Expand Up @@ -3166,6 +3163,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()
Expand Down
13 changes: 6 additions & 7 deletions internal/agent/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,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
Expand Down Expand Up @@ -331,13 +337,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.
Expand Down
Loading