Skip to content

Fix a bash permission bypass and stop the stall checkpoint firing on a finishing run - #790

Merged
yogthos merged 6 commits into
mainfrom
boundary-arbiter-and-bash-splitter
Aug 15, 2026
Merged

Fix a bash permission bypass and stop the stall checkpoint firing on a finishing run#790
yogthos merged 6 commits into
mainfrom
boundary-arbiter-and-bash-splitter

Conversation

@yogthos

@yogthos yogthos commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

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 --trace after 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:

  • only a boundary with a successful tool call is judged;
  • the boundary that ends the inner loop belongs to 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_turn offers, commit spends. A declined checkpoint is no longer charged. 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.

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) — the redirected_statement arm recursed only into an allowlist of kinds and dropped the rest through a bare _ => {}, and tree-sitter parses that input as redirected_statement(body: list(a && b)). End to end against the release binary:

python3 -c "open('pwned_a','w')"                        -> denied
echo hi && python3 -c "open('pwned_b','w')" 2>&1 | cat  -> ran, wrote the file

Any denied command runs by prefixing echo hi && and appending 2>&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 — 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. 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-8zem deliberately rejected — match_candidates exposes commands raw because PATH=/tmp/evil git push and ./env git push run a different binary under an allowed name, pinned by env_and_wrapper_prefixes_do_not_ride_an_allow_rule. The module form is named explicitly instead, generated from PYTHON_MODULE_TOOLS so the eight rules are not a second list, with a test that every entry is still allowed under its own name. -m does not make the interpreter safe: python3 -m http.server and python3 -m pip install keep 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

--trace records 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 as masked-verification (budget kept) and twice as concluding. 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.

Yogthos 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.
@yogthos
yogthos merged commit adfa911 into main Aug 15, 2026
15 checks passed
@yogthos
yogthos deleted the boundary-arbiter-and-bash-splitter branch August 15, 2026 15:09
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