|
| 1 | +--- |
| 2 | +title: Classification bugs in claude-permissions-optimizer extract-commands script |
| 3 | +category: logic-errors |
| 4 | +date: 2026-03-18 |
| 5 | +severity: high |
| 6 | +tags: [security, classification, normalization, permissions, command-extraction, destructive-commands, dcg] |
| 7 | +component: claude-permissions-optimizer |
| 8 | +symptoms: |
| 9 | + - Dangerous commands (find -delete, git push -f) recommended as safe to auto-allow |
| 10 | + - Safe/common commands (git blame, gh CLI) invisible or misclassified in output |
| 11 | + - 632 commands reported as below-threshold noise due to filtering before normalization |
| 12 | + - git restore -S (safe unstage) incorrectly classified as red (destructive) |
| 13 | +--- |
| 14 | + |
| 15 | +# Classification Bugs in claude-permissions-optimizer |
| 16 | + |
| 17 | +## Problem |
| 18 | + |
| 19 | +The `extract-commands.mjs` script in the claude-permissions-optimizer skill had three categories of bugs that affected both security and UX of permission recommendations. |
| 20 | + |
| 21 | +**Symptoms observed:** Running the skill across 200 sessions reported 632 commands as "below threshold noise" -- suspiciously high. Cross-referencing against the Destructive Command Guard (DCG) project confirmed classification gaps on both spectrums. |
| 22 | + |
| 23 | +## Root Cause |
| 24 | + |
| 25 | +### 1. Threshold before normalization (architectural ordering) |
| 26 | + |
| 27 | +The min-count filter was applied to each raw command **before** normalization and grouping. Hundreds of variants of the same logical command (e.g., `git log --oneline src/foo.ts`, `git log --oneline src/bar.ts`) were each discarded individually for falling below the threshold of 5, even though their normalized form (`git log *`) had 200+ total uses. |
| 28 | + |
| 29 | +### 2. Normalization broadens classification |
| 30 | + |
| 31 | +Safety classification happened on the **raw** command, but the result was carried forward to the **normalized** pattern. `node --version` (green via `--version$` regex) would normalize to the dangerously broad `node *`, inheriting the green classification despite `node` being a yellow-tier base command. |
| 32 | + |
| 33 | +### 3. Compound command classification leak |
| 34 | + |
| 35 | +Classify ran on the full raw command string, but normalize only used the first command in a compound chain. So `cd /dir && git branch -D feature` was classified as RED (from the `git branch -D` part) but normalized to `cd *`. The red classification from the second command leaked into the first command's pattern, causing `cd *` to appear in the blocked list. |
| 36 | + |
| 37 | +### 4. Global risk flags causing false fragmentation |
| 38 | + |
| 39 | +Risk flags (`-f`, `-v`) were preserved globally during normalization to keep dangerous variants separate. But `-f` means "force" in `git push -f` and "pattern file" in `grep -f`, while `-v` means "remove volumes" in `docker-compose down -v` and "verbose/invert" everywhere else. Global preservation fragmented green patterns unnecessarily (`grep -v *` separate from `grep *`) and contaminated benign patterns with wrong risk reasons. |
| 40 | + |
| 41 | +### 5. Allowlist glob broader than classification intent |
| 42 | + |
| 43 | +Commands with mode-switching flags (`sed -i`, `find -delete`, `ast-grep --rewrite`) were classified green without the flag but normalized to a broad pattern like `sed *`. The resulting allowlist rule `Bash(sed *)` would auto-allow the destructive form too, since Claude Code's glob matching treats `*` as matching everything. The classification was correct for the individual command but the recommended pattern was unsafe. |
| 44 | + |
| 45 | +### 6. Classification gaps (found via DCG cross-reference) |
| 46 | + |
| 47 | +**Security bugs (dangerous classified as green):** |
| 48 | +- `find` unconditionally in `GREEN_BASES` -- `find -delete` and `find -exec rm` passed as safe |
| 49 | +- `git push -f` regex required `-f` after other args, missed `-f` immediately after `push` |
| 50 | +- `git restore -S` falsely red (lookahead only checked `--staged`, not the `-S` alias) |
| 51 | +- `git clean -fd` regex required `f` at end of flag group, missed `-fd` (f then d) |
| 52 | +- `git checkout HEAD -- file` pattern didn't allow a ref between `checkout` and `--` |
| 53 | +- `git branch --force` not caught alongside `-D` |
| 54 | +- Missing RED patterns: `npm unpublish`, `cargo yank`, `dd of=`, `mkfs`, `pip uninstall`, `apt remove/purge`, `brew uninstall`, `git reset --merge` |
| 55 | + |
| 56 | +**UX bugs (safe commands misclassified):** |
| 57 | +- `git blame`, `git shortlog` -> unknown (missing from GREEN_COMPOUND) |
| 58 | +- `git tag -l`, `git stash list/show` -> yellow instead of green |
| 59 | +- `git clone` -> unknown (not in any YELLOW pattern) |
| 60 | +- All `gh` CLI commands -> unknown (no patterns at all) |
| 61 | +- `git restore --staged/-S` -> red instead of yellow |
| 62 | + |
| 63 | +## Solution |
| 64 | + |
| 65 | +### Fix 1: Reorder the pipeline |
| 66 | + |
| 67 | +Normalize and group commands first, then apply the min-count threshold to the grouped totals: |
| 68 | + |
| 69 | +```javascript |
| 70 | +// Group ALL non-allowed commands by normalized pattern first |
| 71 | +for (const [command, data] of commands) { |
| 72 | + if (isAllowed(command)) { alreadyCovered++; continue; } |
| 73 | + const pattern = "Bash(" + normalize(command) + ")"; |
| 74 | + // ... group by pattern, merge sessions, escalate tiers |
| 75 | +} |
| 76 | + |
| 77 | +// THEN filter by min-count on GROUPED totals |
| 78 | +for (const [pattern, data] of patternGroups) { |
| 79 | + if (data.totalCount < minCount) { |
| 80 | + belowThreshold += data.rawCommands.length; |
| 81 | + patternGroups.delete(pattern); |
| 82 | + } |
| 83 | +} |
| 84 | +``` |
| 85 | + |
| 86 | +### Fix 2: Post-grouping safety reclassification |
| 87 | + |
| 88 | +After grouping, re-classify the normalized pattern itself. If the broader form maps to a more restrictive tier, escalate: |
| 89 | + |
| 90 | +```javascript |
| 91 | +for (const [pattern, data] of patternGroups) { |
| 92 | + if (data.tier !== "green") continue; |
| 93 | + if (!pattern.includes("*")) continue; |
| 94 | + const cmd = pattern.replace(/^Bash\(|\)$/g, ""); |
| 95 | + const { tier, reason } = classify(cmd); |
| 96 | + if (tier === "red") { data.tier = "red"; data.reason = reason; } |
| 97 | + else if (tier === "yellow") { data.tier = "yellow"; } |
| 98 | + else if (tier === "unknown") { data.tier = "unknown"; } |
| 99 | +} |
| 100 | +``` |
| 101 | + |
| 102 | +### Fix 3: Classify must match normalize's scope |
| 103 | + |
| 104 | +Classify now extracts the first command from compound chains (`&&`, `||`, `;`) and pipe chains before checking patterns, matching what normalize does. Pipe-to-shell (`| bash`) is excluded from stripping since the pipe itself is the danger. |
| 105 | + |
| 106 | +```javascript |
| 107 | +function classify(command) { |
| 108 | + const compoundMatch = command.match(/^(.+?)\s*(&&|\|\||;)\s*(.+)$/); |
| 109 | + if (compoundMatch) return classify(compoundMatch[1].trim()); |
| 110 | + const pipeMatch = command.match(/^(.+?)\s*\|\s*(.+)$/); |
| 111 | + if (pipeMatch && !/\|\s*(sh|bash|zsh)\b/.test(command)) { |
| 112 | + return classify(pipeMatch[1].trim()); |
| 113 | + } |
| 114 | + // ... RED/GREEN/YELLOW checks on the first command only |
| 115 | +} |
| 116 | +``` |
| 117 | + |
| 118 | +### Fix 4: Context-specific risk flags |
| 119 | + |
| 120 | +Replaced global `-f`/`-v` risk flags with a contextual system. Flags are only preserved during normalization when they're risky for the specific base command: |
| 121 | + |
| 122 | +```javascript |
| 123 | +const CONTEXTUAL_RISK_FLAGS = { |
| 124 | + "-f": new Set(["git", "docker", "rm"]), |
| 125 | + "-v": new Set(["docker", "docker-compose"]), |
| 126 | +}; |
| 127 | + |
| 128 | +function isRiskFlag(token, base) { |
| 129 | + if (GLOBAL_RISK_FLAGS.has(token)) return true; |
| 130 | + const contexts = CONTEXTUAL_RISK_FLAGS[token]; |
| 131 | + if (contexts && base && contexts.has(base)) return true; |
| 132 | + // ... |
| 133 | +} |
| 134 | +``` |
| 135 | + |
| 136 | +Risk flags are a **presentation improvement**, not a safety mechanism. Classification + tier escalation handles safety regardless. The contextual approach prevents fragmentation of green patterns (`grep -v *` merges with `grep *`) while keeping dangerous variants visible in the blocked table (`git push -f *` stays separate from `git push *`). |
| 137 | + |
| 138 | +Commands with mode-switching flags (`sed -i`, `ast-grep --rewrite`) are handled via dedicated normalization rules rather than risk flags, since their safe and dangerous forms need entirely different classification. |
| 139 | + |
| 140 | +### Fix 5: Mode-preserving normalization |
| 141 | + |
| 142 | +Commands with mode-switching flags get dedicated normalization rules that preserve the safe/dangerous mode flag, producing narrow patterns safe to recommend: |
| 143 | + |
| 144 | +```javascript |
| 145 | +// sed: preserve the mode flag |
| 146 | +if (/^sed\s/.test(command)) { |
| 147 | + if (/\s-i\b/.test(command)) return "sed -i *"; |
| 148 | + const sedFlag = command.match(/^sed\s+(-[a-zA-Z])\s/); |
| 149 | + return sedFlag ? "sed " + sedFlag[1] + " *" : "sed *"; |
| 150 | +} |
| 151 | + |
| 152 | +// find: preserve the predicate/action flag |
| 153 | +if (/^find\s/.test(command)) { |
| 154 | + if (/\s-delete\b/.test(command)) return "find -delete *"; |
| 155 | + if (/\s-exec\s/.test(command)) return "find -exec *"; |
| 156 | + const findFlag = command.match(/\s(-(?:name|type|path|iname))\s/); |
| 157 | + return findFlag ? "find " + findFlag[1] + " *" : "find *"; |
| 158 | +} |
| 159 | +``` |
| 160 | + |
| 161 | +GREEN_COMPOUND then matches the narrow normalized forms: |
| 162 | + |
| 163 | +```javascript |
| 164 | +/^sed\s+-(?!i\b)[a-zA-Z]\s/ // sed -n *, sed -e * (not sed -i *) |
| 165 | +/^find\s+-(?:name|type|path|iname)\s/ // find -name *, find -type * |
| 166 | +/^(ast-grep|sg)\b(?!.*--rewrite)/ // ast-grep * (not ast-grep --rewrite *) |
| 167 | +``` |
| 168 | + |
| 169 | +Bare forms without a mode flag (`sed *`, `find *`) fall to yellow/unknown since `Bash(sed *)` would match the destructive variant. |
| 170 | + |
| 171 | +### Fix 6: Patch classification gaps |
| 172 | + |
| 173 | +Key regex fixes: |
| 174 | + |
| 175 | +```javascript |
| 176 | +// find: removed from GREEN_BASES; destructive forms caught by RED |
| 177 | +{ test: /\bfind\b.*\s-delete\b/, reason: "find -delete permanently removes files" }, |
| 178 | +{ test: /\bfind\b.*\s-exec\s+rm\b/, reason: "find -exec rm permanently removes files" }, |
| 179 | +// Safe find via GREEN_COMPOUND: |
| 180 | +/^find\b(?!.*(-delete|-exec))/ |
| 181 | + |
| 182 | +// git push -f: catch -f in any position |
| 183 | +{ test: /git\s+(?:\S+\s+)*push\s+.*-f\b/ }, |
| 184 | +{ test: /git\s+(?:\S+\s+)*push\s+-f\b/ }, |
| 185 | + |
| 186 | +// git restore: exclude both --staged and -S from red |
| 187 | +{ test: /git\s+restore\s+(?!.*(-S\b|--staged\b))/ }, |
| 188 | +// And add yellow pattern for the safe form: |
| 189 | +/^git\s+restore\s+.*(-S\b|--staged\b)/ |
| 190 | + |
| 191 | +// git clean: match f anywhere in combined flags |
| 192 | +{ test: /git\s+clean\s+.*(-[a-z]*f[a-z]*\b|--force\b)/ }, |
| 193 | + |
| 194 | +// git branch: catch both -D and --force |
| 195 | +{ test: /git\s+branch\s+.*(-D\b|--force\b)/ }, |
| 196 | +``` |
| 197 | + |
| 198 | +New GREEN_COMPOUND patterns for safe commands: |
| 199 | + |
| 200 | +```javascript |
| 201 | +/^git\s+(status|log|diff|show|blame|shortlog|...)\b/ // added blame, shortlog |
| 202 | +/^git\s+tag\s+(-l\b|--list\b)/ // tag listing |
| 203 | +/^git\s+stash\s+(list|show)\b/ // stash read-only |
| 204 | +/^gh\s+(pr|issue|run)\s+(view|list|status|diff|checks)\b/ // gh read-only |
| 205 | +/^gh\s+repo\s+(view|list|clone)\b/ |
| 206 | +/^gh\s+api\b/ |
| 207 | +``` |
| 208 | + |
| 209 | +New YELLOW_COMPOUND patterns: |
| 210 | + |
| 211 | +```javascript |
| 212 | +/^git\s+(...|clone)\b/ // added clone |
| 213 | +/^gh\s+(pr|issue)\s+(create|edit|comment|close|reopen|merge)\b/ // gh write ops |
| 214 | +``` |
| 215 | + |
| 216 | +## Verification |
| 217 | + |
| 218 | +- Built a test suite of 70+ commands across both spectrums (dangerous and safe) |
| 219 | +- Cross-referenced against DCG rule packs: core/git, core/filesystem, package_managers |
| 220 | +- Final result: 0 dangerous commands classified as green, 0 safe commands misclassified |
| 221 | +- Repo test suite: 344 tests pass |
| 222 | + |
| 223 | +## Prevention Strategies |
| 224 | + |
| 225 | +### Pipeline ordering is an architectural invariant |
| 226 | + |
| 227 | +The correct pipeline order is: |
| 228 | + |
| 229 | +``` |
| 230 | +filter(allowlist) -> normalize -> group -> threshold -> re-classify(normalized) -> output |
| 231 | +``` |
| 232 | + |
| 233 | +The post-grouping safety check that re-classifies normalized patterns containing wildcards is load-bearing. It must never be removed or moved before the grouping step. |
| 234 | + |
| 235 | +### The allowlist pattern is the product, not the classification |
| 236 | + |
| 237 | +The skill's output is an allowlist glob like `Bash(sed *)`, not a safety tier. Classification determines whether to recommend a pattern, but the pattern itself must be safe to auto-allow. This creates a critical constraint: **commands with mode-switching flags that change safety profile need normalization that preserves the safe mode flag**, so the resulting glob can't match the destructive form. |
| 238 | + |
| 239 | +Example: `sed -n 's/foo/bar/' file` is read-only and safe. But normalizing it to `sed *` produces `Bash(sed *)` which also matches `sed -i 's/foo/bar/' file` (destructive in-place edit). The fix is mode-preserving normalization: `sed -n *` produces `Bash(sed -n *)` which is narrow enough to be safe. |
| 240 | + |
| 241 | +This applies to any command where a flag changes the safety profile: |
| 242 | +- `sed -n *` (green) vs `sed -i *` (red) -- `-n` is read-only, `-i` edits in place |
| 243 | +- `find -name *` (green) vs `find -delete *` (red) -- `-name` is a predicate, `-delete` removes files |
| 244 | +- `ast-grep *` (green) vs `ast-grep --rewrite *` (red) -- default is search, `--rewrite` modifies files |
| 245 | + |
| 246 | +Commands like these should NOT go in `GREEN_BASES` (which produces the blanket `X *` pattern). They need dedicated normalization rules that preserve the mode flag, and `GREEN_COMPOUND` patterns that match the narrower normalized form. |
| 247 | + |
| 248 | +### GREEN_BASES requires proof of no destructive subcommands |
| 249 | + |
| 250 | +Before adding any command to `GREEN_BASES`, verify it has NO destructive flags or modes. If in doubt, use `GREEN_COMPOUND` with explicit negative lookaheads. Commands that should never be in `GREEN_BASES`: `find`, `xargs`, `sed`, `awk`, `curl`, `wget`. |
| 251 | + |
| 252 | +### Regex negative lookaheads must enumerate ALL flag aliases |
| 253 | + |
| 254 | +Every flag exclusion must cover both long and short forms. For git, consult `git <subcmd> --help` for every alias. Example: `(?!.*(-S\b|--staged\b))` not just `(?!.*--staged\b)`. |
| 255 | + |
| 256 | +### Classify and normalize must operate on the same scope |
| 257 | + |
| 258 | +If normalize extracts the first command from compound chains, classify must do the same. Otherwise a dangerous second command (`git branch -D`) contaminates the first command's pattern (`cd *`). Any future change to normalize's scoping logic must be mirrored in classify. |
| 259 | + |
| 260 | +### Risk flags are contextual, not global |
| 261 | + |
| 262 | +Short flags like `-f` and `-v` mean different things for different commands. Adding a short flag to `GLOBAL_RISK_FLAGS` will fragment every green command that uses it innocently. Use `CONTEXTUAL_RISK_FLAGS` with explicit base-command sets instead. For commands where a flag completely changes the safety profile (`sed -i`, `ast-grep --rewrite`), use a dedicated normalization rule rather than a risk flag. |
| 263 | + |
| 264 | +### GREEN_BASES must exclude commands useless as allowlist rules |
| 265 | + |
| 266 | +Commands like `cd` and `cal` are technically safe but useless as standalone allowlist rules in agent contexts (shell state doesn't persist, novelty commands never used). Including them creates noise in recommendations. Before adding to GREEN_BASES, ask: would a user actually benefit from `Bash(X *)` in their allowlist? |
| 267 | + |
| 268 | +### RISK_FLAGS must stay synchronized with RED_PATTERNS |
| 269 | + |
| 270 | +Every flag in a `RED_PATTERNS` regex must have a corresponding entry in `GLOBAL_RISK_FLAGS` or `CONTEXTUAL_RISK_FLAGS` so normalization preserves it. |
| 271 | + |
| 272 | +## External References |
| 273 | + |
| 274 | +### Destructive Command Guard (DCG) |
| 275 | + |
| 276 | +**Repository:** https://github.com/Dicklesworthstone/destructive_command_guard |
| 277 | + |
| 278 | +DCG is a Rust-based security hook with 49+ modular security packs that classify destructive commands. Its pack-based architecture maps well to the classifier's rule sections: |
| 279 | + |
| 280 | +| DCG Pack | Classifier Section | |
| 281 | +|---|---| |
| 282 | +| `core/filesystem` | RED_PATTERNS (rm, find -delete, chmod, chown) | |
| 283 | +| `core/git` | RED_PATTERNS (force push, reset --hard, clean -f, filter-branch) | |
| 284 | +| `strict_git` | Additional git patterns (rebase, amend, worktree remove) | |
| 285 | +| `package_managers` | RED_PATTERNS (publish, unpublish, uninstall) | |
| 286 | +| `system` | RED_PATTERNS (sudo, reboot, kill -9, dd, mkfs) | |
| 287 | +| `containers` | RED_PATTERNS (--privileged, system prune, volume rm) | |
| 288 | + |
| 289 | +DCG's rule packs are a goldmine for validating classifier completeness. When adding new command categories or modifying rules, cross-reference the corresponding DCG pack. Key packs not yet fully cross-referenced: `database`, `kubernetes`, `cloud`, `infrastructure`, `secrets`. |
| 290 | + |
| 291 | +DCG also demonstrates smart detection patterns worth studying: |
| 292 | +- Scans heredocs and inline scripts (`python -c`, `bash -c`) |
| 293 | +- Context-aware (won't block `grep "rm -rf"` in string literals) |
| 294 | +- Explicit safe-listing of temp directory operations (`rm -rf /tmp/*`) |
| 295 | + |
| 296 | +## Related Documentation |
| 297 | + |
| 298 | +- [Script-first skill architecture](./script-first-skill-architecture.md) -- documents the architectural pattern used by this skill; the classification bugs highlight edge cases in the script-first approach |
| 299 | +- [Compound refresh skill improvements](./compound-refresh-skill-improvements.md) -- related skill maintenance patterns |
| 300 | + |
| 301 | +## Testing Recommendations |
| 302 | + |
| 303 | +Future work should add a dedicated classification test suite covering: |
| 304 | + |
| 305 | +1. **Red boundary tests:** Every RED_PATTERNS entry with positive match AND safe variant |
| 306 | +2. **Green boundary tests:** Every GREEN_BASES/COMPOUND with destructive flag variants |
| 307 | +3. **Normalization safety tests:** Verify that `classify(normalize(cmd))` never returns a lower tier than `classify(cmd)` |
| 308 | +4. **DCG cross-reference tests:** Data-driven test with one entry per DCG pack rule, asserting never-green |
| 309 | +5. **Broadening audit:** For each green rule, generate variants with destructive flags and assert they are NOT green |
| 310 | +6. **Compound command tests:** Verify that `cd /dir && git branch -D feat` classifies as green (cd), not red |
| 311 | +7. **Contextual flag tests:** Verify `grep -v pattern` normalizes to `grep *` (not `grep -v *`), while `docker-compose down -v` preserves `-v` |
| 312 | +8. **Allowlist safety tests:** For every green pattern containing `*`, verify that the glob cannot match a known destructive variant (e.g., `Bash(sed -n *)` must not match `sed -i`) |
0 commit comments