crewforth 3.1.0 → 3.2.0-rc.1

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.
Files changed (70) hide show
  1. package/CHANGELOG.md +151 -0
  2. package/README.md +4 -3
  3. package/VERSION +1 -1
  4. package/adopt.sh +91 -11
  5. package/kit/AGENT_TEMPLATE.md +2 -1
  6. package/kit/CLAUDE.md +9 -8
  7. package/kit/agents/crew-commit-agent.md +7 -2
  8. package/kit/agents/crew-docs-agent.md +71 -0
  9. package/kit/agents/crew-planner.md +2 -0
  10. package/kit/agents/crew-refuter.md +77 -0
  11. package/kit/agents/crew-security-expert.md +3 -1
  12. package/kit/agents/crew-session-manager.md +11 -9
  13. package/kit/eval/doctor.sh +91 -3
  14. package/kit/eval/golden-routing.txt +9 -0
  15. package/kit/eval/lib/crew-map/crew-map.sh +26 -0
  16. package/kit/eval/lib/crew-map/detect.mjs +238 -0
  17. package/kit/eval/lib/crew-map/narrative.mjs +98 -0
  18. package/kit/eval/lib/crew-map/parse.mjs +305 -0
  19. package/kit/eval/lib/crew-map/project.mjs +163 -0
  20. package/kit/eval/lib/crew-map/rules/names.json +38 -0
  21. package/kit/eval/lib/crew-map/rules/packages.json +142 -0
  22. package/kit/eval/lib/crew-map/rules/python-stdlib.json +197 -0
  23. package/kit/eval/lib/crew-map/scan.mjs +214 -0
  24. package/kit/eval/lib/crew-map/stacks.mjs +441 -0
  25. package/kit/eval/lib/crew-map/surface.mjs +457 -0
  26. package/kit/eval/lib/findings/findings.mjs +410 -0
  27. package/kit/eval/lib/findings/findings.schema.json +122 -0
  28. package/kit/eval/lib/findings/findings.sh +29 -0
  29. package/kit/eval/lib/settings-json.awk +5 -0
  30. package/kit/eval/lib/star.sh +23 -7
  31. package/kit/eval/lib/statusline.settings.json +6 -0
  32. package/kit/eval/lib/statusline.sh +115 -0
  33. package/kit/eval/smoke-test.sh +465 -38
  34. package/kit/hooks/agent-outcome.sh +6 -3
  35. package/kit/hooks/guard-agent-model.sh +15 -5
  36. package/kit/hooks/guard-bash.sh +240 -182
  37. package/kit/hooks/guard-question.sh +73 -0
  38. package/kit/hooks/guard-write.sh +21 -0
  39. package/kit/hooks/prompt-approval.sh +320 -103
  40. package/kit/hooks/refuter-ledger.sh +80 -0
  41. package/kit/owned-blobs.tsv +4 -1
  42. package/kit/settings.json +33 -0
  43. package/kit/skills/crew-approve/SKILL.md +7 -0
  44. package/kit/skills/crew-doctor/SKILL.md +5 -1
  45. package/kit/skills/crew-handoff/SKILL.md +3 -2
  46. package/kit/skills/crew-map/SKILL.md +44 -0
  47. package/kit/skills/crew-review/SKILL.md +2 -0
  48. package/kit/skills/crew-update/SKILL.md +1 -1
  49. package/kit/skills/incident-runbook/SKILL.md +2 -2
  50. package/kit/skills/security-scan/references/verify.md +6 -3
  51. package/kit/studio/README.md +12 -0
  52. package/kit/studio/server/index.js +114 -11
  53. package/kit/studio/server/lib/crew-map.js +113 -0
  54. package/kit/studio/server/lib/model-outcomes.js +130 -15
  55. package/kit/studio/server/lib/pricing.js +3 -0
  56. package/kit/studio/server/lib/remote.js +99 -0
  57. package/kit/studio/server/lib/usage.js +74 -14
  58. package/kit/studio/web/app.js +214 -12
  59. package/kit/studio/web/canvas.js +44 -120
  60. package/kit/studio/web/graph-plan.js +10 -4
  61. package/kit/studio/web/index.html +10 -5
  62. package/kit/studio/web/map-plan.js +341 -0
  63. package/kit/studio/web/map.js +503 -0
  64. package/kit/studio/web/models-plan.js +61 -7
  65. package/kit/studio/web/models.js +19 -3
  66. package/kit/studio/web/style.css +125 -0
  67. package/kit/studio/web/usage.js +12 -0
  68. package/kit/studio/web/viewport.js +160 -0
  69. package/package.json +6 -5
  70. package/start.sh +10 -0
