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
77 changes: 69 additions & 8 deletions internal/agent/guardrails.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,22 @@ const (
// error, so this only affects true same-error loops.
toolFailureStopAt = 6

// toolFailureAnyErrorStopAt halts a tool that keeps failing with DIFFERENT
// errors. The streak above cannot see that case by construction: a changed
// signature rebuilds the record at 1, so a tool whose error text varies every
// call never reaches toolFailureStopAt however long it loops.
//
// That is not hypothetical. A headless run made 384 denied calls over 26
// minutes without tripping a halt set to 6, because each denial named a
// different path. Keying denials on their category (see observeToolResult)
// fixes that case; this bound covers every error with no category to key on.
//
// Deliberately well above toolFailureStopAt. A model iterating on a genuinely
// tricky edit legitimately fails several times with different errors while it
// converges, and this must not cut those runs short — the same reasoning that
// moved toolFailureStopAt from 4 to 6. Both counters reset on success.
toolFailureAnyErrorStopAt = 12

// maxContinueNudges bounds how many times the headless completion gate
// (Options.RequireCompletionSignal) re-prompts a model that stopped without a
// tool call while work clearly remained. Once spent, the run finalizes as
Expand Down Expand Up @@ -327,12 +343,22 @@ type toolFailureRecord struct {
count int
errSig string
hintShown bool
// anyErrorCount counts consecutive failures of this tool REGARDLESS of the
// error, and is cleared only by a success. count above restarts whenever the
// signature changes, which is exactly what a varying error message defeats;
// this one cannot be reset by changing the text.
anyErrorCount int
}

type toolFailureOutcome struct {
InjectHint bool
Stop bool
Count int
// Varied reports that the stop came from the content-blind bound, i.e. Count
// failures with DIFFERENT errors rather than the same one repeated. The final
// answer says so, because "failed 12 times with the same error" would be a
// plainly false description of a tool that failed 12 different ways.
Varied bool
}

// errorSignature normalizes a tool error to a short, comparable signature so
Expand All @@ -358,9 +384,13 @@ func toolFailureHint(toolName, schemaJSON, errOutput string) string {

// toolFailureStopAnswer is the final answer when the repeated-failure guard halts
// a run.
func toolFailureStopAnswer(toolName string, count int) string {
func toolFailureStopAnswer(toolName string, count int, varied bool) string {
cause := " times in a row with the same error, "
if varied {
cause = " times in a row, each with a different error, "
}
return "Agent stopped: the `" + toolName + "` tool failed " + strconv.Itoa(count) +
" times in a row with the same error, so I halted instead of looping further. " +
cause + "so I halted instead of looping further. " +
"Please check the request or adjust the tool arguments."
}

Expand Down Expand Up @@ -472,28 +502,59 @@ func newGuardState() *guardState {
// observeToolResult tracks repeated identical failures of a tool. A successful
// result clears that tool's failure streak. Returns whether to inject a one-shot
// corrective hint and/or stop the run.
func (state *guardState) observeToolResult(name string, failed bool, output string) toolFailureOutcome {
// hintable is separate from failed on purpose. A categorized denial MUST count
// toward the streaks — that is the whole point of keying on the category — but a
// schema hint is the wrong response to a policy refusal: the call shape is fine,
// the answer was no. Collapsing the two is what made the earlier version of this
// fix a no-op, since the caller could only pass a flag that excluded denials.
func (state *guardState) observeToolResult(name string, failed bool, hintable bool, output string, denial DenialCategory) toolFailureOutcome {
if state.toolFailures == nil {
state.toolFailures = map[string]*toolFailureRecord{}
}
if !failed {
delete(state.toolFailures, name) // success resets the streak
delete(state.toolFailures, name) // success resets both counters
return toolFailureOutcome{}
}
// A denial keys on its CATEGORY, not its prose. The message embeds the path
// or command that was refused, so it differs on every call while describing
// the same unchanging refusal — which rebuilt the record at 1 each time and
// let a denied tool loop indefinitely under a halt set to 6. The category is
// a small closed enum the loop already sets on the result.
sig := errorSignature(output)
if denial != DenialNone {
sig = "denial:" + string(denial)
}
record := state.toolFailures[name]
if record == nil || record.errSig != sig {
record = &toolFailureRecord{count: 1, errSig: sig}
if record == nil {
record = &toolFailureRecord{errSig: sig}
state.toolFailures[name] = record
}
if record.errSig != sig {
// A different error restarts the same-error streak but NOT the
// content-blind one: changing how a tool fails is not progress.
record.count = 1
record.errSig = sig
record.hintShown = false
} else {
record.count++
}
record.anyErrorCount++

outcome := toolFailureOutcome{Count: record.count}
if record.count >= toolFailureStopAt {
switch {
case record.count >= toolFailureStopAt:
outcome.Stop = true
return outcome
case record.anyErrorCount >= toolFailureAnyErrorStopAt:
// Report the counter that actually tripped. record.count is the
// same-signature streak and is often 1 here, which would describe a tool
// that failed a dozen different ways as having failed once.
outcome.Stop = true
outcome.Count = record.anyErrorCount
outcome.Varied = true
return outcome
}
if record.count >= toolFailureHintAt && !record.hintShown {
if hintable && record.count >= toolFailureHintAt && !record.hintShown {
record.hintShown = true
outcome.InjectHint = true
}
Expand Down
221 changes: 220 additions & 1 deletion internal/agent/guardrails_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package agent

import (
"context"
"path/filepath"
"strconv"
"strings"
"testing"

Expand Down Expand Up @@ -189,7 +191,7 @@ func TestUnknownExecSessionProbingTripsFailureHalt(t *testing.T) {
var state guardState
var stoppedAt int
for i := 1; i <= toolFailureStopAt; i++ {
out := state.observeToolResult(tools.WriteStdinToolName, true, tools.UnknownExecSessionError(i))
out := state.observeToolResult(tools.WriteStdinToolName, true, true, tools.UnknownExecSessionError(i), "")
if out.Stop {
stoppedAt = i
break
Expand All @@ -200,6 +202,223 @@ func TestUnknownExecSessionProbingTripsFailureHalt(t *testing.T) {
}
}

// A permission denial repeats forever when the streak is keyed on the error
// TEXT, because the denial message embeds the path or command that varies per
// call.
//
// Observed, not theorised: a headless run made 384 denied calls over 26 minutes
// without tripping a halt that stops at 6. Every denial carried the same typed
// category and a different reason string, so errorSignature differed each time
// and the record was rebuilt at count 1 on every call.
//
// TestUnknownExecSessionErrorSignatureIsIDInvariant fixed one message this way.
// Keying on the category fixes the class, without needing every future denial
// message to remember to be invariant.
func TestPermissionDenialStreakSurvivesVaryingReasonText(t *testing.T) {
var state guardState
stoppedAt := 0
for i := 1; i <= toolFailureStopAt; i++ {
// The shape the loop actually produces: same tool, same category, a
// different path every time.
output := "Error: Permission denied for write_file: cannot write " +
filepath.Join("C:", "ws", "pkg", "file"+strconv.Itoa(i)+".go")
out := state.observeToolResult("write_file", true, true, output, DenialPermissionDenied)
if out.Stop {
stoppedAt = i
break
}
}
if stoppedAt != toolFailureStopAt {
t.Fatalf("denials with varying reason text stopped at %d, want %d", stoppedAt, toolFailureStopAt)
}
}

// The content-blind bound. A tool failing over and over with genuinely
// DIFFERENT errors and no denial category is still a tool that is not working,
// and the same-signature streak can never see it.
//
// Bounded well above toolFailureStopAt on purpose: a model iterating on a
// tricky edit legitimately fails a few times with different errors while it
// converges, and cutting that short is the regression
// TestSuccessResetsBothFailureCounters guards.
func TestToolFailingWithDifferentErrorsEveryTimeStillStops(t *testing.T) {
var state guardState
stoppedAt := 0
for i := 1; i <= toolFailureAnyErrorStopAt; i++ {
out := state.observeToolResult("bash", true, true, "distinct failure "+strconv.Itoa(i), "")
if out.Stop {
stoppedAt = i
break
}
}
if stoppedAt != toolFailureAnyErrorStopAt {
t.Fatalf("a tool failing with a new error each call stopped at %d, want %d", stoppedAt, toolFailureAnyErrorStopAt)
}
}

// Neither counter may outlive a success, or a long run that fails occasionally
// and recovers would eventually halt for no reason. This is the property that
// keeps the content-blind bound safe to add.
func TestSuccessResetsBothFailureCounters(t *testing.T) {
var state guardState
for i := 1; i < toolFailureAnyErrorStopAt; i++ {
if out := state.observeToolResult("bash", true, true, "distinct failure "+strconv.Itoa(i), ""); out.Stop {
t.Fatalf("stopped at %d before the success that should reset it", i)
}
}
state.observeToolResult("bash", false, false, "ok", "")

// Same again from zero. Reaching the bound a second time proves the counter
// restarted rather than merely paused.
stoppedAt := 0
for i := 1; i <= toolFailureAnyErrorStopAt; i++ {
if out := state.observeToolResult("bash", true, true, "later failure "+strconv.Itoa(i), ""); out.Stop {
stoppedAt = i
break
}
}
if stoppedAt != toolFailureAnyErrorStopAt {
t.Fatalf("after a success the tool stopped at %d, want a full fresh %d", stoppedAt, toolFailureAnyErrorStopAt)
}
}

// Records are keyed per tool, so ANOTHER tool succeeding in between must not
// clear the failing tool's streak.
//
// This is the realistic shape of the run that motivated the fix: the model kept
// making progress elsewhere — reading files, updating its plan — while one tool
// was refused over and over. A reset keyed on "something succeeded" rather than
// "this tool succeeded" would make the halt unreachable in exactly the runs that
// need it.
func TestAnotherToolSucceedingDoesNotClearAFailingToolsStreak(t *testing.T) {
var state guardState
stoppedAt := 0
for i := 1; i <= toolFailureStopAt; i++ {
state.observeToolResult("read_file", false, false, "ok", "")
out := state.observeToolResult("write_file", true, false,
"Error: Permission denied for write_file: "+strconv.Itoa(i), DenialPermissionDenied)
if out.Stop {
stoppedAt = i
break
}
}
if stoppedAt != toolFailureStopAt {
t.Fatalf("denials interleaved with another tool's successes stopped at %d, want %d", stoppedAt, toolFailureStopAt)
}
}

// alwaysPromptingTool is never allowed to run: it exists so a Run-level test can
// drive real permission denials through the loop.
type alwaysPromptingTool struct{ ran int }
Comment on lines +310 to +312

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Remove the duplicate alwaysPromptingTool declaration.

alwaysPromptingTool is declared twice at package scope. Go rejects the test package with a redeclaration error. Keep one declaration so the regression tests compile.

Proposed fix
 type alwaysPromptingTool struct{ ran int }
-type alwaysPromptingTool struct{ ran int }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/agent/guardrails_test.go` around lines 310 - 312, The
alwaysPromptingTool type is declared twice at package scope in the test file,
which causes a Go redeclaration error. Locate the second alwaysPromptingTool
declaration elsewhere in the file and remove it, preserving the one shown in the
diff that includes the explanatory comment about its purpose in the Run-level
test.


func (tool *alwaysPromptingTool) Name() string { return "bash" }
func (tool *alwaysPromptingTool) Description() string { return "test shell tool" }
func (tool *alwaysPromptingTool) Parameters() tools.Schema {
return tools.Schema{
Type: "object",
Properties: map[string]tools.PropertySchema{"command": {Type: "string"}},
Required: []string{"command"},
AdditionalProperties: false,
}
}
func (tool *alwaysPromptingTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectShell, Permission: tools.PermissionPrompt, Reason: "runs shell commands"}
}
func (tool *alwaysPromptingTool) Run(context.Context, map[string]any) tools.Result {
tool.ran++
return tools.Result{Status: tools.StatusOK, Output: "should never run"}
}

// The regression for the gap this whole change existed to close, driven through
// Run rather than through the guard helper.
//
// The first version of this fix was a no-op in production and every one of its
// unit tests passed, because they called observeToolResult directly with
// failed=true. The loop passes isRetriableToolError, which returns false for a
// categorized denial, so the guard took its success branch and deleted the
// record before it could key on the category. A denied tool still looped to the
// turn limit.
//
// This drives the real path: a tool that always prompts, an approver that always
// denies, and a different command each turn so the denial REASON — and therefore
// the error text — varies exactly as it does in a real run.
func TestRunStopsARepeatedlyDeniedToolAtTheFailureBound(t *testing.T) {
tool := &alwaysPromptingTool{}
registry := tools.NewRegistry()
registry.Register(tool)

// Comfortably more turns than the bound, so reaching the bound is what stops
// the run rather than exhausting the provider or MaxTurns.
turns := make([][]zeroruntime.StreamEvent, 0, toolFailureStopAt+4)
for i := range toolFailureStopAt + 4 {
turns = append(turns, toolTurn("call-"+strconv.Itoa(i), "bash",
`{"command":"touch /etc/file`+strconv.Itoa(i)+`"}`))
}
provider := &mockProvider{turns: turns}

denials := 0
result, err := Run(context.Background(), "do the thing", provider, Options{
Registry: registry,
PermissionMode: PermissionModeAsk,
MaxTurns: len(turns) + 5,
OnPermissionRequest: func(_ context.Context, request PermissionRequest) (PermissionDecision, error) {
denials++
// The varying half. In production this is the refused path or command;
// here it is the command, which lands in the denial message the guard
// used to key on.
return PermissionDecision{
Action: PermissionDecisionDeny,
Reason: "refused " + request.ToolName + " call " + strconv.Itoa(denials),
}, nil
},
})
if err != nil {
t.Fatal(err)
}

if denials != toolFailureStopAt {
t.Errorf("the run made %d denied calls, want it halted at %d", denials, toolFailureStopAt)
}
if tool.ran != 0 {
t.Errorf("the denied tool executed %d times; the denial must precede execution", tool.ran)
}
want := toolFailureStopAnswer("bash", toolFailureStopAt, false)
if result.FinalAnswer != want {
t.Errorf("final answer =\n %q\nwant\n %q", result.FinalAnswer, want)
}
}

// The stop message must describe the bound that actually tripped. When the
// content-blind counter is what halts the run, the same-signature streak is
// usually 1, and reporting that would tell the user a tool failed once after it
// failed a dozen different ways.
func TestVariedFailureStopAnswerReportsTheRightCounter(t *testing.T) {
var state guardState
var outcome toolFailureOutcome
for i := 1; i <= toolFailureAnyErrorStopAt; i++ {
outcome = state.observeToolResult("bash", true, true, "distinct failure "+strconv.Itoa(i), "")
if outcome.Stop {
break
}
}
if !outcome.Stop {
t.Fatal("never stopped")
}
if !outcome.Varied {
t.Error("Varied = false for a stop driven by the content-blind counter")
}
if outcome.Count != toolFailureAnyErrorStopAt {
t.Errorf("Count = %d, want the counter that tripped (%d)", outcome.Count, toolFailureAnyErrorStopAt)
}
answer := toolFailureStopAnswer("bash", outcome.Count, outcome.Varied)
if !strings.Contains(answer, "each with a different error") {
t.Errorf("stop answer describes the wrong cause: %q", answer)
}
if strings.Contains(answer, "with the same error") {
t.Errorf("stop answer claims a same-error loop after distinct failures: %q", answer)
}
}

func TestGuardStateResetsToolOnlyStreakOnEmptyNonToolTurn(t *testing.T) {
var state guardState
toolOnly := zeroruntime.CollectedStream{
Expand Down
10 changes: 8 additions & 2 deletions internal/agent/loop.go
Original file line number Diff line number Diff line change
Expand Up @@ -740,7 +740,13 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// aren't fixed by reformatting the call, so a "match this schema" hint
// would misdirect the model toward JSON shape or blocked behavior.
retriableFailure := isRetriableToolError(toolResult)
outcome := guards.observeToolResult(call.Name, retriableFailure, toolResult.Output)
// A categorized denial is NOT retriable — retrying it verbatim is
// pointless — but it is still a failure the streaks must count, or a
// refused tool loops until the turn limit. Passing retriableFailure for
// both is what let that happen: observeToolResult took its success
// branch and deleted the record before it could key on the category.
countedFailure := retriableFailure || toolResult.DenialReason != DenialNone
outcome := guards.observeToolResult(call.Name, countedFailure, retriableFailure, toolResult.Output, toolResult.DenialReason)
Comment on lines +743 to +749

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

# Inventory policy-denial representations and category assignment.
rg -n --type go -C 6 \
  'DenialReason|permission_action|Permission(Action|Decision)Deny|Permission denied for |Permission required for |Sandbox block|Sandbox approval required for |is not enabled for this run' \
  internal/agent || true

# Inspect ToolResult construction sites that can reach the agent loop.
rg -n --type go -C 8 'ToolResult\s*\{' internal/agent || true

Repository: Gitlawb/zero

Length of output: 50369


🏁 Script executed:

# Find all sites constructing ToolResult with error status
rg -n --type go 'return ToolResult\{' internal/agent/loop.go | head -20

# Find executeToolCall and check what it delegates to
ast-grep outline internal/agent/loop.go --view expanded | grep -A 5 "executeToolCall"

# Check for tool results coming from tools.Tool execution
rg -n --type go 'toolResult|tools\.Result' internal/agent/loop.go | grep -E '(toolResult\s*:=|tools\.Result)' | head -20

Repository: Gitlawb/zero

Length of output: 4172


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find all ToolResult{} constructions in executeToolCall to identify denial producers
rg -n --type go -B 2 -A 8 'func executeToolCall' internal/agent/loop.go | head -30

# Check for tool.Execute calls and their result handling
rg -n --type go -A 3 'tool\.Execute|\.Execute(' internal/agent/loop.go | head -30

# Identify all denial-producing paths and check if they set DenialReason
echo "=== Checking for ToolResult errors without DenialReason field ==="
rg -n --type go 'ToolResult\{[^}]*Status:\s*tools\.StatusError[^}]*Output:' internal/agent/loop.go

Repository: Gitlawb/zero

Length of output: 766


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find where tool.Execute is called and how results are handled
rg -n --type go 'tool\.Execute\(' internal/agent/loop.go -A 5

# Find all ToolResult constructions and check if DenialReason is set
echo "=== ToolResult constructions from lines 1080-1150 (parseArgs through tool-not-found) ==="
sed -n '1076,1150p' internal/agent/loop.go | grep -n 'ToolResult\|DenialReason'

# Check executeToolCall main path - tool execution
echo ""
echo "=== Tool execution path in executeToolCall ==="
sed -n '1150,1300p' internal/agent/loop.go | head -80

Repository: Gitlawb/zero

Length of output: 150


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Trace the exact flow: where tool.Execute result flows to ToolResult
# Look for toolResultFromTools or similar conversions
rg -n --type go 'toolResultFrom|convertToolResult|toolToResult' internal/agent/loop.go

# Look at the main path after tool.Execute call
echo "=== Searching around line 1270-1400 for tool.Execute result handling ==="
sed -n '1270,1400p' internal/agent/loop.go

# Check where tools.Result gets converted to ToolResult
rg -n --type go 'ToolResult.*Status.*tools\.' internal/agent/loop.go | head -20

Repository: Gitlawb/zero

Length of output: 6665


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Continue from result := registry.RunWithOptions to see how it flows to ToolResult
echo "=== Looking for tools.Result to ToolResult conversion after registry.RunWithOptions ==="
sed -n '1350,1450p' internal/agent/loop.go

# Also look at toolResultFromPrePermissionReject to understand the pattern
echo ""
echo "=== toolResultFromPrePermissionReject pattern ==="
sed -n '1717,1775p' internal/agent/loop.go

# Find where tools.Result becomes ToolResult in the main path
echo ""
echo "=== End of executeToolCall - how is tools.Result converted ==="
sed -n '1400,1445p' internal/agent/loop.go

Repository: Gitlawb/zero

Length of output: 9493


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# The key issue: main ToolResult conversion at 1417-1438 does NOT set DenialReason from Meta
# Need to find if tools.Result can carry permission_action without DenialReason
# Search tools package for where permission_action meta is set

echo "=== Searching for permission_action meta sources in internal/agent ==="
rg -n --type go '"permission_action"' internal/agent/ | grep -v test | grep -v '\.go-'

echo ""
echo "=== Checking for PrePermissionRejecter and how it's used ==="
rg -n --type go 'PrePermissionRejecter|RejectBeforePermission' internal/agent/loop.go -B 2 -A 8

echo ""
echo "=== Checking the actual tools.Registry/tool.Execute path ==="
# The tools are in a different package - check what meta they can return
rg -n --type go 'permission_action' . --glob '*/tools/*' 2>/dev/null | head -20

Repository: Gitlawb/zero

Length of output: 1138


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check what deniedPermissionResult and canceledPermissionResult actually set
echo "=== deniedPermissionResult (lines 2445-2463) ==="
sed -n '2445,2463p' internal/agent/loop.go

echo ""
echo "=== canceledPermissionResult (lines 2465-2489) ==="
sed -n '2465,2489p' internal/agent/loop.go

echo ""
echo "=== Check if any tool error flow sets DenialReason before line 1417 ==="
# Look for early returns that set DenialReason
sed -n '1076,1420p' internal/agent/loop.go | grep -B 3 'DenialReason:'

echo ""
echo "=== Verify: does toolResultFromPrePermissionReject set DenialReason? ==="
sed -n '1717,1753p' internal/agent/loop.go | grep 'DenialReason'

Repository: Gitlawb/zero

Length of output: 2108


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# The critical question: does any tool execution path (registry.RunWithOptions)
# return a tools.Result with permission_action meta without being converted to ToolResult with DenialReason?
# Let's check all early returns that set permission_action meta

echo "=== All locations that set permission_action meta ==="
rg -n --type go '"permission_action"' internal/agent/loop.go

echo ""
echo "=== Check toolResultFromPrePermissionReject more carefully ==="
# This path doesn't set DenialReason. PrePermissionRejecter tools return tools.Result
# What Meta can they have?
rg -n --type go 'PrePermissionRejecter' . --glob '*/tools/*' -A 3 2>/dev/null | head -30

echo ""
echo "=== Search in tools package for permission-related metadata ==="
fd . --type f --name '*.go' | grep tools | head -10

Repository: Gitlawb/zero

Length of output: 2956


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# The critical path: toolResultFromPrePermissionReject doesn't set DenialReason
# Check if PrePermissionRejecter tools set permission_action in their Meta
echo "=== Search for PrePermissionRejecter implementations and their Meta ==="
fd '\.go$' internal/tools | xargs grep -l 'RejectBeforePermission' | head -5

echo ""
echo "=== Check WebFetchTool for permission_action meta ==="
rg -n --type go 'RejectBeforePermission|permission_action' internal/tools/web_fetch.go -B 3 -A 8

echo ""
echo "=== Check LocalDesktopTerminalTool ==="
rg -n --type go 'RejectBeforePermission|permission_action' internal/tools/local_desktop_terminal.go -B 3 -A 8 | head -40

Repository: Gitlawb/zero

Length of output: 1219


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find observeToolResult implementation to understand the vulnerability
echo "=== guardState.observeToolResult implementation ==="
rg -n --type go -A 25 'func.*observeToolResult' internal/agent/guardrails.go | head -50

echo ""
echo "=== Check what happens when countedFailure=false ==="
sed -n '200,350p' internal/agent/guardrails.go | grep -B 10 -A 10 'failed'

Repository: Gitlawb/zero

Length of output: 1494


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Trace the full vulnerability: toolResultFromPrePermissionReject path
echo "=== toolResultFromPrePermissionReject does NOT set DenialReason ==="
sed -n '1717,1753p' internal/agent/loop.go

echo ""
echo "=== But PrePermissionRejecter can return errors with permission_action? ==="
# Check if any PrePermissionRejecter tool sets permission_action in Meta
rg -n --type go 'RejectBeforePermission' internal/tools/ -A 15 | grep -E 'Meta|permission_action|Result\{' | head -40

echo ""
echo "=== Verify loop test case 577 shows the vulnerability ==="
sed -n '577,590p' internal/agent/loop_test.go

echo ""
echo "=== Trace loop.go line 1116: toolResultFromPrePermissionReject call ==="
sed -n '1113,1120p' internal/agent/loop.go

Repository: Gitlawb/zero

Length of output: 4079


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# The smoking gun: does toolResultFromPrePermissionReject lose DenialReason?
# And can a PrePermissionRejecter return Meta["permission_action"]?
echo "=== Check if any PrePermissionRejecter implementation sets permission_action meta ==="
rg -n --type go 'RejectBeforePermission' internal/tools/ -A 20 | grep -B 5 'permission_action'

echo ""
echo "=== Check web_fetch.go RejectBeforePermission more carefully ==="
sed -n '159,200p' internal/tools/web_fetch.go

echo ""
echo "=== Now verify the actual gap: can a PrePermissionRejecter denial avoid DenialReason? ==="
# The issue is: even if PrePermissionRejecter tools don't set permission_action,
# the tools.Result they return could have Meta with permission_action
# But more importantly: toolResultFromPrePermissionReject NEVER sets DenialReason
# So a PrePermissionRejecter error won't be counted as a failure if isRetriableToolError
# checks Meta["permission_action"] instead of DenialReason

echo "=== isRetriableToolError logic (lines 1891-1912) ==="
sed -n '1891,1912p' internal/agent/loop.go

Repository: Gitlawb/zero

Length of output: 236


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Let's verify the exact vulnerability by examining toolResultFromPrePermissionReject
echo "=== toolResultFromPrePermissionReject - does it ever set DenialReason? ==="
sed -n '1717,1755p' internal/agent/loop.go | cat -n

echo ""
echo "=== Compare with deniedPermissionResult which DOES set DenialReason ==="
sed -n '2445,2463p' internal/agent/loop.go | cat -n

echo ""
echo "=== The vulnerability: PrePermissionRejecter errors don't get DenialReason ==="
echo "Line 1116 returns toolResultFromPrePermissionReject which has NO DenialReason field"
echo "So when isRetriableToolError runs (lines 1897-1901), it checks:"
sed -n '1897,1901p' internal/agent/loop.go

echo ""
echo "=== If a PrePermissionRejecter returns error text matching line 1905 fallback ==="
sed -n '1904,1908p' internal/agent/loop.go

Repository: Gitlawb/zero

Length of output: 3460


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Now check: does any PrePermissionRejecter actually return a denied tool error?
echo "=== PrePermissionRejecter implementations returning errors ==="
rg -n --type go 'RejectBeforePermission' internal/tools/web_fetch.go -A 30 | head -60

echo ""
echo "=== Summary of the vulnerability ==="
echo "1. toolResultFromPrePermissionReject (line 1116 call) never sets DenialReason"
echo "2. If it returns StatusError with text matching 'Permission denied for'"
echo "3. isRetriableToolError returns false (line 1905 match)"
echo "4. countedFailure = false || DenialReason!=DenialNone = false"
echo "5. observeToolResult(false) deletes record, streak resets"
echo ""
echo "=== Confirm: line 748 countedFailure logic ==="
sed -n '742,749p' internal/agent/loop.go

Repository: Gitlawb/zero

Length of output: 2834


Set DenialReason in toolResultFromPrePermissionReject to categorize denials correctly.

The toolResultFromPrePermissionReject function (line 1717) converts a tool's pre-permission error to a ToolResult but does not set DenialReason. When a PrePermissionRejecter tool returns an error message that matches denial text patterns (e.g., "Permission denied for"), isRetriableToolError correctly classifies it as non-retriable, but line 748's countedFailure calculation becomes false because both retriableFailure and DenialReason != DenialNone are false. This allows repeated pre-permission denials to reset the failure streak and continue looping until the turn limit.

Map the pre-permission error output to an appropriate DenialCategory (such as DenialFiltered or DenialPermissionDenied based on the error type) and set it in the returned ToolResult. Alternatively, include pre-permission rejection patterns in the countedFailure condition.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/agent/loop.go` around lines 743 - 749, Update
toolResultFromPrePermissionReject to set ToolResult.DenialReason when converting
a pre-permission rejection, mapping the rejection error type or message to the
appropriate DenialCategory such as DenialFiltered or DenialPermissionDenied.
Preserve the existing output and non-retriable behavior while ensuring
categorized pre-permission denials are counted by the observeToolResult
countedFailure logic.

posture.observeToolOutcome(outcome, toolResult)
if outcome.Stop {
// The assistant message advertised EVERY collected tool call, but
Expand All @@ -750,7 +756,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// messages stay valid for a strict provider replay (Anthropic
// rejects a tool_use with no answering tool_result).
messages = appendAbortedToolResults(messages, collected.ToolCalls[index+1:])
result.FinalAnswer = toolFailureStopAnswer(call.Name, outcome.Count)
result.FinalAnswer = toolFailureStopAnswer(call.Name, outcome.Count, outcome.Varied)
result.Messages = copyMessages(messages)
return result, nil
}
Expand Down
Loading