Convention guardrails

Enforce project conventions deterministically - a post-edit hook that scans what the agent writes and feeds any violation back so it self-corrects, at zero token cost until there is one.

Skills only persuade the model, and they load only when it decides to (progressive disclosure). So a convention that lives only in a skill - “use UUID primary keys”, “prefer Prisma as the ORM for TypeScript” - is often skipped: the agent never reads the skill. Guardrails enforces conventions instead, outside the model.

After the agent writes a file, a post-edit hook runs a set of data-driven rules against it. A blocking violation is fed straight back so the agent fixes it in the same turn. Nothing runs until an edit lands, and a clean file produces no output - so the cost is zero tokens until there is an actual violation, and a tiny message when there is.

It is on by default and everything runs locally.

How it works

Each rule is data, not code - one entry, either built in or in ~/.enigma-guardrails.json:

  • file rules regex-scan the edited file - e.g. a migration using SERIAL/AUTO_INCREMENT instead of a UUID key, a handler that assigns a request body without validating it (Zod/TS, Pydantic/Py), a raw <input type="password"> that should use your reusable Input, a new password accepted with no breach check against Have I Been Pwned, an analytics snippet loading before the cookie banner is answered, a person-name field whose value is never capitalized, a native alert/confirm/prompt that should be a design-system dialog, or a text-overflow: ellipsis with no overflow value, which does nothing and lets the text spill.
  • project rules run a small built-in check against the project (e.g. a TypeScript project on a relational datastore with no Prisma).
  • size rules set a maxBytes budget on a file instead of a pattern - used to stop an agent memory file (CLAUDE.md, AGENTS.md) from growing past 40 KB.

A file rule can also require a mitigation to be present: with an absent regex, the rule fires only when the risky pattern appears without the fix in the same file - that is how “reads input without validating it” is detected across languages.

Rules carry a severity: block is enforced (the agent hook exits non-zero, feeding the fix back to the model; a commit fails), warn is advisory (printed, never blocks). Messages name the owning skill (e.g. database-expert) so a fix is traceable.

Some violations never reach the model at all. Where the correct edit is mechanical, the hook applies it itself before reporting anything: a person-name <input> missing autocapitalize="words" gets the attribute written in, with the right casing for the file, and only that line is touched. A fixer declines anything ambiguous - a custom <TextField> that may not forward the attribute, two inputs on one line - and those fall through to the normal block, so the agent still handles what code cannot decide. Fixing costs no tokens and no turn; the message is only paid for when a human decision is genuinely needed.

A fixer can also belong to a diff-stage rule. fe-truncated-value-unreachable blocks a value that is clipped (truncate, text-overflow: ellipsis) with no title, aria-label or tooltip anywhere to recover the full text - a card that renders a long name or email and shows only its ellipsis. Its fixer runs in the post-edit hook, immediately, on whichever lines of the file the current change actually touched, rather than waiting for the turn-end sweep to find it; only what it cannot copy safely (a fallback expression, a call, a component that may not forward the attribute) still reaches the model, through the sweep.

One rule is about cost rather than correctness: ctx-memory-budget blocks an edit to a CLAUDE.md or AGENTS.md larger than 40 KB. A memory file loads into every session in the project, so a knowledge base living there is paid for on every task, relevant or not. Keep it an index - one line per topic - and move the detail into docs the agent opens on demand. Disable it with enigma guardrails disable ctx-memory-budget if you keep a large memory file on purpose.

The seed set is deliberately tiny and precise - it matches only unambiguous violations, so a false positive never trains the agent (or you) to ignore it. Add your own rules by dropping an entry in ~/.enigma-guardrails.json.

Two stages: the edited file, or the lines a change added

A rule also declares when it runs (stage):

  • edit (the default) is the post-edit hook and the commit backstop. They see a whole file, so a rule here has to be precise enough that your repository’s existing code never fires it - otherwise it blocks every unrelated edit and you switch it off.
  • diff is only ever reported by the turn-end sweep, against the lines the current change added. There is no legacy backlog by construction, so the rule can only fire on code the agent just wrote. A fixer for it still runs earlier, in the post-edit hook - repairing is not a report, so it is not held back for the sweep, only the finding is.

That second stage is what makes a demanding convention affordable. fe-server-first-mutation is the first of them: it blocks a handler that awaits a mutation and only then touches the UI (await fetch(...), then drop the row from the list) - the optimistic-UI defect frontend-policy describes. In a measured corpus of real UI files, most components that mutate anything hold no optimistic update at all, so as a whole-file rule it would have fired forever on code nobody wrote today. Mark the line enigma:allow-server-first when the server’s answer really is required first (a payment, a server-assigned id, an irreversible action).

