ccperm 1.15.0 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.ko.md CHANGED
@@ -44,15 +44,15 @@ TTY 환경(기본)에서는 박스 프레임 TUI가 실행됩니다:
44
44
  **목록 뷰** — 프로젝트가 권한 수 기준으로 정렬됩니다. 컬럼: Bash, MCP, Tools, TOTAL, `!` (위험도), `†` (deprecated `:*`), `G` (글로벌과 중복).
45
45
 
46
46
  ```
47
- ┌ ccperm ──────────────────────────────── 1/8 ┐
48
- │ PROJECT Bash MCP Tools TOTAL G
49
- ├─────────────────────────────────────────────┤
50
- │ ~/.claude 15 · 2 17 ·│
51
- ├─────────────────────────────────────────────┤
52
- │ ▸ my-project local 5 · 3 8 3
53
- │ other-app shared 2 3 · 5 ·
54
- ! risk † deprecated G in global │
55
- └──────── [↑↓] navigate [Enter] detail [/] search [q] quit┘
47
+ ┌ ccperm ──────────────────────────────────────────── 1/8 ┐
48
+ │ PROJECT Bash MCP Tools TOTAL
49
+ ├─────────────────────────────────────────────────────────┤
50
+ │ ~/.claude 15 · 2 17
51
+ ├─────────────────────────────────────────────────────────┤
52
+ │ ▸ my-project local 5 · 3 8
53
+ │ other-app shared 2 3 · 5
54
+ ! risk † deprecated G in global │
55
+ └──── [↑↓] navigate [Enter] detail [/] search [q] quit
56
56
  ```
57
57
 
58
58
  **상세 뷰** — Enter로 프로젝트를 펼칩니다. 카테고리를 Enter로 접고 펼 수 있습니다.
@@ -86,6 +86,17 @@ ccperm은 Claude Code 설정을 세 단계로 구분합니다:
86
86
 
87
87
  권한은 합산 방식 — global + shared + local이 런타임에 병합됩니다.
88
88
 
89
+ ## Allow vs Deny
90
+
91
+ Claude Code 설정은 `permissions.allow`와 `permissions.deny`를 모두 지원합니다. ccperm은 이를 명확히 분리합니다:
92
+
93
+ - **Allow** 권한은 메인 목록에 표시되며, 삭제(`[d]`)나 글로벌 복사(`[g]`)가 가능합니다
94
+ - **Deny** 룰은 상세 뷰에서 접이식 **Deny** 섹션에 `DENY` 태그와 함께 표시됩니다
95
+ - Deny 룰은 **삭제/복사 불가** — 보안 가드레일입니다 (예: `Bash(rm -rf)`, `Write:.env*`)
96
+ - `--verbose` 출력에서 프로젝트별 Deny 섹션이 분리 표시됩니다
97
+ - `--hey-claude-witness-me`에서 deny 룰이 "Protected rules"로 나열됩니다
98
+ - Allow 카운트에 deny 룰은 포함되지 않습니다
99
+
89
100
  ## 위험도 분류
90
101
 
