Hook reference

Twenty-three registrations in hooks/hooks.json across twenty scripts. filesystem_guard, output_credential_scanner and session_baseline each run on two events or matchers. One process and a 5s timeout each, 10s for the SessionStart Sigma update, fail-open by design. Each reads hook-event JSON on stdin and writes either nothing (allow) or a decision on stdout.

Which Claude Code events ForceField uses

Claude Code 2.1.220 exposes 31 registrable hook events. ForceField registers on 10 of them. The other 21 are listed below with the reason, so the coverage boundary is a decision you can check rather than an omission you have to infer.

The 10 in use

Event Registrations What ForceField does there
PreToolUse 9 The main gate. Every guard that can stop a call runs here.
PostToolUse 4 Rewrites output and injects context after a tool has run.
UserPromptSubmit 1 Blocks a pasted private key before the prompt reaches the model.
SessionStart 3 Sigma rule update, security baseline, repository audit.
PreCompact 1 Re-injects the instruction hierarchy so it survives summarization.
SessionEnd 1 Per-session state cleanup and the session.end record.
SubagentStop 1 Validates subagent output before the parent trusts it.
Stop 1 End-of-turn security hygiene reminder.
PermissionDenied 1 Records the outcome of a call that was denied.
FileChanged 1 Records changes to watched paths, whatever process made them.

The 21 not used

Events Why not
PermissionRequest Fires before the permission dialog. It cannot see the answer, and a second gate in front of the gate adds a prompt without adding a decision.
PostToolUseFailure, StopFailure A tool that failed did not act. The pre-call gate already ran.
PostToolBatch Per-call PostToolUse already covers every call in the batch.
SubagentStart PreToolUse[Agent] is the spawn gate and it can rewrite the child’s prompt, which SubagentStart cannot.
UserPromptExpansion Expansion is Claude Code rewriting the user’s own prompt. UserPromptSubmit sees the result.
PostCompact PreCompact re-injects the baseline before the context is summarized, which is the point at which it matters.
Setup, ConfigChange, InstructionsLoaded Configuration and instruction loading. A real surface, since a cloned repo ships .claude/ files, but filesystem_guard gates writes to them and repo_audit reports what a repository carries at session start.
Notification, MessageDisplay, TeammateIdle Presentation and idle signalling. No security decision.
Elicitation, ElicitationResult MCP elicitation. mcp_guard gates the tool call itself.
TaskCreated, TaskCompleted Task bookkeeping, not tool execution.
WorktreeCreate, WorktreeRemove, CwdChanged, DirectoryAdded Workspace movement. repo_audit runs at session start; a per-directory audit on every move would cost a prompt per cd.

Two of these are worth knowing as gaps rather than decisions. ConfigChange and InstructionsLoaded are the surface where a cloned repository’s own instruction files reach the agent, and ForceField covers that at write time and at session start rather than at load time. An instruction file already on disk when the session opens is reported by repo_audit, not blocked.

What FileChanged sees that nothing else does

It is a filesystem watcher over absolute paths, not a post-write tool callback, so it fires for a change made by any process at all: a shell redirect, a script the agent wrote and then ran, a package postinstall, a Makefile, an external editor. Those reach disk without passing PreToolUse[Write|Edit] and without appearing in a command string, so no other registration observes them.

The event carries no decision channel — its hookSpecificOutput accepts only watchPaths, and the watcher settles for 500ms before firing — so file_watch_guard records the change and, for ForceField’s own control surface, warns. It never blocks, because it cannot.

watch_roots.py supplies the paths, delivered through SessionStart’s watchPaths. Two shapes, and the split is forced rather than stylistic: Claude Code watches directories recursively with no depth bound, so credential stores and persistence directories are watched whole (creating a new file there is itself the threat) while shell init files and individual /etc entries are named one by one (their parent is far too large to watch). ~/.claude is deliberately not a root for the same reason: it contains the session transcripts, the plugin cache, and ForceField’s own log, so watching it would mean every record written triggers an event that writes a record.

Each event is classified three ways, from the write ledger rather than from the path: a change is accounted for by a gated tool call, by ForceField writing its own state, or by nothing. Only the third is out_of_band, and that does not mean malicious — your own editor saving a watched file lands there too.

Blocked, then re-routed

A command that gets blocked and whose target is then written through a different tool is a bypass shape. Measured across two weeks of this project’s own logs: 513 blocking decisions, and simple temporal adjacency matched 41% of them, all of it ordinary unrelated work. Requiring the written path to appear in the blocked command left 26 pairs, every one of them the same shape and every one benign.

