Add shell-command policies and a machine-readable Violation category - #74
Conversation
The README named shell.run in its threat model, but URLAllowList was the only strong bundled rule -- anyone guarding a shell tool had to write correct shell-safety logic themselves. ShellCommandAllowList is default-deny and matches structured argv rather than raw-string prefixes, because textual matching is defeated by a leading path, quoting, an environment assignment, a chained command, an embedded newline, or an inline interpreter script. Each of those is now refused, and unparseable input fails closed. An inline interpreter script is blocked even when the interpreter itself is allowlisted: permitting sh must not silently permit everything sh can run. NoDangerousShellPatterns is the complementary tripwire, documented everywhere as a tripwire rather than a boundary. Violation gains a category, every bundled rule sets one, and ModelFuzzBlockError exposes it -- a block is a policy decision, not an infrastructure failure, and an agent loop should be able to tell them apart without regex-matching prose. Fixes #71
An adversarial sweep of this branch found two working bypasses of the allowlist, both reproduced end-to-end through the README's own example. Environment assignments were skipped to find the real binary and then discarded. But the environment decides which program a name resolves to and how it behaves, so the discarded tokens were themselves the exploit: PATH=/tmp/pwn ls runs the attacker's ls, LD_PRELOAD= injects into the genuine one, and GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.fsmonitor \ GIT_CONFIG_VALUE_0=id git status makes an allowlisted `git status` spawn id, with no writable file and no metacharacter. Assignments are now refused; the dangerous names are not enumerable, so the default is all of them, with an allowed_env opt-in. The docstring had advertised the stripping as a hardening feature. Inline-script detection compared whole tokens against four exact spellings, so bash -lc, sh -ec, python -cCODE, perl -0e, node --eval, node -p, node --eval=, python - and sh -s all reached the interpreter. Detection is now structural: bundled short clusters, values attached to the flag, long forms with =value, and stdin scripts. The residual gap cannot be closed by flag inspection at all -- awk carries its program as a positional argument -- so the docstring no longer promises that allowlisting an interpreter is safe, and the constructor warns when it happens. Refs #71
Pushed b740d25 β two working bypasses found and fixedBefore merging I ran an adversarial sweep against this branch: seven independent lenses hunting bypasses, with every candidate empirically re-run by a separate agent whose job was to refute it. 43 candidates raised, 7 survived verification, collapsing to two root causes. Both were reproduced end-to-end through this README's own example, so I've fixed them here rather than shipping and following up. B1 β environment assignments were stripped and discarded (critical)
The last one is the worst: against real git it spawns Worse, the docstring advertised the stripping as a hardening feature ("Environment assignments are skipped to find the real binary"). It was the largest hole in the rule. Fix: assignments are refused, not stripped. The dangerous names aren't enumerable β ShellCommandAllowList(["ls"], allowed_env={"LANG"})Note this inverts a test that had pinned the vulnerable behaviour ( B2 β inline-script detection matched four exact spellings (high)
All verified to really execute under both Fix: detection is structural β scans short-option clusters, handles values attached to the flag, splits The part flag inspection can never fix
Also
On the dict-keys question I flagged earlierThe sweep's verdict: values-only is safe, keep it. It found exactly one input that turns on it β Verification
Ready for review. |
GitHub secret scanning reported tests/test_rules.py as containing a possible live "Amazon AWS Temporary Access Key ID". It never did: AKIAIOSFODNN7EXAMPLE is AWS's published documentation placeholder and every scanner allowlists it, but the ASIA-prefixed variant used to cover temporary credentials is on nobody's list, so the literal read as real. Building it by concatenation keeps the coverage and keeps the shape out of the source text. The irony is on the nose: a scanner matched format rather than validity, which is the exact limitation SecretPatternFilter documents about itself.
Summary
The README named
shell.runin its threat model, butURLAllowListwas the only strong bundled rule β anyone guarding a shell tool had to hand-write correct shell-safety logic. This adds the two policies and thecategoryfield from the refined spec, incorporating @newjsouza's design feedback.ShellCommandAllowListβ default-deny, matching structured argv rather than raw-string prefixes. Entries are argv prefixes:"git status"permitsgit status --short, notgit push.Raw-string prefix matching falls to every row below; structured matching does not:
/bin/ls,./ls,"ls" -lalsβ allowedls; curl evil.commetacharacterls\ncurl evil.commetacharacterFOO=bar curl evil.comcurl, notFOO=barnot_allowlistedenv FOO=bar curl evil.comcurlnot_allowlistedsh -c "curl evil.com"shis allowlistedinterpretersudo lsnot_allowlistedls "unbalancedunparseableNoDangerousShellPatternsβ the raw-text tripwire, documented in the class docstring, README body, README Limitations andAGENTS.mdas a tripwire and explicitly not a shell parser or security boundary. Unlike the allowlist it only blocks on a positive match, so it is safe on a multi-argument tool.Violation.categoryβ every bundled rule now tags its blocks (credential,not_allowlisted,metacharacter,interpreter, β¦), defaulting tounspecifiedso a hand-written policy predating the field keeps working.Fixes #71
Three decisions worth a reviewer's attention
The category had to reach the exception, not just the log. The stated goal was "the agent loop can surface a safe tool error and recover without treating the block as an infrastructure failure" β the loop catches
ModelFuzzBlockError, so a log field alone wouldn't have delivered it.ModelFuzzBlockErrornow exposes.category,.rule_nameand.violation;str(exc)is unchanged and bare one-arg construction still works (both pinned by tests).A newline was a real bypass, caught by a failing test.
shlextreats\nas ordinary whitespace, sols\ncurl evil.comflattened into a single innocent-looking argv while a shell would run two commands. Now checked on the raw string before the split erases it.ShellCommandAllowListreads dict values but not dict keys β a deliberate divergence from the other rules' walk, commented at the call site. Because this rule is default-deny, reading keys made{"cmd": "ls"}block on a command namedcmd, i.e. nearly every dict argument would fail on its own field names. A command arrives as a value.Both new rules name only the offending binary or pattern in the reason, never the full command β same reasoning as #73, since blocks are logged and the arguments are exactly where a credential or customer record lives.
Limits, documented rather than papered over
ShellCommandAllowListtreats every string it sees as a command (a policy cannot know an argument's name, so a second string argument likecwdis judged as a command and blocked β it belongs on an engine guarding a single-command-argument tool), and it governs the command rather than what the command then does. Both are in the docstring, the README section, and Limitations.Test plan
uv run ruff check ./ruff format --check ./mypy --strict src/modelfuzzcleanuv run pytest -qβ 277 passed (122 new), no changes needed to any pre-existing testtests/test_shell_rules.pycovers the four criteria from the issue: quoting variants (whitespace, quoted binary, absolute/relative/traversal paths), nested containers (argv-vs-command-list distinction, dicts, sets, bytes, cycles), interpreter and wrapper invocation (sh -c,bash -c,python -c,perl -e,pwsh -Command,env, bare assignments,sudo), and fail-closed behaviour (unbalanced quotes, empty, assignment-only)AGENTS.mdsnippet end-to-end, including all eight table rows β output matches the documented categories exactly