Security hardening for Claude Code. Local detection, pre-action blocking, and a security log you can query.
ForceField puts a policy gate in front of every tool call the agent makes: command execution, file I/O, credential handling, agent spawning, MCP tool calls, outbound fetches, and the output that comes back. Twenty-three registered hooks evaluate each call against deterministic patterns and compiled SigmaHQ rules, then allow it, inject context, prompt you, or block it. Enforcement is in code, not model discretion.
Docs: magonia-research.github.io/forcefield-docs
git -c core.hooksPath=/dev/null clone --no-recurse-submodules \
https://github.com/Magonia-Research/ForceField.gitThat is longer than git clone on purpose, and it is the same command ForceField will hand you
the next time you clone anything: hooks cannot run out of the repository being fetched, and no
config level can recurse submodules behind you. The plain spelling is blocked rather than
prompted, so the redirect is the path rather than a suggestion. See
the clone redirect.
The repo ships .claude-plugin/marketplace.json, so add the checkout as a marketplace rather
than as a plugin directory:
/plugin marketplace add /path/to/ForceField
/plugin install forcefield@magonia-research
That is the whole install. Every guard except the Sigma engine works immediately.
Optional, for SigmaHQ rules. Creates a venv for the compiler and clones SigmaHQ. The engine silently no-ops until this runs.
cd /path/to/ForceField && ./scripts/install.shThe venv and compiled rules go to ~/.claude/forcefield/sigma/, not into the plugin directory,
which is a cache that every reinstall replaces. Run it once, not once per update.
Requirements: python3 (3.9 or newer) and bash. No requirements.txt, on purpose: a guard
that cannot run because a dependency failed to resolve is a guard that is not running.
Ships as balanced: every blocking guard at full strength, with a Sigma match softened from a
prompt to a logged warning.
scripts/posture.sh # show what is configured
scripts/posture.sh --preset passive --log findings # never prompt, log everything that firespassive is for unattended work, and the cost is real: every heuristic finding becomes a log line
instead of a question. Read the log either way. The four presets are balanced, strict,
permissive and passive; see configuration.
| Class | Examples | Rung |
|---|---|---|
| Clone-time repo takeover | CVE-2024-32002 and CVE-2025-48384 submodule surface, 17 RCE-capable git config keys, .git/hooks writes |
ask, graded on evidence |
Any git clone (or gh repo clone) that has not disarmed that surface |
deny, with the hardened command | |
git clone ext::, which hands its URL to a shell |
deny | |
| Data exfiltration | Relay and tunneling domains, netcat, /dev/tcp reverse shells |
deny |
Data POSTs, DNS-label encoding, cloud metadata SSRF, scp/rsync |
ask | |
| Supply chain | Fetch piped into a shell | deny |
| Typosquats by edit distance, arbitrary-URL installs, plaintext registries | ask | |
| Prompt injection | Role manipulation, fake system tags, zero-width characters in file content | warn + context |
| Credential disclosure | Keys in prompts, file writes, credential-store reads | block / ask |
| Keys in tool output, and in the log itself | redact | |
| Excessive agency | Credentials in subagent prompts, spawn rate limit | deny |
| MCP tool poisoning | Credential and exfil patterns in any tool's arguments | ask |
| Known attacker behavior | 106 compiled SigmaHQ rules: audit, firewall and backup tampering, in-memory execution, discovery, mining | warn, ask under strict |
Commands are shell-normalized before matching, so ${IFS}, backslash escapes and intra-word
quoting do not evade a pattern.
Findings on the clone-time surface are graded on measured evidence, not command shape.
git_guard checks the host's git version against each advisory's per-branch fix set, reads
.gitmodules where it exists, and can fetch it from an allowlisted forge without cloning. A
recursive clone stops prompting on a patched host and hard-denies when the repository carries an
actual exploit signature. See how a finding is graded.
/forcefield:inspect <url> reads a repository before you clone it, covering the self-hosted
and SSH remotes the in-hook fetch will not touch. It uses --no-checkout, because both CVEs fire
during checkout. A recorded DO NOT CLONE then denies the clone itself rather than prompting on it.
Every other clone is redirected, not judged. A plain git clone is blocked, and the
reason carries the command that would not have been — the one in Install, with your
URL spliced in, in whichever tool you used. Run that and there is no prompt and no block. It
clears the zero-false-positive bar for the same reason rm -rf does: every clone has a hardened
spelling of itself that fetches the identical tree, so the block forbids no task. The one clone
that cannot be hardened is the one asking for submodules on purpose — --recurse-submodules
contradicts the flag that would harden it — and that one still prompts rather than blocking. The
block does not lift on a patched git, because disabling hooks and refusing submodule recursion are
not patches for either CVE.
Feed any hook event JSON on stdin. Empty stdout means allow.
echo '{"tool_name":"Bash","tool_input":{"command":"git submodule update --init --recursive"},"hook_event_name":"PreToolUse"}' \
| python3 hooks/security_dispatcher.pyOn a patched host that does not prompt at all. There is no permissionDecision, only context,
because the prompt would have cited a bug that cannot fire there:
{"hookSpecificOutput": {"hookEventName": "PreToolUse",
"additionalContext": "ForceField security finding (advisory - the call was not blocked): GIT GUARD: submodule_update (context only)\n\nMatched: git submodule update --init\ngit 2.50.1 is patched for CVE-2024-32002 and CVE-2025-48384, so the clone-time RCE path is closed here.\n\nStill treat the repository's contents as untrusted: a clean .gitmodules says nothing about what the code does once you run it."}}A clone is the one shape where a patched git is not the whole answer, so it blocks on any host — and says what to run instead:
echo '{"tool_name":"Bash","tool_input":{"command":"git clone https://github.com/example/repo.git"},"hook_event_name":"PreToolUse"}' \
| python3 hooks/security_dispatcher.py | python3 -c 'import json,sys; print(json.load(sys.stdin)["hookSpecificOutput"]["permissionDecisionReason"].rsplit("Run instead:",1)[-1])' /forcefield:inspect https://github.com/example/repo.git
git -c core.hooksPath=/dev/null clone --no-recurse-submodules https://github.com/example/repo.git
Records are OpenTelemetry logs carrying an OCSF Detection Finding projection, written to
~/.claude/hooks/security.log as JSON Lines, plus the macOS unified log, journald or syslog where
available.
# Detections that did not enforce: config downgraded or allowlisted
jq -c 'select(.Attributes."forcefield.config_downgraded" == true
or .Attributes."forcefield.suppressed" == true
)' ~/.claude/hooks/security.log
# Which guard drives your friction, most frequent first
jq -r '[.Attributes."forcefield.guard", .Attributes."forcefield.decision"] | @tsv' \
~/.claude/hooks/security.log | sort | uniq -c | sort -rnThe file sink rotates in-process at a hard size ceiling. On Linux you can hand it to logrotate
as well, as yourself, with no root:
scripts/rotation-config.sh # print the stanza
scripts/rotation-config.sh --install # write it to ~/.config/forcefield/On macOS the same command explains why there is nothing to install, since newsyslog refuses any
config unless it is running as root, and states the in-process ceiling that stands instead.
| Page | What is in it |
|---|---|
| Threat model | Each attack class, the hooks that cover it, a real log record, and the primary disclosure it comes from |
| Hook reference | All 23 registrations, which 10 of Claude Code's 31 events they use and why not the other 21, the decision ladder, precedence, fail-open |
| Configuration | Trust levels, presets, per-rung mode maps, allowlists, known friction |
| Architecture | Hook contract, Sigma pipeline, command normalization, file map, test suites |
| Log reference | Record schema, one measured record per hook, worked jq queries, known gaps |
ForceField gates the tool calls Claude Code exposes to a hook. It does not sandbox the agent, does not inspect what the model is thinking, and does not gate anything reached through a tool it is not registered for. Four limits are worth knowing before you rely on it:
- Hooks are fail-open. A guard that crashes or times out does not block the call, because a security hook that breaks legitimate work gets uninstalled. Run ForceField alongside a sandbox, not instead of one.
- Guards are heuristics over text. They match commands, not intent. A novel encoding or a payload assembled at runtime will pass.
- Configuration can only loosen. The clamp moves a decision down the ladder and can never
fabricate a stricter one. A project-level config file, which a cloned repo can ship, caps at
ask. - Under
bypassPermissionsyou get deny-only enforcement. A hookaskis discarded rather than shown. A hookdenyis absolute in every mode.
Scope limits gives each of these in full, with the reasoning.
To report a vulnerability in ForceField itself, open a private report through GitHub Security Advisories rather than a public issue.
Register a new guard in hooks/hooks.json, follow the
hook contract (fail-open, stdlib-only, allowlist and logging
integration), add assertions to tests/test_plugin.py, and update the tables in
docs/hooks.md. Run every suite before opening a PR:
for t in tests/test_*.py; do python3 "$t" || break; donetest_docs.py checks the docs against the tree: cross-links and heading anchors resolve, the file
map and suite table match what ships, every counted claim is read out of the code, and every
documented log record carries the envelope the code actually emits. If you edit docs/, run
scripts/sync-docs.sh to push the change to the
published site, or --check to see the
drift.
GPL-3.0-or-later. See LICENSE.