@reneturcios/hookcheck 0.1.7 → 0.1.8

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.8";
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 },
@@ -1763,21 +1763,54 @@ function scanOnePackageJson(root, rel, findings, warnings) {
1763
1763
  }
1764
1764
  }
1765
1765
  /**
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.
1766
+ * Unified nested discovery (P-032, P-060, P-065). Every surface the
1767
+ * tool inventories at the scan root is also inventoried when it
1768
+ * appears inside a subdirectory: before v0.1.8 only nested package.json
1769
+ * files (P-032) and plugin hooks files (P-060) were recursed, so a
1770
+ * parent scan of a monorepo or workspace silently missed nested
1771
+ * .claude/settings.json, .vscode/tasks.json, workflows, husky and
1772
+ * githooks directories, lefthook and pre-commit configs, and MCP
1773
+ * configs, and a tree holding only nested surfaces scanned CLEAN with
1774
+ * the empty estate note attached (P-065).
1775
+ *
1776
+ * One bounded walk enumerates every project root (each directory at
1777
+ * most NESTED_MAX_DEPTH levels below the scan root, the scan root
1778
+ * itself included) plus the nested package.json and plugin hooks file
1779
+ * lists. Per surface scanners then run once per project root with the
1780
+ * root prefix carried into every finding file path, so findings carry
1781
+ * their real relative paths. The walk is bounded so a hostile or
1782
+ * giant tree cannot explode the report: directories are visited in
1783
+ * sorted order, node_modules, .git, and dot directories are never
1784
+ * entered, symlinked directories are never followed (a symlink loop
1785
+ * cannot hang the scan), and each surface family inventories at most
1786
+ * NESTED_MAX_FILES_PER_SURFACE nested files, with the cap reported as
1787
+ * a warning when it is hit. When directories remain below the depth
1788
+ * bound the walk says so in a warning naming the bound, and it probes
1789
+ * the directories just below the bound (names only, no file reads) so
1790
+ * surfaces sitting there are counted in the warning instead of being
1791
+ * silently dropped.
1776
1792
  */
