Skip to content

feat(budget): deny-by-default side-effect lockout on the grace turn (Guard D-core) - #37

Merged
Kyzcreig merged 1 commit into
mainfrom
guard/budget-grace-tool-gate
Jun 21, 2026
Merged

Kyzcreig merged 1 commit into
mainfrom
guard/budget-grace-tool-gate

Conversation

@Kyzcreig

Copy link
Copy Markdown
Collaborator

Guard D-core — deny-by-default side-effect lockout on the budget grace turn

Problem

The tool-calling loop (agent/conversation_loop.py) runs one optional grace turn after the iteration budget is exhausted, gated by the _budget_grace_call hook. That grace turn executes whatever tools the model returns — including side-effecting ones (terminal, execute_code, write_file, delegate_task, send_message, …). For a runaway worker, that's one more chance to spawn a subprocess or write to disk after its budget is already gone.

This is the root-cause complement to the runaway-worker guards (B/E/F) that cap blast radius around the loop. This stops the loop itself from taking a side-effecting action past budget.

Change

  • New pure module agent/budget_grace_gate.py — a small, audited read-only allowlist (read_file, search_files, TaskSearch, session_search, mem0_search/mem0_profile, skill_view, skills_list, read-only MCP filesystem tools) and is_readonly_grace_tool() which refuses everything else, including unknown / future / third-party tool names (deny-by-default, not a denylist). The known-mutating set (MUTATING_TOOL_NAMES) always wins over the allowlist as defense-in-depth.
  • conversation_loop.py sets agent._in_budget_grace = True only on the grace turn (recomputed False every other iteration). A refusal never re-arms _budget_grace_call, so a deny can't loop the agent alive.
  • Both dispatch paths (agent/tool_executor.py concurrent + sequential) refuse non-allowlisted tools during the grace turn with a clear blocked tool result (error_type=budget_grace_block) and skip real execution. Per-call gating: a mixed batch executes the read-only call and refuses the side-effecting one individually.

Honest scope note

_budget_grace_call is currently never armed to True anywhere in-tree — the grace turn is a dormant hook, so today the loop breaks on budget exhaustion before any tool runs. This change is therefore defense-in-depth for a latent hole, not a fix for a live exploit. Because the path is dormant, an "exhaust budget end-to-end" test would be vacuously green; the tests instead drive _in_budget_grace directly through the real dispatchers — the exact state the loop sets on the grace turn.

Tests — tests/agent/test_budget_grace_gate.py (13)

  • Predicate: allowlisted permitted; side-effecting refused; unknown/future/empty/None refused (deny-by-default); mutating-set-wins + allowlist∩mutating == ∅; block message/result shape.
  • Real dispatch integration (both paths): refuse side-effecting (handle_function_call not called, blocked role=tool message appended), allow read-only, refuse unknown, mixed-batch per-call gate, no-grace control (side effect runs normally), no-rearm of the grace flag.
  • RED-proven: stashing the dispatcher gate fails exactly the 4 side-effect-refusal integration tests; restored → green.
  • No regression: existing test_tool_call_guardrail_runtime.py + test_tool_guardrails.py + this suite = 35 passed. ruff check clean on all changed files.

Rollback

Self-contained: revert the commit. The new module is additive; the loop/dispatcher changes are guarded by _in_budget_grace (default False), so behavior outside the grace turn is unchanged.

@github-actions

github-actions Bot commented Jun 14, 2026 •

Copy link
Copy Markdown

🔎 Lint report: guard/budget-grace-tool-gate vs origin/main

ruff

Total: 0 on HEAD, 0 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 0 pre-existing issues carried over.

ty (type checker)

Total: 11235 on HEAD, 11220 on base (🆕 +15)

🆕 New issues (11):

Rule Count
unresolved-attribute 9
invalid-argument-type 1
invalid-assignment 1
First entries
tests/agent/test_budget_grace_gate.py:317: [unresolved-attribute] unresolved-attribute: Object of type `AIAgent` has no attribute `_budget_grace_call`
tests/agent/test_budget_grace_gate.py:122: [unresolved-attribute] unresolved-attribute: Unresolved attribute `_use_prompt_caching` on type `AIAgent`
tests/agent/test_budget_grace_gate.py:121: [unresolved-attribute] unresolved-attribute: Unresolved attribute `_cached_system_prompt` on type `AIAgent`
tests/agent/test_budget_grace_gate.py:308: [unresolved-attribute] unresolved-attribute: Unresolved attribute `_in_budget_grace` on type `AIAgent`
tests/agent/test_budget_grace_gate.py:123: [unresolved-attribute] unresolved-attribute: Unresolved attribute `tool_delay` on type `AIAgent`
tests/agent/test_budget_grace_gate.py:124: [unresolved-attribute] unresolved-attribute: Unresolved attribute `compression_enabled` on type `AIAgent`
tests/agent/test_budget_grace_gate.py:309: [unresolved-attribute] unresolved-attribute: Unresolved attribute `_budget_grace_call` on type `AIAgent`
tests/agent/test_budget_grace_gate.py:290: [unresolved-attribute] unresolved-attribute: Object of type `AIAgent` has no attribute `_in_budget_grace`
tests/agent/test_budget_grace_gate.py:125: [unresolved-attribute] unresolved-attribute: Unresolved attribute `save_trajectories` on type `AIAgent`
tests/agent/test_budget_grace_gate.py:57: [invalid-argument-type] invalid-argument-type: Argument to function `is_readonly_grace_tool` is incorrect: Expected `str`, found `None`
tests/run_agent/test_credits_notices_toggle.py:76: [invalid-assignment] invalid-assignment: Object of type `None` is not assignable to attribute `_credits_session_start_micros` of type `int`

