fix: close the credential guard's filename-search bypass - #582
Merged
Conversation
Rules 1 and 2 both ask whether the command spells some form of the
protected directory. A tree search does not have to. Both of these
printed the credentials file against a throwaway HOME, with no
obfuscation and no adversarial intent required:
find ~ -name credentials.json -exec cat {} \;
find ~ -path '*mureo*' -exec cat {} \;
"Look for any leftover credential files under my home directory" is an
ordinary instruction, and the accident it causes is the one this guard
exists to make less likely. Neither rule could see it: one names no
directory at all, the other names it with no leading dot for rule 2 to
anchor on.
Rule 3 denies a glob metacharacter standing immediately before the
written-out name. It reads the RAW command text as well as the
normalized readings, and that is the substance of it: the quotes in
-path '*mureo*' are there to keep the shell off the pattern so that find
can expand it, so normalization -- which faithfully models the shell --
erases the very metacharacter that makes it dangerous. What a downstream
program will expand is written literally in the command, so that is
where to look. Every other rule stays on the readings, because every
other rule is about what the shell does. Pinned so it cannot be
optimized back onto the readings.
Rule 4 denies the protected filenames where they stand on their own,
with no slash before them. The restriction is the rule: a name with a
path in front of it is a specific file rather than a search, and which
file it is has already been settled from the directory -- the protected
path denies on rule 1, while a backups directory holds the user's own
file under a directory this guard does not protect. Without the
restriction rule 4 also contradicted three cases this module already
reasons about and allows, where the name is written but the shell cannot
reach the directory.
Two costs, stated rather than hidden, and both now in the module's
known-open-bypass list alongside a third:
- config.json is deliberately not guarded by name. It is one of the most
common filenames in software and denying it would block real work in
every project the agent touches; the guard is judged by whether it
makes the common accident less likely WITHOUT blocking real work, and
that trade lands the wrong way. A filename search for config.json
still reads that one file.
- A bare "cat credentials.json" in a project of your own now denies.
Rule 4 carries its own reason rather than borrowing the directory
reason, because being told a command can reach a directory it never
named sends an agent looking for a reference that is not there. The
reason names what matched and points at the Read tool, which is
guarded by path and opens a same-named file anywhere outside the
protected directory.
- The Bash guard resolves no symlinks, while the path guard protecting
the same directory resolves them in both directions. A link into the
directory under an unrelated name is invisible to the Bash guard,
which never touches the filesystem. Documented, not closed.
Verified against a real bash with a throwaway HOME, not by inspection:
the two commands above and eight more like them now deny, and the
allow-side corpus (config.json, project files whose names merely contain
a guarded one, a checkout of this repository, "ls *", "rm -rf build/*")
stays allowed. The payload's character constraints are re-checked
mechanically -- no double quote, dollar, backslash, backtick, newline or
bang reaches the python3 -c wrapper.
…old split-brain bug The module's summary still said two rules, and the paragraph warning against a second reader now had a rule that reads the raw command sitting a few lines below it. The distinction is load-bearing and was undocumented: the historical bug was partition (each rule owned one string and was blind to the other), where rule 3 is a union over both, so no fold can open a hole underneath it.
Merged
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.
The hole
Rules 1 and 2 of the Bash guard both ask the same question: does the command spell some form of the protected directory? A tree search does not have to. Against a throwaway
HOME, both of these printed the credentials file:No obfuscation, no adversarial intent, nothing an operator would look at twice. "Look for any leftover credential files under my home directory" is an ordinary instruction, and the accident it causes is precisely the one this guard exists to make less likely — the module docstring's own framing of what the guard is for.
Neither rule could see it. The first command names no directory at all. The second names it, but with no leading dot for rule 2 to anchor on: rule 2 only considers path components beginning with a literal
., andfind -pathmatches against the full path, so*covers the period.This is not a regression. Nothing in this area got worse; the directory-name obfuscation work closed real holes and its claims hold up. This is a part of the surface that was never read.
Rule 3 — a pattern reaching the name
Denies a glob metacharacter standing immediately before the written-out
mureo.It reads the raw command text as well as the normalized readings, and that is the substance of the rule rather than an implementation detail. Normalization models what the shell expands, so it neutralizes a quoted
*— correctly, for the shell. But the quotes in-path '*mureo*'are there for exactly one reason: to keep the shell off the pattern so thatfindcan expand it. By the time the normalized reading exists the pattern has become=mureo=and there is nothing left to match. What a downstream program will expand is written literally in the command, so that is where rule 3 looks for it.Every other rule stays on the normalized readings, because every other rule is about what the shell does. There is a test pinning both the quoted and unquoted spellings so this cannot be quietly optimized back onto the readings.
Deliberately narrower than "any pattern that could match":
mureohas to be written out. Working inside a checkout of this repository (grep -r foo mureo/) is untouched.Rule 4 — the protected filenames
Denies
credentials.json,credentials.json.bak,agency.jsonandsetup_state.jsonwhere the name stands on its own, with no/before it.The restriction is the rule, not a caveat on it. A name with a path in front of it is a specific file rather than a search, and which file it is has already been settled from the directory by rules 1–3: the protected path denies on rule 1, while
~/backups/credentials.jsonis the user's own file under a directory this guard does not protect and refusing it would be overreach.Without the restriction the rule also contradicted three cases this module already reasons about and explicitly allows —
cat "$HOME/.mure?/credentials.json"and two fully-quoted paths — where the name is written down but the shell cannot reach the directory. Those tests failing is what surfaced the narrower, more principled rule; they now pass unchanged.Rule 4 carries its own deny reason rather than borrowing the directory one. Being told a command "can reach" a directory it never named sends an agent looking for a reference that is not there and retrying. The reason names what actually matched and points at the Read tool, which is guarded by path and so opens a same-named file anywhere outside the protected directory.
What this costs, stated rather than hidden
All three are now in the module's known-open-bypass list, which is the list a future change has to edit rather than something a reviewer rediscovers.
config.jsonis deliberately not guarded by name. It is one of the most common filenames in software; denying it would breakcat config.jsonin every project the agent ever touches. The guard is judged by whether it makes the common accident less likely without blocking real work, and that trade lands the wrong way. A filename search forconfig.jsonstill reads that one file.cat credentials.jsonin a project of your own now denies. This is the deterrent working as designed, and the reason tells the operator how to proceed.Verification
Measured against a real bash with a throwaway
HOME, not by inspection.config.jsonin every spelling, project files whose names merely contain a guarded one, a checkout of this repository,ls *,rm -rf build/*,echo {1..100}) stays allowed.tests/test_credential_guard.py— 236 passed, including a newTestSearchByNameRatherThanByDirectoryclass in the existing style.tests/test_credential_guard_product.py— the 2698-member differential product through a real shell, 129 passed.",$, backslash, backtick, newline or!reaches thepython3 -cwrapper, and the payload still compiles.ruff,blackandmypyclean.Provenance
Found by a post-merge review of #568, which was merged without the usual review gate. The two other PRs merged in that batch (#569, #575) were reviewed at the same time and produced no CRITICAL or HIGH findings.