Skip to content

fix: deny shell patterns that expand onto the protected directory - #568

Merged
hyoshi merged 12 commits into
mainfrom
fix/bash-guard-glob-bypass
Aug 11, 2026
Merged

fix: deny shell patterns that expand onto the protected directory#568
hyoshi merged 12 commits into
mainfrom
fix/bash-guard-glob-bypass

Conversation

@hyoshi

@hyoshi hyoshi commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

The hole

The Bash guard looks for .mureo as six consecutive literal characters. A metacharacter placed inside the directory name breaks that match, while the shell still expands the pattern onto the real directory. Verified against a throwaway HOME with bash 5.2 — each of these printed the credentials file, and each was allowed by the guard on main:

cat ~/.mure?/credentials.json
cat ~/.[m]ureo/credentials.json
cat ~/.mur*/credentials.json
cat ~/.m?reo/credentials.json
cat ~/.?????/credentials.json
cat ~/.[!.]*/credentials.json
cat ~/.mure{o,x}/credentials.json     # brace expansion runs before globbing

This is not a regression from #567. The bare substring rule that preceded it missed the same forms. #393 noted "wildcards pass through" and closed the wildcard that comes after the name (cat ~/.mureo/cred*) — the inside of the name was never covered, and the docstring's "also catches wildcard forms" made the partial fix read as a complete one. That sentence is the reason this survived five months, so it is gone.

The path guard is unaffected: it compares os.path.realpath / os.path.abspath of a concrete path field, and no shell expands anything on that route. file_path: "~/.mure?/credentials.json" is allowed by the guard and opens nothing, because no such literal path exists.

Why not a regex

The string that reaches the filesystem does not exist yet at guard time, so no pattern over the command text can decide the question. The guard now asks it from the other side: cut the command into path components, keep the ones that begin at a component boundary with a literal . and contain a metacharacter, and deny when fnmatch says the pattern matches .mureo.

Three things keep that from over-blocking:

  • The literal leading dot is required. A shell will not let a wildcard match the leading period of a filename unless dotglob is set, so ls *, rm -rf build/* and tests/*.py cannot reach .mureo and are never candidates. Without this the rule would have to deny every glob anyone types, fnmatch('.mureo', '*') being true.
  • Quoted spans are skipped. Quoting suppresses pathname expansion, so sed 's/.*//' and find . -name '.*' are a regex and a literal, not globs — and cat "$HOME/.mure?/x" genuinely opens nothing. Unquoted, the same characters do glob and are denied.
  • Substitutions reuse rule 1's clause. $D.mure? and printf '%s.mure?/' are denied by the same $/% test the literal rule uses, where quoting is not consulted because the pattern reaches the shell through a later expansion.

Brace groups are replaced by * before matching. That is an over-approximation: .mure{o,x} denies (correct) and so does mv .{env,env.bak} (which cannot name the directory). Over-blocking is the safe direction, the same argument the guard already makes for case folding.

Measurements

All figures come from running the generated hook command (bash_guard_command()) through bash -c with the tool JSON on stdin — the payload as shipped, not a re-implementation of the rule.

corpus size result
everyday commands (git, gh, ls, rm, cp, grep, sed, find, pytest, ruff, black, mypy, python -c, node --test, glob-heavy forms) 73 0 denied
forms the guard must deny (the whole pre-existing corpus + the patterns above) 51 0 leaked
mureo's own identifiers from #567 (window.MUREO_*, pkgs.mureo.jp, docs.mureo.jp) 10 0 denied

Not one of #567's defenses was dropped: rule 1 is untouched, the new rule can only add denials, and every existing test still passes unchanged.

Commands that do get denied and did not before, all of which genuinely expand onto the directory when run from $HOME: ls .*, ls -d .??*, rm -rf .[!.]*, cp .* /tmp/. The two that are honest over-blocks are the brace-rename idioms mv .{env,env.bak} and cp .{zshrc,zshrc.bak}.

What is still not covered

Written into the docstring, because a guard that overstates its reach is how this bug survived:

  • a pattern the command text does not contain, supplied by an earlier command or another program (cat ~/$P/x; spelled out in one command, P=.mure?; cat ~/$P/x is denied);
  • patterns for sibling names (~/.mur*_backup) — rule 2 asks only whether the pattern matches .mureo itself, while rule 1 does deny literal siblings;
  • extended globs (.mure@(o|x)), off by default in bash;
  • quoting is scanned as balanced pairs and each span is dropped whole, so a pattern split across the quoting (~/'.'mure?/x) or hidden behind a deliberately unbalanced quote escapes the unquoted-text half;
  • shells configured with dotglob / GLOBIGNORE, where * does reach dotfiles.

The guard stays defense-in-depth; filesystem permissions on ~/.mureo remain the control that protects the files.

Payload note

The character class the rule needs contains !, which cannot appear literally in the payload — a shell with history expansion on rewrites ! sequences inside double quotes. It arrives via chr(33), next to the existing chr(36) for $, and tests/hook_guard_runner.py now rejects a literal ! alongside $, backticks, backslashes and newlines.

Tests

tests/test_credential_guard.py gains 20 deny cases (each verified to reach the file under a real bash) and 21 allow cases covering everyday globbing, quoted regexes and quoted paths. Written first, all 20 red before the change.

Gates: ruff check ., black --check ., mypy mureo --ignore-missing-imports clean; python -m pytest = 7810 passed, with only the 12 failures that already fail on main locally (9 × test_live_clients.py, 3 × tool-registry).

@hyoshi

hyoshi commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

Review response — 16738e5

All five reproduced against the shipped commit first (real generated command, real bash, marker credentials file). All five leaked, exactly as reported. Four now deny; one does not and cannot.

# case before after
1 cat ~/'.'mure?/credentials.json leak deny
2 cat ~/".mure"?/credentials.json leak deny
3 echo "it's" ; cat ~/.mure?/credentials.json 'x' leak deny
4 cat ~/.mure$(printf '?')/credentials.json leak deny
5 shopt -s dotglob; cat ~/*/credentials.json leak still open, by design

The quoting layer

Agreed on the diagnosis: hand-rolled quote stripping was the defect. Quoting is now a left fold with a five-state automaton (unquoted, single, double, and the two escaped states), so 1–3 close by construction.

I did not use shlex, and I want to be explicit about overriding that. Two reasons, both measured:

  • shlex.split(posix=True) discards which characters were quoted, and that is the signal the rule needs — a quoted ? is not a glob. Feeding its output to the matcher denies sed 's/.*//', sed -e 's|.*/||' and find . -name '.*'. On the 73-command everyday corpus: shlex 3 denied, fold 0 denied, at identical safety (both 0 undetected leaks across 873 fully-visible fuzz spellings).
  • shlex in non-posix mode gets echo it\'s wrong the same way my regex did — a backslash-escaped quote read as a delimiter. Neither the review list nor my first pass had that case; the fold handles it and it is now a test.

If you still want shlex, the cost is those three commands and I will switch — the prototypes for both are in the scratchpad.

Two more classes the fuzz found

