@reneturcios/hookcheck 0.1.5 → 0.1.7

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
@@ -14,28 +14,29 @@ Reports in October 2026 described AI agents orchestrating attacks and a self-pro
14
14
 
15
15
  | surface | what it looks for |
16
16
  | --- | --- |
17
- | `package.json` | preinstall, install, postinstall, prepare scripts |
17
+ | `package.json` | preinstall, install, postinstall, prepare scripts. The root package.json and nested workspace package.json files (for example `packages/*/package.json`, `apps/*/package.json`, any directory up to 4 levels below the root, node_modules excluded) are inventoried the same way |
18
18
  | `.claude/settings.json` and `settings.local.json` | SessionStart, PreToolUse, and other hook commands. Also discovered: the GNU stow layout (`claude/settings.json`, no dot) and settings nested one level under a config dir (for example `config/.claude/settings.json`). A settings path that exists as a directory is reported as a warning, not silently skipped |
19
+ | Claude Code plugin hooks | `hooks/hooks.json` in a plugin root: at the repo root, or nested under plugin directories (for example `plugins/<name>/hooks/hooks.json`), with the plugin root at most 4 levels below the scan root. The file carries the same events map as the hooks key of `.claude/settings.json` (SessionStart, PreToolUse, PostToolUse, Stop, UserPromptSubmit, and the rest of the Claude Code event set) and its commands are classified exactly the same way. The sibling `hooks/hooks-cursor.json` variant carries the same command object shape under Cursor event names and is inventoried the same way |
19
20
  | `.vscode/tasks.json` | tasks with runOn folderOpen, plus task inventory |
20
21
  | `.husky/` | every hook file and its full command content |
21
22
  | `lefthook.yml` | run commands |
22
23
  | `.pre-commit-config.yaml` | entry commands and remote repo URLs |
23
24
  | `.cursor/` and `.codex/` | config commands and files |
24
- | MCP configs | `.mcp.json`, `mcp.json`, `.vscode/mcp.json`, `.cursor/mcp.json` servers that autostart |
25
+ | MCP configs | `.mcp.json`, `mcp.json`, `.vscode/mcp.json`, `.cursor/mcp.json`, `.windsurf/mcp.json`, `.codex/mcp.json` servers that autostart, each inventoried exactly once |
25
26
  | `.github/workflows/` | run steps, as inventory, including block scalar bodies (`run: |` and `run: >`, with chomping variants). Every workflow finding carries the line number of its `run:` key |
26
27
  | `.githooks/` | hook files |
27
28
 
28
29
  Classification per hook:
29
30
 
30
31
  - **SAFE**: matches known safe dev patterns such as test, lint, build, typecheck, husky, or lint-staged, and matches no risky rule.
