Browse Source

fix(install): wire security + peer-guard hooks into settings.json on script installs

Plugin installs got 5 auto-wired security hooks via hooks.json +
CLAUDE_PLUGIN_ROOT; the documented script-install path wired none -
users installing via install.ps1/install.sh silently ran unprotected.
Both installers now merge the hook set into ~/.claude/settings.json
(paths translated to $HOME/.claude/hooks/), idempotently (match on
script path; re-run is a no-op) and non-destructively (unrelated
settings preserved). CLAUDE_DIR env override makes it testable.
Also wires the written-but-dark peer-writer pair (pre-write-peer-guard
PreToolUse + session-touched-ledger PostToolUse) into hooks.json - 7
hooks total - and updates hooks/README.md.

Built by fleetflow lane gates/g5-hook-wiring (codex); committed by the
orchestrator - codex sandbox cannot write worktree git metadata.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
0xDarkMatter 6 days ago
parent
commit
d1db7de91b
4 changed files with 134 additions and 8 deletions
  1. 9 4
      hooks/README.md
  2. 15 0
      hooks/hooks.json
  3. 64 2
      scripts/install.ps1
  4. 46 2
      scripts/install.sh

+ 9 - 4
hooks/README.md

@@ -16,8 +16,8 @@ Claude Code hooks allow you to run custom scripts at key workflow points.
 | `config-change-guard.sh` | ConfigChange | Worm-persistence tripwire: when a Claude settings file changes mid-session, scan just that file for the vetted IOC set (curl\|sh, base64-decode eval, Invoke-Expression+Download, /dev/tcp, reads of `.claude/settings` / `.aws/credentials`). Silent on clean; advisory `systemMessage` on a finding. `SUPPLY_CHAIN_BLOCK=1` blocks the change (exit 2). Fast single-file sibling of `supply-chain-defense`'s `integrity-audit.sh`. |
 | `worktree-guard.sh` | PreToolUse (Bash) | Enforce `rules/worktree-boundaries.md`: flags `rm` on `.claude/worktrees`, `git worktree remove/prune` against worktrees, `git rm` on worktree gitlinks, and `git add -A`/`.` in a repo that has a `.claude/worktrees` dir. Sessions whose cwd is inside their own worktree are exempt. Advisory by default; `WORKTREE_GUARD_BLOCK=1` hard-denies (exit 2). |
 | `session-start-unicode-scan.sh` | SessionStart | One-shot hidden-Unicode scan of the project's instruction files (CLAUDE.md/AGENTS.md/SKILL.md/.cursorrules) at session boot. Silent on clean; advisory on a finding. Pairs with `prompt-injection-defense`. |
-| `pre-write-peer-guard.sh` | PreToolUse (Edit\|Write) | Mid-session peer-writer guard (`rules/worktree-boundaries.md`): before writing a file, warn if it was freshly modified by something that isn't this session — the signature of a live peer session sharing the checkout. Uses the touched-ledger to tell own edits apart. Advisory by default; `GUARD_BLOCK=1` denies the write. **Not yet wired in `hooks/hooks.json` — opt-in until wired.** |
-| `session-touched-ledger.sh` | PostToolUse (Edit\|Write) | Companion to `pre-write-peer-guard.sh`: records every file this session writes to `~/.claude/.session-touched/<session_id>.list` so the guard can distinguish this session's edits from a peer's. Silent, never blocks. **Not yet wired in `hooks/hooks.json` — opt-in until wired.** |
+| `pre-write-peer-guard.sh` | PreToolUse (Edit\|Write) | Mid-session peer-writer guard (`rules/worktree-boundaries.md`): before writing a file, warn if it was freshly modified by something that isn't this session — the signature of a live peer session sharing the checkout. Uses the touched-ledger to tell own edits apart. Advisory by default; `GUARD_BLOCK=1` denies the write. Auto-wired with its ledger companion. |
+| `session-touched-ledger.sh` | PostToolUse (Edit\|Write) | Companion to `pre-write-peer-guard.sh`: records every file this session writes to `~/.claude/.session-touched/<session_id>.list` so the guard can distinguish this session's edits from a peer's. Silent, never blocks. Auto-wired with the peer guard. |
 | `pre-commit-unicode-scan.sh` | git pre-commit | Refuse commits that ADD hidden Unicode to instruction files. Silent on clean, warn on `high`, **block on `critical`** (tag-block / bidi override). Override once with `PROMPT_INJECTION_ALLOW=1`. |
 
 ## Auto-wired vs opt-in
@@ -28,8 +28,13 @@ paths resolved via `${CLAUDE_PLUGIN_ROOT}`:
 
 | Set | Hooks | Why |
 |-----|-------|-----|
