Why use it: so you are not surprised mid-case, and more importantly — so you do not disable the wrong rail. What it produces: produces nothing; it changes how you read every confirmation prompt that cti-expert raises.
Three hooks, and they only exist when installed via the plugin path
hooks/hooks.json registers exactly three entries:
| Hook | Event captured | Behavior |
|---|---|---|
leakguard.py |
PreToolUse · Write|Edit|NotebookEdit |
deny — blocks writing case data to a tracked |
actionguard.py |
PreToolUse · Bash|mcp__.* |
ask — asks before an irreversible outbound action |
sessionguard.py |
SessionStart |
reports backend tier + warns when the number of @tool changes |
bash scripts/register.sh hooks cannot be installed. Only /plugin install cti-expert has them (01 step 3b). Verify with /hooks in Claude Code — you must see 3 entries.
leakguard.py — RULE 1 at WRITE time
The repo forbids investigation data from leaking into tracked files: real people's names, the case's infrastructure, real GA4/GTM, case IDs, operator PII — including in the form of "illustrative examples" in comments or test fixtures.
There are two enforcement layers, intentionally:
scripts/leakcheck.shruns at git pre-commit (installed viabash scripts/install-hooks.sh, must be run on every fresh clone because.git/hooks/is not tracked).hooks/leakguard.pyruns at Write/Edit time.
The reason for the second one: the git hook only fires at commit and git commit --no-verify can be bypassed, while an agent writes continuously and rarely commits. At the Write layer there is no --no-verify flag at all.
leakguard.py does not re-install the regex patterns itself — it calls scripts/leakcheck.sh. A second copy would drift, and a guard that has drifted always reports clean.
It only denies when both are true: the path is inside a cti-expert checkout, and git does not ignore that path. Writes to intel_engine/cases/, intel_engine/knowledge/, .env — i.e. the already-gitignored stores — are completely untouched. So are writes outside the checkout.
When you need an example in a tracked file, use an obviously fake placeholder: example.com, registrant@example.com, G-XXXXXXXXXX, CASE-0001, 1ExampleBitcoinAddressDoNotUse.
actionguard.py — outbound gate (ask, not deny)
The list is data, not code: hooks/references/outbound_actions.json. Read directly:
python3 -c "
import json;d=json.load(open('hooks/references/outbound_actions.json'))
print('MCP :', list(d['mcp_tools']['entries']))
for e in d['bash_patterns']['entries']: print('bash:', e['match'], '| flag:', e.get('flag_required'))"
7 MCP tools gated: engage_account, harvest_authenticated, anyrun_submit, capture_evidence, pivot_extract, misp_push, misp_publish.
11 bash patterns gated:
| Pattern | Why |
|---|---|
en_engage.py, intel.py engage |
sign up/log in on the target's platform — outbound, attributable to you, irreversible |
en_harvest.py, intel.py engage-harvest |
already logged in inside the target's member area |
bp_anyrun.py submit, --submit, --confirm-submission |
submit samples to a public sandbox |
sh_misp.py push / publish, intel.py misp push / publish |
push/publish events outward |
Two tools have flag_required — capture_evidence and pivot_extract only ask when there is --submit. Their default path (passive collection) does not get asked. That is intentional: asking too much is the fastest way for a guard to be turned off entirely.
Same logic in detail intel.py engage — the trailing space carries meaning: it keeps intel.py engage-report (local write-up render only) from triggering the prompt.
Not gated, intentionally: detect_login, url_paths, passive_ssl, and all routine collection.
Why the gate is doubled
The gate in the code (submit(confirm=…), Engage's preflight) lives in intel_engine/ — the directory vendored. A three-way sync could revert it without not breaking any test (nearly happened on 2026-08-23). The hook lives in cti-expert's own tree and keys on tool name, so a sync cannot reach it.
Consequence for consumers: if you see a confirmation prompt while the tool has also asked itself — that is not a bug, it is two independent guards.
Both PreToolUse hooks FAIL-OPEN
A hook that dies from an internal error lets it through, it does not block. A broken hook must not be allowed to paralyze every write operation in the repo. The backstop is scripts/audit.sh and the git pre-commit hook.
Verification: tests/test_hooks.py asserts both directions — fires on the right target, silent in the adjacent safe case. Run for real:
python3 tests/test_hooks.py
# PASS — Claude Code hooks (58 checks: RULE 1 write-time gate fires on tracked files and stays
# out of the case stores, outbound actions ask, passive work is silent, both hooks fail open,
# and the plugin wiring is machine-independent)
audit.sh §7 additionally checks that every path hooks.json declared still exists — renaming a script will disable that hook silently.
Never submit the case's own sample to a public sandbox
Submitting a sample to a public ANY.RUN/VirusTotal tells the operator they are under investigation, and it cannot be taken back. The repo has its own test for this:
python3 tests/test_no_sample_submission.py
# PASS — no UNGATED sample-submission path (30 checks: every endpoint either read-only or behind
# the marked-and-enforced confirmation gate, contract stated to the analyst)
Before submitting any hash, run hash-id (see 04): a 32-hex string could be the MD5 of a file or the NTLM of a password. Submitting the latter by mistake leaks credentials.
Principles when a guard annoys you
From CLAUDE.md RULE 6, and it holds for both users and repo editors:
Do not loosen a guard to silence a prompt. Prefer tightening
flag_required, or matching more precisely, over deleting a line. A guard you have to turn off to get work done is a guard that will be turned off entirely.
--yolo of the skill drops the skill's interactive prompt of the skill; it does not touch the harness-level hook.