Persistence hook auditor: scan repos for auto-exec hooks, flag Shai-Hulud patterns.
Find the hooks that run before you do. One scan, every auto-exec surface, one verdict.
Modern supply chain attacks do not need you to run anything. They persist through hooks that run automatically: an npm postinstall script, a Claude Code SessionStart hook in .claude/settings.json, a VS Code task set to run on folder open. The Shai-Hulud npm worm used exactly this pattern, planting a preinstall dropper and then persisting through agent and editor hooks so it survived cleanup and stole credentials on the next open.
hookcheck scans a local repo for every auto-exec hook, classifies each one, and prints a single verdict: CLEAN, REVIEW, or COMPROMISED-PATTERN, with the file, command, rules, and evidence for every finding. Deterministic only. No LLM, no network in the core scan.
Reports in October 2026 described AI agents orchestrating attacks and a self-propagating npm worm that backdoored hundreds of packages. Its persistence layer was not exotic malware. It was ordinary config files that every developer and every coding agent already trusts: package.json install scripts, .claude/settings.json hooks, and .vscode/tasks.json folderOpen tasks. Defenders needed a fast local check that an agent itself can call before opening, installing, or trusting a repo. That is hookcheck.
| surface | what it looks for |
|---|---|
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 8 levels below the root, node_modules excluded) are inventoried the same way |
.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 |
| 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 8 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 |
.vscode/tasks.json | tasks with runOn folderOpen, plus task inventory |
.husky/ | every hook file and its full command content |
lefthook.yml | run commands |
.pre-commit-config.yaml | entry commands and remote repo URLs |
.cursor/ and .codex/ | config commands and files |
| 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 |
.github/workflows/ | run steps, as inventory, including block scalar bodies (`run: |
.githooks/ | hook files |
Every surface above is discovered by one bounded recursive walk, not only at the scan root: a surface that appears inside a subdirectory (for example packages/foo/.claude/settings.json, sub/.vscode/tasks.json, or sub/.github/workflows/ci.yml) is inventoried from a parent scan with its real relative path in the finding, within the discovery bound documented under Limits. A surface file is inventoried exactly once however many discovery paths reach it.
Classification per hook:
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.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.Verdict rules:
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. Reports are written through a flush-aware emit: if the reader of a piped report closes the pipe early, the process exits 141 (the shell-visible equivalent of death by SIGPIPE) rather than reporting the verdict code for a report that was never delivered.
Honest boundaries, pinned by tests in test/hardening.test.ts:
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..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..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..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.npx @reneturcios/hookcheck scan
npx @reneturcios/hookcheck scan ./some-repo
npx @reneturcios/hookcheck scan ./some-repo --json
npx @reneturcios/hookcheck explain ./some-repo package.json:postinstall
Example output:
hookcheck ./some-repo: COMPROMISED-PATTERN
2 hook(s): 0 SAFE, 2 RISKY, 0 UNKNOWN
[RISKY] package.json:postinstall (package.json)
command: node setup.mjs
rules: NET-FETCH, REMOTE-URL, ENV-BULK, ENV-EXFIL
evidence: referenced local file setup.mjs is RISKY: NET-FETCH: performs a network fetch
COMPROMISED-PATTERN: at least one risky hook shows strong evidence: fetched or decoded content executed as code, credential values flowing into an outbound send, or credential store access combined with network behavior.
Add to your MCP client config:
{
"mcpServers": {
"hookcheck": {
"command": "npx",
"args": ["-y", "@reneturcios/hookcheck", "mcp"]
}
}
}
Tools:
scan_hooks(path): full scan with verdict, findings, counts, and warnings.explain_hook(path, hookId or file): one hook explained in plain language with its evidence.docker run -i ghcr.io/cloudcorprecords/hookcheck mcp
docker run --rm -v "$PWD:/repo" ghcr.io/cloudcorprecords/hookcheck scan /repo
hookcheck is part of a family of small, single-purpose developer tools by the same author, all built the same way: one bin that serves a CLI and an MCP server, deterministic checks with evidence, no invented answers.
npm install
npm run build
npm test
MIT
Source-derived launch command. Check the maintainer’s required arguments and credentials before running:
npx -y @reneturcios/hookcheckMerge this template into ~/Library/Application Support/Claude/claude_desktop_config.json. Keep existing servers. Add any arguments, credentials, and permissions required by the maintainer; this template has not been install-tested.
{
"mcpServers": {
"io-github-cloudcorprecords-hookcheck": {
"command": "npx",
"args": [
"-y",
"@reneturcios/hookcheck"
]
}
}
}Restart Claude Desktop completely for changes to take effect. Confirm the server appears connected in the client’s tool list, then try a read-only example from its documentation.
Claude Desktop setup referenceio.github.CloudCorpRecords/hookcheck works with any MCP-compatible client. Copy the config snippet from the Configuration section above and add it to the file shown for your client, then restart the application.
~/Library/Application Support/Claude/claude_desktop_config.jsonRestart Claude Desktop completely for changes to take effect.~/.cursor/mcp.jsonRestart Cursor for changes to take effect..vscode/mcp.jsonReload VS Code window for changes to take effect.~/.codeium/windsurf/mcp_config.jsonRestart Windsurf for changes to take effect..mcp.jsonSave at the project root, then start Claude Code in that project and review the MCP server approval prompt. Keep real credentials out of shared files.