Skip to content
Security
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.

From plugin
ai-tc
1711 skills10 commands

Claude Code

$ npx -y skills add akasecurity/ai-tc --skill write-detection-rule --agent claude-code

SKILL.md

write-detection-rule.SKILL.md

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

Read more
@akasecurity
Ships withai-tc

Guardrails for the agent harness. Regulated data stays on your machine, and every prompt and tool call is scanned before it runs.

Stars on GitHub

Momentum

stars in total
17
Star history of akasecurity/ai-tc on GitHub, a chart by star-history.com

Other skills on ai-tc.

More skills from other plugins.

api-security

api-security

Use for authorized security assessment of REST, GraphQL, WebSocket, or SOAP APIs, including discovery, authentication,…

@zhaoxuya520@zhaoxuya520Install Skill
nemoclaw-user-guide

nemoclaw-user-guide

Find official NemoClaw documentation for installation, configuration, operation, or troubleshooting. Also handles requested docs…

@nvidia@nvidiaInstall Skill

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.