Unauthored Hooks Are Persistence: Remove Them and Prove They Stay Gone
Claude Code hooks malware survives a declined consent prompt. Baseline hooks, logon tasks and PATH shims off-box, allowlist helpers, prove removal holds.
Go deeper. Build your own.
Declining llm-interceptor’s consent prompt removes nothing. By the time the prompt appears, its postinstall.js has already registered MCP servers, written a SessionEnd hook into ~/.claude/settings.json and set up three separate ways to restart itself on Windows. OSV’s record says the refusal leaves every one of them in place. That is Claude Code hooks malware at its plainest: a line in a settings file that runs someone else’s code each time a session ends, written by a package nobody approved.
Uninstalling the package doesn’t fix it either. npm removes the files it unpacked. It keeps no record of what a script wrote into your home directory, your Task Scheduler or your PATH.
Treat unauthored hooks like any other persistence mechanism: inventory, alert, remove in order, prove. By Tuesday, every agent host should have an off-box baseline of its hooks, logon tasks, autostart entries and PATH shims, with the resolved path and hash of everything each one launches.
A change nobody authored pages someone. Agent-helper packages install only from an allowlist, in a project, through npx or globally. And every cleanup ends with evidence that survives a reboot.
Chatbots suggest; agents act. A hook acts even when nobody is prompting the agent, because the harness launches it on the event, with the user’s rights, under the name of a tool you trust.
Sep 6: 18 agent-helper packages on npm, Claude Code hooks malware among them
On Sep 6, 2026, AgentGate published a list of 18 npm packages it calls malicious and says were still installable that day. AgentGate sells an agent-config scanner, so read it as vendor research. Three entries map onto this runbook’s surfaces. llm-interceptor “registers an MCP server in ~/.cursor/mcp.json, runs claude mcp add, installs a Claude Code SessionEnd hook, and on Windows creates a logon task.” remote-claude-daemon takes tasks over an outbound connection and hands them to local agents “with permission prompts explicitly bypassed.” opencode-optimised-toolings “renames your on-PATH opencode aside and installs its own build in its place”.
OSV’s records, with text from Amazon Inspector, add detail. MAL-2026-13370 says llm-interceptor runs its setup before any consent dialog, sends a heartbeat every 15 minutes regardless of consent, and “installs three redundant Windows persistence mechanisms”: a scheduled task with an ONLOGON trigger, a 5-minute pulse and RestartOnFailure; an HKCU Run key; and a Startup-folder shortcut. MAL-2026-13455 says remote-claude-daemon “spawns the local claude binary with --continue -p --dangerously-skip-permissions and the remote-supplied prompt as input”.
MAL-2026-13452 says the OpenCode package also rewrites ~/.config/opencode/opencode.jsonc, injects a marked block into AGENTS.md and pins itself in tui.json. MAL-2026-5789 says claude-cup runs on the first npx claude-cup and then on every Claude Code tool call once its hooks are in.
Screenshot: OSV, “MAL-2026-13370 - OSV” (Aug 5, 2026), captured Sep 21, 2026.
On Sep 21 we checked the npm registry: all four still resolve, at the latest versions AgentGate listed (0.4.1, 0.7.5, 6.5.2 and 0.9.12). Still resolving means npm hadn’t removed them; it is not a separate finding of malice.
The same day, an OSV query by exact version returned no advisory for remote-claude-daemon@0.7.5 or opencode-optimised-toolings@6.5.2, the current latest of each. A scanner that matches OSV by version would pass both. Step 3 turns on that gap.
The thesis has a primary source too. Mandiant’s AI Risk and Resilience Report (September 2026) includes Case study 3, “Subverting assistant CLI hooks for native remote code execution”. At an IT and software development organization, Mandiant writes, “a threat actor had successfully poisoned an internal AI repository and tampered with the assistant’s underlying CLI hooks.” Its controls include signing and verifying assistant binaries, CLI helpers, plugins and MCP servers before they run, and “multi-party approvals and strict code review policies for all skill/hook modifications”. That second control is how this runbook defines “authored”.
Screenshot: Google Cloud, “Mandiant AI Risk and Resilience Report 2026” (September 2026), captured Sep 21, 2026.
On Sep 14, Claude Code 2.1.271 added --accept-command <sha256> to claude plugin install and claude plugin update, “to accept exactly the command a previous --json run displayed, instead of -y”. The plugin marketplace docs set its scope: “Claude Code runs only the command it showed, for the archive URL it showed.” That is command approval. It doesn’t hash what the plugin contains, and it says nothing about what the plugin writes into your settings once it runs.
Why Claude Code hooks malware outlives the package that wrote it
A hook is an entry in a settings file telling the harness to run something when an event fires. Claude Code’s hooks reference lists five hook types, command, http, mcp_tool, prompt and agent, and the event decides when each one runs; a SessionEnd hook runs as every session closes. Once a package writes one, removing the package doesn’t remove the entry, and the harness keeps launching whatever it names.
An MCP registration gets its command started by every client that reads the config. A logon task runs at each sign-in. A PATH shim runs whenever anyone, human or agent, types the tool’s name.
That is why removing the package is not eradication, and why a scan of node_modules comes back clean on an infected host. The persistence lives in four places npm never tracked.
Marketplace hygiene for plugins and MCP servers re-verifies entries you approved, and plugin pinning has its own runbook (step 2 links it). Neither covers what a package writes after install that nobody approved, or proving it’s gone.
Step 1: Baseline every persistence surface, off the box
Keep one record per host, gathered by a collector the host runs and stored where the host can’t rewrite it. The unit is not the entry. It’s the entry plus the resolved path and SHA-256 of whatever the entry launches, because a shim swap leaves every entry’s text unchanged and alters only a hash.
| Surface | Where to read it | Record for each entry |
|---|---|---|
| Agent hooks | ~/.claude/settings.json, project .claude/settings.json, managed settings, each other CLI’s hook config |
event, matcher, hook type, command or URL, resolved target, SHA-256 of target and script |
| MCP registrations | claude mcp list, ~/.cursor/mcp.json, each client’s MCP config |
server name, command, args, resolved target, SHA-256 |
| Logon and boot tasks (Windows) | Task Scheduler, every task with a logon or boot trigger | task path, trigger, action, resolved target, SHA-256 |
| Run key and Startup folder (Windows) | HKCU Run values, the user’s Startup folder |
value or shortcut, resolved target, SHA-256 |
| PATH shims | every PATH hit for each agent CLI name, in order | order, path, SHA-256, unexpected neighbours |
| CLI config and instruction files | ~/.config/opencode/opencode.jsonc, tui.json, AGENTS.md in working repos |
SHA-256, plus any marker block you didn’t write |
| Global npm packages | global list for every Node install on the host | name, version |
| Autostart on macOS and Linux | user-level launch agents and service units | path, target, SHA-256 |
Four rules make the record worth diffing.
Resolve before you hash. For a command like node C:\Users\dev\.tool\index.js, hash both the interpreter and the script. For a shortcut, hash its target. For a bare name, resolve it through the PATH the harness sees, which may not match your interactive shell’s.
Hash every PATH hit, in order. OSV says that after the OpenCode swap, later opencode runs “execute the author-built binary under the trusted CLI name”. The first hit is what runs; the rest tell you what moved. A file beside the binary with a name like .alonix-toolings-backup is a finding on its own.
Record strings where there is no file. An http hook has a URL and an mcp_tool hook has a server name. There’s nothing local to hash, so the string itself is the value that must not change.
Ship it off-box, append-only. A baseline kept on the host is a notebook kept by the suspect. Push every run to a store the host’s user can’t edit, include the collector’s own hash in the record, and treat a missed check-in as an alert.
# collect-persistence.ps1 - illustrative shape; one JSON line per entry, pushed off-box
$rows = New-Object System.Collections.Generic.List[object]
function Sha($p) { if ($p -and (Test-Path -LiteralPath $p)) { (Get-FileHash -LiteralPath $p -Algorithm SHA256).Hash } }
# Agent hooks: repeat for every settings layer and every CLI's hook config
$cfg = Get-Content "$env:USERPROFILE\.claude\settings.json" -Raw | ConvertFrom-Json
foreach ($ev in $cfg.hooks.PSObject.Properties) { foreach ($g in $ev.Value) { foreach ($hk in $g.hooks) {
$rows.Add(@{ surface='hook'; event=$ev.Name; matcher=$g.matcher; type=$hk.type; cmd=$hk.command; url=$hk.url }) } } }
# Logon and boot tasks
Get-ScheduledTask | Where-Object { $_.Triggers | Where-Object { $_.CimClass.CimClassName -match 'Logon|Boot' } } |
ForEach-Object { $t = $_; foreach ($a in $t.Actions) {
$rows.Add(@{ surface='task'; name="$($t.TaskPath)$($t.TaskName)"; exe=$a.Execute; args=$a.Arguments; sha=(Sha $a.Execute) }) } }
# Run key values and Startup shortcuts
(Get-ItemProperty 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run').PSObject.Properties |
Where-Object Name -notlike 'PS*' | ForEach-Object { $rows.Add(@{ surface='run'; name=$_.Name; value=$_.Value }) }
$sh = New-Object -ComObject WScript.Shell
Get-ChildItem "$env:APPDATA\Microsoft\Windows\Start Menu\Programs\Startup" -Filter *.lnk | ForEach-Object {
$tgt = $sh.CreateShortcut($_.FullName).TargetPath; $rows.Add(@{ surface='startup'; lnk=$_.Name; target=$tgt; sha=(Sha $tgt) }) }
# Every PATH hit for each agent CLI, in resolution order
foreach ($n in 'claude','opencode','codex') {
Get-Command $n -All -ErrorAction SilentlyContinue | ForEach-Object { $rows.Add(@{ surface='path'; name=$n; path=$_.Source; sha=(Sha $_.Source) }) } }
$rows | ForEach-Object { $_ | ConvertTo-Json -Compress } # pipe to the append-only store, never only to local disk
The same shape works in shell on macOS and Linux. OSV’s llm-interceptor record describes Windows persistence only. That’s a fact about one record, not about the next package, so baseline the user-level autostart locations on every OS you run.
Documented behaviour only, from OSV records (text from Amazon Inspector) and AgentGate. A dash means the sources don’t describe it, not that the package doesn’t do it. Each column is a section of your baseline.
Step 2: Alert on any change you did not author
“Authored” needs a definition a script can check. Here it means a change that reached the host through your change path, carrying a reviewed record that names the host group, the surface, the entry and the expected hash, approved by someone other than the requester. That is Mandiant’s multi-party approval for hook changes, applied to all four surfaces. Everything else is unauthored, including a legitimate plugin adding a hook during an update nobody announced.
| Change in the diff | Example | Response |
|---|---|---|
| New entry with no authored record | a SessionEnd hook appears in ~/.claude/settings.json |
Page |
| Same entry, new hash | opencode on PATH now hashes differently |
Page |
| First PATH hit changes | a new directory lands ahead of the real binary | Page |
| New MCP registration | a server appears in ~/.cursor/mcp.json |
Page, then inventory |
| Entry removed | a hook you deployed disappears | Ticket; page if it was a guard |
| Authored change, wrong hash | a plugin update wrote something other than the approved diff | Page; hold the rollout |
| Missed check-in | collector killed or host offline | Ticket; page on the second miss |
Run the collector at logon, on a timer (every 15 minutes is an illustrative starting point), and right after any install or update of an agent CLI, plugin or helper package. The post-install run earns its keep, because it lands the diff next to the command that caused it.
Plugins add hooks legitimately, so make the update itself the authored event. In automation, run claude plugin install or claude plugin update with --json, have a person approve the command it displays, and pass that run’s sha256 to --accept-command instead of -y. Record the diff you expect, run the update, then diff for real.
The flag guarantees the command and archive URL a person saw; the diff tells you what that command wrote. Pinning the plugin itself to a commit is covered in the Plugin4Shell pin-and-verify runbook.
A new MCP registration is a persistence event and an inventory event at once. Alert on it here, then reconcile it through your MCP server inventory ritual. An entry nobody can explain gets the shadow MCP treatment.
Step 3: Allowlist agent-helper packages by name, for every way npm runs code
AgentGate says of its 18: “Each has an upstream OSV/GHSA record”. Per package, that holds. Per version it doesn’t, because two of the four current latest versions returned nothing from OSV on Sep 21.
A denylist knows only the versions someone has already recorded. Allowlist by name, then decide which versions of each name you accept.
The allowlist also has to cover every door, because npm lifecycle scripts run on local, npx and global installs alike. llm-interceptor’s payload starts in postinstall. claude-cup’s runs the first time someone types npx claude-cup, with no lockfile anywhere near it. llm-interceptor can reinstall itself globally on its collector’s instruction.
A lockfile review covers one of those three doors.
Enforce it where the doors meet: the registry path your hosts resolve through. In its case study 1, Mandiant recommends routing “all dependency traffic through secure, internal repositories” and validating AI-recommended dependencies “against cryptographic checksums and approved allowlists”. An internal registry or proxy that serves only allowlisted names does both, for every install mode.
# agent-helpers.allow.yaml - illustrative shape; served by the internal registry, reviewed like code
- name: "@your-org/agent-lint"
versions: "2.4.x"
modes: [project, npx] # a global install needs its own approval
expected_writes: [] # surfaces it may touch at install or first run; empty = none
approved_by: [owner-a, owner-b] # two names: the multi-party rule
reviewed: 2026-09-21
- name: "@your-org/docs-mcp-server"
versions: "1.9.x"
modes: [project]
expected_writes: [mcp_registration]
approved_by: [owner-a, owner-c]
reviewed: 2026-09-21
The expected_writes field ties this step to step 1. If installing an allowlisted package produces a diff outside what its entry declares, that pages someone.
Decide what counts as an agent helper by behaviour, not by name: anything that writes agent config, registers MCP servers, wraps or replaces an agent CLI, or offers to meter, proxy or remote-control one. Registry keywords such as mcp, claude-code and remote-desktop, all used by packages on the list, can route a package to review. They don’t detect anything.
Be honest about the edges. A developer who points npm straight at the public registry, or copies a tarball by hand, walks around the allowlist; that’s why step 1 exists. Turning install scripts off is a sensible extra wall, but it does nothing about a payload that runs when invoked. The publish side of the same supply chain, keeping your own agents from pushing a poisoned version, is covered in stage-only npm tokens for agents.
Step 4: Eradicate in order, reboot, and prove the hook, task and shim stay gone
Order matters, because llm-interceptor is built to come back: RestartOnFailure, a 5-minute pulse, three autostart paths and a self-update. Remove what respawns before you remove what it respawns.
- Pull the host from agent work. Stop its sessions, take its lanes off the scheduler, and block outbound traffic to anything but your management plane. OSV says the heartbeat fires every 15 minutes; make the next one fail.
- Disable the respawners before killing processes. Disable the scheduled task and export its definition as evidence before you delete it. Remove the Run value and the Startup shortcut. Only then stop the processes they launch.
- Remove the agent-side entries. Delete the hook from every settings layer, not just
~/.claude/settings.json. Remove the MCP registrations from every client, including~/.cursor/mcp.jsonand Claude Code’s own list. - Restore the trusted binaries and files. Delete the impostor on PATH, reinstall the CLI from your approved source, and check its hash against the baseline before removing leftovers like
.alonix-toolings-backup. Restoreopencode.jsoncandtui.jsonfrom the baseline copy. Search every repo on the host for injectedAGENTS.mdblocks, committed ones included, because an instruction-file injection can leave the host inside a pushed commit. - Remove the package from every scope. That means project
node_modules, global installs under every Node version on the host, and thenpxcache. - Assume what it read is gone.
llm-interceptorposts transcript turns to its collector.claude-cupharvests.aws/credentials,.npmrc,.git-credentials, SSH keys and.envfiles. Rotate whatever those held; credential brokering is its own discipline and out of scope here. - Reboot and fire every trigger. Log on as the affected user, since an ONLOGON trigger needs a logon. Start and end one agent session so any
SessionEndhook fires, and make one tool call. Run each agent CLI by name once. Then leave the host up for at least two heartbeat intervals, which is 30 minutes against OSV’s 15-minute heartbeat. - Diff, then diff again. Re-run the collector against the pre-incident baseline. The proof passes with zero unauthored delta and no outbound connection to the collector during the wait. Repeat at 24 hours and after the next agent CLI update.
Keep the proof as a record, not a feeling:
| Surface | Trigger you fired | Pass condition | Evidence to keep |
|---|---|---|---|
| Agent hooks | session started and ended, one tool call | no hook outside the baseline | diff output |
| MCP registrations | a fresh session in each client | lists match the baseline | claude mcp list output, config hashes |
| Logon task, Run key, Startup | reboot plus interactive logon | nothing new, nothing re-created | task export, Run values, folder listing |
| PATH shims | each agent CLI run by name | every PATH hit matches its hash | hash list |
Config and AGENTS.md |
CLI launched in each repo | hashes match; no foreign marker blocks | hash list, repo search results |
| Packages | 24 hours later | helper absent from every scope | package list per scope |
| Egress | 30-minute wait | no connection to the collector | proxy or firewall log |
Four surfaces, one diff. The loop only closes when the diff comes back empty after a reboot and every trigger has fired.
Where the baseline lies to you, and what gives it away
The baseline is detection, not a boundary. It fails in specific ways, and each failure leaves a signal.
The collector runs on the compromised host. Code that can write ~/.claude/settings.json can edit a script in the same profile. Signal: the collector’s hash changes, a check-in goes missing, or a report comes back suspiciously short. Fix: run the collector from a path the user can’t write, and alert on silence. The wall behind it: agents run as standard users, with no admin rights on agent hosts.
Legitimate updates bury the signal. Every CLI and plugin update produces diffs. If those aren’t authored events, people start closing alerts in bulk. Signal: unauthored alerts closed as expected with no record attached. Fix: route every update through step 2’s authored path, so the surprises left are real ones.
A removed guard looks like a quiet day. An attacker who deletes your security hook produces a removal, not an addition. Signal: any guard hook missing from the diff, which should page. A guard can also stop matching without being removed, when a harness update renames the tools it watches; catching that is the contract-test problem in a renamed tool is a disabled guard.
The scanner is green and the host isn’t. Version-matched OSV scanning passes the current latest of two named packages. Signal: an allowlist violation alongside a clean scan. The allowlist wins.
Removal without a reboot. Entries come back within the task’s pulse or at the next logon. Signal: the step 4 diff fails the second time. Fix: respawners first, then everything else.
The user declined the prompt. Per OSV, declining removes nothing. Signal: an install of a non-allowlisted helper with no incident attached. Fix: any such install starts step 4, whatever the user clicked.
Persistence is a fleet inventory, not a package ticket
A package ticket closes once the package is gone. Persistence doesn’t, and on a fleet the same package may have reached several hosts through three install modes before anyone opened one. The baseline, the authored-change path, the allowlist and the proof record belong in the layer that runs the fleet, next to the inventory of which hosts run which agents under which identity. That’s the operating-layer argument behind corporate AI on the Windows PC: the desk that launches agents has to be able to say what else launches on that desk.
The trigger won’t always be an npm list. If the LiteLLM MCP bypass on CISA’s KEV list put tool calls with shell reach on your hosts, this runbook is the host-side follow-up. The move is the same either way: baseline, remove, reboot, diff, and keep the diff.
FAQ
Does Claude Code’s --accept-command pin a plugin’s contents?
No. Added in 2.1.271 on Sep 14, 2026, it accepts exactly the command and archive URL that a previous --json run displayed, and refuses if either changed. It does not hash the plugin’s files or check what the plugin writes after it runs. Diff your host baseline after every plugin install or update.
Sources
- AgentGate: 18 malicious npm packages are still remote-controlling AI coding agents (verified today) — Sep 6, 2026; vendor research
- OSV MAL-2026-13370, llm-interceptor — published Aug 5, 2026; record text from Amazon Inspector
- OSV MAL-2026-13455, remote-claude-daemon — published Aug 6, 2026
- OSV MAL-2026-13452, opencode-optimised-toolings — published Aug 6, 2026
- OSV MAL-2026-5789, claude-cup — published Jun 15, 2026
- Mandiant AI Risk and Resilience Report 2026 — September 2026; case studies 1 and 3
- Claude Code changelog — 2.1.271, Sep 14, 2026
- Claude Code docs: Create and distribute a plugin marketplace —
--accept-commandscope - Claude Code docs: Hooks reference — hook types