Random differential fuzzing against a real bash (spell .mureo one character at a time, every quoting/escaping/class/range/brace/substitution form) surfaced two bypasses that were on neither list:

  • cat "$HOME"/.mure"o"/credentials.json — quote removal reassembles the name, no glob anywhere. Both rules now also run over the normalized text, not only the text as written.
  • cat ~/.mure`printf o`/x and cat ~/.mure$X/x — the substitution's own text extended the component being matched, so the pattern no longer matched. An expansion now normalizes to */: unknown text, and unknown extent.

Fail-closed — you were right, and it was already broken

Exit 1 is a non-blocking hook error, so an escaping exception is a bypass. Both payloads had one before this PR: malformed stdin exited 1 and let the call through, and so did a path with an embedded NUL (os.path.realpath raises ValueError). Both now set sys.excepthook to print the deny JSON and os._exit(0); tests cover malformed stdin for both guards and the NUL path for the path guard.

Tests through a real shell

run_guard lifts the payload out of the command, so bash -c "python3 -c \"…\"" had never been exercised — 103 tests passed and caught none of this. TestGuardThroughARealShell runs the generated command as a host runs it (20 deny, 13 allow, 4 malformed-input, 2 path-guard fail-closed), and test_known_open_bypasses pins the open surface so it cannot widen unnoticed. That test immediately earned its place: it failed on cat ~/$(echo .mureo)/x, which I had assumed open and which actually denies — the row is gone.

Over-block disclosure, widened and narrowed

Brace groups are no longer blanketed with *. A group containing a dot becomes .*, any other becomes *. Consequences, all now in the docstring:

  • mv .{env,env.bak} and cp .{zshrc,zshrc.bak}allowed again (the dot in an alternative makes the replacement .*, which cannot match a six-character single-dot name).
  • mv .{foo,bar}, rm .{a,b,c} — still denied. Exact alternative expansion is the fix if it ever bites; it is named as such.
  • Denied and correct: ls .*, ls -d .??*, rm -rf .[!.]* genuinely reach ~/.mureo from $HOME.
  • Denied because unknowable: ls .$X, cat .$(cmd).

What is not closable

Of 2000 fuzzed commands, 1621 read the file: 873 with the name written out in the text — 0 got through; 748 assembled at runtime — 265 did. Every one of those 265 produces the leading dot from a substitution. With dotglob, they are one class: the text handed to the guard does not contain the thing that reaches the filesystem. No inspection of command text decides them, and no further rule should pretend otherwise. They are pinned as tests, not left as a sentence.

On your question

It is a deterrent, not a security boundary, and it cannot be made into one — the agent runs as the owner of the file and can read it through any construction the text does not reveal. Your instinct about the permissions line was right too: it was wrong in the same direction, since permissions do not stop a process running as the owner either. The module now says this in a WHAT THIS GUARD IS paragraph, names what actually limits the damage (not keeping long-lived credentials where an autonomous agent runs, scoping and rotating them, the audit trail), and tells the next person to judge changes by whether they make the common accident less likely without blocking real work.

Gates

ruff / black --check / mypy clean. pytest: 7854 passed, 12 failed — the same 12 that fail on main locally (9 × test_live_clients.py, 3 × tool-registry).

@hyoshi

hyoshi commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

Line continuation — fixed in 9da9829

Reproduced first against the previous commit, real generated command through a real bash, marker credentials file. Five in the family leaked, not one.

case before after
cat ~/.mu\⏎reo/credentials.json leak deny
cat ~/.\⏎mureo/credentials.json leak deny
cat ~/.mure\⏎?/credentials.json leak deny
cat ~/.m\⏎ure?/credentials.json leak deny
cat "$HOME/.mu\⏎reo/credentials.json" (inside double quotes) leak deny
cat ~/.m\⏎u\⏎r\⏎e\⏎o/… allowed, no leak (path was wrong) deny
cat '~/.mu\⏎reo/…' (single quotes — no continuation) allow allow, correct: opens nothing
ls -la \⏎ ~/project allow allow

Your diagnosis was exactly right, including the fix location. The fold already had the states; the change is one clause — in the two escaped states a newline is dropped along with its backslash. Single quotes are untouched, because there a backslash is an ordinary character and .mu\⏎reo really is a name with a newline in it; normalizing that away would over-block something that opens nothing.

The generator, not the case

You were right that the fuzz never produced \⏎. I extended the generator rather than adding the case — continuations before and after each character, inside double quotes as well as outside, plus $"…" — and re-ran against the real command:

2500 commands, 1995 read the file: 825 with the name written out in the text → 0 through; 1170 assembled at runtime → 172 through, all producing the leading dot from a substitution.

The extended run also confirmed $".mureo" denies (it is quoting, not an expansion of anything unseen) and re-confirmed $'\x2emureo' as the known-open encoded form.

My own standard, applied

You are right that the gap between measurement and claim was the real defect: a whole lexer-level rewrite went untested while the numbers read as complete. Since the family is now closed it is not a known-open row, but the docstring carries the lesson where the fuzz numbers are quoted:

That fuzz is also how the line-continuation family should have been found, and was not: the generator had no \<newline> among its escaping forms, so a whole lexer-level rewrite went untested while the numbers above looked complete. A form the generator cannot produce is a form nothing here has checked; extend it before trusting it.

Eight rows added to test_denies_through_the_shell, two to the allow list (single-quoted, and a plain wrapped line).

Smaller items

  • Python 3.8+ is now stated in the docstring, with the consequence spelled out: the payload runs under whatever python3 is on PATH, not necessarily the interpreter mureo was installed with, and on anything older accumulate(initial=) raises — which fails closed and denies every Bash call rather than letting one through. Loud and safe, and the docstring says not to "fix" that by swallowing the error.
  • $((0)) disclosed: arithmetic expansion is treated like any other expansion, so echo .$((1+1)) and cat ~/.mure$((0))?/x deny although neither can reach the directory.

Re-verified after the change

No movement anywhere else: everyday 73 → 0 denied; must-deny 51 → 0 through; #567 identifiers 10 → 0 denied; the four earlier review cases still deny; shopt -s dotglob still open by design; fail-closed unchanged (both guards deny on malformed stdin, path guard denies on embedded NUL).

Gates: ruff / black --check / mypy clean. pytest 7863 passed, the same 12 pre-existing failures. Guard file: 156 tests.

@hyoshi

hyoshi commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

The split brain is gone — 6728616

Your diagnosis was exact, including that ${D2} only denied by accident via _DEBRACE. I did the design pass rather than adding a clause.

One normalized reading, both rules consume it. The separate [$%][a-z0-9_]*[.]mureo scan over raw c is deleted. What made that possible is preserving through the fold the boundary information that rule needed: an expansion becomes */ and swallows the identifier run naming it, so $D.mureo, ${D}.mureo, $1.mureo and %s.mureo all normalize to */.mureo — the dot then sits at a boundary exactly as in ~/.mureo, and the one boundary test sees all of them, including when the name is also broken up. That last part is what the two-rule version structurally could not do.

Reproduced first, then re-checked (real command, real bash, marker file):

