1111// - Container workflows push immutable image tags.
1212//
1313// 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.
14+ // effects — the dispatch itself is the trigger that runs the
15+ // release pipeline, even when an input gates the destructive step.
16+ // Default policy: block all dispatches and ask the user to run them
17+ // themselves. Cost of an extra block: one re-prompt. Cost of a
18+ // missed prod publish: irreversible.
1919//
2020// Exit code 2 with a clear stderr message stops the tool call. The
2121// model never gets to fire the command. The user re-runs it from
2626// - `gh workflow dispatch <id>` (alias of `run`)
2727// - `gh api ... actions/workflows/<id>/dispatches` POST/PUT
2828//
29+ // Bypass — verifiable dry-run only:
30+ // - Pass `-f dry-run=true` (or =1/=yes) explicitly.
31+ // - The workflow YAML must declare a `dry-run:` input under its
32+ // `workflow_dispatch.inputs` block (we read the file from
33+ // $CLAUDE_PROJECT_DIR/.github/workflows/<name>.yml). If the
34+ // workflow doesn't have the input, the gh CLI silently accepts
35+ // the flag but the workflow ignores it — that's the unsafe
36+ // case the verification step prevents.
37+ // - No force-prod overrides may be set: `-f release=true`,
38+ // `-f publish=true`, `-f prod=true`, `-f production=true`.
39+ // - Bypass applies only to `gh workflow run|dispatch`. The
40+ // `gh api .../dispatches` shape takes inputs as a JSON body,
41+ // which is harder to verify safely; route those through the user.
42+ //
43+ // The hook recognizes only kebab-case `dry-run` as the input name —
44+ // see CLAUDE.md "Workflow input naming" for the rule. If a workflow
45+ // declares `dry_run` (snake) or any other shape, the verification
46+ // fails and the bypass doesn't apply. Fix the workflow.
47+ //
2948// This hook is the enforcement layer paired with the CLAUDE.md
3049// rule. The rule documents the policy; the hook makes it
3150// mechanical so the model can't accidentally dispatch a workflow
3453// Reads a Claude Code PreToolUse JSON payload from stdin:
3554// { "tool_name": "Bash", "tool_input": { "command": "..." } }
3655
37- import { readFileSync } from 'node:fs'
56+ import { existsSync , readFileSync } from 'node:fs'
57+ import path from 'node:path'
3858import process from 'node:process'
3959
4060type ToolInput = {
@@ -55,6 +75,35 @@ const GH_WORKFLOW_DISPATCH_RE =
5575const GH_API_WORKFLOW_DISPATCH_RE =
5676 / \b g h \s + a p i \b [ ^ | ] * ?\/ a c t i o n s \/ w o r k f l o w s \/ ( [ ^ / \s ] + ) \/ d i s p a t c h e s \b / g
5777
78+ // Dry-run input detection. The fleet standardized on `dry-run`
79+ // (kebab-case) — see socket-registry's shared actions and every
80+ // `*.yml` workflow that takes a dispatch input. Match values
81+ // "true"/"1"/"yes" as truthy and "false"/"0"/"no" as falsy. Quote-
82+ // mask handling lives in detectDispatch; these regexes scan the
83+ // same masked range as the dispatch detector.
84+ const DRY_RUN_TRUE_RE = / - f \s + d r y - r u n \s * = \s * [ ' " ] ? (?: t r u e | 1 | y e s ) [ ' " ] ? / i
85+ const DRY_RUN_FALSE_RE = / - f \s + d r y - r u n \s * = \s * [ ' " ] ? (?: f a l s e | 0 | n o ) [ ' " ] ? / i
86+
87+ // Inputs that flip a workflow back into "do the prod thing." Even
88+ // with dry-run=true, if any of these are explicitly set the dispatch
89+ // is no longer benign — block. Order matters: this runs after
90+ // dry-run detection, so an explicit publish=true overrides.
91+ const FORCE_PROD_INPUTS_RE =
92+ / - f \s + (?: r e l e a s e | p u b l i s h | p r o d | p r o d u c t i o n ) \s * = \s * [ ' " ] ? (?: t r u e | 1 | y e s ) [ ' " ] ? / i
93+
94+ // Workflow YAML input declaration. Match the canonical
95+ // `dry-run:` line under `inputs:` — used to verify a workflow
96+ // actually accepts a dry-run override before allowing a dispatch
97+ // that claims to use it. Tolerates leading whitespace (any
98+ // indentation) since YAML nesting depth varies by file.
99+ const WORKFLOW_DRY_RUN_INPUT_RE = / ^ \s + d r y - r u n : \s * $ / m
100+
101+ // `--repo <owner>/<name>` parser. Captures the repo name (after the
102+ // slash). Used to gate the dry-run bypass: a dispatch targeting a
103+ // repo other than the current $CLAUDE_PROJECT_DIR can't be verified
104+ // from disk, so we conservatively block it.
105+ const GH_REPO_FLAG_RE = / \s - - r e p o \s + \S * ?\/ ( [ ^ \s / ] + ) /
106+
58107// Walk the command and return a per-position boolean: true means the
59108// char at index i sits inside a single- or double-quoted string. We
60109// use this to skip matches that fall inside `git commit -m "..."`
@@ -105,11 +154,88 @@ function buildQuoteMask(s: string): boolean[] {
105154 return mask
106155}
107156
108- function detectDispatch ( command : string ) : {
157+ type DispatchResult = {
109158 blocked : boolean
110159 workflow ?: string
111160 shape ?: string
112- } {
161+ // When `blocked` is false, populated with the reason the dispatch
162+ // was allowed through. Surfaced in the hook's "allowed" log line so
163+ // the user can see exactly why the guard let it pass.
164+ allowedReason ? : string
165+ }
166+
167+ // Resolve the workflow file path and verify it actually declares a
168+ // `dry-run` input. The path is resolved relative to
169+ // `$CLAUDE_PROJECT_DIR/.github/workflows/<workflow>` since the hook
170+ // runs from arbitrary cwds; falls back to ".github/workflows/<wf>"
171+ // when the env var is unset (e.g. the hook invoked outside Claude
172+ // Code). The check is intentionally permissive: any unparseable
173+ // workflow file is treated as "no dry-run input" (block-the-default).
174+ function workflowDeclaresDryRunInput ( workflow : string ) : boolean {
175+ // Workflow arg can be "id.yml", "name.yaml", a numeric ID, or a path.
176+ // Numeric IDs and paths-without-extension can't be resolved without
177+ // hitting GitHub's API — for those, conservatively return false.
178+ if ( ! / \. (?: y m l | y a m l ) $ / i. test ( workflow ) ) {
179+ return false
180+ }
181+ const projectDir = process . env [ 'CLAUDE_PROJECT_DIR' ] ?? process . cwd ( )
182+ // Strip any leading directory prefix the user passed (e.g. they
183+ // typed the path explicitly). The bare filename is what
184+ // .github/workflows/ holds.
185+ const filename = path . basename ( workflow )
186+ const fullPath = path . join ( projectDir , '.github' , 'workflows' , filename )
187+ if ( ! existsSync ( fullPath ) ) {
188+ return false
189+ }
190+ try {
191+ const yaml = readFileSync ( fullPath , 'utf8' )
192+ return WORKFLOW_DRY_RUN_INPUT_RE . test ( yaml )
193+ } catch {
194+ return false
195+ }
196+ }
197+
198+ // Decide whether a dispatch on `workflow` should be allowed because
199+ // it's a verifiable dry-run. All five conditions must hold:
200+ // 1. `-f dry-run=true|1|yes` is explicitly present in the command
201+ // 2. `-f dry-run=false|0|no` is NOT present (user didn't override)
202+ // 3. No force-prod input is present (release/publish/prod=true)
203+ // 4. If `--repo <owner>/<name>` is present, the repo basename
204+ // matches the current $CLAUDE_PROJECT_DIR's basename. Verifying
205+ // a workflow file in a different repo would require scanning
206+ // sibling clones — too brittle. A cross-repo dispatch goes
207+ // through the user.
208+ // 5. The workflow YAML actually declares a `dry-run:` input under
209+ // its `workflow_dispatch.inputs` block — without that, the gh
210+ // CLI silently accepts the flag but the workflow ignores it.
211+ function isVerifiableDryRun (
212+ command : string ,
213+ workflow : string | undefined ,
214+ ) : boolean {
215+ if ( ! workflow ) {
216+ return false
217+ }
218+ if ( ! DRY_RUN_TRUE_RE . test ( command ) ) {
219+ return false
220+ }
221+ if ( DRY_RUN_FALSE_RE . test ( command ) ) {
222+ return false
223+ }
224+ if ( FORCE_PROD_INPUTS_RE . test ( command ) ) {
225+ return false
226+ }
227+ const repoMatch = GH_REPO_FLAG_RE . exec ( command )
228+ if ( repoMatch ) {
229+ const projectDir = process . env [ 'CLAUDE_PROJECT_DIR' ] ?? process . cwd ( )
230+ const projectName = path . basename ( projectDir )
231+ if ( repoMatch [ 1 ] !== projectName ) {
232+ return false
233+ }
234+ }
235+ return workflowDeclaresDryRunInput ( workflow )
236+ }
237+
238+ function detectDispatch ( command : string ) : DispatchResult {
113239 // We can't `replace(/\s+/g, ' ')` first because that would offset
114240 // the quote mask from the original string. Match against the raw
115241 // command and use the mask to filter false-positives.
@@ -126,16 +252,28 @@ function detectDispatch(command: string): {
126252 let cliMatch : RegExpExecArray | null
127253 while ( ( cliMatch = GH_WORKFLOW_DISPATCH_RE . exec ( command ) ) ) {
128254 if ( ! mask [ cliMatch . index ] ) {
255+ const workflow = cliMatch [ 2 ]
256+ if ( isVerifiableDryRun ( command , workflow ) ) {
257+ return {
258+ blocked : false ,
259+ workflow,
260+ shape : 'gh workflow run/dispatch' ,
261+ allowedReason :
262+ 'verifiable dry-run (-f dry-run=true + workflow declares dry-run input)' ,
263+ }
264+ }
129265 return {
130266 blocked : true ,
131- workflow : cliMatch [ 2 ] ,
267+ workflow,
132268 shape : 'gh workflow run/dispatch' ,
133269 }
134270 }
135271 }
136272
137273 // Same /g-flag reset rationale as above — keep the regex stateless
138- // across calls.
274+ // across calls. The dry-run bypass intentionally doesn't apply to
275+ // `gh api .../dispatches` — that path takes inputs as a JSON body,
276+ // which is harder to verify safely; route those through the user.
139277 GH_API_WORKFLOW_DISPATCH_RE . lastIndex = 0
140278 let apiMatch : RegExpExecArray | null
141279 while ( ( apiMatch = GH_API_WORKFLOW_DISPATCH_RE . exec ( command ) ) ) {
@@ -174,8 +312,16 @@ function main(): void {
174312 return
175313 }
176314
177- const { blocked, workflow, shape } = detectDispatch ( command )
315+ const { blocked, workflow, shape, allowedReason } = detectDispatch ( command )
178316 if ( ! blocked ) {
317+ if ( allowedReason ) {
318+ // Transparently log the bypass so the user sees why the guard
319+ // let it through. Stderr only — no exit-code change, hook
320+ // behaves as if it never fired.
321+ process . stderr . write (
322+ `[release-workflow-guard] ALLOWED: ${ shape } on ${ workflow ?? '<unknown>' } — ${ allowedReason } \n` ,
323+ )
324+ }
179325 return
180326 }
181327
@@ -187,16 +333,17 @@ function main(): void {
187333 ' - Publish workflows push npm versions (unpublishable after 24h).' ,
188334 ' - Build/Release workflows create GitHub releases pinned by SHA.' ,
189335 ' - Container workflows push immutable image tags.' ,
190- " - Even build workflows with a 'dry_run' input still treat the" ,
191- ' dispatch itself as the prod trigger.' ,
192336 '' ,
193- ' The user runs workflow_dispatch jobs manually — never Claude.' ,
194- ' Tell the user to run the command in their own terminal (or' ,
195- ' via the GitHub Actions UI), then resume.' ,
337+ ' Allowed bypass — verifiable dry-run:' ,
338+ ' - Pass `-f dry-run=true` explicitly, AND' ,
339+ ' - The workflow YAML must declare a `dry-run:` input under' ,
340+ ' its workflow_dispatch.inputs block.' ,
341+ ' - No force-prod overrides may be set' ,
342+ ' (e.g. -f release=true / -f publish=true).' ,
196343 '' ,
197- ' This hook has no opt-out. If you genuinely need to run a ' ,
198- ' benign dispatch (e.g. a debug-only utility workflow), ask ' ,
199- " the user to invoke it themselves; don't seek a bypass here." ,
344+ ' Without that bypass, the user runs workflow_dispatch jobs ' ,
345+ ' manually. Tell the user to run the command in their own ' ,
346+ ' terminal (or via the GitHub Actions UI), then resume.' ,
200347 ]
201348 process . stderr . write ( lines . join ( '\n' ) + '\n' )
202349 process . exitCode = 2
0 commit comments