✅ Fixed issues (2):

Rule Count
unresolved-attribute 2
First entries
tests/run_agent/test_credits_notices_toggle.py:76: [unresolved-attribute] unresolved-attribute: Unresolved attribute `_credits_session_start_micros` on type `AIAgent`
run_agent.py:2931: [unresolved-attribute] unresolved-attribute: Object of type `Self@get_credits_spent_micros` has no attribute `_credits_session_start_micros`

Unchanged: 5860 pre-existing issues carried over.

Diagnostics are surfaced as warnings — this check never fails the build.

@greptile-apps

greptile-apps Bot commented Jun 21, 2026 •

Copy link
Copy Markdown

Greptile Summary

Introduces Guard D-core: a deny-by-default side-effect lockout that prevents the tool dispatchers from executing mutating tools during the budget grace turn. _in_budget_grace is set in the loop exactly once per grace turn and reset at the top of every other iteration, so the gate is self-contained and cannot affect normal execution.

  • agent/budget_grace_gate.py: new pure module with an explicit read-only allowlist, is_readonly_grace_tool() predicate (deny-by-default, mutating-set always wins), and grace_block_result() helper now called consistently from both dispatch paths so the budget_grace_block metadata key appears in the actual model-visible JSON.
  • agent/tool_executor.py: both concurrent (parse phase) and sequential paths gate on _in_budget_grace before other block checks; both use grace_block_result() and pass middleware_trace to _emit_terminal_post_tool_call, matching sibling block-path telemetry.
  • Tests: 13 tests drive the real dispatchers with _in_budget_grace = True to prove side-effecting tools are refused, read-only tools execute, mixed batches are gated per-call, and the no-rearm invariant holds; the budget_grace_block shape is verified against both paths.

Confidence Score: 5/5

Safe to merge — the gate is guarded by _in_budget_grace (default False), so all code paths outside the grace turn are completely unaffected.

The change is additive and self-contained: the new module is pure, both dispatch paths correctly call grace_block_result() and pass middleware_trace, the loop resets _in_budget_grace at the top of every iteration preventing stale state, and 13 tests drive the real dispatchers to verify the gate in both concurrent and sequential paths with mixed batches. All concerns from prior review rounds have been addressed in this revision.

No files require special attention.

Important Files Changed

Filename Overview
agent/budget_grace_gate.py New pure module implementing the deny-by-default grace-turn allowlist, block message, and block result helpers. Logic is correct, allowlist is small and audited, mutating-set override is robust, and __all__ export is consistent with what the dispatchers actually import.
agent/conversation_loop.py Correctly sets _in_budget_grace = False at the top of every iteration (guaranteed reset) and True only when the grace flag is consumed; the grace flag is cleared before the gate is raised, preventing any re-arm.
agent/tool_executor.py Both concurrent (parse-phase) and sequential paths now check _in_budget_grace before other block types, use grace_block_result() for the model-visible JSON (with the budget_grace_block metadata key), and pass middleware_trace to _emit_terminal_post_tool_call consistently with sibling block paths.
agent/agent_init.py Adds _in_budget_grace = False initialization alongside the existing _budget_grace_call flag; correct placement and default value.
tests/agent/test_budget_grace_gate.py 13 tests covering both predicate-layer and real-dispatcher integration for sequential and concurrent paths, including mixed-batch per-call gating, deny-by-default for unknown tools, the budget_grace_block metadata key in both dispatchers, and the no-rearm invariant. One inline comment is stale after the shared-helper fix (see comment).

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Loop iteration start] --> B["agent._in_budget_grace = False"]
    B --> C{_budget_grace_call?}
    C -- Yes --> D["_budget_grace_call = False\n_in_budget_grace = True"]
    C -- No --> E{Budget remaining?}
    E -- No --> F[break — budget_exhausted]
    E -- Yes --> G[API call → tool calls returned]
    D --> G
    G --> H{_in_budget_grace?}
    H -- False --> I[Normal tool execution]
    H -- True --> J{is_readonly_grace_tool?}
    J -- Yes / allowlisted --> K[Execute tool normally]
    J -- No / side-effect / unknown --> L["grace_block_result()\nerror_type=budget_grace_block\n_emit_terminal_post_tool_call"]
    L --> M[Append blocked role=tool message]
    K --> N[Append result role=tool message]
    M --> O[Next iteration → _in_budget_grace reset to False → budget exhausted → break]
    N --> O
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[Loop iteration start] --> B["agent._in_budget_grace = False"]
    B --> C{_budget_grace_call?}
    C -- Yes --> D["_budget_grace_call = False\n_in_budget_grace = True"]
    C -- No --> E{Budget remaining?}
    E -- No --> F[break — budget_exhausted]
    E -- Yes --> G[API call → tool calls returned]
    D --> G
    G --> H{_in_budget_grace?}
    H -- False --> I[Normal tool execution]
    H -- True --> J{is_readonly_grace_tool?}
    J -- Yes / allowlisted --> K[Execute tool normally]
    J -- No / side-effect / unknown --> L["grace_block_result()\nerror_type=budget_grace_block\n_emit_terminal_post_tool_call"]
    L --> M[Append blocked role=tool message]
    K --> N[Append result role=tool message]
    M --> O[Next iteration → _in_budget_grace reset to False → budget exhausted → break]
    N --> O