case before after
D2=~/⏎cat $D2.mu\⏎reo/credentials.json leak deny
D2=~/; cat "$D2".mu\⏎reo/… leak deny
set -- ~/; cat $1.mu\⏎reo/… leak deny
D2=~/; E2=; cat $D2$E2.mu\⏎reo/… allow deny
D2=~/; cat $D2.\⏎mureo/… allow deny
D2=~/; cat $D2.m\⏎ur\⏎eo/… allow deny
D2=~/; cat ${D2}.mu\⏎reo/… deny by accident deny by construction
D2=~/; cat $D2.mure"o"/… (no continuation) leak deny

Two design bugs the corpus caught during the pass

Both would have been silent regressions; both were found by running the existing must-deny corpus through the new reading, not by inspection:

  1. The placeholder was an identifier character. A quoted metacharacter folded to _, so '{}.mureo' became __.mureo and the boundary test read one long name — it broke python3 -c "…open('{}.mureo/x'.format(h))…". The placeholder now has to read as a boundary: it is =.
  2. Quoted text is literal unless a program builds a path from it. printf '%s.mure?/x' is a template whose ? survives into a filename that the shell then globs. A % in a quoted span now keeps that span's metacharacters live; the flag resets at the end of the span, so echo "100%" ; sed 's/.*//' is still allowed. This is the normalization-level statement of the old % rule, which is why the rule itself could be deleted.

The generator now walks the product

You were right that it only walked the margins. It is now exhaustive over the product, not sampled:

{parent: literal, $HOME, "$HOME", $VAR, "$VAR", ${VAR}, $VAR$EMPTY, $1, $(cmd), backtick} × {name broken by: nothing, continuation, two continuations, single-quote split, single-quoted char, double-quote split, double-quoted char, escaped char, class, wildcard, brace, star} × {position}

560 members. All 560 read the credentials file in real bash. All 560 deny. (The prototype and the shipped payload were run separately over the same product; identical.)

The random fuzz is unchanged: 2500 commands, 1995 read the file, 0 undetected among the 825 whose name is written out, 172 of the 1170 runtime-assembled — all still producing the leading dot from a substitution.

Regression checks

Everyday 73 → 0 denied. Must-deny 51 → 0 through. #567 identifiers 10 → 0 denied. Continuation family (last round) → still all deny. Round-2 review cases → still deny. Fail-closed unchanged. Known-open rows unchanged and still exactly the enumerated five.

New over-block, disclosed

printf '%s.%s' a b denies: a format string shaped <something>.<something> is indistinguishable from printf '%s.mureo/…' ~/. Of 20 %-heavy everyday commands (date +%Y-%m-%d, git log --format=%h, awk '{printf "%.2f", $1}', grep '100%', a commit message reading 30% faster) it is the only one denied.

On the k>2 comment

It no longer risks that reading, because there is no longer a substitution-boundary path to be continuation-unsafe — there is one path. The docstring now opens the Bash-guard section with the invariant, in the imperative, so the next person does not reintroduce it:

Nothing here may reintroduce a second reader. If a rule needs information the fold destroys, the fold has to preserve it — which is what _COLLAPSE does for expansion boundaries — rather than the rule reaching for a different string.

Gates: ruff / black --check / mypy clean. pytest 7877 passed, the same 12 pre-existing failures. Guard file: 170 tests, 14 of them the new product-shaped rows.

@hyoshi

hyoshi commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

Brace groups are expanded now, not approximated — 338e4e2

Reproduced first (real generated command, real bash, marker file), then fixed. Expansion, not per-position dot tracking — it was tractable in the payload.

case before after
cat ~/.{mureo,x.y}/credentials.json leak deny
cp -r ~/.{mureo,bashrc.bak} dest/ leak deny
ls -la ~/.{mureo,x.y} leak deny
.mure{o,x.y}, .m{ureo,x.y}, .{MUREO,x.y} leak deny
$HOME/.{mureo,x.y}, .{mure?,x.y}, .{a.b,c.d,mureo} leak deny
.{mureo,{a,b}.c} (nested) leak deny

The normalized command becomes the list of strings the shell would produce, and every rule runs against all of them. No rule depends on a guess about any single reading.

Two more the report didn't list

Found while writing the expansion — and the naive split(',') I was about to ship would have missed them too:

cat ~/.{l..n}ureo/credentials.json     → read the file
cat ~/.mure{n..p}/credentials.json     → read the file

Sequence expressions. The letter m appears nowhere in either. A sequence is not a comma-separated alternative list, so it falls back to both coarse readings, * and .* — which between them cover "supplies a leading dot" and "does not". That pair is exactly what the old single guess was missing: where a guess is unavoidable, take both branches instead of picking one. Both now deny.

Over-blocks released, and one corrected

  • mv .{foo,bar}, rm .{a,b,c}allowed again, exactly as you predicted: each alternative is judged on its own.
  • ls ~/.mur{eo} — now allowed, and this is a correction, not a loss. A group with neither a comma nor a .. is not brace expansion; bash leaves {eo} literal and it opens nothing. I had it asserted as a deny in my scratchpad oracle, i.e. an over-block recorded as if it were protection. It was never in the repo tests.

The generator dimension

You were right, and it landed one axis deeper than my own sentence anticipated. It could vary which breaking form and where, but every brace alternative was inert filler (z), so "a group holding an unrelated dot" was not in the space at all.

It is now a product over {parent supplied by} × {name broken by, incl. brace-here / brace-tail / brace-whole / sequence} × {what the breaking form contains: plain, alternative with its own dot, with two, backup-looking name, nested group, metacharacter, leading dot} × {where}.

1510 members. All 1510 read the credentials file in real bash. All 1510 deny.

I also corrected the docstring claim you flagged as false — the round-4 text said "of the 825 whose name is written out, 0 got through" while this family was written out and got through. The numbers now come from the widened product.

What coarse approximations remain

Stated in the docstring, as the short honest list you asked for:

  1. an expansion's text (unknowable → *)
  2. an expansion's extent (unknowable → /)
  3. a % template's result
  4. a sequence group (→ both coarse readings)
  5. a group with more than 64 alternatives (→ both coarse readings)

1–3 are the "text the command does not contain" class and are not closable by reading command text. 4 and 5 are closable in principle — enumerate the sequence, lift the cap — and the docstring says the shape of the fix for each is the same one applied here: compute what the shell would produce instead of approximating it.

Regression checks

Everyday 73 → 0 denied. Must-deny 51 → 0 through. #567 identifiers 10 → 0 denied. %-heavy 20 → 1 denied (unchanged, disclosed). Continuation family, substitution family, round-2 cases → all still deny. Fail-closed unchanged. Known-open still exactly the same five rows. All 28 docstring claims re-verified against the real command through bash: 0 wrong.

Gates: ruff / black --check / mypy clean. pytest 7889 passed, the same 12 pre-existing failures. Guard file: 182 tests, 16 new brace/sequence rows at the shell level.

@hyoshi

hyoshi commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator Author

Unresolved structure now denies — ec75d9a

Taken as the rule, not as a bigger budget: when the guard cannot compute what the shell would produce, it denies. Anything still holding an expandable group after the passes denies on that ground alone; the coarse fallback is deleted. The 8-pass / 400-string budget stays — it stops the fork bomb — but exceeding it is a refusal instead of a shrug.

Depths 1–20 × {2, 3, 5} alternatives per level

Every cell reads the credentials file in real bash. before is 338e4e2, after is this commit.

