Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 55 additions & 75 deletions .claude/hooks/path-guard/index.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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(*, '..', '<name>', '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.
Expand Down Expand Up @@ -293,13 +228,58 @@ const checkRuleB = (calls: ReturnType<typeof extractPathCalls>): 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 => {
Expand Down
80 changes: 80 additions & 0 deletions .claude/hooks/path-guard/segments.mts
Original file line number Diff line number Diff line change
@@ -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
// `<root>/<stage>/<lib>` doesn't fire if no build-root segment is
// present; `<root>/build/<stage>/out/<stage>` 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/<mode>/<arch>/out/<stage>` 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',
])
54 changes: 54 additions & 0 deletions .claude/hooks/path-guard/test/path-guard.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `
Expand Down
12 changes: 11 additions & 1 deletion .claude/hooks/token-guard/index.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down
19 changes: 19 additions & 0 deletions .claude/hooks/token-guard/test/token-guard.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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], {
Expand Down
12 changes: 12 additions & 0 deletions .claude/skills/path-guard/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading