Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/hooks/check-new-deps/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
},
"dependencies": {
"@socketregistry/packageurl-js": "1.4.2",
"@socketsecurity/lib": "5.24.0",
"@socketsecurity/lib": "catalog:",
"@socketsecurity/sdk": "4.0.1"
},
"devDependencies": {
Expand Down
98 changes: 83 additions & 15 deletions .claude/hooks/release-workflow-guard/index.mts
Original file line number Diff line number Diff line change
Expand Up @@ -48,35 +48,103 @@ type ToolInput = {
// The captured workflow argument is reported back so the user can
// see what was blocked.
const GH_WORKFLOW_DISPATCH_RE =
/\bgh\s+workflow\s+(?:run|dispatch)\b(?:\s+(?:--repo|--ref|-f|--field)\s+\S+)*\s+(['"]?)([^\s'"]+)\1/
/\bgh\s+workflow\s+(?:run|dispatch)\b(?:\s+(?:--repo|--ref|-f|--field)\s+\S+)*\s+(['"]?)([^\s'"]+)\1/g

// `gh api .../actions/workflows/<id>/dispatches` (POST/PUT).
// The path component implies dispatch — no need to also match -X.
const GH_API_WORKFLOW_DISPATCH_RE =
/\bgh\s+api\b[^|]*?\/actions\/workflows\/([^/\s]+)\/dispatches\b/
/\bgh\s+api\b[^|]*?\/actions\/workflows\/([^/\s]+)\/dispatches\b/g

// Walk the command and return a per-position boolean: true means the
// char at index i sits inside a single- or double-quoted string. We
// use this to skip matches that fall inside `git commit -m "..."`
// message bodies, heredocs, etc. — text that the shell will pass as
// a literal argument value, not execute. Without this, mentioning
// `gh workflow run` inside a commit message body trips the hook.
//
// Limitations: this is not a full POSIX shell parser. Heredocs
// (<<EOF ... EOF) read as code-mode here, but in practice commit
// messages via heredoc are quoted by `$(cat <<'EOF' ... EOF)` and
// the outer `$(...)`/`"..."` wrap puts the body in quoted-mode.
// `\$` and other escapes inside quotes are honored only in the
// limited sense of skipping the next char.
function buildQuoteMask(s: string): boolean[] {
const mask = new Array<boolean>(s.length).fill(false)
let inSingle = false
let inDouble = false
for (let i = 0; i < s.length; i += 1) {
const c = s[i]
if (!inSingle && !inDouble && c === "'") {
inSingle = true
mask[i] = true
continue
}
if (inSingle && c === "'") {
inSingle = false
mask[i] = true
continue
}
if (!inSingle && !inDouble && c === '"') {
inDouble = true
mask[i] = true
continue
}
if (inDouble && c === '"') {
inDouble = false
mask[i] = true
continue
}
if (inDouble && c === '\\' && i + 1 < s.length) {
mask[i] = true
mask[i + 1] = true
i += 1
continue
}
mask[i] = inSingle || inDouble
}
return mask
}

function detectDispatch(command: string): {
blocked: boolean
workflow?: string
shape?: string
} {
const normalized = command.replace(/\s+/g, ' ')
// We can't `replace(/\s+/g, ' ')` first because that would offset
// the quote mask from the original string. Match against the raw
// command and use the mask to filter false-positives.
const mask = buildQuoteMask(command)

const cliMatch = GH_WORKFLOW_DISPATCH_RE.exec(normalized)
if (cliMatch) {
return {
blocked: true,
workflow: cliMatch[2],
shape: 'gh workflow run/dispatch',
// The /g-flag regex is a module-scoped singleton; `.exec()` advances
// `lastIndex` and only resets when it returns null at end-of-input.
// If our previous call broke out of the loop early (because we found
// a quote-masked match), `lastIndex` is left mid-string and the next
// `detectDispatch` call would resume from there instead of scanning
// the whole command. Reset before each scan to make the regex
// stateless from the caller's perspective.
GH_WORKFLOW_DISPATCH_RE.lastIndex = 0
let cliMatch: RegExpExecArray | null
while ((cliMatch = GH_WORKFLOW_DISPATCH_RE.exec(command))) {
if (!mask[cliMatch.index]) {
return {
blocked: true,
workflow: cliMatch[2],
shape: 'gh workflow run/dispatch',
}
}
}

const apiMatch = GH_API_WORKFLOW_DISPATCH_RE.exec(normalized)
if (apiMatch) {
return {
blocked: true,
workflow: apiMatch[1],
shape: 'gh api .../dispatches',
// Same /g-flag reset rationale as above — keep the regex stateless
// across calls.
GH_API_WORKFLOW_DISPATCH_RE.lastIndex = 0
let apiMatch: RegExpExecArray | null
while ((apiMatch = GH_API_WORKFLOW_DISPATCH_RE.exec(command))) {
if (!mask[apiMatch.index]) {
return {
blocked: true,
workflow: apiMatch[1],
shape: 'gh api .../dispatches',
}
}
}

Expand Down
6 changes: 5 additions & 1 deletion .claude/hooks/release-workflow-guard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@
"exports": {
".": "./index.mts"
},
"scripts": {
"test": "node --test test/*.test.mts"
},
"devDependencies": {
"@types/node": "24.9.2"
"@socketsecurity/lib": "catalog:",
"@types/node": "catalog:"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
/**
* @fileoverview Tests for the release-workflow-guard hook.
*
* Runs the hook as a subprocess (node --test), piping a tool-use
* payload on stdin and asserting on the exit code + stderr. Exit 2
* means the hook refused the command; exit 0 means it passed it
* through.
*/

import { execPath } from 'node:process'
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'

import { isSpawnError, spawn } from '@socketsecurity/lib/spawn'

const hookScript = new URL('../index.mts', import.meta.url).pathname

async function runHook(
command: string,
toolName = 'Bash',
): Promise<{ code: number | null; stdout: string; stderr: string }> {
const payload = JSON.stringify({
tool_name: toolName,
tool_input: { command },
})
return runChild(payload)
}

// Async @socketsecurity/lib/spawn — preferred over child_process
// spawnSync (see CLAUDE.md "Async spawn preferred"). Hooks are
// small, but async tests run in parallel under node --test, so
// even short subprocess waits compound when sync. spawn returns
// `{ stdin, stdout, stderr, process }` synchronously plus a thenable
// for the result; write the payload to stdin and await the result.
// On non-zero exit it throws a SpawnError — catch and lift fields
// back out so tests can assert on code (the hook's exit-2 path is
// the primary thing we test).
async function runChild(
payload: string,
): Promise<{ code: number | null; stdout: string; stderr: string }> {
const child = spawn(execPath, [hookScript], {
timeout: 5_000,
stdio: ['pipe', 'pipe', 'pipe'],
})
child.stdin?.end(payload)
try {
const result = await child
return {
code: result.code,
stdout: (result.stdout || '').toString(),
stderr: (result.stderr || '').toString(),
}
} catch (e) {
if (isSpawnError(e)) {
return {
code: e.code,
stdout: (e.stdout || '').toString(),
stderr: (e.stderr || '').toString(),
}
}
throw e
}
}

describe('release-workflow-guard hook', () => {
describe('blocks dispatching commands', () => {
it('gh workflow run', async () => {
const r = await runHook('gh workflow run release.yml')
assert.equal(r.code, 2)
assert.match(r.stderr, /BLOCKED/)
assert.match(r.stderr, /release\.yml/)
})

it('gh workflow dispatch', async () => {
const r = await runHook('gh workflow dispatch publish.yml')
assert.equal(r.code, 2)
assert.match(r.stderr, /publish\.yml/)
})

it('gh workflow run with -f flags', async () => {
const r = await runHook(
'gh workflow run build.yml -f mode=prod -f arch=arm64',
)
assert.equal(r.code, 2)
assert.match(r.stderr, /build\.yml/)
})

it('gh api .../dispatches', async () => {
const r = await runHook(
'gh api repos/foo/bar/actions/workflows/42/dispatches -X POST',
)
assert.equal(r.code, 2)
assert.match(r.stderr, /42/)
})

it('gh workflow run after a chained &&', async () => {
const r = await runHook('git fetch && gh workflow run release.yml')
assert.equal(r.code, 2)
})
})

describe('allows benign commands', () => {
it('plain echo', async () => {
assert.equal((await runHook('echo hello')).code, 0)
})

it('git status', async () => {
assert.equal((await runHook('git status --short')).code, 0)
})

it('gh pr list (not a dispatch)', async () => {
assert.equal((await runHook('gh pr list --state open')).code, 0)
})

it('gh workflow list (read-only, no dispatch)', async () => {
assert.equal((await runHook('gh workflow list')).code, 0)
})

it('gh api repos/.../workflows (no /dispatches)', async () => {
assert.equal(
(await runHook('gh api repos/foo/bar/actions/workflows')).code,
0,
)
})
})

describe('does not match inside quoted argument bodies', () => {
it('git commit -m with double-quoted body mentioning gh workflow run', async () => {
const r = await runHook(
'git commit -m "chore: blocks dispatching gh workflow run jobs"',
)
assert.equal(r.code, 0, `Expected 0 but got ${r.code}: ${r.stderr}`)
})

it('git commit -m with heredoc body mentioning gh workflow run', async () => {
const r = await runHook(
`git commit -m "$(cat <<'EOF'\nchore: never gh workflow run anything\nEOF\n)"`,
)
assert.equal(r.code, 0, `Expected 0 but got ${r.code}: ${r.stderr}`)
})

it('echo of a doc string mentioning gh api .../dispatches', async () => {
const r = await runHook(
'echo "see also: gh api repos/x/y/actions/workflows/1/dispatches"',
)
assert.equal(r.code, 0, `Expected 0 but got ${r.code}: ${r.stderr}`)
})

it('single-quoted body protects against dispatch substring', async () => {
const r = await runHook(
"echo 'pretend command: gh workflow dispatch foo.yml'",
)
assert.equal(r.code, 0, `Expected 0 but got ${r.code}: ${r.stderr}`)
})
})

describe('payload edge cases', () => {
it('non-Bash tool is ignored', async () => {
assert.equal(
(await runHook('gh workflow run release.yml', 'Read')).code,
0,
)
})

it('empty command is ignored', async () => {
assert.equal((await runHook('')).code, 0)
})

it('invalid JSON on stdin returns 0 (silent)', async () => {
// Hook intentionally returns 0 on bad JSON (don't punish the
// model for unparseable payloads — pass them through).
const r = await runChild('not json')
assert.equal(r.code, 0)
})
})
})
Loading
Loading