91
102
  각 권한에 [Destructive Command Guard (DCG)](https://github.com/Dicklesworthstone/destructive_command_guard)에서 영감을 받은 위험도가 부여됩니다. `--hey-claude-witness-me` 출력과 TUI 정보 모드에서 사용됩니다.
package/README.md CHANGED
@@ -44,15 +44,15 @@ When running in a TTY (the default), ccperm opens a box-frame TUI:
44
44
  **List view** — Projects sorted by permission count. Columns: Bash, MCP, Tools, TOTAL, `!` (risk), `†` (deprecated `:*`), `G` (redundant with global).
45
45
 
46
46
  ```
47
- ┌ ccperm ──────────────────────────────── 1/8 ┐
48
- │ PROJECT Bash MCP Tools TOTAL G
49
- ├─────────────────────────────────────────────┤
50
- │ ~/.claude 15 · 2 17 ·│
51
- ├─────────────────────────────────────────────┤
52
- │ ▸ my-project local 5 · 3 8 3
53
- │ other-app shared 2 3 · 5 ·
54
- ! risk † deprecated G in global │
55
- └──────── [↑↓] navigate [Enter] detail [/] search [q] quit┘
47
+ ┌ ccperm ──────────────────────────────────────────── 1/8 ┐
48
+ │ PROJECT Bash MCP Tools TOTAL
49
+ ├─────────────────────────────────────────────────────────┤
50
+ │ ~/.claude 15 · 2 17
51
+ ├─────────────────────────────────────────────────────────┤
52
+ │ ▸ my-project local 5 · 3 8
53
+ │ other-app shared 2 3 · 5
54
+ ! risk † deprecated G in global │
55
+ └──── [↑↓] navigate [Enter] detail [/] search [q] quit
56
56
  ```
57
57
 
58
58
  **Detail view** — Press Enter to expand a project. Categories are collapsible; press Enter to toggle.
@@ -86,6 +86,17 @@ ccperm distinguishes three levels of Claude Code settings:
86
86
 
87
87
  Permissions are additive — global + shared + local are merged at runtime.
88
88
 
89
+ ## Allow vs Deny
90
+
91
+ Claude Code settings support both `permissions.allow` and `permissions.deny`. ccperm separates them clearly:
92
+
93
+ - **Allow** permissions are shown in the main list and can be deleted (`[d]`) or copied to global (`[g]`)
94
+ - **Deny** rules appear in a collapsible **Deny** section in detail view, tagged with `DENY` and dimmed
95
+ - Deny rules **cannot be deleted or copied** — they are security guardrails (e.g. `Bash(rm -rf)`, `Write:.env*`)
96
+ - `--verbose` output shows a separate Deny section per project
97
+ - `--hey-claude-witness-me` lists deny rules under "Protected rules"
98
+ - Allow counts never include deny rules
99
+
89
100
  ## Risk Classification
90
101
 
91
102
  Each permission is assigned a risk level inspired by [Destructive Command Guard (DCG)](https://github.com/Dicklesworthstone/destructive_command_guard). Used in `--hey-claude-witness-me` output and the TUI info mode.
package/demo.gif CHANGED
Binary file
package/dist/advisor.js CHANGED
@@ -33,10 +33,13 @@ function analyze(results) {
33
33
  }
34
34
  const critical = findings.filter((f) => f.severity === 'critical');
35
35
  const high = findings.filter((f) => f.severity === 'high');
36
+ // Deny stats
37
+ const totalDeny = results.reduce((sum, r) => sum + r.denyCount, 0);
36
38
  // Header
37
39
  lines.push(`# ccperm: Permission Audit`);
38
40
  lines.push(``);
39
- lines.push(`Scanned ${results.length} settings files across ${dirs.size} projects. Found ${totalPerms} total permissions.`);
41
+ const denySuffix = totalDeny > 0 ? `, ${totalDeny} deny rules` : '';
42
+ lines.push(`Scanned ${results.length} settings files across ${dirs.size} projects. Found ${totalPerms} total permissions${denySuffix}.`);
40
43
  lines.push(``);
41
44
  // Severity summary
42
45
  const counts = { critical: critical.length, high: high.length, medium: findings.filter((f) => f.severity === 'medium').length, low: findings.filter((f) => f.severity === 'low').length };
@@ -161,6 +164,29 @@ function analyze(results) {
161
164
  }
162
165
  lines.push(``);
163
166
  }
167
+ // Protected rules (deny)
168
+ const denyFindings = [];
169
+ for (const r of results) {
170
+ const dir = (0, aggregator_js_1.projectDir)(r.display).replace(/.*\//, '');
171
+ for (const g of r.denyGroups) {
172
+ for (const item of g.items) {
173
+ denyFindings.push({ permission: item.name, project: dir });
174
+ }
175
+ }
176
+ }
177
+ if (denyFindings.length > 0) {
178
+ lines.push(`## Protected rules (deny)`);
179
+ lines.push(`These deny rules actively block dangerous operations and should not be removed.`);
180
+ const seen = new Set();
181
+ for (const f of denyFindings) {
182
+ const key = `${f.project}:${f.permission}`;
183
+ if (seen.has(key))
184
+ continue;
185
+ seen.add(key);
186
+ lines.push(`- \`${f.permission}\` in ${f.project}`);
187
+ }
188
+ lines.push(``);
189
+ }
164
190
  // How to act
165
191
  lines.push(`## How to act`);
166
192
  lines.push(`- Global settings: ~/.claude/settings.json`);
@@ -20,7 +20,7 @@ function toFileEntries(results) {
20
20
  }
21
21
  const fileType = r.isGlobal ? 'global' : r.display.includes('settings.local.json') ? 'local' : 'shared';
22
22
  const name = r.isGlobal ? '~/.claude' : shortPath(r.display);
23
- return { display: r.display, shortName: name, totalCount: r.totalCount, groups, isGlobal: r.isGlobal, fileType };
23
+ return { display: r.display, shortName: name, totalCount: r.totalCount, denyCount: r.denyCount, groups, isGlobal: r.isGlobal, fileType };
24
24
  });
