Rules / CFG012
CFG012 settings.json structure does not match the official schema
Severity: error (type mismatch or unloadable guard hook, either of which discards the whole file) · warn (unknown top-level key; a guard-hook value that only newer releases reject)
OWASP: LLM02:2026 – Sensitive Information Disclosure
What cfgaudit checks
cfgaudit ships with an embedded snapshot of the SchemaStore Claude Code settings schema and validates the top level of settings.json against it. Two sub-checks fire, at different severities because they have different consequences:
- Unknown top-level keys (
warn) — any key that is not defined in the bundled schema. Catches typos, stale config from old Claude Code versions, and injected keys an attacker added hoping nobody would notice. Claude Code tolerates these: the rest of the file still applies. - Type mismatch (
error) — a known key whose JSON type the schema rejects (e.g.apiKeyHelperset tonull,cleanupPeriodDaysto a string). Claude Code does not ignore the value: it discards the whole settings file, so every permission rule, hook and env entry in it is inert.
What a type mismatch actually does
Measured against 2.1.282 and 2.1.273 with a settings file holding a SessionStart hook, where the hook running is the proof that the file loaded:
| beside the hook | file loaded |
|---|---|
| nothing (control) | yes |
"totallyUnknownKeyXyz": 1 |
yes |
"cleanupPeriodDays": "thirty" |
no |
"model": 5 |
no |
"apiKeyHelper": null |
no |
"permissions": null |
no |
"permissions": {"deny": [123]} |
yes |
The boundary is the top-level property: a wrong scalar type, or null, on any one of them drops the file, while an unknown key and a malformed value deeper inside are tolerated. Nothing is printed when it happens, so a repository whose settings.json has one wrong type looks configured and is not. The same holds for .claude/settings.local.json and for the user-scope ~/.claude/settings.json, both measured the same way against a valid-file control.
Because of this, the rules that go quiet when a deny block covers a file class — CFG006, CFG025 and CFG041–CFG044 — treat a file with a type mismatch as having no deny block at all, and CFG006 says so in its message. Rules that report a dangerous value in the same file still report it: the value is inert only until the type is fixed, which is what the CFG012 error asks for.
An unloadable PreToolUse or PermissionRequest hook
There is one exception to "deeper values are tolerated". An entry under hooks.PreToolUse or hooks.PermissionRequest that Claude Code cannot load drops the whole file as well, because such a hook may be what guards the permissions declared beside it. claude doctor says as much: "nothing it sits in is applied until the entry is fixed or removed". Under every other hook event the same mistake only skips that one entry.
Measured against 2.1.296 (and 2.1.291 for the version gate) with the entry under test in settings.json and a SessionStart marker in settings.local.json; a sample of the results:
| PreToolUse entry | file loaded |
|---|---|
valid command hook (control) |
yes |
| unknown extra key | yes |
"shell": "fish" (only bash, powershell) |
no |
"onFailure": "deny" (only continue, block) |
no from 2.1.295, yes on 2.1.291 |
"timeout": 0 or -1 |
no |
"timeout": 0.5 |
yes |
"async": "yes", "once": "yes", "statusMessage": 5, "if": 5 |
no |
"type": "bogus", or no type |
no |
command hook without command |
no |
"command": "" |
yes |
http hook with "url": "/h" or "" |
no |
http hook with "url": "ftp://…" |
yes |
"model": 5 on a prompt hook |
no |
"model": 5 on a command hook |
yes (stripped, it is not a command field) |
group without hooks, or "matcher": 5 |
no |
group without matcher |
yes |
old flat shape {"matcher": "Bash", "command": "..."} (no hooks array) |
no |
So Claude Code validates the fields that belong to the entry's type and strips the rest. CFG012 follows that model (in rules/guardhooks.go, with the full measured table as its test) rather than the bundled schema, which disagrees with the runtime in both directions: it declares additionalProperties: false and minLength: 1 on command, neither of which is enforced, and it has no once or onFailure. Each unloadable entry gets its own error finding naming its path, and the deny-coverage rules stop crediting the file exactly as for a type mismatch.
On 1,082 real .claude/settings.json and settings.local.json files that carry a PreToolUse, PermissionRequest or onFailure key, the check fires on 7 files, and all 7 were confirmed as dropped by running 2.1.296 on the file itself. Six use the old flat shape above, which is why the finding for that case names the fix; the seventh sets "onFailure": "ignore".
onFailure arrived in 2.1.295. On an older detected release the invalid value is still ignored, so the finding is a warn that says newer releases drop the file; with no detected version it is an error.
Schema lag
The published schema lags behind CLI releases. A key absent from the schema is not necessarily invalid — it may have been added upstream in a recent Claude Code version, which is why the unknown-key check stays at warn.
The type check carries a smaller version of the same risk: if upstream widens a key's type and the bundled schema has not caught up, cfgaudit reports an error and treats the file as discarded when Claude Code actually loads it. That direction costs a false "no deny block" finding, never silence, which is the trade the severity is set on. Either way, a known false positive is silenced in .cfgaudit.yml, with rules: { CFG012: off } or by excluding the file with exclude-paths.
One widening is modelled already: from 2.1.299, enabledMcpjsonServers and disabledMcpjsonServers may hold a single server name (letters, digits, - or _) as a plain string instead of an array. Measured on 2.1.304 with a SessionStart hook, "github" and ["github"] load and "*" is still dropped. Such a string is not reported on 2.1.299 or later, or when the version is unknown; on an older detected release it is, since that release discards the file.
A small allowlist accommodates keys that are widespread in real configurations but absent from the schema today (currently mcpServers and defaultMode). Update it in internal/schema/schema.go as the upstream schema catches up.
Strict-parse keys
For keys the internal Go parser models (permissions, env, hooks, mcpServers), a type mismatch is caught by the JSON unmarshaller before any rule runs, and the file is reported as unparseable by CFG109 instead. CFG012 catches type mismatches for the remaining keys (everything that flows through the raw map) and the guard-hook values the parser accepts.
Trigger examples
Unknown key:
{
"thisKeyDoesNotExist": true
}
Type mismatch:
{
"apiKeyHelper": ["should be a string, not an array"]
}
Safe alternative
Keep settings.json to keys documented in the Claude Code settings reference and use the JSON types the schema specifies. If you intentionally use a newer key not yet in the bundled schema, suppress the warning inline.
References
- SchemaStore Claude Code settings schema
- Claude Code docs: Settings
- OWASP LLM02:2026 – Sensitive Information Disclosure
Documentation for cfgaudit 1.18.1. Source of this page · Rule implementation