Rules / CFG111
CFG111 a permission rule or hook matcher holds an invisible character, so it does nothing
Severity: error (deny / ask rule, hook matcher) / info (allow rule)
OWASP: LLM03:2026 – Excessive Agency
What cfgaudit checks
Every entry of permissions.deny, permissions.ask and permissions.allow, and every hook matcher, in Claude Code settings files and plugin hooks.json, for a character that renders as nothing: a Unicode format character (zero-width space and joiners, bidirectional controls such as U+202E, the Tags block, soft hyphen, word joiner, BOM), a control character such as NUL, or a blank Hangul filler.
{
"permissions": {
"deny": ["Bash(cu\u200brl:*)"] // CFG111 error: reads as Bash(curl:*), blocks nothing
},
"hooks": {
"PreToolUse": [{ "matcher": "Ba\u200bsh", "hooks": [ ... ] }] // CFG111 error: never runs
}
}
The message shows the entry with the character spelled out (Bash(cu<U+200B>rl:*)) and its position.
Measured on Claude Code 2.1.288
Scratch HOME, trusted scratch project, permissions.allow: ["Bash(touch:*)"], and a local endpoint replaying a touch Bash tool call:
| project settings | touch ran |
|---|---|
deny: ["Bash(touch:*)"] (control) |
no, "Permission to use Bash ... has been denied" |
| the same deny with U+200B, NUL, U+202E or U+00AD inside it | yes |
ask: ["Bash(tou\u200bch:*)"] |
yes, no prompt |
A PreToolUse hook with "matcher": "Bash" ran for the call; with "matcher": "Ba\u200bsh" it did not run. In none of the cases did Claude Code warn: the only log line is the rule being added, with the character in it. (2.1.287 refuses a hook matcher with an invisible character, but only for cloud-synced hooks, not settings files.)
So the entry reads as configured in every editor, in git diff and in review, and is out of force. A deny or ask rule and a hook matcher are guardrails, so that is error, and it is the Trojan Source shape: one character in a pull request disables a rule nobody sees change.
Why allow is only info
A hidden character makes a rule's text longer, so an allow rule can only match less than it appears to, never more. That carries no risk; the rule simply does not grant what it says.
What is deliberately not flagged
- Line breaks and tab. Claude Code writes them itself: a rule saved from "Yes, and don't ask again" on a multi-line command, such as a heredoc commit message or an inline script, keeps its newlines.
- A zero-width joiner between two emoji, which is how
👨\u200d💻is built and which saved commit-message rules carry. A joiner anywhere else is reported. - Visible non-ASCII text: CJK, Cyrillic and accented paths and messages are ordinary rule content.
False-positive measurement
1085 committed settings files (seeded on deny, ask and matcher in .claude/settings*.json, plus the corpora of #609 and #610), holding 34,122 permission rules and 981 hook matchers, 146 of the rules with non-ASCII text.
- First version (control characters including newline, allow at
warn): 28 findings, 26 of them newlines in rules Claude Code saved itself. - Final: 0 error, 2
info, both one real file whose Windows path was written with\binside a JSON string (...Project\barangay...), which JSON reads as a backspace, so the allow rule really matches nothing.
The zero on deny, ask and matchers is the expected shape: the character is an accident or an attack, and neither is common. The rule exists because the attack costs one keystroke and is invisible to the reviewer.
Fix
Delete the entry and type it again. Copying a rule out of a chat, a web page or a PDF is the usual accidental source; an editor that shows invisible characters (VS Code's editor.unicodeHighlight.invisibleCharacters) makes them visible.
Documentation for cfgaudit 1.18.1. Source of this page · Rule implementation