write-detection-rule · git:20261007.566a463 · 2026-10-07 · sha256 aa6e426a66c9e61b
write-detection-rule git:20261007.566a463F
Immutable. This exact content never changes and is served at /api/v1/blob/aa6e426a66c9e61b.
# Skill: write-detection-rule
This skill teaches Claude Code how to write detection rules for the AI Traffic Control rule engine. Read this before creating or modifying anything in `rules/`. The full rule schema is `Rule` in `packages/schema/src/zod/rule.ts` — it is the source of truth for every field below.
## Rule file format (specVersion 1)
Every rule is a JSON file named `<rule-name>.json` inside a pack directory:
```json
{
"specVersion": 1,
"id": "<pack-id>/<rule-name>",
"name": "Human-readable name",
"category": "pii|financial|secret|phi|code_context|code_flaw|custom|config",
"severity": "critical|high|medium|low",
"matcher": { ... },
"postValidators": ["luhn"],
"examples": ["example matching string"]
}
```
### A misspelled key fails the parse — it is not dropped
Every object in the rule tree is **strict**: an unrecognized key is a parse error naming the key,
at whatever depth it sits. This holds for the rule itself, for `matcher`, `appliesTo`,
`requiresNearby`, the object form of a `postValidators` entry, and for fixture files including each
entry of `expectedSpans`.
The reason is that the old behaviour was to strip the key and carry on, which is the worst outcome
available: the rule still parses, still loads and still fires, with whatever the key was meant to
configure simply gone. `postValidator` for `postValidators` dropped a false-positive guard.
`capture_group` for `captureGroup` widened the redacted span from the value to the whole match.
`windowChar` for `windowChars` left a proximity gate at the 160-character default while the author
believed they had narrowed it. None of these produce a failing fixture, so nothing caught them.
So a rule that parses is a rule whose every key was understood. If you get `Unrecognized key`,
check the spelling against `Rule` in `packages/schema/src/zod/rule.ts` — the field is real but named
something else, or it belongs one level up or down.
### Matcher types
**keyword** — fast literal or phrase match, good for high-recall low-precision terms:
```json
{ "type": "keyword", "keywords": ["password", "secret", "api_key"], "caseSensitive": false }
```
**regex** — pattern match with optional capture group:
```json
{ "type": "regex", "pattern": "\\bAKIA[A-Z0-9]{16}\\b", "flags": "g" }
```
`captureGroup` (integer) extracts a subgroup as the matching span. `flags` defaults to `gi`.
**`captureGroup` is checked against the pattern's real group count.** Group 0 is the whole match,
so a pattern with two groups accepts `0`, `1` or `2` and nothing higher. Only _capturing_ groups
count — `(?:…)` and lookarounds do not — which is exactly what is easy to miscount. An index past
the last group is `undefined` at match time, so the matcher records no span and the rule never
fires; that used to parse cleanly and look, from the outside, identical to a pattern that simply
did not match. The refusal names the count so you can correct the index.
**A whole-match pattern must not be able to match the empty string.** `Rule.parse` rejects
`\d*`, `a?`, `(?:)` and the like: under `g` (part of the `gi` default) a zero-length match never
advances the scan position, so the rule would re-match at the same index forever. Require at
least one character — `\d+`, not `\d*`. The schema enforces this on every whole-match regex rule
whatever its flags, so dropping `g` is not a way around it.
The check is scoped to the whole match only, so a `captureGroup` rule may still use `*` or `?`
around its capture: `key=(\w*)` is valid, because the overall match still needs the literal
`key=` to advance.
### ReDoS protection
Three defenses stop a catastrophic regex from hanging a scan:
- **Authoring time.** Every bundled rule is measured against an adversarial
probe battery in CI (`packages/detections/test/security/redos.test.ts`) — a
rule that backtracks catastrophically fails the build before it can land in
`rules/`.
- **Runtime, before the scan.** A regex rule that arrives from a pulled or
custom pack (never seen by the CI battery) is measured once against the same
probe battery when it is first loaded, and the verdict is cached locally. A
rule that exceeds the timing budget is excluded from the active ruleset and
logged to stderr (`[aka] quarantined rule ...`) — never silently skipped.
The measurement itself runs in a worker thread, because the battery decides
by making the pattern backtrack: a pattern that never returns would otherwise
hang the gate meant to catch it.
- **Runtime, during the scan.** Both batteries are empirical: they prove a
pattern did not backtrack on the inputs they construct, not that it cannot.
So whenever a pulled/custom regex rule survives the pre-flight, the scan
itself runs in a worker thread under a wall-clock bound
(`packages/plugin-sdk/src/guarded-scan.ts`). A rule that does not finish is
terminated mid-execution, quarantined by the same cache so it never loads
again, and the built-in packs keep detecting. A scan with no such rule in it
— the state of a machine that installed nothing extra — runs in-process at no
added cost.
These two cover the **plugin capture path** — every hook, plus the worktree
scanner. The dashboard's `/scan` Server Action still evaluates the installed
ruleset in-process with neither gate, so a catastrophic pulled rule hangs that
request; the plugin is where the bound is.
**What this means for a rule you are writing.** A bundled rule never reaches
either runtime gate: CI is your gate, and a pattern that fails it fails the
build. Write patterns that cannot backtrack rather than relying on the bound —
a terminated scan costs the user their pulled rules for the rest of that
process, which is a detection gap, not a graceful degradation.
**If a rule of yours gets quarantined on a machine**, the verdict is cached and
the rule stops detecting until it is cleared: `aka detections unquarantine`
forgets every quarantine verdict so the rules behind them are measured again,
and `aka detections` shows the count. A rule that really is catastrophic lands
straight back in quarantine — clearing costs one re-measurement, not safety.
### Optional gating fields
**appliesTo** — scoping by file type, capture kind, or both. Give at least one of the two
lists; neither may be empty.
- `extensions` — `{ "appliesTo": { "extensions": [".py", ".ts"] } }`. The rule only runs
against text whose file extension is listed, and still runs when there is no file
context at all (live prompt/response hooks).
- `eventKinds` — `{ "appliesTo": { "eventKinds": ["tool_use"] } }`. The rule only runs on
captures of a listed kind (`prompt`, `response`, `code_change`, `tool_use`), and does
NOT run when the scan has no capture kind (a folder scan). Use it for rules about
commands an agent runs, so the same text quoted in a tool response or a file is not
reported as if it had run.
With both lists, both must match.
**requiresNearby** — co-occurrence gate: a match is kept only if corroborated by another
match (by `categories` or `ruleIds`) or by one of `labels` appearing within `windowChars`
of its span. Use it to suppress context-free false positives. `windowChars` counts
visible characters, for all three kinds: invisible format characters (zero-width spaces,
BOMs, soft hyphens, ZWNJ and the rest of Unicode `Cf`) between the two take up no room, so
size the window against the text a reader sees.
**evidence** — `"code"` or `"value"` (default `"value"`). It decides what a finding's excerpt
in the dashboard shows. A `"value"` rule's match is itself sensitive (a key, a password, an
email): the excerpt is the matched line alone with the match redacted. A `"code"` rule's match
is a code pattern (`exec(`, `innerHTML =`): the excerpt is the matched line plus two lines
either side — cut to the matched line alone when any of those lines holds a recognised value —
with the match left readable and highlighted. Set `"code"` only when the matched
text can never contain a secret value — a rule whose match includes a hardcoded password or key
stays `"value"` even though it is a code flaw.
Bundled rules do **not** carry this field in their JSON. An older plugin build parses installed
rules strictly and would drop any rule carrying a key it does not know, so the built-in packs take
their evidence from the compiled-in map in `packages/detections/src/evidence.ts` instead. A new
bundled code-pattern rule is added there; the field is for custom and pulled packs.
### Post-validators
A post-validator is a checksum or heuristic (not a standalone matcher, used via `postValidators`). Each runs against the matched span (or `captureGroup` if set) and must pass for the match to become a finding. Reference one as a bare name, or as `{ "name": ..., "config": { ... } }` for per-rule tuning.
The engine (`packages/detections/src/engine.ts`) implements exactly two:
- `luhn` — credit/debit card number check digit
- `entropy` — Shannon entropy >= 3.5 over a run of 20+ characters (distinguishes random secrets from low-entropy words; pair it with a `captureGroup` so entropy is measured on the token, not surrounding context). Config: `threshold`, `minLength`.
**Any other name fails the parse.** `PostValidatorName` in `packages/schema/src/zod/rule.ts` enumerates
the two, and the engine keys its validator table on that type — so the pair cannot drift, and a
misspelling (`Luhn`, `luhnn`) or a plausible-but-unimplemented name (`ssn-checksum` names a real
checksum, so it reads as legitimate, but nothing implements it) is refused with a message naming what
does exist. This used to be a silent no-op: the rule parsed, loaded and fired with the
false-positive guard simply absent.
### Encoded text
A `secret`-category rule also runs over base64 runs and hex dumps (`xxd`, `xxd -p`,
`hexdump -C`) found in the scanned text. The engine decodes each candidate that decodes to
readable text, scans it with the secret rules, and maps every finding back onto the encoded
characters, so a redaction masks the encoding. Nothing extra is needed in the rule: write the
pattern against the plain value. Rules in other categories never see decoded text. Decoding is
one level deep and bounded (`packages/detections/src/encoded.ts`).
## Pack structure
```
rules/<pack-id>/
manifest.json # required: { specVersion, id, name, version, rules: ["name1", ...] }
<rule-name>.json # one file per rule listed in manifest.rules
fixtures/
<rule-name>.json # REQUIRED: array of { label, text, shouldMatch: bool }
```
**A rule without fixtures will be rejected by CI** — see the bar below.
## Fixture requirements
Every rule must have labeled **positive** fixtures (where `shouldMatch: true`) and **negative** fixtures (where `shouldMatch: false`). **At least 2 distinct cases of each — CI enforces this**, and a rule below the bar fails the build. Distinct means a different scanned `text`/`filePath`/`eventKind`, so a repeated fixture does not count twice. Negatives are as important as positives — they prove your pattern doesn't over-match.
```json
[
{ "label": "exact match", "text": "AKIAIOSFODNN7EXAMPLE", "shouldMatch": true },
{ "label": "too short", "text": "AKIASHORT", "shouldMatch": false },
{ "label": "wrong prefix", "text": "XKIAIOSFODNN7EXAMPLE", "shouldMatch": false }
]
```
Three optional fixture fields (schema: `RuleFixture` in `packages/schema/src/zod/rule.ts`):
- `filePath` — simulated file context for the scan, so fixtures can assert `appliesTo`
gating (e.g. prove a Python-only pattern does NOT fire when `filePath` ends in `.ts`).
- `eventKind` — simulated capture kind for the scan, so fixtures can assert
`appliesTo.eventKinds` (e.g. a `tool_use` positive plus the same text as a `response`
negative, and once more with no `eventKind` to cover a folder scan).
- `expectedSpans` — array of `{ start, end }` pinning exactly which characters the
finding must cover. Add these whenever the rule redacts a value (via `captureGroup`),
so the span provably covers the value itself, not just a label next to it.
## Writing a good rule
1. Start with a regex that matches the full valid format
2. Use negative lookahead/lookbehind for adjacent-character exclusions (e.g., `(?!000)`)
3. Add `postValidators` for mathematical checks (Luhn, entropy) rather than making the regex more complex
4. Write negative fixtures that capture common false-positive patterns in code (variable names, comments, documentation examples)
5. Test locally: `pnpm test --filter @akasecurity/detections` — all fixtures must pass before opening a PR
## Adding a new pack
1. Create `rules/<pack-id>/manifest.json`
2. Create each `rules/<pack-id>/<rule>.json`
3. Create `rules/<pack-id>/fixtures/<rule>.json` (mandatory)
4. Run `pnpm test --filter @akasecurity/detections` and verify all fixtures pass
5. Regenerate the bundled-pack snapshot: `pnpm --filter @akasecurity/plugin-sdk gen:bundled-packs`
rewrites `packages/plugin-sdk/src/bundled-packs.generated.ts` from the `rules/` tree — that
generated module is how packs reach the shipped plugin and CLI (rule JSON is inlined at
build time; there is no runtime pack download). The drift test
`packages/plugin-sdk/test/rule-packs.test.ts` fails CI if you forget this step.
## Severity guide
Calibrated against the shipped packs:
| Severity | When to use |
| -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| critical | Immediate credential/key leak (cloud API key, private key) or a directly exploitable code flaw (command/SQL injection, JWT verification disabled) |
| high | High-risk PII with fraud potential (SSN, passport, credit card) or a risky code pattern (embedded credential, TLS verification disabled, unsafe deserialization) |
| medium | Moderate-risk PII (email, phone, name+address combinations) or a weak-crypto / debug-left-on code pattern |
| low | Low-risk contextual data (internal hostnames, file paths, feature flags, DB table names) |