Skip to content

Commit 1e8016f

Browse files
committed
feat(path-guard): drift-resistant allowlist via snippet_hash + template literal detection
Sync from socket-repo-template@000943d. Hook + gate now flag template- literal build paths; allowlist replaces ±2 line tolerance with exact-line OR snippet_hash match. New --show-hashes flag prints SHA-256 prefix for allowlist entries that survive reformatting.
1 parent 6cbdc0a commit 1e8016f

5 files changed

Lines changed: 353 additions & 8 deletions

File tree

.claude/hooks/path-guard/index.mts

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -293,13 +293,58 @@ const checkRuleB = (calls: ReturnType<typeof extractPathCalls>): void => {
293293
}
294294
}
295295

296+
// Backtick template-literal detection. Path construction via
297+
// `${buildDir}/out/Final/${binary}` follows the same shape as
298+
// path.join() and constitutes the same Rule A violation. Placeholders
299+
// (${...}) are stripped to a sentinel that won't match any segment
300+
// set, so segments composed entirely of interpolation contribute
301+
// nothing to the trigger.
302+
const TEMPLATE_LITERAL_RE = /`((?:\\.|(?:\$\{(?:[^{}]|\{[^{}]*\})*\})|(?!`)[^\\])*)`/g
303+
304+
const checkRuleATemplate = (source: string): void => {
305+
TEMPLATE_LITERAL_RE.lastIndex = 0
306+
let m: RegExpExecArray | null
307+
while ((m = TEMPLATE_LITERAL_RE.exec(source)) !== null) {
308+
const body = m[1] ?? ''
309+
if (!body.includes('/')) {
310+
continue
311+
}
312+
const stripped = body.replace(/\$\{(?:[^{}]|\{[^{}]*\})*\}/g, '\x00')
313+
const segments = stripped
314+
.split('/')
315+
.filter(s => s.length > 0 && s !== '\x00')
316+
const stages = segments.filter(s => STAGE_SEGMENTS.has(s))
317+
const buildRoots = segments.filter(s => BUILD_ROOT_SEGMENTS.has(s))
318+
const modes = segments.filter(s => MODE_SEGMENTS.has(s))
319+
// Template literal trigger is tighter than path.join() because
320+
// backtick strings often appear in patch fixtures, error messages,
321+
// and other multi-line content that incidentally contains stage
322+
// tokens like `wasm`. Require the canonical build-output shape.
323+
const hasBuildAndOut =
324+
buildRoots.includes('build') && buildRoots.includes('out')
325+
const hasOut = buildRoots.includes('out')
326+
const hasBuild = buildRoots.includes('build')
327+
const triggers =
328+
(hasBuildAndOut && stages.length >= 1) ||
329+
(stages.length >= 2 && hasOut) ||
330+
(hasBuild && stages.length >= 1 && modes.length >= 1)
331+
if (triggers) {
332+
throw new BlockError(
333+
'A — multi-stage path constructed inline via template literal',
334+
'Construct this path in the owning `paths.mts` (or a build-infra helper) and import the computed value here. 1 path, 1 reference.',
335+
m[0],
336+
)
337+
}
338+
}
339+
}
340+
296341
const check = (source: string): void => {
297342
const calls = extractPathCalls(source)
298-
if (calls.length === 0) {
299-
return
343+
if (calls.length > 0) {
344+
checkRuleA(calls)
345+
checkRuleB(calls)
300346
}
301-
checkRuleA(calls)
302-
checkRuleB(calls)
347+
checkRuleATemplate(source)
303348
}
304349
305350
const emitBlock = (filePath: string, err: BlockError): void => {

.claude/hooks/path-guard/test/path-guard.test.mts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,60 @@ describe('path-guard — paren-balance correctness', () => {
190190
})
191191
})
192192

193+
describe('path-guard — template literals', () => {
194+
it('detects A in fully-literal template path', () => {
195+
const source = '\n const p = `build/dev/out/Final/binary`\n '
196+
const { code } = runHook(
197+
'Write',
198+
'packages/foo/scripts/build.mts',
199+
source,
200+
)
201+
assert.equal(code, 2)
202+
})
203+
204+
it('detects A in template with placeholders', () => {
205+
const source =
206+
'\n const p = `${PKG}/build/${mode}/${arch}/out/Final/${name}`\n '
207+
const { code } = runHook(
208+
'Write',
209+
'packages/foo/scripts/build.mts',
210+
source,
211+
)
212+
assert.equal(code, 2)
213+
})
214+
215+
it('allows template with single non-stage segment', () => {
216+
const source = '\n const url = `https://example.com/path`\n '
217+
const { code } = runHook(
218+
'Write',
219+
'packages/foo/scripts/build.mts',
220+
source,
221+
)
222+
assert.equal(code, 0)
223+
})
224+
225+
it('allows template with no stage segments', () => {
226+
const source = '\n const tmp = `${packageRoot}/build/temp/cache`\n '
227+
const { code } = runHook(
228+
'Write',
229+
'packages/foo/scripts/build.mts',
230+
source,
231+
)
232+
assert.equal(code, 0)
233+
})
234+
235+
it('allows template that is purely interpolation', () => {
236+
// `${a}/${b}/${c}` has no literal stage segments.
237+
const source = '\n const p = `${a}/${b}/${c}`\n '
238+
const { code } = runHook(
239+
'Write',
240+
'packages/foo/scripts/build.mts',
241+
source,
242+
)
243+
assert.equal(code, 0)
244+
})
245+
})
246+
193247
describe('path-guard — file-type filter', () => {
194248
it('skips .ts files', () => {
195249
const source = `

.claude/skills/path-guard/SKILL.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,18 @@ pnpm run check:paths --explain
199199

200200
Print the gate's findings without making any edits. Exit 0 if clean, 1 if findings present. Useful for CI / pre-merge inspection.
201201

202+
## Allowlisting a finding
203+
204+
When a genuine exemption is needed (rare — most "false positives" should be reported as gate bugs), add an entry to `.github/paths-allowlist.yml`. Two ways to pin the entry to a specific site:
205+
206+
- **`line:`** — exact line number. Strict; a single-line edit above shifts the entry off-target and the finding re-surfaces.
207+
- **`snippet_hash:`** — 12-char SHA-256 prefix of the offending snippet (whitespace-normalized). Drift-resistant: survives reformatting, but any content-changing edit invalidates it. Get the hash:
208+
```bash
209+
pnpm run check:paths --show-hashes
210+
```
211+
212+
Both may be set — either matching is sufficient. Prefer `snippet_hash` over raw `line:` when the exemption is expected to outlive routine reformatting; prefer `line:` when you specifically *want* the entry to fall off after any nearby edit.
213+
202214
## Mode: install (new repo)
203215

204216
When invoked as `/path-guard install` on a Socket repo that doesn't yet have the gate:

.claude/skills/path-guard/reference/check-paths.mts.tmpl

Lines changed: 119 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
* 2 — gate itself crashed
6868
*/
6969

70+
import { createHash } from 'node:crypto'
7071
import { existsSync, readFileSync, readdirSync } from 'node:fs'
7172
import path from 'node:path'
7273
import process from 'node:process'
@@ -179,6 +180,7 @@ const args = parseArgs({
179180
explain: { type: 'boolean', default: false },
180181
json: { type: 'boolean', default: false },
181182
quiet: { type: 'boolean', default: false },
183+
'show-hashes': { type: 'boolean', default: false },
182184
},
183185
strict: false,
184186
})
@@ -195,6 +197,7 @@ type AllowlistEntry = {
195197
pattern?: string
196198
rule?: string
197199
line?: number
200+
snippet_hash?: string
198201
reason: string
199202
}
200203

@@ -315,6 +318,36 @@ const unquote = (s: string): string => {
315318

316319
const ALLOWLIST = loadAllowlist()
317320

321+
/**
322+
* Stable, normalized snippet hash. Whitespace-insensitive so trivial
323+
* reformatting (indent change, trailing comma, line wrap) doesn't
324+
* invalidate an allowlist entry, but content-changing edits do. The
325+
* hash exposes only the first 12 hex chars (~48 bits) which is plenty
326+
* for collision-resistance within a single repo's finding set and
327+
* keeps the YAML readable.
328+
*/
329+
const snippetHash = (snippet: string): string => {
330+
const normalized = snippet.replace(/\s+/g, ' ').trim()
331+
return createHash('sha256').update(normalized).digest('hex').slice(0, 12)
332+
}
333+
334+
/**
335+
* Allowlist matching trades off two failure modes:
336+
*
337+
* - Drift via reformatting (a line shift breaks an entry, the
338+
* finding re-surfaces, devs paper over with a new entry).
339+
* - Stealth allowlisting (an entry pinned to "anywhere in this file"
340+
* silently exempts unrelated future violations).
341+
*
342+
* Strategy: exact line match OR `snippet_hash` match (whitespace-
343+
* normalized SHA-256, first 12 hex). Either is sufficient. Lines stay
344+
* exact (was ±2; the slack let reformatting silently slide), and
345+
* `snippet_hash` provides reformatting-tolerant matching that's still
346+
* tied to the literal text — paste-and-edit cheating would change the
347+
* hash. If neither `line` nor `snippet_hash` is provided, the entry
348+
* matches purely by `rule` + `file` + `pattern` (file-level exempt;
349+
* use sparingly and always pair with a precise `pattern`).
350+
*/
318351
const isAllowlisted = (finding: Finding): boolean =>
319352
ALLOWLIST.some(entry => {
320353
if (entry.rule && entry.rule !== finding.rule) {
@@ -326,8 +359,17 @@ const isAllowlisted = (finding: Finding): boolean =>
326359
if (entry.pattern && !finding.snippet.includes(entry.pattern)) {
327360
return false
328361
}
329-
if (entry.line !== undefined && Math.abs(entry.line - finding.line) > 2) {
330-
return false
362+
const lineProvided = entry.line !== undefined
363+
const hashProvided =
364+
typeof entry.snippet_hash === 'string' && entry.snippet_hash.length > 0
365+
if (lineProvided || hashProvided) {
366+
const lineMatches =
367+
lineProvided && entry.line === finding.line
368+
const hashMatches =
369+
hashProvided && entry.snippet_hash === snippetHash(finding.snippet)
370+
if (!(lineMatches || hashMatches)) {
371+
return false
372+
}
331373
}
332374
return true
333375
})
@@ -382,6 +424,27 @@ const walk = function* (
382424
const PATH_CALL_RE = /\bpath\.(?:join|resolve)\s*\(/g
383425
const STRING_LITERAL_RE = /(['"])((?:\\.|(?!\1)[^\\])*)\1/g
384426

427+
// Template literal scanner. Captures backtick-delimited strings
428+
// (including those with `${...}` placeholders) so Rule A also catches
429+
// path construction via template literals like
430+
// `${buildDir}/out/Final/${binary}` or `build/${mode}/out/Final`.
431+
const TEMPLATE_LITERAL_RE = /`((?:\\.|(?:\$\{(?:[^{}]|\{[^{}]*\})*\})|(?!`)[^\\])*)`/g
432+
433+
/**
434+
* Convert a template-literal body into a synthetic forward-slash path
435+
* by replacing `${...}` placeholders with a sentinel and normalizing
436+
* separators. Returns the sequence of path segments split on `/`. The
437+
* sentinel doesn't match any STAGE/BUILD_ROOT/MODE token, so a
438+
* placeholder-only segment (`${binaryName}`) won't match those sets.
439+
*/
440+
const templateLiteralSegments = (body: string): string[] => {
441+
// Strip placeholders so they don't introduce noise in segments.
442+
// Empty result for a placeholder is fine; downstream filters by set
443+
// membership and skips empties.
444+
const stripped = body.replace(/\$\{(?:[^{}]|\{[^{}]*\})*\}/g, '\x00')
445+
return stripped.split('/').filter(seg => seg.length > 0 && seg !== '\x00')
446+
}
447+
385448
/**
386449
* Extract every `path.join(...)` and `path.resolve(...)` call from the
387450
* source text, returning each call's literal start offset and argument
@@ -530,6 +593,54 @@ const scanCodeFile = (relPath: string): void => {
530593
}
531594
}
532595
}
596+
597+
// Rule A (template literal variant). Backtick strings like
598+
// `${buildDir}/out/Final/${binary}` or `build/${mode}/${arch}/out/Final`
599+
// construct paths the same way `path.join(...)` does — flag the
600+
// same shapes. Skip raw imports / template tag positions by
601+
// filtering out leading `import.meta.url`-style / tag positions
602+
// implicitly: TEMPLATE_LITERAL_RE matches any backtick string and
603+
// we rely on segment composition to decide if it's a path.
604+
TEMPLATE_LITERAL_RE.lastIndex = 0
605+
let tmpl: RegExpExecArray | null
606+
while ((tmpl = TEMPLATE_LITERAL_RE.exec(content)) !== null) {
607+
const body = tmpl[1] ?? ''
608+
if (!body.includes('/')) {
609+
continue
610+
}
611+
const segments = templateLiteralSegments(body)
612+
const stages = segments.filter(s => STAGE_SEGMENTS.has(s))
613+
const buildRoots = segments.filter(s => BUILD_ROOT_SEGMENTS.has(s))
614+
const modes = segments.filter(s => MODE_SEGMENTS.has(s))
615+
// Template literal trigger is tighter than path.join() because
616+
// backtick strings often appear in patch fixtures, error messages,
617+
// and other multi-line content that incidentally contains stage
618+
// tokens like `wasm`. Require the canonical build-output shape:
619+
// - 'build' + 'out' + stage (canonical multi-stage layout), OR
620+
// - 2+ stage segments AND 'out' (e.g. `wasm/out/Final`), OR
621+
// - 'build' + stage + literal mode (back-compat with path.join).
622+
const hasBuildAndOut =
623+
buildRoots.includes('build') && buildRoots.includes('out')
624+
const hasOut = buildRoots.includes('out')
625+
const hasBuild = buildRoots.includes('build')
626+
const triggersA =
627+
(hasBuildAndOut && stages.length >= 1) ||
628+
(stages.length >= 2 && hasOut) ||
629+
(hasBuild && stages.length >= 1 && modes.length >= 1)
630+
if (triggersA) {
631+
const line = offsetToLine(tmpl.index)
632+
const snippet = (lines[line - 1] ?? '').trim()
633+
findings.push({
634+
rule: 'A',
635+
file: relPath,
636+
line,
637+
snippet,
638+
message:
639+
'Multi-stage path constructed inline via template literal (outside paths.mts).',
640+
fix: 'Construct in the owning paths.mts (or use getFinalBinaryPath / getDownloadedDir from build-infra/lib/paths). Import the computed value here.',
641+
})
642+
}
643+
}
533644
}
534645

535646
// ──────────────────────────────────────────────────────────────────
@@ -853,6 +964,9 @@ const main = (): number => {
853964
logger.log(` [${f.rule}] ${f.file}:${f.line}`)
854965
logger.log(` ${f.snippet}`)
855966
logger.log(` → ${f.message}`)
967+
if (args.values['show-hashes']) {
968+
logger.log(` snippet_hash: ${snippetHash(f.snippet)}`)
969+
}
856970
if (args.values.explain) {
857971
logger.log(` Fix: ${f.fix}`)
858972
}
@@ -863,6 +977,9 @@ const main = (): number => {
863977
logger.log(
864978
'Add intentional exceptions to .github/paths-allowlist.yml with a `reason` field.',
865979
)
980+
logger.log(
981+
'Run with --show-hashes to print the snippet_hash for each finding (drift-resistant allowlisting).',
982+
)
866983
}
867984
return 1
868985
}

0 commit comments

Comments
 (0)