Skip to content

Commit 6a1bb11

Browse files
authored
chore(hooks): path-guard + token-guard + .sh→.mts conversion (#628)
Self-landable split from #620. Combines the hook overhaul into one atomic PR: path-guard infra, token-guard hook, .sh→.mts conversion of git hooks, and the assorted fleet hooks (private-name-guard, public-surface-reminder, release-workflow-guard, check-new-deps). What's included: Path-guard infra - .claude/hooks/path-guard/ (hook + tests + segments.mts) - .claude/skills/path-guard/ (audit-and-fix skill) - .claude/skills/_shared/path-guard-rule.md (canonical rule) - scripts/check-paths.mts (the gate) - .github/paths-allowlist.yml (empty starter, full schema docs) - .claude/settings.json (wires hook on Edit|Write) - scripts/check.mts (invokes the gate) Token-guard hook - .claude/hooks/token-guard/ (renamed from token-hygiene; word- boundary match for sensitive env names; ALWAYS_DANGEROUS check skips when redaction pipeline is present) .sh → .mts hook conversion (Node 25+) - .git-hooks/_helpers.mts (was _helpers.sh) — exports filterAllowedApiKeys + scanners (personal paths, AWS keys, GitHub tokens, private keys, AI attribution) - .git-hooks/{commit-msg,pre-commit,pre-push}.mts (were .sh) - .husky/* shims invoke node directly Fleet hooks - .claude/hooks/check-new-deps (npm dep introspection) - .claude/hooks/private-name-guard - .claude/hooks/public-surface-reminder - .claude/hooks/release-workflow-guard Verification: pnpm install ✓ pnpm run check --all ✓ pnpm test --all ✓ 565/565 tests pass
1 parent d1af774 commit 6a1bb11

44 files changed

Lines changed: 5301 additions & 489 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.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: 56 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ interface CheckResult {
8686
reason?: string
8787
}
8888

89+
8990
// A cached API lookup result with expiration timestamp.
9091
interface CacheEntry {
9192
result: CheckResult | undefined
@@ -159,23 +160,46 @@ const extractors: Record<string, Extractor> = {
159160
(m): Dep => ({ type: 'cargo', name: m[1] })
160161
),
161162
'Cargo.toml': (content: string): Dep[] => {
162-
// Rust: only extract from [dependencies], [dev-dependencies], [build-dependencies] sections.
163-
// 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.
164180
const deps: Dep[] = []
165-
const depSectionRe = /^\[(?:(?:dev-|build-)?dependencies(?:\.[^\]]+)?)\]\s*$/gm
181+
const depSectionRe = /^\[(?:(?:dev-|build-)?dependencies(?:\.[^\]]+)?|target\.[^\]]+\.(?:dev-|build-)?dependencies(?:\.[^\]]+)?)\]\s*$/gm
166182
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+
}
167196
let sectionMatch
168197
while ((sectionMatch = depSectionRe.exec(content)) !== null) {
169198
const sectionStart = sectionMatch.index + sectionMatch[0].length
170199
anySectionRe.lastIndex = sectionStart
171200
const nextSection = anySectionRe.exec(content)
172201
const sectionEnd = nextSection ? nextSection.index : content.length
173-
const sectionText = content.slice(sectionStart, sectionEnd)
174-
const lineRe = /^(\w[\w-]*)\s*=\s*(?:\{[^}]*version\s*=\s*"[^"]*"|\s*"[^"]*")/gm
175-
let m
176-
while ((m = lineRe.exec(sectionText)) !== null) {
177-
deps.push({ type: 'cargo', name: m[1] })
178-
}
202+
push(content.slice(sectionStart, sectionEnd))
179203
}
180204
return deps
181205
},
@@ -280,21 +304,6 @@ const extractors: Record<string, Extractor> = {
280304
'yarn.lock': extractNpmLockfile,
281305
}
282306

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

300309
// Orchestrates the full check: extract deps, diff against old, query API.
@@ -728,3 +737,26 @@ export {
728737
extractTerraform,
729738
findExtractor,
730739
}
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+
}