-| **Auto-wired (security advisory)** | `pre-install-scan.sh` (PreToolUse Bash), `worktree-guard.sh` (PreToolUse Bash), `manifest-dep-scan.sh` (PostToolUse Write\|Edit), `session-start-unicode-scan.sh` (SessionStart), `config-change-guard.sh` (ConfigChange) | Silent-on-clean guardrails: zero noise until something is actually wrong, so they're safe to ship on by default. |
-| **Opt-in (opinionated / formatting)** | `pre-commit-lint.sh`, `post-edit-format.sh`, `dangerous-cmd-warn.sh`, `enforce-uv.sh`, `check-mail.sh`, `pre-commit-unicode-scan.sh` (a *git* hook), `pre-write-peer-guard.sh` + `session-touched-ledger.sh` (peer-writer guard pair — wire both together) | Workflow opinions — wire them yourself per the examples below. |
+| **Auto-wired (security advisory)** | `pre-install-scan.sh` (PreToolUse Bash), `worktree-guard.sh` (PreToolUse Bash), `manifest-dep-scan.sh` (PostToolUse Write\|Edit), `session-start-unicode-scan.sh` (SessionStart), `config-change-guard.sh` (ConfigChange), `pre-write-peer-guard.sh` (PreToolUse Write\|Edit), `session-touched-ledger.sh` (PostToolUse Write\|Edit) | Silent-on-clean guardrails: zero noise until something is actually wrong, so they're safe to ship on by default. |
+| **Opt-in (opinionated / formatting)** | `pre-commit-lint.sh`, `post-edit-format.sh`, `dangerous-cmd-warn.sh`, `enforce-uv.sh`, `check-mail.sh`, `pre-commit-unicode-scan.sh` (a *git* hook) | Workflow opinions — wire them yourself per the examples below. |
+
+Plugin installs resolve auto-wired commands through `${CLAUDE_PLUGIN_ROOT}`. Script
+installs copy the hook scripts into `${CLAUDE_DIR:-~/.claude}/hooks/` and merge the
+same wiring into `settings.json`. Existing settings and hook groups are preserved;
+an already-wired script path is not added again.
 
 ### Env toggles (auto-wired set)
 

+ 15 - 0
hooks/hooks.json

