diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 31a58f3..1bc2b9a 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -133,6 +133,20 @@ jobs: bash "$t" done + - name: retrofit-hooks fixture tests + run: | + set -e + for t in test/retrofit-hooks/test-*.sh; do + bash "$t" + done + + - name: schema-hygiene fixture tests + run: | + set -e + for t in test/schema-hygiene/test-*.sh; do + bash "$t" + done + - name: Install jq for availability-check tests run: sudo apt-get install -y jq diff --git a/CHANGELOG.md b/CHANGELOG.md index 927822c..a57d421 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project. Format: [Keep a Changelog](https://keepacha ## Unreleased +- **fix(retrofit): union hook groups by command within a matcher so a standalone→bundled membership change stops double-firing (dogfood F1 + F3).** The 2026-05-30 dogfood (upgrading an adjacent Next.js project from `cc-configure 2.6.0` to post-2.6.0 HEAD) hit a HIGH-severity defect PR #61 had explicitly scoped out: `deep_merge_settings` merged hook groups per event with whole-dict `==` dedup (`_merge_unique_list`), so when the configurator moves a hook between releases from a **standalone** matcher group to a **bundled** one — exactly what the post-2.6.0 `safety` change does, splitting a lone `block-dangerous-bash` Bash group into `[block-dangerous-bash, check-package-availability]` — the two group dicts aren't equal, the new bundled group is **appended**, and the user's `.claude/settings.json` ends with two `Bash` groups both containing `block-dangerous-bash`, which then **fires twice on every Bash call**. PR #61's CHANGELOG called this "rare … out of scope"; the dogfood revises that — adding a hook to an existing matcher group is the *normal* way a module grows, so ~100% of safety-module users upgrading 2.6.0→latest inherit a double-firing dangerous-bash guard. **Fix:** new `_merge_hook_groups(existing, new)` replaces the whole-group dedup on the hooks path — it keys by `matcher` and unions inner `hooks[]` by `command`, appending a configurator command only when it's absent from *every* existing group sharing that matcher (so the standalone+bundled pair collapses to one group, each command wired once). User customizations still survive untouched (a different command, a different matcher, or a tweaked-timeout copy under the same matcher is preserved — PR #61's `test-user-customizations-preserved` still passes), and the old N+1 self-heal still collapses exact whole-group duplicates. The `[ MERGED ]` summary gains an "N hook command(s)" counter. **F3 belt-and-suspenders:** `check_settings_validates` gains a third check (new `_find_duplicate_hook_commands`) that flags any command wired more than once under the same `(event, matcher)` — keyed by matcher so the legitimate "same command under different matchers" case is *not* flagged — emitting a `[ SETTINGS WARNINGS ]` line. The merge prevents the corruption on every shipped upgrade path; F3 catches a residual already-corrupted `settings.json` (e.g. a user who tracked unreleased HEAD before this fix; that bundled-safety-patch was never tagged, so no released upgrade produces it). New TDD fixtures: `test/retrofit-hooks/test-standalone-bundled-merge.sh` (the exact dogfood shape; fails against the old `_merge_unique_list`) and `test/schema-hygiene/test-duplicate-hook-commands.sh` (same-matcher dup flagged, cross-matcher allowed, within-group caught, clean + no-hooks-key pass). **CI gap also fixed:** `test/retrofit-hooks/` and `test/schema-hygiene/` (created by PRs #60/#61) were never wired into `.github/workflows/check.yml` — they only ran locally; both directories are now added as workflow steps, so all seven prior fixtures plus the two new ones run in CI for the first time. Static `configure.py --check` clean; adversarially reviewed (non-list `hooks[]` append-path crash hardened with a coercion guard for parity with the read-side helpers). - **fix(safety): close `block-dangerous-bash` trailing-slash + `$HOME` bypass; refresh CC compat survey to 2.1.154.** The PreToolUse guard `templates/safety/hooks/block-dangerous-bash.sh` had the same blind spot Claude Code itself fixed in 2.1.154 ("`rm -rf $HOME` not blocked as a dangerous path when `HOME` has a trailing slash"): its `~($|\s)` and `/($|\s)` anchors stopped matching the instant a trailing slash followed, and the `$HOME` variable form was never matched at all — so `rm -rf ~/`, `rm -rf //`, `rm -rf $HOME`, `rm -rf $HOME/`, and `rm -rf "$HOME"/` all sailed through while bare `rm -rf ~` and `rm -rf /` were blocked. Widened three patterns: `/+` (also catches `//`), `~/?` (also catches `~/`), and a new `"?\$HOME"?/?` pattern covering the bare, quoted, and trailing-slash variable forms — all still anchored on end-or-space so subdirectory deletes (`rm -rf ~/myproject/node_modules`, `rm -rf $HOME/tmp`, `rm -rf ./build`) stay allowed. New `test/safety/` group with `test-block-dangerous-bash.sh` (7 should-block + 4 should-allow cases, TDD red→green — the guard previously had no behavioral test) wired into `.github/workflows/check.yml` as a `Safety hook fixture tests` step. Also refreshes the `CLAUDE_CODE_COMPAT` rationale comment in `config_schema.py` for the 2.1.151–154 survey (2026-05-28): flags `CLAUDE_CODE_OPUS_4_6_FAST_MODE_OVERRIDE` as deprecated/removed-2026-06-01 (CC 2.1.154); reclassifies hook-payload fields `terminalSequence` (CC 2.1.141) and `background_tasks`/`session_crons` (CC 2.1.145) as doc-governed/not-SchemaStore-gated and ready to ship; records that 2.1.151–154 added no new in-territory `settings.json` keys and that SchemaStore sync PR #5723 remains open, so `tested_up_to` stays pinned at 2.1.150. No `tested_up_to` bump (no-lone-bumps rule); the guard fix is independent of any schema gate. - **feat(safety): package-availability gate + OS/tool-version status-line chip.** New PreToolUse Bash hook `safety/hooks/check-package-availability.sh` hard-denies `apt|apt-get|brew|dnf|yum|pacman|apk install` commands when the target packages aren't in any configured repo. Structured denial message lists missing packages, related siblings found via per-PM regex search (up to 8), the detected installed major version of the same family (e.g., `PostgreSQL 17.4` when blocking `postgresql-18`), three concrete next-step options, and a stale-cache warning when `/var/lib/apt/lists/` is older than 7 days. Shared bash libs land at `templates/safety/hooks/_lib/` (`availability_check.sh`, `detect_tool_versions.sh`) — the first reusable across hooks, the second consumed by the status line. UI module's `templates/ui/statusline.sh` now appends an OS+tool-version chip (`deb13 · pg17 · node20 · py3.13 · docker27`) by sourcing `detect_tool_versions.sh` per render; new `ui.no_version_chip` flag emits `CC_STATUSLINE_NO_VERSION_CHIP=1` for opt-out. Hook is fail-open by design: composite shell expressions (`|`, `&&`, `;`, `$()`, backticks), globs, brace expansion, shell variables, file installs (`./local.deb`, `*.rpm`, `*.apk`), missing `jq`, slow probes (>3s `timeout`), and any internal error (ERR trap) all bail with stderr note and exit 0. Per-probe time bound at 3s. Always-declared `pkgs`/`missing`/`pm` state lets `_log_decision` (JSONL writer to `.claude/logs/availability-check.log`) run safely from ERR trap before parsing. v1 covers OS package managers only — language PMs (pip/npm/cargo/gem/go) and download-URL freshness are tracked for follow-up PRs; URL freshness in particular has a high false-positive rate at the network level and is deferred until a semantic check exists. New `test/availability-check/` directory with 19 fixture tests (TDD-developed per the brainstormed spec/plan) covers the per-PM probes, the chip with/without stub fixtures, the JSON-parse decision matrix, every parser bailout, hook regressions (sudo + env stripping, value-taking flags like `-t bookworm-backports`, version pins, multi-pkg with mixed available+missing), error handling (jq missing, malformed JSON, stale cache), and JSONL logging conditionality on `.claude/logs/` presence. CI gains an `Install jq` step + `Availability-check hook tests` loop in `.github/workflows/check.yml`. Persona snapshots regenerated for all four personas that ship `safety/` (custom unchanged). Spec + plan in `docs/superpowers/specs/2026-05-27-package-availability-gate-design.md` and `docs/superpowers/plans/2026-05-27-package-availability-gate.md` (gitignored, local-only per PR #50). - **feat(license): relicense from MIT to AGPL-3.0 with discipline-skills MIT carve-out.** The configurator's own code (configure.py, config_schema.py, all original templates, hooks, docs, tests, examples) switches to GNU Affero General Public License v3.0 to close the SaaS loophole that lets hyperscalers fork permissive copyleft projects into closed managed services without contributing back (the "MongoDB on AWS" pattern that drove MongoDB→SSPL, Elastic→ELv2/SSPL, and HelixDB's 2025-05-13 GPL→AGPL switch — the proximate model for this change). Network-served modifications now trigger AGPL-3.0 source-distribution requirements. **Carve-out:** `templates/discipline-skills/` keeps its MIT License (© 2025 Jesse Vincent, forked from obra/superpowers v5.1.0) — the MIT terms travel with those files when users install the module into their projects; the surrounding configurator code is AGPL-3.0; MIT is compatible with AGPL aggregation. New root `NOTICE` documents the bundled-license breakdown. README `## License` section rewritten to explain the change + carve-out + irrevocability of past MIT releases. AGPL-3.0 added to the user-facing license picker in `config_schema.py` (between Apache-2.0 and GPL-3.0) so users can select it for their own projects. `test/discipline-skills/test-license-attribution.sh` continues to pass (validates the MIT carve-out, not the root license). Past tagged releases remain MIT under their original terms; AGPL-3.0 applies to all subsequent commits. diff --git a/configure.py b/configure.py index 01d8b5e..401dd7f 100755 --- a/configure.py +++ b/configure.py @@ -840,9 +840,42 @@ def _find_doc_label_paths(obj, prefix=""): yield from _find_doc_label_paths(v, f"{prefix}[{i}]") +def _find_duplicate_hook_commands(settings: dict) -> list: + """Return (event, matcher, command, count) for hook commands wired more + than once under the same (event, matcher) — each wiring fires per matching + call, so the command fires count times (the F1 dogfood symptom: a command + left in two groups that share a matcher). The SAME command under DIFFERENT + matchers is legitimate — it targets different tools — and is not flagged. + deep_merge_settings now collapses same-matcher dups on retrofit; this is + the render-time / template regression net (and catches stale pre-fix + settings.json files). Occurrences are counted per wiring, so a command + repeated inside one group's hooks[] is caught too.""" + dups = [] + hooks = settings.get("hooks") + if not isinstance(hooks, dict): + return dups + for event, groups in hooks.items(): + if not isinstance(groups, list): + continue + counts = {} # (matcher, command) -> total wirings + for g in groups: + if not isinstance(g, dict): + continue + matcher = g.get("matcher") + for h in g.get("hooks", []): + if isinstance(h, dict) and h.get("command"): + key = (matcher, h["command"]) + counts[key] = counts.get(key, 0) + 1 + for (matcher, cmd), n in sorted(counts.items(), + key=lambda kv: (str(kv[0][0]), str(kv[0][1]))): + if n > 1: + dups.append((event, matcher, cmd, n)) + return dups + + def check_settings_validates(settings: dict) -> list: - """Catch the two settings-validator complaint classes surfaced by - dogfooding cc-configure on a downstream project (2026-05-24): + """Catch the settings-validator complaint classes surfaced by + dogfooding cc-configure on a downstream project (2026-05-24, 2026-05-30): 1. Top-level `//`-prefixed keys (doc labels / commented stubs) — the Claude Code settings schema rejects unknown top-level keys, so @@ -854,6 +887,9 @@ def check_settings_validates(settings: dict) -> list: Earlier versions shipped a top-level string `"name-only"` which the current schema rejects. Also: per code.claude.com/docs/en/settings the setting does not apply to plugin skills at all. + 3. Duplicate hook commands — the same hook `command` wired into more + than one group under one event, so it fires multiple times per call + (the F1 dogfood symptom). See _find_duplicate_hook_commands. """ warnings = [] valid_overrides = {"on", "name-only", "user-invocable-only", "off"} @@ -885,6 +921,15 @@ def check_settings_validates(settings: dict) -> list: f"value isn't one of {sorted(valid_overrides)}: " f"{bad[:3]}" + (f" (+{len(bad) - 3} more)" if len(bad) > 3 else "") ) + + for event, matcher, cmd, n in _find_duplicate_hook_commands(settings): + short = cmd.rsplit("/", 1)[-1] + warnings.append( + f"settings.json hooks.{event} wires {short} {n}x under matcher " + f"{matcher!r} — it fires {n}x per matching call. Likely a stale " + "group from a prior scaffold; run cc-configure --retrofit (the " + "merge now collapses these) or remove the duplicate group." + ) return warnings @@ -1136,6 +1181,75 @@ def _merge_unique_list(existing_list, new_list): return out +def _hook_commands(group: dict) -> set: + """The set of `command` strings in a hook group's hooks[] list.""" + return { + h.get("command") + for h in group.get("hooks", []) + if isinstance(h, dict) and h.get("command") + } + + +def _merge_hook_groups(existing_groups, new_groups): + """Merge the configurator's hook groups into a user's existing groups for + one event, keyed by `matcher` and unioning inner hooks by `command`. + Returns (merged_groups, groups_added, commands_added). + + Why per-command and not whole-group equality (the F1 dogfood fix, + 2026-05-30): the configurator may move a hook between releases from a + standalone matcher group to a bundled one (e.g. a lone `block-dangerous-bash` + Bash group -> `[block-dangerous-bash, check-package-availability]`). + Whole-group `==` dedup treats those as distinct and keeps both, so the + shared command ends up in two groups and fires twice on every matching + call. Unioning the new commands into the first existing same-matcher group + collapses them to one. + + Preserves user customizations: existing groups are never deleted or + rewritten, and a new command is appended only when its `command` is absent + from every existing group sharing that matcher. A user's deliberate + same-matcher group (different command) or tweaked copy (same command, + different timeout) therefore survives untouched. Exact whole-group + duplicates left by the pre-fix N+1 retrofit bug are still collapsed + (self-heal).""" + def _clone(x): + return json.loads(json.dumps(x)) + + out = [] + for g in existing_groups: + gc = _clone(g) + if gc not in out: # collapse exact whole-group dups (self-heal) + out.append(gc) + + groups_added = 0 + commands_added = 0 + for ng in new_groups: + matcher = ng.get("matcher") + targets = [g for g in out if g.get("matcher") == matcher] + if not targets: + ngc = _clone(ng) + if ngc not in out: + out.append(ngc) + groups_added += 1 + continue + present = set() + for g in targets: + present |= _hook_commands(g) + first = targets[0] + # Coerce a malformed (non-list) hooks value to a list before appending, + # for parity with _hook_commands / _find_duplicate_hook_commands which + # tolerate it. setdefault alone doesn't help when the key is present but + # holds a non-list (already schema-invalid, but never crash on it). + dest_hooks = first.get("hooks") + if not isinstance(dest_hooks, list): + dest_hooks = first["hooks"] = [] + for h in ng.get("hooks", []): + if isinstance(h, dict) and h.get("command") and h.get("command") not in present: + dest_hooks.append(_clone(h)) + present.add(h.get("command")) + commands_added += 1 + return out, groups_added, commands_added + + def deep_merge_settings(existing: dict, new: dict): """Merge a user's existing .claude/settings.json with the configurator's new version. Returns (merged_dict, summary_str). @@ -1146,23 +1260,25 @@ def deep_merge_settings(existing: dict, new: dict): existing entries first, new entries appended without duplicates. - permissions.disableBypassPermissionsMode: ours wins (security default the user opted into by selecting safety). - - hooks: per-event groups merged via _merge_unique_list (existing - first, then ours, structural-equality dedup). Critical for - retrofits: without dedup, every cc-configure --retrofit run - re-appends the configurator's own hook set against the prior - scaffold's identical set, so after N retrofits every hook fires - N+1 times. User-customized hook groups (different matcher, - different command list, different timeout) are structurally - distinct from configurator-shipped ones and survive dedup. - Self-heals existing buildup: a user whose settings.json already - accumulated N duplicates collapses them to 1 on next retrofit. + - hooks: per-event groups merged via _merge_hook_groups — keyed by + `matcher`, unioning inner hooks by `command`. Critical for retrofits: + a naive append re-adds the configurator's own hook set every run, and + whole-group `==` dedup misses the case where a hook moved between a + standalone and a bundled group across releases (F1) — so the shared + command would fire twice. Unioning by command collapses both the + repeat-retrofit and the standalone->bundled cases to one entry. + User-authored groups survive: existing entries are never rewritten and + a command is only appended when absent from every same-matcher group, + so a different command or a tweaked timeout under the same matcher is + preserved. Exact whole-group duplicates from the pre-fix N+1 bug are + still collapsed (self-heal). - env: dict merge with existing keys winning on collision (preserves user's deliberate overrides). - statusLine, model: preserve existing if set; otherwise use new. - Unknown top-level keys: pass through verbatim from existing. """ out = dict(existing) - counts = {"perms_added": 0, "hook_groups_added": 0, "env_added": 0} + counts = {"perms_added": 0, "hook_groups_added": 0, "hook_cmds_added": 0, "env_added": 0} if "$schema" in new: out["$schema"] = new["$schema"] @@ -1184,11 +1300,9 @@ def deep_merge_settings(existing: dict, new: dict): out_hooks = dict(out.get("hooks", {})) for event, new_groups in new["hooks"].items(): existing_groups = out_hooks.get(event, []) - merged = _merge_unique_list(existing_groups, new_groups) - # Also collapse any prior-retrofit duplicates already in - # existing_groups (self-heal). Reapply unique-list against itself. - merged = _merge_unique_list([], merged) - counts["hook_groups_added"] += len(merged) - len(existing_groups) + merged, g_added, c_added = _merge_hook_groups(existing_groups, new_groups) + counts["hook_groups_added"] += g_added + counts["hook_cmds_added"] += c_added out_hooks[event] = merged out["hooks"] = out_hooks @@ -1210,7 +1324,8 @@ def deep_merge_settings(existing: dict, new: dict): out[k] = v msg = (f"preserved existing config; added {counts['perms_added']} permission rule(s), " - f"{counts['hook_groups_added']} hook group(s), {counts['env_added']} env var(s)") + f"{counts['hook_groups_added']} hook group(s), {counts['hook_cmds_added']} hook command(s), " + f"{counts['env_added']} env var(s)") return out, msg diff --git a/test/retrofit-hooks/test-standalone-bundled-merge.sh b/test/retrofit-hooks/test-standalone-bundled-merge.sh new file mode 100755 index 0000000..2945bdf --- /dev/null +++ b/test/retrofit-hooks/test-standalone-bundled-merge.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# F1 (dogfood 2026-05-30): when a prior scaffold shipped a hook as a STANDALONE +# matcher group and the new release ships it BUNDLED with a sibling in one +# group, deep_merge_settings must union them into a single matcher group with +# each command appearing once — not append a second group that double-fires the +# shared command. This is the exact 2.6.0 -> HEAD `safety` upgrade shape +# (block-dangerous-bash standalone -> [block-dangerous-bash, check-package-availability]). +set -euo pipefail + +python3 - <<'EOF' +import sys +sys.path.insert(0, '.') +from configure import deep_merge_settings + +B = "$CLAUDE_PROJECT_DIR/.claude/hooks/block-dangerous-bash.sh" +P = "$CLAUDE_PROJECT_DIR/.claude/hooks/check-package-availability.sh" +S = "$CLAUDE_PROJECT_DIR/.claude/hooks/scan-secrets.sh" + +# existing = prior 2.6.0 scaffold: block-dangerous-bash as a STANDALONE Bash group +existing = {"hooks": {"PreToolUse": [ + {"matcher": "Bash", "hooks": [{"type": "command", "command": B, "timeout": 10}]}, + {"matcher": "Write|Edit", "hooks": [{"type": "command", "command": S, "timeout": 10}]}, +]}} + +# new = current release: block-dangerous-bash BUNDLED with check-package-availability +new = {"hooks": {"PreToolUse": [ + {"matcher": "Bash", "hooks": [ + {"type": "command", "command": B, "timeout": 10}, + {"type": "command", "command": P, "timeout": 10}, + ]}, + {"matcher": "Write|Edit", "hooks": [{"type": "command", "command": S, "timeout": 10}]}, +]}} + +merged, msg = deep_merge_settings(existing, new) +pre = merged["hooks"]["PreToolUse"] + +def count_cmd(groups, needle): + return sum(1 for g in groups for h in g.get("hooks", []) if needle in h.get("command", "")) + +bash_groups = [g for g in pre if g.get("matcher") == "Bash"] +assert len(bash_groups) == 1, f"expected 1 Bash group, got {len(bash_groups)}: {pre}" + +n_block = count_cmd(pre, "block-dangerous-bash") +assert n_block == 1, f"block-dangerous-bash should appear once, got {n_block}: {pre}" + +n_pkg = count_cmd(pre, "check-package-availability") +assert n_pkg == 1, f"check-package-availability should be merged in once, got {n_pkg}: {pre}" + +n_scan = count_cmd(pre, "scan-secrets") +assert n_scan == 1, f"scan-secrets should appear once, got {n_scan}: {pre}" + +print("PASS: standalone+bundled same-matcher groups union into one; no command double-fires") +EOF diff --git a/test/schema-hygiene/test-duplicate-hook-commands.sh b/test/schema-hygiene/test-duplicate-hook-commands.sh new file mode 100755 index 0000000..b35a6ca --- /dev/null +++ b/test/schema-hygiene/test-duplicate-hook-commands.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# F3 (dogfood 2026-05-30): check_settings_validates must flag a hook command +# wired more than once under the SAME (event, matcher) — it fires multiple +# times per matching call. Belt-and-suspenders regression net for the F1 +# symptom; catches render-time/template dups and stale pre-fix settings.json. +# The same command under DIFFERENT matchers is legitimate and must NOT flag. +set -euo pipefail + +python3 - <<'EOF' +import sys +sys.path.insert(0, '.') +from configure import check_settings_validates, _find_duplicate_hook_commands + +# --- The F1 symptom: same command in two groups that share matcher "Bash" --- +dup = {"hooks": {"PreToolUse": [ + {"matcher": "Bash", "hooks": [ + {"type": "command", "command": "x/.claude/hooks/block-dangerous-bash.sh", "timeout": 10}]}, + {"matcher": "Bash", "hooks": [ + {"type": "command", "command": "x/.claude/hooks/block-dangerous-bash.sh", "timeout": 10}, + {"type": "command", "command": "x/.claude/hooks/check-package-availability.sh", "timeout": 10}]}, +]}} +found = _find_duplicate_hook_commands(dup) +assert any(ev == "PreToolUse" and m == "Bash" and "block-dangerous-bash" in cmd and n == 2 + for ev, m, cmd, n in found), f"helper missed same-matcher dup: {found}" +w = check_settings_validates(dup) +assert any("block-dangerous-bash" in msg and "PreToolUse" in msg and "Bash" in msg + for msg in w), f"preflight missed dup: {w}" +# check-package-availability appears once -> not flagged (no false positive) +assert not any("check-package-availability" in cmd for ev, m, cmd, n in found), \ + f"false positive on singleton: {found}" + +# --- Legitimate: same command under DIFFERENT matchers must NOT be flagged --- +cross = {"hooks": {"PreToolUse": [ + {"matcher": "Bash", "hooks": [ + {"type": "command", "command": "x/log.sh", "timeout": 5}]}, + {"matcher": "Write|Edit", "hooks": [ + {"type": "command", "command": "x/log.sh", "timeout": 5}]}, +]}} +assert _find_duplicate_hook_commands(cross) == [], \ + f"false positive on same-command-different-matcher: {_find_duplicate_hook_commands(cross)}" + +# --- Within one group, the same command twice is also caught --- +within = {"hooks": {"PostToolUse": [ + {"matcher": "Write|Edit", "hooks": [ + {"type": "command", "command": "x/fmt.sh", "timeout": 5}, + {"type": "command", "command": "x/fmt.sh", "timeout": 5}]}, +]}} +fw = _find_duplicate_hook_commands(within) +assert any(m == "Write|Edit" and "fmt.sh" in cmd and n == 2 for ev, m, cmd, n in fw), \ + f"missed within-group dup: {fw}" + +# --- Clean: each command once per matcher -> no dup warning --- +clean = {"hooks": {"PreToolUse": [ + {"matcher": "Bash", "hooks": [ + {"type": "command", "command": "x/block-dangerous-bash.sh", "timeout": 10}, + {"type": "command", "command": "x/check-package-availability.sh", "timeout": 10}]}, + {"matcher": "Write|Edit", "hooks": [ + {"type": "command", "command": "x/scan-secrets.sh", "timeout": 10}]}, +]}} +assert _find_duplicate_hook_commands(clean) == [], \ + f"false positive on clean: {_find_duplicate_hook_commands(clean)}" + +# --- No hooks key at all -> safe, empty --- +assert _find_duplicate_hook_commands({"$schema": "x"}) == [] + +print("PASS: same-matcher dup flagged; cross-matcher allowed; within-group caught; clean passes") +EOF