Why use it: the repo has 6 hard rules and a vendored engine; violating them usually fails silently rather than turning tests red.
What it produces: a change that passes scripts/audit.sh and the entire test suite without lowering the safety bar.
This file is independent of
01–08. But reading 04 first helps — theDISPATCHtable you must update here is exactly what that file describes.
Install the dev environment
git clone https://github.com/7onez/cti-expert.git && cd cti-expert
bash scripts/install-hooks.sh # hook git pre-commit → leakcheck.sh (BẮT BUỘC mỗi clone)
uv venv && uv pip install -r requirements.txt # cho §9 của audit + engine test cần SDK
.git/hooks/ is not tracked, so install-hooks.sh must be re-run on every fresh clone. Without it, RULE 1 has only one layer left.
Single gate: scripts/audit.sh
bash scripts/audit.sh
Run it for real without .venv → AUDIT: clean, rc=0. It gates 9 items:
| § | What it checks |
|---|---|
| 1 | leak check RULE 1, two passes: diff (what this commit ADDS) + tree (every tracked text file) |
| 2 | every op DISPATCH resolves to a real script (RULE 3) |
| 3–4 | the shim collector is still a re-export, not turned back into a copy (RULE 4) |
| 5 | the number of @tool matches the figure recorded in CLAUDE.md |
| 6 | py_compile + tests at repo root |
| 7 | every hook path that hooks.json registers still exists |
| 8 | test the vendored engine, stdlib-only set (15 files) |
| 9 | engine tests require claude_agent_sdk — SKIP if there is no .venv |
§1 has two passes because they answer two different questions. The diff pass only sees lines someone just touched; a leaked value sitting in a file nobody opens will live forever — 4 values survived for months in exactly that way. The diff pass also only inspects addedlines: rejecting even - lines used to make the gate lock itself (the only way to remove a leaked value is --no-verify, i.e. the gate pushes you back onto the very shortcut it was built to block).
§9 being SKIPped is a real trap. Locally it prints:
== 9. vendored-engine tests needing claude_agent_sdk ==
SKIPPED (claude_agent_sdk not importable) — 5 tests incl. the context-governor check.
CI has its own job (engine-tests in .github/workflows/audit.yml) and that job fails if it sees a SKIPPED line — because a guard that is silently skipped is not a guard.
Run tests manually
for t in tests/test_*.py; do python3 "$t" >/dev/null 2>&1 && echo "PASS $t" || echo "FAIL $t"; done
10 files at the repo root, all zero-dep (no pytest needed). Run them for real, all 10 rc=0:
| Test | What it asserts |
|---|---|
test_hooks.py |
58 checks — the hook fires on the right target, stays silent in safe cases, both fail-open, plugin wiring is machine-independent |
test_references.py |
580 checks — data files are documented, consumers load real data, a broken file reports loudly |
test_no_sample_submission.py |
30 checks — no sample-submission path is left ungated |
test_indicator_classification.py |
RULE 5 — 16 placeholders, 7 real names, 8 privacy names, 7 managed nameservers, 3 self-hosted nameservers |
test_collect_core.py |
egress gate, cache reuse, --force, fan-out, the collect→ingest→correlate loop |
test_cluster_corroboration.py · test_social_noise_classification.py |
cluster merge/split logic |
test_email_permute.py · test_email_hygiene_nullmx.py · test_osint_tools.py |
local-only tools |
The vendored engine has its own suites: intel_engine/tests/ (test_tool_registry for RULE 2, test_tool_gate for submission approval, test_engage, …). run_eval.py puts both directories on the path so neither suite silently stops running.
Six rules and the cost of breaking them
RULE 1 — never put case data into the skill
Tracked files contain only code and tradecraft. No real people's names, case infrastructure, real GA4/GTM/GSC identifiers, case IDs, or operator PII — including in comments, fixtures, or "real-world examples". Investigation data lives only in intel_engine/cases/, intel_engine/knowledge/, intel_engine/MEMORY/, .env (all gitignored).
The only narrow exception: the curated false-positive ledger (/reference add) — storing indicator values is its purpose, and it lives in the gitignored KB.
When a tracked file needs an example, use: example.com, registrant@example.com, Operator A, G-XXXXXXXXXX, GTM-XXXXXXX, UA-100000001, CASE-0001, 1ExampleBitcoinAddressDoNotUse. Generic public constants (registrar addresses, CDN/ASN ranges, Sedo/Wix favicon hashes) are allowed — they describe tooling rather than a case.
RULE 2 — exactly ONE case store
$INTEL_HOME/cases/<CASE-ID>/. Do not create cases/ at the repository root: being gitignored makes it seem harmless, but kb_ingest will read an empty store and report "no shared indicators" instead of an error.
RULE 3 — every new tool must be registered on the MCP surface
Adding a tool requires all four steps:
- Wrap it as
@tool(name, description, {params})inintel_engine/harness/tools.py—mcp_server.pydiscovers it automatically, with no second edit needed. - Add a row to
DISPATCHinscripts/backend/intel.pyso the Tier 2 CLI can reach it. - Update the
@toolcount inCLAUDE.md(currently 78;audit.sh§5 checks the match). Verify:grep -c '@tool(' intel_engine/harness/tools.py. - If it is a new command in
SKILL.md§3, it must map to a realDISPATCHoperation or a real@tool. A documented command with nothing to execute is worse than having no command.
Adding a mode to an existing tool does not create a new @tool — only extend its description.
Smoke check:
.venv/bin/python intel_engine/harness/mcp_server.py # gửi JSON-RPC tools/list, xác nhận tool có mặt
In Claude Code, verify with /mcp. Note that Claude Code caches the tool list when connecting — an old registration silently exposes an outdated surface; that is exactly why sessionguard.py warns when the @tool count changes.
RULE 4 — wrapper/collector drift must heal automatically
Five collectors exist in both layers. Each has one canonical file and one nine-line re-export shim (Windows-safe, no symlinks):
| collector | canonical (edit here) | shim (do not edit) |
|---|---|---|
pivot_extract, cdn_ranges, graph_build, wayback_ga |
scripts/webpivot/ |
intel_engine/WebPivot/tools/ |
whois_enrich |
intel_engine/WebPivot/tools/ |
scripts/webpivot/ |
Never turn a shim back into a copy. If directories move, update the relative depth in the shim.
The harness wrapper probes --help for each collector and filters unsupported flags (_supported_flags/_filter_args). Dropped flags are reported in the tool result — deliberately. A dropped --submit/--archive-missing means the evidence was NOT archived; do not silence that warning.
RULE 5 — indicator-classification changes require tests
Two files govern clustering:
intel_engine/tools/kb/noise_filters.py—MANAGED_DNS_SUFFIXES, parking favicons, and parking hosts. Maintenance model: add providers as they are encountered.intel_engine/tools/kb/hypothesize.py—_tier(), classifying a relationship as attribution / corroborating / noise.
uses_nameserver is conditional: delegation to a managed provider is noise; delegation to an operator-run nameserver is attribution evidence (you cannot point a domain at ns1.<host-của-họ> without controlling that zone). Adding a provider to MANAGED_DNS_SUFFIXES therefore weakens clustering — deliberately, which is why this list matters.
Every change to these two files must include classification checks covering at least one managed provider and one self-hosted nameserver.
RULE 6 — the harness layer is part of the skill
hooks/ + .claude-plugin/plugin.json is where the two safety properties are actually enforced under Claude Code. Details are in 07. Three points for code contributors:
leakguard.pymust not reimplement the regex patterns — it shells out toleakcheck.sh. A second copy will drift, and a drifting guard always reports clean.- Adding an outbound capability means adding a row to
hooks/references/outbound_actions.json, rather than merely adding a confirmation flag in Python. - Do not loosen a guard to suppress a prompt. Prefer tightening
flag_requiredor matching exactly. The trailing space inintel.py engage(which permitsengage-report) is the pattern to follow.
Syncing the vendored engine — a three-way merge, not a copy
Upstream: the working tree intelligence_assist (GitHub 0xdefh/Intelligence-AS). Synchronization is one-way; cti-expert never writes back.
"Copy and reapply the five shims" is not enough. The vendored tree also contains more than a dozen files intentionally patched by cti-expert, and overwriting them breaks things silently — collectors still run but stop finding anything. Two known examples: wp_common goes up one additional directory level to find .env (cti-expert is nested more deeply; the upstream version resolves all API keys to empty values), and pivot_extract enables reverse WHOIS by default, with --no-whois-reverse as an opt-out (upstream does the reverse).
Find the divergence point using blob identities, rather than memory: index every (blob, path) pair ever committed upstream, plus the current working tree, then hash each vendored file.
| vendored blob | meaning | action |
|---|---|---|
| matches an upstream blob at the same path | unmodified copy | safe to overwrite |
| path exists upstream, but its blob matches none of them | cti-expert patch | three-way merge |
| path does not exist upstream | cti-expert only | must survive; never rsync --delete |
For each patch, choose the merge base with the smallest diff distance across that path's blob history, then git merge-file <ours> <base> <theirs>. Expect duplicate @tool/def blocks where the base predates a tool that both sides later added:
grep -oE '^async def [a-z_]+' <file> | sort | uniq -d
Three remaining traps:
- Exclude
.venv,__pycache__,cases/,knowledge/,MEMORY/,.env. - Rename
SKILL.mdin each component toSKILL.reference.md— the repository has exactly oneSKILL.md, at the root. - zsh does not split unquoted parameters into words — an
rsync $EXCLUDESassembled as one string excludes nothing, pulling the entire 500 MB.venvinto the tree.
After syncing, verify all five things:
bash scripts/audit.sh
for t in tests/test_*.py; do python3 "$t" >/dev/null && echo "PASS $t"; done
for t in intel_engine/tests/test_*.py; do python3 "$t" >/dev/null 2>&1 && echo "PASS $t"; done
.venv/bin/python intel_engine/harness/mcp_server.py # gửi tools/list
python3 scripts/backend/intel.py pipeline open <CASE> <seeds>
Repository CI (read to understand, do not copy)
| Workflow | What it gates |
|---|---|
audit.yml job structural |
bash scripts/audit.sh — standard library/grep, no virtual environment, runs in seconds |
audit.yml job engine-tests |
installs requirements.txt and reruns the audit; fails if §9 reports SKIPPED |
audit.yml job leak-diff |
PRs only — scans only ADDED lines against the base ref, so example values already in the tree are not flagged again |
smoke.yml |
clone→install→run in a minimal Ubuntu container (root, no sudo, no preinstalled git/curl/python) plus standard macOS/Ubuntu runners; gated by scripts/smoke-test.sh |
These two workflows gate development of the tool itself. If you are a consumer who wants CI, see 08 — its example was written for a completely different purpose.
Before opening a PR
bash scripts/audit.sh # phải in "AUDIT: clean"
python3 tests/test_hooks.py # nếu bạn chạm hooks/
python3 tests/test_indicator_classification.py # nếu bạn chạm noise_filters/hypothesize (RULE 5)
python3 scripts/backend/intel.py list # nếu bạn thêm op — nó phải xuất hiện, và audit §2 phải xanh
grep -c '@tool(' intel_engine/harness/tools.py # nếu bạn thêm @tool — số này phải khớp CLAUDE.md
Everything tracked in this repository is public-facing. Treat it accordingly.