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/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 7abd7ba..0c40da6 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, @@ -144,6 +165,7 @@ export { isUpdateAvailable, readUpdateCache, resolveUpdateStatus, + safeRemoteText, updateCachePath, writeUpdateCache, type InstallScope, 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/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('…')); +}); 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');