Skip to content

Commit eaaba19

Browse files
authored
feat: add claude-permissions-optimizer skill (#298)
1 parent 754c2a8 commit eaaba19

6 files changed

Lines changed: 1230 additions & 3 deletions

File tree

Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
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

Comments
 (0)