Skip to content

Commit 40432a7

Browse files
committed
chore(claude): sync hooks to template canonical
Adds private-name-guard, public-surface-reminder, and release-workflow-guard hooks (previously the rules were in CLAUDE.md without the enforcement hook). Refreshes check-new-deps index.mts + README to canonical (Cargo.toml fragment-mode parsing, score-based warnings, module-aware main). Wires the 4 Bash hooks alphabetically in settings.json. Now byte-identical with template/.claude/hooks/ for index.mts + README.md across all four hooks. package.json kept at per-repo catalog/pin style.
1 parent c0b453e commit 40432a7

12 files changed

Lines changed: 582 additions & 29 deletions

File tree

.claude/hooks/check-new-deps/README.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@ When Claude edits a file like `package.json`, `requirements.txt`, `Cargo.toml`,
88

99
1. **Detects the file type** and extracts dependency names from the content
1010
2. **Diffs against the old content** (for edits) so only *newly added* deps are checked
11-
3. **Queries the Socket.dev API** to check for malware
12-
4. **Blocks the edit** (exit code 2) if malware is detected
13-
5. **Allows** (exit code 0) if everything is clean or the file isn't a manifest
11+
3. **Queries the Socket.dev API** to check for malware and critical security alerts
12+
4. **Blocks the edit** (exit code 2) if malware or critical alerts are found
13+
5. **Warns** (but allows) if a package has a low quality score
14+
6. **Allows** (exit code 0) if everything is clean or the file isn't a manifest
1415

1516
## How it works
1617

