Imperial Edicts
August 26, 2026 · View on GitHub
Project-specific hard rules that ride on top of cc-enforcer's built-in 12 rules. v0.12 introduces this as a layer-0 customisation mechanism.
1. Why Imperial Edicts
The built-in 12 rules cover general AI laziness patterns (verify don't guess, root cause not symptom, etc.). But every project has its own red lines that no general rule can cover:
- "No mongoose — this project uses prisma."
- "Every API call goes through
src/api/client.ts." - "No direct fetch inside a React component."
- "No await inside .map."
- "Every migration file ships a matching rollback."
These are per-project, user-defined, and ideally physically enforced (not just soft reminders that get ignored). An edict is that.
The metaphor: built-in rules are constitutional law; an edict is project royal decree — more specific, top priority, can override default suggestions, but cannot override constitutional safeguards (the built-in hooks still run first).
2. File format
Location: ${CLAUDE_PROJECT_DIR}/.claude/cc-enforcer/edicts.toml.
Fallback: ~/.claude/cc-enforcer/edicts.toml (personal global).
Format: TOML, array of tables.
[[edicts]]
id = "E01" # required: unique short id
text = "No mongoose — this project uses prisma" # required: imperative text shown to the agent
severity = "must" # "must" (default) | "should"
deny_edit = ['''from ["']mongoose["']'''] # optional: regex list, matched against Edit/Write content
deny_bash = ['''npm (i|install) mongoose'''] # optional: regex list, matched against Bash commands
note = "Standardised on prisma; mongoose removed in PR #142" # optional: rationale shown in deny reason
[[edicts]]
id = "E02"
text = "Every API call goes through src/api/client.ts"
severity = "should" # soft layer only: injected as reminder, NOT physically enforced
Fields
| Field | Required | Type | Description |
|---|---|---|---|
id | yes | string | Unique short id (any string). Appears in deny reasons. |
text | yes | string | Imperative one-liner the agent sees in the injection. |
severity | no (default must) | "must" | "should" | must = physically DENY on regex match. should = soft reminder only. |
deny_edit | no | list[string] | Regexes matched against Edit/Write new_string / content. |
deny_bash | no | list[string] | Regexes matched against Bash command. |
note | no | string | Optional context shown in the deny reason (e.g. PR link, ticket id). |
Regex tips
- Use triple-quoted strings (
'''...''') — TOML's single-quoted literal strings need no escaping inside, so your regex stays readable. - Regexes use Python's
resyntax. Test interactively withpython -c "import re; print(re.search(r'PATTERN', 'TEST_STRING'))". - Each edict can have multiple
deny_edit/deny_bashpatterns; any match triggers the deny. - A broken regex is skipped with a stderr warning; the other patterns in the same edict still apply.
3. Enforcement contract
| Layer | When | Behavior |
|---|---|---|
| Soft (SessionStart) | At session boot | All edicts (must + should) injected as a markdown table. Survives the entire session. |
| Soft (UserPromptSubmit) | Every user turn | Re-injected to survive context compaction. |
Hard (PreToolUse(Edit|Write)) | When agent calls Edit / Write | For each must edict with deny_edit: scan new_string / content. First match → DENY with reason naming the edict id. |
Hard (PreToolUse(Bash)) | When agent calls Bash | For each must edict with deny_bash: scan command. First match → DENY. |
Bilingual rendering (v0.17; default flipped to English in v0.21)
Both the soft-layer injection block and the hard-layer DENY reason
honor the CC_ENFORCER_LANG env var that v0.15 introduced for the
base prompts. Since the v0.21 skeleton flip, English is the default and
unknown codes fall back to English:
CC_ENFORCER_LANG | Injection banner | DENY headline |
|---|---|---|
unset / en / unknown | 🏛️ Imperial Edicts (project hard rules; priority > builtin 12) | cc-enforcer · Imperial Edict E01 violation |
zh | 🏛️ 圣旨(项目自定义硬规则;优先级 > 通用 12 条) | cc-enforcer · 圣旨 E01 violation |
The edict text / note strings themselves are passed through
verbatim — they're whatever you wrote in edicts.toml. Only the
framing language switches.
Built-in rules run first, with one documented exception. The order in
read_guard.py is:
- read-before-edit guard (rule 04 + 08)
- patch-style marker guard (rule 09)
- hardcoded-secret guard (rule 10)
- path-dependency guard (rule 11)
- Edict scan
- rolling-patch frequency guard (rule 09, v0.13) — the one built-in layer that runs after the edict scan, because it is a counter over the session rather than a content check, and it must not increment for a write that some earlier layer is going to deny anyway
Order in bash_guard.py (inverted in v0.25.0 — it used to run the
escape hatch first):
- static deny patterns:
--no-verify/--no-gpg-sign/chmod 777/git rebase --skip/--break-system-packages/rm -rfon a root path (rule 03 + 09) - force-push detection (parsed through
lib/shellcmd, not a regex) - Edict scan
register_read.pyescape hatch (v0.4.0)
The v0.25.0 inversion is observable, which is why the old ordering above
was worth correcting rather than glossing: under the documented-but-wrong
order a register_read command returned before anything else ran, so it
was never scanned by edicts — and register_read.py --file F --hash H && git push --force was allowed. Under the real order every deny check
clears first, and a command destined for denial no longer mutates session
state.
You cannot define an edict that whitelists --no-verify — the built-in
hook fires before reaching the edict layer.
4. Managing edicts
Slash command (/cc-enforcer:edict)
/cc-enforcer:edict list
/cc-enforcer:edict add E01 "No mongoose" --must --deny-edit 'mongoose' --deny-bash 'npm i mongoose'
/cc-enforcer:edict remove E01
/cc-enforcer:edict path
Direct CLI
python "${CLAUDE_PLUGIN_ROOT}/hooks/scripts/manage_edicts.py" list
python "${CLAUDE_PLUGIN_ROOT}/hooks/scripts/manage_edicts.py" add ID "TEXT" [--must|--should] \
[--deny-edit REGEX]* [--deny-bash REGEX]* [--note NOTE] [--global]
python "${CLAUDE_PLUGIN_ROOT}/hooks/scripts/manage_edicts.py" remove ID [--global]
python "${CLAUDE_PLUGIN_ROOT}/hooks/scripts/manage_edicts.py" path
--global flag (v0.14)
By default add writes to ${CLAUDE_PROJECT_DIR}/.claude/cc-enforcer/edicts.toml
(team-shareable, recommended). Pass --global to write to
${HOME}/.claude/cc-enforcer/edicts.toml instead — useful for
personal rules that should apply across all your projects (e.g. "never
let claude touch my dotfiles", "always use my preferred test runner").
remove without --global looks in the project file first then falls
back to the global file; pass --global to restrict removal to the
global file only.
The loader (used by all hooks) tries project first then global, so project edicts take precedence when both files define the same id.
Hand-edit
The file is small enough to edit directly. Changes take effect on the next hook event — no reload needed (the loader reads disk on every invocation).
5. Examples
Block a specific library
[[edicts]]
id = "E01"
text = "No mongoose — this project uses prisma (migrated in PR #142)"
severity = "must"
deny_edit = [
'''from ["']mongoose["']''',
'''require\(["']mongoose["']\)''',
'''import .* from ["']mongoose["']''',
]
deny_bash = [
'''npm\s+(i|install)\s+.*\bmongoose\b''',
'''yarn\s+add\s+.*\bmongoose\b''',
]
note = "see PR #142 / RFC 0007"
Enforce architecture boundary (soft)
[[edicts]]
id = "E02"
text = "Every HTTP call goes through src/api/client.ts; no bare fetch or axios"
severity = "should" # soft -- complex to regex perfectly, prefer reminder
Block a known footgun
[[edicts]]
id = "E03"
text = "No await inside .map / .forEach — use Promise.all with map"
severity = "must"
deny_edit = [
'''\.(map|forEach)\s*\(\s*(?:async\b[^)]*=>|\([^)]*\)\s*=>\s*\{[^}]*\bawait\b)''',
]
note = "It serialises what should be concurrent; use Promise.all(arr.map(async ...))"
6. Limitations
These are decided, not pending. There is no "future work" list here any more: the one item that was on it is retired below with its reason, because a limitations section that doubles as a wish-list is how a permanent constraint gets read as a temporary one.
- Per-session ephemeral edicts (
/cc-enforcer:edict add --session ...) — proposed since v0.12, dropped in v0.32.1, not deferred. The blocker is structural, not effort: this CLI is a Bash subprocess and has nosession_id. Only the hook payload carries one, which is precisely whyregister_read's authoritative half lives insidebash_guard.py. Building it would mean a second hook-mediated write path for a feature whose whole value is being temporary — and ashouldedict already covers the light-touch case without any of that. Use the file, or pass--should. - No exception mechanism — an edict either matches or it doesn't. If you want a per-file exemption, write a more specific regex or remove the edict.
- Regex is the only matcher. AST-based / semantic matching is out of
scope; if you need it, write a custom hook in
hooks/hooks.json.
See CHANGELOG.md §0.12 for the full enforcement
contract changelog.