fix(shell_safety): repair two regressions from #18 and the test that missed them - #19
Open
jasonxi89 wants to merge 2 commits into
Open
Conversation
added 2 commits
August 18, 2026 15:44
…the test that missed them Reviewing my own merged change turned up two behavior regressions and a test that could not have caught either. 1. The bundle calls register_callback(..., fail_closed=True), which only exists in code-puppy>=0.0.735, but declares no dependency on the core at all. Upgrading the bundle against an older core raises TypeError at registration, and the loader logs and skips the entry point — so shell_safety silently does not load. That is worse than the fail-open mpfaffenberger#18 set out to fix. Declares the floor. 2. shell_safety_callback read `model` before consulting yolo_mode, so after mpfaffenberger#18 an unreadable model value refused commands for users who had turned automatic assessment off entirely. yolo_mode is now read first and abstains before anything else is touched; current_model is only used for the OAuth check, so nothing downstream moves. Every read left above the `try` now happens only when safety is actually on. 3. The test fixture used yolo_mode=%, which is read elsewhere on the execution path too — so the command would not have run either way and the assertion proved nothing. Rebuilt around safety_permission_level, the only one of the three read solely by this guard, with a positive control and a pre-change assertion so the pair brackets the behavior. The yolo_mode=% case stays, retitled to what it actually demonstrates. Verified with a 12-state config matrix against the real run_shell_command: exactly one state changes behavior, and it is the intended one.
The floor was one version too high. Scanning published sdists, fail_closed first appears in 0.0.734 — 0.0.733 has no occurrence — so >=0.0.735 would have forced an upgrade off a version that already works. Verified against real isolated installs: pinning 0.0.733 alongside this bundle is now unsatisfiable, and pinning 0.0.734 installs and registers with fail_closed=True. Also asserts the capability, not just the declaration. A dependency line only helps if the resolver acted on it, so the test now inspects the installed register_callback for the keyword and for its opt-in default. A wrong floor or a bypassed resolver fails here instead of at a user's registration.
jasonxi89
marked this pull request as ready for review
August 19, 2026 18:44
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.
Draft — follow-up to #18, from reviewing my own merged change. Two of these are behavior regressions I introduced there; the third is a test that passed for the wrong reason.
Draft because item 1 is a judgement call about how this bundle is versioned, and I would rather agree on that before it lands.
1. The bundle can now silently disable
shell_safety#18 made this package call
register_callback(..., fail_closed=True), a keyword that only exists incode-puppy>=0.0.735. This package declares no dependency oncode-puppyat all, sopip install -U code-puppy-core-pluginsagainst an older core is a supported install path today.What happens then:
and
code_puppy/plugins/__init__.pyhandles a failing entry point like this:Logged, skipped, startup continues. The outcome is not a crash — it is
shell_safetynot loading at all, which is strictly worse than the fail-open #18 set out to fix.Fix: declare the floor.
0.0.734is the first published core with the keyword — scanning sdists,0.0.733has no occurrence and0.0.734has it. Verified against real isolated installs:code-puppy==0.0.733+ this bundlecode-puppy==0.0.734+ this bundleregister_callback(..., fail_closed=True)succeedscode-puppy==0.0.734pinned, floor removed0.0.734being unusableThe suite also asserts the capability rather than only the declaration: a dependency line helps only if the resolver acted on it, so a test inspects the installed
register_callbackfor the keyword and for its opt-in default. A wrong floor or a bypassed resolver fails there instead of at a user's registration.The judgement call for you: a floor is the minimum. If the bundle is expected to track core closely you may want a ceiling or a compatible-release pin instead, and a floor does nothing if a future core removes or renames the keyword. Happy to change it to whatever matches how you release the two together.
I deliberately did not add a
try/except TypeErrorfallback to a two-argument call: that would silently re-register withfail_closed=False, quietly restoring the exact fail-open this was meant to remove.2. Safety being off could still refuse a command
shell_safety_callbackreadmodelbefore consultingyolo_mode:After #18, an unreadable
modelvalue therefore refused commands for users who had turned automatic assessment off entirely. Withyolo_modeoff the user reviews every command by hand, and this guard should not be able to affect the outcome at all.Fix: read
yolo_modefirst and abstain before touching anything else.current_modelis only used for the OAuth check, so nothing downstream moves. Every read left above thetrynow happens only when safety is genuinely on — which is exactly the surfacefail_closedis meant to cover.A mirror regression I checked for and could not reproduce. Swapping the order raises the symmetric question: does
OAuth model + unreadable yolo_modenow get refused where it previously passed? Measured both orderings — it does not, because an unreadableyolo_modealso fails on the caller's own path, so the command stops regardless of what this guard decides:yolo_mode=%(nope)syolo_mode=%(nope)sThe underlying observation still stands as design feedback: OAuth and
yolo_mode=falseare two independent reasons this guard does not apply, and ordering them means a broken read for one can mask the other. Making that order-independent is a larger change than this PR, and today it is unobservable. Happy to follow up if you want it.3. The test in #18 passed for the wrong reason
The fixture used
yolo_mode=%. Measured across all three unprotected reads, one isolated process per row:yolo_mode=%model=%(missing)ssafety_permission_level=%(nope)syolo_modeis also read on the caller's own path, so corrupting it stops the command for an unrelated reason and the guard's failure is masked. The other two are not: the rest of the system stays healthy while the assessment dies, and the command runs unchecked.So the one config the original fixture used is the one config that proves nothing:
test_the_command_never_reaches_the_shellasserted an absent sentinel in a case where the sentinel would have been absent anyway. It could not have caught a regression.Rebuilt around the case that discriminates, plus two things the original lacked:
assert sentinel.exists()), so the pair brackets the behaviorThe
yolo_mode=%case is kept, retitled to what it actually shows: the opt-in converts an escaping exception into a structured refusal.Verification
Every figure below is one measurement per interpreter process, driving the real
run_shell_commandand watching for a side effect on disk:safety_permission_levelunreadable, no opt-in — the bugsafety_permission_levelunreadable, with opt-in — the fixyolo_mode=false+ unreadablemodel, model-first — the regressionyolo_mode=false+ unreadablemodel, yolo-first — item 2Every table in this description was re-measured this way after the first draft; an earlier version of the item 3 table was taken from a shared-process run and reported
model=%(missing)sas not running, which is wrong.One measurement per process matters here:
code_puppy.configcaches across reads within a process, so a parameterised matrix sharing one interpreter reports stale values. An earlier draft of this table was measured that way and disagreed with itself between runs; these numbers are from isolated processes and reproduce.Each of the ten tests also passes when run alone, not only as a suite.
Suite: 1982 passed, 2 skipped.
ruff check/ruff formatclean.Framing
Worth being precise about severity: the trigger is a local config that cannot be read, not attacker-controlled input. This is fail-closed hardening and a regression fix, not a remotely exploitable vulnerability — I would rather #18 and this PR were not read as more than they are.