depth 2 alts 3 alts 5 alts depth 2 alts 3 alts 5 alts
1 ok / ok ok / ok ok / ok 11 LEAK / ok LEAK / ok LEAK / ok
2 ok / ok ok / ok ok / ok 12 LEAK / ok LEAK / ok LEAK / ok
3 ok / ok ok / ok ok / ok 13 LEAK / ok LEAK / ok LEAK / ok
4 ok / ok ok / ok ok / ok 14 LEAK / ok LEAK / ok LEAK / ok
5 ok / ok ok / ok ok / ok 15 LEAK / ok LEAK / ok LEAK / ok
6 ok / ok ok / ok LEAK / ok 16 LEAK / ok LEAK / ok LEAK / ok
7 ok / ok ok / ok LEAK / ok 17 LEAK / ok LEAK / ok LEAK / ok
8 ok / ok LEAK / ok LEAK / ok 18 LEAK / ok LEAK / ok LEAK / ok
9 ok / ok LEAK / ok LEAK / ok 19 LEAK / ok LEAK / ok LEAK / ok
10 ok / ok LEAK / ok LEAK / ok 20 LEAK / ok LEAK / ok LEAK / ok

38 of 60 cells leaked before; 0 leak now. Cliffs at depth 11 / 8 / 6, exactly as you reported.

The false sentence

Corrected in place, and the reason kept next to it rather than tidied away — the docstring said the fallback "over-approximates rather than dropping candidates", and past the budget it dropped them. The text now reads: "A budget that shrugs is a bypass with a length requirement."

What the refusal costs

Nine brace groups on one line. That is it. Of 21 brace-using everyday commands — awk '{print $1}', awk '{printf "%.2f\n", $1}', find . -exec rm {} ;, mkdir -p build/{lib,bin,share}, mv report.{txt,md}, mv file{1..10}.txt, cp {src,dst}/{a,b}.txt, jq '{name: .name}', kubectl -o jsonpath='{...}', eight groups on one line — 1 is refused, the nine-group one. Quoted braces never reach the step; a group with no comma and no .. is literal to bash and to fe. Eight of those commands are now pinned as allow-rows at the shell level.

ga across newlines — fixed rather than written down

[^{}]* matched newlines, so braces on different lines could pair. An unquoted newline is a token separator and bash will not expand a group across one, so the newline is excluded now. echo '{' > a.json ⏎ echo '}' >> a.json stays two independent lines, and it is a test row.

Generator: nesting depth

You were right about why 1510 missed it — "a nested group" was one fixed-depth filler, so every member sat at depth ≤ 2. Depth is now a dimension: {0, 1, 2, 3, 5, 8, 9, 11, 14, 20}.

Product is now 2698 members. All 2698 read the file in real bash. All 2698 deny.

Worth recording: each round's missing dimension was one level inside the last one I added — form → what the form contains → how deep the form nests. The docstring says that, and draws the conclusion you did: prefer a rule that fails closed on unresolved structure over a measurement claiming the gap is unreachable.

Regressions

Everyday 73 → 0 denied. Must-deny 51 → 0 through. #567 identifiers 10 → 0 denied. %-heavy 20 → 1 (unchanged). Brace, continuation, substitution and round-2 families → all still deny (15/15, 10/10, 12/12, 4/5 with dotglob known-open). Fail-closed unchanged. Known-open still exactly five rows. All docstring claims re-verified through real bash: 0 wrong.

Gates: ruff / black --check / mypy clean. pytest 7902 passed, same 12 pre-existing failures. Guard file: 195 tests.

@hyoshi

hyoshi commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator Author

Round seven — b32fe2c

All three addressed. The third one is the one I sat with, and you were right that it is the recurring defect rather than a side item.

1. The computational escape

sys.excepthook cannot catch the host killing an overrunning hook, and that process exits non-zero without printing the deny JSON — the non-blocking case. So the guarantee had a time-shaped hole. Fixed at all three causes you named.

command bytes before after
633 deny 0.09s deny 0.08s
60,057 deny 0.18s deny 0.12s
496,165 deny 1.39s / 582 MB deny 0.08s
2,016,293 deny 16.50s deny 0.10s
4,032,573 TIMEOUT → allow deny 0.13s
8,064,573 TIMEOUT → allow deny 0.20s

Worst case under the 64 KB cap, where the byte budget has to do the work alone: 8 groups × 64 alternatives at 64,077 bytes → deny in 0.13s; a 1000-alternative single group → 0.12s; 2000-deep nesting → 0.09s. Boundary is exact: 65,536 bytes processed, 65,537 refused.

A bug I found while fixing this: the early break compared list length to detect "nothing changed", but a sequence group expands to a single alternative, so mkdir -p test{1..3}/{a..c} was marked finished with a group still unexpanded and then denied by the refusal rule. It compares content now — caught only because I re-ran the everyday sweep after the change.

2. Bare sequence over-block

A sequence yields integers or single characters. Integers hold no dot; a character range holds one only if it spans ASCII 46. Consulting the range fixes all four of your examples without giving up coverage:

command before after
echo {1..100} DENY allow
for i in {1..5}; do touch file$i.txt; done DENY allow
printf '%s\n' {A..Z} DENY allow
touch file{1..20}.log allow allow
mkdir -p test{1..3}/{a..c} DENY allow
cat ~/.{l..n}ureo/… deny deny
cat ~/.mure{n..p}/… deny deny
echo {-..0} / cat ~/{-..0}mureo/… deny deny (range spans the dot)

All six allow cases are now pinned rows, and {-..0} is a pinned deny row.

3. The numbers now have a committed artifact

This is the honest fix, and I took your framing: the numbers growing was not evidence of rigour while none of them were re-runnable.

  • tests/credential_guard_product.py — the generator, with the axes documented and the reason each exists (each was a bypass).
  • tests/test_credential_guard_product.py — runs it. Default: an evenly-strided sample of 118 of 2698, ~14s, on every commit. pytest -m slow: all 2698, each executed in a throwaway HOME to confirm it really reads the marker before the guard is asked — 2698/2698, 5m09s, passing.
  • The nesting-depth table (60 cells → 30 parametrised rows) and the resource bounds are ordinary tests too, in the default run.
  • test_the_axes_are_all_represented fails if an axis silently drops out of the product.

The slow test is opt-in via a fixture checking the marker expression, not a global addopts filter — a marker that silently vanishes from the default run is how a suite ends up with checks nobody has run in months. Plain pytest reports it as skipped rather than passing.

I deleted the random-fuzz figure ("2500 commands, 1995 read the file…") rather than restating it, because nothing in the repo reproduces it. The docstring now says: "A number in a docstring with no committed artifact is a claim about the past, not a property of the code."

Guard test count: 92 → 240 rows, plus the 2698-member slow run.

Regressions

Everyday 73 → 0 denied. Must-deny 51 → 0 through. #567 identifiers 10 → 0 denied. Brace-using 21 → 1 refused (nine groups on one line, unchanged). %-heavy 20 → 1 (unchanged). All families still deny. Fail-closed unchanged. Known-open still exactly five rows. Docstring claims re-verified: 0 wrong.

Gates: ruff / black --check / mypy clean. pytest 7947 passed, the same 12 pre-existing failures.

On where this stands