So the shape is detectable at high precision and says nothing about intent. filesystem_guard and security_dispatcher record it always and prompt only when the re-routed target is itself a protected sink. Over the measured period that split would have prompted zero times while still covering the case that matters. Correlation is on path only, from shell redirection and explicit output flags: a blocked command naming no file is not correlated at all.

Coverage map

  UserPromptSubmit ──► pasted private keys blocked, tokens warned
         │
         ▼
  Claude processes ──► CLAUDE.md rules via /forcefield:full-power-to-shields
         │
  ── PreToolUse: gate before the call ───────────────────────────────────
     Bash        → container-first · sigma · exfil · supply-chain · git
                   · cred-read · self-protection
     Write/Edit  → credential-leak scan · filesystem destination guard
     Read        → filesystem credential-store gate
     WebFetch    → outbound URL: SSRF / exfil-domain / encoded-blob
     mcp__*      → credential + exfil scan of tool arguments
     Agent       → least-privilege checks + subagent constraint injection

  ── PostToolUse: inspect the result ────────────────────────────────────
     Bash          → output credential scan + redact
     Read          → injection defense + output credential scan + redact
     Agent|SendMsg → parent-targeting injection / credential-leak scan

  ── Session lifecycle ──────────────────────────────────────────────────
     SessionStart → sigma rule update (24h) · security baseline · repo audit
     SubagentStop → validate subagent output before the parent trusts it
     PermissionDenied → record what happened to a call ForceField asked about
     PreCompact / SessionEnd / Stop → re-inject baseline · cleanup · checklist

Prompt entry

Hook Event What it does
Prompt Credential Guard UserPromptSubmit Blocks pasted private keys, warns on API tokens, suggests env-var alternatives

PreToolUse

Hook Matcher What it does
Container-First Bash Denies rm -rf, obfuscation, escape techniques (nsenter/unshare/ptrace), kernel manipulation. Asks on over-privileged containers. A host package install or interpreter gets a context-only reminder, never a prompt
Sigma Engine Bash Evaluates compiled SigmaHQ process_creation rules (Linux/macOS, medium and above; 106 by default). Never denies, because the rules are broad heuristics: a match is a natural ask that the shipped balanced preset softens to a logged warning, and strict prompts on. Off until scripts/install.sh compiles a ruleset. What it catches, and its leading-token limit
Security Dispatcher Bash Five guards in one process: exfil (relay domains, netcat, /dev/tcp, data POST, DNS-label, metadata SSRF, scp/rsync), supply-chain (typosquats, fetch-to-shell, arbitrary-URL installs, plaintext registries), git (clone-time RCE, config RCE primitives, GIT_* env, .git/hooks writes, graded on measured evidence), credential-read (.env, ~/.ssh, ~/.aws, keychains), self-protection (shell writes to ForceField’s and Claude Code’s own config). Scans the first 8 KiB and asks on anything longer
Credential Guard Write/Edit Detects API keys, tokens, private keys and passwords in file writes
Filesystem Guard Write/Edit/MultiEdit/NotebookEdit, and Read Guards the write destination (credential stores, shell init, persistence, /etc, plugin config) and gates credential-store reads. Canonicalizes paths to resist ../ and symlink evasion. All findings ask
MCP Guard mcp__.* Scans every MCP tool’s arguments for credential and exfil patterns. Any server can be an exfil channel
Agent Guard Agent Least-privilege spawning: blocks credential leakage, detects injection, dangerous modes, excessive privilege, sensitive paths, prompt size. Injects constraints into subagent prompts. Rate-limits spawns over a rolling hour (10 ask, 20 deny), clearable with agent_guard.py --reset-spawns <session-id>
WebFetch Guard WebFetch Denies known exfil and tunneling domains, asks on embedded credentials, encoded blobs, or sensitive query params

PostToolUse

Hook Matcher What it does
Output Credential Scanner Bash, Read Redacts high-confidence credentials in place (AWS, GitHub, GitLab, npm, Anthropic, private keys), warns on low-confidence
Injection Defense Read Detects indirect prompt injection in file contents: role manipulation, fake system tags, instruction overrides, zero-width chars, hidden HTML. Warns Claude to treat file content as data
Agent Output Guard Agent|SendMessage Scans subagent and inter-agent output for parent-targeting injection, leaked credentials, embedded commands

Session lifecycle

