From 524e5ad20efe3339ec874f87cbb4bc6bfb8f86ac Mon Sep 17 00:00:00 2001 From: jdalton Date: Wed, 20 May 2026 15:48:35 -0400 Subject: [PATCH] chore(sync): cascade fleet template@032a512 Auto-applied by socket-wheelhouse sync-scaffolding into cascade-socket-bin-54762. 32 file(s) touched: - .claude/hooks/no-blind-keychain-read-guard/README.md - .claude/hooks/no-blind-keychain-read-guard/index.mts - .claude/hooks/no-blind-keychain-read-guard/package.json - .claude/hooks/no-blind-keychain-read-guard/test/index.test.mts - .claude/hooks/no-blind-keychain-read-guard/tsconfig.json - .claude/hooks/path-guard/README.md - .claude/hooks/path-guard/segments.mts - .claude/settings.json - .claude/skills/cascading-fleet/lib/cascade-template.sh - .claude/skills/cascading-fleet/lib/fleet-repos.json - .claude/skills/cascading-fleet/lib/fleet-repos.txt - .config/.markdownlint-cli2.jsonc - .config/.prettierignore - .config/markdownlint-rules/_shared/wheelhouse-self-skip.mjs - .config/markdownlint-rules/socket-no-private-wheelhouse-leak.mjs - .config/markdownlint-rules/socket-no-relative-sibling-script.mjs - .config/markdownlint-rules/socket-readme-required-sections.mjs - .config/oxlint-plugin/rules/prefer-cached-for-loop.mts - .config/oxlint-plugin/rules/socket-api-token-env.mts - .config/oxlint-plugin/rules/sort-boolean-chains.mts ... and 12 more --- .../no-blind-keychain-read-guard/README.md | 65 +++++ .../no-blind-keychain-read-guard/index.mts | 243 ++++++++++++++++++ .../no-blind-keychain-read-guard/package.json | 15 ++ .../test/index.test.mts | 142 ++++++++++ .../tsconfig.json | 16 ++ .claude/hooks/path-guard/README.md | 9 +- .claude/hooks/path-guard/segments.mts | 2 - .claude/settings.json | 4 + .../cascading-fleet/lib/cascade-template.sh | 35 ++- .../cascading-fleet/lib/fleet-repos.json | 53 ++++ .../cascading-fleet/lib/fleet-repos.txt | 5 +- .config/.markdownlint-cli2.jsonc | 51 ++++ .config/.prettierignore | 14 + .../_shared/wheelhouse-self-skip.mjs | 40 +++ .../socket-no-private-wheelhouse-leak.mjs | 61 +++++ .../socket-no-relative-sibling-script.mjs | 67 +++++ .../socket-readme-required-sections.mjs | 93 +++++++ .../rules/prefer-cached-for-loop.mts | 10 +- .../rules/socket-api-token-env.mts | 25 +- .../rules/sort-boolean-chains.mts | 33 ++- .../test/no-underscore-identifier.test.mts | 10 +- .../test/prefer-cached-for-loop.test.mts | 8 + .../test/prefer-spawn-over-execsync.test.mts | 27 +- .../test/socket-api-token-env.test.mts | 7 + .../test/sort-boolean-chains.test.mts | 8 + docs/claude.md/fleet/sorting.md | 2 +- .../lib/release-checksums/consumer.mts | 24 +- .../lib/release-checksums/core.mts | 78 +++--- .../build-infra/release-assets.schema.json | 2 +- scripts/install-token-minifier.mts | 16 +- scripts/validate-bundle-deps.mts | 13 +- scripts/validate-file-size.mts | 16 ++ 32 files changed, 1073 insertions(+), 121 deletions(-) create mode 100644 .claude/hooks/no-blind-keychain-read-guard/README.md create mode 100644 .claude/hooks/no-blind-keychain-read-guard/index.mts create mode 100644 .claude/hooks/no-blind-keychain-read-guard/package.json create mode 100644 .claude/hooks/no-blind-keychain-read-guard/test/index.test.mts create mode 100644 .claude/hooks/no-blind-keychain-read-guard/tsconfig.json create mode 100644 .claude/skills/cascading-fleet/lib/fleet-repos.json create mode 100644 .config/.markdownlint-cli2.jsonc create mode 100644 .config/markdownlint-rules/_shared/wheelhouse-self-skip.mjs create mode 100644 .config/markdownlint-rules/socket-no-private-wheelhouse-leak.mjs create mode 100644 .config/markdownlint-rules/socket-no-relative-sibling-script.mjs create mode 100644 .config/markdownlint-rules/socket-readme-required-sections.mjs diff --git a/.claude/hooks/no-blind-keychain-read-guard/README.md b/.claude/hooks/no-blind-keychain-read-guard/README.md new file mode 100644 index 0000000..1d7d79b --- /dev/null +++ b/.claude/hooks/no-blind-keychain-read-guard/README.md @@ -0,0 +1,65 @@ +# no-blind-keychain-read-guard + +`PreToolUse(Bash)` blocker that refuses direct keychain READ calls +from Bash. The keychain APIs surface a UI auth prompt per call; +reading three times costs three prompts. The fleet's canonical +in-process resolver (`api-token.mts.findApiToken()`) caches the +value module-scoped after the first hit, so subsequent code paths +should never need to re-read the keychain. + +## Detected reads + +| Platform | Pattern | +| --------------- | ------------------------------------------- | +| macOS | `security find-{generic,internet}-password` | +| Linux | `secret-tool lookup` / `secret-tool search` | +| Windows | `Get-StoredCredential` | +| Windows | `Get-Credential … \| ConvertFrom-SecureString` | +| cross-platform | `keyring get` | + +## Allowed (not flagged) + +Writes and deletes — these only happen during operator-driven +setup / rotation, never on hot paths: + +- `security add-generic-password` / `security delete-generic-password` +- `secret-tool store` / `secret-tool clear` +- `New-StoredCredential` / `Remove-StoredCredential` +- `keyring set` / `keyring del` + +## Bypass + +Type the canonical phrase verbatim in your next user turn: + +``` +Allow blind-keychain-read bypass +``` + +Use when you genuinely need a fresh keychain read — operator-invoked +diagnostics, verifying an entry exists, etc. + +## Why + +`security find-generic-password` on macOS prompts the user every call +unless the calling process is on the entry's ACL. Claude Code's Bash +tool spawns a fresh process per call, so each `security` invocation +re-prompts. The same shape exists on Linux (`secret-tool` against +gnome-keyring / kwallet) and Windows (`Get-StoredCredential` against +the CredentialManager UI). + +The right answer is to read the cached value from process state: + +```ts +import { findApiToken } from '../setup-security-tools/lib/api-token.mts' +const { token } = findApiToken() // module-cached after first call +``` + +Or from a child process spawned by hooks: + +```bash +echo "$SOCKET_API_KEY" # populated by wheelhouse shell-rc bridge +``` + +The bridge writes the token to `~/.zshenv` (or platform equivalent) +so every new shell exports `SOCKET_API_KEY` + `SOCKET_API_TOKEN` +without a keychain read. diff --git a/.claude/hooks/no-blind-keychain-read-guard/index.mts b/.claude/hooks/no-blind-keychain-read-guard/index.mts new file mode 100644 index 0000000..0264af7 --- /dev/null +++ b/.claude/hooks/no-blind-keychain-read-guard/index.mts @@ -0,0 +1,243 @@ +#!/usr/bin/env node +// Claude Code PreToolUse hook — no-blind-keychain-read-guard. +// +// Blocks Bash invocations that READ a credential from the OS +// keychain. Reading via the platform CLI surfaces a per-call UI auth +// prompt on the user's screen ("this app wants to access your +// keychain"), and the prompt fires once per call — a hook chain that +// reads the keychain three times costs three prompts. Tokens are +// already cached in process memory after the first resolution; the +// fleet's canonical resolver (`api-token.mts.findApiToken()`) hits +// the cache, then env, then keychain, in that order. Bash callers +// that go straight to `security find-generic-password` skip all of +// that and re-prompt the user every time. +// +// Detects (case-sensitive, structural — not just substring): +// +// macOS: +// security find-generic-password +// security find-internet-password +// +// Linux: +// secret-tool lookup +// secret-tool search +// +// Windows (PowerShell): +// Get-StoredCredential (CredentialManager module) +// Get-Credential (when piping to ConvertFrom-SecureString) +// +// Cross-platform (Python keyring CLI): +// keyring get +// +// Allowed (writes / deletes — necessary for operator-driven setup / +// rotation, never on hot paths): +// +// security add-generic-password security delete-generic-password +// secret-tool store secret-tool clear +// New-StoredCredential Remove-StoredCredential +// keyring set keyring del +// +// Bypass: `Allow blind-keychain-read bypass` in a recent user turn. +// Use when you genuinely need to verify a keychain entry exists +// (e.g. operator-invoked diagnostics). +// +// Exit codes: +// 0 — pass. +// 2 — block. +// +// Fails open on malformed payloads (exit 0 + stderr log) — the fleet's +// hook contract. + +import process from 'node:process' + +import { bypassPhrasePresent } from '../_shared/transcript.mts' + +interface ToolInput { + readonly tool_input?: + | { + readonly command?: string | undefined + } + | undefined + readonly tool_name?: string | undefined + readonly transcript_path?: string | undefined +} + +interface Hit { + readonly tool: string + readonly platform: 'macos' | 'linux' | 'windows' | 'cross-platform' + readonly snippet: string +} + +const BYPASS_PHRASE = 'Allow blind-keychain-read bypass' + +// Token-bearing read patterns. Each entry: the literal verb that +// surfaces a UI prompt + a label for the error message. Writes / +// deletes are intentionally absent from this list. +const READ_PATTERNS: ReadonlyArray<{ + readonly re: RegExp + readonly tool: string + readonly platform: Hit['platform'] +}> = [ + // macOS — `security(1)`. The `-w` flag prints the password to + // stdout, but even the metadata-only form triggers the ACL prompt. + { + re: /\bsecurity\s+(?:find-generic-password|find-internet-password)\b/, + tool: 'security find-*-password', + platform: 'macos', + }, + // Linux — `secret-tool`. `lookup` returns the password; `search` + // lists matches (also surfaces the libsecret prompt). + { + re: /\bsecret-tool\s+(?:lookup|search)\b/, + tool: 'secret-tool lookup/search', + platform: 'linux', + }, + // Windows PowerShell — CredentialManager module. The + // `Get-StoredCredential` cmdlet returns a PSCredential; reading + // `.Password | ConvertFrom-SecureString` is the read pattern. + { + re: /\bGet-StoredCredential\b/, + tool: 'Get-StoredCredential', + platform: 'windows', + }, + // PowerShell `Get-Credential -Credential` piped to + // `ConvertFrom-SecureString -AsPlainText` is the readback shape. + // The bare `Get-Credential` (no pipe) is a fresh-prompt-the-user + // flow and not the issue here — match only the readback pipe. + { + re: /\bGet-Credential\b[^|]*\|\s*ConvertFrom-SecureString\b/, + tool: 'Get-Credential | ConvertFrom-SecureString', + platform: 'windows', + }, + // Python `keyring` CLI — `keyring get `. + { + re: /\bkeyring\s+get\b/, + tool: 'keyring get', + platform: 'cross-platform', + }, +] + +/** + * Scan a Bash command string for keychain READ patterns. Returns one hit per + * matching subcommand so the error message can name them all (a `&&`-chained + * command might have multiple). + */ +export function findKeychainReads(command: string): Hit[] { + const hits: Hit[] = [] + for (let i = 0, { length } = READ_PATTERNS; i < length; i += 1) { + const entry = READ_PATTERNS[i]! + const m = entry.re.exec(command) + if (!m) { + continue + } + // Pull a short snippet around the match (up to 80 chars) so the + // operator can see the context. Centered on the match start. + const start = Math.max(0, m.index - 10) + const end = Math.min(command.length, m.index + m[0].length + 50) + const snippet = command.slice(start, end) + hits.push({ + tool: entry.tool, + platform: entry.platform, + snippet: snippet.length < command.length ? `…${snippet}…` : snippet, + }) + } + return hits +} + +function handlePayload(payloadRaw: string): number { + let payload: ToolInput + try { + payload = JSON.parse(payloadRaw) as ToolInput + } catch { + return 0 + } + if (payload.tool_name !== 'Bash') { + return 0 + } + const command = payload.tool_input?.command ?? '' + if (!command) { + return 0 + } + const hits = findKeychainReads(command) + if (hits.length === 0) { + return 0 + } + if (bypassPhrasePresent(payload.transcript_path, BYPASS_PHRASE)) { + return 0 + } + const lines: string[] = [] + lines.push( + '[no-blind-keychain-read-guard] Blocked: direct keychain READ from Bash.', + ) + lines.push('') + for (let i = 0, { length } = hits; i < length; i += 1) { + const h = hits[i]! + lines.push(` ${h.platform.padEnd(15)} ${h.tool}`) + lines.push(` Saw: ${h.snippet}`) + } + lines.push('') + lines.push( + ' Reading the keychain via the platform CLI surfaces a UI auth', + ) + lines.push( + " prompt on the user's screen — and the prompt fires once per", + ) + lines.push( + ' call. A hook chain that reads three times costs three prompts.', + ) + lines.push('') + lines.push(' The token is almost certainly already available without a') + lines.push(' keychain read:') + lines.push('') + lines.push( + ' - In-process: call findApiToken() from setup-security-tools/', + ) + lines.push( + ' lib/api-token.mts. It returns the module-cached value from', + ) + lines.push(' the first call onward, then env, then keychain.') + lines.push('') + lines.push(' - From Bash: read process.env.SOCKET_API_KEY or') + lines.push( + ' process.env.SOCKET_API_TOKEN. The wheelhouse shell-rc bridge', + ) + lines.push(' exports both for every new shell session.') + lines.push('') + lines.push( + ' Writes / deletes (security add-generic-password / secret-tool', + ) + lines.push( + ' store / New-StoredCredential / etc.) are allowed — they only', + ) + lines.push(' happen during operator-driven setup / rotation.') + lines.push('') + lines.push(' Bypass (e.g. operator-invoked diagnostics that need a fresh') + lines.push(' keychain read):') + lines.push(` Type "${BYPASS_PHRASE}" in your next message.`) + process.stderr.write(lines.join('\n') + '\n') + return 2 +} + +export { handlePayload } + +// CLI entrypoint — only fires when this file is the main module. +// During tests the importer pulls `findKeychainReads` without triggering +// the stdin reader (which would never see an `end` event in test env +// and hang the process). +if (process.argv[1] && process.argv[1].endsWith('index.mts')) { + let payloadRaw = '' + process.stdin.setEncoding('utf8') + process.stdin.on('data', chunk => { + payloadRaw += chunk + }) + process.stdin.on('end', () => { + try { + process.exit(handlePayload(payloadRaw)) + } catch (e) { + process.stderr.write( + `[no-blind-keychain-read-guard] hook error (allowing): ${e}\n`, + ) + process.exit(0) + } + }) +} diff --git a/.claude/hooks/no-blind-keychain-read-guard/package.json b/.claude/hooks/no-blind-keychain-read-guard/package.json new file mode 100644 index 0000000..819429b --- /dev/null +++ b/.claude/hooks/no-blind-keychain-read-guard/package.json @@ -0,0 +1,15 @@ +{ + "name": "hook-no-blind-keychain-read-guard", + "private": true, + "type": "module", + "main": "./index.mts", + "exports": { + ".": "./index.mts" + }, + "scripts": { + "test": "node --test test/*.test.mts" + }, + "devDependencies": { + "@types/node": "catalog:" + } +} diff --git a/.claude/hooks/no-blind-keychain-read-guard/test/index.test.mts b/.claude/hooks/no-blind-keychain-read-guard/test/index.test.mts new file mode 100644 index 0000000..2816b87 --- /dev/null +++ b/.claude/hooks/no-blind-keychain-read-guard/test/index.test.mts @@ -0,0 +1,142 @@ +/** + * @file Unit tests for findKeychainReads — the structural matcher that + * classifies a Bash command string into keychain READ hits (vs writes, + * deletes, and unrelated commands). + */ + +import test from 'node:test' +import assert from 'node:assert/strict' + +import { findKeychainReads } from '../index.mts' + +test('macOS find-generic-password is flagged', () => { + const hits = findKeychainReads( + 'security find-generic-password -s socket-cli -a SOCKET_API_KEY -w', + ) + assert.equal(hits.length, 1) + assert.equal(hits[0]!.platform, 'macos') +}) + +test('macOS find-internet-password is flagged', () => { + const hits = findKeychainReads( + 'security find-internet-password -s example.com -a user', + ) + assert.equal(hits.length, 1) + assert.equal(hits[0]!.platform, 'macos') +}) + +test('macOS add-generic-password is NOT flagged (write)', () => { + const hits = findKeychainReads( + 'security add-generic-password -U -s socket-cli -a SOCKET_API_KEY -w xxx', + ) + assert.equal(hits.length, 0) +}) + +test('macOS delete-generic-password is NOT flagged (delete)', () => { + const hits = findKeychainReads( + 'security delete-generic-password -s socket-cli -a SOCKET_API_KEY', + ) + assert.equal(hits.length, 0) +}) + +test('Linux secret-tool lookup is flagged', () => { + const hits = findKeychainReads( + 'secret-tool lookup service socket-cli user SOCKET_API_KEY', + ) + assert.equal(hits.length, 1) + assert.equal(hits[0]!.platform, 'linux') +}) + +test('Linux secret-tool search is flagged', () => { + const hits = findKeychainReads('secret-tool search service socket-cli') + assert.equal(hits.length, 1) + assert.equal(hits[0]!.platform, 'linux') +}) + +test('Linux secret-tool store is NOT flagged (write)', () => { + const hits = findKeychainReads( + 'secret-tool store --label="Socket API token" service socket-cli user SOCKET_API_KEY', + ) + assert.equal(hits.length, 0) +}) + +test('Linux secret-tool clear is NOT flagged (delete)', () => { + const hits = findKeychainReads( + 'secret-tool clear service socket-cli user SOCKET_API_KEY', + ) + assert.equal(hits.length, 0) +}) + +test('Windows Get-StoredCredential is flagged', () => { + const hits = findKeychainReads( + "powershell -Command \"(Get-StoredCredential -Target 'socket-cli:SOCKET_API_KEY').Password\"", + ) + assert.equal(hits.length, 1) + assert.equal(hits[0]!.platform, 'windows') +}) + +test('Windows Get-Credential | ConvertFrom-SecureString is flagged', () => { + const hits = findKeychainReads( + 'Get-Credential -Credential admin | ConvertFrom-SecureString -AsPlainText', + ) + assert.equal(hits.length, 1) + assert.equal(hits[0]!.platform, 'windows') +}) + +test('Windows Get-Credential WITHOUT pipe is NOT flagged (fresh prompt)', () => { + // Bare Get-Credential is an interactive fresh-prompt flow, not a + // readback of a stored credential. Don't block. + const hits = findKeychainReads('$cred = Get-Credential -Credential admin') + assert.equal(hits.length, 0) +}) + +test('Windows New-StoredCredential is NOT flagged (write)', () => { + const hits = findKeychainReads( + "New-StoredCredential -Target 'socket-cli:SOCKET_API_KEY' -UserName x -SecurePassword $s", + ) + assert.equal(hits.length, 0) +}) + +test('keyring get is flagged', () => { + const hits = findKeychainReads('keyring get socket-cli SOCKET_API_KEY') + assert.equal(hits.length, 1) + assert.equal(hits[0]!.platform, 'cross-platform') +}) + +test('keyring set is NOT flagged (write)', () => { + const hits = findKeychainReads('keyring set socket-cli SOCKET_API_KEY') + assert.equal(hits.length, 0) +}) + +test('chained reads count separately', () => { + // && chain with two reads + const hits = findKeychainReads( + 'security find-generic-password -s a -a b -w && secret-tool lookup service a user b', + ) + assert.equal(hits.length, 2) +}) + +test('unrelated commands are not flagged', () => { + for (const cmd of [ + 'ls -la', + "git log --oneline -5", + 'echo $SOCKET_API_KEY', + 'pnpm install', + 'grep security file.txt', + 'security delete-keychain ~/Library/Keychains/foo.keychain', + ]) { + const hits = findKeychainReads(cmd) + assert.equal(hits.length, 0, `should not flag: ${cmd}`) + } +}) + +test('command substitution wrapping is still flagged', () => { + // The structural matcher is intentionally a regex, not an AST. This + // catches the common subshell shape — verifying the inner verb is + // detected even inside `$(...)`. AST-based parsing is overkill for + // a non-security-critical reminder hook. + const hits = findKeychainReads( + 'TOKEN="$(security find-generic-password -s socket-cli -a SOCKET_API_KEY -w)" && echo done', + ) + assert.equal(hits.length, 1) +}) diff --git a/.claude/hooks/no-blind-keychain-read-guard/tsconfig.json b/.claude/hooks/no-blind-keychain-read-guard/tsconfig.json new file mode 100644 index 0000000..19458cf --- /dev/null +++ b/.claude/hooks/no-blind-keychain-read-guard/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "declarationMap": false, + "erasableSyntaxOnly": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "noEmit": true, + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node"], + "verbatimModuleSyntax": true + } +} diff --git a/.claude/hooks/path-guard/README.md b/.claude/hooks/path-guard/README.md index 72ef970..bec03c1 100644 --- a/.claude/hooks/path-guard/README.md +++ b/.claude/hooks/path-guard/README.md @@ -73,11 +73,10 @@ The hook recognizes Rule B traversals only when the next segment after `..` is a known fleet package name: `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` +`codet5-models-builder`, `curl-builder`, `libpq-builder`, +`lief-builder`, `minilm-builder`, `models`, `napi-go`, +`node-smol-builder`, `onnxruntime-builder`, `opentui-builder`, +`stubs-builder`, `ultraviolet-builder`, `yoga-layout-builder` When a new package joins the workspace, add it to `KNOWN_SIBLING_PACKAGES` in `index.mts`. diff --git a/.claude/hooks/path-guard/segments.mts b/.claude/hooks/path-guard/segments.mts index d680eb8..c4eb78e 100644 --- a/.claude/hooks/path-guard/segments.mts +++ b/.claude/hooks/path-guard/segments.mts @@ -54,8 +54,6 @@ export const KNOWN_SIBLING_PACKAGES = new Set([ 'codet5-models-builder', 'core', 'curl-builder', - 'ink-builder', - 'iocraft-builder', 'libpq-builder', 'lief-builder', 'minilm-builder', diff --git a/.claude/settings.json b/.claude/settings.json index b07aef2..1816620 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -155,6 +155,10 @@ "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/default-branch-guard/index.mts" }, + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-blind-keychain-read-guard/index.mts" + }, { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/no-experimental-strip-types-guard/index.mts" diff --git a/.claude/skills/cascading-fleet/lib/cascade-template.sh b/.claude/skills/cascading-fleet/lib/cascade-template.sh index 7972c2e..5ba1669 100755 --- a/.claude/skills/cascading-fleet/lib/cascade-template.sh +++ b/.claude/skills/cascading-fleet/lib/cascade-template.sh @@ -53,15 +53,46 @@ echo "══ Cascade ${TEMPLATE_SHA} ══" echo "Log: $LOG_FILE" echo +# Resolve a canonical fleet repo name to a local primary checkout. +# Mirrors scripts/sync-scaffolding/discover.mts directoryAliasesFor(): +# canonical `socket-` also resolves to `~/projects//`; canonical +# `` (no socket- prefix — sdxgen, stuie, ultrathink) also resolves +# to `~/projects/socket-/`. First primary checkout wins. Echoes +# the resolved absolute path, or empty when no primary checkout exists. +resolveLocalCheckout() { + local canonical="$1" + local candidate + # Exact canonical name first. + candidate="${PROJECTS}/${canonical}" + if [ -d "${candidate}/.git" ]; then + echo "$candidate" + return 0 + fi + # Alias: socket-. + case "$canonical" in + socket-*) + candidate="${PROJECTS}/${canonical#socket-}" + ;; + *) + candidate="${PROJECTS}/socket-${canonical}" + ;; + esac + if [ -d "${candidate}/.git" ]; then + echo "$candidate" + return 0 + fi + return 1 +} + while IFS= read -r repo; do [ -z "$repo" ] && continue case "$repo" in '#'*) continue ;; esac - src="${PROJECTS}/${repo}" + src="$(resolveLocalCheckout "$repo")" wt="/tmp/cascade-${repo}-$$" echo "── ${repo} ──" - if [ ! -d "${src}/.git" ]; then + if [ -z "$src" ]; then RESULTS+=("${repo}|skip:no-git") continue fi diff --git a/.claude/skills/cascading-fleet/lib/fleet-repos.json b/.claude/skills/cascading-fleet/lib/fleet-repos.json new file mode 100644 index 0000000..e632036 --- /dev/null +++ b/.claude/skills/cascading-fleet/lib/fleet-repos.json @@ -0,0 +1,53 @@ +{ + "$schema": "./fleet-repos.schema.json", + "repos": [ + { + "name": "socket-addon", + "description": "NAPI .node binaries for @socketaddon/* npm packages" + }, + { + "name": "socket-bin", + "description": "SEA-packed CLI distributions for @socketbin/* packages" + }, + { + "name": "socket-btm", + "description": "Build toolchain — produces signed prebuilt binaries for @socketaddon/* and @socketbin/*" + }, + { + "name": "socket-cli", + "description": "Command-line interface for socket.dev security analysis" + }, + { + "name": "socket-lib", + "description": "Core library: fs, processes, HTTP, logging, env detection" + }, + { + "name": "socket-mcp", + "description": "Model Context Protocol server for socket.dev integration" + }, + { + "name": "socket-packageurl-js", + "description": "purl spec implementation for JavaScript" + }, + { + "name": "socket-registry", + "description": "Optimized package overrides for Socket Optimize" + }, + { + "name": "socket-sdk-js", + "description": "JavaScript SDK for the socket.dev API" + }, + { + "name": "sdxgen", + "description": "CycloneDX and SPDX manifest generator (Socket dx gen)" + }, + { + "name": "stuie", + "description": "Terminal UI library: OpenTUI + yoga-layout + React" + }, + { + "name": "socket-wheelhouse", + "description": "Internal scaffolding template for socket-* repos" + } + ] +} diff --git a/.claude/skills/cascading-fleet/lib/fleet-repos.txt b/.claude/skills/cascading-fleet/lib/fleet-repos.txt index 52aaf95..84ea8f1 100644 --- a/.claude/skills/cascading-fleet/lib/fleet-repos.txt +++ b/.claude/skills/cascading-fleet/lib/fleet-repos.txt @@ -1,4 +1,5 @@ socket-addon +socket-bin socket-btm socket-cli socket-lib @@ -6,5 +7,5 @@ socket-mcp socket-packageurl-js socket-registry socket-sdk-js -socket-sdxgen -socket-stuie +sdxgen +stuie diff --git a/.config/.markdownlint-cli2.jsonc b/.config/.markdownlint-cli2.jsonc new file mode 100644 index 0000000..c681a7c --- /dev/null +++ b/.config/.markdownlint-cli2.jsonc @@ -0,0 +1,51 @@ +// markdownlint-cli2 configuration for fleet repos. +// +// Loaded by `pnpm run lint` (template/scripts/lint.mts invokes +// markdownlint-cli2 with `-c .config/.markdownlint-cli2.jsonc`). +// +// Two concerns: +// 1. Stock markdownlint rules — disable a handful that bite real prose +// without adding value, leave the rest at defaults. +// 2. Fleet-canonical custom rules under markdownlint-rules/ — these +// enforce the fleet's README hygiene contract (no private-repo +// mentions, no relative-path commands, README skeleton structure). +{ + "config": { + "default": true, + // MD013 (line-length) bites code-block-heavy READMEs without warning. + // Disabled fleet-wide; reviewers catch genuinely-too-long prose. + "MD013": false, + // MD033 (no inline HTML) bites our
collapsed Development + // sections and Socket-badge img tags. Allow specific tags only. + "MD033": { "allowed_elements": ["details", "summary", "img", "br"] }, + // MD041 (first-line-h1) is the contract; keep it on. + // MD024 (no-duplicate-heading) — siblings-only mode so that ### subsections + // can repeat under different ## parents (common in API docs). + "MD024": { "siblings_only": true }, + }, + // Globs: every *.md / *.mdx under the repo, except generated output, + // node_modules, vendored upstream trees, and CHANGELOG.md (auto-generated). + "globs": ["**/*.md", "**/*.mdx"], + "ignores": [ + "**/node_modules/**", + "**/dist/**", + "**/build/**", + "**/coverage/**", + "**/vendor/**", + "**/upstream/**", + "**/third_party/**", + "**/CHANGELOG.md", + // .claude/ markdown is internal scaffolding (hook READMEs, agent + // role files, skill SKILL.mds, command reminders) — scoped docs + // with their own shape conventions. Excluded from the public- + // facing markdown lint surface. + "**/.claude/**", + ], + // Custom rule plugins live under markdownlint-rules/, byte-identical + // across the fleet via sync-scaffolding manifest registration. + "customRules": [ + "./markdownlint-rules/socket-no-private-wheelhouse-leak.mjs", + "./markdownlint-rules/socket-no-relative-sibling-script.mjs", + "./markdownlint-rules/socket-readme-required-sections.mjs", + ], +} diff --git a/.config/.prettierignore b/.config/.prettierignore index f8193ad..6cbe4c6 100644 --- a/.config/.prettierignore +++ b/.config/.prettierignore @@ -31,3 +31,17 @@ template/.claude/hooks/_shared/acorn/acorn.wasm template/.claude/hooks/_shared/acorn/acorn-bindgen.cjs .claude/hooks/_shared/acorn/acorn-bindgen.cjs + +# Vendored / upstream trees — kept byte-identical with their source +# of truth. Per CLAUDE.md "Untracked-by-default for vendored / build- +# copied trees": these are someone else's source, not ours, and the +# formatter would happily rewrite (e.g.) an upstream HTML test +# fixture or shipped third-party JS into our local style. +**/upstream/** +upstream/** +**/vendor/** +vendor/** +**/third_party/** +third_party/** +**/external/** +external/** diff --git a/.config/markdownlint-rules/_shared/wheelhouse-self-skip.mjs b/.config/markdownlint-rules/_shared/wheelhouse-self-skip.mjs new file mode 100644 index 0000000..9c52223 --- /dev/null +++ b/.config/markdownlint-rules/_shared/wheelhouse-self-skip.mjs @@ -0,0 +1,40 @@ +/** + * @file Shared helper for fleet markdown rules: detect whether the lint is + * running inside socket-wheelhouse itself, in which case the rule should + * bail. The custom rules in this directory exist to protect PUBLIC fleet + * consumers from leaking internal scaffolding; wheelhouse referencing itself + * in its own docs is the canonical case and must not trigger. Detection + * prefers explicit env override (CI sets SOCKET_FLEET_REPO_NAME) then falls + * back to checking the cwd's basename and git remote. + */ + +// oxlint-disable-next-line socket/prefer-async-spawn -- markdownlint-cli2 calls isInsideWheelhouse() synchronously at rule init; an async spawn would require the rule loader to await, which markdownlint-cli2 doesnt support. +import { spawnSync } from 'node:child_process' +import path from 'node:path' +import process from 'node:process' + +export function isInsideWheelhouse() { + const envName = process.env['SOCKET_FLEET_REPO_NAME'] + if (envName) { + return envName === 'socket-wheelhouse' + } + const cwd = process.cwd() + if (path.basename(cwd) === 'socket-wheelhouse') { + return true + } + // Fallback: probe the git remote URL. Tolerates renamed local + // checkout dirs (`~/projects/wheelhouse/` would still match). + // spawnSync (not execSync) — array args, no shell interpolation. + // This file is loaded by markdownlint-cli2 as a regular ESM module, + // not bundled, so we cant pull in @socketsecurity/lib-stable/spawn — + // node:child_process spawnSync is the canonical fallback. + const r = spawnSync('git', ['config', '--get', 'remote.origin.url'], { + cwd, + stdio: ['ignore', 'pipe', 'ignore'], + }) + if (r.status !== 0 || !r.stdout) { + return false + } + const remote = r.stdout.toString().trim() + return /[/:]socket-wheelhouse(?:\.git)?$/.test(remote) +} diff --git a/.config/markdownlint-rules/socket-no-private-wheelhouse-leak.mjs b/.config/markdownlint-rules/socket-no-private-wheelhouse-leak.mjs new file mode 100644 index 0000000..ae4dbda --- /dev/null +++ b/.config/markdownlint-rules/socket-no-private-wheelhouse-leak.mjs @@ -0,0 +1,61 @@ +/** + * @file Flag mentions of `socket-wheelhouse` in public-facing markdown. + * socket-wheelhouse is a private repo. Public READMEs / docs / release notes + * that link to it leak the internal tooling layout to users who can't access + * the link anyway. Whatever the markdown is trying to teach should be + * rewritten to not require the reference. Detects: + * + * - The literal token `socket-wheelhouse` (case-insensitive) anywhere in a + * line. + * - `https://github.com/SocketDev/socket-wheelhouse...` URL forms. Skips fenced + * code blocks because those are intentional examples (and fenced-block + * scanning would false-positive on the very markdownlint config that + * references this file). No autofix: the right rewrite is contextual. + */ + +import { isInsideWheelhouse } from './_shared/wheelhouse-self-skip.mjs' + +const RULE_NAME = 'socket-no-private-wheelhouse-leak' +const FORBIDDEN_TOKEN_RE = /socket-wheelhouse/i + +/** + * @type {import('markdownlint').Rule} + */ +const rule = { + names: [RULE_NAME, 'socket/no-private-wheelhouse-leak'], + description: + 'socket-wheelhouse is a private repo — never reference it in public markdown', + tags: ['socket', 'privacy'], + parser: 'none', + function(params, onError) { + if (isInsideWheelhouse()) { + return + } + let inFence = false + for (let i = 0; i < params.lines.length; i += 1) { + const line = params.lines[i] + // Track fenced-code state. Open/close on lines that START with ``` or ~~~. + if (/^\s*(?:```|~~~)/.test(line)) { + inFence = !inFence + continue + } + if (inFence) { + continue + } + const match = FORBIDDEN_TOKEN_RE.exec(line) + if (!match) { + continue + } + onError({ + lineNumber: i + 1, + detail: + 'Rewrite to not mention socket-wheelhouse — it is a private repo and the link will 404 for outside readers.', + context: line.trim().slice(0, 120), + range: [match.index + 1, match[0].length], + }) + } + }, +} + +// oxlint-disable-next-line socket/no-default-export -- markdownlint-cli2 loads custom rules via dynamic import and expects the default export to be the rule object. +export default rule diff --git a/.config/markdownlint-rules/socket-no-relative-sibling-script.mjs b/.config/markdownlint-rules/socket-no-relative-sibling-script.mjs new file mode 100644 index 0000000..7fe4453 --- /dev/null +++ b/.config/markdownlint-rules/socket-no-relative-sibling-script.mjs @@ -0,0 +1,67 @@ +/** + * @file Flag commands that reference sibling repos via relative paths. `node + * ../socket-foo/scripts/bar.mts` in a fleet README assumes the reader has the + * sibling repo checked out at exactly the right level relative to the current + * repo. That's almost never true for an outside user, and the command + * silently fails. Detects (inside fenced code blocks and inline `code`): + * + * - `node ..//...` invocations + * - `pnpm ..//...` invocations + * - Bare `../socket-/...` references in code/inline-code Skips: + * relative paths to the current repo's own tree (`./scripts/`, + * `../package.json` within a monorepo), which are useful and don't leak + * sibling state. No autofix: the rewrite is to either inline the script's + * content or publish the helper to npm and reference the published name. + */ + +import { isInsideWheelhouse } from './_shared/wheelhouse-self-skip.mjs' + +const RULE_NAME = 'socket-no-relative-sibling-script' +const SIBLING_PATH_RES = [ + // Detect ` ..//...` where runner is one of the common + // JS/TS toolchain binaries (any runtime invocation). + /\b(?:bun|deno|node|npm|pnpm|yarn)\s+\.\.\/[\w@-]+\//, + // Detect bare ..// where the first segment doesn't start with `.` + // (i.e. genuine sibling, not the current repo's `..` for monorepo packages). + // `(?:^|\s)` alternation order is the canonical regex idiom (anchor-first). + /(?:^|\s)\.\.\/socket-[\w-]+\//i, // socket-hook: allow regex-alternation-order + /(?:^|\s)\.\.\/sdxgen\//, // socket-hook: allow regex-alternation-order + /(?:^|\s)\.\.\/stuie\//, // socket-hook: allow regex-alternation-order +] + +/** + * @type {import('markdownlint').Rule} + */ +const rule = { + names: [RULE_NAME, 'socket/no-relative-sibling-script'], + description: + 'Commands referencing sibling fleet repos via relative paths fail for outside readers', + tags: ['socket', 'fleet'], + parser: 'none', + function(params, onError) { + if (isInsideWheelhouse()) { + return + } + for (let i = 0; i < params.lines.length; i += 1) { + const line = params.lines[i] + for (let j = 0; j < SIBLING_PATH_RES.length; j += 1) { + const re = SIBLING_PATH_RES[j] + const match = re.exec(line) + if (!match) { + continue + } + onError({ + lineNumber: i + 1, + detail: + 'Rewrite the command to not depend on a sibling-repo checkout. Inline the script, link to its source on GitHub, or publish the helper to npm and reference the package name.', + context: line.trim().slice(0, 120), + range: [match.index + 1, match[0].length], + }) + break + } + } + }, +} + +// oxlint-disable-next-line socket/no-default-export -- markdownlint-cli2 loads custom rules via dynamic import and expects the default export to be the rule object. +export default rule diff --git a/.config/markdownlint-rules/socket-readme-required-sections.mjs b/.config/markdownlint-rules/socket-readme-required-sections.mjs new file mode 100644 index 0000000..61e4342 --- /dev/null +++ b/.config/markdownlint-rules/socket-readme-required-sections.mjs @@ -0,0 +1,93 @@ +/** + * @file Enforce the canonical fleet README section list. Fires only on the + * repo-root `README.md` (skipped for nested READMEs under `packages/`, + * `docs/`, `.claude/`, etc. — those are scoped docs with their own shape). + * Every fleet root README must contain five level-2 sections in this order: + * + * 1. Why this repo exists + * 2. Install + * 3. Usage + * 4. Development + * 5. License The canonical skeleton lives at + * socket-wheelhouse/template/README.md. Additional sections between/after + * these are allowed; reordering / missing / typo'd sections are findings. + * No autofix: a missing section needs content, not just a heading. + */ + +import path from 'node:path' + +import { isInsideWheelhouse } from './_shared/wheelhouse-self-skip.mjs' + +const RULE_NAME = 'socket-readme-required-sections' +const REQUIRED_SECTIONS = [ + 'Why this repo exists', + 'Install', + 'Usage', + 'Development', + 'License', +] + +export function isRootReadme(filePath) { + // markdownlint passes `params.name` as a path relative to the working + // dir. The root README is the one whose basename is README.md AND + // whose directory is the cwd or `.`. + if (!filePath) { + return false + } + const base = path.basename(filePath) + if (base !== 'README.md') { + return false + } + const dir = path.dirname(filePath) + return dir === '.' || dir === '' || dir === process.cwd() +} + +/** + * @type {import('markdownlint').Rule} + */ +const rule = { + names: [RULE_NAME, 'socket/readme-required-sections'], + description: + 'Fleet root README must contain the canonical five sections in order', + tags: ['socket', 'fleet', 'readme'], + parser: 'none', + function(params, onError) { + if (isInsideWheelhouse()) { + return + } + if (!isRootReadme(params.name)) { + return + } + const headings = [] + for (let i = 0; i < params.lines.length; i += 1) { + const line = params.lines[i] + const m = /^##\s+(.+?)\s*$/.exec(line) + if (m) { + headings.push({ text: m[1], lineNumber: i + 1 }) + } + } + let cursor = 0 + for (let r = 0; r < REQUIRED_SECTIONS.length; r += 1) { + const want = REQUIRED_SECTIONS[r] + let found = -1 + for (let h = cursor; h < headings.length; h += 1) { + if (headings[h].text === want) { + found = h + break + } + } + if (found === -1) { + onError({ + lineNumber: 1, + detail: `Missing required section "## ${want}" (or it appears out of order). Canonical order: ${REQUIRED_SECTIONS.map(s => `"## ${s}"`).join(' → ')}.`, + context: `README.md: required section "## ${want}" not found after position ${cursor}`, + }) + return + } + cursor = found + 1 + } + }, +} + +// oxlint-disable-next-line socket/no-default-export -- markdownlint-cli2 loads custom rules via dynamic import and expects the default export to be the rule object. +export default rule diff --git a/.config/oxlint-plugin/rules/prefer-cached-for-loop.mts b/.config/oxlint-plugin/rules/prefer-cached-for-loop.mts index 3f43f2a..94fbbcf 100644 --- a/.config/oxlint-plugin/rules/prefer-cached-for-loop.mts +++ b/.config/oxlint-plugin/rules/prefer-cached-for-loop.mts @@ -340,8 +340,16 @@ const rule = { // integer-indexable). The companion rule // socket/no-cached-for-on-iterable would then flag what THIS // rule just wrote. Skip silently rather than fight ourselves. + // + // Also skip when the kind can't be determined from the AST + // (e.g. `await fn()` / `someCall()` initializers without a + // type annotation). Without type info we can't prove the + // iterable is integer-indexable, and autofixing produces + // broken code (Set.length / Set[i]) on the wrong guess. + // Require explicit array shape (literal, type annotation, + // Array.from, Object.keys/values/entries) to opt in. const iterKind = resolveKind(node, iter.name as string) - if (FLAGGED_KINDS.has(iterKind)) { + if (FLAGGED_KINDS.has(iterKind) || iterKind === 'unknown') { return } if (node.body.type !== 'BlockStatement') { diff --git a/.config/oxlint-plugin/rules/socket-api-token-env.mts b/.config/oxlint-plugin/rules/socket-api-token-env.mts index 2f0e681..ed24ec8 100644 --- a/.config/oxlint-plugin/rules/socket-api-token-env.mts +++ b/.config/oxlint-plugin/rules/socket-api-token-env.mts @@ -56,12 +56,27 @@ const rule = { : context.sourceCode function hasBypassComment(node: AstNode) { - const before = sourceCode.getCommentsBefore(node) - const after = sourceCode.getCommentsAfter(node) - for (const c of [...before, ...after]) { - if (BYPASS_RE.test(c.value)) { - return true + // Walk up: literal -> array element -> array/declaration. The bypass + // comment can sit on the literal itself OR on any ancestor up to (and + // including) the nearest statement. This lets the entire alias-lookup + // array carry one bypass instead of needing one per element. + let cursor: AstNode | undefined = node + while (cursor) { + const before = sourceCode.getCommentsBefore(cursor) + const after = sourceCode.getCommentsAfter(cursor) + for (const c of [...before, ...after]) { + if (BYPASS_RE.test(c.value)) { + return true + } + } + if ( + cursor.type === 'ExpressionStatement' || + cursor.type === 'VariableDeclaration' || + cursor.type === 'ExportNamedDeclaration' + ) { + break } + cursor = cursor.parent } return false } diff --git a/.config/oxlint-plugin/rules/sort-boolean-chains.mts b/.config/oxlint-plugin/rules/sort-boolean-chains.mts index 3546bc7..b7fd69c 100644 --- a/.config/oxlint-plugin/rules/sort-boolean-chains.mts +++ b/.config/oxlint-plugin/rules/sort-boolean-chains.mts @@ -1,27 +1,33 @@ /** * @file Sort all-identifier boolean chains alphanumerically. Per CLAUDE.md - * "Sorting" rule, a chain like `agentshieldOk && zizmorOk && sfwOk` reads - * with the identifier names in alpha order: `agentshieldOk && sfwOk && + * "Sorting" rule, a flag-list chain like `agentshieldOk && zizmorOk && sfwOk` + * reads with the identifier names in alpha order: `agentshieldOk && sfwOk && * zizmorOk`. The runtime is short-circuit-insensitive to operand order _when * every operand is a plain identifier_ (no calls, no member access with * getters) — so reordering doesn't change semantics. Sorting reduces diff * churn when adding a new flag and makes "is everything ready?" checks - * visually consistent. Detects: chains of `&&` or `||` whose operands are ALL - * bare Identifiers (length ≥ 2, no duplicates, uniform operator across the - * flattened chain). Skipped (not reported, autofix-safe stays narrow): + * visually consistent. Scope: lists of flags, not guard pairs. The rule ONLY + * fires on chains of length ≥ 3. Two-operand chains like `useHttp && + * oauthEnabled` are guard patterns — the order carries narrative ("in HTTP + * mode, did OAuth get enabled?") that alpha-sort destroys. Three or more bare + * identifiers in a single chain is the structural signal that it's a flag + * list, not a guard. Detects: chains of `&&` or `||` whose operands are ALL + * bare Identifiers (length ≥ 3, no duplicates, uniform operator across the + * flattened chain). Skipped (not reported): * + * - Length 2 — guard patterns; narrative order is intentional. * - Any operand isn't a bare `Identifier` (Calls / member-access / literals / * negations / nested non-uniform logical exprs short-circuit, and a * `getter` on a member-access can have side effects — reordering would be * observable). * - Duplicate identifiers in the chain (rare, but rewriting through the * duplicate would silently drop one). - * - Comments live between operands (autofix would relocate them). - * - Chain length < 2 (nothing to sort). Why a separate rule from - * sort-equality-disjunctions: that rule sorts the right-hand string-literal - * of an equality chain (`x === 'a' || x === 'b'`); this rule sorts the - * bare-identifier operands of a pure-identifier chain. Structurally - * different ASTs, semantically different safety arguments. + * - Comments live between operands (autofix would relocate them). Why a + * separate rule from sort-equality-disjunctions: that rule sorts the + * right-hand string-literal of an equality chain (`x === 'a' || x === + * 'b'`); this rule sorts the bare-identifier operands of a pure-identifier + * chain. Structurally different ASTs, semantically different safety + * arguments. */ /** @@ -103,7 +109,10 @@ const rule = { const leaves: AstNode[] = [] flatten(rootNode, op, leaves) - if (leaves.length < 2) { + // Length 2 chains are guard patterns (`useHttp && oauthEnabled`) + // where order carries narrative; only length 3+ chains are flag + // lists where alpha-sort is unambiguously a readability win. + if (leaves.length < 3) { return } diff --git a/.config/oxlint-plugin/test/no-underscore-identifier.test.mts b/.config/oxlint-plugin/test/no-underscore-identifier.test.mts index 55c0baa..bffa1bf 100644 --- a/.config/oxlint-plugin/test/no-underscore-identifier.test.mts +++ b/.config/oxlint-plugin/test/no-underscore-identifier.test.mts @@ -1,9 +1,9 @@ /** - * @file Unit tests for the no-underscore-identifier oxlint rule. - * Spawns the real oxlint binary against fixture files in a tmp - * dir (see lib/rule-tester.mts). Skips silently when `oxlint` - * isn't on PATH so a fresh-laptop checkout doesn't false-fail - * before `pnpm install` materializes the bin link. + * @file Unit tests for the no-underscore-identifier oxlint rule. Spawns the + * real oxlint binary against fixture files in a tmp dir (see + * lib/rule-tester.mts). Skips silently when `oxlint` isn't on PATH so a + * fresh-laptop checkout doesn't false-fail before `pnpm install` materializes + * the bin link. */ import { describe, test } from 'node:test' diff --git a/.config/oxlint-plugin/test/prefer-cached-for-loop.test.mts b/.config/oxlint-plugin/test/prefer-cached-for-loop.test.mts index e848573..a24f962 100644 --- a/.config/oxlint-plugin/test/prefer-cached-for-loop.test.mts +++ b/.config/oxlint-plugin/test/prefer-cached-for-loop.test.mts @@ -19,6 +19,14 @@ describe('socket/prefer-cached-for-loop', () => { name: 'for-of', code: 'for (const x of [1,2,3]) {}\n', }, + { + name: 'for-of over awaited value — unknown kind, skip autofix', + code: + 'async function f() {\n' + + ' const items = await getThings()\n' + + ' for (const x of items) { console.log(x) }\n' + + '}\n', + }, ], invalid: [ { diff --git a/.config/oxlint-plugin/test/prefer-spawn-over-execsync.test.mts b/.config/oxlint-plugin/test/prefer-spawn-over-execsync.test.mts index 71d2f42..766cf9c 100644 --- a/.config/oxlint-plugin/test/prefer-spawn-over-execsync.test.mts +++ b/.config/oxlint-plugin/test/prefer-spawn-over-execsync.test.mts @@ -1,9 +1,9 @@ /** - * @file Unit tests for the prefer-spawn-over-execsync oxlint rule. - * Spawns the real oxlint binary against fixture files in a tmp - * dir (see lib/rule-tester.mts). Skips silently when `oxlint` - * isn't on PATH so a fresh-laptop checkout doesn't false-fail - * before `pnpm install` materializes the bin link. + * @file Unit tests for the prefer-spawn-over-execsync oxlint rule. Spawns the + * real oxlint binary against fixture files in a tmp dir (see + * lib/rule-tester.mts). Skips silently when `oxlint` isn't on PATH so a + * fresh-laptop checkout doesn't false-fail before `pnpm install` materializes + * the bin link. */ import { describe, test } from 'node:test' @@ -17,13 +17,11 @@ describe('socket/prefer-spawn-over-execsync', () => { valid: [ { name: 'lib-stable spawn import', - code: - "import { spawn } from '@socketsecurity/lib-stable/spawn'\n", + code: "import { spawn } from '@socketsecurity/lib-stable/spawn'\n", }, { name: 'lib-stable spawnSync import', - code: - "import { spawnSync } from '@socketsecurity/lib-stable/spawn'\n", + code: "import { spawnSync } from '@socketsecurity/lib-stable/spawn'\n", }, { name: 'node:child_process spawn (not exec*Sync) is acceptable', @@ -41,18 +39,13 @@ describe('socket/prefer-spawn-over-execsync', () => { }, { name: 'execFileSync from node:child_process', - code: - "import { execFileSync } from 'node:child_process'\n", + code: "import { execFileSync } from 'node:child_process'\n", errors: [{ messageId: 'preferSpawn' }], }, { name: 'mixed execSync + execFileSync', - code: - "import { execSync, execFileSync } from 'node:child_process'\n", - errors: [ - { messageId: 'preferSpawn' }, - { messageId: 'preferSpawn' }, - ], + code: "import { execSync, execFileSync } from 'node:child_process'\n", + errors: [{ messageId: 'preferSpawn' }, { messageId: 'preferSpawn' }], }, ], }) diff --git a/.config/oxlint-plugin/test/socket-api-token-env.test.mts b/.config/oxlint-plugin/test/socket-api-token-env.test.mts index dcfb34b..550bbca 100644 --- a/.config/oxlint-plugin/test/socket-api-token-env.test.mts +++ b/.config/oxlint-plugin/test/socket-api-token-env.test.mts @@ -15,6 +15,13 @@ describe('socket/socket-api-token-env', () => { name: 'canonical SOCKET_API_TOKEN', code: 'const t = process.env["SOCKET_API_TOKEN"]\nconsole.log(t)\n', }, + { + name: 'alias-lookup array with declaration-level bypass comment', + code: + '// socket-api-token-env: bootstrap -- alias-normalization shim.\n' + + "const ALIASES = ['SOCKET_API_TOKEN', 'SOCKET_API_KEY', 'SOCKET_SECURITY_API_TOKEN'] as const\n" + + 'console.log(ALIASES)\n', + }, ], invalid: [ { diff --git a/.config/oxlint-plugin/test/sort-boolean-chains.test.mts b/.config/oxlint-plugin/test/sort-boolean-chains.test.mts index 7c04eab..6f12230 100644 --- a/.config/oxlint-plugin/test/sort-boolean-chains.test.mts +++ b/.config/oxlint-plugin/test/sort-boolean-chains.test.mts @@ -31,6 +31,14 @@ describe('socket/sort-boolean-chains', () => { name: 'single operand — not a chain', code: 'export const r = (a: boolean) => a\n', }, + { + name: 'two-operand guard pair — narrative order preserved', + code: 'export const r = (useHttp: boolean, oauthEnabled: boolean) => useHttp && oauthEnabled\n', + }, + { + name: 'two-operand reversed guard pair — still not sorted', + code: 'export const r = (b: boolean, a: boolean) => b && a\n', + }, { name: 'duplicates skipped', code: 'export const r = (b: boolean, a: boolean) => b && a && b\n', diff --git a/docs/claude.md/fleet/sorting.md b/docs/claude.md/fleet/sorting.md index 2f7b5cc..9ea6ea6 100644 --- a/docs/claude.md/fleet/sorting.md +++ b/docs/claude.md/fleet/sorting.md @@ -12,7 +12,7 @@ Sort lists alphanumerically (literal byte order, ASCII before letters). - **`Set` constructor arguments** — `new Set([...])` and `new SafeSet([...])` literals. The runtime is order-insensitive, so source order is alphanumeric. Same rationale as Array literals: predictable diffs, no merge conflicts on insertions. - **Regex alternation groups** — `(foo|bar|baz)` reads as `(bar|baz|foo)`. Capturing, non-capturing, and named-capture groups all follow the rule. Auto-fixable when every alternative is a simple literal. The exception is order-bearing alternations where the regex engine MUST try one alternative before another (rare; the canonical example is markup parsers where `` would silently mismatch if reordered) — append `// socket-hook: allow regex-alternation-order` on those lines. - **String-equality disjunctions** — `x === 'a' || x === 'b' || x === 'c'` reads with the comparand strings in alpha order. The De Morgan dual `x !== 'a' && x !== 'b'` (negative-membership check) follows the same rule. The `||` chain short-circuits regardless of operand order; sorting reduces diff churn when adding new comparands and makes "is X in this set?" checks visually consistent. Auto-fixable when every clause has the same left operand and uses string-literal comparands. Mixed shape (different left, different operator, non-string right) is skipped — those are usually genuine ordering-sensitive predicates and the autofix would change semantics. -- **Boolean identifier chains** — `agentshieldOk && zizmorOk && sfwOk` reads with the names in alpha order: `agentshieldOk && sfwOk && zizmorOk`. Same rule for `||` chains. The lint rule fires only when every leaf is a bare `Identifier` (no calls, no member access, no literals, no negations) — those have side-effect or short-circuit semantics where order can be observable. Duplicate identifiers and chains with interior comments are skipped (the autofix would lose information). Enforced by `socket/sort-boolean-chains`. +- **Boolean identifier chains** — `agentshieldOk && zizmorOk && sfwOk` reads with the names in alpha order: `agentshieldOk && sfwOk && zizmorOk`. Same rule for `||` chains. The lint rule fires only when (1) every leaf is a bare `Identifier` (no calls, no member access, no literals, no negations) — those have side-effect or short-circuit semantics where order can be observable — AND (2) the chain has **3 or more operands**. Two-operand chains like `useHttp && oauthEnabled` are guard patterns where order carries narrative ("in HTTP mode, did OAuth get enabled?") that alpha-sort would destroy; only length-3+ chains are unambiguously flag lists. Duplicate identifiers and chains with interior comments are skipped (the autofix would lose information). Enforced by `socket/sort-boolean-chains`. - **TypeScript union of string literals** — `type Source = 'download' | 'path' | 'vfs'` (not `'vfs' | 'path' | 'download'`). Members are interchangeable at the type level; alpha order makes "which values can this take?" answerable without scanning. Applies to type aliases, inline parameter unions, and template-literal type alternatives. Position-bearing unions (rare — e.g. a discriminator where order encodes priority) keep their meaningful order; append `// socket-hook: allow union-order` on those lines. ## Default diff --git a/packages/build-infra/lib/release-checksums/consumer.mts b/packages/build-infra/lib/release-checksums/consumer.mts index 33222be..f069ffd 100644 --- a/packages/build-infra/lib/release-checksums/consumer.mts +++ b/packages/build-infra/lib/release-checksums/consumer.mts @@ -35,34 +35,41 @@ export interface ChecksumsResult { const checksumCache = new Map() +/** + * Clear the checksum cache. Useful for testing or forcing re-download. + */ +export function clearChecksumCache(): void { + checksumCache.clear() +} + interface GetChecksumsOptions { /** * The producing repo whose releases we're verifying against. */ repoConfig: RepoConfig /** - * Tool name prefix used in the producing repo's release tag (e.g. `iocraft`). + * Tool name prefix used in the producing repo's release tag (e.g. `lief`). */ tool: string /** * Specific tag to fetch. If omitted, uses the embedded tag, then `latest`. */ - releaseTag?: string + releaseTag?: string | undefined /** * Where to cache the downloaded `checksums.txt`. Defaults to * `/build/temp`. */ - tempDir?: string + tempDir?: string | undefined /** * Suppress info/warn logging (errors still log). */ - quiet?: boolean + quiet?: boolean | undefined /** * If true (default), use embedded checksums when available even if a network * fetch could find newer ones. Set false to force a network fetch — useful * when bumping checksums. */ - preferEmbedded?: boolean + preferEmbedded?: boolean | undefined } /** @@ -208,10 +215,3 @@ export async function getReleaseChecksums( return { checksums: {}, source: 'network', tag } } } - -/** - * Clear the checksum cache. Useful for testing or forcing re-download. - */ -export function clearChecksumCache(): void { - checksumCache.clear() -} diff --git a/packages/build-infra/lib/release-checksums/core.mts b/packages/build-infra/lib/release-checksums/core.mts index 7be5e63..aaa1ae5 100644 --- a/packages/build-infra/lib/release-checksums/core.mts +++ b/packages/build-infra/lib/release-checksums/core.mts @@ -29,7 +29,7 @@ const __dirname = path.dirname(__filename) // --------------------------------------------------------------------------- export interface ToolConfig { - description?: string + description?: string | undefined tag: string checksums: Record } @@ -37,10 +37,10 @@ export interface ToolConfig { export type EmbeddedChecksums = Record export interface VerifyResult { - actual?: string - expected?: string - source?: string - skipped?: boolean + actual?: string | undefined + expected?: string | undefined + source?: string | undefined + skipped?: boolean | undefined valid: boolean } @@ -55,27 +55,16 @@ export interface VerifyResult { let embeddedChecksums: EmbeddedChecksums | undefined | null -export function getEmbeddedChecksums(): EmbeddedChecksums | undefined { - if (embeddedChecksums === null) { - return undefined - } - if (embeddedChecksums === undefined) { - try { - const checksumPath = path.join( - __dirname, - '..', - '..', - 'release-assets.json', - ) - embeddedChecksums = JSON.parse( - readFileSync(checksumPath, 'utf8'), - ) as EmbeddedChecksums - } catch { - embeddedChecksums = null - return undefined - } +/** + * Compute SHA256 hash of a file as lowercase hex. + */ +export async function computeFileHash(filePath: string): Promise { + const hash = crypto.createHash('sha256') + const stream = createReadStream(filePath) + for await (const chunk of stream) { + hash.update(chunk) } - return embeddedChecksums + return hash.digest('hex') } export function getEmbeddedChecksum( @@ -97,20 +86,27 @@ export function getEmbeddedChecksum( return { checksum, tag: toolConfig.tag } } -// --------------------------------------------------------------------------- -// Format primitives. -// --------------------------------------------------------------------------- - -/** - * Compute SHA256 hash of a file as lowercase hex. - */ -export async function computeFileHash(filePath: string): Promise { - const hash = crypto.createHash('sha256') - const stream = createReadStream(filePath) - for await (const chunk of stream) { - hash.update(chunk) +export function getEmbeddedChecksums(): EmbeddedChecksums | undefined { + if (embeddedChecksums === null) { + return undefined } - return hash.digest('hex') + if (embeddedChecksums === undefined) { + try { + const checksumPath = path.join( + __dirname, + '..', + '..', + 'release-assets.json', + ) + embeddedChecksums = JSON.parse( + readFileSync(checksumPath, 'utf8'), + ) as EmbeddedChecksums + } catch { + embeddedChecksums = undefined + return undefined + } + } + return embeddedChecksums } /** @@ -136,15 +132,11 @@ export function parseChecksums(content: string): Record { return checksums } -// --------------------------------------------------------------------------- -// Verify. -// --------------------------------------------------------------------------- - interface VerifyOptions { filePath: string assetName: string tool: string - quiet?: boolean + quiet?: boolean | undefined } /** diff --git a/packages/build-infra/release-assets.schema.json b/packages/build-infra/release-assets.schema.json index d049c86..4044659 100644 --- a/packages/build-infra/release-assets.schema.json +++ b/packages/build-infra/release-assets.schema.json @@ -29,7 +29,7 @@ }, "tag": { "type": "string", - "description": "Full release tag, including the tool prefix (e.g. `iocraft-20260424-18f0f46`).", + "description": "Full release tag, including the tool prefix (e.g. `lief-20260507-76c1796`).", "minLength": 1 }, "checksums": { diff --git a/scripts/install-token-minifier.mts b/scripts/install-token-minifier.mts index 2430a2e..46af914 100644 --- a/scripts/install-token-minifier.mts +++ b/scripts/install-token-minifier.mts @@ -94,7 +94,7 @@ interface CatalogYamlMap { * line-shaped (key: value) and we only need the @socketsecurity/* entries the * proxy actually references. */ -function readNeededCatalogEntries(): CatalogYamlMap { +export function readNeededCatalogEntries(): CatalogYamlMap { const yamlPath = path.join(WHEELHOUSE_ROOT, 'pnpm-workspace.yaml') const text = readFileSync(yamlPath, 'utf8') const lines = text.split('\n') @@ -140,7 +140,7 @@ function readNeededCatalogEntries(): CatalogYamlMap { * catalog aliases the package source declares. Keeps imports of * `@socketsecurity/lib-stable/...` resolvable from inside the install. */ -function writeInstallWorkspaceYaml(catalog: CatalogYamlMap): void { +export function writeInstallWorkspaceYaml(catalog: CatalogYamlMap): void { const lines = ['catalog:'] for (const [k, v] of Object.entries(catalog)) { // Quote values that aren't bare versions (e.g. `npm:foo@1.0.0`). @@ -161,7 +161,7 @@ function writeInstallWorkspaceYaml(catalog: CatalogYamlMap): void { * Stripping `bin`/`exports` keeps pnpm from trying to wire global binaries at * install time — we drop our own shim explicitly. */ -function writeInstallPackageJson(sourceVersion: string): void { +export function writeInstallPackageJson(sourceVersion: string): void { const sourcePkg = JSON.parse( readFileSync(path.join(PKG_SOURCE_DIR, 'package.json'), 'utf8'), ) @@ -190,7 +190,7 @@ function writeInstallPackageJson(sourceVersion: string): void { * `fs.cp` with recursive + force is the cross-platform equivalent of `cp -r`. * Force overwrites stale files on reinstall. */ -function copySource(): void { +export function copySource(): void { // Use sync fs API for consistency with the rest of the script — this // is a one-shot install, not a hot path. `cpSync` exists since // Node 20; the recursive option is required for directories. @@ -207,14 +207,14 @@ function copySource(): void { * when the recorded x-source-version in the dest's package.json differs from * the source. */ -function readSourceVersion(): string { +export function readSourceVersion(): string { const pkg = JSON.parse( readFileSync(path.join(PKG_SOURCE_DIR, 'package.json'), 'utf8'), ) return pkg.version ?? '0.0.0' } -function readInstalledVersion(): string | undefined { +export function readInstalledVersion(): string | undefined { const installedPkgPath = path.join(INSTALL_DIR, 'package.json') if (!existsSync(installedPkgPath)) { return undefined @@ -227,7 +227,7 @@ function readInstalledVersion(): string | undefined { } } -function pnpmInstallAtDest(quiet: boolean): void { +export function pnpmInstallAtDest(quiet: boolean): void { const result = spawnSync( 'pnpm', [ @@ -248,7 +248,7 @@ function pnpmInstallAtDest(quiet: boolean): void { } } -function writeBinShim(): void { +export function writeBinShim(): void { // Shim execs the proxy's top-level bin/ entry. Source lives at // INSTALL_DIR/bin/, NOT under node_modules/ — so Node 22+ can strip // types from the .mts file at runtime. `node` is on PATH on every diff --git a/scripts/validate-bundle-deps.mts b/scripts/validate-bundle-deps.mts index 111007b..c71bb93 100644 --- a/scripts/validate-bundle-deps.mts +++ b/scripts/validate-bundle-deps.mts @@ -23,10 +23,15 @@ const logger = getDefaultLogger() const __dirname = path.dirname(fileURLToPath(import.meta.url)) const rootPath = path.join(__dirname, '..') -// Node.js builtins to ignore (including node: prefix variants) +// Node.js builtins to ignore (including node: prefix variants). +// node:smol-* are Socket SEA-bundled optional builtins (smol-util, smol-primordial); +// they appear in dist behind `mod.isBuiltin('node:smol-util')` guards and are only +// resolvable in SEA binaries, so they should never be expected in dependencies. +const SOCKET_SEA_BUILTINS = ['node:smol-util', 'node:smol-primordial'] const BUILTIN_MODULES = new Set([ ...builtinModules, ...builtinModules.map(m => `node:${m}`), + ...SOCKET_SEA_BUILTINS, ]) /** @@ -364,16 +369,14 @@ async function validateBundleDeps(): Promise { // externals + bundled are Set — use for...of, the // canonical fix for set / map / iterable iteration. - for (let i = 0, { length } = externals; i < length; i += 1) { - const ext = externals[i]! + for (const ext of externals) { const packageName = getPackageName(ext) if (packageName && !BUILTIN_MODULES.has(packageName)) { allExternals.add(packageName) } } - for (let i = 0, { length } = bundled; i < length; i += 1) { - const bun = bundled[i]! + for (const bun of bundled) { allBundled.add(bun) } } diff --git a/scripts/validate-file-size.mts b/scripts/validate-file-size.mts index 19b624a..41b3bcc 100644 --- a/scripts/validate-file-size.mts +++ b/scripts/validate-file-size.mts @@ -23,6 +23,19 @@ const rootPath = path.join(__dirname, '..') // Maximum file size: 2MB (2,097,152 bytes) const MAX_FILE_SIZE = 2 * 1024 * 1024 +// Allowlisted large files: fleet-canonical assets whose size is bounded by +// the upstream they ship, not by repo authoring. acorn.wasm is the AST +// parser shared by AST-based oxlint plugin rules + hooks; its ~3MB is the +// upstream build artifact. Two paths because socket-lib vendors its own +// copy at vendor/acorn-wasm/ (so the lib package's own AST helpers can +// load without a node_modules round-trip). Adding a path here is +// intentional — it should only happen for files the fleet jointly owns, +// not per-repo binary leaks. +const ALLOWED_LARGE_FILES = new Set([ + '.claude/hooks/_shared/acorn/acorn.wasm', + 'vendor/acorn-wasm/acorn.wasm', +]) + // Directories to skip const SKIP_DIRS = new Set([ '.cache', @@ -95,6 +108,9 @@ async function scanDirectory( const stats = await fs.stat(fullPath) if (stats.size > MAX_FILE_SIZE) { const relativePath = path.relative(rootPath, fullPath) + if (ALLOWED_LARGE_FILES.has(relativePath)) { + continue + } violations.push({ file: relativePath, size: stats.size,