Rules / CFG101
CFG101 a deny rule constrains bundled short flags, which reordering walks past
Severity: warn
OWASP: LLM03:2026 – Excessive Agency
What cfgaudit checks
Claude Code matches a Bash(...) permission pattern as a literal prefix. A rule that names flags is therefore evaded by writing the same flags differently. Measured against 2.1.231 in #480, with Bash(echo -n *) denied:
| Command | Result |
|---|---|
echo -n zzAAA |
DENIED |
echo -e -n zzBBB |
ran |
echo zzCCC -n |
ran |
Applied to the shape this rule reports: Bash(rm -rf *) never covered rm -fr x. Upstream says the same in its own words, calling argument-constrained Bash patterns "fragile".
cfgaudit reports a deny or ask entry whose pattern contains a bundled short-flag token — two or three lowercase letters behind one dash, such as -rf, -fr, -it, -xzf. Those are the tokens the shell treats as a set, so writing them in one order does not cover the others. The message names a spelling that reaches the same command.
allow rules are not examined. An allow rule that fails to match produces a prompt, which is the safe direction.
One finding per list, not per entry
A file's offending entries are collapsed into a single finding per permission list, so a deny block with four fragile patterns reports once and names all four. deny and ask stay separate, because they are different guarantees with the same remedy.
This follows a measurement taken before the v1.12.0 release (#517), which found the rule reports on a large share of deny-carrying configs and that none of that volume is a false positive. See Footprint for the rates. The collapse cut a sampled corpus from 68 findings to 39 over the same 38 repositories, which is 43% fewer lines for exactly the same set of problems. It loses nothing, because the entries live in one file and are fixed in one edit.
Why it is exactly this narrow
Every exclusion is here because something real would otherwise be misread:
| Shape | Why it is not reported |
|---|---|
--force |
a long flag cannot be permuted within itself |
-f |
a lone short flag has nothing to permute |
find -name, find -type, java -version |
four or more letters behind one dash is where single-dash long options live in real tools. Permuting their letters produces nonsense |
PowerShell(Remove-Item -Recurse) |
PowerShell writes every parameter as a single -Word and does not bundle at all, so the evasion does not exist there |
npm run test |
a subcommand is not a flag and does not reorder freely |
Bash(*) |
a catch-all constrains no arguments |
A file that also denies the bare command is not reported. Bash(rm -rf *) beside Bash(rm *) leaves no gap: the broad rule catches every spelling and the narrow one is redundant rather than a hole.
The remedy is always available for the reported shape
Deny the command by name:
{ "permissions": { "deny": ["Bash(rm *)"] } }
That is what #482 did to this project's own cfgaudit init baseline, replacing Bash(rm -rf *) with Bash(rm *). Widening a deny rule is safe in a way that widening an allow rule is not.
For shapes this rule does not report, such as force-push, no broader form exists that would not block every push. Those stay guidance, and upstream points at OS-level sandboxing and PreToolUse hooks for the part a pattern cannot enforce.
Footprint
Two numbers, over different populations. They answer different questions and both are in #517.
Population rate: about 44%. Of 22,016 indexed settings.json files carrying a permissions.deny block, 9,792 contain Bash(rm -rf and a further 850 contain git clean -fd. That is the share of deny-carrying configs in public repositories expected to report at least one finding here, and it is the number to plan around.
Run against real files: 26 of 141 (18%), for 49 findings before the collapse above. Those 141 are the .claude/settings.json files carrying a deny list in the 504-repo false-positive corpus. The lower rate is the corpus rather than the rule: those repositories were not selected for having a deny list, and the index query counts a substring where the rule applies its exclusions.
Neither is a false-positive rate. Every finding in both samples was read and none is a false positive. Three repositories in the 141 write both Bash(rm -rf …) and Bash(rm -fr …), so their authors already knew the ordering matters and enumerated around it, and still miss -r -f. Only one file in the whole sample also carries the bare Bash(rm *) that would close it.
This is the distinction that separates this rule from findings this project has declined at similar rates. #459 dropped CFG095's build-cache warn, and CFG089's escalation and the Cursor environment.json routing were both declined, because in each case the population was documented normal usage. Here the population is attempted protection that does not work, which is what a config linter exists to say.
Trigger example
{ "permissions": { "deny": ["Bash(rm -rf *)"] } }
→ warn: rm -fr reaches the same command.
References
- #480 — the measurement this rule generalises
- CFG006, CFG041–CFG044 — the deny-coverage rules, which ask whether a class is covered rather than whether the pattern holds
- Configure permissions — Claude Code Docs
Documentation for cfgaudit 1.18.1. Source of this page · Rule implementation