.claude/hooks/check-new-deps/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"name": "@socketsecurity/hook-check-new-deps",
2+
"name": "hook-check-new-deps",
33
"private": true,
44
"type": "module",
55
"main": "./index.mts",
@@ -11,7 +11,7 @@
1111
},
1212
"dependencies": {
1313
"@socketregistry/packageurl-js": "1.4.2",
14-
"@socketsecurity/lib": "5.21.0",
14+
"@socketsecurity/lib": "5.24.0",
1515
"@socketsecurity/sdk": "4.0.1"
1616
},
1717
"devDependencies": {

.claude/hooks/path-guard/README.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# path-guard
2+
3+
Claude Code `PreToolUse` hook that refuses `Edit`/`Write` tool calls that would *construct* a multi-segment build/output path inline in a `.mts` or `.cts` file. Mandatory across the Socket fleet — every repo ships this file byte-for-byte via `scripts/sync-scaffolding.mjs`.
4+
5+
**Mantra: 1 path, 1 reference.**
6+
7+
Construct a path *once* in the canonical `paths.mts` (or a build-infra helper); reference the computed value everywhere else.
8+
9+
## What it blocks
10+
11+
| Rule | Example | Fix |
12+
|------|---------|-----|
13+
| **A** — Multi-stage path constructed inline | `path.join(PKG, 'build', mode, 'out', 'Final', name)` | Construct in the package's `scripts/paths.mts` (or use `getFinalBinaryPath` from `build-infra/lib/paths`); import the computed value here |
14+
| **B** — Cross-package path traversal | `path.join(PKG, '..', 'lief-builder', 'build', ...)` | Add `lief-builder: workspace:*` as a dep; import its `paths.mts` via the workspace `exports` field |
15+
16+
The hook fires on `Edit` and `Write` tool calls when the target path ends in `.mts` or `.cts`. Other extensions (`.ts`, `.mjs`, `.js`, `.yml`, `.json`, `.md`) pass through — TS path code lives in `.mts` per CLAUDE.md, and other file types are covered by the `scripts/check-paths.mts` gate at commit time.
17+
18+
## What it allows
19+
20+
- Edits to a `paths.mts` (canonical constructor — every package's source of truth).
21+
- Edits to `scripts/check-paths.mts` (the gate, which legitimately enumerates patterns).
22+
- Edits to this hook's own files (the test suite has to enumerate the same patterns).
23+
- Edits to `scripts/check-consistency.mts` (existing path-scanning gate).
24+
- `path.join` calls with a single stage segment (e.g. `path.join(packageRoot, 'build', 'temp')`) — that's a one-off helper path, not a multi-stage build output.
25+
- `path.join` calls with no stage segments at all (most general-purpose joins).
26+
- Any string concatenation that doesn't go through `path.join` — the hook is regex-based and intentionally narrow; the gate runs a deeper scan at commit time.
27+
28+
## Stage segments the hook recognizes
29+
30+
These come from `build-infra/lib/constants.mts` `BUILD_STAGES` plus the lowercase directory-name siblings used by some builders:
31+
32+
`Final`, `Release`, `Stripped`, `Compressed`, `Optimized`, `Synced`, `wasm`, `downloaded`
33+
34+
Two or more in the same `path.join` call (or one stage + one of `'build'`/`'out'` + one mode `'dev'`/`'prod'`) triggers Rule A.
35+
36+
## Known sibling packages (for Rule B)
37+
38+
The hook recognizes Rule B traversals only when the next segment after `..` is a known fleet package name:
39+
40+
`binflate`, `binject`, `binpress`, `bin-infra`, `build-infra`, `codet5-models-builder`, `curl-builder`, `iocraft-builder`, `ink-builder`, `libpq-builder`, `lief-builder`, `minilm-builder`, `models`, `napi-go`, `node-smol-builder`, `onnxruntime-builder`, `opentui-builder`, `stubs-builder`, `ultraviolet-builder`, `yoga-layout-builder`
41+
42+
When a new package joins the workspace, add it here.
43+
44+
## Control flow
45+
46+
The hook reads the tool-use payload from stdin, type-checks `tool_name === 'Edit'` or `'Write'`, filters to `.mts`/`.cts` files, and runs `check(source)`. Any rule violation `throw`s a typed `BlockError`; a single top-level `try/catch` in `main()` writes the block message to stderr and sets `process.exitCode = 2`.
47+
48+
Hook bugs fail **open** — a crash in the hook writes a log line and returns exit 0 so legitimate work isn't blocked on a bad deploy. The companion `scripts/check-paths.mts` gate runs a thorough whole-repo scan at `pnpm check` time, catching anything the hook misses.
49+
50+
## Testing
51+
52+
```bash
53+
pnpm --filter hook-path-guard test
54+
```
55+
56+
Adding a new detection pattern: update `STAGE_SEGMENTS` (or `KNOWN_SIBLING_PACKAGES`) in `index.mts`, add a positive and negative test in `test/path-guard.test.mts`.
57+
58+
## Updating across the fleet
59+
60+
This file is in `IDENTICAL_FILES` in `scripts/sync-scaffolding.mjs` (in `socket-repo-template`). After editing, run from `socket-repo-template`:
61+
62+
```bash
63+
node scripts/sync-scaffolding.mjs --all --fix
64+
```
65+
66+
to propagate the change to every fleet repo.

0 commit comments

Comments
 (0)