settings.json writes silently strip unknown keys from hook groups and hook items
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report (please file separate reports for different bugs)
- [x] I am using the latest version of Claude Code
What's Wrong?
Any write to settings.json removes unknown keys from hook group objects and hook item objects. Unknown keys at the settings root are preserved, so the behaviour is inconsistent within a single file.
The write does not have to touch hooks. Enabling a plugin, adding a marketplace, or granting a permission rewrites the whole file, and every hook group in it loses its unknown keys as a side effect.
This breaks a pattern several third-party tools depend on: putting a marker key on their own hook group so a re-install can recognise and update its own registration in place. When the marker is silently deleted, the tool can no longer identify its entry. It either appends a duplicate (the hook then fires twice per event, forever) or fails to remove its own hook at uninstall.
At least two independent tool ecosystems have already shipped workarounds for this, months apart, without an upstream report. See Additional Information.
Nothing is lost from the schema's point of view, which is presumably why it has gone unnoticed. The loss is only visible to whoever wrote the key.
What Should Happen?
Either unknown keys are preserved everywhere in settings.json (matching what the root object already does), or they are rejected loudly with a warning naming the key and the file.
Silently deleting a key the user put there is the one option that leaves no way to notice.
Error Messages/Logs
There is no error. The strip is silent. Diff of the hooks subtree across one claude plugin marketplace add:
{
"matcher": "",
- "_group_key": "group-level-unknown",
"hooks": [
- { "type": "command", "command": "/bin/echo stop", "timeout": 5, "_item_key": "item-level-unknown" }
+ {
+ "type": "command",
+ "command": "/bin/echo stop",
+ "timeout": 5
+ }
]
}
matcher, type, command, timeout, env and permissions all survive. Only the unknown keys inside the hooks subtree are dropped.
Scope of the strip:
| Key location | Result |
|---|---|
| Hook group object (sibling of matcher / hooks) | stripped |
| Hook item object (sibling of type / command) | stripped |
| Settings root object | preserved |
| Known schema keys anywhere | preserved |
Confirmed on SessionStart, Stop, SubagentStop and PreToolUse in a single write.
Steps to Reproduce
Fully isolated. Uses a throwaway CLAUDE_CONFIG_DIR and a local marketplace directory, so it never touches your real ~/.claude, and needs no network.
- Create a throwaway config dir and a local marketplace:
WORK=$(mktemp -d); CFG="$WORK/config"; MKT="$WORK/marketplace"
mkdir -p "$CFG" "$MKT/.claude-plugin" "$MKT/probeplug/.claude-plugin"
printf '%s\n' '{"name":"probemkt","owner":{"name":"probe"},"plugins":[{"name":"probeplug","source":"./probeplug","description":"probe"}]}' > "$MKT/.claude-plugin/marketplace.json"
printf '%s\n' '{"name":"probeplug","version":"1.0.0","description":"probe"}' > "$MKT/probeplug/.claude-plugin/plugin.json"
- Seed
$CFG/settings.jsonwith an unknown key at three depths:
{
"hooks": {
"Stop": [
{
"matcher": "",
"_group_key": "group-level-unknown",
"hooks": [
{ "type": "command", "command": "/bin/echo stop", "timeout": 5, "_item_key": "item-level-unknown" }
]
}
]
},
"_root_key": "root-level-unknown",
"env": { "PROBE_ENV": "kept" },
"permissions": { "allow": ["Bash(echo:*)"] }
}
- Make the client write the file (this adds
extraKnownMarketplaces, and touches nothing about hooks):
CLAUDE_CONFIG_DIR="$CFG" claude plugin marketplace add "$MKT"
- Inspect
$CFG/settings.json.
Observed:
STRIPPED _group_key
STRIPPED _item_key
KEPT _root_key
Expected: all three keys preserved, or a warning naming the ones being removed.
A client command that fails writes nothing and strips nothing, so the strip is tied to the write path rather than to validation on read.
Claude Model
Not model-dependent (no model involved; this is a CLI config write).
Is this a regression?
Unknown, and I cannot name a last-good version. The same strip was observed on this machine on 2026-07-16 and again on 2026-08-20, across client upgrades in between, so it is not a recent change.
Last Working Version
None known.
Claude Code Version
2.1.227
Platform
Claude Code CLI (native binary, installed via Homebrew cask)
Operating System
macOS 26.6 (build 25G72), arm64
Terminal/Shell
zsh
Additional Information
Triggering operations. Anything that persists settings.json. Verified: claude plugin marketplace add (writes extraKnownMarketplaces) and claude plugin install (writes enabledPlugins). Expected to behave the same by inspection of which keys they persist, though not individually verified: plugin enable / disable, in-session permission grants, and config toggles that persist to user settings. Project-level .claude/settings.json and settings.local.json are untested.
None of these operations are about hooks. That is the crux: toggling a plugin has no obvious connection to your hook registrations being modified.
A real incident. Reconstructed from on-disk evidence:
| Time (2026-08-20) | Event |
|---|---|
| 10:17:05 | plugins/known_marketplaces.json written |
| 10:18:43 | plugins/installed_plugins.json written |
| ~10:18 | Client writes settings.json; three hook-group markers vanish |
| 10:24:28 | An unrelated tool backs up settings.json and finds the markers already gone |
The client write is provable independently of the markers: a backup taken at 10:24 contains a new enabledPlugins entry that is absent from the previous day's backup. The same file lost every hook-group marker in that write. A backup series on the same machine shows marker count going 1 → 0 on 2026-07-16 and 2 → 0 on 2026-08-20.
Prior art — workarounds already in the wild. gstack (public, garrytan/gstack) does not trust its own marker and keys identity on an intrinsic table instead. From bin/gstack-settings-hook:
Entry-level_gstack_sourcetags are best-effort metadata — Claude Code strips unknown keys when it rewritessettings.json, so identity is intrinsic (the table), never tag-only.
A second, unrelated hook-managing tool carries a dated note to the same effect:
TAGS DO NOT SURVIVE (observed 2026-07-16): Claude Code rewrites settings.json and STRIPS unknown keys [...] Without the command fallback a re-add after a client rewrite silently DUPLICATES the entry.
Both now key identity on the command string. Both paid for that with a duplicate-hook bug first.
Related issues, and why this is not one of them. The existing reports in this area are data-loss-on-merge bugs, where whole known keys are lost to a clobbering or partial write:
- #66279 (closed, not planned) — project-scope permission grant drops
extraKnownMarketplaces/enabledPlugins. Closest match, but those are known schema keys at the root, and this report shows root-level unknown keys are preserved in user settings. Different level, opposite direction. - #59870 (closed) — whole top-level
hookskey dropped when granting a permission. - #78392 (open) —
settings.jsonrewritten with only{env, statusLine}, droppinghooks. - #62486 (closed) — partial rewrite strips
statusLine,enabledPlugins,hooksmid-session. - #76451 (open) — the claude.ai plugin ingester rejects an unknown top-level key in
hooks.jsonthat the CLI tolerates. Same theme of unknown-key handling differing between surfaces.
This one is a schema-strip rather than a merge bug: deterministic, reproducible on demand, affects only unknown keys, only inside the hooks subtree, and leaves the root untouched.
Suggested fix. Allow passthrough on the hook group and hook item schemas, matching what the root settings object already does. If unknown keys inside hooks are genuinely unwanted, the alternative is to warn rather than delete — claude doctor already reads settings files and would be a natural home for "unknown key _foo in hooks.Stop[0] will be removed".
Workaround for tool authors. Treat any marker key on a hook group as having a short half-life. Key identity on something the schema preserves (the command string, or a table of basename + event + matcher) and use the marker only as a hint. Re-stamp it on every re-registration, and expect it gone by the next plugin toggle.