@reneturcios/hookcheck 0.1.13 → 0.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -31,7 +31,7 @@ Every surface above is discovered by one bounded recursive walk, not only at the
31
31
  Classification per hook:
32
32
 
33
33
  - **SAFE**: matches known safe dev patterns such as test, lint, build, typecheck, husky, or lint-staged, and matches no risky rule.
34
- - **RISKY**: matches at least one risky rule: network fetch, curl piped to an interpreter, base64 decode piped to an interpreter, credential store paths or credential files (matched on content and paths, never on a script filename that merely contains the word credentials), credential shaped environment names (token, secret, password, api key) or bulk environment access, base64 decoding or eval style obfuscation, inline code execution, or a remote URL. Ordinary single named environment reads (NODE_ENV, HOME, RUNNER_DEBUG) are not flagged. Neither are operations that are not reads: `env -u NAME` unsets a variable, `os.environ.setdefault(...)` writes one key with a default, and a `[ -n "$NAME" ]` test checks existence. A hook whose target is a shell script is classified by that script's contents, not by the fact that it is a shell script. Comments are stripped before classification, so a docs URL inside a comment is not a fetch, and a fetch whose every URL is loopback (a localhost health check) is not treated as network behavior. In script files (JavaScript, Python), the contents of string and regex literals are data, not behavior: a defensive guard whose pattern list quotes `eval(` or `curl | sh` performs neither, and hookcheck does not classify it as an attacker. Literals handed to exec style calls (`execSync("...")`, `os.system("...")`) are code by construction and are still matched. Two rules are about value flow, not co-occurrence: ENV-EXFIL fires only when a credential value demonstrably flows into an outbound send (a request payload, a query string, a pipe into a network tool, or printed output next to a send), and CRED-EXFIL when credential store contents are piped or posted straight into a network send. Environment inheritance is not a send: when the only environment movement is inheritance or bulk access passed as a subprocess environment (env=os.environ, a {**os.environ, ...} spread, dict(os.environ), os.environ.copy(), and their JavaScript forms), ENV-EXFIL additionally requires a non-loopback destination in the flow, so a test harness that starts its own server on 127.0.0.1 and hands it the inherited environment does not verdict as exfiltration; the same forms serialized into a payload, or paired with a remote destination, still fire. The ENV-BULK evidence names the form it saw: inheritance and copy forms are described as bulk access in inheritance or copy form, not as a serialization or dump. A token used as authentication (an Authorization header, the userinfo of a push URL to the repo's own host, a `--token` flag) is authentication, not exfiltration, and never fires either rule. Authentication positions are recognized by shape, not only by the Authorization spelling: any curl `-H` header argument whatever its name, any dict or object entry whose key is an authentication header name (Authorization, `x-api-key`, `*-api-key`, `*-auth-token`, and friends) in a one hop script file, and any header shaped entry inside a headers construction (a dict assigned to a headers variable, a `headers=` argument, a headers property of a request options object) all count as authentication positions. Further authentication positions are recognized by the same shape principle: a curl `-u` or `--user` basic auth pair, the value configured for a git `http.extraheader` key (an Authorization header value however it was built), an `::add-mask::` registration (printing a value there is the masking idiom itself), and destination positions in the URL of a send itself: a credential that is an entire URL path segment, the whole URL, or the URL origin (capability URLs are credentials by design: a build hook URL, a preview token in the path, a token query parameter), the value of an authentication named query parameter (`token`, `api_key`, `access_token`, `auth`, `secret`, `signature`, `key`), a bare reference in the curl URL operand position, and the destination first argument of a fetch style call. The body test decides: a credential presented in one of those positions with no credential or bulk data in the body is authentication, while the same credential in a body, in a data named query parameter, in a partial URL path segment, or in printed output still counts as exfiltration, and a capability URL shape plus credential data in the body still fires. Taint does not pass through a locally defined helper function's return value unless the helper itself returns tainted data (its body is analyzed as part of the same text), a printed length or count of a value (`len(x)`, `x.length`, `${#NAME}`) is metadata rather than a disclosure of the value, and a name occurrence that is an object key, a property name, an assignment target, or a parameter declaration is a binding, not a reference to the variable of that name. A shell variable assigned in the same text from a value containing no credential atom (a token named response variable bound to a literal file path, used as a curl `--output` sink), or bound via a command substitution whose only credential atoms sit in destination positions (an HTTP status code variable), is not itself a credential value. Fetch detection requires command position: curl or wget counts as a network fetch only when it is the invoked command (the head of a statement or pipeline segment, after sudo or env wrappers, or the head of a command string handed to an interpreter), never when the word merely names a package in an install line (`pacman -S curl`, `apt-get install curl`). In script files the remote URL rule follows the same data view as the other behavior rules: a URL that appears only inside a string literal or a comment (an error message, a documentation link, an API base constant) is data, while a URL handed to a network call (fetch, requests, http.get, urlopen, axios, sendBeacon) is a fetch target and still fires. Inline code execution (DOWNLOAD-EXEC) fires only when the evaluated content itself carries a download or remote reference; constant inline code such as a `node -e` version comparison downloads nothing and is not flagged.
34
+ - **RISKY**: matches at least one risky rule: network fetch, curl piped to an interpreter, base64 decode piped to an interpreter, credential store paths or credential files (matched on content and paths, never on a script filename that merely contains the word credentials), credential shaped environment names (token, secret, password, api key) or bulk environment access, base64 decoding or eval style obfuscation, inline code execution, or a remote URL. Ordinary single named environment reads (NODE_ENV, HOME, RUNNER_DEBUG) are not flagged. Neither are operations that are not reads: `env -u NAME` unsets a variable, `os.environ.setdefault(...)` writes one key with a default, and a `[ -n "$NAME" ]` test checks existence. A hook whose target is a shell script is classified by that script's contents, not by the fact that it is a shell script. Comments are stripped before classification, so a docs URL inside a comment is not a fetch, and a fetch whose every URL is loopback (a localhost health check) is not treated as network behavior. In script files (JavaScript, Python), the contents of string and regex literals are data, not behavior: a defensive guard whose pattern list quotes `eval(` or `curl | sh` performs neither, and hookcheck does not classify it as an attacker. Literals handed to exec style calls (`execSync("...")`, `os.system("...")`) are code by construction and are still matched. Two rules are about value flow, not co-occurrence: ENV-EXFIL fires only when a credential value demonstrably flows into an outbound send (a request payload, a query string, a pipe into a network tool, or printed output next to a send), and CRED-EXFIL when credential store contents are piped or posted straight into a network send. Environment inheritance is not a send: when the only environment movement is inheritance or bulk access passed as a subprocess environment (env=os.environ, a {**os.environ, ...} spread, dict(os.environ), os.environ.copy(), and their JavaScript forms), ENV-EXFIL additionally requires a non-loopback destination in the flow, so a test harness that starts its own server on 127.0.0.1 and hands it the inherited environment does not verdict as exfiltration; the same forms serialized into a payload, or paired with a remote destination, still fire. The ENV-BULK evidence names the form it saw: inheritance and copy forms are described as bulk access in inheritance or copy form, not as a serialization or dump. A token used as authentication (an Authorization header, the userinfo of a push URL to the repo's own host, a `--token` flag) is authentication, not exfiltration, and never fires either rule. Authentication positions are recognized by shape, not only by the Authorization spelling: any curl `-H` header argument whatever its name, any dict or object entry whose key is an authentication header name (Authorization, `x-api-key`, `*-api-key`, `*-auth-token`, and friends) in a one hop script file, and any header shaped entry inside a headers construction (a dict assigned to a headers variable, a `headers=` argument, a headers property of a request options object) all count as authentication positions. Further authentication positions are recognized by the same shape principle: a curl `-u` or `--user` basic auth pair, the value configured for a git `http.extraheader` key (an Authorization header value however it was built), an `::add-mask::` registration (printing a value there is the masking idiom itself), a credential hand-off position (the value configured for a git `credential.helper` key, including the body of a shell function the helper value names: git reads the helper's echoed username and password lines as its authentication protocol, so that echo is presentation, not disclosure; the same family covers a `core.askPass` value, a `GIT_ASKPASS` or `SSH_ASKPASS` assignment, input piped into a `git credential` invocation, and a heredoc feeding one), and destination positions in the URL of a send itself: a credential that is an entire URL path segment, the whole URL, or the URL origin (capability URLs are credentials by design: a build hook URL, a preview token in the path, a token query parameter), the value of an authentication named query parameter (`token`, `api_key`, `access_token`, `auth`, `secret`, `signature`, `key`), a bare reference in the curl URL operand position, and the destination first argument of a fetch style call. The body test decides: a credential presented in one of those positions with no credential or bulk data in the body is authentication, while the same credential in a body, in a data named query parameter, in a partial URL path segment, or in printed output still counts as exfiltration, and a capability URL shape plus credential data in the body still fires. Taint does not pass through a locally defined helper function's return value unless the helper itself returns tainted data (its body is analyzed as part of the same text), a printed length or count of a value (`len(x)`, `x.length`, `${#NAME}`) is metadata rather than a disclosure of the value, and a name occurrence that is an object key, a property name, an assignment target, or a parameter declaration is a binding, not a reference to the variable of that name. A shell variable assigned in the same text from a value containing no credential atom (a token named response variable bound to a literal file path, used as a curl `--output` sink), or bound via a command substitution whose only credential atoms sit in destination positions (an HTTP status code variable), is not itself a credential value. Fetch detection requires command position: curl or wget counts as a network fetch only when it is the invoked command (the head of a statement or pipeline segment, after sudo or env wrappers, or the head of a command string handed to an interpreter), never when the word merely names a package in an install line (`pacman -S curl`, `apt-get install curl`). In script files the remote URL rule follows the same data view as the other behavior rules: a URL that appears only inside a string literal or a comment (an error message, a documentation link, an API base constant) is data, while a URL handed to a network call (fetch, requests, http.get, urlopen, axios, sendBeacon) is a fetch target and still fires. Inline code execution (DOWNLOAD-EXEC) fires only when the evaluated content itself carries a download or remote reference; constant inline code such as a `node -e` version comparison downloads nothing and is not flagged.
35
35
  - **UNKNOWN**: matches neither safe nor risky patterns. A local script name alone is UNKNOWN until a human reads it. When a hook runs a local file such as `node setup.mjs`, hookcheck reads that file and escalates the finding if the file itself matches risky rules. The same one hop following applies to direct script execution (`./setup.sh`), `node --require ./setup.mjs`, and package.json script chains (`postinstall: npm run other-script`). If the referenced file itself spawns a further local script, the chain runs past the one hop limit: the finding is floored at UNKNOWN, never SAFE, and the evidence names the second hop file, so the verdict lands at REVIEW for a hand read.
36
36
 
37
37
  Verdict rules:
@@ -56,7 +56,8 @@ Honest boundaries, pinned by tests in test/hardening.test.ts:
56
56
  - Classification reads up to 200,000 characters of a hook file or a referenced local file. Beyond that depth a payload can hide; the cap exists so a hostile file cannot make the scan unbounded.
57
57
  - GitHub workflow run steps in block scalars (`run: |`, `run: >`, and chomping variants) are parsed and classified like one line commands. Plain multi-line scalars without a block header are not joined, and reusable workflow internals are not resolved. YAML anchors, aliases, and merge keys on run steps are resolved when the anchor is defined in the same file; anchors applied to other step fields, and anchors defined in other files, are not resolved. Workflow files with CRLF line endings are parsed exactly like their LF equivalents.
58
58
  - Nested discovery is one bounded walk covering every inventoried surface: package.json files, `.claude/settings.json` and `settings.local.json` (including the stow layout `claude/settings.json`), plugin `hooks/hooks.json` and `hooks/hooks-cursor.json` files, `.vscode/tasks.json`, `.github/workflows/*.yml` and `*.yaml`, `.husky/` and `.githooks/` hook files, lefthook configs (`lefthook.yml`, `lefthook.yaml`, `.lefthook.yml`), `.pre-commit-config.yaml`, `.cursor/` and `.codex/` config files, and MCP configs (`.mcp.json`, `mcp.json`, `.cursor/mcp.json`, `.windsurf/mcp.json`, `.codex/mcp.json`, and `.vscode/mcp.json`). A surface is in scope when its project directory sits at most 8 levels below the scan root (the plugin hooks directory itself sits one level below its plugin root), visited in sorted order. Each surface family inventories at most 200 nested files; the scan root files always count. node_modules, .git, dot directories, and symlinked directories are never entered, so surfaces deeper than the depth bound, or anywhere under node_modules, are not scanned. Non-regular files (FIFOs, sockets, device files) are never opened: every file the scanner reads is checked by stat type first and a non-regular file is skipped with a warning naming it, because opening a FIFO blocks until a writer appears and would hang the scan. The bound is never silent: when the walk hits the depth bound with more tree remaining, the report carries a warning naming the depth bound and counts the directories just below the bound that contain hook surface files or config directories; when a family hits the file cap, a warning names the cap and the count found. A scan cut short by either bound, by the cap, or by unreadable files is partial, says so in its warnings, and never verdicts a bare CLEAN when surfaces are known to have been missed. Only the object command form of the plugin schema is inventoried; a hook entry written as a bare string instead of a command object is outside the documented schema and is not inventoried.
59
- - A file or directory the scanner sets out to inventory but cannot read (for example a workflow file at mode 000) is never skipped silently: the report carries a warning naming it, and the verdict is floored at REVIEW while any part of the estate is unread, so a partial scan never presents as a complete all-clear.
59
+ - A file or directory the scanner sets out to inventory but cannot read (for example a workflow file at mode 000) is never skipped silently: the report carries a warning naming it, and the verdict is floored at REVIEW while any part of the estate is unread, so a partial scan never presents as a complete all-clear. The same holds for surfaces skipped under the no-follow policy: a symlinked directory is never entered, and since v0.1.15 the skip produces one warning per directory naming the link and the reason (a dangling or looping symlink at a surface path warns the same way), and a scan that skipped a surface floors at REVIEW.
60
+ - Repeated identical SAFE findings are aggregated (v0.1.15): when the same SAFE finding (same file, source, hook, command, rules, and evidence) repeats at least 3 times, the report carries one aggregate record with a count and the first and last lines instead of one record per occurrence, and an aggregation note in the JSON and the human output states how many findings were collapsed. RISKY and UNKNOWN findings are never aggregated. The verdict is computed on the full inventory before aggregation, so verdicts and exit codes do not change; a 5 MB workflow of about 92,000 identical benign steps reports 1 aggregate record and about 1 KB of JSON instead of 91,980 records and 35 MB.
60
61
  - Custom git hook directories set through core.hooksPath are not scanned: hookcheck inventories the standard `.githooks/` directory and `.husky/`, but it does not parse git config or a committed `.gitconfig` to discover a custom core.hooksPath directory. Hooks kept only in a custom hooksPath directory are outside the inventory; audit them by scanning a tree where they sit in a standard location.
61
62
  - Agent config coverage boundaries: `.gemini/settings.json` is not scanned yet. Template configs (for example `templates/settings.json.template`) are not scanned: they are inert until copied into place, at which point the copied file is scanned under its real name.
62
63
  - UNKNOWN is the dominant classification on large real repos (measured at roughly 80 percent of findings on langfuse and cli/cli in Round 1 validation). The classifications are inventory, not suspicion: a REVIEW verdict on a large estate means the auto-exec surface is mapped and needs human triage, and the per finding evidence says exactly what each hook runs. More workflow step patterns will be classified over time; until then hookcheck does not guess.
package/dist/core.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { relative } from "node:path";
2
2
  import type { Classification, HookFinding, ScanReport, Verdict } from "./types.js";
3
- export declare const VERSION = "0.1.13";
3
+ export declare const VERSION = "0.1.15";
4
4
  interface RuleDef {
5
5
  id: string;
6
6
  pattern: RegExp;
@@ -118,6 +118,31 @@ export interface Discovery {
118
118
  /** True when the depth bound left directories unentered. */
119
119
  depthBoundHit: boolean;
120
120
  }
121
+ /**
122
+ * SAFE finding aggregation (P-070). A single 5 MB workflow of about
123
+ * 92,000 repeated benign steps produced one finding record per step
124
+ * on 0.1.14: 91,980 findings, 35,390,354 bytes of JSON, 23.7 seconds
125
+ * of wall time on the published binary, nearly all of it inventory
126
+ * noise (every finding the same SAFE step). Repetition at that scale
127
+ * is one fact, not 92,000: when at least AGGREGATE_MIN identical
128
+ * SAFE findings (same file, source, hook, command, rules, and
129
+ * evidence) repeat, they collapse into a single aggregate record
130
+ * that keeps the first finding's id and line and carries count (the
131
+ * number folded in) and lastLine (the last folded line). Groups of
132
+ * two stay individual: a pair is ordinary inventory, not mass
133
+ * repetition.
134
+ *
135
+ * Only SAFE findings are ever aggregated. A RISKY or UNKNOWN finding
136
+ * is a per-occurrence fact (its line is where a human must look),
137
+ * and collapsing one could hide the one occurrence that matters.
138
+ * The verdict is computed on the full inventory BEFORE aggregation,
139
+ * so verdicts and exit codes are unchanged by the collapse; counts
140
+ * in the report describe the reported records, and the aggregation
141
+ * object preserves the pre-aggregation total and states the
142
+ * collapse in words, in JSON and in the human renderer. The
143
+ * collapse is never silent.
144
+ */
145
+ export declare const AGGREGATE_MIN = 3;
121
146
  /**
122
147
  * Empty estate note (P-014, hookcheck half). A scan that inventories
123
148
  * nothing must say so next to the verdict, in JSON as well as in the
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.13";
9
+ export const VERSION = "0.1.15";
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 },
@@ -1675,6 +1675,106 @@ function authRanges(text) {
1675
1675
  for (const m of text.matchAll(/\bauth\s*=\s*\(/g)) {
1676
1676
  ranges.push({ start: m.index, end: consumeBalanced(text, m.index + m[0].length - 1) });
1677
1677
  }
1678
+ // Credential hand-off positions (P-111). A credential.helper
1679
+ // definition is the credential protocol of the tool performing the
1680
+ // send: git invokes the helper and reads username and password
1681
+ // lines from its stdout, so a token echoed inside a credential
1682
+ // helper is presented as authentication, exactly like a header or
1683
+ // a -u pair. The v0.1.11 auth model scrubbed headers, -u pairs,
1684
+ // extraheader, and URL userinfo, but saw the helper's echoed
1685
+ // password as print disclosure next to a send, which fired
1686
+ // ENV-EXFIL on All-Hands-AI/OpenHands (the Publish live E2E media
1687
+ // step, whose push token is used only inside such a helper). The
1688
+ // same protocol family covers core.askPass values, askpass
1689
+ // environment assignments, input piped or heredoc-fed into a
1690
+ // git credential invocation, and the body of a shell function a
1691
+ // credential.helper value names. Only the hand-off span itself is
1692
+ // scrubbed: the same token echoed to a log, or posted by a curl
1693
+ // statement elsewhere in the step, keeps full exfil weight.
1694
+ const configValueAfter = (from) => {
1695
+ let i = from;
1696
+ while (i < text.length && /\s/.test(text[i]))
1697
+ i++;
1698
+ if (text[i] === "=" || text[i] === ":") {
1699
+ i++;
1700
+ while (i < text.length && /\s/.test(text[i]))
1701
+ i++;
1702
+ }
1703
+ if (i >= text.length)
1704
+ return null;
1705
+ if (text[i] === "'" || text[i] === '"' || text[i] === "`") {
1706
+ const end = consumeLiteral(text, i);
1707
+ return end > i ? { start: i, end } : null;
1708
+ }
1709
+ let end = i;
1710
+ while (end < text.length && !/[\s;"']/.test(text[end]))
1711
+ end++;
1712
+ return end > i ? { start: i, end } : null;
1713
+ };
1714
+ const helperNames = [];
1715
+ for (const m of text.matchAll(/\bcredential\.helper\b/gi)) {
1716
+ const v = configValueAfter(m.index + m[0].length);
1717
+ if (!v)
1718
+ continue;
1719
+ ranges.push(v);
1720
+ const rawVal = text.slice(v.start, v.end).replace(/^['"`]/, "").replace(/^!/, "");
1721
+ const tok = rawVal.split(/\s+/)[0] ?? "";
1722
+ if (/^[A-Za-z_][A-Za-z0-9_-]*$/.test(tok))
1723
+ helperNames.push(tok);
1724
+ }
1725
+ for (const m of text.matchAll(/\bcore\.askpass\b/gi)) {
1726
+ const v = configValueAfter(m.index + m[0].length);
1727
+ if (v)
1728
+ ranges.push(v);
1729
+ }
1730
+ for (const m of text.matchAll(/\b(?:GIT_ASKPASS|SSH_ASKPASS|SUDO_ASKPASS)\s*=\s*/g)) {
1731
+ const v = configValueAfter(m.index + m[0].length);
1732
+ if (v)
1733
+ ranges.push(v);
1734
+ }
1735
+ // A shell function a credential.helper value names by bare word:
1736
+ // its body is the helper program, so its echoed password lines are
1737
+ // protocol output. Builtin helper names (cache, store, manager)
1738
+ // simply have no definition in the text and match nothing.
1739
+ for (const name of helperNames) {
1740
+ const fnRe = new RegExp(`(?:^|[\\n;])\\s*(?:function\\s+)?${name}\\s*(?:\\(\\s*\\))?\\s*\\{`, "g");
1741
+ let fm;
1742
+ while ((fm = fnRe.exec(text)) !== null) {
1743
+ const brace = fm.index + fm[0].length - 1;
1744
+ const end = consumeBalanced(text, brace);
1745
+ if (end > fm.index)
1746
+ ranges.push({ start: fm.index, end });
1747
+ }
1748
+ }
1749
+ // Input handed to a git credential invocation: every earlier
1750
+ // segment of a pipeline whose later segment runs git credential
1751
+ // is protocol input (protocol, host, username, password lines),
1752
+ // not a disclosure to a log.
1753
+ for (const stmt of splitShell(text, ["\n", ";", "&&", "||"])) {
1754
+ const stmtText = text.slice(stmt.start, stmt.end);
1755
+ const segs = splitShell(stmtText, ["|"]);
1756
+ if (segs.length < 2)
1757
+ continue;
1758
+ for (let k = 1; k < segs.length; k++) {
1759
+ const segText = stmtText.slice(segs[k].start, segs[k].end);
1760
+ if (!/^\s*(?:[A-Za-z_][A-Za-z0-9_]*=\S+\s+)*(?:sudo\s+)?git\s+credential\b/.test(segText))
1761
+ continue;
1762
+ for (let e = 0; e < k; e++) {
1763
+ ranges.push({ start: stmt.start + segs[e].start, end: stmt.start + segs[e].end });
1764
+ }
1765
+ }
1766
+ }
1767
+ // A heredoc feeding git credential is protocol input as well:
1768
+ // the body up to the delimiter line is read by git as credential
1769
+ // fields, never printed to a log.
1770
+ for (const m of text.matchAll(/\bgit\s+credential\b[^\n]*<<-?\s*['"]?([A-Za-z_][A-Za-z0-9_]*)['"]?/g)) {
1771
+ const bodyStart = m.index + m[0].length;
1772
+ const rest = text.slice(bodyStart);
1773
+ const endMatch = rest.match(new RegExp(`\\n[ \\t]*${m[1]}\\b`));
1774
+ if (endMatch && (endMatch.index ?? 0) > 0) {
1775
+ ranges.push({ start: bodyStart, end: bodyStart + (endMatch.index ?? 0) });
1776
+ }
1777
+ }
1678
1778
  // Dict and object entries: authentication header keys anywhere, and
1679
1779
  // any header-shaped key inside a headers construction.
1680
1780
  const entryRe = /(?:^|[{,;\n])\s*(?:(["'])([A-Za-z][A-Za-z0-9_-]*)\1|([A-Za-z][A-Za-z0-9_-]*))\s*:\s*/g;
@@ -3111,6 +3211,7 @@ function discoverSurfaces(root, warnings) {
3111
3211
  if (skipDirs.has(name) || name.startsWith("."))
3112
3212
  continue;
3113
3213
  const childAbs = join(cur.abs, name);
3214
+ const childRel = cur.rel === "" ? name : `${cur.rel}/${name}`;
3114
3215
  let lst;
3115
3216
  try {
3116
3217
  lst = lstatSync(childAbs);
@@ -3118,9 +3219,34 @@ function discoverSurfaces(root, warnings) {
3118
3219
  catch {
3119
3220
  continue;
3120
3221
  }
3121
- if (lst.isSymbolicLink() || !lst.isDirectory())
3222
+ if (lst.isSymbolicLink()) {
3223
+ // No-follow policy (P-074): a symlinked directory is never
3224
+ // entered, but the skip is no longer silent. One warning per
3225
+ // skipped directory names the path and the reason, when the
3226
+ // link resolves to a directory (the container shape that can
3227
+ // hold hook surfaces) or when a surface-shaped name cannot
3228
+ // be resolved at all. A link to a regular file is not a
3229
+ // directory container and is judged by the surface scanners
3230
+ // at its own path, so it earns no warning here.
3231
+ let targetIsDir = false;
3232
+ let unresolved = false;
3233
+ try {
3234
+ targetIsDir = statSync(childAbs).isDirectory();
3235
+ }
3236
+ catch {
3237
+ unresolved = true;
3238
+ }
3239
+ if (targetIsDir) {
3240
+ warnings.push(`Symlinked directory skipped: ${childRel} is a symbolic link to a directory; symlinked directories are not followed, so any hook surfaces inside it were not inventoried`);
3241
+ }
3242
+ else if (unresolved && SURFACE_DIR_INDICATORS.has(name)) {
3243
+ warnings.push(`Symlinked directory skipped: ${childRel} is a symbolic link that could not be resolved; it was not inventoried`);
3244
+ }
3245
+ continue;
3246
+ }
3247
+ if (!lst.isDirectory())
3122
3248
  continue;
3123
- children.push({ abs: childAbs, rel: cur.rel === "" ? name : `${cur.rel}/${name}`, depth: cur.depth + 1 });
3249
+ children.push({ abs: childAbs, rel: childRel, depth: cur.depth + 1 });
3124
3250
  }
3125
3251
  if (cur.depth >= NESTED_MAX_DEPTH) {
3126
3252
  frontierDirs += children.length;
@@ -3266,13 +3392,29 @@ function scanClaude(root, findings, warnings, state) {
3266
3392
  const baseAbs = prefix === "" ? root : join(root, prefix);
3267
3393
  for (const rel of claudeSettingsCandidates(root, prefix)) {
3268
3394
  const abs = join(root, rel);
3269
- if (!existsSync(abs))
3395
+ // lstat first (P-074): a candidate that is absent is the normal
3396
+ // case and stays silent, but a candidate that EXISTS in some
3397
+ // form and cannot be examined (a symlink loop, a dangling
3398
+ // link, an unreadable parent directory) was skipped silently
3399
+ // before v0.1.15; it now warns by name, once per path.
3400
+ let lst;
3401
+ try {
3402
+ lst = lstatSync(abs);
3403
+ }
3404
+ catch {
3270
3405
  continue;
3406
+ }
3407
+ void lst;
3271
3408
  let st;
3272
3409
  try {
3273
3410
  st = statSync(abs);
3274
3411
  }
3275
- catch {
3412
+ catch (err) {
3413
+ if (!state.inventoried.has(rel)) {
3414
+ state.inventoried.add(rel);
3415
+ const code = err?.code ?? "error";
3416
+ warnings.push(`Unreadable surface skipped: ${rel} could not be examined (${code}); it was not inventoried`);
3417
+ }
3276
3418
  continue;
3277
3419
  }
3278
3420
  if (st.isDirectory()) {
@@ -3282,8 +3424,13 @@ function scanClaude(root, findings, warnings, state) {
3282
3424
  }
3283
3425
  continue;
3284
3426
  }
3285
- if (!st.isFile())
3427
+ if (!st.isFile()) {
3428
+ if (!state.inventoried.has(rel)) {
3429
+ state.inventoried.add(rel);
3430
+ warnings.push(`Non-regular surface skipped: ${rel} is not a regular file; it was not inventoried`);
3431
+ }
3286
3432
  continue;
3433
+ }
3287
3434
  if (!claimFile(state, "settings", rel, prefix !== ""))
3288
3435
  continue;
3289
3436
  const data = readJson(root, rel, warnings);
@@ -3404,18 +3551,23 @@ function scanDirFiles(root, dirName, source, family, findings, warnings, state)
3404
3551
  }
3405
3552
  for (const name of entries.sort()) {
3406
3553
  const fileAbs = join(abs, name);
3554
+ const fileRel = `${dirRel}/${name}`;
3555
+ // An entry the directory listing proved exists but stat cannot
3556
+ // examine (a dangling symlink, a symlink loop) was skipped
3557
+ // silently before v0.1.15 (P-074); it now warns by name.
3407
3558
  let st;
3408
3559
  try {
3409
3560
  st = statSync(fileAbs);
3410
3561
  }
3411
- catch {
3562
+ catch (err) {
3563
+ const code = err?.code ?? "error";
3564
+ warnings.push(`Unreadable surface skipped: ${fileRel} could not be examined (${code}); it was not inventoried`);
3412
3565
  continue;
3413
3566
  }
3414
3567
  if (!st.isFile())
3415
3568
  continue;
3416
3569
  if (name === "_" || name.startsWith("."))
3417
3570
  continue;
3418
- const fileRel = `${dirRel}/${name}`;
3419
3571
  if (!claimFile(state, family, fileRel, prefix !== ""))
3420
3572
  continue;
3421
3573
  const content = readTextWarn(fileAbs, fileRel, warnings);
@@ -3496,20 +3648,23 @@ function scanAgentDirs(root, findings, warnings, state) {
3496
3648
  entries = readdirSync(abs);
3497
3649
  }
3498
3650
  catch {
3651
+ warnings.push(`Unreadable directory skipped: ${base}${dir} could not be listed; the scan is partial`);
3499
3652
  continue;
3500
3653
  }
3501
3654
  for (const name of entries.sort()) {
3502
3655
  const fileAbs = join(abs, name);
3656
+ const rel = `${base}${dir}/${name}`;
3503
3657
  let st;
3504
3658
  try {
3505
3659
  st = statSync(fileAbs);
3506
3660
  }
3507
- catch {
3661
+ catch (err) {
3662
+ const code = err?.code ?? "error";
3663
+ warnings.push(`Unreadable surface skipped: ${rel} could not be examined (${code}); it was not inventoried`);
3508
3664
  continue;
3509
3665
  }
3510
3666
  if (!st.isFile())
3511
3667
  continue;
3512
- const rel = `${base}${dir}/${name}`;
3513
3668
  // mcp.json is an MCP config surface, inventoried exactly once by
3514
3669
  // the central scanMcpFile pass in scanHooks (P-035). Running it
3515
3670
  // through the generic command walk here as well counted every
@@ -3627,7 +3782,15 @@ function scanOneWorkflow(root, rel, baseAbs, findings, warnings) {
3627
3782
  if (rawFile === null)
3628
3783
  return;
3629
3784
  const raw = rawFile.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
3630
- const lines = raw.split("\n");
3785
+ // Trailing whitespace is not YAML content (P-075): a line that
3786
+ // differs from its twin only by trailing spaces must parse
3787
+ // identically. Before v0.1.15 a map anchor line carrying only
3788
+ // trailing whitespace after the anchor name matched the scalar
3789
+ // anchor pattern with a whitespace "value", so the mapping
3790
+ // anchor never registered, its definition run line was
3791
+ // inventoried as a live step, and alias and merge-key steps
3792
+ // resolved to an empty command at UNKNOWN.
3793
+ const lines = raw.split("\n").map((l) => l.replace(/[ \t]+$/, ""));
3631
3794
  const indentOf = (l) => l.length - l.trimStart().length;
3632
3795
  const unquote = (s) => {
3633
3796
  const t = s.trim();
@@ -3675,7 +3838,7 @@ function scanOneWorkflow(root, rel, baseAbs, findings, warnings) {
3675
3838
  };
3676
3839
  for (let idx = 0; idx < lines.length; idx++) {
3677
3840
  const line = lines[idx];
3678
- const scalarM = line.match(/^(\s*)(?:-\s*)?([A-Za-z0-9_-]+):\s*&([A-Za-z0-9_-]+)\s+(.+?)\s*$/);
3841
+ const scalarM = line.match(/^(\s*)(?:-\s*)?([A-Za-z0-9_-]+):\s*&([A-Za-z0-9_-]+)\s+(\S.*?)\s*$/);
3679
3842
  if (scalarM && scalarM[2] !== "run") {
3680
3843
  scalarAnchors.set(scalarM[3], unquote(scalarM[4]));
3681
3844
  continue;
@@ -3743,6 +3906,11 @@ function scanOneWorkflow(root, rel, baseAbs, findings, warnings) {
3743
3906
  else if (/^\*[A-Za-z0-9_-]+$/.test(rest)) {
3744
3907
  rest = resolveAlias(rest);
3745
3908
  }
3909
+ // A plain quoted scalar is the same command as its unquoted
3910
+ // form (P-076): run: "echo single-line" is echo single-line.
3911
+ // Strip one pair of surrounding quotes before classification
3912
+ // and storage, so both forms produce the identical finding.
3913
+ rest = unquote(rest);
3746
3914
  const blockHeader = rest.match(/^([|>])([+-]?)\s*(#.*)?$/);
3747
3915
  if (blockHeader) {
3748
3916
  const { body, end } = collectBlock(idx, runIndent);
@@ -3761,6 +3929,79 @@ function scanOneWorkflow(root, rel, baseAbs, findings, warnings) {
3761
3929
  function scanGitHooks(root, findings, warnings, state) {
3762
3930
  scanDirFiles(root, ".githooks", "githooks", "githooks", findings, warnings, state);
3763
3931
  }
3932
+ /**
3933
+ * SAFE finding aggregation (P-070). A single 5 MB workflow of about
3934
+ * 92,000 repeated benign steps produced one finding record per step
3935
+ * on 0.1.14: 91,980 findings, 35,390,354 bytes of JSON, 23.7 seconds
3936
+ * of wall time on the published binary, nearly all of it inventory
3937
+ * noise (every finding the same SAFE step). Repetition at that scale
3938
+ * is one fact, not 92,000: when at least AGGREGATE_MIN identical
3939
+ * SAFE findings (same file, source, hook, command, rules, and
3940
+ * evidence) repeat, they collapse into a single aggregate record
3941
+ * that keeps the first finding's id and line and carries count (the
3942
+ * number folded in) and lastLine (the last folded line). Groups of
3943
+ * two stay individual: a pair is ordinary inventory, not mass
3944
+ * repetition.
3945
+ *
3946
+ * Only SAFE findings are ever aggregated. A RISKY or UNKNOWN finding
3947
+ * is a per-occurrence fact (its line is where a human must look),
3948
+ * and collapsing one could hide the one occurrence that matters.
3949
+ * The verdict is computed on the full inventory BEFORE aggregation,
3950
+ * so verdicts and exit codes are unchanged by the collapse; counts
3951
+ * in the report describe the reported records, and the aggregation
3952
+ * object preserves the pre-aggregation total and states the
3953
+ * collapse in words, in JSON and in the human renderer. The
3954
+ * collapse is never silent.
3955
+ */
3956
+ export const AGGREGATE_MIN = 3;
3957
+ function aggregateSafeFindings(findings) {
3958
+ const keyOf = (f) => [f.file, f.source, f.hook, f.command, f.classification, f.rules.join(","), f.evidence].join("\u0001");
3959
+ const groups = new Map();
3960
+ findings.forEach((f, i) => {
3961
+ if (f.classification !== "SAFE")
3962
+ return;
3963
+ const key = keyOf(f);
3964
+ const arr = groups.get(key) ?? [];
3965
+ arr.push(i);
3966
+ groups.set(key, arr);
3967
+ });
3968
+ let aggregateRecords = 0;
3969
+ let collapsedFindings = 0;
3970
+ const out = [];
3971
+ findings.forEach((f, i) => {
3972
+ if (f.classification !== "SAFE") {
3973
+ out.push(f);
3974
+ return;
3975
+ }
3976
+ const idxs = groups.get(keyOf(f));
3977
+ if (idxs.length < AGGREGATE_MIN) {
3978
+ out.push(f);
3979
+ return;
3980
+ }
3981
+ if (idxs[0] !== i)
3982
+ return; // folded into the group's first record
3983
+ aggregateRecords++;
3984
+ collapsedFindings += idxs.length - 1;
3985
+ const last = findings[idxs[idxs.length - 1]];
3986
+ out.push({
3987
+ ...f,
3988
+ count: idxs.length,
3989
+ ...(last.line !== undefined && f.line !== undefined && last.line !== f.line ? { lastLine: last.line } : {}),
3990
+ });
3991
+ });
3992
+ if (aggregateRecords === 0)
3993
+ return { findings: out };
3994
+ const folded = collapsedFindings + aggregateRecords;
3995
+ return {
3996
+ findings: out,
3997
+ aggregation: {
3998
+ findingsBeforeAggregation: findings.length,
3999
+ aggregateRecords,
4000
+ collapsedFindings,
4001
+ note: `${folded} identical SAFE findings were collapsed into ${aggregateRecords} counted aggregate record(s); ${collapsedFindings} finding record(s) were folded away. Only SAFE findings are aggregated; RISKY and UNKNOWN findings are always reported individually. The verdict was computed on the full inventory of ${findings.length} findings before aggregation, so the verdict and exit codes are unchanged.`,
4002
+ },
4003
+ };
4004
+ }
3764
4005
  /**
3765
4006
  * Empty estate note (P-014, hookcheck half). A scan that inventories
3766
4007
  * nothing must say so next to the verdict, in JSON as well as in the
@@ -3843,27 +4084,34 @@ export function scanHooks(root) {
3843
4084
  seen.add(key);
3844
4085
  return true;
3845
4086
  });
3846
- const counts = {
3847
- total: deduped.length,
3848
- safe: deduped.filter((f) => f.classification === "SAFE").length,
3849
- risky: deduped.filter((f) => f.classification === "RISKY").length,
3850
- unknown: deduped.filter((f) => f.classification === "UNKNOWN").length,
3851
- };
3852
- // Partial scan floor (P-049, extended by P-065): when any inventoried
3853
- // file or directory could not be read, when a file cap left surface
3854
- // files uninventoried, or when hook surfaces were spotted just below
3855
- // the discovery depth bound, the scan did not see the whole estate,
3856
- // so a bare CLEAN is not an honest verdict. Floor at REVIEW; the
3857
- // warnings name exactly what was missed.
4087
+ // The verdict is computed on the full inventory, before any
4088
+ // aggregation (P-070): collapsing repeated SAFE records for
4089
+ // presentation must never move a verdict or an exit code.
3858
4090
  let verdict = computeVerdict(deduped);
3859
- if (verdict === "CLEAN" && (warnings.some((w) => w.startsWith("Unreadable")) || state.partial || disc.surfacesBelowBound > 0)) {
4091
+ // Partial scan floor (P-049, extended by P-065 and P-074): when any
4092
+ // inventoried file or directory could not be read, when a file cap
4093
+ // left surface files uninventoried, when hook surfaces were
4094
+ // spotted just below the discovery depth bound, or when a
4095
+ // symlinked directory was skipped under the no-follow policy, the
4096
+ // scan did not see the whole estate, so a bare CLEAN is not an
4097
+ // honest verdict. Floor at REVIEW; the warnings name exactly what
4098
+ // was missed.
4099
+ if (verdict === "CLEAN" && (warnings.some((w) => w.startsWith("Unreadable") || w.startsWith("Symlinked directory skipped")) || state.partial || disc.surfacesBelowBound > 0)) {
3860
4100
  verdict = "REVIEW";
3861
4101
  }
4102
+ const { findings: reported, aggregation } = aggregateSafeFindings(deduped);
4103
+ const counts = {
4104
+ total: reported.length,
4105
+ safe: reported.filter((f) => f.classification === "SAFE").length,
4106
+ risky: reported.filter((f) => f.classification === "RISKY").length,
4107
+ unknown: reported.filter((f) => f.classification === "UNKNOWN").length,
4108
+ };
3862
4109
  return {
3863
4110
  path: root,
3864
4111
  verdict,
3865
- findings: deduped,
4112
+ findings: reported,
3866
4113
  counts,
4114
+ ...(aggregation ? { aggregation } : {}),
3867
4115
  warnings,
3868
4116
  scannedAt: new Date().toISOString(),
3869
4117
  ...(counts.total === 0 ? { note: EMPTY_ESTATE_NOTE } : {}),
@@ -3892,10 +4140,14 @@ export function renderReport(report) {
3892
4140
  const lines = [];
3893
4141
  lines.push(`hookcheck ${report.path}: ${report.verdict}`);
3894
4142
  lines.push(` ${report.counts.total} hook(s): ${report.counts.safe} SAFE, ${report.counts.risky} RISKY, ${report.counts.unknown} UNKNOWN`);
4143
+ if (report.aggregation)
4144
+ lines.push(` aggregation: ${report.aggregation.note}`);
3895
4145
  if (report.note)
3896
4146
  lines.push(` note: ${report.note}`);
3897
4147
  for (const f of report.findings) {
3898
- lines.push(` [${f.classification}] ${f.id} (${f.source}${f.line !== undefined ? `, line ${f.line}` : ""})`);
4148
+ const linePart = f.line !== undefined ? (f.lastLine !== undefined ? `, lines ${f.line}-${f.lastLine}` : `, line ${f.line}`) : "";
4149
+ const countPart = f.count !== undefined ? `, count ${f.count}` : "";
4150
+ lines.push(` [${f.classification}] ${f.id} (${f.source}${linePart}${countPart})`);
3899
4151
  lines.push(` command: ${f.command.slice(0, 200)}`);
3900
4152
  if (f.rules.length)
3901
4153
  lines.push(` rules: ${f.rules.join(", ")}`);