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.

Claude Code hooks malware as persistence: an amber respawn loop around a SessionEnd hook, an MCP entry, a logon task and a PATH shim, broken by a badge reading diff: 0 unauthored
Four places a helper package hides after install. Removing them is the easy half; proving they stayed removed is the job.

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.

OSV record MAL-2026-13370 for llm-interceptor, sourced from Amazon Inspector, describing a postinstall setup, a Claude SessionEnd hook, a 15-minute heartbeat and three Windows persistence mechanisms 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”.

Mandiant AI Risk and Resilience Report 2026, Case study 3, Subverting assistant CLI hooks for native remote code execution, describing a poisoned internal AI repository and tampered assistant CLI hooks 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.

Chart matrix for Claude Code hooks malware and other agent-helper npm packages: llm-interceptor documents a hook, two MCP entries and three Windows autostarts; opencode-optimised-toolings a PATH swap and three config rewrites; claude-cup hooks; OSV finds no advisory for two of the four latest versions 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.

  1. 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.
  2. 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.
  3. 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.json and Claude Code’s own list.
  4. 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. Restore opencode.jsonc and tui.json from the baseline copy. Search every repo on the host for injected AGENTS.md blocks, committed ones included, because an instruction-file injection can leave the host inside a pushed commit.
  5. Remove the package from every scope. That means project node_modules, global installs under every Node version on the host, and the npx cache.
  6. Assume what it read is gone. llm-interceptor posts transcript turns to its collector. claude-cup harvests .aws/credentials, .npmrc, .git-credentials, SSH keys and .env files. Rotate whatever those held; credential brokering is its own discipline and out of scope here.
  7. 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 SessionEnd hook 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.
  8. 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

Diagram of the proof loop against Claude Code hooks malware: an npm install or npx run fans out to an agent hook, an MCP entry, OS autostart and a PATH shim; all four feed an off-box baseline diff; an unauthored change triggers eradication, reboot and a re-diff until the delta is zero 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