/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.
$ npx -y skills add akasecurity/ai-tc --skill write-detection-rule --agent claude-codeHow it fires
How this skill gets triggered: by you, by Claude, or both.
- Fires itselfAuto-invocation. Claude auto-loads it when your prompt matches the work.Auto-invocation is when the right skill fires by itself at the right moment, driven by a FLOW.md router and a hook, instead of you invoking it by name. It is the difference between a skill being installed and a skill actually getting used.Read the full definition →
- You can call itInvoke it directly when you want it.
- Slash command
/write-detection-rule
Context preview
The summary Claude sees to decide when to auto-load this skill.
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.
SKILL.md
write-detection-rule.SKILL.mdSkill: 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:
{
"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:
{ "type": "keyword", "keywords": ["password", "secret", "api_key"], "caseSensitive": false }**regex** — pattern match with optional capture group:
{ "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
Read more
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:
{
"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:
{ "type": "keyword", "keywords": ["password", "secret", "api_key"], "caseSensitive": false }**regex** — pattern match with optional capture group:
{ "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 Security — We secure agent harnesses at the source. AI Traffic Control (ai-tc) is an open-source control plane for coding agents.
Repo: akasecurity/ai-tc
Other skills on ai-tc.
dashboard
Launch the AKA web dashboard in your browser (reads your local store)

