Skip to content

WEB-4814: merge hooks block instead of overwriting (preserve other tools)#140

Draft
vigneshsubbiah16 wants to merge 2 commits into
stagingfrom
vis1/web-4814-merge-hooks-not-overwrite
Draft

WEB-4814: merge hooks block instead of overwriting (preserve other tools)#140
vigneshsubbiah16 wants to merge 2 commits into
stagingfrom
vis1/web-4814-merge-hooks-not-overwrite

Conversation

@vigneshsubbiah16

Copy link
Copy Markdown
Collaborator

WEB-4814 (Urgent)

The MDM hook installer overwrote Claude Code's managed-settings hooks key wholesale, clobbering any pre-existing hooks belonging to other tools or prior configuration.

Root cause

Two MDM writers did a blind assignment of the whole hooks block:

Path Location Before
Binary (primary) binary/src/unbound_hook/setup_cmd.py_write_claude_managed_settings() settings["hooks"] = _claude_hooks_config()
Python MDM (secondary) claude-code/hooks/mdm/setup.pysetup_managed_hooks() settings["hooks"] = hooks_config

The read-before-write already preserved other top-level keys (model, etc.) — only the hooks key was destroyed.

Fix

Replace both assignments with a per-event merge that mirrors the merge pattern already proven in the user-level path (claude-code/hooks/setup.pyconfigure_claude_settings()):

  • Preserves hooks belonging to other tools / hand-authored entries.
  • Idempotent: before appending Unbound's current entry for an event, strips any prior Unbound-owned entry. Match is by the hook binary / script path substring (not exact command equality), so a re-run after a binary path/version change replaces the stale entry instead of stacking a duplicate.
  • Fail-open (runs as root on customer machines): a hooks value that isn't a dict is coerced to empty rather than crashing the installer; on any JSON parse error the existing read-before-write already leaves the file intact + logs.

Each writer passes its own owner substring: the binary path (HOOK_BINARY) for the binary writer, the managed script_path for the python writer (the latter covers both the Unix "path" and Windows py -3 "path" command forms).

Scope notes

  • Touched only _write_claude_managed_settings + the python MDM setup_managed_hooks (plus their tests), to stay non-conflicting with concurrent work on _run_discovery in the same file. No reformatting of unrelated code.
  • Codex (_write_codex_managed_settings) has the same wholesale-overwrite shape but is out of scope for this ticket; the hooks.json it writes is Unbound-owned for enterprise installs. Flagging for a follow-up if Codex managed settings can ever be co-authored.
  • Cursor enterprise hooks.json left as-is: scoping found that file is Unbound-owned for enterprise installs, and it uses a different schema. Not changed.

Tests (outermost layer)

Binary path — binary/tests/test_setup_migration.py (runs setup_cmd.run([...]) E2E against a sandboxed managed dir):

  • foreign PreToolUse hook survives + unrelated top-level key preserved + Unbound entry present exactly once
  • re-run over (foreign + prior Unbound) does not duplicate
  • a drifted Unbound command (extra trailing args) is replaced, not stacked (path-substring match)
  • a non-dict hooks block does not crash; Unbound hooks still written

Python MDM path — claude-code/hooks/test_setup.py (runs setup_managed_hooks() with network download stubbed):

  • foreign hook survives, Unbound once, idempotent on re-run
  • malformed hooks block does not crash
binary/tests/test_setup_migration.py    19 passed
claude-code/hooks/test_setup.py         20 passed

Pre-existing failures on clean origin/staging (unrelated to this change, verified by stashing): test_hook_cli.py::test_frozen_session_start_makes_no_downloads (4) and test_identity.py::test_keys_limited_to_identity_fields (1).

Refs WEB-4814.

🤖 Generated with Claude Code

vigneshsubbiah16 and others added 2 commits June 13, 2026 10:42
…ols)

The two MDM writers for Claude Code's managed-settings.json did a blind
wholesale overwrite of the hooks key, clobbering any pre-existing hooks
that belonged to other tools or prior configuration:

  - binary/src/unbound_hook/setup_cmd.py:_write_claude_managed_settings
  - claude-code/hooks/mdm/setup.py:setup_managed_hooks

Replace both assignments with a per-event MERGE (mirrors the existing
user-level merge in claude-code/hooks/setup.py:configure_claude_settings):
preserve other tools' / hand-authored entries, and stay idempotent by
stripping any stale Unbound-owned entry (matched on the hook binary /
script path substring) before appending the current one. Coerces a
non-dict existing hooks block to empty so a malformed file can never
crash the root installer (fail-open).

Tests at the outermost layer (setup_cmd.run / setup_managed_hooks):
foreign hook survives, Unbound entry present exactly once, re-run does
not duplicate, drifted-command stale entry is replaced, malformed hooks
block does not crash.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Convergent reviewer hardenings on the merge-don't-overwrite path (both
elite + security reviewers approved these as cheap fixes that protect the
ticket's core "don't destroy other tools' hooks" guarantee).

Fix 1 (both reviewers): _entry_is_unbound matched `owner_substr in command`,
which would mis-classify — and DELETE — a foreign hook whose command merely
references our install path mid-string (e.g. a wrapper exec'ing our binary as
an argument). New _command_is_unbound anchors on path-prefix in the exact
forms our writers emit ("<path>" ..., bare <path> ..., and Windows
`py -3 "<path>"`), still tolerating trailing-arg/version drift so idempotency
holds. Applied identically to both the binary and python-MDM mirrors.

Fix 2 (security LOW-1): the python MDM wrote managed-settings.json via a
non-atomic write_text; a crash mid-write leaves a truncated file that Claude
Code silently ignores, dropping the security control. Mirror the binary's
tmp+os.replace atomic write. Existing chmod step preserves file mode.

Tests: added the Fix-1 guarantee to both suites (a foreign hook containing
our path as a substring survives; our own drifted entry is replaced once) —
both fail on pre-fix code, pass after — plus an atomic-write test. Existing
suites stay green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant