Skip to content

Commit 0d2832b

Browse files
committed
chore(claude): add release-workflow-guard hook + rule
Sibling to private-name-guard / public-surface-reminder / token-guard. PreToolUse hook on Bash that BLOCKS every command that would dispatch a GitHub Actions workflow: - gh workflow run <id> - gh workflow dispatch <id> - gh api .../actions/workflows/<id>/dispatches Workflow dispatches are irrevocable in the short term: Publish workflows push npm versions (unpublishable after 24h), Build/ Release workflows pin GitHub releases by SHA, container workflows push immutable tags. Even build workflows with a `dry_run` input still treat the dispatch itself as the prod trigger. The user runs workflow_dispatch jobs manually after CI passes — Claude never dispatches them. Exit code 2 with stderr message; the model never gets to fire the command. No opt-out; benign dispatches go through the user's own terminal or the GitHub Actions UI. CLAUDE.md grows a corresponding rule. The hook is the enforcement layer; the rule applies even when the hook isn't installed.
1 parent efadd20 commit 0d2832b

5 files changed

Lines changed: 222 additions & 0 deletions

File tree

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# release-workflow-guard
2+
3+
`PreToolUse` hook that **blocks** every Bash command that would
4+
dispatch a GitHub Actions workflow. Exit code `2`; the model never
5+
gets to fire the command.
6+
7+
> Workflow dispatches are irrevocable. Publish workflows push npm
8+
> versions (unpublishable after 24h). Build/Release workflows pin
9+
> GitHub releases by SHA. Container workflows push immutable image
10+
> tags. Even build workflows with a `dry_run` input still treat the
11+
> dispatch itself as the prod trigger — the user runs them
12+
> manually, never Claude.
13+
14+
## What gets blocked
15+
16+
- `gh workflow run <id>`
17+
- `gh workflow dispatch <id>` (alias of `run`)
18+
- `gh api .../actions/workflows/<id>/dispatches` POST/PUT
19+
20+
Any other `Bash` command passes through silently.
21+
22+
## Why no per-workflow allowlist
23+
24+
Because allowlists drift. A "benign" CI dispatch today becomes a
25+
prod-touching dispatch tomorrow when someone wires a publish step
26+
behind it; the allowlist hasn't updated. The cost of an extra
27+
block is one re-prompt (the user runs the command in their own
28+
terminal). The cost of a missed prod dispatch is irreversible.
29+
Block all dispatches; let the user judge.
30+
31+
## Override
32+
33+
There is no opt-out. If a real workflow id needs dispatching during
34+
a Claude session, the user runs it themselves — either in a plain
35+
shell, via the GitHub Actions UI, or by typing `! gh workflow run
36+
...` outside of a Claude prompt where the hook doesn't fire.
37+
38+
## Wiring
39+
40+
`.claude/settings.json`:
41+
42+
```json
43+
{
44+
"hooks": {
45+
"PreToolUse": [
46+
{
47+
"matcher": "Bash",
48+
"hooks": [{ "type": "command", "command": "node .claude/hooks/release-workflow-guard/index.mts" }]
49+
}
50+
]
51+
}
52+
}
53+
```
54+
55+
## Exit code
56+
57+
- `0` — command is not a workflow dispatch; pass through
58+
- `2` — command is a workflow dispatch; block + write reason to stderr
59+
60+
## Sibling hooks
61+
62+
- `private-name-guard` — primes the model on private repo / project names
63+
- `public-surface-reminder` — primes on customer / company names
64+
- `token-guard` — blocks token-leaking shell shapes
65+
66+
`release-workflow-guard` is the third hook that **blocks** rather
67+
than primes (alongside `token-guard` and `path-guard`). The shared
68+
rule: block when the harm of a wrong fire is irreversible.
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
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()
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"name": "hook-release-workflow-guard",
3+
"private": true,
4+
"type": "module",
5+
"main": "./index.mts",
6+
"exports": {
7+
".": "./index.mts"
8+
},
9+
"devDependencies": {
10+
"@types/node": "24.9.2"
11+
}
12+
}

.claude/settings.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@
2525
"type": "command",
2626
"command": "node .claude/hooks/public-surface-reminder/index.mts"
2727
},
28+
{
29+
"type": "command",
30+
"command": "node .claude/hooks/release-workflow-guard/index.mts"
31+
},
2832
{
2933
"type": "command",
3034
"command": "node .claude/hooks/token-guard/index.mts"

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ See `docs/references/error-messages.md` for worked examples and anti-patterns.
145145
- 🚨 **NEVER use `npx`, `pnpm dlx`, or `yarn dlx`** — use `pnpm exec` or `pnpm run` # zizmor: documentation-prohibition
146146
- **minimumReleaseAge**: NEVER add packages to `minimumReleaseAgeExclude` in CI. Locally, ASK before adding — the age threshold is a security control.
147147
- 🚨 **NEVER mention private repos or internal project names** in commits, PR titles/descriptions/comments, issues, release notes, or any public-surface text. Internal codenames, unreleased product names, internal tooling repo names not on the public org page, customer names, partner names — none belong in public surfaces. **Omit the reference entirely.** Don't substitute a placeholder ("an internal tool", "a downstream consumer", etc.) — the placeholder itself is a tell that something is being elided. Rewrite the sentence to not need the reference at all. The `.claude/hooks/private-name-guard` hook re-prints this rule on every public-surface `git`/`gh` command as a priming nudge; the rule applies even when the hook isn't installed.
148+
- 🚨 **NEVER trigger Publish / Release / Provenance / Build-Release workflows** — no `gh workflow run`, `gh workflow dispatch`, or `gh api .../dispatches`. Workflow dispatches are irrevocable: Publish workflows push npm versions (unpublishable after 24h), Build/Release workflows pin GitHub releases by SHA, container workflows push immutable tags. Even build workflows with a `dry_run` input still treat the dispatch itself as the prod trigger. The user runs workflow_dispatch jobs manually after CI passes on the release commit + tag — Claude **never** dispatches them. If the user asks for a publish, tell them to run the command in their own terminal (or the GitHub Actions UI). The `.claude/hooks/release-workflow-guard` hook BLOCKS these commands; the rule applies even when the hook isn't installed.
148149

149150
## DOCUMENTATION POLICY
150151

0 commit comments

Comments
 (0)