Skip to content

Commit dea56b1

Browse files
committed
chore(sync): release-workflow-guard dry-run bypass + naming rule
Mirrors socket-repo-template@bc292e1. The release-workflow-guard hook now allows dispatches that pass -f dry-run=true when the target workflow declares a dry-run input in its workflow_dispatch.inputs block. Cross-repo dispatches (--repo flag pointing at a different project) still block. CLAUDE.md picks up the kebab-case "Workflow input naming" rule formalizing the fleet convention.
1 parent ea13cf2 commit dea56b1

3 files changed

Lines changed: 402 additions & 23 deletions

File tree

.claude/hooks/release-workflow-guard/index.mts

Lines changed: 166 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,11 @@
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
@@ -26,6 +26,25 @@
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
@@ -34,7 +53,8 @@
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'
3858
import process from 'node:process'
3959

4060
type ToolInput = {
@@ -55,6 +75,35 @@ const GH_WORKFLOW_DISPATCH_RE =
5575
const GH_API_WORKFLOW_DISPATCH_RE =
5676
/\bgh\s+api\b[^|]*?\/actions\/workflows\/([^/\s]+)\/dispatches\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+dry-run\s*=\s*['"]?(?:true|1|yes)['"]?/i
85+
const DRY_RUN_FALSE_RE = /-f\s+dry-run\s*=\s*['"]?(?:false|0|no)['"]?/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+(?:release|publish|prod|production)\s*=\s*['"]?(?:true|1|yes)['"]?/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+dry-run:\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--repo\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 (!/\.(?:yml|yaml)$/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

Comments
 (0)