@fro.bot/systematic 1.23.2 → 2.0.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.md +75 -61
- package/agents/research/best-practices-researcher.md +2 -3
- package/agents/research/issue-intelligence-analyst.md +2 -3
- package/package.json +2 -3
- package/skills/ce-brainstorm/SKILL.md +10 -11
- package/skills/ce-compound/SKILL.md +11 -11
- package/skills/ce-compound-refresh/SKILL.md +2 -2
- package/skills/ce-ideate/SKILL.md +3 -4
- package/skills/ce-plan/SKILL.md +8 -8
- package/skills/ce-plan-beta/SKILL.md +9 -10
- package/skills/ce-review/SKILL.md +7 -7
- package/skills/ce-work/SKILL.md +4 -4
- package/skills/ce-work-beta/SKILL.md +556 -0
- package/skills/claude-permissions-optimizer/SKILL.md +161 -0
- package/skills/claude-permissions-optimizer/scripts/extract-commands.mjs +805 -0
- package/skills/deepen-plan/SKILL.md +15 -15
- package/skills/deepen-plan-beta/SKILL.md +3 -3
- package/skills/deploy-docs/SKILL.md +8 -8
- package/skills/file-todos/SKILL.md +2 -1
- package/skills/generate_command/SKILL.md +1 -1
- package/skills/{report-bug → report-bug-ce}/SKILL.md +38 -33
- package/skills/resolve-todo-parallel/SKILL.md +65 -0
- package/skills/setup/SKILL.md +3 -3
- package/skills/test-browser/SKILL.md +3 -4
- package/commands/.gitkeep +0 -0
- package/skills/create-agent-skill/SKILL.md +0 -10
- package/skills/create-agent-skills/SKILL.md +0 -265
- package/skills/create-agent-skills/references/api-security.md +0 -226
- package/skills/create-agent-skills/references/be-clear-and-direct.md +0 -531
- package/skills/create-agent-skills/references/best-practices.md +0 -404
- package/skills/create-agent-skills/references/common-patterns.md +0 -595
- package/skills/create-agent-skills/references/core-principles.md +0 -437
- package/skills/create-agent-skills/references/executable-code.md +0 -175
- package/skills/create-agent-skills/references/iteration-and-testing.md +0 -474
- package/skills/create-agent-skills/references/official-spec.md +0 -134
- package/skills/create-agent-skills/references/recommended-structure.md +0 -168
- package/skills/create-agent-skills/references/skill-structure.md +0 -152
- package/skills/create-agent-skills/references/using-scripts.md +0 -113
- package/skills/create-agent-skills/references/using-templates.md +0 -112
- package/skills/create-agent-skills/references/workflows-and-validation.md +0 -510
- package/skills/create-agent-skills/templates/router-skill.md +0 -73
- package/skills/create-agent-skills/templates/simple-skill.md +0 -33
- package/skills/create-agent-skills/workflows/add-reference.md +0 -96
- package/skills/create-agent-skills/workflows/add-script.md +0 -93
- package/skills/create-agent-skills/workflows/add-template.md +0 -74
- package/skills/create-agent-skills/workflows/add-workflow.md +0 -126
- package/skills/create-agent-skills/workflows/audit-skill.md +0 -138
- package/skills/create-agent-skills/workflows/create-domain-expertise-skill.md +0 -605
- package/skills/create-agent-skills/workflows/create-new-skill.md +0 -197
- package/skills/create-agent-skills/workflows/get-guidance.md +0 -121
- package/skills/create-agent-skills/workflows/upgrade-to-router.md +0 -161
- package/skills/create-agent-skills/workflows/verify-skill.md +0 -204
- package/skills/heal-skill/SKILL.md +0 -148
- package/skills/resolve_parallel/SKILL.md +0 -36
- package/skills/resolve_todo_parallel/SKILL.md +0 -38
|
@@ -0,0 +1,805 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// Extracts, normalizes, and pre-classifies Bash commands from OpenCode sessions.
|
|
4
|
+
// Filters against the current allowlist, groups by normalized pattern, and classifies
|
|
5
|
+
// each pattern as green/yellow/red so the model can review rather than classify from scratch.
|
|
6
|
+
//
|
|
7
|
+
// Usage: node extract-commands.mjs [--days <N>] [--project-slug <slug>] [--min-count 5]
|
|
8
|
+
// [--settings <path>] [--settings <path>] ...
|
|
9
|
+
//
|
|
10
|
+
// Analyzes the most recent sessions, bounded by both count and time.
|
|
11
|
+
// Defaults: last 200 sessions or 30 days, whichever is more restrictive.
|
|
12
|
+
//
|
|
13
|
+
// Output: JSON with { green, yellowFootnote, stats }
|
|
14
|
+
|
|
15
|
+
import { readdir, readFile, stat } from 'node:fs/promises'
|
|
16
|
+
import { homedir } from 'node:os'
|
|
17
|
+
import { join } from 'node:path'
|
|
18
|
+
|
|
19
|
+
const args = process.argv.slice(2)
|
|
20
|
+
|
|
21
|
+
function flag(name, fallback) {
|
|
22
|
+
const i = args.indexOf(`--${name}`)
|
|
23
|
+
return i !== -1 && args[i + 1] ? args[i + 1] : fallback
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function flagAll(name) {
|
|
27
|
+
const results = []
|
|
28
|
+
let i = 0
|
|
29
|
+
while (i < args.length) {
|
|
30
|
+
if (args[i] === `--${name}` && args[i + 1]) {
|
|
31
|
+
results.push(args[i + 1])
|
|
32
|
+
i += 2
|
|
33
|
+
} else {
|
|
34
|
+
i++
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return results
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const days = parseInt(flag('days', '30'), 10)
|
|
41
|
+
const maxSessions = parseInt(flag('max-sessions', '500'), 10)
|
|
42
|
+
const minCount = parseInt(flag('min-count', '5'), 10)
|
|
43
|
+
const projectSlugFilter = flag('project-slug', null)
|
|
44
|
+
const settingsPaths = flagAll('settings')
|
|
45
|
+
const opencodeDir =
|
|
46
|
+
process.env.OPENCODE_CONFIG_DIR || join(homedir(), '.config', 'opencode')
|
|
47
|
+
const projectsDir = join(opencodeDir, 'projects')
|
|
48
|
+
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000
|
|
49
|
+
|
|
50
|
+
// ── Allowlist loading ──────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
const allowPatterns = []
|
|
53
|
+
|
|
54
|
+
async function loadAllowlist(filePath) {
|
|
55
|
+
try {
|
|
56
|
+
const content = await readFile(filePath, 'utf-8')
|
|
57
|
+
const settings = JSON.parse(content)
|
|
58
|
+
const allow = settings?.permissions?.allow || []
|
|
59
|
+
for (const rule of allow) {
|
|
60
|
+
const match = rule.match(/^Bash\((.+)\)$/)
|
|
61
|
+
if (match) {
|
|
62
|
+
allowPatterns.push(match[1])
|
|
63
|
+
} else if (rule === 'Bash' || rule === 'Bash(*)') {
|
|
64
|
+
allowPatterns.push('*')
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
} catch {
|
|
68
|
+
// file doesn't exist or isn't valid JSON
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (settingsPaths.length === 0) {
|
|
73
|
+
settingsPaths.push(join(opencodeDir, 'settings.json'))
|
|
74
|
+
settingsPaths.push(join(process.cwd(), '.opencode', 'settings.json'))
|
|
75
|
+
settingsPaths.push(join(process.cwd(), '.opencode', 'settings.local.json'))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
for (const p of settingsPaths) {
|
|
79
|
+
await loadAllowlist(p)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isAllowed(command) {
|
|
83
|
+
for (const pattern of allowPatterns) {
|
|
84
|
+
if (pattern === '*') return true
|
|
85
|
+
if (matchGlob(pattern, command)) return true
|
|
86
|
+
}
|
|
87
|
+
return false
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function matchGlob(pattern, command) {
|
|
91
|
+
const normalized = pattern.replace(/:(\*)$/, ' $1')
|
|
92
|
+
let regexStr
|
|
93
|
+
if (normalized.endsWith(' *')) {
|
|
94
|
+
const base = normalized.slice(0, -2)
|
|
95
|
+
const escaped = base.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
96
|
+
regexStr = `^${escaped}($| .*)`
|
|
97
|
+
} else {
|
|
98
|
+
regexStr =
|
|
99
|
+
'^' +
|
|
100
|
+
normalized.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') +
|
|
101
|
+
'$'
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
return new RegExp(regexStr).test(command)
|
|
105
|
+
} catch {
|
|
106
|
+
return false
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── Classification rules ───────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
// RED: patterns that should never be allowlisted with wildcards.
|
|
113
|
+
// Checked first -- highest priority.
|
|
114
|
+
const RED_PATTERNS = [
|
|
115
|
+
// Destructive file ops -- all rm variants
|
|
116
|
+
{ test: /^rm\s/, reason: 'Irreversible file deletion' },
|
|
117
|
+
{ test: /^sudo\s/, reason: 'Privilege escalation' },
|
|
118
|
+
{ test: /^su\s/, reason: 'Privilege escalation' },
|
|
119
|
+
// find with destructive actions (must be before GREEN_BASES check)
|
|
120
|
+
{
|
|
121
|
+
test: /\bfind\b.*\s-delete\b/,
|
|
122
|
+
reason: 'find -delete permanently removes files',
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
test: /\bfind\b.*\s-exec\s+rm\b/,
|
|
126
|
+
reason: 'find -exec rm permanently removes files',
|
|
127
|
+
},
|
|
128
|
+
// ast-grep rewrite modifies files in place
|
|
129
|
+
{
|
|
130
|
+
test: /\b(ast-grep|sg)\b.*--rewrite\b/,
|
|
131
|
+
reason: 'ast-grep --rewrite modifies files in place',
|
|
132
|
+
},
|
|
133
|
+
// sed -i edits files in place
|
|
134
|
+
{ test: /\bsed\s+.*-i\b/, reason: 'sed -i modifies files in place' },
|
|
135
|
+
// Git irreversible
|
|
136
|
+
{
|
|
137
|
+
test: /git\s+(?:\S+\s+)*push\s+.*--force(?!-with-lease)/,
|
|
138
|
+
reason: 'Force push overwrites remote history',
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
test: /git\s+(?:\S+\s+)*push\s+.*\s-f\b/,
|
|
142
|
+
reason: 'Force push overwrites remote history',
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
test: /git\s+(?:\S+\s+)*push\s+-f\b/,
|
|
146
|
+
reason: 'Force push overwrites remote history',
|
|
147
|
+
},
|
|
148
|
+
{ test: /git\s+reset\s+--(hard|merge)/, reason: 'Destroys uncommitted work' },
|
|
149
|
+
{
|
|
150
|
+
test: /git\s+clean\s+.*(-[a-z]*f[a-z]*\b|--force\b)/,
|
|
151
|
+
reason: 'Permanently deletes untracked files',
|
|
152
|
+
},
|
|
153
|
+
{ test: /git\s+commit\s+.*--no-verify/, reason: 'Skips safety hooks' },
|
|
154
|
+
{ test: /git\s+config\s+--system/, reason: 'System-wide config change' },
|
|
155
|
+
{ test: /git\s+filter-branch/, reason: 'Rewrites entire repo history' },
|
|
156
|
+
{ test: /git\s+filter-repo/, reason: 'Rewrites repo history' },
|
|
157
|
+
{
|
|
158
|
+
test: /git\s+gc\s+.*--aggressive/,
|
|
159
|
+
reason: 'Can remove recoverable objects',
|
|
160
|
+
},
|
|
161
|
+
{ test: /git\s+reflog\s+expire/, reason: 'Removes recovery safety net' },
|
|
162
|
+
{
|
|
163
|
+
test: /git\s+stash\s+clear\b/,
|
|
164
|
+
reason: 'Removes ALL stash entries permanently',
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
test: /git\s+branch\s+.*(-D\b|--force\b)/,
|
|
168
|
+
reason: 'Force-deletes without merge check',
|
|
169
|
+
},
|
|
170
|
+
{ test: /git\s+checkout\s+.*\s--\s/, reason: 'Discards uncommitted changes' },
|
|
171
|
+
{ test: /git\s+checkout\s+--\s/, reason: 'Discards uncommitted changes' },
|
|
172
|
+
{
|
|
173
|
+
test: /git\s+restore\s+(?!.*(-S\b|--staged\b))/,
|
|
174
|
+
reason: 'Discards working tree changes',
|
|
175
|
+
},
|
|
176
|
+
// Publishing -- permanent across all ecosystems
|
|
177
|
+
{
|
|
178
|
+
test: /\b(npm|yarn|pnpm)\s+publish\b/,
|
|
179
|
+
reason: 'Permanent package publishing',
|
|
180
|
+
},
|
|
181
|
+
{ test: /\bnpm\s+unpublish\b/, reason: 'Permanent package removal' },
|
|
182
|
+
{ test: /\bcargo\s+publish\b/, reason: 'Permanent crate publishing' },
|
|
183
|
+
{ test: /\bcargo\s+yank\b/, reason: 'Unavails crate version' },
|
|
184
|
+
{ test: /\bgem\s+push\b/, reason: 'Permanent gem publishing' },
|
|
185
|
+
{ test: /\bpoetry\s+publish\b/, reason: 'Permanent package publishing' },
|
|
186
|
+
{ test: /\btwine\s+upload\b/, reason: 'Permanent package publishing' },
|
|
187
|
+
{ test: /\bgh\s+release\s+create\b/, reason: 'Permanent release creation' },
|
|
188
|
+
// Shell injection
|
|
189
|
+
{ test: /\|\s*(sh|bash|zsh)\b/, reason: 'Pipe to shell execution' },
|
|
190
|
+
{ test: /\beval\s/, reason: 'Arbitrary code execution' },
|
|
191
|
+
// Docker destructive
|
|
192
|
+
{ test: /docker\s+run\s+.*--privileged/, reason: 'Full host access' },
|
|
193
|
+
{
|
|
194
|
+
test: /docker\s+system\s+prune\b(?!.*--dry-run)/,
|
|
195
|
+
reason: 'Removes all unused data',
|
|
196
|
+
},
|
|
197
|
+
{ test: /docker\s+volume\s+(rm|prune)\b/, reason: 'Permanent data deletion' },
|
|
198
|
+
{
|
|
199
|
+
test: /docker[- ]compose\s+down\s+.*(-v\b|--volumes\b)/,
|
|
200
|
+
reason: 'Removes volumes and data',
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
test: /docker[- ]compose\s+down\s+.*--rmi\b/,
|
|
204
|
+
reason: 'Removes all images',
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
test: /docker\s+(rm|rmi)\s+.*-[a-z]*f/,
|
|
208
|
+
reason: 'Force removes without confirmation',
|
|
209
|
+
},
|
|
210
|
+
// System
|
|
211
|
+
{ test: /^reboot\b/, reason: 'System restart' },
|
|
212
|
+
{ test: /^shutdown\b/, reason: 'System halt' },
|
|
213
|
+
{ test: /^halt\b/, reason: 'System halt' },
|
|
214
|
+
{
|
|
215
|
+
test: /\bsystemctl\s+(stop|disable|mask)\b/,
|
|
216
|
+
reason: 'Stops system services',
|
|
217
|
+
},
|
|
218
|
+
{ test: /\bkill\s+-9\b/, reason: 'Force kill without cleanup' },
|
|
219
|
+
{ test: /\bpkill\s+-9\b/, reason: 'Force kill by name' },
|
|
220
|
+
// Disk destructive
|
|
221
|
+
{ test: /\bdd\s+.*\bof=/, reason: 'Raw disk write' },
|
|
222
|
+
{ test: /\bmkfs\b/, reason: 'Formats disk partition' },
|
|
223
|
+
// Permissions
|
|
224
|
+
{ test: /\bchmod\s+777\b/, reason: 'World-writable permissions' },
|
|
225
|
+
{ test: /\bchmod\s+-R\b/, reason: 'Recursive permission change' },
|
|
226
|
+
{ test: /\bchown\s+-R\b/, reason: 'Recursive ownership change' },
|
|
227
|
+
// Database destructive
|
|
228
|
+
{
|
|
229
|
+
test: /\bDROP\s+(DATABASE|TABLE|SCHEMA)\b/i,
|
|
230
|
+
reason: 'Permanent data deletion',
|
|
231
|
+
},
|
|
232
|
+
{ test: /\bTRUNCATE\b/i, reason: 'Permanent row deletion' },
|
|
233
|
+
// Network
|
|
234
|
+
{ test: /^(nc|ncat)\s/, reason: 'Raw socket access' },
|
|
235
|
+
// Credential exposure
|
|
236
|
+
{ test: /\bcat\s+\.env.*\|/, reason: 'Credential exposure via pipe' },
|
|
237
|
+
{ test: /\bprintenv\b.*\|/, reason: 'Credential exposure via pipe' },
|
|
238
|
+
// Package removal (from DCG)
|
|
239
|
+
{ test: /\bpip3?\s+uninstall\b/, reason: 'Package removal' },
|
|
240
|
+
{
|
|
241
|
+
test: /\bapt(?:-get)?\s+(remove|purge|autoremove)\b/,
|
|
242
|
+
reason: 'Package removal',
|
|
243
|
+
},
|
|
244
|
+
{ test: /\bbrew\s+uninstall\b/, reason: 'Package removal' },
|
|
245
|
+
]
|
|
246
|
+
|
|
247
|
+
// GREEN: base commands that are always read-only / safe.
|
|
248
|
+
// NOTE: `find` is intentionally excluded -- `find -delete` and `find -exec rm`
|
|
249
|
+
// are destructive. Safe find usage is handled via GREEN_COMPOUND instead.
|
|
250
|
+
const GREEN_BASES = new Set([
|
|
251
|
+
'ls',
|
|
252
|
+
'cat',
|
|
253
|
+
'head',
|
|
254
|
+
'tail',
|
|
255
|
+
'wc',
|
|
256
|
+
'file',
|
|
257
|
+
'tree',
|
|
258
|
+
'stat',
|
|
259
|
+
'du',
|
|
260
|
+
'diff',
|
|
261
|
+
'grep',
|
|
262
|
+
'rg',
|
|
263
|
+
'ag',
|
|
264
|
+
'ack',
|
|
265
|
+
'which',
|
|
266
|
+
'whoami',
|
|
267
|
+
'pwd',
|
|
268
|
+
'echo',
|
|
269
|
+
'printf',
|
|
270
|
+
'env',
|
|
271
|
+
'printenv',
|
|
272
|
+
'uname',
|
|
273
|
+
'hostname',
|
|
274
|
+
'jq',
|
|
275
|
+
'sort',
|
|
276
|
+
'uniq',
|
|
277
|
+
'tr',
|
|
278
|
+
'cut',
|
|
279
|
+
'less',
|
|
280
|
+
'more',
|
|
281
|
+
'man',
|
|
282
|
+
'type',
|
|
283
|
+
'realpath',
|
|
284
|
+
'dirname',
|
|
285
|
+
'basename',
|
|
286
|
+
'date',
|
|
287
|
+
'ps',
|
|
288
|
+
'top',
|
|
289
|
+
'htop',
|
|
290
|
+
'free',
|
|
291
|
+
'uptime',
|
|
292
|
+
'id',
|
|
293
|
+
'groups',
|
|
294
|
+
'lsof',
|
|
295
|
+
'open',
|
|
296
|
+
'xdg-open',
|
|
297
|
+
])
|
|
298
|
+
|
|
299
|
+
// GREEN: compound patterns
|
|
300
|
+
const GREEN_COMPOUND = [
|
|
301
|
+
/--version\s*$/,
|
|
302
|
+
/--help(\s|$)/,
|
|
303
|
+
/^git\s+(status|log|diff|show|blame|shortlog|branch\s+-[alv]|remote\s+-v|rev-parse|describe|reflog\b(?!\s+expire))\b/,
|
|
304
|
+
/^git\s+tag\s+(-l\b|--list\b)/, // tag listing (not creation)
|
|
305
|
+
/^git\s+stash\s+(list|show)\b/, // stash read-only operations
|
|
306
|
+
/^(npm|bun|pnpm|yarn)\s+run\s+(test|lint|build|check|typecheck)\b/,
|
|
307
|
+
/^(npm|bun|pnpm|yarn)\s+(test|lint|audit|outdated|list)\b/,
|
|
308
|
+
/^(npx|bunx)\s+(vitest|jest|eslint|prettier|tsc)\b/,
|
|
309
|
+
/^(pytest|jest|cargo\s+test|go\s+test|rspec|bundle\s+exec\s+rspec|make\s+test|rake\s+rspec)\b/,
|
|
310
|
+
/^(eslint|prettier|rubocop|black|flake8|cargo\s+(clippy|fmt)|gofmt|golangci-lint|tsc(\s+--noEmit)?|mypy|pyright)\b/,
|
|
311
|
+
/^(cargo\s+(build|check|doc|bench)|go\s+(build|vet))\b/,
|
|
312
|
+
/^pnpm\s+--filter\s/,
|
|
313
|
+
/^(npm|bun|pnpm|yarn)\s+(typecheck|format|verify|validate|check|analyze)\b/, // common safe script names
|
|
314
|
+
/^git\s+-C\s+\S+\s+(status|log|diff|show|branch|remote|rev-parse|describe)\b/, // git -C <dir> <read-only>
|
|
315
|
+
/^docker\s+(ps|images|logs|inspect|stats|system\s+df)\b/,
|
|
316
|
+
/^docker[- ]compose\s+(ps|logs|config)\b/,
|
|
317
|
+
/^systemctl\s+(status|list-|show|is-|cat)\b/,
|
|
318
|
+
/^journalctl\b/,
|
|
319
|
+
/^(pg_dump|mysqldump)\b(?!.*--clean)/,
|
|
320
|
+
/\b--dry-run\b/,
|
|
321
|
+
/^git\s+clean\s+.*(-[a-z]*n|--dry-run)\b/, // git clean dry run
|
|
322
|
+
// NOTE: find is intentionally NOT green. Bash(find *) would also match
|
|
323
|
+
// find -delete and find -exec rm in OpenCode's allowlist glob matching.
|
|
324
|
+
// Commands with mode-switching flags: only green when the normalized pattern
|
|
325
|
+
// is narrow enough that the allowlist glob can't match the destructive form.
|
|
326
|
+
// Bash(sed -n *) is safe; Bash(sed *) would also match sed -i.
|
|
327
|
+
/^sed\s+-(?!i\b)[a-zA-Z]\s/, // sed with a non-destructive flag (matches normalized sed -n *, sed -e *, etc.)
|
|
328
|
+
/^(ast-grep|sg)\b(?!.*--rewrite)/, // ast-grep without --rewrite
|
|
329
|
+
/^find\s+-(?:name|type|path|iname)\s/, // find with safe predicate flag (matches normalized form)
|
|
330
|
+
// gh CLI read-only operations
|
|
331
|
+
/^gh\s+(pr|issue|run)\s+(view|list|status|diff|checks)\b/,
|
|
332
|
+
/^gh\s+repo\s+(view|list|clone)\b/,
|
|
333
|
+
/^gh\s+api\b/,
|
|
334
|
+
]
|
|
335
|
+
|
|
336
|
+
// YELLOW: base commands that modify local state but are recoverable
|
|
337
|
+
const YELLOW_BASES = new Set([
|
|
338
|
+
'mkdir',
|
|
339
|
+
'touch',
|
|
340
|
+
'cp',
|
|
341
|
+
'mv',
|
|
342
|
+
'tee',
|
|
343
|
+
'curl',
|
|
344
|
+
'wget',
|
|
345
|
+
'ssh',
|
|
346
|
+
'scp',
|
|
347
|
+
'rsync',
|
|
348
|
+
'python',
|
|
349
|
+
'python3',
|
|
350
|
+
'node',
|
|
351
|
+
'ruby',
|
|
352
|
+
'perl',
|
|
353
|
+
'make',
|
|
354
|
+
'just',
|
|
355
|
+
'awk', // awk can write files; safe forms handled case-by-case if needed
|
|
356
|
+
])
|
|
357
|
+
|
|
358
|
+
// YELLOW: compound patterns
|
|
359
|
+
const YELLOW_COMPOUND = [
|
|
360
|
+
/^git\s+(add|commit(?!\s+.*--no-verify)|checkout(?!\s+--\s)|switch|pull|push(?!\s+.*--force)(?!\s+.*-f\b)|fetch|merge|rebase|stash(?!\s+clear\b)|branch\b(?!\s+.*(-D\b|--force\b))|cherry-pick|tag|clone)\b/,
|
|
361
|
+
/^git\s+push\s+--force-with-lease\b/,
|
|
362
|
+
/^git\s+restore\s+.*(-S\b|--staged\b)/, // restore --staged is safe (just unstages)
|
|
363
|
+
/^git\s+gc\b(?!\s+.*--aggressive)/,
|
|
364
|
+
/^(npm|bun|pnpm|yarn)\s+install\b/,
|
|
365
|
+
/^(npm|bun|pnpm|yarn)\s+(add|remove|uninstall|update)\b/,
|
|
366
|
+
/^(npm|bun|pnpm)\s+run\s+(start|dev|serve)\b/,
|
|
367
|
+
/^(pip|pip3)\s+install\b(?!\s+https?:)/,
|
|
368
|
+
/^bundle\s+install\b/,
|
|
369
|
+
/^(cargo\s+add|go\s+get)\b/,
|
|
370
|
+
/^docker\s+(build|run(?!\s+.*--privileged)|stop|start)\b/,
|
|
371
|
+
/^docker[- ]compose\s+(up|down\b(?!\s+.*(-v\b|--volumes\b|--rmi\b)))/,
|
|
372
|
+
/^systemctl\s+restart\b/,
|
|
373
|
+
/^kill\s+(?!.*-9)\d/,
|
|
374
|
+
/^rake\b/,
|
|
375
|
+
// gh CLI write operations (recoverable)
|
|
376
|
+
/^gh\s+(pr|issue)\s+(create|edit|comment|close|reopen|merge)\b/,
|
|
377
|
+
/^gh\s+run\s+(rerun|cancel|watch)\b/,
|
|
378
|
+
]
|
|
379
|
+
|
|
380
|
+
function classify(command) {
|
|
381
|
+
// Extract the first command from compound chains (&&, ||, ;) and pipes
|
|
382
|
+
// so that `cd /dir && git branch -D feat` classifies as green (cd),
|
|
383
|
+
// not red (git branch -D). This matches what normalize() does.
|
|
384
|
+
const compoundMatch = command.match(/^(.+?)\s*(&&|\|\||;)\s*(.+)$/)
|
|
385
|
+
if (compoundMatch) return classify(compoundMatch[1].trim())
|
|
386
|
+
const pipeMatch = command.match(/^(.+?)\s*\|\s*(.+)$/)
|
|
387
|
+
if (pipeMatch && !/\|\s*(sh|bash|zsh)\b/.test(command)) {
|
|
388
|
+
return classify(pipeMatch[1].trim())
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// RED check first (highest priority)
|
|
392
|
+
for (const { test, reason } of RED_PATTERNS) {
|
|
393
|
+
if (test.test(command)) return { tier: 'red', reason }
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// GREEN checks
|
|
397
|
+
const baseCmd = command.split(/\s+/)[0]
|
|
398
|
+
if (GREEN_BASES.has(baseCmd)) return { tier: 'green' }
|
|
399
|
+
for (const re of GREEN_COMPOUND) {
|
|
400
|
+
if (re.test(command)) return { tier: 'green' }
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// YELLOW checks
|
|
404
|
+
if (YELLOW_BASES.has(baseCmd)) return { tier: 'yellow' }
|
|
405
|
+
for (const re of YELLOW_COMPOUND) {
|
|
406
|
+
if (re.test(command)) return { tier: 'yellow' }
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Unclassified -- silently dropped from output
|
|
410
|
+
return { tier: 'unknown' }
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ── Normalization ──────────────────────────────────────────────────────────
|
|
414
|
+
|
|
415
|
+
// Risk-modifying flags that must NOT be collapsed into wildcards.
|
|
416
|
+
// Global flags are always preserved; context-specific flags only matter
|
|
417
|
+
// for certain base commands.
|
|
418
|
+
const GLOBAL_RISK_FLAGS = new Set([
|
|
419
|
+
'--force',
|
|
420
|
+
'--hard',
|
|
421
|
+
'-rf',
|
|
422
|
+
'--privileged',
|
|
423
|
+
'--no-verify',
|
|
424
|
+
'--system',
|
|
425
|
+
'--force-with-lease',
|
|
426
|
+
'-D',
|
|
427
|
+
'--force-if-includes',
|
|
428
|
+
'--volumes',
|
|
429
|
+
'--rmi',
|
|
430
|
+
'--rewrite',
|
|
431
|
+
'--delete',
|
|
432
|
+
])
|
|
433
|
+
|
|
434
|
+
// Flags that are only risky for specific base commands.
|
|
435
|
+
// -f means force-push in git, force-remove in docker, but pattern-file in grep.
|
|
436
|
+
// -v means remove-volumes in docker-compose, but verbose everywhere else.
|
|
437
|
+
const CONTEXTUAL_RISK_FLAGS = {
|
|
438
|
+
'-f': new Set(['git', 'docker', 'rm']),
|
|
439
|
+
'-v': new Set(['docker', 'docker-compose']),
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function isRiskFlag(token, base) {
|
|
443
|
+
if (GLOBAL_RISK_FLAGS.has(token)) return true
|
|
444
|
+
// Check context-specific flags
|
|
445
|
+
const contexts = CONTEXTUAL_RISK_FLAGS[token]
|
|
446
|
+
if (contexts && base && contexts.has(base)) return true
|
|
447
|
+
// Combined short flags containing risk chars: -rf, -fr, -fR, etc.
|
|
448
|
+
if (/^-[a-zA-Z]*[rf][a-zA-Z]*$/.test(token) && token.length <= 4) return true
|
|
449
|
+
return false
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: command normalization intentionally centralizes risk checks and pattern shaping.
|
|
453
|
+
function normalize(command) {
|
|
454
|
+
// Don't normalize shell injection patterns
|
|
455
|
+
if (/\|\s*(sh|bash|zsh)\b/.test(command)) return command
|
|
456
|
+
// Don't normalize sudo -- keep as-is
|
|
457
|
+
if (/^sudo\s/.test(command)) return 'sudo *'
|
|
458
|
+
|
|
459
|
+
// Handle pnpm --filter <pkg> <subcommand> specially
|
|
460
|
+
const pnpmFilter = command.match(/^pnpm\s+--filter\s+\S+\s+(\S+)/)
|
|
461
|
+
if (pnpmFilter) return `pnpm --filter * ${pnpmFilter[1]} *`
|
|
462
|
+
|
|
463
|
+
// Handle sed specially -- preserve the mode flag to keep safe patterns narrow.
|
|
464
|
+
// sed -i (in-place) is destructive; sed -n, sed -e, bare sed are read-only.
|
|
465
|
+
if (/^sed\s/.test(command)) {
|
|
466
|
+
if (/\s-i\b/.test(command)) return 'sed -i *'
|
|
467
|
+
const sedFlag = command.match(/^sed\s+(-[a-zA-Z])\s/)
|
|
468
|
+
return sedFlag ? `sed ${sedFlag[1]} *` : 'sed *'
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Handle ast-grep specially -- preserve --rewrite flag.
|
|
472
|
+
if (/^(ast-grep|sg)\s/.test(command)) {
|
|
473
|
+
const base = command.startsWith('sg') ? 'sg' : 'ast-grep'
|
|
474
|
+
return /\s--rewrite\b/.test(command) ? `${base} --rewrite *` : `${base} *`
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Handle find specially -- preserve key action flags.
|
|
478
|
+
// find -delete and find -exec rm are destructive; find -name/-type are safe.
|
|
479
|
+
if (/^find\s/.test(command)) {
|
|
480
|
+
if (/\s-delete\b/.test(command)) return 'find -delete *'
|
|
481
|
+
if (/\s-exec\s/.test(command)) return 'find -exec *'
|
|
482
|
+
// Extract the first predicate flag for a narrower safe pattern
|
|
483
|
+
const findFlag = command.match(/\s(-(?:name|type|path|iname))\s/)
|
|
484
|
+
return findFlag ? `find ${findFlag[1]} *` : 'find *'
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Handle git -C <dir> <subcommand> -- strip the -C <dir> and normalize the git subcommand
|
|
488
|
+
const gitC = command.match(/^git\s+-C\s+\S+\s+(.+)$/)
|
|
489
|
+
if (gitC) return normalize(`git ${gitC[1]}`)
|
|
490
|
+
|
|
491
|
+
// Split on compound operators -- normalize the first command only
|
|
492
|
+
const compoundMatch = command.match(/^(.+?)\s*(&&|\|\||;)\s*(.+)$/)
|
|
493
|
+
if (compoundMatch) {
|
|
494
|
+
return normalize(compoundMatch[1].trim())
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Strip trailing pipe chains for normalization (e.g., `cmd | tail -5`)
|
|
498
|
+
// but preserve pipe-to-shell (already handled by shell injection check above)
|
|
499
|
+
const pipeMatch = command.match(/^(.+?)\s*\|\s*(.+)$/)
|
|
500
|
+
if (pipeMatch) {
|
|
501
|
+
return normalize(pipeMatch[1].trim())
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Strip trailing redirections (2>&1, > file, >> file)
|
|
505
|
+
const cleaned = command
|
|
506
|
+
.replace(/\s*[12]?>>?\s*\S+\s*$/, '')
|
|
507
|
+
.replace(/\s*2>&1\s*$/, '')
|
|
508
|
+
.trim()
|
|
509
|
+
|
|
510
|
+
const parts = cleaned.split(/\s+/)
|
|
511
|
+
if (parts.length === 0) return command
|
|
512
|
+
|
|
513
|
+
const base = parts[0]
|
|
514
|
+
|
|
515
|
+
// For git/docker/gh/npm etc, include the subcommand
|
|
516
|
+
const multiWordBases = [
|
|
517
|
+
'git',
|
|
518
|
+
'docker',
|
|
519
|
+
'docker-compose',
|
|
520
|
+
'gh',
|
|
521
|
+
'npm',
|
|
522
|
+
'bun',
|
|
523
|
+
'pnpm',
|
|
524
|
+
'yarn',
|
|
525
|
+
'cargo',
|
|
526
|
+
'pip',
|
|
527
|
+
'pip3',
|
|
528
|
+
'bundle',
|
|
529
|
+
'systemctl',
|
|
530
|
+
'kubectl',
|
|
531
|
+
]
|
|
532
|
+
|
|
533
|
+
let prefix = base
|
|
534
|
+
let argStart = 1
|
|
535
|
+
|
|
536
|
+
if (multiWordBases.includes(base) && parts.length > 1) {
|
|
537
|
+
prefix = `${base} ${parts[1]}`
|
|
538
|
+
argStart = 2
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Preserve risk-modifying flags in the remaining args
|
|
542
|
+
const preservedFlags = []
|
|
543
|
+
for (let i = argStart; i < parts.length; i++) {
|
|
544
|
+
if (isRiskFlag(parts[i], base)) {
|
|
545
|
+
preservedFlags.push(parts[i])
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Build the normalized pattern
|
|
550
|
+
if (parts.length <= argStart && preservedFlags.length === 0) {
|
|
551
|
+
return prefix // no args, no flags: e.g., "git status"
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const flagStr =
|
|
555
|
+
preservedFlags.length > 0 ? ` ${preservedFlags.join(' ')}` : ''
|
|
556
|
+
const hasVaryingArgs = parts.length > argStart + preservedFlags.length
|
|
557
|
+
|
|
558
|
+
if (hasVaryingArgs) {
|
|
559
|
+
return `${prefix + flagStr} *`
|
|
560
|
+
}
|
|
561
|
+
return prefix + flagStr
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// ── Session file scanning ──────────────────────────────────────────────────
|
|
565
|
+
|
|
566
|
+
const commands = new Map()
|
|
567
|
+
let filesScanned = 0
|
|
568
|
+
const sessionsScanned = new Set()
|
|
569
|
+
|
|
570
|
+
async function listDirs(dir) {
|
|
571
|
+
try {
|
|
572
|
+
const entries = await readdir(dir, { withFileTypes: true })
|
|
573
|
+
return entries.filter((e) => e.isDirectory()).map((e) => e.name)
|
|
574
|
+
} catch {
|
|
575
|
+
return []
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
async function listJsonlFiles(dir) {
|
|
580
|
+
try {
|
|
581
|
+
const entries = await readdir(dir, { withFileTypes: true })
|
|
582
|
+
return entries
|
|
583
|
+
.filter((e) => e.isFile() && e.name.endsWith('.jsonl'))
|
|
584
|
+
.map((e) => e.name)
|
|
585
|
+
} catch {
|
|
586
|
+
return []
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: transcript parsing requires defensive guards for heterogeneous session data.
|
|
591
|
+
async function processFile(filePath, sessionId) {
|
|
592
|
+
try {
|
|
593
|
+
filesScanned++
|
|
594
|
+
sessionsScanned.add(sessionId)
|
|
595
|
+
|
|
596
|
+
const content = await readFile(filePath, 'utf-8')
|
|
597
|
+
for (const line of content.split('\n')) {
|
|
598
|
+
if (!line.includes('"Bash"')) continue
|
|
599
|
+
try {
|
|
600
|
+
const record = JSON.parse(line)
|
|
601
|
+
if (record.type !== 'assistant') continue
|
|
602
|
+
const blocks = record.message?.content
|
|
603
|
+
if (!Array.isArray(blocks)) continue
|
|
604
|
+
for (const block of blocks) {
|
|
605
|
+
if (block.type !== 'tool_use' || block.name !== 'Bash') continue
|
|
606
|
+
const cmd = block.input?.command
|
|
607
|
+
if (!cmd) continue
|
|
608
|
+
const ts = record.timestamp
|
|
609
|
+
? new Date(record.timestamp).getTime()
|
|
610
|
+
: info.mtimeMs
|
|
611
|
+
const existing = commands.get(cmd)
|
|
612
|
+
if (existing) {
|
|
613
|
+
existing.count++
|
|
614
|
+
existing.sessions.add(sessionId)
|
|
615
|
+
existing.firstSeen = Math.min(existing.firstSeen, ts)
|
|
616
|
+
existing.lastSeen = Math.max(existing.lastSeen, ts)
|
|
617
|
+
} else {
|
|
618
|
+
commands.set(cmd, {
|
|
619
|
+
count: 1,
|
|
620
|
+
sessions: new Set([sessionId]),
|
|
621
|
+
firstSeen: ts,
|
|
622
|
+
lastSeen: ts,
|
|
623
|
+
})
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
} catch {
|
|
627
|
+
// skip malformed lines
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
} catch {
|
|
631
|
+
// skip unreadable files
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Collect all candidate session files, then sort by recency and limit
|
|
636
|
+
const candidates = []
|
|
637
|
+
const projectSlugs = await listDirs(projectsDir)
|
|
638
|
+
for (const slug of projectSlugs) {
|
|
639
|
+
if (projectSlugFilter && slug !== projectSlugFilter) continue
|
|
640
|
+
const slugDir = join(projectsDir, slug)
|
|
641
|
+
const jsonlFiles = await listJsonlFiles(slugDir)
|
|
642
|
+
for (const f of jsonlFiles) {
|
|
643
|
+
const filePath = join(slugDir, f)
|
|
644
|
+
try {
|
|
645
|
+
const info = await stat(filePath)
|
|
646
|
+
if (info.mtimeMs >= cutoff) {
|
|
647
|
+
candidates.push({
|
|
648
|
+
filePath,
|
|
649
|
+
sessionId: f.replace('.jsonl', ''),
|
|
650
|
+
mtime: info.mtimeMs,
|
|
651
|
+
})
|
|
652
|
+
}
|
|
653
|
+
} catch {
|
|
654
|
+
// skip unreadable files
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// Sort by most recent first, then take at most maxSessions
|
|
660
|
+
candidates.sort((a, b) => b.mtime - a.mtime)
|
|
661
|
+
const toProcess = candidates.slice(0, maxSessions)
|
|
662
|
+
|
|
663
|
+
await Promise.all(toProcess.map((c) => processFile(c.filePath, c.sessionId)))
|
|
664
|
+
|
|
665
|
+
// ── Filter, normalize, group, classify ─────────────────────────────────────
|
|
666
|
+
|
|
667
|
+
const totalExtracted = commands.size
|
|
668
|
+
let alreadyCovered = 0
|
|
669
|
+
let belowThreshold = 0
|
|
670
|
+
|
|
671
|
+
// Group raw commands by normalized pattern, tracking unique sessions per group.
|
|
672
|
+
// Normalize and group FIRST, then apply the min-count threshold to the grouped
|
|
673
|
+
// totals. This prevents many low-frequency variants of the same pattern from
|
|
674
|
+
// being individually discarded as noise when they collectively exceed the threshold.
|
|
675
|
+
const patternGroups = new Map()
|
|
676
|
+
|
|
677
|
+
for (const [command, data] of commands) {
|
|
678
|
+
if (isAllowed(command)) {
|
|
679
|
+
alreadyCovered++
|
|
680
|
+
continue
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
const pattern = `Bash(${normalize(command)})`
|
|
684
|
+
const { tier, reason } = classify(command)
|
|
685
|
+
|
|
686
|
+
const existing = patternGroups.get(pattern)
|
|
687
|
+
if (existing) {
|
|
688
|
+
existing.rawCommands.push({ command, count: data.count })
|
|
689
|
+
existing.totalCount += data.count
|
|
690
|
+
// Merge session sets to avoid overcounting
|
|
691
|
+
for (const s of data.sessions) existing.sessionSet.add(s)
|
|
692
|
+
// Escalation: highest tier wins
|
|
693
|
+
if (tier === 'red' && existing.tier !== 'red') {
|
|
694
|
+
existing.tier = 'red'
|
|
695
|
+
existing.reason = reason
|
|
696
|
+
} else if (tier === 'yellow' && existing.tier === 'green') {
|
|
697
|
+
existing.tier = 'yellow'
|
|
698
|
+
} else if (tier === 'unknown' && existing.tier === 'green') {
|
|
699
|
+
existing.tier = 'unknown'
|
|
700
|
+
}
|
|
701
|
+
} else {
|
|
702
|
+
patternGroups.set(pattern, {
|
|
703
|
+
rawCommands: [{ command, count: data.count }],
|
|
704
|
+
totalCount: data.count,
|
|
705
|
+
sessionSet: new Set(data.sessions),
|
|
706
|
+
tier,
|
|
707
|
+
reason: reason || null,
|
|
708
|
+
})
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// Now filter by min-count on the GROUPED totals
|
|
713
|
+
for (const [pattern, data] of patternGroups) {
|
|
714
|
+
if (data.totalCount < minCount) {
|
|
715
|
+
belowThreshold += data.rawCommands.length
|
|
716
|
+
patternGroups.delete(pattern)
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Post-grouping safety check: normalization can broaden a safe command into an
|
|
721
|
+
// unsafe pattern (e.g., "node --version" is green, but normalizes to "node *"
|
|
722
|
+
// which would also match arbitrary code execution). Re-classify the normalized
|
|
723
|
+
// pattern itself and escalate if the broader form is riskier.
|
|
724
|
+
for (const [pattern, data] of patternGroups) {
|
|
725
|
+
if (data.tier !== 'green') continue
|
|
726
|
+
if (!pattern.includes('*')) continue
|
|
727
|
+
const cmd = pattern.replace(/^Bash\(|\)$/g, '')
|
|
728
|
+
const { tier, reason } = classify(cmd)
|
|
729
|
+
if (tier === 'red') {
|
|
730
|
+
data.tier = 'red'
|
|
731
|
+
data.reason = reason
|
|
732
|
+
} else if (tier === 'yellow') {
|
|
733
|
+
data.tier = 'yellow'
|
|
734
|
+
} else if (tier === 'unknown') {
|
|
735
|
+
data.tier = 'unknown'
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// Only output green (safe) patterns. Yellow, red, and unknown are counted
|
|
740
|
+
// in stats for transparency but not included as arrays.
|
|
741
|
+
const green = []
|
|
742
|
+
let greenRawCount = 0 // unique raw commands covered by green patterns
|
|
743
|
+
let yellowCount = 0
|
|
744
|
+
const redBlocked = []
|
|
745
|
+
let unclassified = 0
|
|
746
|
+
const yellowNames = [] // brief list for the footnote
|
|
747
|
+
|
|
748
|
+
for (const [pattern, data] of patternGroups) {
|
|
749
|
+
switch (data.tier) {
|
|
750
|
+
case 'green':
|
|
751
|
+
green.push({
|
|
752
|
+
pattern,
|
|
753
|
+
count: data.totalCount,
|
|
754
|
+
sessions: data.sessionSet.size,
|
|
755
|
+
examples: data.rawCommands
|
|
756
|
+
.sort((a, b) => b.count - a.count)
|
|
757
|
+
.slice(0, 3)
|
|
758
|
+
.map((c) => c.command),
|
|
759
|
+
})
|
|
760
|
+
greenRawCount += data.rawCommands.length
|
|
761
|
+
break
|
|
762
|
+
case 'yellow':
|
|
763
|
+
yellowCount++
|
|
764
|
+
yellowNames.push(pattern.replace(/^Bash\(|\)$/g, '').replace(/ \*$/, ''))
|
|
765
|
+
break
|
|
766
|
+
case 'red':
|
|
767
|
+
redBlocked.push({
|
|
768
|
+
pattern: pattern.replace(/^Bash\(|\)$/g, ''),
|
|
769
|
+
reason: data.reason,
|
|
770
|
+
count: data.totalCount,
|
|
771
|
+
})
|
|
772
|
+
break
|
|
773
|
+
default:
|
|
774
|
+
unclassified++
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
green.sort((a, b) => b.count - a.count)
|
|
779
|
+
redBlocked.sort((a, b) => b.count - a.count)
|
|
780
|
+
|
|
781
|
+
const output = {
|
|
782
|
+
green,
|
|
783
|
+
redExamples: redBlocked.slice(0, 5),
|
|
784
|
+
yellowFootnote:
|
|
785
|
+
yellowNames.length > 0
|
|
786
|
+
? `Also frequently used: ${yellowNames.join(', ')} (not classified as safe to auto-allow but may be worth reviewing)`
|
|
787
|
+
: null,
|
|
788
|
+
stats: {
|
|
789
|
+
totalExtracted,
|
|
790
|
+
alreadyCovered,
|
|
791
|
+
belowThreshold,
|
|
792
|
+
unclassified,
|
|
793
|
+
yellowSkipped: yellowCount,
|
|
794
|
+
redBlocked: redBlocked.length,
|
|
795
|
+
patternsReturned: green.length,
|
|
796
|
+
greenRawCount,
|
|
797
|
+
sessionsScanned: sessionsScanned.size,
|
|
798
|
+
filesScanned,
|
|
799
|
+
allowPatternsLoaded: allowPatterns.length,
|
|
800
|
+
daysWindow: days,
|
|
801
|
+
minCount,
|
|
802
|
+
},
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
console.log(JSON.stringify(output, null, 2))
|