The sweep runs inside the turn-end completion hook (see Verified completion): blocking findings deny the stop, warnings ride along in the same message, and both are recorded. It is also the only channel that can reach the model with a warning at all - a post-edit warning exits zero and is printed where the model never reads it.

Where it runs

  • Claude Code - a PostToolUse hook (matcher Edit|Write|MultiEdit|NotebookEdit) for the edit stage, and the turn-end Stop hook for the diff-stage sweep.
  • opencode - an auto-loaded plugin that runs after each write/edit, through the same hidden hook Claude Code calls - so a diff-stage fixer still repairs what it can there. It has no turn-end hook that can answer the model, so an unrepaired diff-stage finding is never reported.
  • Codex has no per-edit hook mechanism, so it is skipped (like auto-lint).
  • Commit / CI backstop - enigma security also copies the engine into .githooks/, so the same rules block a commit even if the agent hook was off or bypassed. Diff-stage rules stand down here: they are written for the lines a change added, not for a whole file.

What it keeps skipping

A rule that gets skipped leaves no trace. A block is fed back and forgotten, a warning exits zero, and an auto-fix is silent by design - so “the agent keeps ignoring this one” was never a number you could look at.

Every finding the agent is actually confronted with is appended to a local ledger (~/.enigma/guardrail-log.jsonl, one JSON line each, kept bounded). enigma guardrails stats reports it per rule in three columns: blocked (it was stopped and had to fix it), warned (it was told - or the turn-end gate stood down - and the code shipped anyway), fixed (repaired by code, no turn spent). The warned column is the point: it counts what the agent got away with, which is what tells a rule that should be promoted to block from one that only ever fires wrongly and should go.

Only the model-facing channels write to it - the post-edit hook and the turn-end sweep, never the commit backstop, whose whole-file scan would bury the signal under code nobody wrote today. The dashboard’s “Enigma Systems” row shows the 7-day count beside the active rule count.

The same file also holds a second population: the verify hook’s reply-style findings, one row per turn whose final message broke the output-style level. They answer a different question - “does the agent keep padding replies”, not “which convention keeps getting skipped” - so they are never counted alongside the rows above; enigma guardrails stats prints them as their own table.

Use it

enigma guardrails [cmd]

No argument lists the rules (built-in and custom, with their on/off state); check <file> runs the rules against a file (every rule, diff-stage ones included, so you can reproduce a finding you were blocked by); stats [days] reports which rules the agent keeps breaking, over the last days or over everything recorded, plus a second table for how often a reply broke the output style; disable/enable <id> toggles a built-in; remove <id> drops a custom rule. Toggle the whole feature with enigma config guardrails on|off (applies immediately to Claude Code and opencode); guardrails: false in a repository’s .enigma.json stands the turn-end sweep down for that project.

$ enigma guardrails
$ enigma guardrails check db/schema.sql
$ enigma guardrails stats 30
$ enigma guardrails disable db-uuid-pk
$ enigma guardrails enable db-uuid-pk
$ enigma config guardrails off

Add a custom rule

Custom rules live in ~/.enigma-guardrails.json. A file rule is a regex over the edited file:

{
  "rules": [
    {
      "id": "no-select-star",
      "label": "No SELECT *",
      "files": ["*.sql"],
      "scope": "file",
      "pattern": "select\\s+\\*",
      "flags": "i",
      "message": "Avoid SELECT * on hot paths (database-expert).",
      "severity": "warn"
    }
  ],
  "disabled": ["db-uuid-pk"]
}

disabled turns off a built-in by id. Add "stage": "diff" to a rule that should only be judged on the lines a change adds - that is how you write a demanding rule for a convention your existing code does not follow yet. That’s the whole authoring surface - one JSON entry per convention, no code.

What guardrails can’t check

Guardrails see the syntax of the code, not intent or behaviour. Some conventions have no reliable syntactic signature - “reads are cached for 30s”, “the form validates in real time”. A regex for those is either impossible or pure noise. Those live in enigma’s always-on memory instead (the “Engineering Defaults” block), stated once, language-agnostic, so the agent applies them by default without a hook. Guardrails back up the syntactic half; the memory covers the semantic half.

A rule also only sees the file it is scanning. fe-server-first-mutation catches the clearest shape of a server-first mutation, but a handler that hides the wait behind a parent refetch (await onChanged(), await load()) is the same defect with no local state write to judge - that half stays persuasion, and frontend-policy says so explicitly.

Privacy

Everything runs in-process on your machine; nothing is sent anywhere. The ledger is a local file holding rule ids, file paths and line numbers - never file contents. Turning the setting off removes the hooks from your agents.