Loading

Reviews (4): Last reviewed commit: "feat(budget): deny-by-default side-effec..." | Re-trigger Greptile

Comment thread agent/tool_executor.py
Comment on lines +94 to +106
def grace_block_result(tool_name: str) -> str:
"""Build the synthetic role=tool content string for a grace-refused call.

Shape mirrors the plugin/guardrail block path in the dispatchers
(``{"error": ...}``) so the model sees a consistent blocked-tool result.
"""
return json.dumps(
{
"error": grace_block_message(tool_name),
"budget_grace_block": {"tool_name": tool_name, "reason": "budget_exhausted_grace_turn"},
},
ensure_ascii=False,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 grace_block_result() is exported in __all__ and tested in test_block_message_and_result_shape, but neither dispatcher imports or calls it. Both the concurrent path (line 341 of tool_executor.py) and the sequential path (line 962) build their own json.dumps({"error": _grace_msg}) directly, omitting the budget_grace_block nested object. As a result the structured field — which the docstring says is intended to carry telemetry — never actually appears in any tool result message. test_block_message_and_result_shape is therefore verifying behaviour of a dead code path rather than what the model or a log parser would see. Either the dispatchers should call grace_block_result() instead of constructing their own JSON, or the function should be removed (or at least not re-exported) to avoid the false guarantee.

@greptile-apps

greptile-apps Bot commented Jun 21, 2026 •

Copy link
Copy Markdown

Greptile Summary

This PR introduces agent/budget_grace_gate.py, a deny-by-default side-effect lockout for the optional post-budget grace turn. Both dispatch paths (execute_tool_calls_concurrent and execute_tool_calls_sequential) now check agent._in_budget_grace and refuse any tool not on a small, audited read-only allowlist — including unknown and future tool names. The guard is defense-in-depth for a latent hole (_budget_grace_call is currently never armed), so existing behavior is unchanged.

  • New gate module (budget_grace_gate.py) provides GRACE_READONLY_TOOLS, is_readonly_grace_tool(), grace_block_message(), and grace_block_result(); the allowlist∩mutating-set is verified empty at test time.
  • conversation_loop.py uses a reset-then-set pattern (_in_budget_grace = False every iteration, True only on the grace turn) that prevents any re-arm of the loop after a refusal.
  • grace_block_result() is exported in __all__ and its budget_grace_block JSON key is asserted in test_block_message_and_result_shape, but neither dispatcher imports or calls it — both build their block result inline as {"error": ...}, so the tested shape diverges from what the model actually receives.

Confidence Score: 4/5

Safe to merge — the gate is additive and gated behind a flag that is never armed today, so existing behavior is fully preserved.

The logic is sound and the reset-then-set pattern in the loop is correct. The one notable gap is that grace_block_result() — the helper whose JSON shape is unit-tested — is never called by either dispatcher; both build their block result inline as {"error": ...}, so the budget_grace_block metadata key the test asserts is absent from actual tool results. There is also no mixed-batch test for the concurrent path.

agent/tool_executor.py — the inline {"error": ...} construction in both dispatcher paths should use grace_block_result() to match the tested shape.

Important Files Changed

Filename Overview
agent/budget_grace_gate.py New deny-by-default gate module; grace_block_result() is exported in __all__ and unit-tested but never called by either dispatcher, creating a tested JSON shape that the model never actually receives.
agent/tool_executor.py Both dispatch paths correctly gate on _in_budget_grace; block result is built inline as {"error": ...} rather than via grace_block_result(), omitting the budget_grace_block metadata key that the unit test validates.
agent/conversation_loop.py Reset-then-set pattern for _in_budget_grace is safe; grace flag is consumed before the flag is armed, ensuring no re-arm is possible.
agent/agent_init.py Cleanly initializes _in_budget_grace = False alongside the existing grace-call flags; no issues.
tests/agent/test_budget_grace_gate.py Good integration and predicate coverage; test_block_message_and_result_shape validates grace_block_result() shape but the dispatchers never call that function, so the budget_grace_block key assertion has no live counterpart; also missing a concurrent mixed-batch (read + side-effect) test.

Comments Outside Diff (1)

  1. agent/tool_executor.py, line 960-975 (link)

    P2 Sequential path has the same divergence: it builds json.dumps({"error": _block_msg}) inline rather than using grace_block_result(). Replace so both paths produce the same structure and the budget_grace_block metadata key appears in the actual tool result.

Reviews (2): Last reviewed commit: "feat(budget): deny-by-default side-effec..." | Re-trigger Greptile

Comment thread agent/tool_executor.py
Comment thread agent/tool_executor.py Outdated
Comment thread tests/agent/test_budget_grace_gate.py
…Guard D-core)

