Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 0 additions & 131 deletions .claude/hooks/pr-linkage-mcp-gate.sh

This file was deleted.

95 changes: 0 additions & 95 deletions .claude/hooks/pr-linkage-mcp-gate.test.sh

This file was deleted.

27 changes: 18 additions & 9 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,12 @@ jobs:
# inputs (.github/workflows/** and the named manifests) that a diff confined
# to the docs-only allowlist (scripts/docs-only-paths.txt) cannot touch, so
# on such a diff they report an honest evaluated-and-not-applicable success.
# ShellCheck and exec-bit are NOT
# gated: both scan the whole repo extension- and directory-agnostically
# (ShellCheck lints every tracked *.sh/*.bash, exec-bit flags every tracked
# shebang file recorded 100644), so a shell/shebang file added under an
# otherwise docs-only prefix like docs/topics/ is real input they must still
# catch — gating them would open a fail-closed hole. The job NEVER skips — only
# ShellCheck, exec-bit, and hook-wiring-liveness are NOT
# gated: ShellCheck lints every tracked *.sh/*.bash, exec-bit flags every
# tracked shebang file recorded 100644, and hook-wiring-liveness greps
# .claude/hooks/*.sh against settings.json — a shell/shebang/hook file added
# under an otherwise docs-only prefix like docs/topics/ is real input they
# must still catch — gating them would open a fail-closed hole. The job NEVER skips — only
# the path-scoped steps are gated, via the same never-skip, self-test-first,
# fail-closed detector plugin-gate/miro-plugin use. The detector self-test runs
# unconditionally so a broken detector cannot mask a regression, and detection
Expand Down Expand Up @@ -225,9 +225,16 @@ jobs:
:(exclude)plugins/code-tidying/skills/audit-comment-residue/scripts/**
:(exclude)plugins/code-tidying/skills/audit-comment-residue/evals/**

- name: Test the hook-wiring-liveness gate
run: bash scripts/check-hook-wiring-liveness.test.sh
- name: Check repo-local hooks are wired in settings.json
id: hook_wiring
continue-on-error: true
run: scripts/check-hook-wiring-liveness.sh

- name: Report docs-irrelevant checks not applicable to a docs-only diff
if: steps.scope.outputs.docs_only == 'true'
run: echo "Diff is within the docs-only allowlist (scripts/docs-only-paths.txt); the path-scoped linters (actionlint, check-jsonschema x4, the manifest duplicate-key detector) cannot be affected — reporting success for them. ShellCheck and exec-bit scan the whole repo and stay unconditional."
run: echo "Diff is within the docs-only allowlist (scripts/docs-only-paths.txt); the path-scoped linters (actionlint, check-jsonschema x4, the manifest duplicate-key detector) cannot be affected — reporting success for them. ShellCheck, exec-bit, and hook-wiring-liveness scan the whole repo and stay unconditional."

- name: Test hygiene result aggregation
run: scripts/aggregate-hygiene-results.sh --self-test
Expand All @@ -243,8 +250,9 @@ jobs:
# masked. The aggregator stays fail-closed on any real non-`success`
# (including `skipped`); it is never told to pass on `skipped`. The
# condition (docs_only == 'true') is the exact inverse of each gate, so
# the two can never disagree. ShellCheck and exec-bit are unconditional
# (whole-repo scanners), so they feed their raw outcome.
# the two can never disagree. ShellCheck, exec-bit, and
# hook-wiring-liveness are unconditional (whole-repo scanners), so
# they feed their raw outcome.
CHECK_RESULTS: |
markdown=${{ steps.markdown.outcome }}
typos=${{ steps.typos.outcome }}
Expand All @@ -261,6 +269,7 @@ jobs:
machine-specific-paths=${{ steps.machine_paths.outcome }}
eol-renormalize=${{ steps.eol.outcome }}
comment-hygiene=${{ steps.comment_hygiene.outcome }}
hook-wiring-liveness=${{ steps.hook_wiring.outcome }}
run: scripts/aggregate-hygiene-results.sh

zizmor:
Expand Down
110 changes: 110 additions & 0 deletions scripts/check-hook-wiring-liveness.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
#!/usr/bin/env bash
# Gate: every repo-local hook script under .claude/hooks/*.sh (except *.test.sh)
# must be referenced by .claude/settings.json — a hook command (or args string)
# or an env-block value. HOOK_TELEMETRY_SINK is the existing env-wired case.
#
# scripts/check-hook-wiring-liveness.sh fail on any unwired hook script
#
# Why: #2959 / #2960. A hook can sit checked-in with a header claiming
# enforcement while settings.json no longer wires it, and nothing notices.
# #2188 stripped hooks as a bare-baseline reset ("an instruction returns only
# with ledger evidence"); #2655 restored only SessionStart. The repo-local
# pr-linkage-mcp-gate.sh stayed behind, unwired, and its header kept claiming
# it loaded in every session. Policy enforcement already survives via the
# source-control plugin hook plus required CI `pr-issue-linkage`. This gate
# is the wiring-liveness half — it does not restore the stripped hook.
#
# Scope: .claude/hooks/*.sh minus *.test.sh. A script whose repo-relative
# path, or whose basename as a bounded path segment, does not appear in
# settings.json hook commands, hook args, or env values fails the gate.
# Scripts outside .claude/hooks/ are out of scope.
#
# Exit 0 = every hook script is referenced; 1 = one or more unwired;
# 2 = environment / unreadable input (fail closed — never a silent skip).
set -euo pipefail

cd "$(dirname "${BASH_SOURCE[0]}")/.."

if ! command -v jq >/dev/null 2>&1; then
echo "check-hook-wiring-liveness: jq is required but not installed" >&2
exit 2
fi

SETTINGS=".claude/settings.json"
HOOKS_DIR=".claude/hooks"

if [[ ! -f "$SETTINGS" ]]; then
echo "check-hook-wiring-liveness: $SETTINGS not found" >&2
exit 2
fi

if ! jq -e . "$SETTINGS" >/dev/null 2>&1; then
echo "check-hook-wiring-liveness: $SETTINGS is not valid JSON" >&2
exit 2
fi

# Env values plus every hook command/args string. Recursive descent on
# .hooks so SessionStart / PreToolUse / exec-form args are all covered.
# CRLF-tolerant: Git Bash can emit \r inside jq string values.
refs="$(jq -r '
[
((.env // {}) | to_entries[] | .value | strings),
((.hooks // {}) | .. | objects | .command | strings),
((.hooks // {}) | .. | objects | .args[]? | strings)
][]
' "$SETTINGS" | tr -d '\r')"

# True when $3 names $1 (repo-relative path) or $2 (basename) as a path
# segment, not as a substring of a longer filename. `gate.sh` must not
# match a ref that only names `not-gate.sh`.
hook_ref_matches() {
local hook="$1" base="$2" ref="$3" ere pat
case "$ref" in
*"$hook"*) return 0 ;;
*) ;;
esac
ere="$(printf '%s' "$base" | sed 's/[][(){}.^$*+?|\\]/\\&/g')"
pat="(^|[/\\\\ \"'])${ere}([/\\\\ \"']|$)"
[[ "$ref" =~ $pat ]]
}

errors=0
shopt -s nullglob
for hook in "$HOOKS_DIR"/*.sh; do
base="$(basename "$hook")"
case "$base" in
*.test.sh) continue ;;
*) ;;
esac
wired=0
while IFS= read -r ref; do
[[ -n "$ref" ]] || continue
# shellcheck disable=SC2310
hook_ref_matches "$hook" "$base" "$ref" || continue
wired=1
break
done <<<"$refs"
if ((wired)); then
continue
fi
printf 'UNWIRED HOOK: %s is not referenced by %s hook commands or env\n' \
"$hook" "$SETTINGS" >&2
errors=$((errors + 1))
done

if ((errors > 0)); then
cat >&2 <<'REMEDY'

A repo-local hook script under .claude/hooks/ must be wired by
.claude/settings.json (a hook command/args string, or an env value such as
HOOK_TELEMETRY_SINK). An unwired script is dead weight that can still claim
enforcement in its header (#2959: pr-linkage-mcp-gate.sh, stripped in #2188
and never restored). Delete it, or wire it — do not re-add a hook without
ledger evidence (#2188). Policy enforcement for PR linkage already survives
via the source-control plugin hook plus required CI pr-issue-linkage.

REMEDY
exit 1
fi

echo "Every .claude/hooks/*.sh (except *.test.sh) is referenced by .claude/settings.json."
Loading