Skip to content

Commit 9727cda

Browse files
committed
chore(claude): sync check-new-deps + add public-surface-reminder hook
Refreshes check-new-deps to canonical (Cargo.toml fragment-mode parsing + score-based warnings) and adds the public-surface-reminder hook (settings.json already wired it; the file was just missing).
1 parent 7b11086 commit 9727cda

5 files changed

Lines changed: 196 additions & 28 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: 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.
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
#!/usr/bin/env node
2+
// Claude Code PreToolUse hook — public-surface reminder.
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 two rules freshly in mind:
8+
//
9+
// 1. No real customer/company names — ever. Use `Acme Inc` instead.
10+
// 2. No internal work-item IDs or tracker URLs — no `SOC-123`, `ENG-456`,
11+
// `ASK-789`, `linear.app`, `sentry.io`, etc.
12+
//
13+
// Exit code is always 0. This is attention priming, not enforcement. The
14+
// model is responsible for actually applying the rule — the hook just makes
15+
// sure the rule is in the active context at the moment the command is about
16+
// to fire.
17+
//
18+
// Deliberately carries no list of customer names. Recognition and
19+
// replacement happen at write time, not via enumeration.
20+
//
21+
// Reads a Claude Code PreToolUse JSON payload from stdin:
22+
// { "tool_name": "Bash", "tool_input": { "command": "..." } }
23+
24+
import { readFileSync } from 'node:fs'
25+
26+
type ToolInput = {
27+
tool_name?: string
28+
tool_input?: {
29+
command?: string
30+
}
31+
}
32+
33+
// Commands that can publish content outside the local machine.
34+
// Keep broad — better to remind on an extra read than miss a write.
35+
const PUBLIC_SURFACE_PATTERNS: RegExp[] = [
36+
/\bgit\s+commit\b/,
37+
/\bgit\s+push\b/,
38+
/\bgh\s+pr\s+(create|edit|comment|review)\b/,
39+
/\bgh\s+issue\s+(create|edit|comment)\b/,
40+
/\bgh\s+api\b[^|]*-X\s*(POST|PATCH|PUT)\b/i,
41+
/\bgh\s+release\s+(create|edit)\b/,
42+
]
43+
44+
function isPublicSurface(command: string): boolean {
45+
const normalized = command.replace(/\s+/g, ' ')
46+
return PUBLIC_SURFACE_PATTERNS.some(re => re.test(normalized))
47+
}
48+
49+
function main(): void {
50+
let raw = ''
51+
try {
52+
raw = readFileSync(0, 'utf8')
53+
} catch {
54+
return
55+
}
56+
57+
let input: ToolInput
58+
try {
59+
input = JSON.parse(raw)
60+
} catch {
61+
return
62+
}
63+
64+
if (input.tool_name !== 'Bash') {
65+
return
66+
}
67+
const command = input.tool_input?.command
68+
if (!command || typeof command !== 'string') {
69+
return
70+
}
71+
if (!isPublicSurface(command)) {
72+
return
73+
}
74+
75+
const lines = [
76+
'[public-surface-reminder] This command writes to a public Git/GitHub surface.',
77+
' • Re-read the commit message / PR body / comment BEFORE it sends.',
78+
' • No real customer or company names — use `Acme Inc`. No exceptions.',
79+
' • No internal work-item IDs or tracker URLs (linear.app, sentry.io, SOC-/ENG-/ASK-/etc.).',
80+
' • If you spot one, cancel and rewrite the text first.',
81+
]
82+
process.stderr.write(lines.join('\n') + '\n')
83+
}
84+
85+
main()
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"name": "hook-public-surface-reminder",
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+
}

0 commit comments

Comments
 (0)