Fix a bash permission bypass and stop the stall checkpoint firing on a finishing run - #790
Merged
Merged
Conversation
added 6 commits
August 15, 2026 08:10
…a finishing run
parse_bash_segments_full("a && b 2>&1 | c") returned (["c"], false). The
redirected_statement arm recursed only into children whose kind was
command/pipeline/compound_statement/subshell and dropped the rest, and
tree-sitter parses that as redirected_statement(body: list(a && b)) — so both
commands vanished and the engine authorized a command it had never seen.
Measured: `python3 -c "open('x','w')"` is denied, and the same call wrapped as
`echo hi && python3 -c "…" 2>&1 | cat` ran and wrote the file. Recurse into
everything that isn't a redirect operand, and mark a command the splitter could
not decompose as complex so a matching head can't silently allow it either.
The progress monitor counted every turn boundary, including turns that called
nothing and turns whose calls all failed. Its own premise is successful, varied,
useless calls — errors belong to the failure tracker. That made a run's endgame
barren by definition (todos closed, files touched, green latched), so a long
enough run was guaranteed to be told it had stalled while finishing. Now only a
boundary with a successful call is judged.
Two related fixes at the same seam. A boundary that ends the inner loop belongs
to poll_finalization_follow_up, which was already speaking on it — both arbiters
could push a message before one assistant turn. And a checkpoint the arbiter
declines is no longer charged to its budget: record_turn offers, commit spends.
That was the wart documented at poll_boundary_nudge, and it meant the
masked-verification decline silently burned one of a run's two stall nudges.
`--trace` now records a boundary the arbiter stood down on, with the rule that
declined it and the checkpoint left standing. Without it a trace cannot tell
"the stall never came up" from "it came up three times and was declined".
pytest/ruff/black/mypy are allowed; the bare python/python3 interpreters deliberately are not, and nothing bridged the two. So `python3 -m pytest` — the commonest way a model runs pytest, and the shape the verify nudge asks for — prompted, which headless turns into a denial. Until the splitter fix the only form that ran was `… 2>&1 | tail`, i.e. the masked shape the verifier declines, so the harness demanded what it forbade. Not fixed by stripping the interpreter prefix for allow-matching, which is what the issue proposed. match_candidates exposes commands raw on purpose (dirge-8zem): `PATH=/tmp/evil git push` and `./env git push` run a different binary under an allowed name, and there is a test pinning that. The module form is named explicitly instead, generated from PYTHON_MODULE_TOOLS so the eight rules aren't a second list to keep in step with the first, with a test that every entry is still allowed under its own name. The deny side sees through the module runner too — otherwise the new allows would be a way around a deny on the tool that used to hold. Widening deny candidates is the existing rule there. Measured against the release binary on the same task and stock config that previously denied every verification attempt: 3 errored bash calls and an unverified run become 0 errored and 22/22, with the model correcting its masked command on the first nudge.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Started as dirge-hwk9.7 (the stall checkpoint landing 0.1s before a successful run ends) and turned into three fixes, each found by re-running with
--traceafter the previous one.The stall on a finishing run (dirge-hwk9.7)
The bead assumed a coincidence of timing. It is not one. By the time a run is finishing, its todos are closed (cannot decrease), its files are touched (cannot increase) and its green is latched (no fresh edge) — all three progress signals are structurally unable to move, so every endgame boundary is barren by definition and a long enough run was guaranteed to be told it had stalled. That is why two different models produced the same symptom to within 0.1s.
progress.rs opens by saying what it is for: successful, varied, useless calls, with the other guards owning everything else. It scored every boundary anyway, including turns that called nothing and turns whose calls all failed. The second is worse than noise — the stall text asserts "the calls are succeeding", and a traced run shows it delivered on a boundary whose one call was permission-denied, after which the model spent its final answer arguing that nothing was blocking it.
Three changes at one seam:
poll_finalization_follow_up. Both arbiters were polling it, unranked, so two harness messages could land before one assistant turn. dirge-5mtx.2 closed this at the mid-turn boundary and left the seam between the arbiters open. Safe-state is exempt — an abort with a tree restore is not steering;record_turnoffers,commitspends. A declined checkpoint is no longer charged. That was the wart documented atpoll_boundary_nudge, and it meant the masked-verification decline silently burned one of a run's two stall nudges.Rejected on the evidence: the bead's own suggestion that a boundary following a verification count differently. Resetting on any verification run kills
green_suite_thrash_on_one_file_still_stalls, which is the case the monitor exists for.The bypass (dirge-5flx)
Chasing why that call was denied found a permission-containment bug.
parse_bash_segments_full("a && b 2>&1 | c")returned(["c"], false)— theredirected_statementarm recursed only into an allowlist of kinds and dropped the rest through a bare_ => {}, and tree-sitter parses that input asredirected_statement(body: list(a && b)). End to end against the release binary:Any denied command runs by prefixing
echo hi &&and appending2>&1 | cat. The arm now recurses into everything that is not a redirect operand, so an unknown grammar node over-collects rather than disappearing; and a command the splitter could not decompose is marked complex, which is the backstop that would have contained this instead of letting it become a bypass.The harness demanding what it forbade (dirge-e1nv)
Closing the bypass made a second contradiction visible.
pytest **is allowed and the bare interpreters deliberately are not, with nothing bridging them — sopython3 -m pytest, the commonest way a model runs pytest and the shape the verify nudge asks for, prompted, which headless turns into a denial. The bypass had been the only way that command ran, and only in its masked form, which the verifier declines.Not fixed the way the issue proposed. Stripping the interpreter prefix for allow-matching is what
dirge-8zemdeliberately rejected —match_candidatesexposes commands raw becausePATH=/tmp/evil git pushand./env git pushrun a different binary under an allowed name, pinned byenv_and_wrapper_prefixes_do_not_ride_an_allow_rule. The module form is named explicitly instead, generated fromPYTHON_MODULE_TOOLSso the eight rules are not a second list, with a test that every entry is still allowed under its own name.-mdoes not make the interpreter safe:python3 -m http.serverandpython3 -m pip installkeep prompting. The deny side sees through the module runner too, or the new allows would be a way around a deny that used to hold.Trace
--tracerecords a boundary the arbiter stood down on, with the rule that declined it and the checkpoint left standing. A stand-down otherwise leaves no evidence at all, and a trace cannot tell "the stall never came up" from "it came up three times and was declined".Verification
Same task and model before and after:
[stall]at 56.9s of a 58.9s run → no stall,VerifiedGreen, with the offer standing down once asmasked-verification(budget kept) and twice asconcluding. glm-5.3 66.8s 22/22 and the qwen3.8-27b floor 961.8s 22/22 (the original 618s data point) both silent, both with[verify-before-done]doing the work alone on the terminal boundaries. On the stock config, which had denied every verification attempt: 3 errored bash calls and an unverified run → 0 errored, 22/22, 42.5s.Discrimination control, which matters more than the fix: a task that writes a file (arming the monitor) then searches for a symbol that does not exist fired
[stall]twice, at 13.3s and 38.2s of a 241s run, both mid-run, and stood down on the final boundary. Narrowing the monitor did not mute it.Full suite 5600 pass, four clippy configs and fmt clean.