|
| 1 | +#!/usr/bin/env node |
| 2 | +// Claude Code PreToolUse hook — release-workflow-guard. |
| 3 | +// |
| 4 | +// BLOCKS every Bash command that would dispatch a GitHub Actions |
| 5 | +// workflow. The user runs workflow_dispatch jobs manually after |
| 6 | +// reviewing the release commit and waiting for CI to pass — |
| 7 | +// auto-triggering is irrevocable in the short term: |
| 8 | +// |
| 9 | +// - Publish workflows push npm versions (unpublishable after 24h). |
| 10 | +// - Build/Release workflows publish GitHub releases pinned by SHA. |
| 11 | +// - Container workflows push immutable image tags. |
| 12 | +// |
| 13 | +// Even nominally-CI workflow_dispatches often carry prod side |
| 14 | +// effects (the socket-btm binary builders gate prod releases on a |
| 15 | +// `dry_run` input, but the dispatch itself is the trigger). The |
| 16 | +// safe default is "block all dispatches and ask the user to run |
| 17 | +// them themselves." Cost of an extra block: one re-prompt. Cost |
| 18 | +// of a missed prod publish: irreversible. |
| 19 | +// |
| 20 | +// Exit code 2 with a clear stderr message stops the tool call. The |
| 21 | +// model never gets to fire the command. The user re-runs it from |
| 22 | +// their own terminal (or via the GitHub Actions UI) when ready. |
| 23 | +// |
| 24 | +// Blocked patterns: |
| 25 | +// - `gh workflow run <id>` |
| 26 | +// - `gh workflow dispatch <id>` (alias of `run`) |
| 27 | +// - `gh api ... actions/workflows/<id>/dispatches` POST/PUT |
| 28 | +// |
| 29 | +// This hook is the enforcement layer paired with the CLAUDE.md |
| 30 | +// rule. The rule documents the policy; the hook makes it |
| 31 | +// mechanical so the model can't accidentally dispatch a workflow |
| 32 | +// even when reasoning about urgent release work. |
| 33 | +// |
| 34 | +// Reads a Claude Code PreToolUse JSON payload from stdin: |
| 35 | +// { "tool_name": "Bash", "tool_input": { "command": "..." } } |
| 36 | + |
| 37 | +import { readFileSync } from 'node:fs' |
| 38 | +import process from 'node:process' |
| 39 | + |
| 40 | +type ToolInput = { |
| 41 | + tool_name?: string |
| 42 | + tool_input?: { |
| 43 | + command?: string |
| 44 | + } |
| 45 | +} |
| 46 | + |
| 47 | +// `gh workflow run <id-or-file>` / `gh workflow dispatch <id-or-file>`. |
| 48 | +// The captured workflow argument is reported back so the user can |
| 49 | +// see what was blocked. |
| 50 | +const GH_WORKFLOW_DISPATCH_RE = |
| 51 | + /\bgh\s+workflow\s+(?:run|dispatch)\b(?:\s+(?:--repo|--ref|-f|--field)\s+\S+)*\s+(['"]?)([^\s'"]+)\1/ |
| 52 | + |
| 53 | +// `gh api .../actions/workflows/<id>/dispatches` (POST/PUT). |
| 54 | +// The path component implies dispatch — no need to also match -X. |
| 55 | +const GH_API_WORKFLOW_DISPATCH_RE = |
| 56 | + /\bgh\s+api\b[^|]*?\/actions\/workflows\/([^/\s]+)\/dispatches\b/ |
| 57 | + |
| 58 | +function detectDispatch(command: string): { |
| 59 | + blocked: boolean |
| 60 | + workflow?: string |
| 61 | + shape?: string |
| 62 | +} { |
| 63 | + const normalized = command.replace(/\s+/g, ' ') |
| 64 | + |
| 65 | + const cliMatch = GH_WORKFLOW_DISPATCH_RE.exec(normalized) |
| 66 | + if (cliMatch) { |
| 67 | + return { |
| 68 | + blocked: true, |
| 69 | + workflow: cliMatch[2], |
| 70 | + shape: 'gh workflow run/dispatch', |
| 71 | + } |
| 72 | + } |
| 73 | + |
| 74 | + const apiMatch = GH_API_WORKFLOW_DISPATCH_RE.exec(normalized) |
| 75 | + if (apiMatch) { |
| 76 | + return { |
| 77 | + blocked: true, |
| 78 | + workflow: apiMatch[1], |
| 79 | + shape: 'gh api .../dispatches', |
| 80 | + } |
| 81 | + } |
| 82 | + |
| 83 | + return { blocked: false } |
| 84 | +} |
| 85 | + |
| 86 | +function main(): void { |
| 87 | + let raw = '' |
| 88 | + try { |
| 89 | + raw = readFileSync(0, 'utf8') |
| 90 | + } catch { |
| 91 | + return |
| 92 | + } |
| 93 | + |
| 94 | + let input: ToolInput |
| 95 | + try { |
| 96 | + input = JSON.parse(raw) |
| 97 | + } catch { |
| 98 | + return |
| 99 | + } |
| 100 | + |
| 101 | + if (input.tool_name !== 'Bash') { |
| 102 | + return |
| 103 | + } |
| 104 | + const command = input.tool_input?.command |
| 105 | + if (!command || typeof command !== 'string') { |
| 106 | + return |
| 107 | + } |
| 108 | + |
| 109 | + const { blocked, workflow, shape } = detectDispatch(command) |
| 110 | + if (!blocked) { |
| 111 | + return |
| 112 | + } |
| 113 | + |
| 114 | + const lines = [ |
| 115 | + '[release-workflow-guard] BLOCKED: this command would dispatch a', |
| 116 | + ` GitHub Actions workflow (${shape}, target: ${workflow ?? '<unknown>'}).`, |
| 117 | + '', |
| 118 | + ' Workflow dispatches often have irreversible prod side effects:', |
| 119 | + ' - Publish workflows push npm versions (unpublishable after 24h).', |
| 120 | + ' - Build/Release workflows create GitHub releases pinned by SHA.', |
| 121 | + ' - Container workflows push immutable image tags.', |
| 122 | + " - Even build workflows with a 'dry_run' input still treat the", |
| 123 | + ' dispatch itself as the prod trigger.', |
| 124 | + '', |
| 125 | + ' The user runs workflow_dispatch jobs manually — never Claude.', |
| 126 | + ' Tell the user to run the command in their own terminal (or', |
| 127 | + ' via the GitHub Actions UI), then resume.', |
| 128 | + '', |
| 129 | + ' This hook has no opt-out. If you genuinely need to run a', |
| 130 | + ' benign dispatch (e.g. a debug-only utility workflow), ask', |
| 131 | + " the user to invoke it themselves; don't seek a bypass here.", |
| 132 | + ] |
| 133 | + process.stderr.write(lines.join('\n') + '\n') |
| 134 | + process.exitCode = 2 |
| 135 | +} |
| 136 | + |
| 137 | +main() |
0 commit comments