From 71bbd2013ac4b18a787d5424bc3d2aab8a5b3168 Mon Sep 17 00:00:00 2001 From: Aaron Ware Date: Sun, 30 Aug 2026 00:16:13 -0400 Subject: [PATCH 1/2] feat(NO-TASK): Require trust for repository hooks and contain path arguments Closes four ways a cloned repository could execute code or destroy data through the wt surface. This matters more than usual here because the CLI is designed to be pre-approved in an agent allowlist: a rule like Bash(linchpin wt:*) matches the verb by prefix and cannot see the argument or the repository contents. Hook trust. .linchpin/hooks/* are committed files, so they arrive with a clone -- unlike .git/hooks, which git deliberately refuses to transfer for exactly this reason. Sourced rather than executed, a hook needs no execute bit and no shebang, so nothing in a diff marks it as code that will run. Hooks now do nothing until the machine approves them, keyed to a sha256 of the contents, so editing a trusted hook withdraws its trust. Adds `linchpin wt trust` to list, grant, --all and --revoke. Follows direnv and mise in blocking with instructions rather than prompting. A blocked hook does not fail the operation, and a hook that runs prints its path. Path containment. findHookFile joined an argv value straight onto the hooks directory and the result was sourced as bash, so `wt invoke ../../../payload` ran any file on the machine; wt copy and wt link shared the pattern feeding cpSync and symlinkSync. All three now resolve through a containment helper. Destructive switch. The target of the recursive delete behind `wt switch --force` comes from the committed .linchpin.json, so a repository chose what got removed. The target must now sit inside a WordPress content directory, and --force asks before destroying anything, refusing rather than assuming consent when there is no TTY. --yes is the explicit scriptable answer. Behaviour change for automation: scripts calling `wt switch --force` need --yes, and CI relying on hooks needs them trusted first. See docs/hooks.md. resolveContained uses path.resolve rather than path.join, because join folds an absolute segment into the root instead of refusing it. isContainedAfterLinks resolves both sides, since git tracks symlinks and a candidate-only realpath would compare /private/tmp against /tmp and reject every legitimate path on macOS. Trust keys on the hook realpath for the same reason. Co-Authored-By: Claude Opus 5 (1M context) --- docs/hooks.md | 36 +++++- legacy/commands/wt.js | 176 ++++++++++++++++++++++++-- legacy/lib/hooks.js | 31 ++++- legacy/lib/paths.js | 87 +++++++++++++ legacy/lib/symlink.js | 30 +++++ legacy/lib/trust.js | 146 ++++++++++++++++++++++ src/core/hooks.ts | 34 ++++- src/core/paths.ts | 115 +++++++++++++++++ src/core/symlink.ts | 37 ++++++ src/core/trust.ts | 168 +++++++++++++++++++++++++ src/index.ts | 21 ++++ test-utils/cli-fixture.js | 53 +++++++- test/core-symlink-hooks.test.js | 72 ++++++++--- test/paths.test.js | 206 ++++++++++++++++++++++++++++++ test/trust.test.js | 214 ++++++++++++++++++++++++++++++++ test/wt-readonly.test.js | 4 +- 16 files changed, 1396 insertions(+), 34 deletions(-) create mode 100644 legacy/lib/paths.js create mode 100644 legacy/lib/trust.js create mode 100644 src/core/paths.ts create mode 100644 src/core/trust.ts create mode 100644 test/paths.test.js create mode 100644 test/trust.test.js diff --git a/docs/hooks.md b/docs/hooks.md index f7cf098..746cb9b 100644 --- a/docs/hooks.md +++ b/docs/hooks.md @@ -1,7 +1,8 @@ # Hooks Every worktree operation can run a project script before and after it. Put an executable-or-not -file at `.linchpin/hooks/` and it runs at that point. +file at `.linchpin/hooks/`, approve it with `linchpin wt trust`, and it runs at that +point. ```bash # .linchpin/hooks/post-switch @@ -55,10 +56,41 @@ deliberate: The hook path is passed as an argument (`$1`) rather than interpolated into the script, so a path containing spaces or shell metacharacters stays data. +## Hooks must be trusted before they run + +`.linchpin/hooks/` is committed, so hooks arrive with a `git clone` — unlike `.git/hooks`, +which git deliberately refuses to transfer for exactly this reason. Combined with sourcing, +that would make cloning a repository equivalent to running whatever it shipped: no execute bit, +no shebang, nothing in a diff marking the file as code that will run. + +So a hook does nothing until this machine has approved it, the same way `direnv` and `mise` +handle `.envrc`: + +```bash +linchpin wt trust # list this repo's hooks and their state +linchpin wt trust post-switch # approve one, after reading it +linchpin wt trust --all # approve every hook in the repo +linchpin wt trust post-switch --revoke # withdraw approval +``` + +An untrusted hook is skipped with a message naming the file and the command that would approve +it. The operation itself still succeeds — a blocked hook is not a failed switch. + +**Approval covers the contents, not the filename.** Trust is recorded against a hash of the +file, so editing a trusted hook withdraws its trust automatically and it has to be reviewed +again. Pulling a branch that changes a hook you trusted last week does not inherit that trust. + +Approvals are per-machine and live outside the repository — at `$XDG_DATA_HOME/linchpin/trust.json`, +or `~/.local/share/linchpin/trust.json` by default, overridable with `LINCHPIN_TRUST_FILE`. A +repository cannot grant its own trust. + ## Guardrails - **A failing hook fails the operation.** That is intentional — a `pre-switch` that cannot prepare the environment should stop the switch rather than let it half-happen. - **Hooks run with your full environment and privileges.** They are ordinary shell scripts in - your repository; review them the way you would review any other code you run. + your repository; review them before trusting them, the way you would review any other code + you run. +- **A hook that runs says so.** Each one prints `Ran hook: ` to stderr, so sourcing a file + from the repo is never silent. - **Keep them fast.** A hook on `post-switch` runs every time anyone changes branch. diff --git a/legacy/commands/wt.js b/legacy/commands/wt.js index 37cf0e2..a6c47eb 100644 --- a/legacy/commands/wt.js +++ b/legacy/commands/wt.js @@ -18,7 +18,17 @@ const { writeDefaultConfig } = require('../lib/config'); const { ensurePluginLink, readExistingTarget } = require('../lib/symlink'); -const { findHookFile, runHook } = require('../lib/hooks'); +const { findHookFile, runHook: runHookRaw } = require('../lib/hooks'); +const { + describeUntrustedHook, + hashHookFile, + isHookTrusted, + readTrustStore, + revokeHook, + trustFilePath, + trustHook +} = require('../lib/trust'); +const { requireContained } = require('../lib/paths'); /** Environment types: base path getters for config init. */ const ENV_TYPE_BASES = Object.freeze({ @@ -108,6 +118,29 @@ function buildTargetPath(envType, site, contentDir, slug, wpEnvBase, linkName) { return ''; } +/** + * Run a hook and say so. + * + * Every lifecycle call goes through here rather than through the raw runner, so + * two things are always true: a blocked hook explains itself and the command + * carries on, and a hook that *does* run names itself first. Sourcing a file + * from the repo is never silent in either direction. + * + * Both lines go to stderr — stdout carries the worktree path that + * `cd "$(linchpin wt switch)"` consumes. + */ +function runHook(basePath, hookName, env, options) { + const result = runHookRaw(basePath, hookName, env, options); + + if (result.blocked) { + process.stderr.write(`${result.reason}\n`); + } else if (result.ran) { + process.stderr.write(`Ran hook: ${result.hookFile}\n`); + } + + return result; +} + function runWt(argv, options = {}) { const cwd = options.cwd || process.cwd(); const command = argv[0] || 'help'; @@ -144,6 +177,8 @@ function runWt(argv, options = {}) { return commandLink(cwd, argv.slice(1)); case 'invoke': return commandInvoke(cwd, argv.slice(1)); + case 'trust': + return commandTrust(cwd, argv.slice(1)); case 'config': return commandConfig(cwd, argv.slice(1)); case 'help': @@ -256,6 +291,36 @@ async function commandSwitch(cwd, argv) { LINCHPIN_ENVIRONMENT: environmentName }; + // A --force that replaces a real directory is the one destructive thing this + // command does, and the path it deletes comes from the committed config. Ask + // before doing it, and refuse rather than assume consent when there is no one + // to ask. `--yes` is the explicit, scriptable answer. + if (options.force && !options.dryRun) { + const existingTarget = readExistingTarget(targetPath); + + if (existingTarget.exists && !existingTarget.isSymlink) { + if (!options.yes) { + if (!process.stdin.isTTY) { + throw new Error( + `Refusing to replace ${targetPath} without confirmation. ` + + `Re-run with --yes if you intend to delete it.` + ); + } + + const { confirm } = await import('@inquirer/prompts'); + const approved = await confirm({ + message: `Permanently delete ${targetPath} and replace it with a symlink?`, + default: false + }); + + if (!approved) { + process.stderr.write('Cancelled; nothing was changed.\n'); + return 0; + } + } + } + } + if (!options.dryRun) { runHook(basePath, 'pre-switch', switchEnv); } @@ -603,8 +668,10 @@ function commandCopy(cwd, argv) { assertInLinkedWorktree(cwd, basePath); const currentPath = getCurrentTopLevel(cwd); - const source = path.join(basePath, target); - const destination = path.join(currentPath, target); + // `target` is argv, and it feeds a recursive copy at both ends. Contained so + // `../../..` cannot read outside the base worktree or write outside this one. + const source = requireContained(basePath, target, 'the base worktree'); + const destination = requireContained(currentPath, target, 'the current worktree'); if (!pathExists(source)) { throw new Error(`'${target}' does not exist in base worktree.`); @@ -629,8 +696,10 @@ function commandLink(cwd, argv) { assertInLinkedWorktree(cwd, basePath); const currentPath = getCurrentTopLevel(cwd); - const source = path.join(basePath, target); - const destination = path.join(currentPath, target); + // Same containment as `copy`: this creates a symlink and may unlink whatever + // sits at the destination, so neither end may leave its worktree. + const source = requireContained(basePath, target, 'the base worktree'); + const destination = requireContained(currentPath, target, 'the current worktree'); if (!pathExists(source)) { throw new Error(`'${target}' does not exist in base worktree.`); @@ -665,14 +734,97 @@ function commandInvoke(cwd, argv) { const hookFile = findHookFile(basePath, hookName); if (!hookFile) { + // Also the answer when the name escaped the hooks directory or resolved + // through a symlink out of it — both are "no such hook" from here. throw new Error(`Hook '${hookName}' does not exist in .linchpin/hooks.`); } - runHook(basePath, hookName); + const result = runHook(basePath, hookName); + + if (result.blocked) { + // The wrapper already explained why on stderr; the exit code is what a + // script or an agent reads. + return 1; + } + process.stdout.write(`Ran ${hookFile}\n`); return 0; } +/** + * `linchpin wt trust` — review and approve the hooks this repo ships. + * + * Approval is recorded against the hook's **contents**, so editing a trusted + * hook withdraws its trust automatically and it must be reviewed again. + */ +function commandTrust(cwd, argv) { + const basePath = getBaseWorktreePath(cwd); + const hooksDir = path.join(basePath, '.linchpin', 'hooks'); + const revoking = argv.includes('--revoke'); + const all = argv.includes('--all'); + const name = argv.find((token) => !token.startsWith('-')); + + const present = listRepoHooks(hooksDir); + + if (!name && !all) { + if (present.length === 0) { + process.stdout.write(`No hooks in ${hooksDir}\n`); + return 0; + } + + process.stdout.write(`Hooks in ${hooksDir}\n`); + for (const hookName of present) { + const hookFile = path.join(hooksDir, hookName); + const state = isHookTrusted(hookFile) ? 'trusted' : 'UNTRUSTED'; + process.stdout.write(` ${state.padEnd(10)} ${hookName}\n`); + } + process.stdout.write(`\nTrust file: ${trustFilePath()}\n`); + return 0; + } + + const targets = all ? present : [name]; + + if (targets.length === 0) { + throw new Error(`No hooks found in ${hooksDir}.`); + } + + for (const hookName of targets) { + const hookFile = findHookFile(basePath, hookName); + + if (!hookFile) { + throw new Error(`Hook '${hookName}' does not exist in .linchpin/hooks.`); + } + + if (revoking) { + const removed = revokeHook(hookFile); + process.stdout.write(`${removed ? 'Revoked' : 'Was not trusted'}: ${hookName}\n`); + continue; + } + + const digest = trustHook(hookFile); + if (!digest) { + throw new Error(`Could not record trust for ${hookFile}.`); + } + + process.stdout.write(`Trusted ${hookName} (${digest.slice(0, 12)})\n`); + } + + return 0; +} + +/** Hook filenames in a repo's hooks directory, sorted. Missing directory is empty. */ +function listRepoHooks(hooksDir) { + try { + return fs + .readdirSync(hooksDir, { withFileTypes: true }) + .filter((entry) => entry.isFile()) + .map((entry) => entry.name) + .sort(); + } catch (_error) { + return []; + } +} + async function runConfigInitPrompts(basePath, options = {}) { const { confirm, input, select } = await import('@inquirer/prompts'); const CONFIG_FILE_NAME = '.linchpin.json'; @@ -1067,6 +1219,7 @@ function parseSwitchArgs(argv) { let environment = null; let force = false; let dryRun = false; + let yes = false; for (let index = 0; index < argv.length; index += 1) { const token = argv[index]; @@ -1087,6 +1240,11 @@ function parseSwitchArgs(argv) { continue; } + if (token === '--yes' || token === '-y') { + yes = true; + continue; + } + if (token === '--dry-run') { dryRun = true; continue; @@ -1104,7 +1262,8 @@ function parseSwitchArgs(argv) { ref, environment, force, - dryRun + dryRun, + yes }; } @@ -1196,7 +1355,7 @@ function printWtHelp() { process.stdout.write(`Usage:\n`); process.stdout.write(` linchpin wt ls [--json]\n`); process.stdout.write(` linchpin wt current [--link] [--env ]\n`); - process.stdout.write(` linchpin wt switch [worktree|branch] [--env ] [--force] [--dry-run]\n`); + process.stdout.write(` linchpin wt switch [worktree|branch] [--env ] [--force] [--yes] [--dry-run]\n`); process.stdout.write(` linchpin wt new [name]\n`); process.stdout.write(` linchpin wt get \n`); process.stdout.write(` linchpin wt extract\n`); @@ -1209,6 +1368,7 @@ function printWtHelp() { process.stdout.write(` linchpin wt copy \n`); process.stdout.write(` linchpin wt link \n`); process.stdout.write(` linchpin wt invoke \n`); + process.stdout.write(` linchpin wt trust [|--all] [--revoke]\n`); process.stdout.write(` linchpin wt config init [--plugin-slug ] [--force] [--no-interactive]\n`); process.stdout.write(` linchpin wt config show\n`); process.stdout.write(`\n`); diff --git a/legacy/lib/hooks.js b/legacy/lib/hooks.js index 0b60867..3037fd4 100644 --- a/legacy/lib/hooks.js +++ b/legacy/lib/hooks.js @@ -1,9 +1,27 @@ const fs = require('node:fs'); const path = require('node:path'); const { runCommand } = require('./shell'); +const { isContainedAfterLinks, resolveContained } = require('./paths'); +const { describeUntrustedHook, isHookTrusted } = require('./trust'); +/** + * Resolve `.linchpin/hooks/`, or null when there is no such hook. + * + * `hookName` comes from argv via `wt invoke` and the result is sourced as + * bash, so the join is contained rather than plain — see src/core/paths.ts. + */ function findHookFile(basePath, hookName) { - const hookFile = path.join(basePath, '.linchpin', 'hooks', hookName); + const hooksRoot = path.join(basePath, '.linchpin', 'hooks'); + const hookFile = resolveContained(hooksRoot, hookName); + + if (hookFile === null) { + return null; + } + + if (!isContainedAfterLinks(hooksRoot, hookFile)) { + return null; + } + if (fs.existsSync(hookFile) && fs.statSync(hookFile).isFile()) { return hookFile; } @@ -21,6 +39,17 @@ function runHook(basePath, hookName, env = {}, options = {}) { }; } + // Fail closed: a committed hook runs only once this machine has approved + // these exact bytes. See legacy/lib/trust.js. + if (!isHookTrusted(hookFile)) { + return { + ran: false, + hookFile, + blocked: true, + reason: describeUntrustedHook(hookFile) + }; + } + const execOptions = { env: { ...process.env, diff --git a/legacy/lib/paths.js b/legacy/lib/paths.js new file mode 100644 index 0000000..bcc1c14 --- /dev/null +++ b/legacy/lib/paths.js @@ -0,0 +1,87 @@ +const fs = require('node:fs'); +const path = require('node:path'); + +/** + * CommonJS twin of src/core/paths.ts. + * + * ⚠️ Kept byte-for-byte equivalent in behaviour, not merely similar. The + * dual-mode tests in test/paths.test.js assert both answer identically for + * every case, the same way the hooks and symlink pairs are held together while + * the port is in flight. Change one, change the other. + */ + +/** + * Resolve `segments` under `root`, or null when the result escapes it. + * + * `path.resolve` rather than `path.join` so an absolute segment is rejected + * outright instead of being silently folded into the root. + */ +function resolveContained(root, ...segments) { + const base = path.resolve(root); + const resolved = path.resolve(base, ...segments); + + return isContained(base, resolved) ? resolved : null; +} + +/** + * Is `candidate` `base` itself, or something beneath it? + * + * The trailing separator stops `/repo-evil` matching a base of `/repo`. + */ +function isContained(base, candidate) { + const resolvedBase = path.resolve(base); + const resolvedCandidate = path.resolve(candidate); + + if (resolvedCandidate === resolvedBase) return true; + + const prefix = resolvedBase.endsWith(path.sep) ? resolvedBase : `${resolvedBase}${path.sep}`; + return resolvedCandidate.startsWith(prefix); +} + +/** + * Containment that a symlink cannot talk its way out of. + * + * Both sides are realpath'd so a symlinked root (macOS `/tmp` → + * `/private/tmp`) does not reject every legitimate path. A path that does not + * exist yet falls back to the lexical answer. + */ +function isContainedAfterLinks(base, candidate) { + if (!isContained(base, candidate)) return false; + + let realBase; + let realCandidate; + + try { + realBase = fs.realpathSync(base); + } catch (_error) { + return true; + } + + try { + realCandidate = fs.realpathSync(candidate); + } catch (_error) { + return true; + } + + return isContained(realBase, realCandidate); +} + +/** `resolveContained`, but it throws the message a user should see. */ +function requireContained(root, segment, label) { + const resolved = resolveContained(root, segment); + + if (resolved === null) { + throw new Error( + `'${segment}' resolves outside ${label}. Paths must stay within ${path.resolve(root)}.` + ); + } + + return resolved; +} + +module.exports = { + isContained, + isContainedAfterLinks, + requireContained, + resolveContained +}; diff --git a/legacy/lib/symlink.js b/legacy/lib/symlink.js index daa92d7..7e9a72e 100644 --- a/legacy/lib/symlink.js +++ b/legacy/lib/symlink.js @@ -1,6 +1,26 @@ const fs = require('node:fs'); const path = require('node:path'); +/** + * Does this path look like a slot a WordPress install actually owns? + * + * Guards the recursive delete below, whose target comes from the committed + * .linchpin.json — see src/core/symlink.ts for the full reasoning. + */ +function isWordPressContentTarget(targetPath) { + const segments = path.resolve(targetPath).split(path.sep).filter(Boolean); + if (segments.length === 0) { + return false; + } + + if (segments.includes('wp-content')) { + return true; + } + + const parent = segments[segments.length - 2]; + return parent === 'plugins' || parent === 'themes' || parent === 'mu-plugins'; +} + function ensurePluginLink({ sourcePath, targetPath, force = false, dryRun = false }) { const resolvedSource = path.resolve(sourcePath); const resolvedTarget = path.resolve(targetPath); @@ -50,6 +70,15 @@ function ensurePluginLink({ sourcePath, targetPath, force = false, dryRun = fals ); } + // --force authorises replacing a WordPress content slot, not deleting an + // arbitrary path a cloned repo happened to name. + if (!isWordPressContentTarget(resolvedTarget)) { + throw new Error( + `Refusing to delete ${resolvedTarget}: it is not inside a WordPress content directory. ` + + `Check wordpress.environments in .linchpin.json.` + ); + } + if (!dryRun) { fs.rmSync(resolvedTarget, { force: true, recursive: true }); } @@ -137,5 +166,6 @@ function safeReadlinkResolved(linkPath) { module.exports = { ensurePluginLink, + isWordPressContentTarget, readExistingTarget }; diff --git a/legacy/lib/trust.js b/legacy/lib/trust.js new file mode 100644 index 0000000..cf4d1f1 --- /dev/null +++ b/legacy/lib/trust.js @@ -0,0 +1,146 @@ +const { createHash } = require('node:crypto'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +/** + * CommonJS twin of src/core/trust.ts — which carries the full reasoning. + * + * ⚠️ Behaviour must match exactly; test/trust.test.js asserts both answer the + * same for every case, as with the hooks, symlink and paths pairs. + */ + +const EMPTY_TRUST_STORE = { hooks: {} }; + +function trustFilePath() { + const explicit = process.env.LINCHPIN_TRUST_FILE && process.env.LINCHPIN_TRUST_FILE.trim(); + if (explicit) { + return explicit; + } + + const xdg = process.env.XDG_DATA_HOME && process.env.XDG_DATA_HOME.trim(); + if (xdg) { + return path.join(xdg, 'linchpin', 'trust.json'); + } + + if (process.platform === 'win32') { + const local = process.env.LOCALAPPDATA && process.env.LOCALAPPDATA.trim(); + if (local) { + return path.join(local, 'linchpin', 'trust.json'); + } + } + + return path.join(os.homedir(), '.local', 'share', 'linchpin', 'trust.json'); +} + +/** + * The key a hook is stored under — realpath, so /var and /private/var on macOS + * do not file and look up trust under two different names. + */ +function trustKey(hookFile) { + try { + return fs.realpathSync(hookFile); + } catch (_error) { + return path.resolve(hookFile); + } +} + +function hashHookFile(hookFile) { + try { + return createHash('sha256').update(fs.readFileSync(hookFile)).digest('hex'); + } catch (_error) { + return null; + } +} + +/** A corrupt store denies every hook; it is never read as blanket approval. */ +function readTrustStore(filePath = trustFilePath()) { + try { + const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')); + + if (typeof parsed !== 'object' || parsed === null || !parsed.hooks) { + return EMPTY_TRUST_STORE; + } + + if (typeof parsed.hooks !== 'object' || parsed.hooks === null) { + return EMPTY_TRUST_STORE; + } + + const hooks = {}; + for (const [key, value] of Object.entries(parsed.hooks)) { + if (typeof value === 'string') { + hooks[key] = value; + } + } + + return { hooks }; + } catch (_error) { + return EMPTY_TRUST_STORE; + } +} + +function writeTrustStore(store, filePath = trustFilePath()) { + try { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${JSON.stringify(store, null, 2)}\n`, 'utf8'); + return true; + } catch (_error) { + return false; + } +} + +function isHookTrusted(hookFile, filePath = trustFilePath()) { + const digest = hashHookFile(hookFile); + if (digest === null) { + return false; + } + + const store = readTrustStore(filePath); + return store.hooks[trustKey(hookFile)] === digest; +} + +function trustHook(hookFile, filePath = trustFilePath()) { + const digest = hashHookFile(hookFile); + if (digest === null) { + return null; + } + + const store = readTrustStore(filePath); + const hooks = { ...store.hooks, [trustKey(hookFile)]: digest }; + + return writeTrustStore({ hooks }, filePath) ? digest : null; +} + +function revokeHook(hookFile, filePath = trustFilePath()) { + const store = readTrustStore(filePath); + const key = trustKey(hookFile); + + if (!(key in store.hooks)) { + return false; + } + + const hooks = { ...store.hooks }; + delete hooks[key]; + + return writeTrustStore({ hooks }, filePath); +} + +function describeUntrustedHook(hookFile) { + return ( + `Blocked untrusted hook: ${hookFile}\n` + + ` This file is committed to the repository and would be sourced by your shell.\n` + + ` Review it, then run: linchpin wt trust ${path.basename(hookFile)}` + ); +} + +module.exports = { + EMPTY_TRUST_STORE, + describeUntrustedHook, + hashHookFile, + isHookTrusted, + readTrustStore, + revokeHook, + trustFilePath, + trustHook, + writeTrustStore +}; diff --git a/src/core/hooks.ts b/src/core/hooks.ts index 9461cb4..93ff4da 100644 --- a/src/core/hooks.ts +++ b/src/core/hooks.ts @@ -2,6 +2,8 @@ import fs from 'node:fs'; import path from 'node:path'; import { runCommand } from './exec.js'; +import { isContainedAfterLinks, resolveContained } from './paths.js'; +import { describeUntrustedHook, isHookTrusted } from './trust.js'; /** Operations that can carry hooks. */ export const HOOK_OPERATIONS = ['switch', 'new', 'get', 'extract', 'mv', 'del'] as const; @@ -46,11 +48,31 @@ export interface HookEnvironment { export interface HookResult { readonly ran: boolean; readonly hookFile: string | null; + /** True when a hook was found but this machine has not approved its contents. */ + readonly blocked?: boolean; + /** What to show the user when `blocked` — names the file and the remedy. */ + readonly reason?: string; } -/** Resolve `.linchpin/hooks/`, or null when there is no such hook. */ +/** + * Resolve `.linchpin/hooks/`, or null when there is no such hook. + * + * ⚠️ `hookName` reaches here straight from argv via `wt invoke`, and this + * function's answer is **sourced as bash**. A plain `path.join` let + * `../../../payload` normalize its way clear of the repo and run any file on + * the machine, so the join is contained; a symlink pointing out of the hooks + * directory is refused too, since git tracks symlinks and a cloned repo can + * plant one aimed anywhere. + * + * Returning null rather than throwing keeps the 12 lifecycle points quiet when + * a repo simply has no hook; `wt invoke` reports the miss itself. + */ export function findHookFile(basePath: string, hookName: string): string | null { - const hookFile = path.join(basePath, '.linchpin', 'hooks', hookName); + const hooksRoot = path.join(basePath, '.linchpin', 'hooks'); + const hookFile = resolveContained(hooksRoot, hookName); + + if (hookFile === null) return null; + if (!isContainedAfterLinks(hooksRoot, hookFile)) return null; try { if (fs.statSync(hookFile).isFile()) return hookFile; @@ -84,6 +106,14 @@ export function runHook( if (!hookFile) return { ran: false, hookFile: null }; + // ⚠️ Fail closed. A committed hook is code that arrived with a clone, so it + // runs only once this machine has approved these exact bytes — see trust.ts. + // Returning rather than throwing keeps a blocked hook from failing the + // command around it; the caller reports the reason. + if (!isHookTrusted(hookFile)) { + return { ran: false, hookFile, blocked: true, reason: describeUntrustedHook(hookFile) }; + } + runCommand('bash', ['-c', 'source "$1"', 'linchpin-hook', hookFile], { env: { ...process.env, ...env }, ...(options.cwd === undefined ? {} : { cwd: options.cwd }), diff --git a/src/core/paths.ts b/src/core/paths.ts new file mode 100644 index 0000000..e465bf2 --- /dev/null +++ b/src/core/paths.ts @@ -0,0 +1,115 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +/** + * Path containment — proving a caller-supplied path stays where it belongs. + * + * Several commands take a path fragment from argv and join it to a trusted + * root: `wt invoke `, `wt copy `, `wt link `. A bare + * `path.join` is not a boundary. `path.join(root, '../../x')` normalizes + * happily to a sibling of `root`, so the fragment escapes and the command acts + * on a file the user never named a root for. + * + * That matters more here than in most CLIs. This tool is meant to be + * pre-approved in an agent allowlist (`Bash(linchpin wt:*)`), and a prefix rule + * matches the verb without seeing the argument — so an escaping argument is + * pre-approved right along with a legitimate one. + */ + +/** + * Resolve `segments` under `root`, or null when the result escapes it. + * + * ⚠️ `path.resolve`, not `path.join`, and the difference is the point. `join` + * silently swallows an absolute segment into the root + * (`join('/repo', '/etc/passwd')` → `/repo/etc/passwd`); `resolve` lets it win + * (`/etc/passwd`) so the containment check below can *reject* it. Rejecting is + * honest where quietly rewriting the caller's path is not. + */ +export function resolveContained(root: string, ...segments: string[]): string | null { + const base = path.resolve(root); + const resolved = path.resolve(base, ...segments); + + return isContained(base, resolved) ? resolved : null; +} + +/** + * Is `candidate` `base` itself, or something beneath it? + * + * ⚠️ The trailing separator is load-bearing. A bare + * `candidate.startsWith(base)` accepts `/repo-evil` for a base of `/repo`, + * because the string prefix matches across a directory-name boundary. Compare + * against `base + sep` so only a real descendant passes. + */ +export function isContained(base: string, candidate: string): boolean { + const resolvedBase = path.resolve(base); + const resolvedCandidate = path.resolve(candidate); + + if (resolvedCandidate === resolvedBase) return true; + + const prefix = resolvedBase.endsWith(path.sep) ? resolvedBase : `${resolvedBase}${path.sep}`; + return resolvedCandidate.startsWith(prefix); +} + +/** + * Containment that a symlink cannot talk its way out of. + * + * A lexical check is enough for an argument, but not for a path that already + * exists on disk: `.linchpin/hooks/post-switch` can itself be a **symlink**, + * and git tracks symlinks, so a cloned repo can point one anywhere it likes. + * The lexical check passes — the path really is under the root — while the + * bytes come from outside it. + * + * ⚠️ Both sides are realpath'd, never just the candidate. On macOS a temp dir + * is reached through `/tmp` but resolves to `/private/tmp`; resolving only the + * candidate would compare `/private/tmp/...` against `/tmp/...` and reject + * every legitimate path. Resolving both moves them together. + * + * A path that does not exist has no link to follow, so it falls back to the + * lexical answer rather than failing closed — `wt link` legitimately names a + * destination that is not there yet. + */ +export function isContainedAfterLinks(base: string, candidate: string): boolean { + if (!isContained(base, candidate)) return false; + + let realBase: string; + let realCandidate: string; + + try { + realBase = fs.realpathSync(base); + } catch { + // No base on disk means nothing to compare against; the lexical answer stands. + return true; + } + + try { + realCandidate = fs.realpathSync(candidate); + } catch { + // Nothing at the candidate path yet, so no link can be redirecting it. + return true; + } + + return isContained(realBase, realCandidate); +} + +/** + * `resolveContained`, but it throws the message a user should see. + * + * `label` names the boundary in the caller's own vocabulary — "the hooks + * directory", "the base worktree" — because "path escapes root" tells someone + * nothing about which root they crossed. + */ +export function requireContained( + root: string, + segment: string, + label: string +): string { + const resolved = resolveContained(root, segment); + + if (resolved === null) { + throw new Error( + `'${segment}' resolves outside ${label}. Paths must stay within ${path.resolve(root)}.` + ); + } + + return resolved; +} diff --git a/src/core/symlink.ts b/src/core/symlink.ts index 9bb8f5b..c130a86 100644 --- a/src/core/symlink.ts +++ b/src/core/symlink.ts @@ -1,6 +1,32 @@ import fs from 'node:fs'; import path from 'node:path'; +/** + * Does this path look like a slot a WordPress install actually owns? + * + * ⚠️ This is the guard on a recursive delete whose target comes from + * `.linchpin.json` — a **committed** file, so a cloned repo chooses it. Without + * a check, `wt switch --force` would remove any absolute path a repository + * cared to name. + * + * The test mirrors how `buildTargetPath` composes these paths in the first + * place: something under a `wp-content` directory, or a directory that is one. + * A deliberately loose fit — someone's install can live anywhere — but it does + * rule out a home directory, a source tree, or a volume root, which is the + * class of mistake worth refusing. + */ +export function isWordPressContentTarget(targetPath: string): boolean { + const segments = path.resolve(targetPath).split(path.sep).filter(Boolean); + if (segments.length === 0) return false; + + if (segments.includes('wp-content')) return true; + + // A `wp-content` repo may be linked in under its own name, in which case the + // parent is the WordPress root and holds the usual siblings. + const parent = segments[segments.length - 2]; + return parent === 'plugins' || parent === 'themes' || parent === 'mu-plugins'; +} + export interface ExistingTarget { readonly exists: boolean; readonly isSymlink: boolean; @@ -70,6 +96,17 @@ export function ensurePluginLink({ ); } + // `--force` authorises replacing a WordPress content slot. It is not + // authority to delete an arbitrary path, and the path came from a file the + // repository controls, so the shape of the target is checked before the + // recursive remove rather than after. + if (!isWordPressContentTarget(resolvedTarget)) { + throw new Error( + `Refusing to delete ${resolvedTarget}: it is not inside a WordPress content directory. ` + + `Check wordpress.environments in .linchpin.json.` + ); + } + if (!dryRun) fs.rmSync(resolvedTarget, { force: true, recursive: true }); } diff --git a/src/core/trust.ts b/src/core/trust.ts new file mode 100644 index 0000000..260c312 --- /dev/null +++ b/src/core/trust.ts @@ -0,0 +1,168 @@ +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import { homedir } from 'node:os'; +import path from 'node:path'; + +/** + * Which hooks this machine has agreed to run. + * + * `.linchpin/hooks/*` are committed project files, so they arrive with a clone + * — unlike `.git/hooks`, which git deliberately refuses to transfer for exactly + * this reason. A hook is *sourced*, needing no execute bit and no shebang, so + * nothing about the file in a diff marks it as code that will run. + * + * The answer is the one `direnv` and `mise` settled on: a hook does nothing + * until this machine has vouched for its **contents**. Editing a trusted hook + * changes its digest and withdraws that trust, so approval covers the bytes + * that were reviewed rather than the filename. + * + * Trust is per-machine and lives outside the repo — a repo that could grant + * its own trust would grant nothing at all. + */ + +export interface TrustStore { + /** Absolute hook path → sha256 of the contents that were approved. */ + readonly hooks: Record; +} + +export const EMPTY_TRUST_STORE: TrustStore = { hooks: {} }; + +/** + * Where the trust file lives. + * + * State, not cache: a cleared cache should cost a network round trip, never a + * silent re-grant of code execution. Hence XDG_DATA_HOME rather than the + * update checker's XDG_CACHE_HOME. + */ +export function trustFilePath(): string { + const explicit = process.env.LINCHPIN_TRUST_FILE?.trim(); + if (explicit) return explicit; + + const xdg = process.env.XDG_DATA_HOME?.trim(); + if (xdg) return path.join(xdg, 'linchpin', 'trust.json'); + + if (process.platform === 'win32') { + const local = process.env.LOCALAPPDATA?.trim(); + if (local) return path.join(local, 'linchpin', 'trust.json'); + } + + return path.join(homedir(), '.local', 'share', 'linchpin', 'trust.json'); +} + +/** + * The key a hook is stored under. + * + * ⚠️ realpath, not `path.resolve`. The CLI reaches a repo through + * `safeRealpath`, so on macOS it sees `/private/var/…` where a caller may have + * said `/var/…`. Keying on the un-resolved string would file trust under one + * name and look it up under the other — the hook would read as untrusted + * immediately after being trusted. + */ +function trustKey(hookFile: string): string { + try { + return fs.realpathSync(hookFile); + } catch { + return path.resolve(hookFile); + } +} + +/** sha256 of a hook's contents, or null when it cannot be read. */ +export function hashHookFile(hookFile: string): string | null { + try { + return createHash('sha256').update(fs.readFileSync(hookFile)).digest('hex'); + } catch { + return null; + } +} + +/** + * Read the store. Never throws. + * + * ⚠️ An unreadable or malformed store returns **empty**, which denies every + * hook. That is the direction a failure here must fall: a corrupt file may not + * be read as blanket approval. + */ +export function readTrustStore(filePath: string = trustFilePath()): TrustStore { + try { + const parsed: unknown = JSON.parse(fs.readFileSync(filePath, 'utf8')); + + if (typeof parsed !== 'object' || parsed === null || !('hooks' in parsed)) { + return EMPTY_TRUST_STORE; + } + + const { hooks } = parsed as { hooks: unknown }; + if (typeof hooks !== 'object' || hooks === null) return EMPTY_TRUST_STORE; + + const entries = Object.entries(hooks as Record).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string' + ); + + return { hooks: Object.fromEntries(entries) }; + } catch { + return EMPTY_TRUST_STORE; + } +} + +/** Persist the store. Returns whether it landed; a read-only home is not fatal. */ +export function writeTrustStore(store: TrustStore, filePath: string = trustFilePath()): boolean { + try { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${JSON.stringify(store, null, 2)}\n`, 'utf8'); + return true; + } catch { + return false; + } +} + +/** + * Has this exact hook file, with these exact contents, been approved here? + * + * The digest comparison is what makes an edit withdraw trust: a hook approved + * yesterday and rewritten today is a different hook. + */ +export function isHookTrusted(hookFile: string, filePath: string = trustFilePath()): boolean { + const digest = hashHookFile(hookFile); + if (digest === null) return false; + + const store = readTrustStore(filePath); + return store.hooks[trustKey(hookFile)] === digest; +} + +/** Approve a hook's current contents. Returns the digest recorded, or null. */ +export function trustHook(hookFile: string, filePath: string = trustFilePath()): string | null { + const digest = hashHookFile(hookFile); + if (digest === null) return null; + + const store = readTrustStore(filePath); + const hooks = { ...store.hooks, [trustKey(hookFile)]: digest }; + + return writeTrustStore({ hooks }, filePath) ? digest : null; +} + +/** Withdraw approval. Returns whether there was anything to withdraw. */ +export function revokeHook(hookFile: string, filePath: string = trustFilePath()): boolean { + const store = readTrustStore(filePath); + const key = trustKey(hookFile); + + if (!(key in store.hooks)) return false; + + const hooks = { ...store.hooks }; + delete hooks[key]; + + return writeTrustStore({ hooks }, filePath); +} + +/** + * What to tell someone whose hook did not run. + * + * Names the file and the one command that changes the outcome. A blocked hook + * that only says "blocked" turns a security control into a mystery, and a + * mystery gets worked around rather than reviewed. + */ +export function describeUntrustedHook(hookFile: string): string { + return ( + `Blocked untrusted hook: ${hookFile}\n` + + ` This file is committed to the repository and would be sourced by your shell.\n` + + ` Review it, then run: linchpin wt trust ${path.basename(hookFile)}` + ); +} diff --git a/src/index.ts b/src/index.ts index 7abd7ba..d5b49b9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -104,8 +104,16 @@ export { type WordPressConfig, } from './core/config.js'; +export { + isContained, + isContainedAfterLinks, + requireContained, + resolveContained, +} from './core/paths.js'; + export { ensurePluginLink, + isWordPressContentTarget, readExistingTarget, resolveTargetConflict, type ConflictOutcome, @@ -114,6 +122,19 @@ export { type LinkResult, } from './core/symlink.js'; +export { + EMPTY_TRUST_STORE, + describeUntrustedHook, + hashHookFile, + isHookTrusted, + readTrustStore, + revokeHook, + trustFilePath, + trustHook, + writeTrustStore, + type TrustStore, +} from './core/trust.js'; + export { HOOK_OPERATIONS, HOOK_PHASES, diff --git a/test-utils/cli-fixture.js b/test-utils/cli-fixture.js index ed40c55..3509732 100644 --- a/test-utils/cli-fixture.js +++ b/test-utils/cli-fixture.js @@ -1,6 +1,7 @@ const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); +const { createHash } = require('node:crypto'); const { execFileSync, spawnSync } = require('node:child_process'); const BIN_PATH = path.resolve(__dirname, '..', 'dist', 'cli.js'); @@ -9,14 +10,56 @@ const BIN_PATH = path.resolve(__dirname, '..', 'dist', 'cli.js'); // repo. The update notifier is switched off for a different reason: a suite that // reaches the npm registry fails offline, and its stderr notice would show up in // tests asserting on stderr. `test/update.test.js` opts back in deliberately. + +// Per-process so parallel test *files* cannot race on one trust store. Hooks +// are committed files that only run once trusted, so a fixture that wants its +// hooks to fire says so through `trustHooks`. +const TRUST_FILE = path.join(os.tmpdir(), `linchpin-test-trust-${process.pid}.json`); + const CLEAN_ENV = { ...Object.fromEntries( Object.entries(process.env).filter(([key]) => !key.startsWith('GIT_')) ), LINCHPIN_NO_UPDATE_NOTIFIER: '1', - LINCHPIN_CACHE_DIR: path.join(os.tmpdir(), 'linchpin-test-cache') + LINCHPIN_CACHE_DIR: path.join(os.tmpdir(), 'linchpin-test-cache'), + LINCHPIN_TRUST_FILE: TRUST_FILE }; +/** + * Approve every hook currently in a repo, the way a user would after reading + * them. Call it again after writing a new hook — trust follows contents. + */ +function trustHooks(basePath) { + const hooksDir = path.join(basePath, '.linchpin', 'hooks'); + + let store = { hooks: {} }; + try { + store = JSON.parse(fs.readFileSync(TRUST_FILE, 'utf8')); + } catch (_error) { + store = { hooks: {} }; + } + + let entries = []; + try { + entries = fs.readdirSync(hooksDir, { withFileTypes: true }); + } catch (_error) { + return TRUST_FILE; + } + + for (const entry of entries) { + if (!entry.isFile()) continue; + const hookFile = path.join(hooksDir, entry.name); + store.hooks[canonicalPath(hookFile)] = createHash('sha256') + .update(fs.readFileSync(hookFile)) + .digest('hex'); + } + + fs.mkdirSync(path.dirname(TRUST_FILE), { recursive: true }); + fs.writeFileSync(TRUST_FILE, `${JSON.stringify(store, null, 2)}\n`, 'utf8'); + + return TRUST_FILE; +} + function makeTempDir() { return fs.mkdtempSync(path.join(os.tmpdir(), 'linchpin-cli-')); } @@ -111,16 +154,20 @@ function createFixture() { fs.mkdirSync(path.join(basePath, '.linchpin', 'hooks'), { recursive: true }); fs.writeFileSync(path.join(basePath, '.linchpin', 'hooks', 'pre-new'), 'echo pre-new hook\n', 'utf8'); + trustHooks(basePath); + return { root, basePath, defaultBranch, - pluginPath + pluginPath, + trustFile: TRUST_FILE }; } module.exports = { canonicalPath, createFixture, - runCli + runCli, + trustHooks }; diff --git a/test/core-symlink-hooks.test.js b/test/core-symlink-hooks.test.js index 02f2c32..f0222d0 100644 --- a/test/core-symlink-hooks.test.js +++ b/test/core-symlink-hooks.test.js @@ -16,6 +16,20 @@ function tempDir(label) { return fs.mkdtempSync(path.join(os.tmpdir(), `linchpin-${label}-`)); } +// Hooks are committed files and now run only once this machine has approved +// their contents, so a test that wants one to fire says so first — the same +// grant `linchpin wt trust` records. +function writeTrustedHook(root, name, contents) { + const hooksDir = path.join(root, '.linchpin', 'hooks'); + fs.mkdirSync(hooksDir, { recursive: true }); + + const hookFile = path.join(hooksDir, name); + fs.writeFileSync(hookFile, contents, 'utf8'); + lib.trustHook(hookFile); + + return hookFile; +} + // --- ensurePluginLink ------------------------------------------------------- test('ensurePluginLink creates, is idempotent, and repoints', () => { @@ -84,9 +98,10 @@ test('a broken symlink pointing at the intended source is still "already linked" test('refuses to clobber a real directory without force', () => { const root = tempDir('clobber'); const source = path.join(root, 'wt'); - const target = path.join(root, 'real-folder'); + // A real WordPress slot, because --force is only authority over one of those. + const target = path.join(root, 'wp-content', 'plugins', 'fixture'); fs.mkdirSync(source); - fs.mkdirSync(target); + fs.mkdirSync(target, { recursive: true }); fs.writeFileSync(path.join(target, 'important.txt'), 'do not lose me'); assert.throws( @@ -100,6 +115,37 @@ test('refuses to clobber a real directory without force', () => { assert.equal(fs.realpathSync(target), fs.realpathSync(source)); }); +test('--force is not authority to delete a path outside a WordPress install', () => { + const root = tempDir('outside'); + const source = path.join(root, 'wt'); + // The shape a committed .linchpin.json could otherwise point --force at. + const target = path.join(root, 'precious'); + fs.mkdirSync(source); + fs.mkdirSync(target); + fs.writeFileSync(path.join(target, 'important.txt'), 'irreplaceable'); + + assert.throws( + () => lib.ensurePluginLink({ sourcePath: source, targetPath: target, force: true }), + /not inside a WordPress content directory/ + ); + assert.equal( + fs.readFileSync(path.join(target, 'important.txt'), 'utf8'), + 'irreplaceable', + 'data was destroyed despite the refusal' + ); +}); + +test('isWordPressContentTarget recognises the slots wt switch builds', () => { + assert.equal(lib.isWordPressContentTarget('/srv/site/wp-content/plugins/acme'), true); + assert.equal(lib.isWordPressContentTarget('/srv/site/wp-content/themes/acme'), true); + assert.equal(lib.isWordPressContentTarget('/srv/site/wp-content'), true); + assert.equal(lib.isWordPressContentTarget('/srv/site/public/plugins/acme'), true); + + assert.equal(lib.isWordPressContentTarget('/Users/someone'), false); + assert.equal(lib.isWordPressContentTarget('/Users/someone/Documents'), false); + assert.equal(lib.isWordPressContentTarget('/'), false); +}); + test('dry run reports without touching the filesystem', () => { const root = tempDir('dry'); const source = path.join(root, 'wt'); @@ -238,13 +284,11 @@ test('hooks are SOURCED, not executed — no shebang or execute bit needed', () // Sourcing is what lets a hook export variables and define functions for the // caller. It also means most people's hooks (no shebang, not chmod +x) work. const root = tempDir('hooksource'); - const hooksDir = path.join(root, '.linchpin', 'hooks'); const outFile = path.join(root, 'proof.txt'); - fs.mkdirSync(hooksDir, { recursive: true }); - fs.writeFileSync(path.join(hooksDir, 'post-switch'), `echo sourced > "${outFile}"\n`); + const hookFile = writeTrustedHook(root, 'post-switch', `echo sourced > "${outFile}"\n`); // Deliberately not executable and with no shebang. - fs.chmodSync(path.join(hooksDir, 'post-switch'), 0o644); + fs.chmodSync(hookFile, 0o644); const result = lib.runHook(root, 'post-switch'); @@ -256,12 +300,11 @@ test('the hook environment contract is honored', () => { // These names are a documented public API — someone's post-switch depends on // them, so renaming one is a breaking change. const root = tempDir('hookenv'); - const hooksDir = path.join(root, '.linchpin', 'hooks'); const outFile = path.join(root, 'env.txt'); - fs.mkdirSync(hooksDir, { recursive: true }); - fs.writeFileSync( - path.join(hooksDir, 'post-mv'), + writeTrustedHook( + root, + 'post-mv', `printf '%s|%s|%s|%s|%s' ` + `"$LINCHPIN_WORKTREE" "$LINCHPIN_BRANCH" "$LINCHPIN_ENVIRONMENT" ` + `"$LINCHPIN_OLD_BRANCH" "$LINCHPIN_OLD_WORKTREE" > "${outFile}"\n` @@ -293,13 +336,11 @@ test('unset hook variables are absent rather than the string "undefined"', () => test('post-switch runs with cwd set to the new worktree', () => { const root = tempDir('hookcwd'); - const hooksDir = path.join(root, '.linchpin', 'hooks'); const worktree = path.join(root, 'the-worktree'); const outFile = path.join(root, 'cwd.txt'); - fs.mkdirSync(hooksDir, { recursive: true }); fs.mkdirSync(worktree); - fs.writeFileSync(path.join(hooksDir, 'post-switch'), `pwd > "${outFile}"\n`); + writeTrustedHook(root, 'post-switch', `pwd > "${outFile}"\n`); lib.runHook(root, 'post-switch', {}, { cwd: worktree }); @@ -317,12 +358,9 @@ test('a missing hook is a silent no-op', () => { test('a hook path containing spaces and metacharacters stays data', () => { // The hook path is passed as $1, never interpolated into the script string. const root = tempDir('hookodd'); - const hooksDir = path.join(root, 'weird dir; touch /tmp/linchpin-hook-pwned', '.linchpin', 'hooks'); const outFile = path.join(root, 'ok.txt'); - fs.mkdirSync(hooksDir, { recursive: true }); - fs.writeFileSync(path.join(hooksDir, 'post-get'), `echo fine > "${outFile}"\n`); - const base = path.join(root, 'weird dir; touch /tmp/linchpin-hook-pwned'); + writeTrustedHook(base, 'post-get', `echo fine > "${outFile}"\n`); const result = lib.runHook(base, 'post-get'); assert.equal(result.ran, true); diff --git a/test/paths.test.js b/test/paths.test.js new file mode 100644 index 0000000..94c3316 --- /dev/null +++ b/test/paths.test.js @@ -0,0 +1,206 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const { pathToFileURL } = require('node:url'); + +const legacy = require('../legacy/lib/paths'); +const legacyHooks = require('../legacy/lib/hooks'); + +const { createFixture, runCli } = require('../test-utils/cli-fixture'); + +const LIB = pathToFileURL(path.resolve(__dirname, '..', 'dist', 'index.js')).href; + +let ported; +test.before(async () => { + ported = await import(LIB); +}); + +function makeTempDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'linchpin-paths-')); +} + +/** Assert both implementations agree, so the port cannot drift from legacy. */ +function bothResolve(root, segment) { + const fromLegacy = legacy.resolveContained(root, segment); + const fromPorted = ported.resolveContained(root, segment); + + assert.equal(fromPorted, fromLegacy, 'the port diverged from legacy'); + return fromLegacy; +} + +test('resolveContained accepts a path inside the root', () => { + const root = makeTempDir(); + + assert.equal(bothResolve(root, 'hook'), path.join(root, 'hook')); + assert.equal(bothResolve(root, 'nested/hook'), path.join(root, 'nested', 'hook')); + assert.equal(bothResolve(root, '.'), path.resolve(root)); +}); + +test('resolveContained rejects traversal out of the root', () => { + const root = makeTempDir(); + + assert.equal(bothResolve(root, '../escape'), null); + assert.equal(bothResolve(root, '../../../../tmp/payload'), null); + assert.equal(bothResolve(root, 'nested/../../escape'), null); +}); + +test('resolveContained rejects an absolute path rather than folding it in', () => { + const root = makeTempDir(); + + // path.join would have quietly produced `${root}/etc/passwd`. Rejecting is + // the honest answer, and it is what stops an absolute argv value. + assert.equal(bothResolve(root, '/etc/passwd'), null); +}); + +test('resolveContained does not match a sibling sharing the root name prefix', () => { + const root = makeTempDir(); + const sibling = `${root}-evil`; + + // The bug a bare startsWith() check would have: `/repo-evil` is not in `/repo`. + assert.equal(legacy.isContained(root, sibling), false); + assert.equal(ported.isContained(root, sibling), false); + assert.equal(legacy.isContained(root, path.join(root, 'child')), true); + assert.equal(ported.isContained(root, path.join(root, 'child')), true); +}); + +test('isContainedAfterLinks refuses a symlink pointing out of the root', () => { + const root = makeTempDir(); + const outside = makeTempDir(); + const secret = path.join(outside, 'payload'); + + fs.writeFileSync(secret, 'echo pwned\n', 'utf8'); + + const link = path.join(root, 'post-switch'); + fs.symlinkSync(secret, link, 'file'); + + // Lexically inside — the escape only shows once the link is followed. + assert.equal(legacy.isContained(root, link), true); + assert.equal(legacy.isContainedAfterLinks(root, link), false); + assert.equal(ported.isContainedAfterLinks(root, link), false); +}); + +test('isContainedAfterLinks allows a symlink that stays inside the root', () => { + const root = makeTempDir(); + const real = path.join(root, 'real'); + + fs.writeFileSync(real, 'echo fine\n', 'utf8'); + + const link = path.join(root, 'alias'); + fs.symlinkSync(real, link, 'file'); + + assert.equal(legacy.isContainedAfterLinks(root, link), true); + assert.equal(ported.isContainedAfterLinks(root, link), true); +}); + +test('isContainedAfterLinks tolerates a root reached through a symlink', () => { + // The macOS case: a temp dir is reached via /tmp but resolves to /private/tmp. + // Resolving only one side would reject every legitimate path here. + const root = makeTempDir(); + const child = path.join(root, 'child'); + + fs.writeFileSync(child, 'contents\n', 'utf8'); + + assert.equal(legacy.isContainedAfterLinks(root, child), true); + assert.equal(ported.isContainedAfterLinks(root, child), true); +}); + +test('isContainedAfterLinks falls back to the lexical answer for a missing path', () => { + const root = makeTempDir(); + const notYet = path.join(root, 'does-not-exist-yet'); + + // `wt link` names a destination before creating it, so absent must not fail closed. + assert.equal(legacy.isContainedAfterLinks(root, notYet), true); + assert.equal(ported.isContainedAfterLinks(root, notYet), true); + assert.equal(legacy.isContainedAfterLinks(root, path.join(root, '..', 'nope')), false); + assert.equal(ported.isContainedAfterLinks(root, path.join(root, '..', 'nope')), false); +}); + +test('requireContained throws naming the boundary that was crossed', () => { + const root = makeTempDir(); + + assert.throws( + () => legacy.requireContained(root, '../escape', 'the base worktree'), + /resolves outside the base worktree/ + ); + assert.throws( + () => ported.requireContained(root, '../escape', 'the base worktree'), + /resolves outside the base worktree/ + ); + assert.equal(legacy.requireContained(root, 'ok', 'the base worktree'), path.join(root, 'ok')); +}); + +test('findHookFile refuses a name that escapes the hooks directory', () => { + const root = makeTempDir(); + const hooksDir = path.join(root, '.linchpin', 'hooks'); + + fs.mkdirSync(hooksDir, { recursive: true }); + fs.writeFileSync(path.join(hooksDir, 'pre-new'), 'echo ok\n', 'utf8'); + + // The file it would have reached before the fix. + const outside = path.join(root, 'payload'); + fs.writeFileSync(outside, 'echo pwned\n', 'utf8'); + + assert.equal(legacyHooks.findHookFile(root, 'pre-new'), path.join(hooksDir, 'pre-new')); + assert.equal(ported.findHookFile(root, 'pre-new'), path.join(hooksDir, 'pre-new')); + + assert.equal(legacyHooks.findHookFile(root, '../../payload'), null); + assert.equal(ported.findHookFile(root, '../../payload'), null); +}); + +test('findHookFile refuses a hook that is a symlink out of the hooks directory', () => { + const root = makeTempDir(); + const outside = makeTempDir(); + const hooksDir = path.join(root, '.linchpin', 'hooks'); + + fs.mkdirSync(hooksDir, { recursive: true }); + + const payload = path.join(outside, 'payload'); + fs.writeFileSync(payload, 'echo pwned\n', 'utf8'); + + // git tracks symlinks, so a cloned repo can ship exactly this. + fs.symlinkSync(payload, path.join(hooksDir, 'post-switch'), 'file'); + + assert.equal(legacyHooks.findHookFile(root, 'post-switch'), null); + assert.equal(ported.findHookFile(root, 'post-switch'), null); +}); + +test( + 'wt invoke, copy and link refuse arguments that leave their worktree', + { timeout: 120_000 }, + () => { + const fixture = createFixture(); + + // A real file outside the repo, of the kind `invoke` used to reach and source. + const outside = path.join(fixture.root, 'outside-payload'); + fs.writeFileSync(outside, 'echo pwned\n', 'utf8'); + + const invoke = runCli(fixture.basePath, ['wt', 'invoke', '../../outside-payload']); + assert.notEqual(invoke.code, 0, 'wt invoke must refuse a traversing hook name'); + assert.match(`${invoke.stdout}${invoke.stderr}`, /does not exist in \.linchpin\/hooks/); + assert.doesNotMatch(`${invoke.stdout}${invoke.stderr}`, /Ran /); + + // A legitimate hook still runs, so containment did not break the feature. + const ok = runCli(fixture.basePath, ['wt', 'invoke', 'pre-new']); + assert.equal(ok.code, 0, `wt invoke should still run a real hook\nSTDERR:\n${ok.stderr}`); + assert.match(ok.stdout, /Ran /); + + const created = runCli(fixture.basePath, ['wt', 'new', 'feature/containment']); + assert.equal(created.code, 0, `wt new failed\nSTDERR:\n${created.stderr}`); + const worktree = created.stdout.split('\n').at(-1); + + const copy = runCli(worktree, ['wt', 'copy', '../outside-payload']); + assert.notEqual(copy.code, 0, 'wt copy must refuse a traversing path'); + assert.match(`${copy.stdout}${copy.stderr}`, /resolves outside/); + + const link = runCli(worktree, ['wt', 'link', '../../outside-payload']); + assert.notEqual(link.code, 0, 'wt link must refuse a traversing path'); + assert.match(`${link.stdout}${link.stderr}`, /resolves outside/); + + // Nothing was written outside the worktree by either refusal. + assert.equal(fs.existsSync(path.join(fixture.root, 'outside-payload')), true); + assert.equal(fs.readFileSync(outside, 'utf8'), 'echo pwned\n'); + } +); diff --git a/test/trust.test.js b/test/trust.test.js new file mode 100644 index 0000000..ee9833d --- /dev/null +++ b/test/trust.test.js @@ -0,0 +1,214 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const { pathToFileURL } = require('node:url'); + +const legacy = require('../legacy/lib/trust'); +const legacyHooks = require('../legacy/lib/hooks'); + +const { createFixture, runCli } = require('../test-utils/cli-fixture'); + +const LIB = pathToFileURL(path.resolve(__dirname, '..', 'dist', 'index.js')).href; + +let ported; +test.before(async () => { + ported = await import(LIB); +}); + +function makeTempDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'linchpin-trust-')); +} + +/** A repo root with one hook in it, plus its own trust file. */ +function makeHookRepo(contents = 'echo hello\n') { + const root = makeTempDir(); + const hooksDir = path.join(root, '.linchpin', 'hooks'); + fs.mkdirSync(hooksDir, { recursive: true }); + + const hookFile = path.join(hooksDir, 'post-switch'); + fs.writeFileSync(hookFile, contents, 'utf8'); + + return { root, hookFile, store: path.join(root, 'trust.json') }; +} + +test('trustFilePath honours LINCHPIN_TRUST_FILE', () => { + const previous = process.env.LINCHPIN_TRUST_FILE; + process.env.LINCHPIN_TRUST_FILE = '/tmp/explicit-trust.json'; + + try { + assert.equal(legacy.trustFilePath(), '/tmp/explicit-trust.json'); + assert.equal(ported.trustFilePath(), '/tmp/explicit-trust.json'); + } finally { + if (previous === undefined) delete process.env.LINCHPIN_TRUST_FILE; + else process.env.LINCHPIN_TRUST_FILE = previous; + } +}); + +test('a hook is untrusted until it is trusted, in both implementations', () => { + const { hookFile, store } = makeHookRepo(); + + assert.equal(legacy.isHookTrusted(hookFile, store), false); + assert.equal(ported.isHookTrusted(hookFile, store), false); + + const digest = legacy.trustHook(hookFile, store); + assert.equal(typeof digest, 'string'); + assert.equal(digest.length, 64, 'sha256 hex'); + + assert.equal(legacy.isHookTrusted(hookFile, store), true); + assert.equal(ported.isHookTrusted(hookFile, store), true, 'the port diverged'); +}); + +test('editing a trusted hook withdraws its trust', () => { + const { hookFile, store } = makeHookRepo(); + + legacy.trustHook(hookFile, store); + assert.equal(legacy.isHookTrusted(hookFile, store), true); + + // Trust is recorded against contents, so this is a different hook now. + fs.writeFileSync(hookFile, 'echo something else entirely\n', 'utf8'); + + assert.equal(legacy.isHookTrusted(hookFile, store), false, 'trust survived an edit'); + assert.equal(ported.isHookTrusted(hookFile, store), false, 'the port diverged'); +}); + +test('revoking withdraws trust and reports whether there was any', () => { + const { hookFile, store } = makeHookRepo(); + + assert.equal(legacy.revokeHook(hookFile, store), false, 'nothing to revoke yet'); + + legacy.trustHook(hookFile, store); + assert.equal(legacy.revokeHook(hookFile, store), true); + assert.equal(legacy.isHookTrusted(hookFile, store), false); +}); + +test('a corrupt or missing trust store denies every hook', () => { + const { hookFile, store } = makeHookRepo(); + + // Missing. + assert.deepEqual(legacy.readTrustStore(store), { hooks: {} }); + assert.deepEqual(ported.readTrustStore(store), { hooks: {} }); + + // Corrupt: must not be read as blanket approval. + fs.writeFileSync(store, '{ this is not json', 'utf8'); + assert.deepEqual(legacy.readTrustStore(store), { hooks: {} }); + assert.equal(legacy.isHookTrusted(hookFile, store), false); + assert.equal(ported.isHookTrusted(hookFile, store), false); + + // Wrong shape. + fs.writeFileSync(store, '{"hooks": "everything"}', 'utf8'); + assert.deepEqual(legacy.readTrustStore(store), { hooks: {} }); + assert.deepEqual(ported.readTrustStore(store), { hooks: {} }); +}); + +test('runHook refuses an untrusted hook and explains why', () => { + const { root, hookFile, store } = makeHookRepo(`touch "${path.join(os.tmpdir(), 'linchpin-should-not-exist')}"\n`); + + const previous = process.env.LINCHPIN_TRUST_FILE; + process.env.LINCHPIN_TRUST_FILE = store; + + try { + const blocked = legacyHooks.runHook(root, 'post-switch'); + + assert.equal(blocked.ran, false, 'an untrusted hook must not run'); + assert.equal(blocked.blocked, true); + assert.equal(blocked.hookFile, hookFile); + assert.match(blocked.reason, /Blocked untrusted hook/); + assert.match(blocked.reason, /linchpin wt trust post-switch/); + + // And once trusted, it runs. + legacy.trustHook(hookFile, store); + const ran = legacyHooks.runHook(root, 'post-switch'); + assert.equal(ran.ran, true); + assert.equal(ran.blocked, undefined); + } finally { + if (previous === undefined) delete process.env.LINCHPIN_TRUST_FILE; + else process.env.LINCHPIN_TRUST_FILE = previous; + fs.rmSync(path.join(os.tmpdir(), 'linchpin-should-not-exist'), { force: true }); + } +}); + +test( + 'a hook that arrived with a clone does not run until it is trusted', + { timeout: 120_000 }, + () => { + // The original finding, as a regression test: committing a hook must not be + // enough to get it executed on someone else's machine. + const fixture = createFixture(); + const hooksDir = path.join(fixture.basePath, '.linchpin', 'hooks'); + const proof = path.join(fixture.root, 'hook-ran.txt'); + + fs.mkdirSync(hooksDir, { recursive: true }); + fs.writeFileSync( + path.join(hooksDir, 'post-switch'), + `echo ran > "${proof}"\n`, + 'utf8' + ); + + const blocked = runCli(fixture.basePath, ['wt', 'switch', '--env', 'studio']); + assert.equal(blocked.code, 0, 'a blocked hook must not fail the command'); + assert.match(blocked.stderr, /Blocked untrusted hook/); + assert.equal(fs.existsSync(proof), false, 'the untrusted hook executed'); + + // Listing shows it as untrusted before it is granted. + const list = runCli(fixture.basePath, ['wt', 'trust']); + assert.equal(list.code, 0); + assert.match(list.stdout, /UNTRUSTED\s+post-switch/); + + const granted = runCli(fixture.basePath, ['wt', 'trust', 'post-switch']); + assert.equal(granted.code, 0, `wt trust failed\nSTDERR:\n${granted.stderr}`); + assert.match(granted.stdout, /Trusted post-switch/); + + const allowed = runCli(fixture.basePath, ['wt', 'switch', '--env', 'studio']); + assert.equal(allowed.code, 0); + assert.equal(fs.existsSync(proof), true, 'a trusted hook should run'); + assert.match(allowed.stderr, /Ran hook:/); + + // Editing it withdraws trust again, without anyone having to notice. + fs.rmSync(proof, { force: true }); + fs.writeFileSync( + path.join(hooksDir, 'post-switch'), + `echo tampered > "${proof}"\n`, + 'utf8' + ); + + const afterEdit = runCli(fixture.basePath, ['wt', 'switch', '--env', 'studio']); + assert.match(afterEdit.stderr, /Blocked untrusted hook/, 'an edited hook stayed trusted'); + assert.equal(fs.existsSync(proof), false, 'the edited hook executed'); + + // And trust can be withdrawn deliberately. + runCli(fixture.basePath, ['wt', 'trust', 'post-switch']); + const revoked = runCli(fixture.basePath, ['wt', 'trust', 'post-switch', '--revoke']); + assert.equal(revoked.code, 0); + assert.match(revoked.stdout, /Revoked: post-switch/); + } +); + +test( + 'wt switch --force refuses to delete without confirmation when nobody can answer', + { timeout: 120_000 }, + () => { + const fixture = createFixture(); + + // A real directory in the WordPress slot, of the kind --force replaces. + fs.mkdirSync(fixture.pluginPath, { recursive: true }); + fs.writeFileSync(path.join(fixture.pluginPath, 'keep.txt'), 'important\n', 'utf8'); + + // runCli is not a TTY, so this is the non-interactive path. + const refused = runCli(fixture.basePath, ['wt', 'switch', '--env', 'studio', '--force']); + assert.notEqual(refused.code, 0, '--force must not proceed unattended'); + assert.match(`${refused.stdout}${refused.stderr}`, /without confirmation/); + assert.equal( + fs.readFileSync(path.join(fixture.pluginPath, 'keep.txt'), 'utf8'), + 'important\n', + 'data was destroyed despite the refusal' + ); + + // --yes is the explicit, scriptable answer. + const approved = runCli(fixture.basePath, ['wt', 'switch', '--env', 'studio', '--force', '--yes']); + assert.equal(approved.code, 0, `--yes should proceed\nSTDERR:\n${approved.stderr}`); + assert.equal(fs.lstatSync(fixture.pluginPath).isSymbolicLink(), true); + } +); diff --git a/test/wt-readonly.test.js b/test/wt-readonly.test.js index 9172a55..2ec8ccc 100644 --- a/test/wt-readonly.test.js +++ b/test/wt-readonly.test.js @@ -3,7 +3,7 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); -const { canonicalPath, createFixture, runCli } = require('../test-utils/cli-fixture'); +const { canonicalPath, createFixture, runCli, trustHooks } = require('../test-utils/cli-fixture'); function assertOk(result, message) { assert.equal(result.code, 0, `${message}\nSTDERR:\n${result.stderr}`); @@ -70,6 +70,8 @@ test( 'echo "post-switch ran"', 'utf8' ); + // Hooks are committed files: they run only once this machine trusts them. + trustHooks(fixture.basePath); const invoke = runCli(fixture.basePath, ['wt', 'invoke', 'post-switch']); assertOk(invoke, 'linchpin wt invoke post-switch should succeed'); From 66d4679e79a90f9914ad95e77c0b7ac1e65ede9b Mon Sep 17 00:00:00 2001 From: Aaron Ware Date: Sun, 30 Aug 2026 00:16:31 -0400 Subject: [PATCH 2/2] fix(NO-TASK): Refuse plaintext registries and sanitize registry responses LINCHPIN_REGISTRY and npm_config_registry were accepted with no scheme check, so a plaintext registry -- which anyone on the path can answer -- was honoured. They are environment values, exactly as trustworthy as whatever set them. http is now refused unless the host is loopback, so a local Verdaccio mirror still works, or LINCHPIN_REGISTRY_ALLOW_INSECURE is set. The latest dist-tag and the response status text both reach a terminal, and neither was stripped of control characters, so a hostile or intercepted registry could rewrite the output around an update notice with ANSI escapes. Both are sanitized at the boundary rather than at each print site, because latest is cached to disk and read back by later runs -- cleaning it on arrival is what stops a hostile answer outliving the request that fetched it. The install command was never at risk: it is a hardcoded name@latest with no argv injection. The child npm does inherit the environment, so a poisoned npm_config_registry still steers the real install; that is npm's own behaviour and is out of scope here. Also fixes an unparseable registry value, including the empty string, producing a relative URL and a confusing fetch failure. It now falls back to the default. Co-Authored-By: Claude Opus 5 (1M context) --- src/core/update.ts | 63 ++++++++++++++++++- src/index.ts | 1 + test/update-registry.test.js | 117 +++++++++++++++++++++++++++++++++++ 3 files changed, 178 insertions(+), 3 deletions(-) create mode 100644 test/update-registry.test.js diff --git a/src/core/update.ts b/src/core/update.ts index 39fc470..e50bf24 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -208,6 +208,38 @@ export function isCacheFresh( return age >= 0 && age < maxAgeMs; } +/** Loopback hosts, where plaintext is a local mirror rather than a downgrade. */ +const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1']); + +/** + * Strip control characters from text the registry chose. + * + * `statusText` and a dist-tag are remote strings that reach a terminal. Left + * raw, an ANSI escape in either one rewrites the surrounding output — the + * update notice is a natural place to forge a line the user trusts. Printable + * characters only, and a length cap so a megabyte of "version" cannot scroll a + * warning off the screen. + */ +export function safeRemoteText(value: string, maxLength = 96): string { + // eslint-disable-next-line no-control-regex + const stripped = value.replace(/[\u0000-\u001f\u007f-\u009f]/g, ''); + return stripped.length > maxLength ? `${stripped.slice(0, maxLength)}…` : stripped; +} + +/** + * The registry to ask, having refused the ones that cannot be trusted. + * + * ⚠️ `LINCHPIN_REGISTRY` and `npm_config_registry` are environment values, so + * they are exactly as trustworthy as whatever set them — a `.envrc`, a CI + * config, a compromised shell profile. A plaintext registry is a downgrade + * anyone on the path can answer, so http is refused unless the host is + * loopback (a local mirror such as Verdaccio) or the caller has said out loud + * that it wants the insecure one. + * + * An unparseable value falls back to the default rather than throwing: it also + * covers `LINCHPIN_REGISTRY=''`, which previously produced a relative URL and + * a confusing fetch failure. + */ function registryBase(override?: string): string { const candidate = override ?? @@ -215,7 +247,29 @@ function registryBase(override?: string): string { process.env.npm_config_registry?.trim() ?? REGISTRY_URL; - return candidate.replace(/\/+$/, ''); + const trimmed = candidate.replace(/\/+$/, ''); + if (trimmed === '') return REGISTRY_URL; + + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + return REGISTRY_URL; + } + + if (parsed.protocol === 'https:') return trimmed; + + const allowInsecure = process.env.LINCHPIN_REGISTRY_ALLOW_INSECURE?.trim(); + const permitted = + LOOPBACK_HOSTS.has(parsed.hostname) || + (allowInsecure !== undefined && allowInsecure !== '' && allowInsecure !== '0'); + + if (parsed.protocol === 'http:' && permitted) return trimmed; + + throw new Error( + `Refusing to query registry over ${parsed.protocol}//: ${trimmed}. ` + + `Use https, or set LINCHPIN_REGISTRY_ALLOW_INSECURE=1 to accept it.` + ); } /** @@ -240,7 +294,7 @@ export async function fetchLatestVersion(options: { if (!response.ok) { throw new Error( - `${options.packageName}: registry answered ${String(response.status)} ${response.statusText}` + `${options.packageName}: registry answered ${String(response.status)} ${safeRemoteText(response.statusText)}` ); } @@ -255,7 +309,10 @@ export async function fetchLatestVersion(options: { throw new Error(`${options.packageName}: registry response has no "latest" dist-tag`); } - return body.latest; + // Sanitized at the boundary rather than at each print site: `latest` is + // cached to disk and read back by later runs, so cleaning it here is what + // keeps a hostile answer from outliving the request that fetched it. + return safeRemoteText(body.latest); } /** diff --git a/src/index.ts b/src/index.ts index d5b49b9..0c40da6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -165,6 +165,7 @@ export { isUpdateAvailable, readUpdateCache, resolveUpdateStatus, + safeRemoteText, updateCachePath, writeUpdateCache, type InstallScope, diff --git a/test/update-registry.test.js b/test/update-registry.test.js new file mode 100644 index 0000000..dfa1dc3 --- /dev/null +++ b/test/update-registry.test.js @@ -0,0 +1,117 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +const { pathToFileURL } = require('node:url'); + +const LIB = pathToFileURL(path.resolve(__dirname, '..', 'dist', 'index.js')).href; + +let lib; +test.before(async () => { + lib = await import(LIB); +}); + +const REGISTRY_KEYS = [ + 'LINCHPIN_REGISTRY', + 'npm_config_registry', + 'LINCHPIN_REGISTRY_ALLOW_INSECURE', +]; + +/** Run `fn` with the registry environment set to exactly `env`. */ +function withRegistryEnv(env, fn) { + const previous = Object.fromEntries(REGISTRY_KEYS.map((key) => [key, process.env[key]])); + + for (const key of REGISTRY_KEYS) delete process.env[key]; + for (const [key, value] of Object.entries(env)) process.env[key] = value; + + try { + return fn(); + } finally { + for (const key of REGISTRY_KEYS) { + if (previous[key] === undefined) delete process.env[key]; + else process.env[key] = previous[key]; + } + } +} + +/** + * `registryBase` is private, so it is exercised through the one caller that + * reaches it before any network work — an unreachable host still proves which + * URL was accepted, because a refusal throws before the fetch is attempted. + */ +function resolveRegistry(env) { + return withRegistryEnv(env, async () => { + try { + await lib.fetchLatestVersion({ + packageName: '@linchpinagency/cli', + timeoutMs: 1, + }); + return { refused: false }; + } catch (error) { + return { refused: /Refusing to query registry/.test(error.message), message: error.message }; + } + }); +} + +test('a plaintext registry is refused', async () => { + const result = await resolveRegistry({ LINCHPIN_REGISTRY: 'http://registry.example.com' }); + + assert.equal(result.refused, true, 'http registry was accepted'); + assert.match(result.message, /LINCHPIN_REGISTRY_ALLOW_INSECURE/, 'the remedy was not named'); +}); + +test('npm_config_registry is held to the same standard', async () => { + // It is an environment value like any other — a .envrc or CI config sets it. + const result = await resolveRegistry({ npm_config_registry: 'http://registry.example.com' }); + + assert.equal(result.refused, true, 'http via npm_config_registry was accepted'); +}); + +test('a plaintext registry is allowed when explicitly permitted', async () => { + const result = await resolveRegistry({ + LINCHPIN_REGISTRY: 'http://registry.example.com', + LINCHPIN_REGISTRY_ALLOW_INSECURE: '1', + }); + + assert.equal(result.refused, false, 'the explicit override was ignored'); +}); + +test('a loopback registry is allowed, so a local mirror still works', async () => { + for (const url of ['http://localhost:4873', 'http://127.0.0.1:4873']) { + const result = await resolveRegistry({ LINCHPIN_REGISTRY: url }); + assert.equal(result.refused, false, `${url} should be allowed`); + } +}); + +test('https is accepted without ceremony', async () => { + const result = await resolveRegistry({ LINCHPIN_REGISTRY: 'https://registry.example.com' }); + + assert.equal(result.refused, false); +}); + +test('an unusable registry value falls back to the default rather than breaking', async () => { + // The empty string previously produced a relative URL and a confusing failure. + for (const value of ['', 'not a url']) { + const result = await resolveRegistry({ LINCHPIN_REGISTRY: value }); + assert.equal(result.refused, false, `${JSON.stringify(value)} should fall back, not refuse`); + } +}); + +test('safeRemoteText strips control characters the registry chose', () => { + // An ANSI escape in a version string or a status line would otherwise rewrite + // the terminal around the update notice. + const hostile = '1.0.0\rEverything is fine'; + + const cleaned = lib.safeRemoteText(hostile); + + assert.equal(cleaned.includes(''), false, 'escape survived'); + assert.equal(cleaned.includes('\r'), false, 'carriage return survived'); + assert.equal(cleaned, '1.0.0[2KEverything is fine'); +}); + +test('safeRemoteText caps length so a notice cannot be scrolled away', () => { + const cleaned = lib.safeRemoteText('9'.repeat(500)); + + assert.equal(cleaned.length, 97, 'expected 96 characters plus an ellipsis'); + assert.ok(cleaned.endsWith('…')); +});