The tool-calling loop in agent/conversation_loop.py runs one optional grace
turn after the iteration budget is exhausted (the `_budget_grace_call` hook).
That grace turn would execute whatever tools the model returns — including
side-effecting ones (terminal, execute_code, write_file, delegate_task,
send_message). For a runaway worker that's one more chance to spawn/write past
its budget.

This adds a deny-by-default gate for the grace turn:
- New pure module agent/budget_grace_gate.py: a small audited read-only
  allowlist (read_file, search_files, TaskSearch, session_search, mem0_search/
  profile, skill_view, skills_list, read-only MCP fs) and is_readonly_grace_tool()
  which refuses everything else — including unknown/future/third-party tool
  names. The known-mutating set always wins over the allowlist.
- conversation_loop.py sets agent._in_budget_grace True ONLY on the grace turn
  (recomputed False every other iteration). Refusing a call never re-arms the
  grace flag, so a deny can't loop.
- Both dispatch paths (concurrent + sequential, agent/tool_executor.py) refuse
  non-allowlisted tools during the grace turn with a clear blocked tool result
  (error_type=budget_grace_block) and skip real execution — per-call gating, so
  a mixed batch runs the read and refuses the side effect.

Note: `_budget_grace_call` is currently never armed to True in-tree, so this is
defense-in-depth for a latent hole, not a live bug. Tested accordingly by
driving _in_budget_grace directly through the real dispatchers.

Tests (tests/agent/test_budget_grace_gate.py, 13): predicate allow/deny/unknown/
mutating-overlap/message-shape + real-dispatch integration on both paths
(refuse side-effect, allow read-only, refuse unknown, mixed-batch per-call gate,
no-grace control, no-rearm). RED-proven: stashing the dispatcher gate fails
exactly the 4 side-effect-refusal integration tests; restored → green. No
regression in the existing guardrail-runtime/guardrail suites (35 passed).
@Kyzcreig
Kyzcreig force-pushed the guard/budget-grace-tool-gate branch from 10b13dd to 0803c67 Compare June 21, 2026 03:08
@Kyzcreig
Kyzcreig merged commit 2258d37 into main Jun 21, 2026
37 checks passed
@Kyzcreig
Kyzcreig deleted the guard/budget-grace-tool-gate branch June 21, 2026 03:14
Kyzcreig pushed a commit that referenced this pull request Sep 28, 2026
…39, #35 sibling)

- #37 import map is module-level; function-local imports bind only in their function
- #38 sink_dotted honoured when sink_names is None
- #39 a function-local import shadows a same-file def
- offload-call args walked (eager), lambda args still deferred
New precision arms: 4 red on base, 5/5 green. Consumer gates 32/32; the
widened walker surfaced 2 pre-existing telegram get_label->requests.get
reaches (same shape as baselined matrix entry), added to REACHABLE_BASELINE.
Kyzcreig pushed a commit that referenced this pull request Sep 28, 2026
…39, #35 sibling)

- #37 import map is module-level; function-local imports bind only in their function
- #38 sink_dotted honoured when sink_names is None
- #39 a function-local import shadows a same-file def
- offload-call args walked (eager), lambda args still deferred
New precision arms: 4 red on base, 5/5 green. Consumer gates 32/32; the
widened walker surfaced 2 pre-existing telegram get_label->requests.get
reaches (same shape as baselined matrix entry), added to REACHABLE_BASELINE.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant