Skip to content

Latest commit

 

History

History
245 lines (196 loc) · 14.7 KB

File metadata and controls

245 lines (196 loc) · 14.7 KB

Configuration

ForceField ships the balanced preset: every guard that blocks runs at full strength, and the one thing it softens is a SigmaHQ rule match, from a prompt to a logged warning.

Config can only loosen a guard down the ladder; it can never fabricate a stricter block, so the zero-false-positive-deny guarantee holds through any configuration, including a hostile one.

Two files, two trust levels

File Trust May
~/.claude/forcefield.json trusted, since only you write your home directory Loosen any guard to any rung, including off; scope overrides by path via projects
<project>/.claude/forcefield.json untrusted, since a cloned repo may ship one Soften a blocking guard only as far as ask

Why the project file is capped at ask: a repository you have just cloned is exactly the thing the guards are watching. If a repo could set exfil_guard to off in its own checked-in config, it would blind the guard standing between it and your credentials before you read a line of it. Capping it at ask means a hostile repo can add friction at worst; it can never remove the human from the loop.

Format

{
  "preset": "balanced",
  "log_level": "info",
  "log_free_text": "admin",
  "guards": {
    "webfetch_guard": { "mode": "ask" },
    "sigma_engine":   { "mode": "warn", "severity_floor": "high" },
    "git_guard":      { "mode": { "deny": "deny", "ask": "warn" } }
  },
  "projects": {
    "/path/to/a/trusted/repo": { "preset": "permissive" }
  }
}

Set it without editing JSON:

scripts/posture.sh                                     # show what is configured
scripts/posture.sh --preset passive --log findings     # pick a posture
scripts/install.sh --posture passive --log findings    # or during sigma setup

Presets

Preset Behaviour
balanced the default, applied when no config names a preset; strict except that a Sigma match warns instead of prompting
strict prompts on a Sigma match too, and drops the Sigma severity floor to low so more rules fire
permissive prompts for everything, blocks nothing
passive never prompts: every finding becomes a logged warning and work continues, except a known-exploit finding, which still blocks

Ceilings and per-rung maps

A plain ceiling clamps every rung at or above it, so setting a blocking guard to ask takes its deny down with it, and deny fires only on the zero-false-positive tier. That is why no default preset softens a blocking guard: it would block less than passive does.

Passive is the posture for unattended or flow-critical work, and the one setting a single ceiling cannot express. So a mode may also be a per-rung map:

"git_guard": { "mode": { "deny": "deny", "ask": "warn" } }

That is exactly what passive sets on every guard. The line it draws is one the guards already drew, since a guard emits deny only for its own hard-deny set and everything heuristic emits ask, so passive just stops turning the heuristics into questions.

The cost is real. Under passive every heuristic finding becomes a log line rather than a question, so nothing stops an unreviewed action except the hard-deny tier. On the clone-time surface the loss is smaller than it used to be, because a recursive clone is already context-only on a patched git, and a measured exploit signature in .gitmodules still denies, because evidence outranks posture. Read the log.

Disabling the remote pre-flight

git_guard can fetch a remote's .gitmodules from an allowlisted forge without cloning it, to decide a fresh clone on content rather than on shape. It is the only place a guard reaches the network. To turn it off without disabling the guard:

export FORCEFIELD_NO_REMOTE_INSPECT=1

Findings then fall back to host preconditions and on-disk evidence alone, which means a recursive clone from an unknown repository prompts rather than being cleared. The fetch is already confined to exactly-matched forge hosts, capped at 1.5s inside the hook's 5s budget, and bounded in response size; disable it if any outbound request from a hook is unacceptable in your environment.

Log level

log_level is a floor on the OTel SeverityNumber, the same ladder the severity table uses, so there is one ordering rather than two:

log_level floor contains
debug 5 everything below, plus guard_ran, a record that a conditionally-silent guard ran and found nothing
info (default) 9 off, allow, warn_low, warn, ask, redact, deny, block
warn 13 warn, ask, redact, deny, block
error 17 deny, block

No level can drop a record the suppression machinery depends on. A deny or block, a decision nobody modelled, any lifecycle or permission record, anything from secure_store or inspect_remote, anything whose natural decision was a deny, and anything config downgraded are all written at every level. That is a property of the record rather than a flag a call site has to remember. The old model's force=True was missed on exactly the path that needed it most, so a hard deny softened to warn by config vanished from the log entirely.

log_verbosity was replaced by log_level, not aliased. An unmigrated config simply carries an unrecognised key and falls back to info, and that direction is safe: gating was the quietest old setting, and info is exactly as complete as the old all. An unmigrated config gets more logging, never less. scripts/posture.sh --reset removes the dead key once.

Free-text disclosure

log_free_text decides which sinks may carry the fields that hold attacker-influenced or environment free text: command.line, file.path, process.working_directory, session.transcript_path, agent.transcript_path:

value effect
admin (default) free text reaches the 0600 file sink, the macOS unified log (whose store is drwxr-x--- root:admin, re-checked at runtime) and the systemd journal
owner free text reaches the 0600 file sink only

This key can only ever tighten disclosure. There is no value that widens it past what the per-sink measurement licenses, so it is the mirror of the "config may only loosen enforcement" rule rather than an exception to it.

Credential values are masked out of those fields, in every sink, at every setting, but masking is pattern-based, so what it covers is exactly the credential patterns the guards detect with plus the redaction-only set (tokens and keys by prefix, URL userinfo, curl -u/--user/-U, smbclient -U user%pass, mysql -p, Authorization: headers, X-Api-Key:-style headers, --password=, PGPASSWORD=). A secret in a shape no pattern names reaches whichever sinks this setting allows. owner is the setting that bounds that exposure to the 0600 file.

Native sinks and the environment

FORCEFIELD_LOG_SINKS narrows the native sinks, the machine-global ones (oslog, journald, syslog, winevt), for the processes that inherit it. It exists for the test suite, which spawns real hooks and would otherwise write fabricated records into the operator's real unified log or journal; no $HOME diversion can prevent that, because neither store is under $HOME.

export FORCEFIELD_LOG_SINKS=none        # file sink only
export FORCEFIELD_LOG_SINKS=journald    # the file sink plus journald, if this host has one

Two things it cannot do:

  • It cannot remove the file sink. ~/.claude/hooks/security.log is unioned in after the variable is read, at every value including none.
  • A value it does not recognise is ignored entirely, and the platform default stands. FORCEFIELD_LOG_SINKS=oslgo used to select exactly what none selects, so a typo removed every machine-global copy of the audit trail with nothing recorded anywhere. An empty value is ignored the same way: it is not a token, and FORCEFIELD_LOG_SINKS=$SOMETHING_UNSET is one shell expansion from it.

Whatever it did is on the session.start record as forcefield.sinks.env, next to forcefield.sinks: the value read, whether it was honoured, and any token that was rejected. So "this host has no journal" and "this host has a journal and the environment switched it off" are different readings rather than the same silence.

Both keys are trusted-config only: a repo can soften a guard, but it can never turn down the record of what the guards did, nor change how much of that record reaches a sink other accounts can read.

A per-guard mode overrides the preset; severity_floor tunes which Sigma rules fire. Anything missing or malformed fails open to the default. Twelve gating guards are configurable: the eleven PreToolUse ones plus the subagent-stop guard. The advisory guards (injection defense, output scanner, agent output guard, prompt credential guard) are always on, because none of them blocks anything and turning them off only removes information.

Per-project allowlists

Create .claude/hook-allowlist.json to suppress specific patterns or paths:

{
  "exfil_guard": {
    "suppress_patterns": ["curl_post_data"],
    "suppress_paths": ["src/api/client.py"]
  },
  "credential_guard": {
    "suppress_paths": ["tests/fixtures/**", "**/*.example"]
  },
  "injection_defense": {
    "suppress_patterns": ["role_manipulation"],
    "suppress_paths": ["docs/security/**"]
  }
}

An allowlist cannot suppress a hard deny. Loosening one requires a preset or per-guard mode in your trusted home config, never a repo file: a cloned repo cannot allowlist away the guard standing between it and your credentials.