25
25
  }
26
26
  function summarize(results) {
@@ -34,11 +34,13 @@ function summarize(results) {
34
34
  const projectsWithPerms = results.filter((r) => r.totalCount > 0).length;
35
35
  const projectsEmpty = results.filter((r) => r.totalCount === 0).length;
36
36
  const totalPerms = [...categoryTotals.values()].reduce((a, b) => a + b, 0);
37
+ const totalDeny = results.reduce((sum, r) => sum + r.denyCount, 0);
37
38
  return {
38
39
  totalProjects: dirs.size,
39
40
  projectsWithPerms,
40
41
  projectsEmpty,
41
42
  totalPerms,
43
+ totalDeny,
42
44
  categoryTotals,
43
45
  };
44
46
  }
package/dist/cli.js CHANGED
@@ -93,8 +93,11 @@ async function main() {
93
93
  console.log(` ${colors_js_1.GREEN}✔ No settings files found.${colors_js_1.NC}\n`);
94
94
  return;
95
95
  }
96
- console.log(` ${colors_js_1.GREEN}✔${colors_js_1.NC} Found ${colors_js_1.CYAN}${files.length}${colors_js_1.NC} settings files\n`);
97
- const results = files.map(scanner_js_1.scanFile).filter((r) => r !== null);
96
+ const results0 = files.map(scanner_js_1.scanFile).filter((r) => r !== null);
97
+ const totalPerms = results0.reduce((sum, r) => sum + r.totalCount, 0);
98
+ const projects = new Set(results0.map(r => r.display.replace(/\/\.claude\/.*$/, ''))).size;
99
+ console.log(` ${colors_js_1.GREEN}✔${colors_js_1.NC} Found ${colors_js_1.CYAN}${files.length}${colors_js_1.NC} settings files · ${colors_js_1.CYAN}${projects}${colors_js_1.NC} projects · ${colors_js_1.CYAN}${totalPerms}${colors_js_1.NC} permissions\n`);
100
+ const results = results0;
98
101
  if (args.includes('--hey-claude-witness-me')) {
99
102
  console.log((0, advisor_js_1.analyze)(results));
100
103
  return;
@@ -61,7 +61,11 @@ function boxBottom(hint, width) {
61
61
  return `${colors_js_1.DIM}└${'─'.repeat(fill)}${hintPart}┘${colors_js_1.NC}`;
62
62
  }
63
63
  function boxBottom2(line1, line2, width) {
64
- return boxLine(line1, width) + '\n' + boxBottom(line2, width);
64
+ const inner = width - 2;
65
+ const hintPart = ` ${line1} `;
66
+ const fill = Math.max(0, inner - visLen(hintPart));
67
+ const top = `${colors_js_1.DIM}│${colors_js_1.NC}${' '.repeat(fill)}${hintPart}${colors_js_1.DIM}│${colors_js_1.NC}`;
68
+ return top + '\n' + boxBottom(line2, width);
65
69
  }
66
70
  function boxSep(width) {
67
71
  return `${colors_js_1.DIM}├${'─'.repeat(width - 2)}┤${colors_js_1.NC}`;
@@ -74,6 +78,7 @@ function refreshProject(results, withPerms, idx, filePath) {
74
78
  results[ri] = updated;
75
79
  const entry = withPerms[idx];
76
80
  entry.totalCount = updated.totalCount;
81
+ entry.denyCount = updated.denyCount;
77
82
  entry.groups = new Map();
78
83
  for (const g of updated.groups)
79
84
  entry.groups.set(g.category, g.items.length);
@@ -445,15 +450,13 @@ function renderList(state, withPerms, emptyCount, riskMap, depMap, dupMap) {
445
450
  legendParts.push(`${colors_js_1.DIM}†${colors_js_1.NC} deprecated`);
446
451
  if (hasDup)
447
452
  legendParts.push(`${colors_js_1.YELLOW}G${colors_js_1.NC} in global`);
453
+ const hint = '[↑↓] navigate [Enter] detail [/] search [q] quit';
448
454
  if (legendParts.length > 0) {
449
455
  const legendStr = legendParts.join(' ');
450
- const legendVisLen = visLen(legendStr);
451
- const padLeft = Math.max(0, w - 4 - legendVisLen);
452
- lines.push(boxLine(`${' '.repeat(padLeft)}${legendStr}`, w));
453
- lines.push(boxBottom('[↑↓] navigate [Enter] detail [/] search [q] quit', w));
456
+ lines.push(boxBottom2(legendStr, hint, w));
454
457
  }
455
458
  else {
456
- lines.push(boxBottom('[↑↓] navigate [Enter] detail [/] search [q] quit', w));
459
+ lines.push(boxBottom(hint, w));
457
460
  }
458
461
  process.stdout.write(lines.join('\n') + '\n');
459
462
  }
@@ -574,6 +577,23 @@ function renderDetail(state, withPerms, results, dupMap) {
574
577
  }
575
578
  }
576
579
  }
580
+ // Deny section
581
+ if (fileResult.denyCount > 0) {
582
+ const denyKey = `${fileResult.path}:__deny__`;
583
+ const denyOpen = state.expanded.has(denyKey);
584
+ const denyArrow = denyOpen ? '▾' : '▸';
585
+ allNavRows.push({ text: `${colors_js_1.DIM}${denyArrow} Deny${colors_js_1.NC} ${colors_js_1.DIM}(${fileResult.denyCount})${colors_js_1.NC}`, key: denyKey, isDeny: true });
586
+ if (denyOpen) {
587
+ for (const group of fileResult.denyGroups) {
588
+ for (const item of group.items) {
589
+ const clean = cleanLabel(item.name);
590
+ const maxLen = w - 16;
591
+ const name = clean.length > maxLen ? clean.slice(0, maxLen - 1) + '…' : clean;
592
+ allNavRows.push({ text: ` ${colors_js_1.DIM}DENY ${name}${colors_js_1.NC}`, isDeny: true });
593
+ }
594
+ }
595
+ }
596
+ }
577
597
  const navRows = allNavRows;
578
598
  // handle toggle
579
599
  if (state._toggle) {
@@ -592,7 +612,10 @@ function renderDetail(state, withPerms, results, dupMap) {
592
612
  if (state._delete) {
593
613
  delete state._delete;
594
614
  const row = navRows[state.detailCursor];
595
- if (row?.rawPerm) {
615
+ if (row?.isDeny) {
616
+ state.flash = `${colors_js_1.DIM}· Deny rules cannot be deleted${colors_js_1.NC}`;
617
+ }
618
+ else if (row?.rawPerm) {
596
619
  state.confirmDelete = { perm: row.perm, rawPerm: row.rawPerm, filePath: fileResult.path };
597
620
  }
598
621
  }
@@ -600,7 +623,10 @@ function renderDetail(state, withPerms, results, dupMap) {
600
623
  if (state._global) {
601
624
  delete state._global;
602
625
  const row = navRows[state.detailCursor];
603
- if (row?.rawPerm && !project.isGlobal) {
626
+ if (row?.isDeny) {
627
+ state.flash = `${colors_js_1.DIM}· Deny rules cannot be copied${colors_js_1.NC}`;
628
+ }
629
+ else if (row?.rawPerm && !project.isGlobal) {
604
630
  state.confirmGlobal = { perm: row.perm, rawPerm: row.rawPerm };
605
631
  }
606
632
  }
package/dist/renderer.js CHANGED
@@ -59,6 +59,14 @@ function printVerbose(results, summary) {
59
59
  console.log(` ${colors_js_1.DIM}${item.name}${colors_js_1.NC}`);
60
60
  }
61
61
  }
62
+ if (result.denyCount > 0) {
63
+ console.log(` ${colors_js_1.RED}Deny${colors_js_1.NC} ${colors_js_1.DIM}(${result.denyCount})${colors_js_1.NC}`);
64
+ for (const group of result.denyGroups) {
65
+ for (const item of group.items) {
66
+ console.log(` ${colors_js_1.DIM}DENY ${item.name}${colors_js_1.NC}`);
67
+ }
68
+ }
69
+ }
62
70
  console.log('');
63
71
  }
64
72
  if (projectsEmpty.length > 0) {
@@ -68,5 +76,6 @@ function printVerbose(results, summary) {
68
76
  function printFooter(summary) {
69
77
  console.log(`\n ${colors_js_1.DIM}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${colors_js_1.NC}`);
70
78
  const catSummary = [...summary.categoryTotals.entries()].map(([k, v]) => `${k}: ${colors_js_1.BOLD}${v}${colors_js_1.NC}${colors_js_1.DIM}`).join(' ');
71
- console.log(` ${colors_js_1.BOLD}${summary.totalProjects}${colors_js_1.NC} projects ${colors_js_1.BOLD}${summary.totalPerms}${colors_js_1.NC} permissions ${colors_js_1.DIM}(${catSummary})${colors_js_1.NC}\n`);
79
+ const denySuffix = summary.totalDeny > 0 ? ` ${colors_js_1.BOLD}${summary.totalDeny}${colors_js_1.NC} deny rules` : '';
80
+ console.log(` ${colors_js_1.BOLD}${summary.totalProjects}${colors_js_1.NC} projects ${colors_js_1.BOLD}${summary.totalPerms}${colors_js_1.NC} permissions${denySuffix} ${colors_js_1.DIM}(${catSummary})${colors_js_1.NC}\n`);
72
81
  }
package/dist/scanner.js CHANGED
@@ -12,7 +12,6 @@ exports.scanFile = scanFile;
12
12
  const node_fs_1 = __importDefault(require("node:fs"));
13
13
  const node_path_1 = __importDefault(require("node:path"));
14
14
  const node_os_1 = __importDefault(require("node:os"));
15
- const PERM_RE = /"(Bash|Write|Edit|Read|Glob|Grep|WebSearch|WebFetch|mcp_)[^"]*"/g;
16
15
  const DEPRECATED_RE = /:\*\)|:\*"/g;
17
16
  const AUDIT_DIR = node_path_1.default.join(node_os_1.default.homedir(), '.ccperm', 'audit');
18
17
  function writeAudit(action, filePath, perm, before, after) {
@@ -202,10 +201,20 @@ function scanFile(filePath) {
202
201
  catch {
203
202
  return null;
204
203
  }
205
- const perms = [...new Set((content.match(PERM_RE) || []).map((s) => s.slice(1, -1)))].sort();
204
+ let json;
205
+ try {
206
+ json = JSON.parse(content);
207
+ }
208
+ catch {
209
+ return null;
210
+ }
211
+ const allowArr = Array.isArray(json?.permissions?.allow) ? json.permissions.allow : [];
212
+ const denyArr = Array.isArray(json?.permissions?.deny) ? json.permissions.deny : [];
213
+ const perms = [...new Set(allowArr)].sort();
214
+ const denyPerms = [...new Set(denyArr)].sort();
206
215
  const groups = groupPermissions(perms);
207
- const totalCount = perms.length;
208
- return { path: filePath, display, permissions: perms, groups, totalCount, isGlobal };
216
+ const denyGroups = groupPermissions(denyPerms);
217
+ return { path: filePath, display, permissions: perms, groups, totalCount: perms.length, denyPermissions: denyPerms, denyGroups, denyCount: denyPerms.length, isGlobal };
209
218
  }
210
219
  function categorize(perm) {
211
220
  if (perm.startsWith('Bash')) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccperm",
3
- "version": "1.15.0",
3
+ "version": "1.16.0",
4
4
  "description": "Audit Claude Code permissions across all your projects",
5
5
  "bin": {
6
6
  "ccperm": "bin/ccperm.js"