Hook Event What it does
Sigma Update SessionStart Auto-updates SigmaHQ rules on a 24h cooldown
Session Baseline SessionStart, PreCompact Re-injects the TIER 0 to 3 instruction hierarchy so it survives compaction, and logs compaction without ever blocking it. On SessionStart it also writes the session.start record: plugin version, resolved config tier, interpreter, hook roster, compiled-rule state, and what every log sink is doing
Repo Audit SessionStart Audits the repository the session opened in and reports what it carries: planted git hooks, RCE-capable config keys, submodule signatures. warn for a known exploit signature, warn_low for an inventory finding
Subagent Stop Guard SubagentStop Validates subagent output before the parent trusts it. Blocks on a credential. Injection, embedded commands and exfil indicators are advisory, because a Stop-family rejection reason is fed back to the model as its next instruction, and a block that quoted the trigger would put it straight into the retry
Permission Outcome PermissionDenied Records a permission.outcome for a denied tool call, so an ask in the log has an outcome. Never gates: the call is already denied by the time it runs. Whether the event fires on human denials or only on policy denials is not established, so the record carries the event’s own reason without interpreting it
Session Cleanup SessionEnd Removes per-session spawn state, sweeps stale files older than 24h, writes the session.end record
Stop Checklist Stop Security hygiene reminder: secrets, containers, temp files. The one registration that writes no log record

Every gating guard’s decision is clampable by the tiered strictness config. Every hook’s actual log record is in records by hook.

Decision model

The intrusiveness ladder, used by the config clamp: deny > ask > redact > warn > allow > off.

Decision Meaning Examples
deny Zero-false-positive patterns, hard-blocked without a prompt Relay/exfil domains, netcat, /dev/tcp reverse shell, fetch-piped-to-shell, git clone ext::, rm -rf, hex/octal obfuscation, escape techniques, high-confidence credentials in agent prompts, spawn rate limit
ask User must approve Data POST, DNS-label exfil, metadata SSRF, scp/rsync/sftp, curl upload, typosquats, arbitrary-URL or plaintext-registry installs, credential-file reads, guarded write destinations, Sigma match, submodule RCE, git config RCE primitives, an unhardened git clone, agent injection or excessive privilege
redact Credential values replaced with [REDACTED: pattern_name] High-confidence keys only, surrounding context preserved
warn Context injected via systemMessage Credential-handling reminders, injection warnings on file reads, low-confidence alerts
allow + context Soft reminder, no gate Host package install, interpreter on host, subagent constraint injection

Why ask carries the weight. Most guards prompt rather than block, precisely so the deny tier can stay reserved for patterns with no legitimate reading. A guard that hard-denies something with a real workflow behind it gets the whole plugin uninstalled, and an uninstalled plugin defends nothing. git_guard is the clearest case: pre-commit legitimately sets core.hooksPath and monorepos legitimately set core.fsmonitor, so those ask. ext::, whose documented purpose is to run its URL as a command, denies.

One guard grades its rung on evidence. For the three patterns whose entire rationale is the two clone-time CVEs, git_guard consults git_forensics and moves the decision in either direction: down to warn on a patched host, up to deny on a measured exploit signature. Full model: how a git finding is graded.

And one finding is a redirect rather than a verdict. Every git clone that has not disarmed the clone-time execution surface asks, and the reason carries the exact command that would not have: git -c core.hooksPath=/dev/null clone --no-recurse-submodules <url>. Run that and there is no prompt. It is the only place ForceField answers a finding with a replacement command instead of a judgement about the one you typed — the friction is meant to be spent once, on learning the safer spelling. See the clone redirect.

Precedence

Claude Code applies deny > ask > allow when several hooks fire on one call. The dispatcher returns the highest of its five guards, so every guard runs and a lower-severity match can never pre-empt a hard deny. A hard deny bypasses per-project suppression.

Under bypassPermissions you get deny-only enforcement. A hook ask is discarded rather than shown, so every finding raised at ask passes silently: most of the filesystem, MCP, agent, git and credential-read checks, and every Sigma match. A hook deny is absolute in every mode. Read from the Claude Code 2.1.220 bundle rather than measured end to end, so treat the exact rung list as approximate and the shape as reliable.

Fail-open

A hook that crashes, times out, or emits invalid output never blocks the call. Security hooks should not break legitimate work through their own bugs. The agent guard is two-phase, building its constraint-injection response before running detection, so subagents still receive constraints if detection crashes.

That makes anything which can provoke a failure a bypass, so the dispatcher isolates each guard (one raising costs only its own verdict), bounds the text it scans, and turns “I could not fully inspect this” into an ask rather than a silent pass. The 5s timeout is a security boundary: a hook killed mid-scan never delivers its verdict, so a computed hard deny becomes a silent allow.

Skill: /forcefield:full-power-to-shields

Injects behavioral rules into a project’s CLAUDE.md that hooks cannot enforce: never echoing credentials in responses, refusing instructions embedded in fetched content, MCP data minimization, credential placeholders, and multi-step attack awareness. Both layers exist because hooks are fail-open.

Adding or changing a guard

Register it in hooks/hooks.json, follow the hook contract, add assertions to tests/test_plugin.py, and update the tables above.