/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.
Claude Code
$ npx -y skills add akasecurity/ai-tc --skill write-detection-rule --agent claude-code
Codex
$ npx skills add akasecurity/ai-tc --skill write-detection-rule -a codex -y
Cursor
$ npx skills add akasecurity/ai-tc --skill write-detection-rule -a cursor -y
Gemini CLI
$ npx skills add akasecurity/ai-tc --skill write-detection-rule -a gemini-cli -y
GitHub Copilot
$ npx skills add akasecurity/ai-tc --skill write-detection-rule -a github-copilot -y
OpenCode
$ npx skills add akasecurity/ai-tc --skill write-detection-rule -a opencode -y
Windsurf
$ npx skills add akasecurity/ai-tc --skill write-detection-rule -a windsurf -y
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
Guardrails for the agent harness. Regulated data stays on your machine, and every prompt and tool call is scanned before it runs.
Other skills on ai-tc.
dashboard
Launch the AKA web dashboard in your browser (reads your local store)
detections
List installed AKA detection packs, versions, and available updates
exceptions
List active AKA detection exceptions (masked) from the local store
More skills from other plugins.
api-security
Use for authorized security assessment of REST, GraphQL, WebSocket, or SOAP APIs, including discovery, authentication,…
abusing-dpapi-for-cred…
Extract and decrypt Windows DPAPI-protected secrets (Credential Manager, browser logins/cookies, Wi-Fi credentials, KeePass keys)…
clean-user-facing-text
Clean and finalize authorized natural-language text intended for readers by auditing suspicious invisible Unicode and rewriting…
Questions about write-detection-rule.
- Which coding agents can use write-detection-rule?
- write-detection-rule installs in Claude Code, Codex, Cursor, Gemini CLI, GitHub Copilot, OpenCode and Windsurf with the skills CLI, using each agent's flag shown on this page. Flowy's auto-invocation, which fires a skill when your prompt matches, runs in Claude Code only.