Understood on the frame, and I agree with it. The remaining disclosed gaps are unchanged and enumerated: dotglob, runtime-produced text, encoded notations, extglob, globbed siblings, plus the bounded refusals (>8 brace groups, >200 KB expansion, >64 KB command) and the over-blocks (.$X, printf '%s.%s', {-..0}). If round eight surfaces another class, I'd support shipping with those enumerated rather than continuing — the module already says in its own words that this is a best-effort deterrent and not a security boundary.

@hyoshi

hyoshi commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator Author

All three fixed — 0fae0ac

1. A refusal now says it is a refusal

command reason returned
70 KB of z, nothing protected command over 65536 bytes was refused unread, not analysed; shorten it or run it in pieces
cat ~/.mureo/credentials.json commands that can reach ~/.mureo are blocked
nine brace groups (unresolved structure) commands that can reach ~/.mureo are blocked
under the cap, nothing protected allow

One branch on bg, one new reason, _SAFE_REASON_CHARS unchanged. A match and an oversize refusal no longer read the same, so an agent stops hunting for a reference that is not there.

Unresolved structure still returns the match reason. I left that deliberately: unlike an unread command, the guard did read it and found brace structure it could not resolve, so "can reach" is a fair description of what it is refusing on. Say the word if you would rather it were a third string.

2. The {-..0} comment — you were right, and it goes further

Verified in bash 5.2:

{-..0}  -> [{-..0}]      not expanded
{+..0}  -> [{+..0}]      not expanded
{!..0}  -> [{!..0}]      not expanded
{A..z}  -> [A]...[z]     expanded, ASCII 65..122
{1..5}  -> [1][2][3][4][5]

A sequence endpoint must be an integer or a single letter. Letters live at ASCII 65–122, so no recognised sequence can produce a dot at all — the .* branch is not for dot-producing ranges, it is for syntax the guard does not recognise. Comment and disclosure rewritten to say that.

The test row was worse than the comment. I had cat ~/{-..0}mureo/credentials.json sitting in the shell-level deny list under that false rationale. Since bash leaves it literal, it reaches nothing — an over-block pinned as though it were protection, which is precisely the shape of mistake this review has been about. It now lives in test_denies_sequence_syntax_it_does_not_recognise with echo {-..0} and echo {a..z..2}, named for what it checks and documented as an over-block.

3. Depth table: 60 claimed, 30 committed

I kept the claim and fixed the artifact rather than the reverse — the parametrisation now walks every depth 1–20 × {2,3,5}: 60 rows, 7.65s, each asserting the command reads the marker file and that the guard denies it. The docstring figure with no counterpart ("38 of those cells were allowed") is deleted, replaced by what someone can reproduce by reverting.

On the pattern

Taken, and it is the right read. Both 2 and 3 were sentences easier to write than to check, in exactly the places CI does not look — a comment, a commit message, and a test row's rationale. The test row is the one that bothers me most, because a pinned over-block dressed as protection would have survived indefinitely and looked like coverage.

The only durable answer I have is procedural rather than clever: write the check first and the sentence after, and when a sentence explains why a row is there, verify the why against a real shell, not against my model of one. That is what turned up the fact that no bash sequence can produce a dot — a claim I had asserted twice without ever running printf '[%s]' {-..0}.

Re-verification

Everyday 73 → 0 denied · must-deny 51 → 0 through · #567 identifiers 10 → 0 denied · brace-using 21 → 1 refused · %-heavy 20 → 1 refused · brace family 15/15 · continuation 10/10 · substitution 12/12 · docstring claims 0 wrong · 4 MB 0.13s, 8 MB 0.19s · 65536 allow / 65537 deny · fail-closed intact · known-open still exactly five rows.

Guard rows 240 → 272; product 2698, default sample 118, depth table 60. Gates: ruff / black --check / mypy clean; pytest 7979 passed, the same 12 pre-existing failures.

Ready for the merge queue. One thing still outstanding that is not mine to close: AGENTS.md requires a python-reviewer / security-reviewer pass before each commit, and I have no Agent tool in this session — nine commits have gone in without it.

hyoshi added 9 commits August 8, 2026 10:53
The Bash credential guard looked for six consecutive literal characters,
so any metacharacter placed inside the directory name broke the match
while the shell still expanded the pattern onto the real files. Against a
throwaway HOME with bash 5.2, every one of these printed the credentials
file and was allowed by the guard:

    cat ~/.mure?/credentials.json
    cat ~/.[m]ureo/credentials.json
    cat ~/.mur*/credentials.json
    cat ~/.m?reo/credentials.json
    cat ~/.?????/credentials.json
    cat ~/.[!.]*/credentials.json
    cat ~/.mure{o,x}/credentials.json

This is not a regression from the boundary rule: the earlier bare
substring check missed them too. The wildcard closed back in #393 was
only the one *after* the name (`cat ~/.mureo/cred*`), and the docstring's
claim that the guard "catches wildcard forms" made a partial fix read as
a complete one.

No regex over the command text can decide this, because the string that
reaches the filesystem does not exist yet. So the guard now asks the
question from the other side: it takes the path components of the
command, keeps those that begin at a component boundary with a literal
`.` and contain a metacharacter, and denies when fnmatch says the
pattern matches `.mureo`.

Requiring the literal leading dot is what makes that safe. A shell will
not let a wildcard match the leading period of a filename unless dotglob
is set, so `ls *` and `rm -rf build/*` cannot reach the directory and are
never candidates -- without that restriction the rule would have to deny
every glob anyone types. Quoted spans are skipped, because quoting
suppresses pathname expansion: `sed 's/.*//'` is a regex, not a glob.
Patterns spliced in by a substitution (`$D.mure?`, `printf '%s.mure?/'`)
go through the same `$`/`%` clause the literal rule uses.

Measured with the generated hook command run through bash: 73 everyday
git/gh/ls/rm/grep/find/pytest/ruff/python commands, none denied; the 51
forms the guard must deny (the existing corpus plus the patterns above),
all denied; the 10 mureo identifiers of #567 (window.MUREO_*,
pkgs.mureo.jp), all still allowed.

The docstring now states what the rule covers and what it does not:
patterns the command text does not contain, patterns for sibling names,
extended globs, quoting split inside a component, and dotglob shells.

