-
Notifications
You must be signed in to change notification settings - Fork 127
fix(agent): stop a denied tool looping past the repeated-failure halt #866
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 || trueRepository: 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 -20Repository: 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.goRepository: 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 -80Repository: 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 -20Repository: 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.goRepository: 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 -20Repository: 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 -10Repository: 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 -40Repository: 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.goRepository: 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.goRepository: 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.goRepository: 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.goRepository: Gitlawb/zero Length of output: 2834 Set The Map the pre-permission error output to an appropriate 🤖 Prompt for AI Agents |
||
| posture.observeToolOutcome(outcome, toolResult) | ||
| if outcome.Stop { | ||
| // The assistant message advertised EVERY collected tool call, but | ||
|
|
@@ -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 | ||
| } | ||
|
|
||
There was a problem hiding this comment.
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
alwaysPromptingTooldeclaration.alwaysPromptingToolis 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