@@ -29,8 +30,11 @@ Build Package URLs (PURLs) for each dep
2930
3031
3132
Call sdk.checkMalware(components)
33+
- ≤5 deps: parallel firewall API (fast, full data)
34+
- >5 deps: batch PURL API (efficient)
3235
33-
├── Malware detected → EXIT 2 (blocked)
36+
├── Malware/critical alert → EXIT 2 (blocked)
37+
├── Low score → warn, EXIT 0 (allowed)
3438
└── Clean → EXIT 0 (allowed)
3539
```
3640

.claude/hooks/check-new-deps/index.mts

Lines changed: 55 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -160,23 +160,46 @@ const extractors: Record<string, Extractor> = {
160160
(m): Dep => ({ type: 'cargo', name: m[1] })
161161
),
162162
'Cargo.toml': (content: string): Dep[] => {
163-
// Rust: only extract from [dependencies], [dev-dependencies], [build-dependencies] sections.
164-
// Skip [package], [lib], [bin], [workspace], [profile] metadata sections.
163+
// Rust: extract crate names from dep lines.
164+
//
165+
// Two-mode strategy because the hook receives either a full
166+
// Cargo.toml (Write) or a fragment (Edit's new_string, often just
167+
// the added line with no section header):
168+
//
169+
// Full file — scan only [dependencies] / [dev-dependencies] /
170+
// [build-dependencies] (incl. target-specific
171+
// [target.*.dependencies] via the `.<name>` suffix)
172+
// and skip [package], [features], [profile], etc.
173+
// Fragment — no section headers at all → treat the whole
174+
// content as an implicit [dependencies] body and
175+
// match any `name = "..."` or `name = { version = "..." }`.
176+
//
177+
// The lineRe requires the value to look like a version spec
178+
// (string or table with a `version` key), so `[features]`-style
179+
// `key = ["derive"]` array values don't match even in fragment mode.
165180
const deps: Dep[] = []
166-
const depSectionRe = /^\[(?:(?:dev-|build-)?dependencies(?:\.[^\]]+)?)\]\s*$/gm
181+
const depSectionRe = /^\[(?:(?:dev-|build-)?dependencies(?:\.[^\]]+)?|target\.[^\]]+\.(?:dev-|build-)?dependencies(?:\.[^\]]+)?)\]\s*$/gm
167182
const anySectionRe = /^\[/gm
183+
const lineRe = /^(\w[\w-]*)\s*=\s*(?:\{[^}]*version\s*=\s*"[^"]*"|\s*"[^"]*")/gm
184+
const push = (section: string) => {
185+
let m
186+
while ((m = lineRe.exec(section)) !== null) {
187+
deps.push({ type: 'cargo', name: m[1] })
188+
}
189+
lineRe.lastIndex = 0
190+
}
191+
const hasAnySection = /^\[/m.test(content)
192+
if (!hasAnySection) {
193+
push(content)
194+
return deps
195+
}
168196
let sectionMatch
169197
while ((sectionMatch = depSectionRe.exec(content)) !== null) {
170198
const sectionStart = sectionMatch.index + sectionMatch[0].length
171199
anySectionRe.lastIndex = sectionStart
172200
const nextSection = anySectionRe.exec(content)
173201
const sectionEnd = nextSection ? nextSection.index : content.length
174-
const sectionText = content.slice(sectionStart, sectionEnd)
175-
const lineRe = /^(\w[\w-]*)\s*=\s*(?:\{[^}]*version\s*=\s*"[^"]*"|\s*"[^"]*")/gm
176-
let m
177-
while ((m = lineRe.exec(sectionText)) !== null) {
178-
deps.push({ type: 'cargo', name: m[1] })
179-
}
202+
push(content.slice(sectionStart, sectionEnd))
180203
}
181204
return deps
182205
},
@@ -281,21 +304,6 @@ const extractors: Record<string, Extractor> = {
281304
'yarn.lock': extractNpmLockfile,
282305
}
283306

284-
// --- main (only when executed directly, not imported) ---
285-
286-
if (fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
287-
// Read the full JSON blob from stdin (piped by Claude Code).
288-
let input = ''
289-
for await (const chunk of process.stdin) input += chunk
290-
const hook: HookInput = JSON.parse(input)
291-
292-
if (hook.tool_name !== 'Edit' && hook.tool_name !== 'Write') {
293-
process.exitCode = 0
294-
} else {
295-
process.exitCode = await check(hook)
296-
}
297-
}
298-
299307
// --- core ---
300308

301309
// Orchestrates the full check: extract deps, diff against old, query API.
@@ -729,3 +737,26 @@ export {
729737
extractTerraform,
730738
findExtractor,
731739
}
740+
741+
// --- main (only when executed directly, not imported) ---
742+
//
743+
// Kept at the bottom because the module uses top-level await
744+
// (`for await (const chunk of process.stdin)`) to read the hook payload.
745+
// Top-level await suspends module evaluation at the suspension point, so
746+
// any `const` declared AFTER the suspending block is still in the TDZ
747+
// when the awaited work calls back into the module (e.g. extractNpm →
748+
// PACKAGE_JSON_METADATA_KEYS). Placing main last guarantees every
749+
// module-level declaration is initialized before main runs.
750+
751+
if (fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
752+
// Read the full JSON blob from stdin (piped by Claude Code).
753+
let input = ''
754+
for await (const chunk of process.stdin) input += chunk
755+
const hook: HookInput = JSON.parse(input)
756+
757+
if (hook.tool_name !== 'Edit' && hook.tool_name !== 'Write') {
758+
process.exitCode = 0
759+
} else {
760+
process.exitCode = await check(hook)
761+
}
762+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# private-name-guard
2+
3+
`PreToolUse` hook that **never blocks**. On every `Bash` command that
4+
would publish text to a public Git/GitHub surface, writes a short
5+
reminder to stderr so the model re-reads the command with the rule
6+
freshly in mind:
7+
8+
> No private repos or internal project names in public surfaces. Omit
9+
> the reference entirely — don't substitute a placeholder. The
10+
> placeholder itself is a tell.
11+
12+
Attention priming, not enforcement. The model is responsible for
13+
applying the rule — the hook just ensures the rule is in the active
14+
context at the moment the command is about to fire.
15+
16+
Sibling to `public-surface-reminder`, which covers customer/company
17+
names and internal work-item IDs. The two hooks compose: both fire on
18+
the same public-surface commands, each priming a distinct slice of the
19+
rule set.
20+
21+
## What counts as "public surface"
22+
23+
- `git commit` (including `--amend`)
24+
- `git push`
25+
- `gh pr (create|edit|comment|review)`
26+
- `gh issue (create|edit|comment)`
27+
- `gh api -X POST|PATCH|PUT`
28+
- `gh release (create|edit)`
29+
30+
Any other `Bash` command passes through silently.
31+
32+
## Why no denylist
33+
34+
Because a denylist is itself a leak. A file named `private-projects.txt`
35+
that enumerates "these are our internal repos" is worse than no list at
36+
all — anyone who finds it gets the org's full internal map for free.
37+
Recognition happens at write time, every time, by the model reading the
38+
text it's about to send. The hook just makes sure that read happens.
39+
40+
## Wiring
41+
42+
`.claude/settings.json`:
43+
44+
```json
45+
{
46+
"hooks": {
47+
"PreToolUse": [
48+
{
49+
"matcher": "Bash",
50+
"hooks": [{ "type": "command", "command": "node .claude/hooks/private-name-guard/index.mts" }]
51+
}
52+
]
53+
}
54+
}
55+
```
56+
57+
## Exit code
58+
59+
Always `0`. The hook never blocks; it only prints to stderr.
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
#!/usr/bin/env node
2+
// Claude Code PreToolUse hook — private-name guard.
3+
//
4+
// Never blocks. On every Bash command that would publish text to a public
5+
// Git/GitHub surface (git commit, git push, gh pr/issue/api/release write),
6+
// writes a short reminder to stderr so the model re-reads the command with
7+
// the rule freshly in mind:
8+
//
9+
// No private repos or internal project names in public surfaces.
10+
// Omit the reference entirely — don't substitute a placeholder.
11+
//
12+
// Exit code is always 0. This is attention priming, not enforcement. The
13+
// model is responsible for applying the rule — the hook just makes sure
14+
// the rule is in the active context at the moment the command is about
15+
// to fire.
16+
//
17+
// Deliberately carries no enumerated denylist. Recognition and replacement
18+
// happen at write time, not via a list of names. A denylist is itself a
19+
// leak — a file named `private-projects.txt` would be the very thing it
20+
// tries to prevent.
21+
//
22+
// Reads a Claude Code PreToolUse JSON payload from stdin:
23+
// { "tool_name": "Bash", "tool_input": { "command": "..." } }
24+
25+
import { readFileSync } from 'node:fs'
26+
27+
type ToolInput = {
28+
tool_name?: string
29+
tool_input?: {
30+
command?: string
31+
}
32+
}
33+
34+
// Commands that can publish content outside the local machine.
35+
// Keep broad — better to remind on an extra read than miss a write.
36+
const PUBLIC_SURFACE_PATTERNS: RegExp[] = [
37+
/\bgit\s+commit\b/,
38+
/\bgit\s+push\b/,
39+
/\bgh\s+pr\s+(create|edit|comment|review)\b/,
40+
/\bgh\s+issue\s+(create|edit|comment)\b/,
41+
/\bgh\s+api\b[^|]*-X\s*(POST|PATCH|PUT)\b/i,
42+
/\bgh\s+release\s+(create|edit)\b/,
43+
]
44+
45+
function isPublicSurface(command: string): boolean {
46+
const normalized = command.replace(/\s+/g, ' ')
47+
return PUBLIC_SURFACE_PATTERNS.some(re => re.test(normalized))
48+
}
49+
50+
function main(): void {
51+
let raw = ''
52+
try {
53+
raw = readFileSync(0, 'utf8')
54+
} catch {
55+
return
56+
}
57+
58+
let input: ToolInput
59+
try {
60+
input = JSON.parse(raw)
61+
} catch {
62+
return
63+
}
64+
65+
if (input.tool_name !== 'Bash') {
66+
return
67+
}
68+
const command = input.tool_input?.command
69+
if (!command || typeof command !== 'string') {
70+
return
71+
}
72+
if (!isPublicSurface(command)) {
73+
return
74+
}
75+
76+
const lines = [
77+
'[private-name-guard] This command writes to a public Git/GitHub surface.',
78+
' • Re-read the commit message / PR body / comment BEFORE it sends.',
79+
' • No private repo names. No internal project codenames. No unreleased',
80+
' product names. No internal-only tooling repos absent from the public',
81+
' org page. No customer/partner names.',
82+
' • Omit the reference entirely. Do not substitute a placeholder — the',
83+
' placeholder itself is a tell.',
84+
' • If you spot one, cancel and rewrite the text first.',
85+
]
86+
process.stderr.write(lines.join('\n') + '\n')
87+
}
88+
89+
main()
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"name": "hook-private-name-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+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# public-surface-reminder
2+
3+
`PreToolUse` hook that **never blocks**. On every `Bash` command that would
4+
publish text to a public Git/GitHub surface, writes a short reminder to
5+
stderr so the model re-reads the command with the two rules freshly in
6+
mind:
7+
8+
1. **No real customer or company names.** Use `Acme Inc`. No exceptions.
9+
2. **No internal work-item IDs or tracker URLs.** No `SOC-123` /
10+
`ENG-456` / `ASK-789` / similar, no `linear.app` / `sentry.io` URLs.
11+
12+
Attention priming, not enforcement. The model is responsible for actually
13+
applying the rule — the hook just ensures the rule is in the active
14+
context at the moment the command is about to fire.
15+
16+
## What counts as "public surface"
17+
18+
- `git commit` (including `--amend`)
19+
- `git push`
20+
- `gh pr (create|edit|comment|review)`
21+
- `gh issue (create|edit|comment)`
22+
- `gh api -X POST|PATCH|PUT`
23+
- `gh release (create|edit)`
24+
25+
Any other `Bash` command passes through silently.
26+
27+
## Why no denylist
28+
29+
Because a denylist is itself a customer leak. A file named
30+
`customers.txt` that enumerates "these are our customers" is worse than
31+
the bug it tries to prevent. Recognition and replacement happen at write
32+
time, done by the model, every time.
33+
34+
## Exit code
35+
36+
Always `0`. The hook prints a reminder and steps aside.

0 commit comments

Comments
 (0)