The payload gained chr(33) for `!`, which the pattern character class
needs; a shell with history expansion on would rewrite `!` sequences
inside the double quotes, so the test helper now rejects it alongside
`$`, backticks, backslashes and newlines.
Review of the first commit reproduced five one-line commands that still
read the credentials file, four of them through the quoting layer:

    cat ~/'.'mure?/credentials.json
    cat ~/".mure"?/credentials.json
    echo "it's" ; cat ~/.mure?/credentials.json 'x'
    cat ~/.mure$(printf '?')/credentials.json
    shopt -s dotglob; cat ~/*/credentials.json

The defect was hand-rolled quote stripping, not a missing character in a
class. Two regex passes cannot decide quoting: the single-quote pass read
the apostrophe in `"it's"` as an opening delimiter, paired it with the
unrelated `'x'` later in the line, and deleted the real pattern between
them. Quoting is now a left fold with a five-state automaton (unquoted,
single, double, and the two escaped states), which also gets `echo it\'s`
right -- an escaped quote is not a delimiter, and neither shlex in
non-posix mode nor the previous regex handled that.

A differential fuzz against a real bash then found two more classes that
neither the review nor I had listed:

  - quote removal reassembles the name with no glob involved at all
    (`cat "$HOME"/.mure"o"/credentials.json`), so both rules now also run
    over the normalized text, not only over the text as written;
  - the substitution's own text extended the component being matched, so
    `.mure`printf o`` and `.mure$X` escaped. An expansion now normalizes
    to `*/`: unknown text, and unknown extent.

Brace groups are expanded before matching rather than blanketed with `*`:
a group containing a dot becomes `.*`, any other becomes `*`. That closes
`~/{.,z}mureo`, where the group supplies the leading dot, and it removes
the over-block the review flagged -- `mv .{env,env.bak}` is allowed again.
`mv .{foo,bar}` still denies, and the docstring says so.

Fail closed. Exit 1 is a non-blocking hook error in both hosts, so an
exception escaping the payload was a bypass rather than a crash -- and
both payloads already had one: malformed stdin exited 1 and let the call
through, as did a path with an embedded NUL, which makes realpath raise.
Both now set sys.excepthook to print the deny JSON and exit 0.

Tests go through a real shell. run_guard lifts the python payload out of
the command, so `bash -c "python3 -c \"...\""` -- where all of these live
-- had never been exercised: 103 tests passed and caught none of them.
TestGuardThroughARealShell runs the generated command as a host runs it,
and pins the known-open bypasses so the surface cannot widen unnoticed.

Measured against the real generated command through a real bash, with a
marker credentials file: 73 everyday commands, 0 denied; 51 forms the
guard must deny, 0 through; 10 mureo identifiers from #567, 0 denied. The
fuzz spelled the directory name one character at a time in every quoting,
escaping, class, range, brace and substitution form: of 2000 commands,
1621 read the file -- 873 with the name written out in the text, of which
0 got through, and 748 assembled at runtime, of which 265 did.

Those 265 all produce the leading dot from a substitution, and with
dotglob they are the shape that cannot be decided by reading command
text. The docstring now says that outright, and says what this guard is:
a deterrent against accidental access, not a security boundary. It runs
as the same user as the file it protects and cannot be made into one --
the previous claim that "real safety comes from filesystem permissions"
was wrong in the same direction, since permissions do not stop the owner
either.
The $P row reads the file only once an earlier call has set the
variable; the docstring claimed all of them print the credentials file.
A backslash followed by a newline is removed by the shell, both
characters, before it tokenises anything. The normalizer dropped the
backslash and kept the newline, so the name was never contiguous and
neither rule saw it:

    cat ~/.mu\
    reo/credentials.json

Verified against a throwaway HOME with bash 5.2: that prints the
credentials file, and so do `.\<NL>mureo`, `.m\<NL>u\<NL>r\<NL>e\<NL>o`,
`.mure\<NL>?`, `.m\<NL>ure?` and the same spellings inside double quotes,
where the continuation still applies. Five leaked; all five now deny.

The fold already had the states needed, so the fix is one clause: in the
two escaped states, a newline is dropped along with its backslash. Inside
single quotes a backslash is an ordinary character and there is no
continuation, so `cat '~/.mu<NL>reo/x'` is a name with a newline in it and
stays allowed -- it opens nothing.

This is not a regression: main misses it for the same reason, and so does
shlex.split(posix=True), which leaves the raw newline in the token. It is
in scope anyway, because the docstring claims to read the command as a
shell reads it once quoting is resolved, and line continuation is resolved
before that.

The reason it was not already an enumerated known-open row is that the
differential fuzz never generated `\<newline>`: a whole lexer-level
rewrite went untested while the numbers looked complete. The generator now
emits continuations in and out of quotes, plus `$"..."`. Re-run: of 2500
commands, 1995 read the file -- 825 with the name written out in the text,
0 through; 1170 assembled at runtime, 172 through, all producing the
leading dot from a substitution. The docstring records both the new
numbers and the lesson about trusting a generator's coverage.

Also documented, both found while checking this:

  - the Bash payload needs Python 3.8+ for accumulate(initial=). It runs
    under whatever python3 is on PATH, not necessarily the interpreter
    mureo was installed with; on anything older it raises, which fails
    closed and denies every Bash call rather than letting one through.
  - an arithmetic expansion is treated like any other expansion, so
    `echo .$((1+1))` and `cat ~/.mure$((0))?/x` deny although neither can
    reach the directory.
The recurring defect was structural, not a missing case: one rule scanned
the raw command and the other scanned the folded text, so whatever one of
them resolved was invisible to the other. Each round of folding therefore
opened a hole on the axis the other rule owned. This round's report:

    D2=~/
    cat $D2.mu\
    reo/credentials.json

reads the file. The continuation was folded away in the text the pattern
rule read, while the rule that understood `$D2` was still looking at raw
text. `${D2}` denied only by accident, because the debrace pass happened
to rewrite `{D2}`. A sibling needed no continuation at all:
`D2=~/; cat $D2.mure"o"/credentials.json`.

There is now one normalized reading, and both rules consume it. The piece
that makes that possible is preserving, through the fold, the boundary
information the literal rule used to get from raw text: an expansion
becomes `*/` and swallows the identifier run naming it, so `$D.mureo`,
`${D}.mureo`, `$1.mureo` and `%s.mureo` all read as `*/.mureo`, whose dot
sits at a boundary exactly like the one in `~/.mureo`. The separate
`[$%][a-z0-9_]*` rule over raw text is gone, and with it the asymmetry.

Two things the corpus proved wrong on the way, both real design bugs:

  - the placeholder for a quoted metacharacter was `_`, an *identifier*
    character, so `'{}.mureo'` folded to `__.mureo` and the boundary test
    read one long name. It is now `=`, which reads as a boundary;
  - quoted text is literal unless a program will build a path out of it.
    `printf '%s.mure?/x'` is a template whose `?` survives into a filename
    that the shell then globs. A `%` in a quoted span now keeps that
    span's metacharacters live, and the flag resets at the end of the
    span, so `echo "100%" ; sed 's/.*//'` stays allowed.

Measured against the real generated command through a real bash, with a
marker credentials file:

  - an exhaustive product of {parent supplied by: literal, $HOME, "$HOME",
    $VAR, "$VAR", ${VAR}, $VAR$EMPTY, $1, $(cmd), backtick} x {name broken
    by: nothing, continuation, two continuations, single-quote split,
    single-quoted char, double-quote split, double-quoted char, escaped
    char, class, wildcard, brace, star} x {position}: all 560 members read
    the file, all 560 deny;
  - 73 everyday commands, 0 denied; 51 must-deny forms, 0 through; the 10
    mureo identifiers of #567, 0 denied; the random fuzz unchanged at 0
    undetected among names written out in the text.

Both previous rounds were products of two axes and the generator had only
walked the margins -- it emitted continuations, and it emitted
substitutions, but never a continuation inside a substituted parent. It is
now combinatorial over the product, and the docstring says that a form the
generator cannot produce is a form nothing has checked, combinations
included.

Disclosed over-block, since the same normalization drives it: a format
string of the shape `<something>.<something>` cannot be told apart from
`printf '%s.mureo/...' ~/`, so `printf '%s.%s' a b` denies. Of twenty
%-heavy everyday commands it is the only one.
`cat ~/.{mureo,x.y}/credentials.json` reads the credentials file. The
group was folded to a single placeholder chosen by asking whether the
span contained a dot anywhere, never where: a dot before the group and a
dot inside an unrelated alternative combined into `..*`, which wants two
leading dots, while the literal `.mureo` rule 1 would have matched had
already been replaced. Both rules passed.

Brace groups are now expanded rather than approximated. The normalized
command becomes the list of strings the shell would produce, and every
rule runs against all of them, so nothing depends on a guess about any
one of them. `~/.{mureo,x.y}`, `.mure{o,x.y}`, `.m{ureo,x.y}`,
`.{MUREO,x.y}`, `$HOME/.{mureo,x.y}`, `.{mure?,x.y}`, `.{a.b,c.d,mureo}`
and the nested `.{mureo,{a,b}.c}` all deny.

Two forms found while writing the expansion, which the report did not
list and which the old fold also missed: sequence expressions.
`cat ~/.{l..n}ureo/credentials.json` and `cat ~/.mure{n..p}/…` read the
file, and the letter `m` appears nowhere in either. A sequence is not a
list of alternatives that can be enumerated by splitting on commas, so it
falls back to *both* coarse readings -- `*` and `.*` -- which between them
cover "supplies a leading dot" and "does not". That pair is precisely what
the old single guess was missing; where a guess is unavoidable, take both
branches rather than picking one.

Expansion also removes an over-block, as predicted: `mv .{foo,bar}` and
`rm .{a,b,c}` are allowed again, each alternative now being judged on its
own. And a group with neither a comma nor a `..` is not brace expansion at
all -- bash leaves `{eo}` literal -- so `~/.mur{eo}` is allowed, matching
what the shell actually does rather than over-blocking it.

The generator gained the dimension this round exposed. It could vary which
breaking form was used and where, but every brace alternative was inert
filler, so a group holding an unrelated dot could not be produced at all.
It is now a product over {parent supplied by} x {name broken by} x {what
the breaking form contains: plain, an alternative with its own dot, with
two, a backup-looking name, a nested group, a metacharacter, a leading
dot} x {where}. 1510 members, every one of which reads the file in real
bash, and all 1510 deny.

Unchanged elsewhere: 73 everyday commands 0 denied, 51 must-deny forms 0
through, the 10 mureo identifiers of #567 0 denied, the continuation and
substitution families still deny, fail-closed still holds, and the five
known-open rows are still exactly those five.

The docstring now lists the coarse approximations that remain after this
-- an expansion's text, an expansion's extent, a `%` template's result, a
sequence group, an oversized group -- and says that computing what the
shell would produce, rather than approximating it, is the shape of the fix
for each.
The budget was the bug. Expansion runs eight passes and then, until now,
handed whatever was left to a coarse fallback of two `re.sub` collapses.
Past ten levels of nesting that left literal `{` and `}` in the
candidates -- ordinary characters to fnmatch, not syntax -- so neither
rule fired at all:

    cat ~/.{z11,{z10,{z9,{z8,{z7,{z6,{z5,{z4,{z3,{z2,{z1,mureo}}}}}}}}}}}/credentials.json

Ninety characters, no exotic syntax, and bash reads the file. With three
alternatives per level the cliff is at depth 8, with five at depth 6.
Measured across depths 1-20 x {2,3,5} alternatives: 60 cells, every one of
which reads the file, 38 of them allowed.

The docstring asserted the opposite -- that the fallback "over-approximates
rather than dropping candidates". It dropped them. That sentence is
corrected in place rather than quietly replaced, because the pattern it
belongs to matters more than the sentence.

The fix is a rule, not a bigger budget: when the guard cannot compute what
the shell would produce, it denies. Anything still holding an expandable
group after the passes denies on that ground alone, and the coarse
fallback is gone. The budget stays at eight passes and 400 strings, since
it exists to stop a fork bomb; exceeding it is now a refusal instead of a
shrug. All 60 cells deny.

What that refuses is a command with more than eight brace groups or an
expansion of more than 400 strings. Of twenty-one brace-using everyday
commands -- awk '{print $1}', find . -exec rm {} ;, mkdir -p
build/{lib,bin,share}, mv file{1..10}.txt, jq '{name: .name}', eight
groups on one line -- exactly one is refused: nine groups on one line.
Quoted braces never reach the step, and a group with no comma and no `..`
is literal to bash and to the guard.

Also fixed rather than documented: the group regex used `[^{}]*`, which
matches newlines, so unrelated braces on separate lines of a multi-line
command could pair up and swallow what lay between them. An unquoted
newline is a token separator and bash will not expand a group across one,
so the newline is now excluded and the two `echo '{'` / `echo '}'` lines
stay independent.

The generator gained nesting depth as a dimension. It had "a nested group"
as one fixed-depth filler, so all 1510 members sat at depth two or less
and the cliff at eleven could not appear. It is now a product over {parent
supplied by} x {name broken by} x {what the form contains} x {how deeply
it nests: 0,1,2,3,5,8,9,11,14,20} x {where}: 2698 members, every one of
which reads the file in real bash, and all 2698 deny.

Rounds four, five and six were one shape: a defence reading an
approximation instead of the text, and failing open where it could not
compute. Expansion removed the guess for braces and the budget put a
silent one back. The docstring now carries the general rule -- prefer a
rule that fails closed on unresolved structure over a measurement that
says the gap is unreachable -- and notes that each round's missing
generator dimension was one level inside the last one added.
…from

Three things, one of which is about this file's claims rather than its
behaviour.

1. A guard that is merely slow is a guard that is bypassed.
   sys.excepthook catches what Python raises; it cannot catch the host
   killing a hook that overruns, and that process exits non-zero WITHOUT
   printing the deny JSON -- the non-blocking case, where the tool call
   proceeds. A 4 MB command of nested brace groups took it there: no
   answer in 45 s, 1.95 GB resident, and one of the alternatives was
   `mureo`. Measured before: 496 KB 1.4 s, 2 MB 16.5 s, 4 MB timeout.

   Three bounds, all cheap. The command is refused unread above 64 KB, so
   the normalization never runs on it. Expansion is budgeted on total
   normalized bytes rather than on candidate count, which is what let
   per-candidate text grow without limit. And a pass that reverts stops
   the loop instead of letting the remaining seven recompute and discard
   the same expansion. Multi-megabyte bombs now answer in ~0.2 s; the
   worst case under the cap is 0.13 s.

   Found while fixing it: the early break compared list *length* to decide
   nothing had changed, but a sequence group expands to a single
   alternative, so `mkdir -p test{1..3}/{a..c}` was marked finished with a
   group still unexpanded and denied on the refusal rule. It compares
   content now.

2. A bare sequence group was over-blocked. `echo {1..100}`,
   `for i in {1..5}`, `printf '%s' {A..Z}` and `touch file{1..20}.log`
   all denied, because any sequence collapsed to `.*`. A sequence yields
   integers or single characters: integers hold no dot, and a character
   range holds one only if it spans ASCII 46. Consulting the range fixes
   all four while keeping `.{l..n}ureo` and `.mure{n..p}` denied, and it
   correctly denies `{-..0}`, whose range does contain the dot.

3. The docstring quoted "2698 members", "1510", "60 cells", "2500 fuzz"
   with no committed artifact anywhere. CI checked ~92 parametrised rows
   and nothing else, so those numbers were claims about the past, not
   properties of the code -- the same defect as every round, at a larger
   scale and with bigger numbers.

   tests/credential_guard_product.py now builds the product, and
   tests/test_credential_guard_product.py runs it: an evenly-strided
   sample of 118 on every commit, and all 2698 members under `pytest -m
   slow`, each executed in a throwaway HOME to confirm it really reaches
   the file before the guard is asked. The nesting-depth table and the
   resource bounds are tests too. The random single-character fuzz figure
   is deleted rather than restated, because nothing in the repository
   reproduces it.

   The slow run is opt-in through a fixture that checks the marker
   expression rather than through a global addopts filter: a marker that
   silently vanishes from the default run is how a suite ends up with
   checks nobody has executed in months.

Unchanged: 73 everyday commands 0 denied, 51 must-deny forms 0 through,
the 10 mureo identifiers of #567 0 denied, 21 brace-using commands 1
refused (nine groups on one line), depth table 60/60, all families still
denied, fail-closed still holds, known-open still exactly five rows.
Three small things, two of which are the same defect in a smaller place.

1. A refusal is not a match. A 70 KB command with nothing resembling the
   directory in it returned "commands that can reach ~/.mureo are
   blocked", so an agent reading the reason would hunt for a reference
   that is not there and retry. Oversize now has its own reason saying
   what actually happened: the command was refused unread and nothing was
   concluded about it. The reason for a real hit is unchanged.

2. The sequence comment was wrong, and so was a test row that rested on
   it. It claimed `{-..0}` denies because its character range spans ASCII
   46 and can therefore produce the dot. Bash does not expand `{-..0}` at
   all -- verified: printf '[%s]' {-..0} prints [{-..0}] -- because a
   sequence endpoint must be an integer or a single letter. Following
   that through: a letter range lies within ASCII 65..122, so no
   recognised sequence can produce a dot, and the `.*` reading is not for
   dot-producing ranges but for syntax the guard does not recognise. The
   comment now says that, and the deny is described as what it is: a
   conservative refusal of syntax this does not parse.

   The test row is worse than the comment was. `cat ~/{-..0}mureo/...`
   sat in the shell-level deny list under a comment about spanning ASCII
   46, and since bash leaves it literal the command reaches nothing -- an
   over-block pinned as though it were protection, which is the shape of
   mistake this whole review has been about. It has moved to its own test
   with `echo {-..0}` and `echo {a..z..2}`, named for what it checks and
   documented as an over-block.

3. The depth table was claimed as 60 cells and committed as 30, because
   the parametrisation sampled ten depths rather than walking all twenty.
   The claim is the one worth keeping, so the test now walks 1 to 20 with
   two, three and five alternatives per level: 60 rows, each asserting
   both that the command reads the marker file and that the guard denies
   it. The docstring figure that had no counterpart -- "38 of those cells
   were allowed" -- is gone, replaced by what can be reproduced by
   reverting.

Two and three are the same failure as every round of this review: a
sentence stronger than the measurement behind it. The docstring is
defended by CI now; comments, commit messages and test-row rationales are
not, and that is exactly where it came back. The only durable answer is
to write the check first and the sentence after.

Verified unchanged: 73 everyday commands 0 denied, 51 must-deny forms 0
through, the 10 mureo identifiers of #567 0 denied, 21 brace-using
commands 1 refused, 20 %-heavy commands 1 refused, all reproduction
families denied, 4 MB and 8 MB answered in 0.13 s and 0.19 s, the
65536/65537 boundary exact, fail-closed intact, known-open still five
rows. Guard rows 240 -> 272; product 2698, sample 118.
@hyoshi
hyoshi force-pushed the fix/bash-guard-glob-bypass branch from 0fae0ac to 3d8e933 Compare August 8, 2026 01:53
hyoshi added 3 commits August 8, 2026 12:25
… claim

test-windows failed 61 times with "TypeError: environment can only contain
strings". `reads_marker` is annotated `home: str` and every caller passes a
`Path`; subprocess on POSIX accepts one in `env` and Windows does not. The
annotation disagreeing with the argument was not cosmetic -- it was a
latent failure waiting for the first runtime that enforces the contract,
and the review saw it, reasoned from one platform, and downgraded it.

Coerced inside `reads_marker` rather than at the call sites, so the
signature and the reality agree without every caller having to remember.
`build_home` returns a Path now, which removes the Path/str juggling that
produced the mismatch in the first place.

Same function built a minimal `{"HOME", "PATH"}` environment. On a platform
where a shell needs more than that to start, the failure mode is "the
command did not read the file" -- indistinguishable from the guard working,
which is the worst way for a check to break. It extends os.environ now,
like hook_guard_runner already did.

Audited the rest of the branch for the shape rather than the instance:
hook_guard_runner passes str() and inherits os.environ in both helpers, and
TestResourceBounds builds no environment at all. reads_marker was the only
offender on either count.

Two contract tests pin it, and they run on every platform precisely because
the lenient one is where the mistake gets made: the environment handed to
each subprocess must be all strings, and must extend os.environ rather than
replace it.

Separately, the depth table bundled two different claims into one test: that
a real shell reaches the file, and that the guard denies. The first is a
statement about POSIX path and glob semantics; under an emulated shell a
"no" would mean "not reachable through this translation layer", which is not
the property under test and reads exactly like the guard working. They are
two tests now -- the guarantee runs everywhere, the premise is POSIX-only
with a skip reason that names the requirement. No assertion was weakened to
pass on both.

Coverage lost on Windows: the 60 "really reaches the file" premises and the
slow whole-product run, both of which are shell-semantics claims. Everything
the guard decides is still checked there -- 60 depth denials, the 118-member
product sample, the resource bounds, and all 272 rows in
test_credential_guard.py.

mypy --strict over the four guard test files is clean now (it is not in CI's
scope, which stays mureo/ by an earlier documented decision -- I ran it by
hand because this class of bug is exactly what it catches).

Local: 8041 passed, the same 12 pre-existing failures; slow product
2698/2698.
test-windows was down to one failure after the environment fix, and it is
a different defect: os.path.realpath raises on an embedded NUL on POSIX
and does not on Windows, where the path simply fails to resolve under the
protected directory. Nothing fails open there -- there is no exception to
escape -- so the guard allowing it is correct, and the assertion was
pinning a platform's behaviour rather than the guard's.

This was one of the original 61 failures and was never an environment
problem; it hid inside that count.

Coverage lost on Windows: this single trigger. The property it
demonstrates -- an exception denies rather than exiting 1, which is the
non-blocking case both hosts treat as "proceed" -- is still asserted there
by the malformed-stdin tests for both guards, which pass on every
platform.
@hyoshi
hyoshi merged commit 4d65abe into main Aug 11, 2026
13 checks passed
@hyoshi
hyoshi deleted the fix/bash-guard-glob-bypass branch August 11, 2026 21:42
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