1777
- export const PACKAGE_MAX_DEPTH = 4;
1778
- export const PACKAGE_MAX_NESTED = 200;
1779
- function discoverNestedPackageJson(root, warnings) {
1780
- const found = [];
1793
+ export const NESTED_MAX_DEPTH = 4;
1794
+ export const NESTED_MAX_FILES_PER_SURFACE = 200;
1795
+ export const PACKAGE_MAX_DEPTH = NESTED_MAX_DEPTH;
1796
+ export const PACKAGE_MAX_NESTED = NESTED_MAX_FILES_PER_SURFACE;
1797
+ export const PLUGIN_HOOKS_MAX_DEPTH = NESTED_MAX_DEPTH;
1798
+ export const PLUGIN_HOOKS_MAX_FILES = NESTED_MAX_FILES_PER_SURFACE;
1799
+ const SURFACE_FILE_INDICATORS = new Set([
1800
+ "package.json", "lefthook.yml", "lefthook.yaml", ".lefthook.yml", ".lefthook.yaml",
1801
+ ".pre-commit-config.yaml", ".pre-commit-config.yml", ".mcp.json", "mcp.json",
1802
+ ]);
1803
+ const SURFACE_DIR_INDICATORS = new Set([
1804
+ ".claude", "claude", ".vscode", ".github", ".husky", ".githooks",
1805
+ "hooks", ".cursor", ".windsurf", ".codex",
1806
+ ]);
1807
+ function discoverSurfaces(root, warnings) {
1808
+ const projectRoots = [];
1809
+ const packageFiles = [];
1810
+ const pluginSet = new Set();
1811
+ let frontierDirs = 0;
1812
+ let surfacesBelowBound = 0;
1813
+ let probes = 0;
1781
1814
  const skipDirs = new Set(["node_modules", ".git"]);
1782
1815
  const stack = [{ abs: root, rel: "", depth: 0 }];
1783
1816
  while (stack.length > 0) {
@@ -1792,14 +1825,38 @@ function discoverNestedPackageJson(root, warnings) {
1792
1825
  continue;
1793
1826
  }
1794
1827
  entries.sort();
1828
+ projectRoots.push(cur.rel);
1795
1829
  if (cur.depth >= 1 && entries.includes("package.json")) {
1796
- found.push(cur.rel === "" ? "package.json" : `${cur.rel}/package.json`);
1830
+ packageFiles.push(`${cur.rel}/package.json`);
1831
+ }
1832
+ // Plugin hooks files sit in a hooks directory one level below the
1833
+ // plugin root, so they are spotted from the plugin root listing
1834
+ // (the hooks directory itself can sit at depth bound plus one).
1835
+ if (entries.includes("hooks")) {
1836
+ const hooksAbs = join(cur.abs, "hooks");
1837
+ let lst = null;
1838
+ try {
1839
+ lst = lstatSync(hooksAbs);
1840
+ }
1841
+ catch {
1842
+ lst = null;
1843
+ }
1844
+ if (lst && lst.isDirectory() && !lst.isSymbolicLink()) {
1845
+ let hookEntries = [];
1846
+ try {
1847
+ hookEntries = readdirSync(hooksAbs);
1848
+ }
1849
+ catch {
1850
+ hookEntries = [];
1851
+ }
1852
+ for (const name of ["hooks.json", "hooks-cursor.json"]) {
1853
+ if (hookEntries.includes(name))
1854
+ pluginSet.add(cur.rel === "" ? `hooks/${name}` : `${cur.rel}/hooks/${name}`);
1855
+ }
1856
+ }
1797
1857
  }
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];
1858
+ const children = [];
1859
+ for (const name of entries) {
1803
1860
  if (skipDirs.has(name) || name.startsWith("."))
1804
1861
  continue;
1805
1862
  const childAbs = join(cur.abs, name);
@@ -1812,19 +1869,84 @@ function discoverNestedPackageJson(root, warnings) {
1812
1869
  }
1813
1870
  if (lst.isSymbolicLink() || !lst.isDirectory())
1814
1871
  continue;
1815
- stack.push({ abs: childAbs, rel: cur.rel === "" ? name : `${cur.rel}/${name}`, depth: cur.depth + 1 });
1872
+ children.push({ abs: childAbs, rel: cur.rel === "" ? name : `${cur.rel}/${name}`, depth: cur.depth + 1 });
1816
1873
  }
1874
+ if (cur.depth >= NESTED_MAX_DEPTH) {
1875
+ frontierDirs += children.length;
1876
+ for (const child of children) {
1877
+ if (probes >= 1000)
1878
+ break;
1879
+ probes++;
1880
+ let sub = [];
1881
+ try {
1882
+ sub = readdirSync(child.abs);
1883
+ }
1884
+ catch {
1885
+ continue;
1886
+ }
1887
+ if (sub.some((n) => SURFACE_FILE_INDICATORS.has(n) || SURFACE_DIR_INDICATORS.has(n)))
1888
+ surfacesBelowBound++;
1889
+ }
1890
+ continue;
1891
+ }
1892
+ // Push in reverse so the stack pops in sorted order.
1893
+ for (let i = children.length - 1; i >= 0; i--)
1894
+ stack.push(children[i]);
1817
1895
  }
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);
1896
+ projectRoots.sort((a, b) => (a === b ? 0 : a === "" ? -1 : b === "" ? 1 : a < b ? -1 : 1));
1897
+ packageFiles.sort();
1898
+ return {
1899
+ projectRoots,
1900
+ packageFiles,
1901
+ pluginHookFiles: Array.from(pluginSet).sort(),
1902
+ frontierDirs,
1903
+ surfacesBelowBound,
1904
+ depthBoundHit: frontierDirs > 0,
1905
+ };
1906
+ }
1907
+ const FAMILY_LABELS = {
1908
+ settings: "Claude settings",
1909
+ tasks: "VS Code tasks",
1910
+ workflows: "workflow",
1911
+ husky: "husky hook",
1912
+ githooks: "githooks hook",
1913
+ lefthook: "lefthook config",
1914
+ "pre-commit": "pre-commit config",
1915
+ mcp: "MCP config",
1916
+ agent: "agent config",
1917
+ };
1918
+ /**
1919
+ * Claim one surface file for inventory. Returns false when the file
1920
+ * was already inventoried through another discovery path, or when the
1921
+ * nested file cap for its family is already spent (the skip is
1922
+ * counted, and scanHooks warns once per capped family).
1923
+ */
1924
+ function claimFile(state, family, rel, isNested) {
1925
+ if (state.inventoried.has(rel))
1926
+ return false;
1927
+ if (isNested) {
1928
+ const found = (state.familyFound.get(family) ?? 0) + 1;
1929
+ state.familyFound.set(family, found);
1930
+ if (found > NESTED_MAX_FILES_PER_SURFACE) {
1931
+ state.familyCapped.set(family, (state.familyCapped.get(family) ?? 0) + 1);
1932
+ state.partial = true;
1933
+ return false;
1934
+ }
1822
1935
  }
1823
- return found;
1936
+ state.inventoried.add(rel);
1937
+ return true;
1824
1938
  }