@@ -0,0 +1,305 @@
1
+ // parse.mjs — the four readers the scanner needs, each one a SUBSET written for the files it reads, and each one
2
+ // keeping the line every key came from so a fact can be cited. Node's standard library only.
3
+ //
4
+ // The rule that makes a subset safe: a construct the reader does not understand THROWS (Unsupported). It is never
5
+ // read partly. The scanner turns the throw into a notMeasured entry with the reason, so an unread file is visible
6
+ // instead of looking like a file that said nothing.
7
+
8
+ export class Unsupported extends Error {}
9
+
10
+ // ---- JSON with comments and trailing commas (package.json, tsconfig.json, appsettings*.json) ---------------
11
+ // Returns { value, lines } where `lines` maps a key path (keys joined by '\u0000') to the 1-based line of the key.
12
+ export function parseJsonc(text) {
13
+ let i = 0; let line = 1;
14
+ const lines = new Map();
15
+ const ws = () => {
16
+ for (;;) {
17
+ const c = text[i];
18
+ if (c === '\n') { line++; i++; }
19
+ else if (c === ' ' || c === '\t' || c === '\r' || c === '') i++;
20
+ else if (c === '/' && text[i + 1] === '/') { while (i < text.length && text[i] !== '\n') i++; }
21
+ else if (c === '/' && text[i + 1] === '*') {
22
+ const end = text.indexOf('*/', i + 2);
23
+ if (end === -1) throw new Unsupported(`unterminated comment at line ${line}`);
24
+ for (let k = i; k < end; k++) if (text[k] === '\n') line++;
25
+ i = end + 2;
26
+ } else return;
27
+ }
28
+ };
29
+ const str = () => {
30
+ let out = ''; i++;
31
+ for (;;) {
32
+ const c = text[i];
33
+ if (c === undefined || c === '\n') throw new Unsupported(`unterminated string at line ${line}`);
34
+ if (c === '"') { i++; return out; }
35
+ if (c === '\\') {
36
+ const e = text[i + 1];
37
+ if (e === 'u') { out += String.fromCharCode(parseInt(text.slice(i + 2, i + 6), 16)); i += 6; continue; }
38
+ out += { n: '\n', t: '\t', r: '\r', b: '\b', f: '\f' }[e] ?? e; i += 2; continue;
39
+ }
40
+ out += c; i++;
41
+ }
42
+ };
43
+ const val = (at) => {
44
+ ws();
45
+ const c = text[i];
46
+ if (c === '{') {
47
+ i++; const o = {};
48
+ for (;;) {
49
+ ws();
50
+ if (text[i] === '}') { i++; return o; }
51
+ if (text[i] !== '"') throw new Unsupported(`expected a key at line ${line}`);
52
+ const kl = line; const k = str();
53
+ lines.set([...at, k].join('\u0000'), kl);
54
+ ws(); if (text[i] !== ':') throw new Unsupported(`expected ':' at line ${line}`); i++;
55
+ o[k] = val([...at, k]);
56
+ ws(); if (text[i] === ',') { i++; continue; }
57
+ if (text[i] === '}') { i++; return o; }
58
+ throw new Unsupported(`expected ',' or '}' at line ${line}`);
59
+ }
60
+ }
61
+ if (c === '[') {
62
+ i++; const a = [];
63
+ for (;;) {
64
+ ws();
65
+ if (text[i] === ']') { i++; return a; }
66
+ lines.set([...at, String(a.length)].join('\u0000'), line);
67
+ a.push(val([...at, String(a.length)]));
68
+ ws(); if (text[i] === ',') { i++; continue; }
69
+ if (text[i] === ']') { i++; return a; }
70
+ throw new Unsupported(`expected ',' or ']' at line ${line}`);
71
+ }
72
+ }
73
+ if (c === '"') return str();
74
+ const m = /^(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|true|false|null)/.exec(text.slice(i, i + 64));
75
+ if (!m) throw new Unsupported(`unexpected character at line ${line}`);
76
+ i += m[0].length;
77
+ return JSON.parse(m[0]);
78
+ };
79
+ const value = val([]);
80
+ ws();
81
+ if (i < text.length) throw new Unsupported(`trailing content at line ${line}`);
82
+ return { value, lines };
83
+ }
84
+
85
+ // ---- TOML subset (pyproject.toml) --------------------------------------------------------------------------
86
+ // Tables, dotted table names, key = value with strings, numbers, booleans, arrays (one or many lines) and inline
87
+ // tables of those. Returns a list of { table, key, value, line } in file order. Dates, multi-line strings and
88
+ // array-of-tables are refused.
89
+ export function parseToml(text) {
90
+ const out = []; let table = '';
91
+ const src = text.split('\n');
92
+ for (let n = 0; n < src.length; n++) {
93
+ let s = src[n].replace(/\r$/, '');
94
+ const t = s.trim();
95
+ if (t === '' || t.startsWith('#')) continue;
96
+ if (t.startsWith('[[')) throw new Unsupported(`array of tables at line ${n + 1}`);
97
+ const th = /^\[\s*([A-Za-z0-9_.\-"' ]+?)\s*\]\s*(#.*)?$/.exec(t);
98
+ if (th) { table = th[1].replace(/["' ]/g, ''); continue; }
99
+ const kv = /^([A-Za-z0-9_.\-"]+)\s*=\s*(.*)$/.exec(t);
100
+ if (!kv) throw new Unsupported(`unreadable line ${n + 1}`);
101
+ let rest = kv[2]; const line = n + 1;
102
+ // An array may continue over several lines: gather until the brackets balance outside strings.
103
+ while (depth(rest) > 0) {
104
+ n++;
105
+ if (n >= src.length) throw new Unsupported(`unterminated array from line ${line}`);
106
+ rest += '\n' + src[n].replace(/\r$/, '');
107
+ }
108
+ const p = { s: rest, i: 0 };
109
+ const value = tomlValue(p, line);
110
+ skipTomlWs(p);
111
+ if (p.i < p.s.length && p.s[p.i] !== '#') throw new Unsupported(`trailing content at line ${line}`);
112
+ out.push({ table, key: kv[1].replace(/"/g, ''), value, line });
113
+ }
114
+ return out;
115
+ }
116
+ function depth(s) {
117
+ let d = 0; let q = null;
118
+ for (let i = 0; i < s.length; i++) {
119
+ const c = s[i];
120
+ if (q) { if (c === '\\' && q === '"') i++; else if (c === q) q = null; continue; }
121
+ if (c === '"' || c === "'") { if (s.startsWith(c.repeat(3), i)) throw new Unsupported('multi-line string'); q = c; }
122
+ else if (c === '#') { const nl = s.indexOf('\n', i); if (nl === -1) break; i = nl; }
123
+ else if (c === '[' || c === '{') d++;
124
+ else if (c === ']' || c === '}') d--;
125
+ }
126
+ return d;
127
+ }
128
+ function skipTomlWs(p) {
129
+ for (;;) {
130
+ const c = p.s[p.i];
131
+ if (c === ' ' || c === '\t' || c === '\n' || c === '\r' || c === ',') p.i++;
132
+ else if (c === '#' && p.s.slice(p.i).includes('\n')) p.i = p.s.indexOf('\n', p.i) + 1;
133
+ else return;
134
+ }
135
+ }
136
+ function tomlValue(p, line) {
137
+ while (p.s[p.i] === ' ' || p.s[p.i] === '\t') p.i++;
138
+ const c = p.s[p.i];
139
+ if (c === '"' || c === "'") {
140
+ let out = ''; p.i++;
141
+ for (;;) {
142
+ const d = p.s[p.i];
143
+ if (d === undefined || d === '\n') throw new Unsupported(`unterminated string at line ${line}`);
144
+ if (d === c) { p.i++; return out; }
145
+ if (d === '\\' && c === '"') { out += p.s[p.i + 1]; p.i += 2; continue; }
146
+ out += d; p.i++;
147
+ }
148
+ }
149
+ if (c === '[') {
150
+ p.i++; const a = [];
151
+ for (;;) { skipTomlWs(p); if (p.s[p.i] === ']') { p.i++; return a; } a.push(tomlValue(p, line)); }
152
+ }
153
+ if (c === '{') {
154
+ p.i++; const o = {};
155
+ for (;;) {
156
+ skipTomlWs(p);
157
+ if (p.s[p.i] === '}') { p.i++; return o; }
158
+ const m = /^([A-Za-z0-9_\-"]+)\s*=\s*/.exec(p.s.slice(p.i));
159
+ if (!m) throw new Unsupported(`unreadable inline table at line ${line}`);
160
+ p.i += m[0].length; o[m[1].replace(/"/g, '')] = tomlValue(p, line);
161
+ }
162
+ }
163
+ const m = /^(true|false|[+-]?\d[\d_]*(?:\.\d+)?)(?=[\s,\]}#]|$)/.exec(p.s.slice(p.i));
164
+ if (!m) throw new Unsupported(`unsupported value at line ${line}`);
165
+ p.i += m[0].length;
166
+ return m[1] === 'true' ? true : m[1] === 'false' ? false : Number(m[1].replace(/_/g, ''));
167
+ }
168
+
169
+ // ---- YAML subset (docker-compose*.yml, application*.yml) ---------------------------------------------------
170
+ // Block maps and sequences by indentation, plain and quoted scalars, one-line flow sequences, comments. A block
171
+ // scalar (| or >) is kept as an opaque value. Anchors, aliases, merge keys, tags and multi-document files are
172
+ // refused: reading around them would invent structure.
173
+ // A node is { t: 'map', e: [{ k, line, v }] } | { t: 'seq', e: [{ line, v }] } | { t: 'str', v, line }.
174
+ export function parseYaml(text) {
175
+ const rows = [];
176
+ const src = text.split('\n');
177
+ for (let n = 0; n < src.length; n++) {
178
+ const raw = src[n].replace(/\r$/, '');
179
+ if (/^\s*(#.*)?$/.test(raw)) continue;
180
+ if (/^(---|\.\.\.)\s*$/.test(raw)) { if (rows.length) throw new Unsupported(`a second document at line ${n + 1}`); continue; }
181
+ if (/\t/.test(raw.match(/^\s*/)[0])) throw new Unsupported(`tab indentation at line ${n + 1}`);
182
+ rows.push({ ind: raw.match(/^ */)[0].length, s: stripComment(raw.trim()), line: n + 1 });
183
+ }
184
+ const p = { rows, i: 0 };
185
+ if (!rows.length) return { t: 'map', e: [] };
186
+ return yBlock(p, rows[0].ind);
187
+ }
188
+ function stripComment(s) {
189
+ let q = null;
190
+ for (let i = 0; i < s.length; i++) {
191
+ const c = s[i];
192
+ if (q) { if (c === q) q = null; continue; }
193
+ if (c === '"' || c === "'") { if (i === 0 || /[\s:[,{-]/.test(s[i - 1])) q = c; }
194
+ else if (c === '#' && (i === 0 || s[i - 1] === ' ')) return s.slice(0, i).trimEnd();
195
+ }
196
+ return s;
197
+ }
198
+ function yScalar(s, line) {
199
+ if (/^[&*!]/.test(s)) throw new Unsupported(`anchor, alias or tag at line ${line}`);
200
+ if (s.startsWith('[')) {
201
+ if (!s.endsWith(']')) throw new Unsupported(`multi-line flow sequence at line ${line}`);
202
+ const inner = s.slice(1, -1).trim();
203
+ return { t: 'seq', e: inner === '' ? [] : inner.split(',').map((x) => ({ line, v: yScalar(x.trim(), line) })) };
204
+ }
205
+ if (s.startsWith('{')) throw new Unsupported(`flow mapping at line ${line}`);
206
+ if ((s.startsWith('"') && s.endsWith('"') && s.length > 1) || (s.startsWith("'") && s.endsWith("'") && s.length > 1)) return { t: 'str', v: s.slice(1, -1), line };
207
+ return { t: 'str', v: s, line };
208
+ }
209
+ function keyOf(s) {
210
+ const m = /^("[^"]*"|'[^']*'|[^\s:"'][^:]*?)\s*:(?:\s+(.*)|$)/.exec(s);
211
+ if (!m) return null;
212
+ return { k: m[1].replace(/^["']|["']$/g, ''), rest: m[2] === undefined ? '' : m[2] };
213
+ }
214
+ function yBlock(p, ind) {
215
+ const first = p.rows[p.i];
216
+ if (first.s.startsWith('- ') || first.s === '-') {
217
+ const node = { t: 'seq', e: [] };
218
+ while (p.i < p.rows.length && p.rows[p.i].ind === ind && (p.rows[p.i].s.startsWith('- ') || p.rows[p.i].s === '-')) {
219
+ const r = p.rows[p.i]; const rest = r.s === '-' ? '' : r.s.slice(2).trim();
220
+ if (rest === '') { p.i++; node.e.push({ line: r.line, v: p.i < p.rows.length && p.rows[p.i].ind > ind ? yBlock(p, p.rows[p.i].ind) : { t: 'str', v: '', line: r.line } }); continue; }
221
+ const kv = keyOf(rest);
222
+ if (kv) {
223
+ // "- key: value" opens a map whose first entry sits on the dash line; the rest are indented to the key.
224
+ p.rows[p.i] = { ind: ind + 2, s: rest, line: r.line };
225
+ node.e.push({ line: r.line, v: yBlock(p, ind + 2) });
226
+ continue;
227
+ }
228
+ node.e.push({ line: r.line, v: yScalar(rest, r.line) }); p.i++;
229
+ }
230
+ return node;
231
+ }
232
+ const node = { t: 'map', e: [] };
233
+ while (p.i < p.rows.length && p.rows[p.i].ind === ind) {
234
+ const r = p.rows[p.i];
235
+ if (r.s.startsWith('- ')) break;
236
+ const kv = keyOf(r.s);
237
+ if (!kv) throw new Unsupported(`unreadable line ${r.line}`);
238
+ if (kv.k === '<<') throw new Unsupported(`merge key at line ${r.line}`);
239
+ p.i++;
240
+ let v;
241
+ if (kv.rest === '') {
242
+ v = p.i < p.rows.length && (p.rows[p.i].ind > ind || (p.rows[p.i].ind === ind && p.rows[p.i].s.startsWith('- ')))
243
+ ? yBlock(p, p.rows[p.i].ind) : { t: 'str', v: '', line: r.line };
244
+ } else if (/^[|>][+-]?\d*$/.test(kv.rest)) {
245
+ while (p.i < p.rows.length && p.rows[p.i].ind > ind) p.i++;
246
+ v = { t: 'str', v: '', line: r.line, opaque: true };
247
+ } else v = yScalar(kv.rest, r.line);
248
+ node.e.push({ k: kv.k, line: r.line, v });
249
+ }
250
+ if (p.i < p.rows.length && p.rows[p.i].ind > ind) throw new Unsupported(`unexpected indentation at line ${p.rows[p.i].line}`);
251
+ return node;
252
+ }
253
+ export const yGet = (node, k) => (node && node.t === 'map' ? node.e.find((x) => x.k === k) : undefined);
254
+
255
+ // ---- XML, enough for *.csproj, *.props and pom.xml ----------------------------------------------------------
256
+ // Elements with attributes, text and comments. Returns the root { name, attrs, line, children, text }.
257
+ export function parseXml(text) {
258
+ let i = 0; let line = 1;
259
+ const root = { name: '#doc', attrs: {}, line: 1, children: [], text: '' };
260
+ const stack = [root];
261
+ const adv = (to) => { for (let k = i; k < to; k++) if (text[k] === '\n') line++; i = to; };
262
+ while (i < text.length) {
263
+ const lt = text.indexOf('<', i);
264
+ if (lt === -1) { stack[stack.length - 1].text += text.slice(i); adv(text.length); break; }
265
+ const top0 = stack[stack.length - 1]; const chunk = text.slice(i, lt);
266
+ if (top0.textLine === undefined && /\S/.test(chunk)) top0.textLine = line + (chunk.slice(0, chunk.search(/\S/)).match(/\n/g) || []).length;
267
+ top0.text += chunk; adv(lt);
268
+ if (text.startsWith('<!--', i)) { const e = text.indexOf('-->', i); if (e === -1) throw new Unsupported(`unterminated comment at line ${line}`); adv(e + 3); continue; }
269
+ if (text.startsWith('<?', i) || text.startsWith('<!', i)) { const e = text.indexOf('>', i); if (e === -1) throw new Unsupported(`unterminated declaration at line ${line}`); adv(e + 1); continue; }
270
+ const gt = text.indexOf('>', i);
271
+ if (gt === -1) throw new Unsupported(`unterminated tag at line ${line}`);
272
+ const tag = text.slice(i + 1, gt); const at = line;
273
+ adv(gt + 1);
274
+ if (tag.startsWith('/')) {
275
+ const name = tag.slice(1).trim();
276
+ const top = stack.pop();
277
+ if (!top || top.name !== name) throw new Unsupported(`</${name}> does not close <${top && top.name}> at line ${at}`);
278
+ continue;
279
+ }
280
+ const self = tag.endsWith('/');
281
+ const m = /^([\w:.-]+)([\s\S]*?)\/?$/.exec(tag);
282
+ if (!m) throw new Unsupported(`unreadable tag at line ${at}`);
283
+ // Each attribute keeps its own line: a tag may span several, and a citation must land where the value is.
284
+ const attrs = {}; const attrLines = {};
285
+ const off = 1 + m[1].length;
286
+ for (const a of m[2].matchAll(/([\w:.-]+)\s*=\s*("([^"]*)"|'([^']*)')/g)) {
287
+ attrs[a[1]] = a[3] ?? a[4];
288
+ attrLines[a[1]] = at + (tag.slice(0, off + a.index).match(/\n/g) || []).length;
289
+ }
290
+ const el = { name: m[1], attrs, attrLines, line: at, children: [], text: '' };
291
+ stack[stack.length - 1].children.push(el);
292
+ if (!self) stack.push(el);
293
+ }
294
+ if (stack.length !== 1) throw new Unsupported(`<${stack[stack.length - 1].name}> is never closed`);
295
+ return root;
296
+ }
297
+ export const xKids = (el, name) => (el ? el.children.filter((c) => c.name === name) : []);
298
+ export const xKid = (el, name) => xKids(el, name)[0];
299
+ export const xText = (el) => (el ? el.text.trim() : '');
300
+ // The line an element's text is on (a value may sit on the line after its tag).
301
+ export const xLine = (el) => (el ? (el.textLine ?? el.line) : 1);
302
+ export function xFind(el, name, out = []) {
303
+ for (const c of el.children) { if (c.name === name) out.push(c); xFind(c, name, out); }
304
+ return out;
305
+ }
@@ -0,0 +1,163 @@
1
+ // project.mjs — what the scanner may read, how it reads it, and how a fact is cited.
2
+ //
3
+ // One listing per run: `git ls-files` when the root is in a work tree (it honours .gitignore, which is what the
4
+ // project itself calls its source), a directory walk otherwise. Every other read happens inside Node, so the cost
5
+ // of a scan does not grow with a process per file — on Git Bash a process is tens of milliseconds.
6
+
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { spawnSync } from 'node:child_process';
10
+
11
+ // Directories that hold output, dependencies or tool state, never the project's own source. `.claude` is
12
+ // Crewforth itself: its hooks read environment variables the project never defines, and would be reported as missing.
13
+ export const SKIP_DIRS = new Set(['.git', '.hg', '.svn', 'node_modules', 'bower_components', 'bin', 'obj', 'dist', 'build',
14
+ 'out', 'target', 'vendor', '.venv', 'venv', 'env', '__pycache__', '.mypy_cache', '.pytest_cache', '.tox', '.gradle',
15
+ '.idea', '.vs', '.vscode', '.next', '.nuxt', '.svelte-kit', 'coverage', '.crewforth', '.claude', '.terraform']);
16
+ const MAX_BYTES = 2 * 1024 * 1024;
17
+
18
+ // Every child process the scanner starts goes through here, so the gate can count them.
19
+ export const spawned = { count: 0 };
20
+ export function run(cmd, args, cwd) {
21
+ spawned.count++;
22
+ const r = spawnSync(cmd, args, { cwd, encoding: 'buffer', maxBuffer: 256 * 1024 * 1024, windowsHide: true,
23
+ env: { ...process.env, GIT_OPTIONAL_LOCKS: '0', LC_ALL: 'C' } });
24
+ return { ok: r.status === 0 && !r.error, out: r.stdout ? r.stdout.toString('utf8') : '' };
25
+ }
26
+
27
+ const skipped = (rel) => rel.split('/').some((seg) => SKIP_DIRS.has(seg));
28
+
29
+ export function openProject(root) {
30
+ const real = fs.realpathSync(root);
31
+ let files = null; let commit = null;
32
+ const git = run('git', ['ls-files', '-co', '--exclude-standard', '-z'], real);
33
+ if (git.ok) {
34
+ files = git.out.split('\0').filter((f) => f && !skipped(f)).filter((f) => {
35
+ try { return fs.lstatSync(path.join(real, f)).isFile(); } catch { return false; }
36
+ });
37
+ const head = run('git', ['rev-parse', '--verify', '-q', 'HEAD'], real);
38
+ if (head.ok && /^[0-9a-f]{40,64}\s*$/.test(head.out)) commit = head.out.trim();
39
+ } else {
40
+ files = [];
41
+ const walk = (dir, rel) => {
42
+ let names; try { names = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
43
+ for (const d of names) {
44
+ const r = rel ? `${rel}/${d.name}` : d.name;
45
+ if (d.isDirectory()) { if (!SKIP_DIRS.has(d.name)) walk(path.join(dir, d.name), r); }
46
+ else if (d.isFile()) files.push(r);
47
+ }
48
+ };
49
+ walk(real, '');
50
+ }
51
+ files = [...new Set(files)].sort();
52
+ const cache = new Map();
53
+ const tooBig = [];
54
+ // { text, lines } or null (unreadable, binary or over the size limit)
55
+ const read = (rel) => {
56
+ if (cache.has(rel)) return cache.get(rel);
57
+ let r = null;
58
+ try {
59
+ const abs = path.join(real, rel);
60
+ const st = fs.statSync(abs);
61
+ if (st.size > MAX_BYTES) tooBig.push(rel);
62
+ else {
63
+ const buf = fs.readFileSync(abs);
64
+ if (!buf.subarray(0, 8000).includes(0)) {
65
+ const text = buf.toString('utf8').replace(/^/, '');
66
+ r = { text, lines: text.split('\n').map((l) => l.replace(/\r$/, '')) };
67
+ }
68
+ }
69
+ } catch { r = null; }
70
+ cache.set(rel, r);
71
+ return r;
72
+ };
73
+ return { root: real, files, commit, read, tooBig, git: git.ok };
74
+ }
75
+
76
+ // A citation. `anchor` must be a piece of the cited line (the validator checks it, collapsing whitespace); the
77
+ // scanner always cites the NAME it matched — a package, a key, a route — never a configuration value.
78
+ export function ev(file, line, anchor) {
79
+ return { file, line, text: String(anchor) };
80
+ }
81
+ // A citation of a file as a whole: the first token of its first line that has something on it. Never the line
82
+ // itself: line 1 of a requirements file is often `--extra-index-url https://user:pass@host`. null when the file
83
+ // has no such line, and then there is nothing to cite.
84
+ export function evFirst(file, lines) {
85
+ const i = lines.findIndex((l) => /[^\s]/.test(l));
86
+ if (i < 0) return null;
87
+ const tok = /^\s*([^\s:@/?#=]+)/.exec(lines[i]);
88
+ return ev(file, i + 1, tok ? tok[1] : lines[i].trim()[0]);
89
+ }
90
+ export const evKey = (e) => `${e.file}\u0000${String(e.line).padStart(9, '0')}\u0000${e.text}`;
91
+ export function sortEv(list) {
92
+ const seen = new Set(); const out = [];
93
+ for (const e of [...list].sort((a, b) => (evKey(a) < evKey(b) ? -1 : evKey(a) > evKey(b) ? 1 : 0))) {
94
+ const k = evKey(e); if (!seen.has(k)) { seen.add(k); out.push(e); }
95
+ }
96
+ return out;
97
+ }
98
+
99
+ // The 1-based line of the first line in `lines` matching `re` (a RegExp), from `from` (0-based), or 0.
100
+ export function lineOf(lines, re, from = 0) {
101
+ for (let i = from; i < lines.length; i++) if (re.test(lines[i])) return i + 1;
102
+ return 0;
103
+ }
104
+
105
+ // Files whose lines carry configuration VALUES. Their citations keep the key as the anchor and are never given
106
+ // a sha256: a hash of a line like `DB_PASSWORD=hunter2` is an offline oracle for a weak password.
107
+ export function bearsValues(rel) {
108
+ const b = rel.split('/').pop().toLowerCase();
109
+ return b.startsWith('.env') || /^appsettings.*\.json$/.test(b) || /^requirements[^/]*\.txt$/.test(b) || ['.npmrc', 'pip.conf', '.pypirc', 'setup.cfg'].includes(b) || /^application.*\.(properties|ya?ml)$/.test(b)
110
+ || /^(docker-)?compose.*\.ya?ml$/.test(b) || b === 'dockerfile' || b.endsWith('.dockerfile') || /^secrets?\./.test(b);
111
+ }
112
+
113
+ // A test, a fixture or an example: its routes, URLs and environment reads describe a fake system, not this one.
114
+ export const isTestPath = (rel) => /(^|\/)(tests?|__tests__|spec|specs|fixtures?|e2e|testdata|examples?|[\w-]*-test)(\/|$)|\.(test|spec)\.[\w]+$|(^|\/)test_[^/]*\.py$|_test\.go$|Tests?\.cs$|Tests?\.(java|kt)$/i.test(rel);
115
+ // A host that is this machine or a name reserved for tests and documentation is not an external resource.
116
+ export const isLocalHost = (h) => !h || /^(localhost|127\.\d+\.\d+\.\d+|0\.0\.0\.0|\[::1\]|::1)(:\d*)?$/.test(h) || /\.(invalid|example|test|localhost)(:\d*)?$/.test(h);
117
+
118
+ // A line that must not be hashed and a quote that must be cut: credentials in a URL, or a secret pattern.
119
+ export const CRED_URL = /[a-z][a-z0-9+.-]*:\/\/[^/\s@'"`]*@/i;
120
+ const QUERY_SECRET = /[?&][\w.-]*(key|token|secret|sig|signature|password|pwd|auth)[\w.-]*=/i;
121
+ export function risky(text, patterns) {
122
+ let at = -1;
123
+ for (const re of [CRED_URL, QUERY_SECRET, ...(patterns || [])]) {
124
+ const m = re.exec(text); if (m && (at < 0 || m.index < at)) at = m.index;
125
+ }
126
+ return at;
127
+ }
128
+
129
+ // ---- secret masking -----------------------------------------------------------------------------------------
130
+ // A host is the only part of a connection string or URL the map keeps. Credentials are cut off first; then a host
131
+ // that looks like a secret by Crewforth's own scanner patterns is replaced. Without those patterns every host is
132
+ // replaced: a map with fewer names is a smaller loss than one that published a token.
133
+ export function loadSecretPatterns(fromDir) {
134
+ for (const rel of ['../../../hooks/secret-blocklist.txt', '../../../../hooks/secret-blocklist.txt']) {
135
+ const f = path.join(fromDir, rel);
136
+ let text; try { text = fs.readFileSync(f, 'utf8'); } catch { continue; }
137
+ const out = [];
138
+ for (const raw of text.split('\n')) {
139
+ const l = raw.replace(/\r$/, '');
140
+ if (!l || l.startsWith('#')) continue;
141
+ const js = l.replace(/\[\[:alnum:\]\]/g, '[A-Za-z0-9]').replace(/\[:alnum:\]/g, 'A-Za-z0-9')
142
+ .replace(/\[:space:\]/g, '\\s').replace(/\[:alpha:\]/g, 'A-Za-z').replace(/\[:digit:\]/g, '0-9');
143
+ try { out.push(new RegExp(js)); } catch { /* a pattern JS cannot read is skipped; the rest still apply */ }
144
+ }
145
+ if (out.length) return out;
146
+ }
147
+ return null;
148
+ }
149
+
150
+ export const MASK = '[masked]';
151
+ export function makeHostFilter(patterns) {
152
+ return (raw) => {
153
+ if (!raw) return null;
154
+ let h = String(raw).trim().toLowerCase();
155
+ h = h.replace(/^[a-z][a-z0-9+.-]*:\/\//, '');
156
+ // Userinfo first: a password may hold '/', '?' or '#', so the authority is only known after the last '@'.
157
+ if (h.includes('@')) h = h.slice(h.lastIndexOf('@') + 1);
158
+ h = h.split(/[/?#]/)[0].replace(/:$/, '');
159
+ if (!/^[a-z0-9.\-[\]]+(:\d+)?$/.test(h) || h === '') return MASK;
160
+ // Only the host is kept, so only the host is judged: a token elsewhere in the value is never stored.
161
+ return patterns.some((re) => re.test(h)) ? MASK : h;
162
+ };
163
+ }
@@ -0,0 +1,38 @@
1
+ {
2
+ "about": "Names the detectors must not misread. Each list says why it exists; a name added here silences a detector for it everywhere, so add only what a platform or a framework provides on its own.",
3
+ "platformEnv": {
4
+ "about": "Environment variables a runtime, a shell, a bundler (Vite: MODE, DEV, PROD, SSR, BASE_URL) or a hosting platform sets. Reading one is not a missing configuration key.",
5
+ "names": ["BASE_URL", "CI", "DEV", "DOTNET_ENVIRONMENT", "ASPNETCORE_ENVIRONMENT", "ASPNETCORE_URLS", "GOPATH", "GOOS", "GOARCH", "HOME", "HOSTNAME",
6
+ "LANG", "MODE", "NODE_ENV", "PATH", "PORT", "PROD", "PWD", "PYTHONPATH", "SHELL", "SSR", "TEMP", "TMP", "TMPDIR", "TZ", "USER", "USERPROFILE"]
7
+ },
8
+ "frameworkConfig": {
9
+ "about": "Configuration keys (or key prefixes ending in ':' or '.') a framework reads by itself, so code never names them. Not reported as config-unread.",
10
+ "names": ["AllowedHosts", "Kestrel:", "Logging:", "Serilog:", "ConnectionStrings:", "spring.", "server.", "management.", "logging."]
11
+ },
12
+ "nodeBuiltins": {
13
+ "about": "Node's own modules: an import of one of these is never a dependency. Also matched with a node: prefix.",
14
+ "names": ["assert", "async_hooks", "buffer", "child_process", "cluster", "console", "constants", "crypto", "dgram", "diagnostics_channel",
15
+ "dns", "domain", "events", "fs", "http", "http2", "https", "inspector", "module", "net", "os", "path", "perf_hooks", "process",
16
+ "punycode", "querystring", "readline", "repl", "stream", "string_decoder", "sys", "test", "timers", "tls", "trace_events", "tty",
17
+ "url", "util", "v8", "vm", "wasi", "worker_threads", "zlib"]
18
+ },
19
+ "pythonImportNames": {
20
+ "about": "A distribution whose import name differs from its package name. Without this an installed package reads as unused and its import as undeclared.",
21
+ "map": {
22
+ "beautifulsoup4": "bs4", "pillow": "PIL", "pyyaml": "yaml", "psycopg2-binary": "psycopg2", "python-dotenv": "dotenv",
23
+ "scikit-learn": "sklearn", "opencv-python": "cv2", "pyjwt": "jwt", "python-dateutil": "dateutil", "mysqlclient": "MySQLdb",
24
+ "kafka-python": "kafka", "confluent-kafka": "confluent_kafka", "google-cloud-storage": "google", "azure-storage-blob": "azure",
25
+ "attrs": "attr", "protobuf": "google", "msgpack-python": "msgpack", "typing-extensions": "typing_extensions",
26
+ "pymupdf": "fitz", "faiss-cpu": "faiss", "faiss-gpu": "faiss", "ffmpeg-python": "ffmpeg", "openai-whisper": "whisper",
27
+ "python-magic": "magic", "python-docx": "docx", "python-pptx": "pptx", "python-json-logger": "pythonjsonlogger",
28
+ "pdfminer.six": "pdfminer", "markdown-it-py": "markdown_it", "odfpy": "odf", "lingua-language-detector": "lingua"
29
+ }
30
+ },
31
+ "neverImported": {
32
+ "about": "Packages that work without an import: a server started from the command line, a database driver named in a connection URL, a pytest plugin, a spaCy model loaded by name, a library another one loads (multipart forms, e-mail validation). Declared and never imported, they are not unused. A '*' matches any run of characters. A node entry names the package whose presence makes it so (react-dom under Next.js).",
33
+ "python": ["uvicorn", "gunicorn", "hypercorn", "daphne", "uvloop", "httptools", "watchfiles", "aiosqlite", "asyncpg", "psycopg2",
34
+ "psycopg2-binary", "psycopg-binary", "pymysql", "aiomysql", "mysqlclient", "greenlet", "python-multipart", "email-validator",
35
+ "pytest-*", "*-core-*-sm", "*-core-*-md", "*-core-*-lg", "*-core-*-trf"],
36
+ "node": { "react-dom": "next" }
37
+ }
38
+ }
@@ -0,0 +1,142 @@
1
+ {
2
+ "about": "A package whose presence in a manifest says the module talks to an external resource. One line per package: the ecosystem, the package name as the manifest spells it, the kind (db | cache | queue | http-api | storage | auth | other) and a label that names the resource. Adding a package is one line here; nothing in scan.mjs changes. Maven names are groupId:artifactId.",
3
+ "npm": {
4
+ "pg": ["db", "postgres"],
5
+ "postgres": ["db", "postgres"],
6
+ "mysql": ["db", "mysql"],
7
+ "mysql2": ["db", "mysql"],
8
+ "mongodb": ["db", "mongodb"],
9
+ "mongoose": ["db", "mongodb"],
10
+ "better-sqlite3": ["db", "sqlite"],
11
+ "sqlite3": ["db", "sqlite"],
12
+ "@prisma/client": ["db", "prisma"],
13
+ "redis": ["cache", "redis"],
14
+ "ioredis": ["cache", "redis"],
15
+ "memcached": ["cache", "memcached"],
16
+ "kafkajs": ["queue", "kafka"],
17
+ "amqplib": ["queue", "rabbitmq"],
18
+ "bullmq": ["queue", "redis"],
19
+ "@aws-sdk/client-s3": ["storage", "s3"],
20
+ "@aws-sdk/client-sqs": ["queue", "sqs"],
21
+ "@azure/storage-blob": ["storage", "azure-blob"],
22
+ "@google-cloud/storage": ["storage", "gcs"],
23
+ "@elastic/elasticsearch": ["db", "elasticsearch"],
24
+ "firebase-admin": ["auth", "firebase"],
25
+ "passport": ["auth", "passport"],
26
+ "jsonwebtoken": ["auth", "jwt"]
27
+ },
28
+ "nuget": {
29
+ "Npgsql": ["db", "postgres"],
30
+ "Npgsql.EntityFrameworkCore.PostgreSQL": ["db", "postgres"],
31
+ "Microsoft.EntityFrameworkCore.SqlServer": ["db", "sqlserver"],
32
+ "Microsoft.Data.SqlClient": ["db", "sqlserver"],
33
+ "System.Data.SqlClient": ["db", "sqlserver"],
34
+ "Microsoft.EntityFrameworkCore.Sqlite": ["db", "sqlite"],
35
+ "Pomelo.EntityFrameworkCore.MySql": ["db", "mysql"],
36
+ "MySqlConnector": ["db", "mysql"],
37
+ "MongoDB.Driver": ["db", "mongodb"],
38
+ "Oracle.ManagedDataAccess.Core": ["db", "oracle"],
39
+ "StackExchange.Redis": ["cache", "redis"],
40
+ "Microsoft.Extensions.Caching.StackExchangeRedis": ["cache", "redis"],
41
+ "Confluent.Kafka": ["queue", "kafka"],
42
+ "RabbitMQ.Client": ["queue", "rabbitmq"],
43
+ "MassTransit.RabbitMQ": ["queue", "rabbitmq"],
44
+ "Azure.Messaging.ServiceBus": ["queue", "azure-service-bus"],
45
+ "AWSSDK.S3": ["storage", "s3"],
46
+ "AWSSDK.SQS": ["queue", "sqs"],
47
+ "Azure.Storage.Blobs": ["storage", "azure-blob"],
48
+ "Elastic.Clients.Elasticsearch": ["db", "elasticsearch"],
49
+ "Microsoft.AspNetCore.Authentication.JwtBearer": ["auth", "jwt"],
50
+ "Microsoft.Identity.Web": ["auth", "entra-id"]
51
+ },
52
+ "pypi": {
53
+ "psycopg": ["db", "postgres"],
54
+ "psycopg2": ["db", "postgres"],
55
+ "psycopg2-binary": ["db", "postgres"],
56
+ "asyncpg": ["db", "postgres"],
57
+ "pymysql": ["db", "mysql"],
58
+ "mysqlclient": ["db", "mysql"],
59
+ "pymongo": ["db", "mongodb"],
60
+ "motor": ["db", "mongodb"],
61
+ "sqlalchemy": ["db", "sql"],
62
+ "redis": ["cache", "redis"],
63
+ "pymemcache": ["cache", "memcached"],
64
+ "kafka-python": ["queue", "kafka"],
65
+ "confluent-kafka": ["queue", "kafka"],
66
+ "pika": ["queue", "rabbitmq"],
67
+ "celery": ["queue", "celery"],
68
+ "boto3": ["storage", "aws"],
69
+ "azure-storage-blob": ["storage", "azure-blob"],
70
+ "google-cloud-storage": ["storage", "gcs"],
71
+ "elasticsearch": ["db", "elasticsearch"],
72
+ "pyjwt": ["auth", "jwt"]
73
+ },
74
+ "go": {
75
+ "github.com/jackc/pgx/v5": ["db", "postgres"],
76
+ "github.com/lib/pq": ["db", "postgres"],
77
+ "github.com/go-sql-driver/mysql": ["db", "mysql"],
78
+ "go.mongodb.org/mongo-driver": ["db", "mongodb"],
79
+ "github.com/mattn/go-sqlite3": ["db", "sqlite"],
80
+ "gorm.io/gorm": ["db", "sql"],
81
+ "github.com/redis/go-redis/v9": ["cache", "redis"],
82
+ "github.com/go-redis/redis/v8": ["cache", "redis"],
83
+ "github.com/segmentio/kafka-go": ["queue", "kafka"],
84
+ "github.com/IBM/sarama": ["queue", "kafka"],
85
+ "github.com/rabbitmq/amqp091-go": ["queue", "rabbitmq"],
86
+ "github.com/nats-io/nats.go": ["queue", "nats"],
87
+ "github.com/aws/aws-sdk-go-v2/service/s3": ["storage", "s3"],
88
+ "github.com/golang-jwt/jwt/v5": ["auth", "jwt"]
89
+ },
90
+ "maven": {
91
+ "org.postgresql:postgresql": ["db", "postgres"],
92
+ "com.mysql:mysql-connector-j": ["db", "mysql"],
93
+ "mysql:mysql-connector-java": ["db", "mysql"],
94
+ "com.microsoft.sqlserver:mssql-jdbc": ["db", "sqlserver"],
95
+ "org.mongodb:mongodb-driver-sync": ["db", "mongodb"],
96
+ "org.springframework.boot:spring-boot-starter-data-jpa": ["db", "sql"],
97
+ "org.springframework.boot:spring-boot-starter-data-mongodb": ["db", "mongodb"],
98
+ "org.springframework.boot:spring-boot-starter-data-redis": ["cache", "redis"],
99
+ "redis.clients:jedis": ["cache", "redis"],
100
+ "io.lettuce:lettuce-core": ["cache", "redis"],
101
+ "org.springframework.kafka:spring-kafka": ["queue", "kafka"],
102
+ "org.apache.kafka:kafka-clients": ["queue", "kafka"],
103
+ "org.springframework.boot:spring-boot-starter-amqp": ["queue", "rabbitmq"],
104
+ "com.rabbitmq:amqp-client": ["queue", "rabbitmq"],
105
+ "software.amazon.awssdk:s3": ["storage", "s3"],
106
+ "org.springframework.boot:spring-boot-starter-oauth2-resource-server": ["auth", "oauth2"]
107
+ },
108
+ "images": {
109
+ "about": "A container image (the name before ':' and without a registry) that IS an external resource.",
110
+ "postgres": ["db", "postgres"],
111
+ "mysql": ["db", "mysql"],
112
+ "mariadb": ["db", "mysql"],
113
+ "mongo": ["db", "mongodb"],
114
+ "mcr.microsoft.com/mssql/server": ["db", "sqlserver"],
115
+ "redis": ["cache", "redis"],
116
+ "memcached": ["cache", "memcached"],
117
+ "rabbitmq": ["queue", "rabbitmq"],
118
+ "confluentinc/cp-kafka": ["queue", "kafka"],
119
+ "bitnami/kafka": ["queue", "kafka"],
120
+ "nats": ["queue", "nats"],
121
+ "minio/minio": ["storage", "s3"],
122
+ "elasticsearch": ["db", "elasticsearch"],
123
+ "docker.elastic.co/elasticsearch/elasticsearch": ["db", "elasticsearch"],
124
+ "keycloak/keycloak": ["auth", "keycloak"],
125
+ "quay.io/keycloak/keycloak": ["auth", "keycloak"]
126
+ },
127
+ "schemes": {
128
+ "about": "The scheme of a connection string or URL value, read only to name the kind. The value itself is never stored.",
129
+ "postgres": ["db", "postgres"],
130
+ "postgresql": ["db", "postgres"],
131
+ "mysql": ["db", "mysql"],
132
+ "mongodb": ["db", "mongodb"],
133
+ "mongodb+srv": ["db", "mongodb"],
134
+ "redis": ["cache", "redis"],
135
+ "rediss": ["cache", "redis"],
136
+ "amqp": ["queue", "rabbitmq"],
137
+ "amqps": ["queue", "rabbitmq"],
138
+ "kafka": ["queue", "kafka"],
139
+ "nats": ["queue", "nats"],
140
+ "s3": ["storage", "s3"]
141
+ }
142
+ }