fix(guardrails): loop-detect read-only shell commands in terminal - #79839
fix(guardrails): loop-detect read-only shell commands in terminal#79839rodrigogs wants to merge 3 commits into
Conversation
|
Related: #79840 fixes the fallback-chain defect that put the loop-prone free model in charge in the first place. The two are independent — this PR stops the loop, that one stops the bad provider selection — but they were found in the same incident. |
a989c3d to
76ab2c2
Compare
30d95c0 to
dde6e00
Compare
|
Hi — heads-up that CI has never actually run on this PR: every workflow run (CI + Docker Build) since it was opened ends in |
f2b4e10 to
b970ecf
Compare
|
Rebased onto current RebaseConflict-free, and the change is unaltered — the diff against the new base is byte-for-byte the same size Verification on the rebase: Follow-up: the two new knobs are now real config keys
No
One thing left for you to rule onA looping terminal call now receives main's stall-guard notice (3rd consecutive identical call) and this This repository does not run CI on pull requests from forks, so the checks tab stays empty and |
A session repeated one `gh api .../pulls/N/reviews` call 517 times over 46 minutes, each returning a byte-identical `[]` with exit 0, and nothing stopped it until max_iterations. Both loop detectors missed it: - The no-progress detector only considered tools in IDEMPOTENT_TOOL_NAMES. `terminal` is in MUTATING_TOOL_NAMES, so repeated identical results were never tracked for it — even though the command being repeated was a read. - The failure detector only counts non-zero exits. The command succeeded every time; it was logically stuck, not failing. Classify the command instead of only the tool name. `shell_command_is_read_only` walks each `&&`/`|`/`;`-separated segment and requires every one to be a known read (allowlisted commands, plus per-tool read subcommands so `gh pr view` reads while `gh pr merge` does not). Unknown commands, command substitution, file redirections and mutating flags all classify as writes, so a miss costs a later block rather than a wrongly-blocked write. Mutating calls now get the same detector at a looser ceiling (mutating_no_progress_block_after, default 12): a write repeating identical args AND output is also making no progress, just with a weaker signal since it may be legitimately polling something external. Replaying the affected session against the patched controller blocks at the 6th identical call instead of admitting all 517.
Self-review of the previous commit found the classifier leaking in both directions. False negatives: a single global mutating-flag list conflated flags whose meaning is per-command. `-f` is `--field` to gh but `--file` to grep, and `-x` is `--method` to gh but `--exclude-type` to df, so `grep -f patterns.txt file`, `df -x tmpfs`, `test -f x` and `ps -f` were all classified as writes. False positives, which matter more: allowlisting a subcommand admitted its write siblings. `git config user.name foo`, `git config --unset`, `git branch -D`, `git branch newbranch`, `git tag v1.0`, `gh label create`, `find . -delete`, `find . -exec rm`, `sort -o out.txt` and `jq -i` all classified as read-only, which would have given a repeated write the tight read-only threshold. Replace the global list with per-command write-flag sets, split `gh`'s noun-verb pairs from its bare subcommands, and add _FLAG_DECIDED_SUBCOMMANDS for the cases where flags rather than the name decide (`git config --get` reads, `git config k v` writes; `git branch` lists, `git branch name` creates). Also raise hard_stop_after.idempotent_no_progress from 5 to 10 in the shipped configs: a legitimate `sleep`-then-poll CI wait repeats a stable read, and 5 cut it off too early. The affected session's loop still dies at call 11 instead of running 517 times.
The previous commits added `warn_after.mutating_no_progress` and `hard_stop_after.mutating_no_progress` to ToolCallGuardrailConfig but never to DEFAULT_CONFIG. `hermes config set` validates dotted keys by walking DEFAULT_CONFIG, so both knobs were inert from the CLI: setting either printed "'...' is not a recognized config key" and the operator was stuck on the built-in 4 / 12 no matter what they wrote. Register both next to their idempotent siblings, document them in the configuration guide and cli-config.yaml.example, and note that which pair applies to a `terminal` call is decided by the command rather than the tool name. Purely additive defaults, so no `_config_version` bump — load_config deep-merges DEFAULT_CONFIG, so existing config.yaml files pick them up on the next read. Thresholds and detector behaviour are unchanged.
bbdb26a to
5badc74
Compare
|
Force-pushed a metadata-only fix so the contributor attribution check can pass.
Every commit's author is now |
Problem
A single
terminalcall repeated 517 times in one turn, ~46 minutes, each returning a byte-identical[]withexit_code: 0. Nothing stopped it untilmax_iterations.Both loop detectors in
ToolCallGuardrailControllermissed it:IDEMPOTENT_TOOL_NAMES.terminalis inMUTATING_TOOL_NAMES, so_is_idempotent()returnsFalseand repeated identical results were never tracked — even though the command being repeated was a pure read.classify_tool_failure). The command succeeded every time. Logically stuck, technically fine.Same session also showed 89x, 69x, 38x, 36x and 26x repeats of other
terminalcommands.Fix
Classify the command, not just the tool name.
shell_command_is_read_only()splits on&&/||/;/|/newline and requires every segment to be a known read. Allowlist-only — the default answer is "this writes":$(...)/ backticks → write (can hide anything)> out/>> out→ write;2>&1and2>/dev/nulldo not disqualify-fis--fieldtoghbut--filetogrep;-xis--methodtoghbut--exclude-typetodf_FLAG_DECIDED_SUBCOMMANDShandles cases where flags decide:git config --getreads,git config k vwrites;git branchlists,git branch namecreatesA miss therefore costs a later block (the mutating threshold still applies), never a wrongly-blocked write.
Mutating calls now get the same detector at a looser ceiling (
mutating_no_progress_block_after, default 12): a write repeating identical args and output is also making no progress, just with a weaker signal since it may be legitimately polling something external.Verification
sleep-then-poll CI wait with stable output survives 20 polls; 30 distinct reads and a rebuild loop whose output changes are never blocked.pytest -k "guardrail or no_progress or loop_cap or tool_executor or turn_finalizer or turn_context"→ 97 passed, 4 skipped.ruffclean.Note on thresholds
The shipped
hard_stop_after.idempotent_no_progressof 5 cut off a legitimate CI wait too early in testing. Operators running this may want 10.hard_stop_enabledstill defaults tofalse, so nothing changes for existing users until they opt in.