1825
- function scanPackageJson(root, findings, warnings) {
1939
+ function scanPackageJson(root, findings, warnings, state) {
1826
1940
  scanOnePackageJson(root, "package.json", findings, warnings);
1827
- for (const rel of discoverNestedPackageJson(root, warnings)) {
1941
+ state.inventoried.add("package.json");
1942
+ let nested = state.disc.packageFiles;
1943
+ if (nested.length > PACKAGE_MAX_NESTED) {
1944
+ 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`);
1945
+ state.partial = true;
1946
+ nested = nested.slice(0, PACKAGE_MAX_NESTED);
1947
+ }
1948
+ for (const rel of nested) {
1949
+ state.inventoried.add(rel);
1828
1950
  scanOnePackageJson(root, rel, findings, warnings);
1829
1951
  }
1830
1952
  }
@@ -1850,16 +1972,22 @@ function collectClaudeHooks(node, file, root, findings, prefix, source = "claude
1850
1972
  * the GNU stow layout used by real dotfiles repos (claude/settings.json,
1851
1973
  * no dot), and a settings file nested one level under a config dir
1852
1974
  * (config/.claude/settings.json). A settings path that exists as a
1853
- * directory is reported as a warning, never silently skipped.
1975
+ * directory is reported as a warning, never silently skipped. Since
1976
+ * v0.1.8 (P-065) the candidates are computed per project root, so the
1977
+ * same locations are inventoried in every subdirectory within the
1978
+ * discovery bound; the inventoried set in ScanState keeps a file
1979
+ * reachable by two paths (a parent root one level candidate and its
1980
+ * own project root) counted exactly once.
1854
1981
  */
1855
- function claudeSettingsCandidates(root) {
1982
+ function claudeSettingsCandidates(root, prefix) {
1983
+ const base = prefix === "" ? "" : `${prefix}/`;
1856
1984
  const rels = [
1857
- ".claude/settings.json", ".claude/settings.local.json",
1858
- "claude/settings.json", "claude/settings.local.json",
1985
+ `${base}.claude/settings.json`, `${base}.claude/settings.local.json`,
1986
+ `${base}claude/settings.json`, `${base}claude/settings.local.json`,
1859
1987
  ];
1860
1988
  let entries = [];
1861
1989
  try {
1862
- entries = readdirSync(root);
1990
+ entries = readdirSync(join(root, prefix));
1863
1991
  }
1864
1992
  catch {
1865
1993
  entries = [];
@@ -1869,7 +1997,7 @@ function claudeSettingsCandidates(root) {
1869
1997
  continue;
1870
1998
  let st;
1871
1999
  try {
1872
- st = statSync(join(root, name));
2000
+ st = statSync(join(root, prefix, name));
1873
2001
  }
1874
2002
  catch {
1875
2003
  continue;
@@ -1877,42 +2005,50 @@ function claudeSettingsCandidates(root) {
1877
2005
  if (!st.isDirectory())
1878
2006
  continue;
1879
2007
  for (const f of ["settings.json", "settings.local.json"]) {
1880
- rels.push(`${name}/.claude/${f}`);
2008
+ rels.push(`${base}${name}/.claude/${f}`);
1881
2009
  }
1882
2010
  }
1883
2011
  return Array.from(new Set(rels));
1884
2012
  }
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;
2013
+ function scanClaude(root, findings, warnings, state) {
2014
+ for (const prefix of state.disc.projectRoots) {
2015
+ const baseAbs = prefix === "" ? root : join(root, prefix);
2016
+ for (const rel of claudeSettingsCandidates(root, prefix)) {
2017
+ const abs = join(root, rel);
2018
+ if (!existsSync(abs))
2019
+ continue;
2020
+ let st;
2021
+ try {
2022
+ st = statSync(abs);
2023
+ }
2024
+ catch {
2025
+ continue;
2026
+ }
2027
+ if (st.isDirectory()) {
2028
+ if (!state.inventoried.has(rel)) {
2029
+ state.inventoried.add(rel);
2030
+ warnings.push(`${rel} is a directory, not a settings file: any hooks inside it cannot be parsed as Claude settings`);
2031
+ }
2032
+ continue;
2033
+ }
2034
+ if (!st.isFile())
2035
+ continue;
2036
+ if (!claimFile(state, "settings", rel, prefix !== ""))
2037
+ continue;
2038
+ const data = readJson(root, rel, warnings);
2039
+ if (!data || typeof data !== "object")
2040
+ continue;
2041
+ if (!("hooks" in data)) {
2042
+ warnings.push(`No hooks key in ${rel}`);
2043
+ continue;
2044
+ }
2045
+ const hooks = data.hooks;
2046
+ if (!hooks || typeof hooks !== "object") {
2047
+ warnings.push(`hooks key in ${rel} is not an object: hooks skipped`);
2048
+ continue;
2049
+ }
2050
+ collectClaudeHooks(hooks, rel, root, findings, "", "claude-settings", baseAbs);
1914
2051
  }
1915
- collectClaudeHooks(hooks, rel, root, findings, "");
1916
2052
  }
1917
2053
  }
1918
2054
  /**
@@ -1933,67 +2069,21 @@ function scanClaude(root, findings, warnings) {
1933
2069
  * command object shape under Cursor event names, so it is inventoried
1934
2070
  * the same way.
1935
2071
  *
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.
2072
+ * Discovery is part of the unified bounded walk (P-065): the file
2073
+ * list comes from discoverSurfaces, with the plugin root at most
2074
+ * PLUGIN_HOOKS_MAX_DEPTH directories below the scan root (the hooks
2075
+ * directory itself sits one level below its plugin root), and the
2076
+ * same cap with a warning applies.
1942
2077
  */
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)) {
2078
+ function scanPluginHooks(root, findings, warnings, state) {
2079
+ let files = state.disc.pluginHookFiles;
2080
+ if (files.length > PLUGIN_HOOKS_MAX_FILES) {
2081
+ 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`);
2082
+ state.partial = true;
2083
+ files = files.slice(0, PLUGIN_HOOKS_MAX_FILES);
2084
+ }
2085
+ for (const rel of files) {
2086
+ state.inventoried.add(rel);
1997
2087
  const data = readJson(root, rel, warnings);
1998
2088
  if (!data || typeof data !== "object" || Array.isArray(data))
1999
2089
  continue;
@@ -2009,129 +2099,48 @@ function scanPluginHooks(root, findings, warnings) {
2009
2099
  collectClaudeHooks(eventsNode, rel, root, findings, "", "claude-plugin", pluginRootAbs);
2010
2100
  }
2011
2101
  }
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)));
2041
- }
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);
2102
+ function scanVscode(root, findings, warnings, state) {
2103
+ for (const prefix of state.disc.projectRoots) {
2104
+ const base = prefix === "" ? "" : `${prefix}/`;
2105
+ const baseAbs = prefix === "" ? root : join(root, prefix);
2106
+ const rel = `${base}.vscode/tasks.json`;
2107
+ if (existsSync(join(root, rel)) && claimFile(state, "tasks", rel, prefix !== "")) {
2108
+ const data = readJson(root, rel, warnings);
2109
+ if (data && data.tasks !== undefined && !Array.isArray(data.tasks)) {
2110
+ warnings.push(`tasks key in ${rel} is not an array: tasks skipped`);
2047
2111
  }
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);
2112
+ const tasks = Array.isArray(data?.tasks) ? data.tasks : [];
2113
+ tasks.forEach((rawTask, idx) => {
2114
+ // Malformed entries (null, strings, numbers) are skipped, never fatal:
2115
+ // one bad task must not hide every other surface in the repo.
2116
+ if (!rawTask || typeof rawTask !== "object")
2117
+ return;
2118
+ const task = rawTask;
2119
+ const runOptions = (task.runOptions ?? {});
2120
+ const options = (task.options ?? {});
2121
+ const optionsRunOptions = (options.runOptions ?? {});
2122
+ // folderOpen lives in runOptions in the standard schema, but real
2123
+ // world files also nest it under options or put runOn on the task.
2124
+ const runOn = runOptions.runOn ?? optionsRunOptions.runOn ?? task.runOn;
2125
+ const isFolderOpen = runOn === "folderOpen" || (Array.isArray(runOn) && runOn.includes("folderOpen"));
2126
+ const command = typeof task.command === "string" ? task.command : "";
2127
+ const args = Array.isArray(task.args) ? task.args.filter((a) => typeof a === "string").join(" ") : "";
2128
+ const full = [command, args].filter(Boolean).join(" ").trim();
2129
+ if (!full)
2130
+ return;
2131
+ const label = typeof task.label === "string" ? task.label : `task-${idx}`;
2132
+ findings.push(makeFinding(root, rel, "vscode-tasks", isFolderOpen ? `folderOpen:${label}` : label, full, String(idx), undefined, baseAbs));
2133
+ });
2134
+ }
2135
+ // MCP config inside .vscode
2136
+ scanMcpFile(root, `${base}.vscode/mcp.json`, findings, warnings, state, baseAbs, prefix !== "");
2137
+ }
2138
+ }
2139
+ function scanDirFiles(root, dirName, source, family, findings, warnings, state) {
2140
+ for (const prefix of state.disc.projectRoots) {
2141
+ const dirRel = prefix === "" ? dirName : `${prefix}/${dirName}`;
2142
+ const baseAbs = prefix === "" ? root : join(root, prefix);
2143
+ const abs = join(root, dirRel);
2135
2144
  if (!existsSync(abs))
2136
2145
  continue;
2137
2146
  let entries = [];
@@ -2139,6 +2148,7 @@ function scanAgentDirs(root, findings, warnings) {
2139
2148
  entries = readdirSync(abs);
2140
2149
  }
2141
2150
  catch {
2151
+ warnings.push(`Unreadable directory skipped: ${dirRel} could not be listed; the scan is partial`);
2142
2152
  continue;
2143
2153
  }
2144
2154
  for (const name of entries.sort()) {
@@ -2152,43 +2162,144 @@ function scanAgentDirs(root, findings, warnings) {
2152
2162
  }
2153
2163
  if (!st.isFile())
2154
2164
  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")
2165
+ if (name === "_" || name.startsWith("."))
2166
+ continue;
2167
+ const fileRel = `${dirRel}/${name}`;
2168
+ if (!claimFile(state, family, fileRel, prefix !== ""))
2169
+ continue;
2170
+ const content = readTextWarn(fileAbs, fileRel, warnings);
2171
+ if (content === null)
2172
+ continue;
2173
+ // Entire file content is the command surface for husky style hooks.
2174
+ // Classify generously deep into the file: a payload hidden behind
2175
+ // padding must still be seen. Display code truncates separately.
2176
+ findings.push(makeFinding(root, fileRel, source, name, content.trim().slice(0, 200000), undefined, undefined, baseAbs));
2177
+ }
2178
+ }
2179
+ }
2180
+ function scanLefthook(root, findings, warnings, state) {
2181
+ for (const prefix of state.disc.projectRoots) {
2182
+ const base = prefix === "" ? "" : `${prefix}/`;
2183
+ const baseAbs = prefix === "" ? root : join(root, prefix);
2184
+ for (const name of ["lefthook.yml", "lefthook.yaml", ".lefthook.yml"]) {
2185
+ const rel = `${base}${name}`;
2186
+ if (!existsSync(join(root, rel)))
2187
+ continue;
2188
+ if (!claimFile(state, "lefthook", rel, prefix !== ""))
2189
+ continue;
2190
+ const raw = (readTextWarn(join(root, rel), rel, warnings) ?? "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
2191
+ const runRe = /^\s*run:\s*(.+)$/gm;
2192
+ let m;
2193
+ let i = 0;
2194
+ while ((m = runRe.exec(raw)) !== null) {
2195
+ findings.push(makeFinding(root, rel, "lefthook", "run", m[1].trim().replace(/^["']|["']$/g, ""), String(i++), undefined, baseAbs));
2196
+ }
2197
+ }
2198
+ }
2199
+ }
2200
+ function scanPreCommit(root, findings, warnings, state) {
2201
+ for (const prefix of state.disc.projectRoots) {
2202
+ const base = prefix === "" ? "" : `${prefix}/`;
2203
+ const baseAbs = prefix === "" ? root : join(root, prefix);
2204
+ const rel = `${base}.pre-commit-config.yaml`;
2205
+ if (!existsSync(join(root, rel)))
2206
+ continue;
2207
+ if (!claimFile(state, "pre-commit", rel, prefix !== ""))
2208
+ continue;
2209
+ const raw = (readTextWarn(join(root, rel), rel, warnings) ?? "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
2210
+ const entryRe = /^\s*entry:\s*(.+)$/gm;
2211
+ let m;
2212
+ let i = 0;
2213
+ while ((m = entryRe.exec(raw)) !== null) {
2214
+ findings.push(makeFinding(root, rel, "pre-commit", "entry", m[1].trim().replace(/^["']|["']$/g, ""), String(i++), undefined, baseAbs));
2215
+ }
2216
+ const repoRe = /^\s*-\s*repo:\s*(https?:\/\/\S+)$/gm;
2217
+ while ((m = repoRe.exec(raw)) !== null) {
2218
+ // A remote hook repo is the standard pre-commit pattern, not a finding
2219
+ // of compromise: the URL alone is inventoried as UNKNOWN so a human can
2220
+ // review the pinned rev. Classifying the bare URL with the command
2221
+ // rules would verdict every normal pre-commit config COMPROMISED.
2222
+ findings.push({
2223
+ id: `${rel}:remote-repo:${i++}`,
2224
+ file: rel,
2225
+ source: "pre-commit",
2226
+ hook: "remote-repo",
2227
+ command: m[1].trim(),
2228
+ classification: "UNKNOWN",
2229
+ rules: [],
2230
+ evidence: "remote pre-commit hook repo (standard pattern): its hooks run locally at commit time; hookcheck cannot verify remote contents, review the pinned rev",
2231
+ });
2232
+ }
2233
+ }
2234
+ }
2235
+ function scanAgentDirs(root, findings, warnings, state) {
2236
+ for (const prefix of state.disc.projectRoots) {
2237
+ const base = prefix === "" ? "" : `${prefix}/`;
2238
+ const baseAbs = prefix === "" ? root : join(root, prefix);
2239
+ for (const [dir, source] of [[".cursor", "cursor"], [".codex", "codex"]]) {
2240
+ const abs = join(root, base, dir);
2241
+ if (!existsSync(abs))
2242
+ continue;
2243
+ let entries = [];
2244
+ try {
2245
+ entries = readdirSync(abs);
2246
+ }
2247
+ catch {
2161
2248
  continue;
2162
- if (name.endsWith(".json")) {
2163
- const data = readJson(root, rel, warnings);
2164
- if (data)
2165
- collectGenericCommands(data, rel, root, findings, source);
2166
2249
  }
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)));
2250
+ for (const name of entries.sort()) {
2251
+ const fileAbs = join(abs, name);
2252
+ let st;
2253
+ try {
2254
+ st = statSync(fileAbs);
2255
+ }
2256
+ catch {
2257
+ continue;
2258
+ }
2259
+ if (!st.isFile())
2260
+ continue;
2261
+ const rel = `${base}${dir}/${name}`;
2262
+ // mcp.json is an MCP config surface, inventoried exactly once by
2263
+ // the central scanMcpFile pass in scanHooks (P-035). Running it
2264
+ // through the generic command walk here as well counted every
2265
+ // .cursor/mcp.json server twice.
2266
+ if (name === "mcp.json")
2267
+ continue;
2268
+ if (!claimFile(state, "agent", rel, prefix !== ""))
2269
+ continue;
2270
+ if (name.endsWith(".json")) {
2271
+ const data = readJson(root, rel, warnings);
2272
+ if (data)
2273
+ collectGenericCommands(data, rel, root, findings, source, "", baseAbs);
2274
+ }
2275
+ else {
2276
+ const content = readTextWarn(fileAbs, rel, warnings);
2277
+ if (content && content.trim()) {
2278
+ findings.push(makeFinding(root, rel, source, name, content.trim().slice(0, 200000), undefined, undefined, baseAbs));
2279
+ }
2171
2280
  }
2172
2281
  }
2173
2282
  }
2174
2283
  }
2175
2284
  }
2176
- function collectGenericCommands(node, file, root, findings, source, prefix = "") {
2285
+ function collectGenericCommands(node, file, root, findings, source, prefix = "", baseAbs = root) {
2177
2286
  if (!node || typeof node !== "object")
2178
2287
  return;
2179
2288
  const obj = node;
2180
2289
  if (typeof obj.command === "string" && obj.command.trim()) {
2181
2290
  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)));
2291
+ findings.push(makeFinding(root, file, source, prefix || "command", [obj.command, args].filter(Boolean).join(" "), String(findings.length), undefined, baseAbs));
2183
2292
  }
2184
2293
  for (const [key, value] of Object.entries(obj)) {
2185
2294
  if (value && typeof value === "object")
2186
- collectGenericCommands(value, file, root, findings, source, prefix ? `${prefix}:${key}` : key);
2295
+ collectGenericCommands(value, file, root, findings, source, prefix ? `${prefix}:${key}` : key, baseAbs);
2187
2296
  }
2188
2297
  }
2189
- function scanMcpFile(root, rel, findings, warnings) {
2298
+ function scanMcpFile(root, rel, findings, warnings, state, baseAbs, isNested) {
2190
2299
  if (!existsSync(join(root, rel)))
2191
2300
  return;
2301
+ if (!claimFile(state, "mcp", rel, isNested))
2302
+ return;
2192
2303
  const data = readJson(root, rel, warnings);
2193
2304
  if (!data)
2194
2305
  return;
@@ -2203,9 +2314,9 @@ function scanMcpFile(root, rel, findings, warnings) {
2203
2314
  const args = Array.isArray(c.args) ? c.args.filter((a) => typeof a === "string").join(" ") : "";
2204
2315
  const full = [command, args].filter(Boolean).join(" ").trim();
2205
2316
  if (full)
2206
- findings.push(makeFinding(root, rel, "mcp-config", `mcp:${name}`, full));
2317
+ findings.push(makeFinding(root, rel, "mcp-config", `mcp:${name}`, full, undefined, undefined, baseAbs));
2207
2318
  if (typeof c.url === "string" && c.url) {
2208
- findings.push(makeFinding(root, rel, "mcp-config", `mcp:${name}:url`, c.url));
2319
+ findings.push(makeFinding(root, rel, "mcp-config", `mcp:${name}:url`, c.url, undefined, undefined, baseAbs));
2209
2320
  }
2210
2321
  }
2211
2322
  }
@@ -2233,26 +2344,37 @@ function scanMcpFile(root, rel, findings, warnings) {
2233
2344
  * resolved is still inventoried, as the literal alias text at UNKNOWN,
2234
2345
  * so no executable step ever yields zero findings.
2235
2346
  */
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))
2347
+ function scanWorkflows(root, findings, warnings, state) {
2348
+ for (const prefix of state.disc.projectRoots) {
2349
+ const base = prefix === "" ? "" : `${prefix}/`;
2350
+ const baseAbs = prefix === "" ? root : join(root, prefix);
2351
+ const dir = `${base}.github/workflows`;
2352
+ const abs = join(root, dir);
2353
+ if (!existsSync(abs))
2354
+ continue;
2355
+ let entries = [];
2356
+ try {
2357
+ entries = readdirSync(abs);
2358
+ }
2359
+ catch {
2360
+ warnings.push(`Unreadable directory skipped: ${dir} could not be listed; the scan is partial`);
2251
2361
  continue;
2252
- const rel = `${dir}/${name}`;
2362
+ }
2363
+ for (const name of entries.sort()) {
2364
+ if (!/\.(yml|yaml)$/.test(name))
2365
+ continue;
2366
+ const rel = `${dir}/${name}`;
2367
+ if (!claimFile(state, "workflows", rel, prefix !== ""))
2368
+ continue;
2369
+ scanOneWorkflow(root, rel, baseAbs, findings, warnings);
2370
+ }
2371
+ }
2372
+ }
2373
+ function scanOneWorkflow(root, rel, baseAbs, findings, warnings) {
2374
+ {
2253
2375
  const rawFile = readTextWarn(join(root, rel), rel, warnings);
2254
2376
  if (rawFile === null)
2255
- continue;
2377
+ return;
2256
2378
  const raw = rawFile.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
2257
2379
  const lines = raw.split("\n");
2258
2380
  const indentOf = (l) => l.length - l.trimStart().length;
@@ -2344,14 +2466,14 @@ function scanWorkflows(root, findings, warnings) {
2344
2466
  if (aliasStep) {
2345
2467
  const name = aliasStep[1];
2346
2468
  const resolved = scalarAnchors.has(name) || mapAnchors.has(name) ? resolveAlias(`*${name}`) : `*${name}`;
2347
- findings.push(makeFinding(root, rel, "github-workflow", "run", resolved, String(i++), lineNo));
2469
+ findings.push(makeFinding(root, rel, "github-workflow", "run", resolved, String(i++), lineNo, baseAbs));
2348
2470
  continue;
2349
2471
  }
2350
2472
  const mergeKey = line.match(/^\s*<<:\s*\*([A-Za-z0-9_-]+)\s*(#.*)?$/);
2351
2473
  if (mergeKey) {
2352
2474
  const name = mergeKey[1];
2353
2475
  const resolved = scalarAnchors.has(name) || mapAnchors.has(name) ? resolveAlias(`*${name}`) : `*${name}`;
2354
- findings.push(makeFinding(root, rel, "github-workflow", "run", resolved, String(i++), lineNo));
2476
+ findings.push(makeFinding(root, rel, "github-workflow", "run", resolved, String(i++), lineNo, baseAbs));
2355
2477
  continue;
2356
2478
  }
2357
2479
  const m = line.match(/^(\s*)-?\s*run:\s*(.*)$/);
@@ -2374,19 +2496,19 @@ function scanWorkflows(root, findings, warnings) {
2374
2496
  if (blockHeader) {
2375
2497
  const { body, end } = collectBlock(idx, runIndent);
2376
2498
  if (body) {
2377
- findings.push(makeFinding(root, rel, "github-workflow", "run", body, String(i++), lineNoRun));
2499
+ findings.push(makeFinding(root, rel, "github-workflow", "run", body, String(i++), lineNoRun, baseAbs));
2378
2500
  }
2379
2501
  idx = end - 1;
2380
2502
  continue;
2381
2503
  }
2382
2504
  if (rest === "")
2383
2505
  continue;
2384
- findings.push(makeFinding(root, rel, "github-workflow", "run", rest, String(i++), lineNoRun));
2506
+ findings.push(makeFinding(root, rel, "github-workflow", "run", rest, String(i++), lineNoRun, baseAbs));
2385
2507
  }
2386
2508
  }
2387
2509
  }
2388
- function scanGitHooks(root, findings, warnings) {
2389
- scanDirFiles(root, ".githooks", "githooks", findings, warnings);
2510
+ function scanGitHooks(root, findings, warnings, state) {
2511
+ scanDirFiles(root, ".githooks", "githooks", "githooks", findings, warnings, state);
2390
2512
  }
2391
2513
  /**
2392
2514
  * Empty estate note (P-014, hookcheck half). A scan that inventories
@@ -2423,21 +2545,44 @@ export function scanHooks(root) {
2423
2545
  note: EMPTY_ESTATE_NOTE,
2424
2546
  };
2425
2547
  }
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).
2548
+ const disc = discoverSurfaces(root, warnings);
2549
+ const state = { disc, inventoried: new Set(), familyFound: new Map(), familyCapped: new Map(), partial: false };
2550
+ scanPackageJson(root, findings, warnings, state);
2551
+ scanClaude(root, findings, warnings, state);
2552
+ scanPluginHooks(root, findings, warnings, state);
2553
+ scanVscode(root, findings, warnings, state);
2554
+ scanDirFiles(root, ".husky", "husky", "husky", findings, warnings, state);
2555
+ scanLefthook(root, findings, warnings, state);
2556
+ scanPreCommit(root, findings, warnings, state);
2557
+ scanAgentDirs(root, findings, warnings, state);
2558
+ // MCP config surfaces, each inventoried exactly once (P-035), at the
2559
+ // scan root and at every nested project root (P-065).
2435
2560
  // .windsurf/mcp.json joined the covered surfaces in v0.1.6 (P-034);
2436
2561
  // .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);
2562
+ for (const prefix of disc.projectRoots) {
2563
+ const base = prefix === "" ? "" : `${prefix}/`;
2564
+ const baseAbs = prefix === "" ? root : join(root, prefix);
2565
+ for (const name of [".mcp.json", "mcp.json", ".cursor/mcp.json", ".windsurf/mcp.json", ".codex/mcp.json"]) {
2566
+ scanMcpFile(root, `${base}${name}`, findings, warnings, state, baseAbs, prefix !== "");
2567
+ }
2568
+ }
2569
+ scanWorkflows(root, findings, warnings, state);
2570
+ scanGitHooks(root, findings, warnings, state);
2571
+ // Bounded scan honesty (P-065): a scan cut short by the discovery
2572
+ // bounds must say so, naming the bound, instead of presenting as
2573
+ // complete. Surfaces spotted just below the depth bound are counted
2574
+ // in the warning, never silently dropped.
2575
+ if (disc.depthBoundHit) {
2576
+ const below = disc.surfacesBelowBound > 0
2577
+ ? `; at least ${disc.surfacesBelowBound} of the directories just below the bound contain hook surface files or config directories that were not inventoried`
2578
+ : "";
2579
+ 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)`);
2580
+ }
2581
+ for (const [family, capped] of state.familyCapped) {
2582
+ const label = FAMILY_LABELS[family] ?? family;
2583
+ 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`);
2584
+ void capped;
2585
+ }
2441
2586
  // Dedupe identical ids (agent dir scan may double count mcp.json).
2442
2587
  const seen = new Set();
2443
2588
  const deduped = findings.filter((f) => {
@@ -2453,12 +2598,14 @@ export function scanHooks(root) {
2453
2598
  risky: deduped.filter((f) => f.classification === "RISKY").length,
2454
2599
  unknown: deduped.filter((f) => f.classification === "UNKNOWN").length,
2455
2600
  };
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.
2601
+ // Partial scan floor (P-049, extended by P-065): when any inventoried
2602
+ // file or directory could not be read, when a file cap left surface
2603
+ // files uninventoried, or when hook surfaces were spotted just below
2604
+ // the discovery depth bound, the scan did not see the whole estate,
2605
+ // so a bare CLEAN is not an honest verdict. Floor at REVIEW; the
2606
+ // warnings name exactly what was missed.
2460
2607
  let verdict = computeVerdict(deduped);
2461
- if (verdict === "CLEAN" && warnings.some((w) => w.startsWith("Unreadable"))) {
2608
+ if (verdict === "CLEAN" && (warnings.some((w) => w.startsWith("Unreadable")) || state.partial || disc.surfacesBelowBound > 0)) {
2462
2609
  verdict = "REVIEW";
2463
2610
  }
2464
2611
  return {