@reneturcios/hookcheck 0.1.7 → 0.1.9

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/dist/core.js CHANGED
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import { existsSync, lstatSync, readdirSync, readFileSync, statSync } from "node:fs";
8
8
  import { dirname, join, relative, resolve, sep } from "node:path";
9
- export const VERSION = "0.1.7";
9
+ export const VERSION = "0.1.9";
10
10
  export const RISKY_RULES = [
11
11
  { id: "DECODE-PIPE-SH", pattern: /\bbase64\s+(-d|--decode|-D)\b[^|]*\|\s*(sudo\s+)?(sh|bash|zsh|node|python3?|perl|ruby)\b/, evidence: "decodes a base64 payload and pipes it straight into an interpreter", severe: true, remote: true },
12
12
  { id: "CURL-PIPE-SH", pattern: /\b(curl|wget)\b[^|]*\|\s*(sudo\s+)?(sh|bash|zsh|node|python3?|perl|ruby)\b/, evidence: "pipes a network download straight into an interpreter", severe: true, remote: true },
@@ -713,6 +713,17 @@ const CRED_ENV_NAME = /(SECRET|TOKEN|PASSWORD|PASSWD|API_?KEY|PRIVATE_?KEY|ACCES
713
713
  function detectEnvAccess(cmd, dataRanges) {
714
714
  let cred = false;
715
715
  let bulk = false;
716
+ // Bulk form tracking (P-091): the evidence sentence must describe
717
+ // the form that actually fired. A spread ({**os.environ, ...}), a
718
+ // dict() or list() copy, or an os.environ.copy() handed on (the
719
+ // environment inheritance idiom, typically as a subprocess env=) is
720
+ // bulk access, but it is not a serialization or a dump, and the
721
+ // v0.1.8 evidence called every bulk form one. bulkInheritanceOnly
722
+ // is true when every bulk hit is an inheritance or copy form and no
723
+ // serialization or dump form (JSON.stringify, printenv, a bare env
724
+ // statement, str(os.environ)) fired.
725
+ let bulkDump = false;
726
+ let bulkCopy = false;
716
727
  const skipSpans = [
717
728
  ...Array.from(cmd.matchAll(/\benv(?:\s+-u\s+[A-Za-z_][A-Za-z0-9_]*)+/g), (m) => ({ start: m.index, end: m.index + m[0].length })),
718
729
  ...Array.from(cmd.matchAll(/\[\[?\s+-[nz]\s+"?\$\{?[A-Za-z_][A-Za-z0-9_]*\}?"?\s*\]\]?/g), (m) => ({ start: m.index, end: m.index + m[0].length })),
@@ -739,14 +750,26 @@ function detectEnvAccess(cmd, dataRanges) {
739
750
  cred = true;
740
751
  }
741
752
  }
742
- if (/(JSON\.stringify|Object\.(keys|values|entries|assign)|util\.inspect)\s*\([^)]*(process\.env|os\.environ)/.test(cmd))
753
+ if (/(JSON\.stringify|Object\.(keys|values|entries|assign)|util\.inspect)\s*\([^)]*(process\.env|os\.environ)/.test(cmd)) {
743
754
  bulk = true;
744
- if (/\bprintenv\b/.test(cmd) && !inRanges(cmd.search(/\bprintenv\b/), dataRanges))
755
+ bulkDump = true;
756
+ }
757
+ if (/\bprintenv\b/.test(cmd) && !inRanges(cmd.search(/\bprintenv\b/), dataRanges)) {
745
758
  bulk = true;
746
- if (/(^|[;&|]\s*)env\s*($|[|>;])/m.test(cmd))
759
+ bulkDump = true;
760
+ }
761
+ if (/(^|[;&|]\s*)env\s*($|[|>;])/m.test(cmd)) {
747
762
  bulk = true;
748
- if (/\b(str|dict|list)\s*\(\s*os\.environ\s*\)/.test(cmd))
763
+ bulkDump = true;
764
+ }
765
+ if (/\bstr\s*\(\s*os\.environ\s*\)/.test(cmd)) {
766
+ bulk = true;
767
+ bulkDump = true;
768
+ }
769
+ if (/\b(?:dict|list)\s*\(\s*os\.environ\s*\)/.test(cmd)) {
749
770
  bulk = true;
771
+ bulkCopy = true;
772
+ }
750
773
  // Whole os.environ use (P-020): scan every occurrence and judge the
751
774
  // operation that follows it, instead of one negative lookahead.
752
775
  const envRe = /\bos\.environ\b/g;
@@ -763,14 +786,90 @@ function detectEnvAccess(cmd, dataRanges) {
763
786
  continue; // write or removal, not a read
764
787
  if (/^\s*\.copy\s*\(/.test(after)) {
765
788
  bulk = true;
789
+ bulkCopy = true;
766
790
  continue;
767
791
  } // whole copy is a bulk read
768
792
  const before = cmd.slice(Math.max(0, em.index - 24), em.index);
769
793
  if (/env\s*=\s*$/.test(before))
770
794
  continue; // subprocess env inheritance
771
795
  bulk = true;
796
+ // A spread occurrence ({**os.environ, ...}) is the inheritance
797
+ // idiom in dict form; anything else here is an unqualified whole
798
+ // environment use, counted with the dump forms for wording.
799
+ if (/\*\*\s*$/.test(before))
800
+ bulkCopy = true;
801
+ else
802
+ bulkDump = true;
772
803
  }
773
- return { cred, bulk };
804
+ return { cred, bulk, bulkInheritanceOnly: bulk && bulkCopy && !bulkDump };
805
+ }
806
+ /**
807
+ * Text ranges of environment inheritance expressions that are passed
808
+ * as a subprocess environment (P-091). Two shapes count: a value used
809
+ * directly as env= or env: (env={**os.environ, ...},
810
+ * env=os.environ.copy()), and a variable assigned from an inheritance
811
+ * expression that is itself passed as env= somewhere in the same
812
+ * text (ENV = {**os.environ, ...}; subprocess.Popen(..., env=ENV)).
813
+ * The same forms in any other position (a request body, a printed
814
+ * value) are NOT inheritance ranges: dict(os.environ) handed to
815
+ * requests.post as data is serialization into a payload and keeps
816
+ * full exfil weight.
817
+ */
818
+ function isInheritanceExpr(expr) {
819
+ const t = expr.trim();
820
+ if (/^(?:os\.environ|process\.env)$/.test(t))
821
+ return true;
822
+ if (/^(?:os\.environ|process\.env)\.copy\(\s*\)$/.test(t))
823
+ return true;
824
+ if (/^dict\(\s*(?:os\.environ|process\.env)\s*\)$/.test(t))
825
+ return true;
826
+ if (/^Object\.assign\(\s*\{\s*\}\s*,\s*(?:process\.env|os\.environ)\b/.test(t))
827
+ return true;
828
+ if (t.startsWith("{") && (/\*\*\s*(?:os\.environ|process\.env)\b/.test(t) || /\.\.\.\s*(?:process\.env|os\.environ)\b/.test(t)))
829
+ return true;
830
+ return false;
831
+ }
832
+ function inheritanceRanges(text) {
833
+ const ranges = [];
834
+ for (const m of text.matchAll(/\benv\s*[:=]\s*/g)) {
835
+ const start = m.index + m[0].length;
836
+ const end = consumeValueExpr(text, start);
837
+ if (end > start && isInheritanceExpr(text.slice(start, end)))
838
+ ranges.push({ start, end });
839
+ }
840
+ const assignRe = /(?:^|[;\n,])\s*(?:export\s+|const\s+|let\s+|var\s+)?([A-Za-z_$][A-Za-z0-9_$]*)\s*=(?![=<>])\s*/g;
841
+ let m;
842
+ while ((m = assignRe.exec(text)) !== null) {
843
+ const rhsStart = m.index + m[0].length;
844
+ const end = consumeValueExpr(text, rhsStart);
845
+ if (end <= rhsStart)
846
+ continue;
847
+ if (!isInheritanceExpr(text.slice(rhsStart, end)))
848
+ continue;
849
+ const name = m[1].replace(/\$/g, "\\$");
850
+ if (new RegExp(`\\benv\\s*[:=]\\s*(?:${name}\\b|\\{[^}]*\\b${name}\\b)`).test(text)) {
851
+ ranges.push({ start: rhsStart, end });
852
+ }
853
+ }
854
+ return ranges;
855
+ }
856
+ /**
857
+ * True when every network destination visible in the text is
858
+ * loopback and at least one destination is visible at all (P-091).
859
+ * Destinations are URL hosts plus netcat target hosts. An unknown
860
+ * destination (a URL held in a variable) is not loopback-only: the
861
+ * discount applies only when the flow's destinations are provably
862
+ * all local, the same standard as the P-004 loopback downgrade.
863
+ */
864
+ function loopbackOnlyDestinations(text) {
865
+ const hosts = [];
866
+ for (const m of text.matchAll(/https?:\/\/([^\s"'`\/:?#]+)/g))
867
+ hosts.push(m[1]);
868
+ for (const m of text.matchAll(/\b(?:nc|netcat)\s+(?:-\S+\s+)*([A-Za-z0-9._-]+)/g))
869
+ hosts.push(m[1]);
870
+ if (hosts.length === 0)
871
+ return false;
872
+ return hosts.every((h) => isLoopbackHost(h));
774
873
  }
775
874
  /**
776
875
  * Credential value atoms: text positions where a credential shaped env
@@ -1076,14 +1175,19 @@ function authRanges(text) {
1076
1175
  return ranges;
1077
1176
  }
1078
1177
  /**
1079
- * Variables assigned (transitively) from a credential value expression.
1178
+ * Variables assigned (transitively) from a credential value expression,
1179
+ * mapped to their inheritance purity (P-091): a variable is pure when
1180
+ * every taint source in its assignment chain is an inheritance atom
1181
+ * or another pure variable (ENV = {**os.environ, ...} is pure; a
1182
+ * payload built by serializing that same environment is not, because
1183
+ * the serializing atom in its own right-hand side is impure).
1080
1184
  * References inside authentication positions do not propagate taint
1081
1185
  * (P-061): a headers dict whose only credential use is its
1082
1186
  * Authorization value is an authentication container, not a tainted
1083
1187
  * payload, and passing it to fetch does not become exfil.
1084
1188
  */
1085
1189
  function taintedVars(text, atoms, dataRanges, auth = []) {
1086
- const tainted = new Set();
1190
+ const tainted = new Map();
1087
1191
  const assignRe = /(?:^|[;\n,])\s*(?:export\s+|const\s+|let\s+|var\s+)?([A-Za-z_$][A-Za-z0-9_$]*)\s*=(?![=<>])\s*/g;
1088
1192
  const assigns = [];
1089
1193
  let m;
@@ -1099,17 +1203,18 @@ function taintedVars(text, atoms, dataRanges, auth = []) {
1099
1203
  for (const a of assigns) {
1100
1204
  if (tainted.has(a.name))
1101
1205
  continue;
1102
- if (live.some((x) => x.start >= a.rhsStart && x.start < a.rhsEnd)) {
1103
- tainted.add(a.name);
1104
- continue;
1206
+ const sources = [];
1207
+ for (const x of live) {
1208
+ if (x.start >= a.rhsStart && x.start < a.rhsEnd)
1209
+ sources.push(x.pure === true);
1105
1210
  }
1106
1211
  const rhs = text.slice(a.rhsStart, a.rhsEnd);
1107
- for (const v of tainted) {
1108
- if (refOutsideRanges(rhs, v, a.rhsStart, [...dataRanges, ...auth])) {
1109
- tainted.add(a.name);
1110
- break;
1111
- }
1212
+ for (const [v, vPure] of tainted) {
1213
+ if (refOutsideRanges(rhs, v, a.rhsStart, [...dataRanges, ...auth]))
1214
+ sources.push(vPure);
1112
1215
  }
1216
+ if (sources.length > 0)
1217
+ tainted.set(a.name, sources.every(Boolean));
1113
1218
  }
1114
1219
  }
1115
1220
  return tainted;
@@ -1143,17 +1248,33 @@ function callSpan(text, openIdx) {
1143
1248
  }
1144
1249
  return { start: openIdx, end: text.length };
1145
1250
  }
1251
+ /**
1252
+ * Taint inside one span, with inheritance purity (P-091): pure is
1253
+ * true only when every taint hit in the span is inheritance purity.
1254
+ * A span carrying even one impure hit (a credential shaped read, a
1255
+ * serialization of the whole environment) is an impure flow.
1256
+ */
1146
1257
  function spanHasTaint(text, span, atoms, tainted, dataRanges, auth = []) {
1147
- if (atoms.some((a) => !a.inData && a.start >= span.start && a.start < span.end))
1148
- return true;
1149
- if (tainted.size === 0)
1150
- return false;
1151
- const seg = text.slice(span.start, span.end);
1152
- for (const v of tainted) {
1153
- if (refOutsideRanges(seg, v, span.start, [...dataRanges, ...auth]))
1154
- return true;
1258
+ let found = false;
1259
+ let pure = true;
1260
+ for (const a of atoms) {
1261
+ if (!a.inData && a.start >= span.start && a.start < span.end) {
1262
+ found = true;
1263
+ if (a.pure !== true)
1264
+ pure = false;
1265
+ }
1266
+ }
1267
+ if (tainted.size > 0) {
1268
+ const seg = text.slice(span.start, span.end);
1269
+ for (const [v, vPure] of tainted) {
1270
+ if (refOutsideRanges(seg, v, span.start, [...dataRanges, ...auth])) {
1271
+ found = true;
1272
+ if (!vPure)
1273
+ pure = false;
1274
+ }
1275
+ }
1155
1276
  }
1156
- return false;
1277
+ return { found, pure };
1157
1278
  }
1158
1279
  /**
1159
1280
  * Exfil value flow (Round 2, P-018). ENV-EXFIL used to fire on mere
@@ -1181,8 +1302,34 @@ function analyzeExfil(text, views, lang) {
1181
1302
  const dataRanges = views.dataRanges;
1182
1303
  const auth = authRanges(text);
1183
1304
  const atoms = findAtoms(text, dataRanges).filter((a) => !inRanges(a.start, auth));
1305
+ // Inheritance purity (P-091): a bulk atom inside an inheritance
1306
+ // expression passed as a subprocess env= is marked pure. Flows are
1307
+ // accumulated with their purity instead of returning at the first
1308
+ // hit: an impure flow fires ENV-EXFIL unconditionally (the v0.1.4
1309
+ // rule), while a flow whose taint is exclusively inheritance purity
1310
+ // fires only when the flow has a non-loopback destination. The
1311
+ // distilled failure was CloudCorpRecords/xod: a test script builds
1312
+ // ENV = {**os.environ, ...}, passes it as env= to the project's own
1313
+ // server and CLI, and every network call is loopback to the server
1314
+ // it just started; the inherited env tainted the subprocess result
1315
+ // variable, whose print next to the loopback urlopen read as exfil.
1316
+ const inhRanges = inheritanceRanges(text);
1317
+ for (const a of atoms) {
1318
+ if (a.kind === "bulk" && inRanges(a.start, inhRanges))
1319
+ a.pure = true;
1320
+ }
1184
1321
  const tainted = taintedVars(text, atoms, dataRanges, auth);
1185
- const has = (span) => spanHasTaint(text, span, atoms, tainted, dataRanges, auth);
1322
+ let sawPureFlow = false;
1323
+ let sawImpureFlow = false;
1324
+ const note = (r) => {
1325
+ if (!r.found)
1326
+ return;
1327
+ if (r.pure)
1328
+ sawPureFlow = true;
1329
+ else
1330
+ sawImpureFlow = true;
1331
+ };
1332
+ const has = (span) => note(spanHasTaint(text, span, atoms, tainted, dataRanges, auth));
1186
1333
  const callRes = [
1187
1334
  /\bfetch\s*\(/g,
1188
1335
  /\baxios(?:\s*\.\s*(?:post|put|patch|get|request|delete))?\s*\(/g,
@@ -1195,8 +1342,7 @@ function analyzeExfil(text, views, lang) {
1195
1342
  for (const re of callRes) {
1196
1343
  let m;
1197
1344
  while ((m = re.exec(text)) !== null) {
1198
- if (has(callSpan(text, m.index + m[0].length - 1)))
1199
- return true;
1345
+ has(callSpan(text, m.index + m[0].length - 1));
1200
1346
  }
1201
1347
  }
1202
1348
  // Fetch positions run on the code view with data blanked (indices
@@ -1215,8 +1361,7 @@ function analyzeExfil(text, views, lang) {
1215
1361
  }
1216
1362
  if (!isFetchStmt)
1217
1363
  continue;
1218
- if (has(stmt))
1219
- return true;
1364
+ has(stmt);
1220
1365
  }
1221
1366
  for (const stmt of statements) {
1222
1367
  const stmtText = text.slice(stmt.start, stmt.end);
@@ -1232,8 +1377,7 @@ function analyzeExfil(text, views, lang) {
1232
1377
  continue;
1233
1378
  for (let e = 0; e < k; e++) {
1234
1379
  const early = { start: stmt.start + segments[e].start, end: stmt.start + segments[e].end };
1235
- if (has(early))
1236
- return true;
1380
+ has(early);
1237
1381
  }
1238
1382
  }
1239
1383
  }
@@ -1249,27 +1393,35 @@ function analyzeExfil(text, views, lang) {
1249
1393
  if (hasSendConstruct) {
1250
1394
  for (const m of text.matchAll(/(?:^|[;\n|&]\s*)(?:echo|printf)\s+([^\n;|]*)/g)) {
1251
1395
  const span = { start: m.index, end: m.index + m[0].length };
1252
- if (atoms.some((a) => a.start >= span.start && a.start < span.end))
1253
- return true;
1396
+ for (const a of atoms) {
1397
+ if (a.start >= span.start && a.start < span.end)
1398
+ note({ found: true, pure: a.pure === true });
1399
+ }
1254
1400
  const argText = m[1] ?? "";
1255
1401
  const argStart = m.index + m[0].length - argText.length;
1256
- for (const v of tainted) {
1402
+ for (const [v, vPure] of tainted) {
1257
1403
  if (lang === "shell" || refOutsideRanges(argText, v, argStart, [...dataRanges, ...auth]))
1258
- return true;
1404
+ note({ found: true, pure: vPure });
1259
1405
  }
1260
1406
  }
1261
1407
  for (const m of text.matchAll(/\b(?:console\.(?:log|info|debug)|print)\s*\(([^)]*)\)/g)) {
1262
1408
  const span = { start: m.index, end: m.index + m[0].length };
1263
- if (atoms.some((a) => a.start >= span.start && a.start < span.end && (lang === "shell" || !a.inData)))
1264
- return true;
1409
+ for (const a of atoms) {
1410
+ if (a.start >= span.start && a.start < span.end && (lang === "shell" || !a.inData))
1411
+ note({ found: true, pure: a.pure === true });
1412
+ }
1265
1413
  const argText = m[1] ?? "";
1266
1414
  const argStart = m.index + m[0].indexOf(argText);
1267
- for (const v of tainted) {
1415
+ for (const [v, vPure] of tainted) {
1268
1416
  if (lang === "shell" || refOutsideRanges(argText, v, argStart, [...dataRanges, ...auth]))
1269
- return true;
1417
+ note({ found: true, pure: vPure });
1270
1418
  }
1271
1419
  }
1272
1420
  }
1421
+ if (sawImpureFlow)
1422
+ return true;
1423
+ if (sawPureFlow)
1424
+ return !loopbackOnlyDestinations(text);
1273
1425
  return false;
1274
1426
  }
1275
1427
  function isLoopbackHost(host) {
@@ -1388,7 +1540,12 @@ export function classifyCommand(command, lang = "shell") {
1388
1540
  fired.push({ id: "ENV-ACCESS", evidence: "reads an environment variable whose name is credential shaped (token, secret, password, api key)" });
1389
1541
  }
1390
1542
  if (env.bulk) {
1391
- fired.push({ id: "ENV-BULK", evidence: "accesses the whole environment at once (serialization, printenv, or env dump)" });
1543
+ fired.push({
1544
+ id: "ENV-BULK",
1545
+ evidence: env.bulkInheritanceOnly
1546
+ ? "accesses the whole environment at once in inheritance or copy form (a spread, dict(), list(), or copy() of the whole environment handed on, for example as a subprocess environment); bulk access, not by itself a serialization or dump"
1547
+ : "accesses the whole environment at once (serialization, printenv, or env dump)",
1548
+ });
1392
1549
  }
1393
1550
  // Exfil is a value flow, not a co-occurrence (P-018): the credential
1394
1551
  // value must reach a send. Token as authentication does not count.
@@ -1684,7 +1841,30 @@ function makeFinding(root, file, source, hook, command, idSuffix, line, baseAbs
1684
1841
  };
1685
1842
  return escalateWithLocalFile(root, base, baseAbs);
1686
1843
  }
1844
+ /**
1845
+ * Stat-type guard (P-069). A file the scanner has decided to read is
1846
+ * checked by stat type BEFORE it is opened, and only regular files are
1847
+ * ever opened. Opening a FIFO (named pipe) blocks until a writer
1848
+ * appears, so a pipe planted at a surface path (.github/workflows/
1849
+ * pipe.yml, a package.json, an .mcp.json) hung the scan forever with
1850
+ * no internal timeout to catch it; sockets and device files are the
1851
+ * same hazard. injectcheck already stats before opening; every read
1852
+ * path in hookcheck now goes through readText or readTextWarn, which
1853
+ * enforce the check centrally, and the per surface scanners that stat
1854
+ * first (Claude settings, husky and githooks files, agent dirs, the
1855
+ * one hop follower) keep their own isFile checks as well.
1856
+ */
1857
+ function statIsRegularFile(abs) {
1858
+ try {
1859
+ return statSync(abs).isFile();
1860
+ }
1861
+ catch {
1862
+ return null;
1863
+ }
1864
+ }
1687
1865
  function readText(abs) {
1866
+ if (statIsRegularFile(abs) === false)
1867
+ return null;
1688
1868
  try {
1689
1869
  return readFileSync(abs, "utf8");
1690
1870
  }
@@ -1701,6 +1881,10 @@ function readText(abs) {
1701
1881
  * presents as a complete all-clear.
1702
1882
  */
1703
1883
  function readTextWarn(abs, rel, warnings) {
1884
+ if (statIsRegularFile(abs) === false) {
1885
+ warnings.push(`Non-regular file skipped: ${rel} is not a regular file (FIFO, socket, or device); it was not opened or inventoried`);
1886
+ return null;
1887
+ }
1704
1888
  try {
1705
1889
  return readFileSync(abs, "utf8");
1706
1890
  }
@@ -1763,21 +1947,65 @@ function scanOnePackageJson(root, rel, findings, warnings) {
1763
1947
  }
1764
1948
  }
1765
1949
  /**
1766
- * Nested package.json discovery (P-032). Install scripts in workspace
1767
- * subpackages (packages/foo, apps/web) run on a root install, so a
1768
- * root-only scan misses exactly the surface a worm would persist in.
1769
- * The walk is bounded so a hostile or giant tree cannot explode the
1770
- * report: directories are visited in sorted order to depth
1771
- * PACKAGE_MAX_DEPTH (the package.json directory depth below the root),
1772
- * node_modules, .git, and dot directories are never entered, symlinked
1773
- * directories are never followed (a symlink loop cannot hang the scan),
1774
- * and at most PACKAGE_MAX_NESTED nested files are inventoried, with the
1775
- * cap reported as a warning when it is hit.
1950
+ * Unified nested discovery (P-032, P-060, P-065). Every surface the
1951
+ * tool inventories at the scan root is also inventoried when it
1952
+ * appears inside a subdirectory: before v0.1.8 only nested package.json
1953
+ * files (P-032) and plugin hooks files (P-060) were recursed, so a
1954
+ * parent scan of a monorepo or workspace silently missed nested
1955
+ * .claude/settings.json, .vscode/tasks.json, workflows, husky and
1956
+ * githooks directories, lefthook and pre-commit configs, and MCP
1957
+ * configs, and a tree holding only nested surfaces scanned CLEAN with
1958
+ * the empty estate note attached (P-065).
1959
+ *
1960
+ * One bounded walk enumerates every project root (each directory at
1961
+ * most NESTED_MAX_DEPTH levels below the scan root, the scan root
1962
+ * itself included) plus the nested package.json and plugin hooks file
1963
+ * lists. Per surface scanners then run once per project root with the
1964
+ * root prefix carried into every finding file path, so findings carry
1965
+ * their real relative paths. The walk is bounded so a hostile or
1966
+ * giant tree cannot explode the report: directories are visited in
1967
+ * sorted order, node_modules, .git, and dot directories are never
1968
+ * entered, symlinked directories are never followed (a symlink loop
1969
+ * cannot hang the scan), and each surface family inventories at most
1970
+ * NESTED_MAX_FILES_PER_SURFACE nested files, with the cap reported as
1971
+ * a warning when it is hit. When directories remain below the depth
1972
+ * bound the walk says so in a warning naming the bound, and it probes
1973
+ * the directories just below the bound (names only, no file reads) so
1974
+ * surfaces sitting there are counted in the warning instead of being
1975
+ * silently dropped.
1976
+ *
1977
+ * The bound itself was raised from 4 to 8 in v0.1.9 (P-072): at depth
1978
+ * 4, realistic monorepo nesting (apps/<team>/<app>, packages grouped
1979
+ * by area, fixture trees) sat below the bound, so a package.json
1980
+ * dropper at depth 5 floored at REVIEW via the probe and a depth 8
1981
+ * chain verdicted CLEAN with only the warning. Depth 8 covers those
1982
+ * layouts; the walk cost stays linear in the number of directories
1983
+ * actually present, and the 2,000 file tree and 5 MB workflow perf
1984
+ * spot checks for v0.1.9 showed no material regression against
1985
+ * v0.1.8. Whatever bound remains keeps the warning and the
1986
+ * never-bare-CLEAN rule when the probe detects surface below it.
1776
1987
  */
1777
- export const PACKAGE_MAX_DEPTH = 4;
1778
- export const PACKAGE_MAX_NESTED = 200;
1779
- function discoverNestedPackageJson(root, warnings) {
1780
- const found = [];
1988
+ export const NESTED_MAX_DEPTH = 8;
1989
+ export const NESTED_MAX_FILES_PER_SURFACE = 200;
1990
+ export const PACKAGE_MAX_DEPTH = NESTED_MAX_DEPTH;
1991
+ export const PACKAGE_MAX_NESTED = NESTED_MAX_FILES_PER_SURFACE;
1992
+ export const PLUGIN_HOOKS_MAX_DEPTH = NESTED_MAX_DEPTH;
1993
+ export const PLUGIN_HOOKS_MAX_FILES = NESTED_MAX_FILES_PER_SURFACE;
1994
+ const SURFACE_FILE_INDICATORS = new Set([
1995
+ "package.json", "lefthook.yml", "lefthook.yaml", ".lefthook.yml", ".lefthook.yaml",
1996
+ ".pre-commit-config.yaml", ".pre-commit-config.yml", ".mcp.json", "mcp.json",
1997
+ ]);
1998
+ const SURFACE_DIR_INDICATORS = new Set([
1999
+ ".claude", "claude", ".vscode", ".github", ".husky", ".githooks",
2000
+ "hooks", ".cursor", ".windsurf", ".codex",
2001
+ ]);
2002
+ function discoverSurfaces(root, warnings) {
2003
+ const projectRoots = [];
2004
+ const packageFiles = [];
2005
+ const pluginSet = new Set();
2006
+ let frontierDirs = 0;
2007
+ let surfacesBelowBound = 0;
2008
+ let probes = 0;
1781
2009
  const skipDirs = new Set(["node_modules", ".git"]);
1782
2010
  const stack = [{ abs: root, rel: "", depth: 0 }];
1783
2011
  while (stack.length > 0) {
@@ -1792,14 +2020,38 @@ function discoverNestedPackageJson(root, warnings) {
1792
2020
  continue;
1793
2021
  }
1794
2022
  entries.sort();
2023
+ projectRoots.push(cur.rel);
1795
2024
  if (cur.depth >= 1 && entries.includes("package.json")) {
1796
- found.push(cur.rel === "" ? "package.json" : `${cur.rel}/package.json`);
2025
+ packageFiles.push(`${cur.rel}/package.json`);
2026
+ }
2027
+ // Plugin hooks files sit in a hooks directory one level below the
2028
+ // plugin root, so they are spotted from the plugin root listing
2029
+ // (the hooks directory itself can sit at depth bound plus one).
2030
+ if (entries.includes("hooks")) {
2031
+ const hooksAbs = join(cur.abs, "hooks");
2032
+ let lst = null;
2033
+ try {
2034
+ lst = lstatSync(hooksAbs);
2035
+ }
2036
+ catch {
2037
+ lst = null;
2038
+ }
2039
+ if (lst && lst.isDirectory() && !lst.isSymbolicLink()) {
2040
+ let hookEntries = [];
2041
+ try {
2042
+ hookEntries = readdirSync(hooksAbs);
2043
+ }
2044
+ catch {
2045
+ hookEntries = [];
2046
+ }
2047
+ for (const name of ["hooks.json", "hooks-cursor.json"]) {
2048
+ if (hookEntries.includes(name))
2049
+ pluginSet.add(cur.rel === "" ? `hooks/${name}` : `${cur.rel}/hooks/${name}`);
2050
+ }
2051
+ }
1797
2052
  }
1798
- if (cur.depth >= PACKAGE_MAX_DEPTH)
1799
- continue;
1800
- // Push in reverse so the stack pops in sorted order.
1801
- for (let i = entries.length - 1; i >= 0; i--) {
1802
- const name = entries[i];
2053
+ const children = [];
2054
+ for (const name of entries) {
1803
2055
  if (skipDirs.has(name) || name.startsWith("."))
1804
2056
  continue;
1805
2057
  const childAbs = join(cur.abs, name);
@@ -1812,19 +2064,84 @@ function discoverNestedPackageJson(root, warnings) {
1812
2064
  }
1813
2065
  if (lst.isSymbolicLink() || !lst.isDirectory())
1814
2066
  continue;
1815
- stack.push({ abs: childAbs, rel: cur.rel === "" ? name : `${cur.rel}/${name}`, depth: cur.depth + 1 });
2067
+ children.push({ abs: childAbs, rel: cur.rel === "" ? name : `${cur.rel}/${name}`, depth: cur.depth + 1 });
2068
+ }
2069
+ if (cur.depth >= NESTED_MAX_DEPTH) {
2070
+ frontierDirs += children.length;
2071
+ for (const child of children) {
2072
+ if (probes >= 1000)
2073
+ break;
2074
+ probes++;
2075
+ let sub = [];
2076
+ try {
2077
+ sub = readdirSync(child.abs);
2078
+ }
2079
+ catch {
2080
+ continue;
2081
+ }
2082
+ if (sub.some((n) => SURFACE_FILE_INDICATORS.has(n) || SURFACE_DIR_INDICATORS.has(n)))
2083
+ surfacesBelowBound++;
2084
+ }
2085
+ continue;
1816
2086
  }
2087
+ // Push in reverse so the stack pops in sorted order.
2088
+ for (let i = children.length - 1; i >= 0; i--)
2089
+ stack.push(children[i]);
1817
2090
  }
1818
- found.sort();
1819
- if (found.length > PACKAGE_MAX_NESTED) {
1820
- warnings.push(`Nested package.json inventory capped at ${PACKAGE_MAX_NESTED} files (${found.length} found); the remaining nested package.json files were not inventoried and the scan is partial`);
1821
- return found.slice(0, PACKAGE_MAX_NESTED);
2091
+ projectRoots.sort((a, b) => (a === b ? 0 : a === "" ? -1 : b === "" ? 1 : a < b ? -1 : 1));
2092
+ packageFiles.sort();
2093
+ return {
2094
+ projectRoots,
2095
+ packageFiles,
2096
+ pluginHookFiles: Array.from(pluginSet).sort(),
2097
+ frontierDirs,
2098
+ surfacesBelowBound,
2099
+ depthBoundHit: frontierDirs > 0,
2100
+ };
2101
+ }
2102
+ const FAMILY_LABELS = {
2103
+ settings: "Claude settings",
2104
+ tasks: "VS Code tasks",
2105
+ workflows: "workflow",
2106
+ husky: "husky hook",
2107
+ githooks: "githooks hook",
2108
+ lefthook: "lefthook config",
2109
+ "pre-commit": "pre-commit config",
2110
+ mcp: "MCP config",
2111
+ agent: "agent config",
2112
+ };
2113
+ /**
2114
+ * Claim one surface file for inventory. Returns false when the file
2115
+ * was already inventoried through another discovery path, or when the
2116
+ * nested file cap for its family is already spent (the skip is
2117
+ * counted, and scanHooks warns once per capped family).
2118
+ */
2119
+ function claimFile(state, family, rel, isNested) {
2120
+ if (state.inventoried.has(rel))
2121
+ return false;
2122
+ if (isNested) {
2123
+ const found = (state.familyFound.get(family) ?? 0) + 1;
2124
+ state.familyFound.set(family, found);
2125
+ if (found > NESTED_MAX_FILES_PER_SURFACE) {
2126
+ state.familyCapped.set(family, (state.familyCapped.get(family) ?? 0) + 1);
2127
+ state.partial = true;
2128
+ return false;
2129
+ }
1822
2130
  }
1823
- return found;
2131
+ state.inventoried.add(rel);
2132
+ return true;
1824
2133
  }
1825
- function scanPackageJson(root, findings, warnings) {
2134
+ function scanPackageJson(root, findings, warnings, state) {
1826
2135
  scanOnePackageJson(root, "package.json", findings, warnings);
1827
- for (const rel of discoverNestedPackageJson(root, warnings)) {
2136
+ state.inventoried.add("package.json");
2137
+ let nested = state.disc.packageFiles;
2138
+ if (nested.length > PACKAGE_MAX_NESTED) {
2139
+ warnings.push(`Nested package.json inventory capped at ${PACKAGE_MAX_NESTED} files (${nested.length} found); the remaining nested package.json files were not inventoried and the scan is partial`);
2140
+ state.partial = true;
2141
+ nested = nested.slice(0, PACKAGE_MAX_NESTED);
2142
+ }
2143
+ for (const rel of nested) {
2144
+ state.inventoried.add(rel);
1828
2145
  scanOnePackageJson(root, rel, findings, warnings);
1829
2146
  }
1830
2147
  }
@@ -1850,16 +2167,22 @@ function collectClaudeHooks(node, file, root, findings, prefix, source = "claude
1850
2167
  * the GNU stow layout used by real dotfiles repos (claude/settings.json,
1851
2168
  * no dot), and a settings file nested one level under a config dir
1852
2169
  * (config/.claude/settings.json). A settings path that exists as a
1853
- * directory is reported as a warning, never silently skipped.
2170
+ * directory is reported as a warning, never silently skipped. Since
2171
+ * v0.1.8 (P-065) the candidates are computed per project root, so the
2172
+ * same locations are inventoried in every subdirectory within the
2173
+ * discovery bound; the inventoried set in ScanState keeps a file
2174
+ * reachable by two paths (a parent root one level candidate and its
2175
+ * own project root) counted exactly once.
1854
2176
  */
1855
- function claudeSettingsCandidates(root) {
2177
+ function claudeSettingsCandidates(root, prefix) {
2178
+ const base = prefix === "" ? "" : `${prefix}/`;
1856
2179
  const rels = [
1857
- ".claude/settings.json", ".claude/settings.local.json",
1858
- "claude/settings.json", "claude/settings.local.json",
2180
+ `${base}.claude/settings.json`, `${base}.claude/settings.local.json`,
2181
+ `${base}claude/settings.json`, `${base}claude/settings.local.json`,
1859
2182
  ];
1860
2183
  let entries = [];
1861
2184
  try {
1862
- entries = readdirSync(root);
2185
+ entries = readdirSync(join(root, prefix));
1863
2186
  }
1864
2187
  catch {
1865
2188
  entries = [];
@@ -1869,7 +2192,7 @@ function claudeSettingsCandidates(root) {
1869
2192
  continue;
1870
2193
  let st;
1871
2194
  try {
1872
- st = statSync(join(root, name));
2195
+ st = statSync(join(root, prefix, name));
1873
2196
  }
1874
2197
  catch {
1875
2198
  continue;
@@ -1877,42 +2200,50 @@ function claudeSettingsCandidates(root) {
1877
2200
  if (!st.isDirectory())
1878
2201
  continue;
1879
2202
  for (const f of ["settings.json", "settings.local.json"]) {
1880
- rels.push(`${name}/.claude/${f}`);
2203
+ rels.push(`${base}${name}/.claude/${f}`);
1881
2204
  }
1882
2205
  }
1883
2206
  return Array.from(new Set(rels));
1884
2207
  }
1885
- function scanClaude(root, findings, warnings) {
1886
- for (const rel of claudeSettingsCandidates(root)) {
1887
- const abs = join(root, rel);
1888
- if (!existsSync(abs))
1889
- continue;
1890
- let st;
1891
- try {
1892
- st = statSync(abs);
1893
- }
1894
- catch {
1895
- continue;
1896
- }
1897
- if (st.isDirectory()) {
1898
- warnings.push(`${rel} is a directory, not a settings file: any hooks inside it cannot be parsed as Claude settings`);
1899
- continue;
1900
- }
1901
- if (!st.isFile())
1902
- continue;
1903
- const data = readJson(root, rel, warnings);
1904
- if (!data || typeof data !== "object")
1905
- continue;
1906
- if (!("hooks" in data)) {
1907
- warnings.push(`No hooks key in ${rel}`);
1908
- continue;
1909
- }
1910
- const hooks = data.hooks;
1911
- if (!hooks || typeof hooks !== "object") {
1912
- warnings.push(`hooks key in ${rel} is not an object: hooks skipped`);
1913
- continue;
2208
+ function scanClaude(root, findings, warnings, state) {
2209
+ for (const prefix of state.disc.projectRoots) {
2210
+ const baseAbs = prefix === "" ? root : join(root, prefix);
2211
+ for (const rel of claudeSettingsCandidates(root, prefix)) {
2212
+ const abs = join(root, rel);
2213
+ if (!existsSync(abs))
2214
+ continue;
2215
+ let st;
2216
+ try {
2217
+ st = statSync(abs);
2218
+ }
2219
+ catch {
2220
+ continue;
2221
+ }
2222
+ if (st.isDirectory()) {
2223
+ if (!state.inventoried.has(rel)) {
2224
+ state.inventoried.add(rel);
2225
+ warnings.push(`${rel} is a directory, not a settings file: any hooks inside it cannot be parsed as Claude settings`);
2226
+ }
2227
+ continue;
2228
+ }
2229
+ if (!st.isFile())
2230
+ continue;
2231
+ if (!claimFile(state, "settings", rel, prefix !== ""))
2232
+ continue;
2233
+ const data = readJson(root, rel, warnings);
2234
+ if (!data || typeof data !== "object")
2235
+ continue;
2236
+ if (!("hooks" in data)) {
2237
+ warnings.push(`No hooks key in ${rel}`);
2238
+ continue;
2239
+ }
2240
+ const hooks = data.hooks;
2241
+ if (!hooks || typeof hooks !== "object") {
2242
+ warnings.push(`hooks key in ${rel} is not an object: hooks skipped`);
2243
+ continue;
2244
+ }
2245
+ collectClaudeHooks(hooks, rel, root, findings, "", "claude-settings", baseAbs);
1914
2246
  }
1915
- collectClaudeHooks(hooks, rel, root, findings, "");
1916
2247
  }
1917
2248
  }
1918
2249
  /**
@@ -1933,67 +2264,21 @@ function scanClaude(root, findings, warnings) {
1933
2264
  * command object shape under Cursor event names, so it is inventoried
1934
2265
  * the same way.
1935
2266
  *
1936
- * The walk mirrors the nested package.json discipline (P-032): sorted
1937
- * order, node_modules, .git, and dot directories never entered,
1938
- * symlinked directories never followed, and a cap with a warning. The
1939
- * depth bound applies to the plugin root (at most PLUGIN_HOOKS_MAX_DEPTH
1940
- * directories below the scan root); the hooks directory itself sits one
1941
- * level below its plugin root.
2267
+ * Discovery is part of the unified bounded walk (P-065): the file
2268
+ * list comes from discoverSurfaces, with the plugin root at most
2269
+ * PLUGIN_HOOKS_MAX_DEPTH directories below the scan root (the hooks
2270
+ * directory itself sits one level below its plugin root), and the
2271
+ * same cap with a warning applies.
1942
2272
  */
1943
- export const PLUGIN_HOOKS_MAX_DEPTH = 4;
1944
- export const PLUGIN_HOOKS_MAX_FILES = 200;
1945
- function discoverPluginHookFiles(root, warnings) {
1946
- const found = [];
1947
- const skipDirs = new Set(["node_modules", ".git"]);
1948
- const maxHooksDirDepth = PLUGIN_HOOKS_MAX_DEPTH + 1;
1949
- const stack = [{ abs: root, rel: "", depth: 0 }];
1950
- while (stack.length > 0) {
1951
- const cur = stack.pop();
1952
- let entries = [];
1953
- try {
1954
- entries = readdirSync(cur.abs);
1955
- }
1956
- catch {
1957
- if (cur.rel)
1958
- warnings.push(`Unreadable directory skipped: ${cur.rel} could not be listed; the scan is partial`);
1959
- continue;
1960
- }
1961
- entries.sort();
1962
- const base = cur.rel === "" ? "" : (cur.rel.split("/").pop() ?? "");
1963
- if (cur.depth >= 1 && base === "hooks") {
1964
- for (const name of ["hooks.json", "hooks-cursor.json"]) {
1965
- if (entries.includes(name))
1966
- found.push(cur.rel === "" ? name : `${cur.rel}/${name}`);
1967
- }
1968
- }
1969
- if (cur.depth >= maxHooksDirDepth)
1970
- continue;
1971
- for (let i = entries.length - 1; i >= 0; i--) {
1972
- const name = entries[i];
1973
- if (skipDirs.has(name) || name.startsWith("."))
1974
- continue;
1975
- const childAbs = join(cur.abs, name);
1976
- let lst;
1977
- try {
1978
- lst = lstatSync(childAbs);
1979
- }
1980
- catch {
1981
- continue;
1982
- }
1983
- if (lst.isSymbolicLink() || !lst.isDirectory())
1984
- continue;
1985
- stack.push({ abs: childAbs, rel: cur.rel === "" ? name : `${cur.rel}/${name}`, depth: cur.depth + 1 });
1986
- }
1987
- }
1988
- found.sort();
1989
- if (found.length > PLUGIN_HOOKS_MAX_FILES) {
1990
- warnings.push(`Plugin hooks inventory capped at ${PLUGIN_HOOKS_MAX_FILES} files (${found.length} found); the remaining plugin hook files were not inventoried and the scan is partial`);
1991
- return found.slice(0, PLUGIN_HOOKS_MAX_FILES);
1992
- }
1993
- return found;
1994
- }
1995
- function scanPluginHooks(root, findings, warnings) {
1996
- for (const rel of discoverPluginHookFiles(root, warnings)) {
2273
+ function scanPluginHooks(root, findings, warnings, state) {
2274
+ let files = state.disc.pluginHookFiles;
2275
+ if (files.length > PLUGIN_HOOKS_MAX_FILES) {
2276
+ warnings.push(`Plugin hooks inventory capped at ${PLUGIN_HOOKS_MAX_FILES} files (${files.length} found); the remaining plugin hook files were not inventoried and the scan is partial`);
2277
+ state.partial = true;
2278
+ files = files.slice(0, PLUGIN_HOOKS_MAX_FILES);
2279
+ }
2280
+ for (const rel of files) {
2281
+ state.inventoried.add(rel);
1997
2282
  const data = readJson(root, rel, warnings);
1998
2283
  if (!data || typeof data !== "object" || Array.isArray(data))
1999
2284
  continue;
@@ -2009,129 +2294,48 @@ function scanPluginHooks(root, findings, warnings) {
2009
2294
  collectClaudeHooks(eventsNode, rel, root, findings, "", "claude-plugin", pluginRootAbs);
2010
2295
  }
2011
2296
  }
2012
- function scanVscode(root, findings, warnings) {
2013
- const rel = ".vscode/tasks.json";
2014
- if (existsSync(join(root, rel))) {
2015
- const data = readJson(root, rel, warnings);
2016
- if (data && data.tasks !== undefined && !Array.isArray(data.tasks)) {
2017
- warnings.push(`tasks key in ${rel} is not an array: tasks skipped`);
2018
- }
2019
- const tasks = Array.isArray(data?.tasks) ? data.tasks : [];
2020
- tasks.forEach((rawTask, idx) => {
2021
- // Malformed entries (null, strings, numbers) are skipped, never fatal:
2022
- // one bad task must not hide every other surface in the repo.
2023
- if (!rawTask || typeof rawTask !== "object")
2024
- return;
2025
- const task = rawTask;
2026
- const runOptions = (task.runOptions ?? {});
2027
- const options = (task.options ?? {});
2028
- const optionsRunOptions = (options.runOptions ?? {});
2029
- // folderOpen lives in runOptions in the standard schema, but real
2030
- // world files also nest it under options or put runOn on the task.
2031
- const runOn = runOptions.runOn ?? optionsRunOptions.runOn ?? task.runOn;
2032
- const isFolderOpen = runOn === "folderOpen" || (Array.isArray(runOn) && runOn.includes("folderOpen"));
2033
- const command = typeof task.command === "string" ? task.command : "";
2034
- const args = Array.isArray(task.args) ? task.args.filter((a) => typeof a === "string").join(" ") : "";
2035
- const full = [command, args].filter(Boolean).join(" ").trim();
2036
- if (!full)
2037
- return;
2038
- const label = typeof task.label === "string" ? task.label : `task-${idx}`;
2039
- if (isFolderOpen) {
2040
- findings.push(makeFinding(root, rel, "vscode-tasks", `folderOpen:${label}`, full, String(idx)));
2297
+ function scanVscode(root, findings, warnings, state) {
2298
+ for (const prefix of state.disc.projectRoots) {
2299
+ const base = prefix === "" ? "" : `${prefix}/`;
2300
+ const baseAbs = prefix === "" ? root : join(root, prefix);
2301
+ const rel = `${base}.vscode/tasks.json`;
2302
+ if (existsSync(join(root, rel)) && claimFile(state, "tasks", rel, prefix !== "")) {
2303
+ const data = readJson(root, rel, warnings);
2304
+ if (data && data.tasks !== undefined && !Array.isArray(data.tasks)) {
2305
+ warnings.push(`tasks key in ${rel} is not an array: tasks skipped`);
2041
2306
  }
2042
- else {
2043
- // Inventory non auto-run tasks at lower weight: still record them so
2044
- // explain can find them, classified by their command.
2045
- const f = makeFinding(root, rel, "vscode-tasks", label, full, String(idx));
2046
- findings.push(f);
2047
- }
2048
- });
2049
- }
2050
- // MCP config inside .vscode
2051
- scanMcpFile(root, ".vscode/mcp.json", findings, warnings);
2052
- }
2053
- function scanDirFiles(root, dirRel, source, findings, warnings) {
2054
- const abs = join(root, dirRel);
2055
- if (!existsSync(abs))
2056
- return;
2057
- let entries = [];
2058
- try {
2059
- entries = readdirSync(abs);
2060
- }
2061
- catch {
2062
- warnings.push(`Unreadable directory skipped: ${dirRel} could not be listed; the scan is partial`);
2063
- return;
2064
- }
2065
- for (const name of entries.sort()) {
2066
- const fileAbs = join(abs, name);
2067
- let st;
2068
- try {
2069
- st = statSync(fileAbs);
2070
- }
2071
- catch {
2072
- continue;
2073
- }
2074
- if (!st.isFile())
2075
- continue;
2076
- if (name === "_" || name.startsWith("."))
2077
- continue;
2078
- const content = readTextWarn(fileAbs, `${dirRel}/${name}`, warnings);
2079
- if (content === null)
2080
- continue;
2081
- const firstMeaningful = content.split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#") && !l.startsWith("set -")).join(" ");
2082
- const fileRel = `${dirRel}/${name}`;
2083
- // Entire file content is the command surface for husky style hooks.
2084
- // Classify generously deep into the file: a payload hidden behind
2085
- // padding must still be seen. Display code truncates separately.
2086
- findings.push(makeFinding(root, fileRel, source, name, content.trim().slice(0, 200000)));
2087
- void firstMeaningful;
2088
- }
2089
- }
2090
- function scanLefthook(root, findings, warnings) {
2091
- for (const rel of ["lefthook.yml", "lefthook.yaml", ".lefthook.yml"]) {
2092
- if (!existsSync(join(root, rel)))
2093
- continue;
2094
- const raw = (readTextWarn(join(root, rel), rel, warnings) ?? "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
2095
- const runRe = /^\s*run:\s*(.+)$/gm;
2096
- let m;
2097
- let i = 0;
2098
- while ((m = runRe.exec(raw)) !== null) {
2099
- findings.push(makeFinding(root, rel, "lefthook", "run", m[1].trim().replace(/^["']|["']$/g, ""), String(i++)));
2100
- }
2101
- }
2102
- }
2103
- function scanPreCommit(root, findings, warnings) {
2104
- const rel = ".pre-commit-config.yaml";
2105
- if (!existsSync(join(root, rel)))
2106
- return;
2107
- const raw = (readTextWarn(join(root, rel), rel, warnings) ?? "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
2108
- const entryRe = /^\s*entry:\s*(.+)$/gm;
2109
- let m;
2110
- let i = 0;
2111
- while ((m = entryRe.exec(raw)) !== null) {
2112
- findings.push(makeFinding(root, rel, "pre-commit", "entry", m[1].trim().replace(/^["']|["']$/g, ""), String(i++)));
2113
- }
2114
- const repoRe = /^\s*-\s*repo:\s*(https?:\/\/\S+)$/gm;
2115
- while ((m = repoRe.exec(raw)) !== null) {
2116
- // A remote hook repo is the standard pre-commit pattern, not a finding
2117
- // of compromise: the URL alone is inventoried as UNKNOWN so a human can
2118
- // review the pinned rev. Classifying the bare URL with the command
2119
- // rules would verdict every normal pre-commit config COMPROMISED.
2120
- findings.push({
2121
- id: `${rel}:remote-repo:${i++}`,
2122
- file: rel,
2123
- source: "pre-commit",
2124
- hook: "remote-repo",
2125
- command: m[1].trim(),
2126
- classification: "UNKNOWN",
2127
- rules: [],
2128
- evidence: "remote pre-commit hook repo (standard pattern): its hooks run locally at commit time; hookcheck cannot verify remote contents, review the pinned rev",
2129
- });
2130
- }
2131
- }
2132
- function scanAgentDirs(root, findings, warnings) {
2133
- for (const [dir, source] of [[".cursor", "cursor"], [".codex", "codex"]]) {
2134
- const abs = join(root, dir);
2307
+ const tasks = Array.isArray(data?.tasks) ? data.tasks : [];
2308
+ tasks.forEach((rawTask, idx) => {
2309
+ // Malformed entries (null, strings, numbers) are skipped, never fatal:
2310
+ // one bad task must not hide every other surface in the repo.
2311
+ if (!rawTask || typeof rawTask !== "object")
2312
+ return;
2313
+ const task = rawTask;
2314
+ const runOptions = (task.runOptions ?? {});
2315
+ const options = (task.options ?? {});
2316
+ const optionsRunOptions = (options.runOptions ?? {});
2317
+ // folderOpen lives in runOptions in the standard schema, but real
2318
+ // world files also nest it under options or put runOn on the task.
2319
+ const runOn = runOptions.runOn ?? optionsRunOptions.runOn ?? task.runOn;
2320
+ const isFolderOpen = runOn === "folderOpen" || (Array.isArray(runOn) && runOn.includes("folderOpen"));
2321
+ const command = typeof task.command === "string" ? task.command : "";
2322
+ const args = Array.isArray(task.args) ? task.args.filter((a) => typeof a === "string").join(" ") : "";
2323
+ const full = [command, args].filter(Boolean).join(" ").trim();
2324
+ if (!full)
2325
+ return;
2326
+ const label = typeof task.label === "string" ? task.label : `task-${idx}`;
2327
+ findings.push(makeFinding(root, rel, "vscode-tasks", isFolderOpen ? `folderOpen:${label}` : label, full, String(idx), undefined, baseAbs));
2328
+ });
2329
+ }
2330
+ // MCP config inside .vscode
2331
+ scanMcpFile(root, `${base}.vscode/mcp.json`, findings, warnings, state, baseAbs, prefix !== "");
2332
+ }
2333
+ }
2334
+ function scanDirFiles(root, dirName, source, family, findings, warnings, state) {
2335
+ for (const prefix of state.disc.projectRoots) {
2336
+ const dirRel = prefix === "" ? dirName : `${prefix}/${dirName}`;
2337
+ const baseAbs = prefix === "" ? root : join(root, prefix);
2338
+ const abs = join(root, dirRel);
2135
2339
  if (!existsSync(abs))
2136
2340
  continue;
2137
2341
  let entries = [];
@@ -2139,6 +2343,7 @@ function scanAgentDirs(root, findings, warnings) {
2139
2343
  entries = readdirSync(abs);
2140
2344
  }
2141
2345
  catch {
2346
+ warnings.push(`Unreadable directory skipped: ${dirRel} could not be listed; the scan is partial`);
2142
2347
  continue;
2143
2348
  }
2144
2349
  for (const name of entries.sort()) {
@@ -2152,43 +2357,144 @@ function scanAgentDirs(root, findings, warnings) {
2152
2357
  }
2153
2358
  if (!st.isFile())
2154
2359
  continue;
2155
- const rel = `${dir}/${name}`;
2156
- // mcp.json is an MCP config surface, inventoried exactly once by
2157
- // the central scanMcpFile pass in scanHooks (P-035). Running it
2158
- // through the generic command walk here as well counted every
2159
- // .cursor/mcp.json server twice.
2160
- if (name === "mcp.json")
2360
+ if (name === "_" || name.startsWith("."))
2361
+ continue;
2362
+ const fileRel = `${dirRel}/${name}`;
2363
+ if (!claimFile(state, family, fileRel, prefix !== ""))
2364
+ continue;
2365
+ const content = readTextWarn(fileAbs, fileRel, warnings);
2366
+ if (content === null)
2367
+ continue;
2368
+ // Entire file content is the command surface for husky style hooks.
2369
+ // Classify generously deep into the file: a payload hidden behind
2370
+ // padding must still be seen. Display code truncates separately.
2371
+ findings.push(makeFinding(root, fileRel, source, name, content.trim().slice(0, 200000), undefined, undefined, baseAbs));
2372
+ }
2373
+ }
2374
+ }
2375
+ function scanLefthook(root, findings, warnings, state) {
2376
+ for (const prefix of state.disc.projectRoots) {
2377
+ const base = prefix === "" ? "" : `${prefix}/`;
2378
+ const baseAbs = prefix === "" ? root : join(root, prefix);
2379
+ for (const name of ["lefthook.yml", "lefthook.yaml", ".lefthook.yml"]) {
2380
+ const rel = `${base}${name}`;
2381
+ if (!existsSync(join(root, rel)))
2382
+ continue;
2383
+ if (!claimFile(state, "lefthook", rel, prefix !== ""))
2384
+ continue;
2385
+ const raw = (readTextWarn(join(root, rel), rel, warnings) ?? "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
2386
+ const runRe = /^\s*run:\s*(.+)$/gm;
2387
+ let m;
2388
+ let i = 0;
2389
+ while ((m = runRe.exec(raw)) !== null) {
2390
+ findings.push(makeFinding(root, rel, "lefthook", "run", m[1].trim().replace(/^["']|["']$/g, ""), String(i++), undefined, baseAbs));
2391
+ }
2392
+ }
2393
+ }
2394
+ }
2395
+ function scanPreCommit(root, findings, warnings, state) {
2396
+ for (const prefix of state.disc.projectRoots) {
2397
+ const base = prefix === "" ? "" : `${prefix}/`;
2398
+ const baseAbs = prefix === "" ? root : join(root, prefix);
2399
+ const rel = `${base}.pre-commit-config.yaml`;
2400
+ if (!existsSync(join(root, rel)))
2401
+ continue;
2402
+ if (!claimFile(state, "pre-commit", rel, prefix !== ""))
2403
+ continue;
2404
+ const raw = (readTextWarn(join(root, rel), rel, warnings) ?? "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
2405
+ const entryRe = /^\s*entry:\s*(.+)$/gm;
2406
+ let m;
2407
+ let i = 0;
2408
+ while ((m = entryRe.exec(raw)) !== null) {
2409
+ findings.push(makeFinding(root, rel, "pre-commit", "entry", m[1].trim().replace(/^["']|["']$/g, ""), String(i++), undefined, baseAbs));
2410
+ }
2411
+ const repoRe = /^\s*-\s*repo:\s*(https?:\/\/\S+)$/gm;
2412
+ while ((m = repoRe.exec(raw)) !== null) {
2413
+ // A remote hook repo is the standard pre-commit pattern, not a finding
2414
+ // of compromise: the URL alone is inventoried as UNKNOWN so a human can
2415
+ // review the pinned rev. Classifying the bare URL with the command
2416
+ // rules would verdict every normal pre-commit config COMPROMISED.
2417
+ findings.push({
2418
+ id: `${rel}:remote-repo:${i++}`,
2419
+ file: rel,
2420
+ source: "pre-commit",
2421
+ hook: "remote-repo",
2422
+ command: m[1].trim(),
2423
+ classification: "UNKNOWN",
2424
+ rules: [],
2425
+ evidence: "remote pre-commit hook repo (standard pattern): its hooks run locally at commit time; hookcheck cannot verify remote contents, review the pinned rev",
2426
+ });
2427
+ }
2428
+ }
2429
+ }
2430
+ function scanAgentDirs(root, findings, warnings, state) {
2431
+ for (const prefix of state.disc.projectRoots) {
2432
+ const base = prefix === "" ? "" : `${prefix}/`;
2433
+ const baseAbs = prefix === "" ? root : join(root, prefix);
2434
+ for (const [dir, source] of [[".cursor", "cursor"], [".codex", "codex"]]) {
2435
+ const abs = join(root, base, dir);
2436
+ if (!existsSync(abs))
2437
+ continue;
2438
+ let entries = [];
2439
+ try {
2440
+ entries = readdirSync(abs);
2441
+ }
2442
+ catch {
2161
2443
  continue;
2162
- if (name.endsWith(".json")) {
2163
- const data = readJson(root, rel, warnings);
2164
- if (data)
2165
- collectGenericCommands(data, rel, root, findings, source);
2166
2444
  }
2167
- else {
2168
- const content = readTextWarn(fileAbs, rel, warnings);
2169
- if (content && content.trim()) {
2170
- findings.push(makeFinding(root, rel, source, name, content.trim().slice(0, 200000)));
2445
+ for (const name of entries.sort()) {
2446
+ const fileAbs = join(abs, name);
2447
+ let st;
2448
+ try {
2449
+ st = statSync(fileAbs);
2450
+ }
2451
+ catch {
2452
+ continue;
2453
+ }
2454
+ if (!st.isFile())
2455
+ continue;
2456
+ const rel = `${base}${dir}/${name}`;
2457
+ // mcp.json is an MCP config surface, inventoried exactly once by
2458
+ // the central scanMcpFile pass in scanHooks (P-035). Running it
2459
+ // through the generic command walk here as well counted every
2460
+ // .cursor/mcp.json server twice.
2461
+ if (name === "mcp.json")
2462
+ continue;
2463
+ if (!claimFile(state, "agent", rel, prefix !== ""))
2464
+ continue;
2465
+ if (name.endsWith(".json")) {
2466
+ const data = readJson(root, rel, warnings);
2467
+ if (data)
2468
+ collectGenericCommands(data, rel, root, findings, source, "", baseAbs);
2469
+ }
2470
+ else {
2471
+ const content = readTextWarn(fileAbs, rel, warnings);
2472
+ if (content && content.trim()) {
2473
+ findings.push(makeFinding(root, rel, source, name, content.trim().slice(0, 200000), undefined, undefined, baseAbs));
2474
+ }
2171
2475
  }
2172
2476
  }
2173
2477
  }
2174
2478
  }
2175
2479
  }
2176
- function collectGenericCommands(node, file, root, findings, source, prefix = "") {
2480
+ function collectGenericCommands(node, file, root, findings, source, prefix = "", baseAbs = root) {
2177
2481
  if (!node || typeof node !== "object")
2178
2482
  return;
2179
2483
  const obj = node;
2180
2484
  if (typeof obj.command === "string" && obj.command.trim()) {
2181
2485
  const args = Array.isArray(obj.args) ? obj.args.filter((a) => typeof a === "string").join(" ") : "";
2182
- findings.push(makeFinding(root, file, source, prefix || "command", [obj.command, args].filter(Boolean).join(" "), String(findings.length)));
2486
+ findings.push(makeFinding(root, file, source, prefix || "command", [obj.command, args].filter(Boolean).join(" "), String(findings.length), undefined, baseAbs));
2183
2487
  }
2184
2488
  for (const [key, value] of Object.entries(obj)) {
2185
2489
  if (value && typeof value === "object")
2186
- collectGenericCommands(value, file, root, findings, source, prefix ? `${prefix}:${key}` : key);
2490
+ collectGenericCommands(value, file, root, findings, source, prefix ? `${prefix}:${key}` : key, baseAbs);
2187
2491
  }
2188
2492
  }
2189
- function scanMcpFile(root, rel, findings, warnings) {
2493
+ function scanMcpFile(root, rel, findings, warnings, state, baseAbs, isNested) {
2190
2494
  if (!existsSync(join(root, rel)))
2191
2495
  return;
2496
+ if (!claimFile(state, "mcp", rel, isNested))
2497
+ return;
2192
2498
  const data = readJson(root, rel, warnings);
2193
2499
  if (!data)
2194
2500
  return;
@@ -2203,9 +2509,9 @@ function scanMcpFile(root, rel, findings, warnings) {
2203
2509
  const args = Array.isArray(c.args) ? c.args.filter((a) => typeof a === "string").join(" ") : "";
2204
2510
  const full = [command, args].filter(Boolean).join(" ").trim();
2205
2511
  if (full)
2206
- findings.push(makeFinding(root, rel, "mcp-config", `mcp:${name}`, full));
2512
+ findings.push(makeFinding(root, rel, "mcp-config", `mcp:${name}`, full, undefined, undefined, baseAbs));
2207
2513
  if (typeof c.url === "string" && c.url) {
2208
- findings.push(makeFinding(root, rel, "mcp-config", `mcp:${name}:url`, c.url));
2514
+ findings.push(makeFinding(root, rel, "mcp-config", `mcp:${name}:url`, c.url, undefined, undefined, baseAbs));
2209
2515
  }
2210
2516
  }
2211
2517
  }
@@ -2233,26 +2539,37 @@ function scanMcpFile(root, rel, findings, warnings) {
2233
2539
  * resolved is still inventoried, as the literal alias text at UNKNOWN,
2234
2540
  * so no executable step ever yields zero findings.
2235
2541
  */
2236
- function scanWorkflows(root, findings, warnings) {
2237
- const dir = ".github/workflows";
2238
- const abs = join(root, dir);
2239
- if (!existsSync(abs))
2240
- return;
2241
- let entries = [];
2242
- try {
2243
- entries = readdirSync(abs);
2244
- }
2245
- catch {
2246
- warnings.push(`Unreadable directory skipped: ${dir} could not be listed; the scan is partial`);
2247
- return;
2248
- }
2249
- for (const name of entries.sort()) {
2250
- if (!/\.(yml|yaml)$/.test(name))
2542
+ function scanWorkflows(root, findings, warnings, state) {
2543
+ for (const prefix of state.disc.projectRoots) {
2544
+ const base = prefix === "" ? "" : `${prefix}/`;
2545
+ const baseAbs = prefix === "" ? root : join(root, prefix);
2546
+ const dir = `${base}.github/workflows`;
2547
+ const abs = join(root, dir);
2548
+ if (!existsSync(abs))
2251
2549
  continue;
2252
- const rel = `${dir}/${name}`;
2550
+ let entries = [];
2551
+ try {
2552
+ entries = readdirSync(abs);
2553
+ }
2554
+ catch {
2555
+ warnings.push(`Unreadable directory skipped: ${dir} could not be listed; the scan is partial`);
2556
+ continue;
2557
+ }
2558
+ for (const name of entries.sort()) {
2559
+ if (!/\.(yml|yaml)$/.test(name))
2560
+ continue;
2561
+ const rel = `${dir}/${name}`;
2562
+ if (!claimFile(state, "workflows", rel, prefix !== ""))
2563
+ continue;
2564
+ scanOneWorkflow(root, rel, baseAbs, findings, warnings);
2565
+ }
2566
+ }
2567
+ }
2568
+ function scanOneWorkflow(root, rel, baseAbs, findings, warnings) {
2569
+ {
2253
2570
  const rawFile = readTextWarn(join(root, rel), rel, warnings);
2254
2571
  if (rawFile === null)
2255
- continue;
2572
+ return;
2256
2573
  const raw = rawFile.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
2257
2574
  const lines = raw.split("\n");
2258
2575
  const indentOf = (l) => l.length - l.trimStart().length;
@@ -2344,14 +2661,14 @@ function scanWorkflows(root, findings, warnings) {
2344
2661
  if (aliasStep) {
2345
2662
  const name = aliasStep[1];
2346
2663
  const resolved = scalarAnchors.has(name) || mapAnchors.has(name) ? resolveAlias(`*${name}`) : `*${name}`;
2347
- findings.push(makeFinding(root, rel, "github-workflow", "run", resolved, String(i++), lineNo));
2664
+ findings.push(makeFinding(root, rel, "github-workflow", "run", resolved, String(i++), lineNo, baseAbs));
2348
2665
  continue;
2349
2666
  }
2350
2667
  const mergeKey = line.match(/^\s*<<:\s*\*([A-Za-z0-9_-]+)\s*(#.*)?$/);
2351
2668
  if (mergeKey) {
2352
2669
  const name = mergeKey[1];
2353
2670
  const resolved = scalarAnchors.has(name) || mapAnchors.has(name) ? resolveAlias(`*${name}`) : `*${name}`;
2354
- findings.push(makeFinding(root, rel, "github-workflow", "run", resolved, String(i++), lineNo));
2671
+ findings.push(makeFinding(root, rel, "github-workflow", "run", resolved, String(i++), lineNo, baseAbs));
2355
2672
  continue;
2356
2673
  }
2357
2674
  const m = line.match(/^(\s*)-?\s*run:\s*(.*)$/);
@@ -2374,19 +2691,19 @@ function scanWorkflows(root, findings, warnings) {
2374
2691
  if (blockHeader) {
2375
2692
  const { body, end } = collectBlock(idx, runIndent);
2376
2693
  if (body) {
2377
- findings.push(makeFinding(root, rel, "github-workflow", "run", body, String(i++), lineNoRun));
2694
+ findings.push(makeFinding(root, rel, "github-workflow", "run", body, String(i++), lineNoRun, baseAbs));
2378
2695
  }
2379
2696
  idx = end - 1;
2380
2697
  continue;
2381
2698
  }
2382
2699
  if (rest === "")
2383
2700
  continue;
2384
- findings.push(makeFinding(root, rel, "github-workflow", "run", rest, String(i++), lineNoRun));
2701
+ findings.push(makeFinding(root, rel, "github-workflow", "run", rest, String(i++), lineNoRun, baseAbs));
2385
2702
  }
2386
2703
  }
2387
2704
  }
2388
- function scanGitHooks(root, findings, warnings) {
2389
- scanDirFiles(root, ".githooks", "githooks", findings, warnings);
2705
+ function scanGitHooks(root, findings, warnings, state) {
2706
+ scanDirFiles(root, ".githooks", "githooks", "githooks", findings, warnings, state);
2390
2707
  }
2391
2708
  /**
2392
2709
  * Empty estate note (P-014, hookcheck half). A scan that inventories
@@ -2423,21 +2740,44 @@ export function scanHooks(root) {
2423
2740
  note: EMPTY_ESTATE_NOTE,
2424
2741
  };
2425
2742
  }
2426
- scanPackageJson(root, findings, warnings);
2427
- scanClaude(root, findings, warnings);
2428
- scanPluginHooks(root, findings, warnings);
2429
- scanVscode(root, findings, warnings);
2430
- scanDirFiles(root, ".husky", "husky", findings, warnings);
2431
- scanLefthook(root, findings, warnings);
2432
- scanPreCommit(root, findings, warnings);
2433
- scanAgentDirs(root, findings, warnings);
2434
- // MCP config surfaces, each inventoried exactly once (P-035).
2743
+ const disc = discoverSurfaces(root, warnings);
2744
+ const state = { disc, inventoried: new Set(), familyFound: new Map(), familyCapped: new Map(), partial: false };
2745
+ scanPackageJson(root, findings, warnings, state);
2746
+ scanClaude(root, findings, warnings, state);
2747
+ scanPluginHooks(root, findings, warnings, state);
2748
+ scanVscode(root, findings, warnings, state);
2749
+ scanDirFiles(root, ".husky", "husky", "husky", findings, warnings, state);
2750
+ scanLefthook(root, findings, warnings, state);
2751
+ scanPreCommit(root, findings, warnings, state);
2752
+ scanAgentDirs(root, findings, warnings, state);
2753
+ // MCP config surfaces, each inventoried exactly once (P-035), at the
2754
+ // scan root and at every nested project root (P-065).
2435
2755
  // .windsurf/mcp.json joined the covered surfaces in v0.1.6 (P-034);
2436
2756
  // .vscode/mcp.json is handled inside scanVscode.
2437
- for (const rel of [".mcp.json", "mcp.json", ".cursor/mcp.json", ".windsurf/mcp.json", ".codex/mcp.json"])
2438
- scanMcpFile(root, rel, findings, warnings);
2439
- scanWorkflows(root, findings, warnings);
2440
- scanGitHooks(root, findings, warnings);
2757
+ for (const prefix of disc.projectRoots) {
2758
+ const base = prefix === "" ? "" : `${prefix}/`;
2759
+ const baseAbs = prefix === "" ? root : join(root, prefix);
2760
+ for (const name of [".mcp.json", "mcp.json", ".cursor/mcp.json", ".windsurf/mcp.json", ".codex/mcp.json"]) {
2761
+ scanMcpFile(root, `${base}${name}`, findings, warnings, state, baseAbs, prefix !== "");
2762
+ }
2763
+ }
2764
+ scanWorkflows(root, findings, warnings, state);
2765
+ scanGitHooks(root, findings, warnings, state);
2766
+ // Bounded scan honesty (P-065): a scan cut short by the discovery
2767
+ // bounds must say so, naming the bound, instead of presenting as
2768
+ // complete. Surfaces spotted just below the depth bound are counted
2769
+ // in the warning, never silently dropped.
2770
+ if (disc.depthBoundHit) {
2771
+ const below = disc.surfacesBelowBound > 0
2772
+ ? `; at least ${disc.surfacesBelowBound} of the directories just below the bound contain hook surface files or config directories that were not inventoried`
2773
+ : "";
2774
+ warnings.push(`Nested discovery bounded at depth ${NESTED_MAX_DEPTH}: project directories more than ${NESTED_MAX_DEPTH} levels below the scan root were not scanned (${disc.frontierDirs} ${disc.frontierDirs === 1 ? "directory" : "directories"} below the bound not entered${below}); the scan is partial (bounded)`);
2775
+ }
2776
+ for (const [family, capped] of state.familyCapped) {
2777
+ const label = FAMILY_LABELS[family] ?? family;
2778
+ warnings.push(`Nested ${label} inventory capped at ${NESTED_MAX_FILES_PER_SURFACE} files (${state.familyFound.get(family) ?? 0} found); the remaining nested ${label} files were not inventoried and the scan is partial`);
2779
+ void capped;
2780
+ }
2441
2781
  // Dedupe identical ids (agent dir scan may double count mcp.json).
2442
2782
  const seen = new Set();
2443
2783
  const deduped = findings.filter((f) => {
@@ -2453,12 +2793,14 @@ export function scanHooks(root) {
2453
2793
  risky: deduped.filter((f) => f.classification === "RISKY").length,
2454
2794
  unknown: deduped.filter((f) => f.classification === "UNKNOWN").length,
2455
2795
  };
2456
- // Partial scan floor (P-049): when any inventoried file or directory
2457
- // could not be read, the scan did not see the whole estate, so a bare
2458
- // CLEAN is not an honest verdict. Floor at REVIEW; the warnings name
2459
- // exactly what was missed.
2796
+ // Partial scan floor (P-049, extended by P-065): when any inventoried
2797
+ // file or directory could not be read, when a file cap left surface
2798
+ // files uninventoried, or when hook surfaces were spotted just below
2799
+ // the discovery depth bound, the scan did not see the whole estate,
2800
+ // so a bare CLEAN is not an honest verdict. Floor at REVIEW; the
2801
+ // warnings name exactly what was missed.
2460
2802
  let verdict = computeVerdict(deduped);
2461
- if (verdict === "CLEAN" && warnings.some((w) => w.startsWith("Unreadable"))) {
2803
+ if (verdict === "CLEAN" && (warnings.some((w) => w.startsWith("Unreadable")) || state.partial || disc.surfacesBelowBound > 0)) {
2462
2804
  verdict = "REVIEW";
2463
2805
  }
2464
2806
  return {