31
- - **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. 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. The flow analysis follows code references only: a variable named inside prose (help text, a banner string, a docstring) is mentioned, not used, and cannot make a send look tainted.
32
- - **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`).
32
+ - **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. 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, while the same credential in a body, a query string, or printed output still counts as exfiltration. 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.
33
+ - **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.
33
34
 
34
35
  Verdict rules:
35
36
 
36
37
  - **CLEAN**: every finding is SAFE, or no hooks were found. Exit code 0. When nothing was found, the report carries a note saying nothing was found to audit, in the JSON as well as the human output, so an empty estate never reads as an all-clear.
37
38
  - **REVIEW**: at least one UNKNOWN finding, or a RISKY finding whose evidence is weak on its own (a remote URL alone, a download by itself, credential access with no network behavior). Exit code 1.
38
- - **COMPROMISED-PATTERN**: at least one RISKY finding with strong evidence: fetched or decoded content executed directly as code (curl piped to an interpreter, base64 decode piped to an interpreter, eval of fetched content), a verified exfil flow (a credential value flowing into an outbound send, or credential store contents piped into a network tool), or credential store access combined with network behavior in the same finding. This is the Shai-Hulud persistence shape. Treat the repo as untrusted until proven otherwise. Exit code 1. Canonical legitimate installers that download their own binary (esbuild, puppeteer) verdict REVIEW under this calibration, not COMPROMISED-PATTERN; the download is reported as a RISKY finding with its evidence, and the top verdict no longer over-claims it. The same applies to a repo's own release automation: a step that reads its token and pushes to its own repo, posts its own workflow dispatch, or updates its own homebrew tap uses the token as authentication and verdicts REVIEW, because the credential value never flows into a payload to a third party.
39
+ - **COMPROMISED-PATTERN**: at least one RISKY finding with strong evidence: fetched or decoded content executed directly as code (curl piped to an interpreter, base64 decode piped to an interpreter, eval of fetched content), a verified exfil flow (a credential value flowing into an outbound send, or credential store contents piped into a network tool), or credential store access combined with network behavior in the same finding. This is the Shai-Hulud persistence shape. Treat the repo as untrusted until proven otherwise. Exit code 1. Canonical legitimate installers that download their own binary (esbuild, puppeteer) verdict REVIEW under this calibration, not COMPROMISED-PATTERN; the download is reported as a RISKY finding with its evidence, and the top verdict no longer over-claims it. The same applies to a repo's own release automation: a step that reads its token and pushes to its own repo, posts its own workflow dispatch, or updates its own homebrew tap uses the token as authentication and verdicts REVIEW, because the credential value never flows into a payload to a third party. One more calibration in the same family: a CI workflow step whose only severe evidence is a curl pipe to a named vendor's official installer over HTTPS (an installer shaped path such as /install or /uv/install.sh, or a known installer host such as astral.sh or sh.rustup.rs), with no credential, persistence, or second severe signal in the finding, verdicts REVIEW with the finding kept RISKY. The same pipe on an auto-exec hook surface (an install script, a Claude settings or plugin hook, a folderOpen task), a pipe to a non-installer source, or a pipe accompanied by any other signal still reaches COMPROMISED-PATTERN.
39
40
 
40
41
  Every finding reports file, hook id, command text, classification, rule ids, and evidence. Malformed JSON is reported as a warning, never silently ignored. Scanning a path that does not exist, or that is a file instead of a directory, is a clean error with exit code 2.
41
42
 
@@ -43,7 +44,7 @@ Every finding reports file, hook id, command text, classification, rule ids, and
43
44
 
44
45
  Honest boundaries, pinned by tests in test/hardening.test.ts:
45
46
 
46
- - One hop only. hookcheck follows a hook into the local file or package.json script it directly invokes, and classifies that target. It does not follow a second hop, and it does not trace data flow inside a script. A payload assembled at runtime from pieces that never appear as a recognizable pattern in any single file will not be named; the hook that runs it will usually still land in UNKNOWN or REVIEW, which is the signal to read it by hand.
47
+ - One hop only. hookcheck follows a hook into the local file or package.json script it directly invokes, and classifies that target. It does not follow a second hop, and it does not trace data flow inside a script. A payload assembled at runtime from pieces that never appear as a recognizable pattern in any single file will not be named; the hook that runs it will usually still land in UNKNOWN or REVIEW, which is the signal to read it by hand. When the first hop file spawns a further local script, that landing is enforced rather than usual: the finding is floored at UNKNOWN and the evidence names the second hop.
47
48
  - Pattern based, not execution based. hookcheck never runs the hooks it finds. A RISKY verdict means the command text matches a known bad shape, not that the payload was observed doing harm. A CLEAN verdict means no known shape matched, not that the repo is proven safe.
48
49
  - Exfil detection is a static value flow check inside one finding: a credential value must visibly reach a send construct in the same command or referenced file (including through one variable assignment chain). A value laundered through several files, assembled at runtime, or sent by a tool whose arguments never name it will not be named as exfil; the finding still lands in RISKY or UNKNOWN on its other evidence. Conversely, a credential value placed in a request body or query string counts as exfil shape even to a first party host, because credentials belong in authentication positions, not payloads.
49
50
  - Quote awareness has edges. In script files, string and regex literal contents are treated as data for the behavior rules; a payload hidden inside a string that is assembled and executed indirectly (not through a recognized exec call) can be missed. In shell text, quoted strings are command text and stay visible, except echo and printf arguments, which are output data.
@@ -51,7 +52,11 @@ Honest boundaries, pinned by tests in test/hardening.test.ts:
51
52
  - Duplicate JSON keys resolve last-wins, exactly as JSON.parse, Node, and npm resolve them, so the scanner sees the same value that would actually run.
52
53
  - Non-string package.json script values are skipped: npm only executes string script values, so an array or number in scripts cannot auto-run.
53
54
  - 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.
54
- - 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 YAML anchors and reusable workflow internals are not resolved.
55
+ - 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.
56
+ - Nested package.json inventory is bounded: the root package.json plus nested package.json files whose directory sits at most 4 levels below the root, visited in sorted order, capped at 200 nested files (the cap is reported as a warning when hit). node_modules, .git, dot directories, and symlinked directories are never entered, so package.json files deeper than the depth bound, or anywhere under node_modules, are not scanned.
57
+ - Plugin hooks inventory is bounded the same way: `hooks/hooks.json` and `hooks/hooks-cursor.json` files whose plugin root sits at most 4 levels below the scan root, visited in sorted order, capped at 200 files (the cap is reported as a warning when hit), with node_modules, .git, dot directories, and symlinked directories never entered. Plugin hook files deeper than the bound are not scanned. 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.
58
+ - 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
+ - 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.
55
60
  - 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.
56
61
  - 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.
57
62
 
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.5";
3
+ export declare const VERSION = "0.1.7";
4
4
  interface RuleDef {
5
5
  id: string;
6
6
  pattern: RegExp;
@@ -58,28 +58,48 @@ export declare function classifyCommand(command: string, lang?: Lang): {
58
58
  rules: string[];
59
59
  evidence: string;
60
60
  };
61
+ export declare function computeVerdict(findings: HookFinding[]): Verdict;
61
62
  /**
62
- * Verdict calibration (Round 1, P-003 and P-004; Round 2, P-018).
63
- * COMPROMISED-PATTERN requires strong evidence inside a single finding:
64
- * (a) a severe rule: fetched or decoded content executed directly as
65
- * code (curl piped to an interpreter, base64 decode piped to an
66
- * interpreter, eval of fetched content), or a verified exfil flow
67
- * (ENV-EXFIL, CRED-EXFIL), or
68
- * (b) credential STORE access (CRED-ACCESS: actual credential files
69
- * and stores) combined with network behavior.
70
- * Round 2 narrowed path (b): in v0.1.2 and v0.1.3 any credential evidence
71
- * counted, including a credential shaped env NAME next to a URL. On real
72
- * estates that is the normal shape of a repo's own authenticated
73
- * automation (a release step reading its token and pushing to its own
74
- * repo), and it drove every one of the 15 false COMPROMISED drivers
75
- * hand-read in Round 2. Env references plus network behavior are RISKY
76
- * and verdict REVIEW; the verdict now needs the value to actually move
77
- * (path a) or an actual credential store to be touched (path b).
78
- * Weak signals alone (a remote URL, a localhost fetch, an ordinary env
79
- * read, a download by itself) classify RISKY at most, and a RISKY-only
80
- * estate verdicts REVIEW, never COMPROMISED-PATTERN.
63
+ * Nested package.json discovery (P-032). Install scripts in workspace
64
+ * subpackages (packages/foo, apps/web) run on a root install, so a
65
+ * root-only scan misses exactly the surface a worm would persist in.
66
+ * The walk is bounded so a hostile or giant tree cannot explode the
67
+ * report: directories are visited in sorted order to depth
68
+ * PACKAGE_MAX_DEPTH (the package.json directory depth below the root),
69
+ * node_modules, .git, and dot directories are never entered, symlinked
70
+ * directories are never followed (a symlink loop cannot hang the scan),
71
+ * and at most PACKAGE_MAX_NESTED nested files are inventoried, with the
72
+ * cap reported as a warning when it is hit.
81
73
  */
82
- export declare function computeVerdict(findings: HookFinding[]): Verdict;
74
+ export declare const PACKAGE_MAX_DEPTH = 4;
75
+ export declare const PACKAGE_MAX_NESTED = 200;
76
+ /**
77
+ * Claude Code plugin hook files (P-060, BLOCKER). Plugins distribute
78
+ * hooks in a hooks/ directory at the plugin root: hooks/hooks.json at
79
+ * the repo root for a single plugin repo (obra/superpowers), or
80
+ * plugins/<name>/hooks/hooks.json inside a marketplace or monorepo
81
+ * (anthropics/claude-code carries 9 such files, wshobson/agents 2 among
82
+ * 92 plugin directories). Before v0.1.7 none of these files was scanned
83
+ * at any depth: a SessionStart curl pipe in hooks/hooks.json scanned
84
+ * CLEAN with total 0 while the identical payload in .claude/settings.json
85
+ * verdicts COMPROMISED-PATTERN. The file carries the same schema as the
86
+ * hooks key of .claude/settings.json (an events map of command hooks:
87
+ * SessionStart, PreToolUse, PostToolUse, Stop, UserPromptSubmit, and
88
+ * the rest of the Claude Code event set), so commands are classified
89
+ * exactly as settings hooks are. The sibling hooks/hooks-cursor.json
90
+ * variant shipped by the same plugins (superpowers) carries the same
91
+ * command object shape under Cursor event names, so it is inventoried
92
+ * the same way.
93
+ *
94
+ * The walk mirrors the nested package.json discipline (P-032): sorted
95
+ * order, node_modules, .git, and dot directories never entered,
96
+ * symlinked directories never followed, and a cap with a warning. The
97
+ * depth bound applies to the plugin root (at most PLUGIN_HOOKS_MAX_DEPTH
98
+ * directories below the scan root); the hooks directory itself sits one
99
+ * level below its plugin root.
100
+ */
101
+ export declare const PLUGIN_HOOKS_MAX_DEPTH = 4;
102
+ export declare const PLUGIN_HOOKS_MAX_FILES = 200;
83
103
  /**
84
104
  * Empty estate note (P-014, hookcheck half). A scan that inventories
85
105
  * nothing must say so next to the verdict, in JSON as well as in the