From 84dff67ada0a0683475816a340bc4d276068f2fe Mon Sep 17 00:00:00 2001 From: jdalton Date: Sun, 26 Apr 2026 17:01:56 -0400 Subject: [PATCH 1/4] fix(token-guard): word-boundary match for sensitive env names (sync from template@aeac8c1) --- .claude/hooks/token-guard/index.mts | 12 +++++++++++- .../token-guard/test/token-guard.test.mts | 19 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/.claude/hooks/token-guard/index.mts b/.claude/hooks/token-guard/index.mts index c49a731f7..ea54abf5e 100644 --- a/.claude/hooks/token-guard/index.mts +++ b/.claude/hooks/token-guard/index.mts @@ -120,9 +120,19 @@ type ToolInput = { const hasRedaction = (command: string): boolean => REDACTION_MARKERS.some(re => re.test(command)) +// Word-boundary match so `PASS` doesn't fire on `PATHS-ALLOWLIST` and +// `AUTH` doesn't fire on `AUTHOR`. Env-var-style boundaries treat `_` +// as a separator (so `ACCESS_TOKEN` matches `TOKEN`) but require a +// non-alphanumeric character on each end (so `PATHS` doesn't match +// `PASS`). The pre-fix substring match created false positives +// whenever a path name happened to contain a sensitive keyword as a +// literal substring. +const sensitiveEnvBoundaryRes = SENSITIVE_ENV_NAMES.map( + frag => new RegExp(String.raw`(?:^|[^A-Z0-9])${frag}(?:[^A-Z0-9]|$)`), +) const referencesSensitiveEnv = (command: string): boolean => { const upper = command.toUpperCase() - return SENSITIVE_ENV_NAMES.some(frag => upper.includes(frag)) + return sensitiveEnvBoundaryRes.some(re => re.test(upper)) } const matchesAlwaysDangerous = (command: string): RegExp | null => { diff --git a/.claude/hooks/token-guard/test/token-guard.test.mts b/.claude/hooks/token-guard/test/token-guard.test.mts index 1c296eb84..b2ab67147 100644 --- a/.claude/hooks/token-guard/test/token-guard.test.mts +++ b/.claude/hooks/token-guard/test/token-guard.test.mts @@ -182,6 +182,25 @@ describe('token-guard hook', () => { }) }) + describe('does not false-positive on substring of sensitive name', () => { + // Regression: `PATHS-ALLOWLIST.YML` toUpperCase()d contains `PASS` + // as a substring, which the pre-fix unbounded match treated as + // a sensitive env reference. Word-boundary fix means `PASS` must + // be a standalone token (or at a `_`/`-`/`.`/`/` boundary). + it('paths-allowlist.yml does not trip PASS', () => { + assert.equal(runHook('cat .github/paths-allowlist.yml').code, 0) + }) + it('AUTHOR_NAME does not trip AUTH', () => { + // AUTHOR ends with R; the boundary-after match correctly skips + // it because the next char is `_`, but `AUTH` followed by `O` + // (alphanumeric) is not a token boundary. + assert.equal(runHook('echo $AUTHOR_NAME').code, 0) + }) + it('PASSAGE_TIME does not trip PASS', () => { + assert.equal(runHook('echo $PASSAGE_TIME').code, 0) + }) + }) + describe('fails open on malformed input', () => { it('empty stdin', () => { const r = spawnSync(nodeBin, [hookScript], { From 4a458fead51f739f70bf7a7cb6f72dd9be7cad1c Mon Sep 17 00:00:00 2001 From: jdalton Date: Sun, 26 Apr 2026 17:20:15 -0400 Subject: [PATCH 2/4] feat(path-guard): drift-resistant allowlist via snippet_hash + template literal detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sync from 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. --- .claude/hooks/path-guard/index.mts | 53 +++++++- .../hooks/path-guard/test/path-guard.test.mts | 54 ++++++++ .claude/skills/path-guard/SKILL.md | 12 ++ .../path-guard/reference/check-paths.mts.tmpl | 121 +++++++++++++++++- scripts/check-paths.mts | 121 +++++++++++++++++- 5 files changed, 353 insertions(+), 8 deletions(-) diff --git a/.claude/hooks/path-guard/index.mts b/.claude/hooks/path-guard/index.mts index 9e7e99df1..f389101de 100644 --- a/.claude/hooks/path-guard/index.mts +++ b/.claude/hooks/path-guard/index.mts @@ -293,13 +293,58 @@ const checkRuleB = (calls: ReturnType): void => { } } +// Backtick template-literal detection. Path construction via +// `${buildDir}/out/Final/${binary}` follows the same shape as +// path.join() and constitutes the same Rule A violation. Placeholders +// (${...}) are stripped to a sentinel that won't match any segment +// set, so segments composed entirely of interpolation contribute +// nothing to the trigger. +const TEMPLATE_LITERAL_RE = /`((?:\\.|(?:\$\{(?:[^{}]|\{[^{}]*\})*\})|(?!`)[^\\])*)`/g + +const checkRuleATemplate = (source: string): void => { + TEMPLATE_LITERAL_RE.lastIndex = 0 + let m: RegExpExecArray | null + while ((m = TEMPLATE_LITERAL_RE.exec(source)) !== null) { + const body = m[1] ?? '' + if (!body.includes('/')) { + continue + } + const stripped = body.replace(/\$\{(?:[^{}]|\{[^{}]*\})*\}/g, '\x00') + const segments = stripped + .split('/') + .filter(s => s.length > 0 && s !== '\x00') + const stages = segments.filter(s => STAGE_SEGMENTS.has(s)) + const buildRoots = segments.filter(s => BUILD_ROOT_SEGMENTS.has(s)) + const modes = segments.filter(s => MODE_SEGMENTS.has(s)) + // Template literal trigger is tighter than path.join() because + // backtick strings often appear in patch fixtures, error messages, + // and other multi-line content that incidentally contains stage + // tokens like `wasm`. Require the canonical build-output shape. + const hasBuildAndOut = + buildRoots.includes('build') && buildRoots.includes('out') + const hasOut = buildRoots.includes('out') + const hasBuild = buildRoots.includes('build') + const triggers = + (hasBuildAndOut && stages.length >= 1) || + (stages.length >= 2 && hasOut) || + (hasBuild && stages.length >= 1 && modes.length >= 1) + if (triggers) { + throw new BlockError( + 'A — multi-stage path constructed inline via template literal', + 'Construct this path in the owning `paths.mts` (or a build-infra helper) and import the computed value here. 1 path, 1 reference.', + m[0], + ) + } + } +} + const check = (source: string): void => { const calls = extractPathCalls(source) - if (calls.length === 0) { - return + if (calls.length > 0) { + checkRuleA(calls) + checkRuleB(calls) } - checkRuleA(calls) - checkRuleB(calls) + checkRuleATemplate(source) } const emitBlock = (filePath: string, err: BlockError): void => { diff --git a/.claude/hooks/path-guard/test/path-guard.test.mts b/.claude/hooks/path-guard/test/path-guard.test.mts index 2911ce42f..46c0ae1ec 100644 --- a/.claude/hooks/path-guard/test/path-guard.test.mts +++ b/.claude/hooks/path-guard/test/path-guard.test.mts @@ -190,6 +190,60 @@ describe('path-guard — paren-balance correctness', () => { }) }) +describe('path-guard — template literals', () => { + it('detects A in fully-literal template path', () => { + const source = '\n const p = `build/dev/out/Final/binary`\n ' + const { code } = runHook( + 'Write', + 'packages/foo/scripts/build.mts', + source, + ) + assert.equal(code, 2) + }) + + it('detects A in template with placeholders', () => { + const source = + '\n const p = `${PKG}/build/${mode}/${arch}/out/Final/${name}`\n ' + const { code } = runHook( + 'Write', + 'packages/foo/scripts/build.mts', + source, + ) + assert.equal(code, 2) + }) + + it('allows template with single non-stage segment', () => { + const source = '\n const url = `https://example.com/path`\n ' + const { code } = runHook( + 'Write', + 'packages/foo/scripts/build.mts', + source, + ) + assert.equal(code, 0) + }) + + it('allows template with no stage segments', () => { + const source = '\n const tmp = `${packageRoot}/build/temp/cache`\n ' + const { code } = runHook( + 'Write', + 'packages/foo/scripts/build.mts', + source, + ) + assert.equal(code, 0) + }) + + it('allows template that is purely interpolation', () => { + // `${a}/${b}/${c}` has no literal stage segments. + const source = '\n const p = `${a}/${b}/${c}`\n ' + const { code } = runHook( + 'Write', + 'packages/foo/scripts/build.mts', + source, + ) + assert.equal(code, 0) + }) +}) + describe('path-guard — file-type filter', () => { it('skips .ts files', () => { const source = ` diff --git a/.claude/skills/path-guard/SKILL.md b/.claude/skills/path-guard/SKILL.md index f58382518..11d0e5ba7 100644 --- a/.claude/skills/path-guard/SKILL.md +++ b/.claude/skills/path-guard/SKILL.md @@ -199,6 +199,18 @@ pnpm run check:paths --explain Print the gate's findings without making any edits. Exit 0 if clean, 1 if findings present. Useful for CI / pre-merge inspection. +## Allowlisting a finding + +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: + +- **`line:`** — exact line number. Strict; a single-line edit above shifts the entry off-target and the finding re-surfaces. +- **`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: + ```bash + pnpm run check:paths --show-hashes + ``` + +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. + ## Mode: install (new repo) When invoked as `/path-guard install` on a Socket repo that doesn't yet have the gate: diff --git a/.claude/skills/path-guard/reference/check-paths.mts.tmpl b/.claude/skills/path-guard/reference/check-paths.mts.tmpl index 41e9b2ac7..d96c89f91 100644 --- a/.claude/skills/path-guard/reference/check-paths.mts.tmpl +++ b/.claude/skills/path-guard/reference/check-paths.mts.tmpl @@ -67,6 +67,7 @@ * 2 — gate itself crashed */ +import { createHash } from 'node:crypto' import { existsSync, readFileSync, readdirSync } from 'node:fs' import path from 'node:path' import process from 'node:process' @@ -179,6 +180,7 @@ const args = parseArgs({ explain: { type: 'boolean', default: false }, json: { type: 'boolean', default: false }, quiet: { type: 'boolean', default: false }, + 'show-hashes': { type: 'boolean', default: false }, }, strict: false, }) @@ -195,6 +197,7 @@ type AllowlistEntry = { pattern?: string rule?: string line?: number + snippet_hash?: string reason: string } @@ -315,6 +318,36 @@ const unquote = (s: string): string => { const ALLOWLIST = loadAllowlist() +/** + * Stable, normalized snippet hash. Whitespace-insensitive so trivial + * reformatting (indent change, trailing comma, line wrap) doesn't + * invalidate an allowlist entry, but content-changing edits do. The + * hash exposes only the first 12 hex chars (~48 bits) which is plenty + * for collision-resistance within a single repo's finding set and + * keeps the YAML readable. + */ +const snippetHash = (snippet: string): string => { + const normalized = snippet.replace(/\s+/g, ' ').trim() + return createHash('sha256').update(normalized).digest('hex').slice(0, 12) +} + +/** + * Allowlist matching trades off two failure modes: + * + * - Drift via reformatting (a line shift breaks an entry, the + * finding re-surfaces, devs paper over with a new entry). + * - Stealth allowlisting (an entry pinned to "anywhere in this file" + * silently exempts unrelated future violations). + * + * Strategy: exact line match OR `snippet_hash` match (whitespace- + * normalized SHA-256, first 12 hex). Either is sufficient. Lines stay + * exact (was ±2; the slack let reformatting silently slide), and + * `snippet_hash` provides reformatting-tolerant matching that's still + * tied to the literal text — paste-and-edit cheating would change the + * hash. If neither `line` nor `snippet_hash` is provided, the entry + * matches purely by `rule` + `file` + `pattern` (file-level exempt; + * use sparingly and always pair with a precise `pattern`). + */ const isAllowlisted = (finding: Finding): boolean => ALLOWLIST.some(entry => { if (entry.rule && entry.rule !== finding.rule) { @@ -326,8 +359,17 @@ const isAllowlisted = (finding: Finding): boolean => if (entry.pattern && !finding.snippet.includes(entry.pattern)) { return false } - if (entry.line !== undefined && Math.abs(entry.line - finding.line) > 2) { - return false + const lineProvided = entry.line !== undefined + const hashProvided = + typeof entry.snippet_hash === 'string' && entry.snippet_hash.length > 0 + if (lineProvided || hashProvided) { + const lineMatches = + lineProvided && entry.line === finding.line + const hashMatches = + hashProvided && entry.snippet_hash === snippetHash(finding.snippet) + if (!(lineMatches || hashMatches)) { + return false + } } return true }) @@ -382,6 +424,27 @@ const walk = function* ( const PATH_CALL_RE = /\bpath\.(?:join|resolve)\s*\(/g const STRING_LITERAL_RE = /(['"])((?:\\.|(?!\1)[^\\])*)\1/g +// Template literal scanner. Captures backtick-delimited strings +// (including those with `${...}` placeholders) so Rule A also catches +// path construction via template literals like +// `${buildDir}/out/Final/${binary}` or `build/${mode}/out/Final`. +const TEMPLATE_LITERAL_RE = /`((?:\\.|(?:\$\{(?:[^{}]|\{[^{}]*\})*\})|(?!`)[^\\])*)`/g + +/** + * Convert a template-literal body into a synthetic forward-slash path + * by replacing `${...}` placeholders with a sentinel and normalizing + * separators. Returns the sequence of path segments split on `/`. The + * sentinel doesn't match any STAGE/BUILD_ROOT/MODE token, so a + * placeholder-only segment (`${binaryName}`) won't match those sets. + */ +const templateLiteralSegments = (body: string): string[] => { + // Strip placeholders so they don't introduce noise in segments. + // Empty result for a placeholder is fine; downstream filters by set + // membership and skips empties. + const stripped = body.replace(/\$\{(?:[^{}]|\{[^{}]*\})*\}/g, '\x00') + return stripped.split('/').filter(seg => seg.length > 0 && seg !== '\x00') +} + /** * Extract every `path.join(...)` and `path.resolve(...)` call from the * source text, returning each call's literal start offset and argument @@ -530,6 +593,54 @@ const scanCodeFile = (relPath: string): void => { } } } + + // Rule A (template literal variant). Backtick strings like + // `${buildDir}/out/Final/${binary}` or `build/${mode}/${arch}/out/Final` + // construct paths the same way `path.join(...)` does — flag the + // same shapes. Skip raw imports / template tag positions by + // filtering out leading `import.meta.url`-style / tag positions + // implicitly: TEMPLATE_LITERAL_RE matches any backtick string and + // we rely on segment composition to decide if it's a path. + TEMPLATE_LITERAL_RE.lastIndex = 0 + let tmpl: RegExpExecArray | null + while ((tmpl = TEMPLATE_LITERAL_RE.exec(content)) !== null) { + const body = tmpl[1] ?? '' + if (!body.includes('/')) { + continue + } + const segments = templateLiteralSegments(body) + const stages = segments.filter(s => STAGE_SEGMENTS.has(s)) + const buildRoots = segments.filter(s => BUILD_ROOT_SEGMENTS.has(s)) + const modes = segments.filter(s => MODE_SEGMENTS.has(s)) + // Template literal trigger is tighter than path.join() because + // backtick strings often appear in patch fixtures, error messages, + // and other multi-line content that incidentally contains stage + // tokens like `wasm`. Require the canonical build-output shape: + // - 'build' + 'out' + stage (canonical multi-stage layout), OR + // - 2+ stage segments AND 'out' (e.g. `wasm/out/Final`), OR + // - 'build' + stage + literal mode (back-compat with path.join). + const hasBuildAndOut = + buildRoots.includes('build') && buildRoots.includes('out') + const hasOut = buildRoots.includes('out') + const hasBuild = buildRoots.includes('build') + const triggersA = + (hasBuildAndOut && stages.length >= 1) || + (stages.length >= 2 && hasOut) || + (hasBuild && stages.length >= 1 && modes.length >= 1) + if (triggersA) { + const line = offsetToLine(tmpl.index) + const snippet = (lines[line - 1] ?? '').trim() + findings.push({ + rule: 'A', + file: relPath, + line, + snippet, + message: + 'Multi-stage path constructed inline via template literal (outside paths.mts).', + fix: 'Construct in the owning paths.mts (or use getFinalBinaryPath / getDownloadedDir from build-infra/lib/paths). Import the computed value here.', + }) + } + } } // ────────────────────────────────────────────────────────────────── @@ -853,6 +964,9 @@ const main = (): number => { logger.log(` [${f.rule}] ${f.file}:${f.line}`) logger.log(` ${f.snippet}`) logger.log(` → ${f.message}`) + if (args.values['show-hashes']) { + logger.log(` snippet_hash: ${snippetHash(f.snippet)}`) + } if (args.values.explain) { logger.log(` Fix: ${f.fix}`) } @@ -863,6 +977,9 @@ const main = (): number => { logger.log( 'Add intentional exceptions to .github/paths-allowlist.yml with a `reason` field.', ) + logger.log( + 'Run with --show-hashes to print the snippet_hash for each finding (drift-resistant allowlisting).', + ) } return 1 } diff --git a/scripts/check-paths.mts b/scripts/check-paths.mts index 41e9b2ac7..d96c89f91 100644 --- a/scripts/check-paths.mts +++ b/scripts/check-paths.mts @@ -67,6 +67,7 @@ * 2 — gate itself crashed */ +import { createHash } from 'node:crypto' import { existsSync, readFileSync, readdirSync } from 'node:fs' import path from 'node:path' import process from 'node:process' @@ -179,6 +180,7 @@ const args = parseArgs({ explain: { type: 'boolean', default: false }, json: { type: 'boolean', default: false }, quiet: { type: 'boolean', default: false }, + 'show-hashes': { type: 'boolean', default: false }, }, strict: false, }) @@ -195,6 +197,7 @@ type AllowlistEntry = { pattern?: string rule?: string line?: number + snippet_hash?: string reason: string } @@ -315,6 +318,36 @@ const unquote = (s: string): string => { const ALLOWLIST = loadAllowlist() +/** + * Stable, normalized snippet hash. Whitespace-insensitive so trivial + * reformatting (indent change, trailing comma, line wrap) doesn't + * invalidate an allowlist entry, but content-changing edits do. The + * hash exposes only the first 12 hex chars (~48 bits) which is plenty + * for collision-resistance within a single repo's finding set and + * keeps the YAML readable. + */ +const snippetHash = (snippet: string): string => { + const normalized = snippet.replace(/\s+/g, ' ').trim() + return createHash('sha256').update(normalized).digest('hex').slice(0, 12) +} + +/** + * Allowlist matching trades off two failure modes: + * + * - Drift via reformatting (a line shift breaks an entry, the + * finding re-surfaces, devs paper over with a new entry). + * - Stealth allowlisting (an entry pinned to "anywhere in this file" + * silently exempts unrelated future violations). + * + * Strategy: exact line match OR `snippet_hash` match (whitespace- + * normalized SHA-256, first 12 hex). Either is sufficient. Lines stay + * exact (was ±2; the slack let reformatting silently slide), and + * `snippet_hash` provides reformatting-tolerant matching that's still + * tied to the literal text — paste-and-edit cheating would change the + * hash. If neither `line` nor `snippet_hash` is provided, the entry + * matches purely by `rule` + `file` + `pattern` (file-level exempt; + * use sparingly and always pair with a precise `pattern`). + */ const isAllowlisted = (finding: Finding): boolean => ALLOWLIST.some(entry => { if (entry.rule && entry.rule !== finding.rule) { @@ -326,8 +359,17 @@ const isAllowlisted = (finding: Finding): boolean => if (entry.pattern && !finding.snippet.includes(entry.pattern)) { return false } - if (entry.line !== undefined && Math.abs(entry.line - finding.line) > 2) { - return false + const lineProvided = entry.line !== undefined + const hashProvided = + typeof entry.snippet_hash === 'string' && entry.snippet_hash.length > 0 + if (lineProvided || hashProvided) { + const lineMatches = + lineProvided && entry.line === finding.line + const hashMatches = + hashProvided && entry.snippet_hash === snippetHash(finding.snippet) + if (!(lineMatches || hashMatches)) { + return false + } } return true }) @@ -382,6 +424,27 @@ const walk = function* ( const PATH_CALL_RE = /\bpath\.(?:join|resolve)\s*\(/g const STRING_LITERAL_RE = /(['"])((?:\\.|(?!\1)[^\\])*)\1/g +// Template literal scanner. Captures backtick-delimited strings +// (including those with `${...}` placeholders) so Rule A also catches +// path construction via template literals like +// `${buildDir}/out/Final/${binary}` or `build/${mode}/out/Final`. +const TEMPLATE_LITERAL_RE = /`((?:\\.|(?:\$\{(?:[^{}]|\{[^{}]*\})*\})|(?!`)[^\\])*)`/g + +/** + * Convert a template-literal body into a synthetic forward-slash path + * by replacing `${...}` placeholders with a sentinel and normalizing + * separators. Returns the sequence of path segments split on `/`. The + * sentinel doesn't match any STAGE/BUILD_ROOT/MODE token, so a + * placeholder-only segment (`${binaryName}`) won't match those sets. + */ +const templateLiteralSegments = (body: string): string[] => { + // Strip placeholders so they don't introduce noise in segments. + // Empty result for a placeholder is fine; downstream filters by set + // membership and skips empties. + const stripped = body.replace(/\$\{(?:[^{}]|\{[^{}]*\})*\}/g, '\x00') + return stripped.split('/').filter(seg => seg.length > 0 && seg !== '\x00') +} + /** * Extract every `path.join(...)` and `path.resolve(...)` call from the * source text, returning each call's literal start offset and argument @@ -530,6 +593,54 @@ const scanCodeFile = (relPath: string): void => { } } } + + // Rule A (template literal variant). Backtick strings like + // `${buildDir}/out/Final/${binary}` or `build/${mode}/${arch}/out/Final` + // construct paths the same way `path.join(...)` does — flag the + // same shapes. Skip raw imports / template tag positions by + // filtering out leading `import.meta.url`-style / tag positions + // implicitly: TEMPLATE_LITERAL_RE matches any backtick string and + // we rely on segment composition to decide if it's a path. + TEMPLATE_LITERAL_RE.lastIndex = 0 + let tmpl: RegExpExecArray | null + while ((tmpl = TEMPLATE_LITERAL_RE.exec(content)) !== null) { + const body = tmpl[1] ?? '' + if (!body.includes('/')) { + continue + } + const segments = templateLiteralSegments(body) + const stages = segments.filter(s => STAGE_SEGMENTS.has(s)) + const buildRoots = segments.filter(s => BUILD_ROOT_SEGMENTS.has(s)) + const modes = segments.filter(s => MODE_SEGMENTS.has(s)) + // Template literal trigger is tighter than path.join() because + // backtick strings often appear in patch fixtures, error messages, + // and other multi-line content that incidentally contains stage + // tokens like `wasm`. Require the canonical build-output shape: + // - 'build' + 'out' + stage (canonical multi-stage layout), OR + // - 2+ stage segments AND 'out' (e.g. `wasm/out/Final`), OR + // - 'build' + stage + literal mode (back-compat with path.join). + const hasBuildAndOut = + buildRoots.includes('build') && buildRoots.includes('out') + const hasOut = buildRoots.includes('out') + const hasBuild = buildRoots.includes('build') + const triggersA = + (hasBuildAndOut && stages.length >= 1) || + (stages.length >= 2 && hasOut) || + (hasBuild && stages.length >= 1 && modes.length >= 1) + if (triggersA) { + const line = offsetToLine(tmpl.index) + const snippet = (lines[line - 1] ?? '').trim() + findings.push({ + rule: 'A', + file: relPath, + line, + snippet, + message: + 'Multi-stage path constructed inline via template literal (outside paths.mts).', + fix: 'Construct in the owning paths.mts (or use getFinalBinaryPath / getDownloadedDir from build-infra/lib/paths). Import the computed value here.', + }) + } + } } // ────────────────────────────────────────────────────────────────── @@ -853,6 +964,9 @@ const main = (): number => { logger.log(` [${f.rule}] ${f.file}:${f.line}`) logger.log(` ${f.snippet}`) logger.log(` → ${f.message}`) + if (args.values['show-hashes']) { + logger.log(` snippet_hash: ${snippetHash(f.snippet)}`) + } if (args.values.explain) { logger.log(` Fix: ${f.fix}`) } @@ -863,6 +977,9 @@ const main = (): number => { logger.log( 'Add intentional exceptions to .github/paths-allowlist.yml with a `reason` field.', ) + logger.log( + 'Run with --show-hashes to print the snippet_hash for each finding (drift-resistant allowlisting).', + ) } return 1 } From 75fe4aca818e2586cb3fd1bab1ea3bed3ca7ae96 Mon Sep 17 00:00:00 2001 From: jdalton Date: Sun, 26 Apr 2026 17:32:59 -0400 Subject: [PATCH 3/4] refactor(path-guard): centralize stage / sibling vocabulary into segments.mts Sync from socket-repo-template@bb21ab5. Mantra: 1 path, 1 reference. Hook and gate now both import STAGE_SEGMENTS, BUILD_ROOT_SEGMENTS, MODE_SEGMENTS, and KNOWN_SIBLING_PACKAGES from a single canonical .claude/hooks/path-guard/segments.mts so they can no longer drift on what counts as a build-output path. --- .claude/hooks/path-guard/index.mts | 77 ++---------------- .claude/hooks/path-guard/segments.mts | 80 +++++++++++++++++++ .../path-guard/reference/check-paths.mts.tmpl | 68 +++------------- scripts/check-paths.mts | 68 +++------------- 4 files changed, 108 insertions(+), 185 deletions(-) create mode 100644 .claude/hooks/path-guard/segments.mts diff --git a/.claude/hooks/path-guard/index.mts b/.claude/hooks/path-guard/index.mts index f389101de..ced9fcfc1 100644 --- a/.claude/hooks/path-guard/index.mts +++ b/.claude/hooks/path-guard/index.mts @@ -56,77 +56,12 @@ import process from 'node:process' -// "Stage" segments — appearing two or more in the same path.join / -// template literal is a Rule A violation. These come from -// build-infra/lib/constants.mts BUILD_STAGES plus their lowercase -// directory-name siblings used by some builders (yoga's `wasm/`, -// build-infra's `downloaded/`). -const STAGE_SEGMENTS = new Set([ - 'Final', - 'Release', - 'Stripped', - 'Compressed', - 'Optimized', - 'Synced', - 'wasm', - 'downloaded', -]) - -// "Build-root" segments — at least one must be present together with a -// stage segment to confirm we're constructing a build output path -// rather than something coincidental. Example: `path.join(SRC, -// 'wasm', 'lib')` shouldn't fire (no build root); `path.join(PKG, -// 'build', 'wasm', 'out', 'Final')` should (build root + wasm + out + -// Final). -const BUILD_ROOT_SEGMENTS = new Set(['build', 'out']) - -// Mode segments — appearing alongside stage + build-root tightens the -// match further. `'dev'` and `'prod'` alone are too generic; we count -// them as a confirming signal, not a trigger. -const MODE_SEGMENTS = new Set(['dev', 'prod', 'shared']) - -// Sibling Socket-fleet packages whose build output is reached via -// `path.join(*, '..', '', 'build', ...)`. Union of all packages -// across the Socket fleet — the hook is byte-identical via -// sync-scaffolding, so listing every fleet package keeps Rule B firing -// in any repo. When a new package joins the workspace, add it here -// and propagate via `node scripts/sync-scaffolding.mjs --all --fix` -// from socket-repo-template. -const KNOWN_SIBLING_PACKAGES = new Set([ - // socket-btm - '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', - // socket-cli - 'cli', - 'package-builder', - // socket-tui - 'core', - 'react', - 'renderer', - 'ultraviolet', - 'yoga', - // socket-registry / ultrathink - 'acorn', - 'npm', -]) +import { + BUILD_ROOT_SEGMENTS, + KNOWN_SIBLING_PACKAGES, + MODE_SEGMENTS, + STAGE_SEGMENTS, +} from './segments.mts' // File-path patterns that are exempt from the hook entirely. Edits to // these files legitimately need to enumerate path segments. diff --git a/.claude/hooks/path-guard/segments.mts b/.claude/hooks/path-guard/segments.mts new file mode 100644 index 000000000..891d0b8b7 --- /dev/null +++ b/.claude/hooks/path-guard/segments.mts @@ -0,0 +1,80 @@ +// Canonical path-segment vocabulary shared by the path-guard hook +// (.claude/hooks/path-guard/index.mts) and gate (scripts/check-paths.mts). +// +// Mantra: 1 path, 1 reference. This module is the *one* place stage, +// build-root, mode, and sibling-package vocabulary is defined. Both +// consumers import from here so they can never drift apart. +// +// Synced byte-identically across the Socket fleet via +// socket-repo-template/scripts/sync-scaffolding.mjs (IDENTICAL_FILES). +// When adding a new stage/build-root/mode/sibling, edit this file in +// the template and re-sync. + +// "Stage" segments — Rule A core. Two of these spread via `path.join` +// or interpolated into a template literal is a finding outside a +// canonical `paths.mts`. Sourced from build-infra/lib/constants.mts +// `BUILD_STAGES` plus their lowercase directory-name siblings used by +// some builders. +export const STAGE_SEGMENTS = new Set([ + 'Compressed', + 'downloaded', + 'Final', + 'Optimized', + 'Release', + 'Stripped', + 'Synced', + 'wasm', +]) + +// "Build-root" segments — at least one must be present together with +// a stage segment to confirm we're constructing a build output path +// rather than something coincidental. Example: a join that yields +// `//` doesn't fire if no build-root segment is +// present; `/build//out/` does. +export const BUILD_ROOT_SEGMENTS = new Set(['build', 'out']) + +// Build-mode segments — a stage segment plus one of these is also a +// finding (`build///out/` is the canonical shape). +export const MODE_SEGMENTS = new Set(['dev', 'prod', 'shared']) + +// Sibling fleet packages (Rule B). Union of all packages across the +// Socket fleet — the gate is byte-identical via sync-scaffolding, so +// listing every fleet package keeps Rule B firing in any repo. When a +// new package joins the workspace, add it here and propagate via +// `node scripts/sync-scaffolding.mjs --all --fix` from +// socket-repo-template. +export const KNOWN_SIBLING_PACKAGES = new Set([ + // socket-btm + 'bin-infra', + 'binflate', + 'binject', + 'binpress', + 'build-infra', + 'codet5-models-builder', + 'curl-builder', + 'ink-builder', + 'iocraft-builder', + 'libpq-builder', + 'lief-builder', + 'minilm-builder', + 'models', + 'napi-go', + 'node-smol-builder', + 'onnxruntime-builder', + 'opentui-builder', + 'stubs-builder', + 'ultraviolet-builder', + 'yoga-layout-builder', + // socket-cli + 'cli', + 'package-builder', + // socket-tui + 'core', + 'react', + 'renderer', + 'ultraviolet', + 'yoga', + // socket-registry / ultrathink + 'acorn', + 'npm', +]) diff --git a/.claude/skills/path-guard/reference/check-paths.mts.tmpl b/.claude/skills/path-guard/reference/check-paths.mts.tmpl index d96c89f91..023b6ce14 100644 --- a/.claude/skills/path-guard/reference/check-paths.mts.tmpl +++ b/.claude/skills/path-guard/reference/check-paths.mts.tmpl @@ -76,6 +76,13 @@ import { fileURLToPath } from 'node:url' import { parseArgs } from 'node:util' +import { + BUILD_ROOT_SEGMENTS, + KNOWN_SIBLING_PACKAGES, + MODE_SEGMENTS, + STAGE_SEGMENTS, +} from '../.claude/hooks/path-guard/segments.mts' + // Plain stderr/stdout output — no @socketsecurity/lib dependency so // the gate is self-contained and works in socket-lib itself (which // would otherwise import itself). @@ -91,63 +98,10 @@ const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) const REPO_ROOT = path.resolve(__dirname, '..') -// Stage segments (Rule A core). Spreading any two of these via -// `path.join` is a finding outside `paths.mts`. -const STAGE_SEGMENTS = new Set([ - 'Final', - 'Release', - 'Stripped', - 'Compressed', - 'Optimized', - 'Synced', - 'wasm', - 'downloaded', -]) - -const BUILD_ROOT_SEGMENTS = new Set(['build', 'out']) -const MODE_SEGMENTS = new Set(['dev', 'prod', 'shared']) - -// Sibling fleet packages (Rule B). Union of all packages across the -// Socket fleet — the gate is byte-identical via sync-scaffolding, so -// listing every fleet package keeps Rule B firing in any repo. When -// a new package joins the workspace, add it here and propagate via -// `node scripts/sync-scaffolding.mjs --all --fix` from -// socket-repo-template. -const KNOWN_SIBLING_PACKAGES = new Set([ - // socket-btm - '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', - // socket-cli - 'cli', - 'package-builder', - // socket-tui - 'core', - 'react', - 'renderer', - 'ultraviolet', - 'yoga', - // socket-registry / ultrathink - 'acorn', - 'npm', -]) +// Stage / build-root / mode / sibling-package vocabularies are imported +// from `.claude/hooks/path-guard/segments.mts` (the canonical source). +// Both this gate and the path-guard hook share that single definition +// — Mantra: 1 path, 1 reference. // File-path patterns that legitimately enumerate path segments. const EXEMPT_FILE_PATTERNS: RegExp[] = [ diff --git a/scripts/check-paths.mts b/scripts/check-paths.mts index d96c89f91..023b6ce14 100644 --- a/scripts/check-paths.mts +++ b/scripts/check-paths.mts @@ -76,6 +76,13 @@ import { fileURLToPath } from 'node:url' import { parseArgs } from 'node:util' +import { + BUILD_ROOT_SEGMENTS, + KNOWN_SIBLING_PACKAGES, + MODE_SEGMENTS, + STAGE_SEGMENTS, +} from '../.claude/hooks/path-guard/segments.mts' + // Plain stderr/stdout output — no @socketsecurity/lib dependency so // the gate is self-contained and works in socket-lib itself (which // would otherwise import itself). @@ -91,63 +98,10 @@ const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) const REPO_ROOT = path.resolve(__dirname, '..') -// Stage segments (Rule A core). Spreading any two of these via -// `path.join` is a finding outside `paths.mts`. -const STAGE_SEGMENTS = new Set([ - 'Final', - 'Release', - 'Stripped', - 'Compressed', - 'Optimized', - 'Synced', - 'wasm', - 'downloaded', -]) - -const BUILD_ROOT_SEGMENTS = new Set(['build', 'out']) -const MODE_SEGMENTS = new Set(['dev', 'prod', 'shared']) - -// Sibling fleet packages (Rule B). Union of all packages across the -// Socket fleet — the gate is byte-identical via sync-scaffolding, so -// listing every fleet package keeps Rule B firing in any repo. When -// a new package joins the workspace, add it here and propagate via -// `node scripts/sync-scaffolding.mjs --all --fix` from -// socket-repo-template. -const KNOWN_SIBLING_PACKAGES = new Set([ - // socket-btm - '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', - // socket-cli - 'cli', - 'package-builder', - // socket-tui - 'core', - 'react', - 'renderer', - 'ultraviolet', - 'yoga', - // socket-registry / ultrathink - 'acorn', - 'npm', -]) +// Stage / build-root / mode / sibling-package vocabularies are imported +// from `.claude/hooks/path-guard/segments.mts` (the canonical source). +// Both this gate and the path-guard hook share that single definition +// — Mantra: 1 path, 1 reference. // File-path patterns that legitimately enumerate path segments. const EXEMPT_FILE_PATTERNS: RegExp[] = [ From 4087a751db02066fa9ab655b4a6b99d54e20ba99 Mon Sep 17 00:00:00 2001 From: jdalton Date: Sun, 26 Apr 2026 17:46:13 -0400 Subject: [PATCH 4/4] chore(fmt): apply oxfmt across repo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-existing format drift in 7 files — collapsed multi-line array/arg literals where oxfmt would have inlined them. No semantic changes. Surfaced by the pre-commit hook running `oxfmt --check .` against the whole repo. --- CLAUDE.md | 6 +++--- scripts/check-paths.mts | 9 +++++---- tools/prim/README.md | 8 ++++---- tools/prim/src/cli.mts | 11 ++++++----- tools/prim/src/lint.mts | 4 +--- tools/prim/src/surface.mts | 10 ++++++---- xport.schema.json | 29 ++++++----------------------- 7 files changed, 31 insertions(+), 46 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5619d8fd5..548acc221 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -221,14 +221,14 @@ Core infrastructure library for Socket.dev security tools. ### 1 path, 1 reference -**A path is *constructed* exactly once. Everywhere else *references* the constructed value.** +**A path is _constructed_ exactly once. Everywhere else _references_ the constructed value.** -Referencing a single computed path many times is fine — that's the whole point of computing it once. What's banned is *re-constructing* the same path in multiple places, because that's where drift is born. +Referencing a single computed path many times is fine — that's the whole point of computing it once. What's banned is _re-constructing_ the same path in multiple places, because that's where drift is born. - **Within a package**: every script imports its own `scripts/paths.mts` (or `lib/paths.mts`). No `path.join('build', mode, ...)` outside that module. - **Across packages**: when package B consumes package A's output, B imports A's `paths.mts` via the workspace `exports` field. Never `path.join(PKG, '..', '', 'build', ...)`. - **Workflows, Dockerfiles, shell scripts**: they can't `import` TS, so they construct the string once and reference it everywhere downstream. Workflows: a "Compute paths" step exposes `steps.paths.outputs.final_dir`; later steps read `${{ steps.paths.outputs.final_dir }}`. Dockerfiles/shell: assign once to a variable / `ENV`, reference by name thereafter. Each canonical construction carries a comment naming the source-of-truth `paths.mts`. **Re-building** the same path in a second step is the violation, not referring to the constructed value many times. -- **Comments**: may describe path *structure* with placeholders ("`/`") but should not encode a complete literal path string. The import statement IS the comment. +- **Comments**: may describe path _structure_ with placeholders ("`/`") but should not encode a complete literal path string. The import statement IS the comment. Code execution takes priority over docs: violations in `.mts`/`.cts`, Makefiles, Dockerfiles, workflow YAML, and shell scripts are blocking. README and doc-comment violations are advisory unless they contain a fully-qualified path with no parametric placeholders. diff --git a/scripts/check-paths.mts b/scripts/check-paths.mts index 023b6ce14..cbecc71e5 100644 --- a/scripts/check-paths.mts +++ b/scripts/check-paths.mts @@ -229,7 +229,8 @@ const loadAllowlist = (): AllowlistEntry[] => { blockLines = [] return } - ;(current as any)[key] = key === 'line' ? Number(unquote(trimmed)) : unquote(trimmed) + ;(current as any)[key] = + key === 'line' ? Number(unquote(trimmed)) : unquote(trimmed) } if (line.startsWith('- ')) { if (current && current.reason) { @@ -317,8 +318,7 @@ const isAllowlisted = (finding: Finding): boolean => const hashProvided = typeof entry.snippet_hash === 'string' && entry.snippet_hash.length > 0 if (lineProvided || hashProvided) { - const lineMatches = - lineProvided && entry.line === finding.line + const lineMatches = lineProvided && entry.line === finding.line const hashMatches = hashProvided && entry.snippet_hash === snippetHash(finding.snippet) if (!(lineMatches || hashMatches)) { @@ -382,7 +382,8 @@ const STRING_LITERAL_RE = /(['"])((?:\\.|(?!\1)[^\\])*)\1/g // (including those with `${...}` placeholders) so Rule A also catches // path construction via template literals like // `${buildDir}/out/Final/${binary}` or `build/${mode}/out/Final`. -const TEMPLATE_LITERAL_RE = /`((?:\\.|(?:\$\{(?:[^{}]|\{[^{}]*\})*\})|(?!`)[^\\])*)`/g +const TEMPLATE_LITERAL_RE = + /`((?:\\.|(?:\$\{(?:[^{}]|\{[^{}]*\})*\})|(?!`)[^\\])*)`/g /** * Convert a template-literal body into a synthetic forward-slash path diff --git a/tools/prim/README.md b/tools/prim/README.md index cac1434bc..d0e2259c9 100644 --- a/tools/prim/README.md +++ b/tools/prim/README.md @@ -44,10 +44,10 @@ development — it always picks up the live source under `tools/prim/`. `prim` has three subcommands, each focused on one operation: -| Subcommand | Purpose | -|---|---| -| `prim audit` | Find call sites where a primordial applies. Default shows both migration candidates (covered) and surface gaps (gap). Filter with `--coverage` or `--gaps`. | -| `prim mod` | Codemod **JavaScript** source files to use primordials. Dry-run by default; `--apply` to write. TypeScript is out of scope (rewriting `.ts` requires source-mapping between stripped-types and original byte offsets) — `prim audit` still walks TS, so candidates are visible. | +| Subcommand | Purpose | +| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `prim audit` | Find call sites where a primordial applies. Default shows both migration candidates (covered) and surface gaps (gap). Filter with `--coverage` or `--gaps`. | +| `prim mod` | Codemod **JavaScript** source files to use primordials. Dry-run by default; `--apply` to write. TypeScript is out of scope (rewriting `.ts` requires source-mapping between stripped-types and original byte offsets) — `prim audit` still walks TS, so candidates are visible. | | `prim lint` | Structural lint rules for primordials destructure blocks. Currently: `ctor-rename` — constructor primordials (`Array`, `Set`, `TypeError`, …) must be aliased `: Ctor` when destructured from `primordials` (or any configured primordials-shaped source). Exits 1 on violations. | ```sh diff --git a/tools/prim/src/cli.mts b/tools/prim/src/cli.mts index f081ec7f1..1764ce4d7 100644 --- a/tools/prim/src/cli.mts +++ b/tools/prim/src/cli.mts @@ -253,11 +253,12 @@ export async function runCli(argv) { if (!wantGaps) { filtered = filtered.filter(f => f.kind !== 'gap') } - const mode = values.coverage && !values.gaps - ? 'coverage' - : values.gaps && !values.coverage - ? 'gaps' - : 'audit' + const mode = + values.coverage && !values.gaps + ? 'coverage' + : values.gaps && !values.coverage + ? 'gaps' + : 'audit' report(filtered, json, path.basename(targetRoot), mode) // Surface parse failures so silent skips don't mask audit gaps. const parseFailures = findings.parseFailures ?? 0 diff --git a/tools/prim/src/lint.mts b/tools/prim/src/lint.mts index ad5cffd83..7be87628e 100644 --- a/tools/prim/src/lint.mts +++ b/tools/prim/src/lint.mts @@ -324,9 +324,7 @@ export function formatLintFindings(findings, ctx) { if (findings.length === 0) { return `${ctx.targetName}: no lint violations.\n` } - const lines = [ - `${ctx.targetName} (lint): ${findings.length} violation(s)\n`, - ] + const lines = [`${ctx.targetName} (lint): ${findings.length} violation(s)\n`] for (const f of findings) { lines.push( ` [${f.rule}] ${f.file}:${f.line}:${f.column} destructured \`${f.name}\` from ${f.source}; expected \`${f.name}: ${f.expected}\``, diff --git a/tools/prim/src/surface.mts b/tools/prim/src/surface.mts index 95357dfcc..8e8c4a491 100644 --- a/tools/prim/src/surface.mts +++ b/tools/prim/src/surface.mts @@ -115,7 +115,11 @@ function deriveNodeBootstrapSurface() { // method. exports.add(name) for (const propName of Object.getOwnPropertyNames(original)) { - if (propName === 'prototype' || propName === 'name' || propName === 'length') { + if ( + propName === 'prototype' || + propName === 'name' || + propName === 'length' + ) { continue } exports.add(`${name}${capitalize(propName)}`) @@ -225,9 +229,7 @@ export function loadPrimordialsSurface(targetRoot, surfacePath) { if (surfacePath) { const resolved = path.resolve(surfacePath) if (!existsSync(resolved)) { - throw new Error( - `--surface path not found: ${resolved}`, - ) + throw new Error(`--surface path not found: ${resolved}`) } return { source: resolved, exports: parseExports(resolved) } } diff --git a/xport.schema.json b/xport.schema.json index 719c5aadf..6cbd80197 100644 --- a/xport.schema.json +++ b/xport.schema.json @@ -4,9 +4,7 @@ "title": "xport lock-step manifest", "description": "Unified lock-step manifest shared across Socket repos. One schema, all cases — `kind` discriminator on each row selects which flavor of lock-step applies.", "type": "object", - "required": [ - "rows" - ], + "required": ["rows"], "properties": { "$schema": { "type": "string" @@ -32,10 +30,7 @@ "^(.*)$": { "additionalProperties": false, "type": "object", - "required": [ - "submodule", - "repo" - ], + "required": ["submodule", "repo"], "properties": { "submodule": { "description": "Submodule path, relative to repo root.", @@ -57,9 +52,7 @@ "^(.*)$": { "additionalProperties": false, "type": "object", - "required": [ - "path" - ], + "required": ["path"], "properties": { "path": { "description": "Path to the port's root directory, relative to repo root.", @@ -212,13 +205,7 @@ "additionalProperties": false, "description": "A behavioral feature reimplemented locally to match upstream behavior. Three-pillar validation: code patterns, test patterns, fixture snapshots.", "type": "object", - "required": [ - "kind", - "id", - "upstream", - "criticality", - "local_area" - ], + "required": ["kind", "id", "upstream", "criticality", "local_area"], "properties": { "kind": { "const": "feature-parity", @@ -273,9 +260,7 @@ "additionalProperties": false, "description": "Golden-input verification. Prefer snapshot-based diffs over hardcoded counts (brittleness lesson from sdxgen's lock-step-features).", "type": "object", - "required": [ - "fixture_path" - ], + "required": ["fixture_path"], "properties": { "fixture_path": { "type": "string" @@ -425,9 +410,7 @@ "additionalProperties": false, "description": "Per-port status for a lang-parity row. `implemented` = port meets assertions; `opt-out` = port consciously skips, requires non-empty `reason`.", "type": "object", - "required": [ - "status" - ], + "required": ["status"], "properties": { "status": { "anyOf": [