A suppression is a detection that did not enforce, and it is logged as one, so query forcefield.suppressed, not severity. See known gaps.

Known friction and how to loosen it

Reach for the narrowest relief: allowlist one pattern or path → soften one guard (guards.<name>.mode) → pick a preset → disable a guard for one project (home config only).

Legitimate workflow that trips a guard Guard Level Relief
Host package install instead of a container: pip, npm, pnpm, yarn, gem, cargo, brew, conda, apt, aptitude, dnf, yum, pacman container-first context only Nothing to relieve: never prompts at any ceiling. Names a runtime that is actually installed (Apple's container first on macOS, podman first elsewhere, else docker/nerdctl) and says to relaunch rather than resume a failed run. Container preference is hygiene, not a security boundary, and a prompt here strands unattended agents. System managers are Linux-only, so off Linux they are not reported; nor is an install past ssh host or wsl, where the phrase is not in command position
Dev server, base64, interpreter one-liner caught by a broad rule sigma_engine ask severity_floor: high, or sigma_engine: warn
curl … | sh installer (rustup, nvm) supply_chain_guard deny permissive, or supply_chain_guard: ask in home config
Install from a plaintext http:// index or registry supply_chain_guard ask Verify the index is a trusted internal mirror, or use the default https one. pipx install, uv pip install --require-hashes and pip install -e do not exempt it
scp / rsync / curl -d to your own host exfil_guard ask Allowlist remote_copy / curl_post_data; relay domains stay denied
Reading a project .env in dev credential_access_guard warn Nothing to do — it is not blocked and needs no approval. The reminder tells the model to keep the values out of its reply, out of files it writes, and out of subagent prompts. Every other credential store still asks
Fake keys in fixtures / .env.example credential_guard ask Placeholders are already skipped; else suppress_paths
Editing ~/.zshrc / ~/.gitconfig filesystem_guard ask Allowlist the path, or disable for that project
Shell write to ~/.claude/forcefield.json, settings.json, anything under ~/.claude/forcefield/ filesystem_guard ask Intentional: these decide what the guards do next, and cannot be suppressed
Any git clone or gh repo clone git_guard ask Clone with git -c core.hooksPath=/dev/null clone --no-recurse-submodules <url>, which the guard passes silently — the prompt names that command. It does not stop on a patched git, because neither setting is a patch for either CVE. Otherwise allowlist unhardened_clone for that repo. See the clone redirect
Submodule init or a recursing pull in a trusted repo git_guard ask (context only on a patched git) Update git first, which closes both CVEs and the prompt stops on its own. Otherwise allowlist submodule_update / submodule_recurse_fetch for that repo
git clone ext::… git_guard deny Not loosenable except by preset. The transport runs its URL as a shell command; see the threat model
Many subagent spawns hitting the rate limit agent_guard deny Spawn on sonnet or haiku, which are not metered at all; otherwise permissive, since the 10/20 limit is not tunable
MCP call carrying base64 or a long token mcp_guard ask Allowlist the pattern for that server
WebFetch URL with an encoded query blob webfetch_guard ask Allowlist it, or webfetch_guard: warn in home config; exfil domains stay denied

Before allowlisting recursive_submodule_clone or submodule_update, read repository takeover at clone time. "A repo I trust" is the assumption both CVE-2024-32002 and CVE-2025-48384 are designed to defeat. Patching git is the fix, and the guard notices, so the prompt disappears on its own once the host is patched. An allowlist only removes the prompt, and it removes it on unpatched hosts too, where the prompt was the last thing standing.

unhardened_clone cannot be allowlisted at all, because it is a deny and the dispatcher refuses project suppression for any git finding whose decision is deny — otherwise a repo could ship a .claude/hook-allowlist.json that waved through its own clone. It is also the one entry here that never needed suppressing: every other entry trades a prompt for accepted risk, while this one is answered by typing a longer command, and the block tells you which. If you genuinely want it back to a prompt, that is a preset decision — permissive caps git_guard at ask — not an allowlist one.

If ForceField is broadly too loud, set a preset once rather than disabling guards one at a time.