fix(hooks): remove quoted/launcher-wrapped hook entries on clear & dedup (WEB-4737)#176
Conversation
…WEB-4737) setup --clear/nuke and the install dedup identified Unbound hook entries by exact string equality against an unquoted path, so quoted and launcher-wrapped commands (the forms MDM/managed and Windows installs write, e.g. "/Users/x/.claude/hooks/unbound.py" or py -3 "C:\...\unbound.py") never matched. On macOS/Linux clear left those entries behind (broken half-state: hook points at a deleted script) and install appended a duplicate instead of deduping. Replace the exact-match with a shared _command_targets_hook helper that tokenizes the command, dropping quotes and any launcher prefix, and compares the normalized path against the hook target. Applied to the claude-code and codex user-level + MDM variants and the binary codex merge. copilot/cursor are unaffected (they own their config files and clear them wholesale). Adds unit tests for quoted/bare/single-quoted/launcher forms, mixed quoted+bare duplicate removal, foreign-hook preservation, install dedup against a quoted entry, and cross-tree matcher parity. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
🛡️ Automated Security Review (consensus)1 finding — 1 high-confidence, 0 to triage. Reviewers: Lead, Cursor, Claude, Semgrep, Gitleaks. Over-broad
|
…back (WEB-4737 review) Review found the matcher over-matched: (1) the normalized-substring fallback matched sibling files like unbound.py.backup, and (2) comparing every token matched foreign hooks that merely passed our path as an argument (e.g. /opt/other/hook.py --config "/.../unbound.py"). Tighten _command_targets_hook to compare only the invoked program token: strip a leading py/python launcher and its option flags, then exact-match the normalized first remaining token against the target. Unparseable commands now return False instead of falling back to a substring scan. Quoted, bare, and launcher-wrapped forms are still covered. Adds regression tests for the target-as-argument and sibling-path cases. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Addressed in 631cb28 (consensus finding — over-broad The matcher now compares only the invoked program token:
The
Regression tests added in both suites: |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 631cb28. Configure here.
🛡️ Automated Security Review (consensus)0 findings — 0 high-confidence, 0 to triage. Reviewers: Lead, Cursor, Claude, Semgrep, Gitleaks. ✅ Security consensus: no issues found. Previously acknowledged (not re-flagged)
🤖 consensus review · reviewers: Cursor,Claude,Semgrep,Gitleaks · head |
…tup-clear-nuke-cannot-remove-quoted-format-hook-entries # Conflicts: # binary/src/unbound_hook/setup_cmd.py
🛡️ Automated Security Review (consensus)0 findings — 0 high-confidence, 0 to triage. Reviewers: Lead, Cursor, Claude, Semgrep, Gitleaks. ✅ Security consensus: no issues found. (reviewers: Lead, Cursor, Claude, Semgrep, Gitleaks) Previously acknowledged (not re-flagged)
🤖 consensus review · reviewers: Cursor,Claude,Semgrep,Gitleaks · head |

What & why
setup --clear/nukeand the install-side dedup identified Unbound hook entries by exact string equality against an unquoted path (cmd == str(script_path)). Hook commands that wrap the path in quotes or a launcher — the forms MDM/managed installs write on Unix ("/Users/x/.claude/hooks/unbound.py") and the Windows installer writes (py -3 "C:\...\unbound.py") — never matched.Consequences (reproduced 2026-06-09, see WEB-4737):
Fix
Single shared
_command_targets_hook(command, target)helper: tokenizes the command withshlex(dropping surrounding quotes and anypy -3/pythonlauncher prefix), normalizes each token, and matches against the resolved hook target — with a normalized-substring fallback for unparseable commands. Used in both the install dedup and the removal path so they can never disagree.Applied to every selective matcher:
claude-code/hooks/setup.py— install dedup +remove_hooks_from_settingsclaude-code/hooks/mdm/setup.py—remove_user_level_hooks_for_usercodex/hooks/setup.py— install dedup +remove_hooks_from_configcodex/hooks/mdm/setup.py— user-level strip + install dedupbinary/src/unbound_hook/setup_cmd.py— codex merge dedup (target =HOOK_BINARY)copilot/cursor are unaffected — they own their config files and clear them wholesale, so there is no selective matcher to fix.
Per the vendored-by-file-path constraint in the binary bundle, the helper is duplicated byte-identically per tree (not cross-imported) and pinned by a parity test.
Tests
claude-code/hooks/test_setup.pyandbinary/tests/test_hook_command_matcher.py:py -3&pythonlauncher / paths with spaces; foreign command and empty string do not matchremove_hooks_from_settings: removes quoted + bare + launcher forms, preserves foreign hooks, prunes emptied eventsLocal run:
claude-code/hooks30 passed (+3 subtests);binary/testsmatcher 2 passed; migration suite 17 passed.Acceptance (WEB-4737)
py -3entries →--clearremoves them all (nukeshares this path)Notes / out of scope
~/.unbound/identity.jsonnon-removal is a documented parity note inunbound-hook clear, and the--clearmtime-churn claim does not originate inremove_hooks_from_settings(it only writes when modified). Suggest tracking separately.Closes WEB-4737
🤖 Generated with Claude Code
Note
Low Risk
Localized hook-config matching logic with broad unit tests and no auth or data-path changes; wrong matches would only affect which JSON hook entries are removed or deduplicated.
Overview
Hook install dedup and clear/nuke paths used exact string equality against an unquoted script path, so MDM-style quoted commands (
"/path/unbound.py") and Windows launcher forms (py -3 "...") were never recognized—leaving stale hook JSON after clear or appending duplicates on reinstall.This PR adds
_command_targets_hook, duplicated identically in five hook setup modules (Claude/Codex user + MDM + binary Codex merge). It tokenizes withshlex, strips optionalpython/pylaunchers and flags, normalizes paths, and compares only the invoked program to the target hook path. That logic now drives both dedup (configure_*,_merge_codex_hooks_json, MDM user-level strip) and removal (remove_hooks_from_settings,remove_hooks_from_config,remove_user_level_hooks_for_user).Tests cover matcher edge cases, clear preserving foreign hooks, install dedup against quoted entries, binary Codex merge dedup, and a byte-identical parity check across all copies.
Reviewed by Cursor Bugbot for commit c60c743. Bugbot is set up for automated code reviews on this repo. Configure here.
Greptile Summary
This PR fixes hook entry matching in
--clear/nukeand install dedup paths that previously relied on exact string equality, breaking for MDM-managed installs (which quote the path) and Windows launcher forms (py -3 \"...\"). A shared_command_targets_hook(command, target)helper is introduced that usesshlex.split, strips launcher prefixes, and normalizes paths before comparing.claude-code/hooks,claude-code/hooks/mdm,codex/hooks,codex/hooks/mdm,binary/src) due to the vendored-binary constraint, and a new parity test (TestMatcherParityAcrossTrees) enforces this invariant.Falseand parseable commands use exact first-token path equality only, correctly preventing false-positive matches.Confidence Score: 5/5
Safe to merge. The change narrows the existing removal/dedup logic to a well-tested, exact-match helper; it cannot accidentally delete foreign hooks or miss installs in any of the covered command forms.
Every changed call site replaces a demonstrably broken comparator with the new helper. The helper tokenizes, strips launcher prefixes, and does normcase+normpath comparison — covering all documented input forms. The removal of the substring fallback eliminates the false-positive path flagged in the previous review. Unit tests cover bare, double-quoted, single-quoted, launcher-prefixed, and path-with-spaces forms as well as false-positive cases. The byte-identical parity test prevents future drift across the five duplicated copies.
No files require special attention. All five modified setup files apply the helper consistently, and both test files exercise the critical matcher and dedup paths.
Important Files Changed
Flowchart
%%{init: {'theme': 'neutral'}}%% flowchart TD A["_command_targets_hook(command, target)"] --> B{command empty?} B -->|Yes| C[return False] B -->|No| D["shlex.split with posix flag"] D -->|ValueError| E[return False] D -->|OK| F["strip quotes from each token, filter empty"] F --> G{any tokens?} G -->|No| H[return False] G -->|Yes| I{"tokens[0] is py/python launcher?"} I -->|Yes| J["drop launcher, drop leading flag tokens"] J --> K{any tokens left?} K -->|No| L[return False] K -->|Yes| M["normcase + normpath compare tokens[0] vs target"] I -->|No| M M --> N[return True or False]%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%% flowchart TD A["_command_targets_hook(command, target)"] --> B{command empty?} B -->|Yes| C[return False] B -->|No| D["shlex.split with posix flag"] D -->|ValueError| E[return False] D -->|OK| F["strip quotes from each token, filter empty"] F --> G{any tokens?} G -->|No| H[return False] G -->|Yes| I{"tokens[0] is py/python launcher?"} I -->|Yes| J["drop launcher, drop leading flag tokens"] J --> K{any tokens left?} K -->|No| L[return False] K -->|Yes| M["normcase + normpath compare tokens[0] vs target"] I -->|No| M M --> N[return True or False]Reviews (3): Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." | Re-trigger Greptile