feat(config-guard): block tampering with guardrail configuration - #39
Merged
Conversation
- claude plugin install/uninstall/enable/disable/update and marketplace add/remove now count as config writes (a plugin install registers arbitrary hooks); list forms stay allowed - Edit/MultiEdit/Write paths resolve through symlinks before checking, so ln -s ~/.claude /tmp/x then writing /tmp/x/settings.json is caught; nonexistent files resolve their parent dir (keeps the CVE vector covered) - 9 new tests; badge synced to 1378
RM, SED -I, TEE, and CLAUDE MCP ADD resolve to the same binaries on the case-insensitive filesystems macOS and Windows default to, so the mutation-verb and CLI regexes now carry the i flag like the path patterns already did. 4 new tests; badge synced to 1382.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation
Every hook in this repo assumes its own config stays intact. Two real incidents broke that assumption:
.claude/settings.json, using the agent's own config as the persistence mechanism.settings.jsonthat did not exist yet.An agent (or anything riding on it) that can rewrite hook config can switch every other guardrail off. This PR closes that loop: who guards the guards.
Design decision
Checked the live hooks reference at code.claude.com/docs/en/hooks before writing anything. Two candidate surfaces:
hook-scripts/pre-tool-use/config-guard.js): it fires before damage and supports deny/ask, so the agent's own Edit/MultiEdit/Write/Bash mutations of guardrail config are stopped up front.hook-scripts/config-change/config-watch.js): the docs confirm ConfigChange CAN block (exit 2 stops the change from taking effect, exceptpolicy_settings), so the secondary is more than detect-and-warn. But the docs do not document the event's input payload schema, so the hook parses defensively (source/config_source/matcher, file_path/path), logs the raw payload for discovery, defaults to a visible systemMessage warn (ConfigChange also fires for changes you make yourself), and offers opt-in hard blocking viaCONFIG_WATCH_BLOCK=true. The README section states these limits plainly.What config-guard covers
.claude/settings.json,.claude/settings.local.json(any depth, so~/.claude/too),managed-settings.json, anything under.claude/hooks/(the guard protects itself),hooks.jsonmanifests.mcp.json,.claude-plugin/, plus the CLI writes that rewrite agent config without naming a path:claude config set|add|remove,claude mcp add|remove, andclaude plugin install|uninstall|enable|disable|update|marketplace add|remove(a plugin install registers arbitrary hooks); list/get forms stay allowedCLAUDE.md,CLAUDE.local.md,.claude/rules/,.claude/agents/,.claude/commands/>,>>,>|) into a protected path,sed -i/perl -i/gawk -i inplace,tee/truncate,mv/cp/ln/rsync/install,rm/unlink/shred/git rm- all case-insensitive, since APFS/NTFS resolveRMandSEDto the same binariesln -s ~/.claude /tmp/xfollowed by a Write to/tmp/x/settings.jsonis still caught; a not-yet-existing file resolves its parent directory, which keeps the CVE creation vector coveredcat/jq/grep/ls/diff, evencat settings.json > /tmp/copy), unrelated dotfiles and app-levelsettings.json/webhooks.json/Reacthooks/dirs passCONFIG_GUARD_ALLOW=truefor an intentional human-approved edit, and the existingHOOK_ASK_CRITICAL/HIGH/STRICTask mode so intentional edits can degrade to a promptgit restoreof config paths are not caught, and shell strings (unlike file-tool paths) are not symlink-resolvedTests
144 new tests (118 for config-guard, 26 for config-watch), hermetic per the repo's audit conventions (temp HOME, env scrubbing, real symlink fixtures). Suite went from 1238 to 1382, zero failures; the meta discovery guard sees both new files.
README: one row in the Pre-Tool-Use table, one new Config-Change section (motivation links + combined settings snippet), config-guard added to the ask-mode list, tests badge bumped.