@@ -1,6 +1,16 @@
 {
   "hooks": {
     "PreToolUse": [
+      {
+        "matcher": "Write|Edit",
+        "hooks": [
+          {
+            "type": "command",
+            "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/pre-write-peer-guard.sh\"",
+            "timeout": 10
+          }
+        ]
+      },
       {
         "matcher": "Bash",
         "hooks": [
@@ -25,6 +35,11 @@
             "type": "command",
             "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/manifest-dep-scan.sh\"",
             "timeout": 10
+          },
+          {
+            "type": "command",
+            "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/session-touched-ledger.sh\"",
+            "timeout": 10
           }
         ]
       }

+ 64 - 2
scripts/install.ps1

@@ -20,10 +20,14 @@ Write-Host ""
 
 $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
 $projectRoot = Split-Path -Parent $scriptDir
-$claudeDir = "$env:USERPROFILE\.claude"
+if ($env:CLAUDE_DIR) {
+    $claudeDir = $env:CLAUDE_DIR
+} else {
+    $claudeDir = "$env:USERPROFILE\.claude"
+}
 
 # Ensure ~/.claude directories exist
-$dirs = @("commands", "skills", "agents", "rules", "output-styles")
+$dirs = @("commands", "skills", "agents", "rules", "output-styles", "hooks")
 foreach ($dir in $dirs) {
     $path = Join-Path $claudeDir $dir
     if (-not (Test-Path $path)) {
@@ -176,6 +180,64 @@ if (Test-Path $stylesDir) {
 }
 Write-Host ""
 
+# =============================================================================
+# HOOKS - Copy scripts and merge plugin-equivalent wiring into settings.json
+# =============================================================================
+Write-Host "Installing hooks..." -ForegroundColor Cyan
+
+$hooksDir = Join-Path $projectRoot "hooks"
+Get-ChildItem -Path $hooksDir -Filter "*.sh" | ForEach-Object {
+    Copy-Item $_.FullName -Destination "$claudeDir\hooks\" -Force
+}
+
+$settingsPath = Join-Path $claudeDir "settings.json"
+if (Test-Path $settingsPath) {
+    $settings = Get-Content $settingsPath -Raw | ConvertFrom-Json
+} else {
+    $settings = [PSCustomObject]@{}
+}
+if (-not $settings.PSObject.Properties["hooks"]) {
+    $settings | Add-Member -MemberType NoteProperty -Name hooks -Value ([PSCustomObject]@{})
+}
+
+$desired = Get-Content (Join-Path $hooksDir "hooks.json") -Raw | ConvertFrom-Json
+foreach ($eventProperty in $desired.hooks.PSObject.Properties) {
+    $eventName = $eventProperty.Name
+    if (-not $settings.hooks.PSObject.Properties[$eventName]) {
+        $settings.hooks | Add-Member -MemberType NoteProperty -Name $eventName -Value @()
+    }
+    $eventGroups = @($settings.hooks.$eventName)
+    foreach ($group in @($eventProperty.Value)) {
+        $missingHooks = @()
+        foreach ($hook in @($group.hooks)) {
+            $command = $hook.command.Replace('${CLAUDE_PLUGIN_ROOT}/hooks', (Join-Path $claudeDir "hooks"))
+            $hookPath = $command -replace '^bash "', '' -replace '"$', ''
+            $alreadyWired = $false
+            foreach ($existingGroup in $eventGroups) {
+                foreach ($existingHook in @($existingGroup.hooks)) {
+                    if ($existingHook.command -and $existingHook.command.Contains($hookPath)) {
+                        $alreadyWired = $true
+                    }
+                }
+            }
+            if (-not $alreadyWired) {
+                $copy = $hook.PSObject.Copy()
+                $copy.command = $command
+                $missingHooks += $copy
+            }
+        }
+        if ($missingHooks.Count -gt 0) {
+            $newGroup = $group.PSObject.Copy()
+            $newGroup.hooks = $missingHooks
+            $eventGroups += $newGroup
+        }
+    }
+    $settings.hooks.$eventName = $eventGroups
+}
+$settings | ConvertTo-Json -Depth 20 | Set-Content $settingsPath -Encoding UTF8
+Write-Host "  Security and peer-guard hooks wired in settings.json" -ForegroundColor Green
+Write-Host ""
+
 # =============================================================================
 # PIGEON - Global install (scripts + hook config hint)
 # =============================================================================

+ 46 - 2
scripts/install.sh

@@ -23,7 +23,7 @@ echo ""
 
 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
 PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
-CLAUDE_DIR="$HOME/.claude"
+CLAUDE_DIR="${CLAUDE_DIR:-$HOME/.claude}"
 
 # Detect Windows (Git Bash / MINGW / MSYS) — chmod is a no-op on NTFS
 IS_WINDOWS=false
@@ -37,7 +37,7 @@ make_executable() {
 }
 
 # Ensure ~/.claude directories exist
-for dir in commands skills agents rules output-styles; do
+for dir in commands skills agents rules output-styles hooks; do
     mkdir -p "$CLAUDE_DIR/$dir"
 done
 
@@ -217,6 +217,50 @@ if [ -d "$PROJECT_ROOT/output-styles" ]; then
 fi
 echo ""
 
+# =============================================================================
+# HOOKS - Copy scripts and merge plugin-equivalent wiring into settings.json
+# =============================================================================
+echo -e "${BLUE}Installing hooks...${NC}"
+
+for file in "$PROJECT_ROOT/hooks"/*.sh; do
+    [ -f "$file" ] || continue
+    cp "$file" "$CLAUDE_DIR/hooks/"
+    make_executable "$CLAUDE_DIR/hooks/$(basename "$file")"
+done
+
+if command -v jq >/dev/null 2>&1; then
+    settings_path="$CLAUDE_DIR/settings.json"
+    [ -f "$settings_path" ] || printf '{}\n' > "$settings_path"
+    tmp_settings="$(mktemp)"
+    jq --arg hook_dir "$CLAUDE_DIR/hooks" --slurpfile desired "$PROJECT_ROOT/hooks/hooks.json" '
+      def installed_path:
+        .command | sub("^bash \\\""; "") | sub("\\\"$"; "");
+      ($desired[0]
+        | walk(if type == "string" then gsub("\\$\\{CLAUDE_PLUGIN_ROOT\\}/hooks"; $hook_dir) else . end)
+      ) as $wanted
+      | .hooks = (.hooks // {})
+      | reduce ($wanted.hooks | to_entries[]) as $event (.;
+          reduce $event.value[] as $group (.;
+            ([.hooks[$event.key][]?.hooks[]?.command // ""]) as $existing
+            |
+            ($group.hooks | map(
+              . as $hook
+              | ($hook | installed_path) as $path
+              | select(any($existing[]; contains($path)) | not)
+            )) as $missing
+            | if ($missing | length) > 0 then
+                .hooks[$event.key] = ((.hooks[$event.key] // []) + [($group | .hooks = $missing)])
+              else . end
+          )
+        )
+    ' "$settings_path" > "$tmp_settings"
+    mv "$tmp_settings" "$settings_path"
+    echo -e "  ${GREEN}Security and peer-guard hooks wired in settings.json${NC}"
+else
+    echo -e "  ${YELLOW}jq not found, skipping hook wiring (plugin installs unaffected)${NC}"
+fi
+echo ""
+
 # =============================================================================
 # PIGEON - Global install (scripts + hook config hint)
 # =============================================================================