From 7d8fb065b5e72f419374b7e7d07be06e67a214da Mon Sep 17 00:00:00 2001 From: mptyl Date: Sun, 9 Aug 2026 22:51:56 +0200 Subject: [PATCH] fix: seal P1 acceptance process boundaries --- PROJECT_STATE.md | 6 +- backend/scripts/p1-acceptance.mjs | 390 ++++++++++++++++++------- backend/scripts/p1-acceptance.test.mjs | 159 ++++++++-- scripts/p1-acceptance.sh | 67 ++++- 4 files changed, 480 insertions(+), 142 deletions(-) diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 279f06ab..0dc2154e 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -5,7 +5,7 @@ ## P1 configuration-process automated integration — PASS 2026-08-09 -- Retained evidence: `.artifacts/p1-integration/p1-70727c7802050c76978d5007a634ede1/report.md` +- Retained evidence: `.artifacts/p1-integration/p1-57d5f3b1e420f348f849943f9ee6227c/report.md` - automated integration: PASS - manual acceptance: PENDING - The contextual negatives use a separate `invalid-context` branch, remote, checkout, data, @@ -20,8 +20,8 @@ (156 objects, 48 blobs) with zero findings; the frozen final filesystem and virtual-report scan also found no fixture canaries outside the excluded secret fixture directory. - Final report hashes: `report.json` - `61d767ea90ad1055a1f6fdadec551752b36f44ca173959c33f470c57a4c706a2`; - `report.md` `dbbb37f47f5d686bde3a3516ff7fb3d06c8835aa3b72167f1984436c330446ef`. + `dfe4b2f9d7bf0b9cce114ca93239e2b87e0b87f5719504d74ed2c437763c817c`; + `report.md` `4f45aaedfc4f6399ab5ab6ebab5764055a243daae16227a29ce16d32b8ee0fa7`. ## Internal Qdrant + Ollama semantic infrastructure — LIVE 2026-08-08 diff --git a/backend/scripts/p1-acceptance.mjs b/backend/scripts/p1-acceptance.mjs index 21eeaf94..9130c3a9 100755 --- a/backend/scripts/p1-acceptance.mjs +++ b/backend/scripts/p1-acceptance.mjs @@ -23,6 +23,7 @@ const mutableWorkerThreads = require("node:worker_threads"); let commandEventSink; let activeCommandCheckId; let activeExecutablePolicy; +let activePolicyRejectionSink; let integrationOwner; let productionSurfaceOwner; const RUN_ID = /^p1-[0-9a-f]{32}$/; @@ -227,41 +228,142 @@ function resolveTrustedSystemExecutableSync(name) { throw new Error(`cannot resolve trusted system executable: ${name}`); } -export async function resolveProductionExecutables({ repositoryRoot, thtBin } = {}) { +export async function resolveProductionExecutables({ repositoryRoot } = {}) { const repo = canonicalRoot(repositoryRoot); const gitPath = resolveTrustedSystemExecutableSync("git"); const pythonPath = resolveTrustedSystemExecutableSync("python3"); - const expectedThtRoot = join(repo, "harness", ".venv"); - const candidateTht = thtBin ?? join(expectedThtRoot, "bin", "tht"); - if (!isAbsolute(candidateTht)) throw new Error("THT executable must be absolute"); - const thtPath = realpathSync(candidateTht); - const thtRelative = relative(expectedThtRoot, thtPath); - if (thtRelative.startsWith("..") || isAbsolute(thtRelative)) throw new Error("THT executable leaves the repository virtual environment"); - await access(thtPath, fsConstants.X_OK); - return { gitPath, pythonPath, thtPath }; + const thtPath = join(repo, "harness", ".venv", "bin", "tht"); + let entry; + try { entry = lstatSync(thtPath); } catch { throw new Error("trusted THT executable is unavailable"); } + if (!entry.isFile() || entry.isSymbolicLink() || realpathSync(thtPath) !== thtPath) throw new Error("trusted THT entrypoint identity is invalid"); + accessSync(thtPath, fsConstants.X_OK); + const entrypointBytes = await readFile(thtPath, "utf8"); + const lines = entrypointBytes.replaceAll("\r\n", "\n").split("\n"); + const shebang = lines.shift() ?? ""; + const pythonLexical = shebang.startsWith("#!") ? shebang.slice(2) : ""; + const expectedBody = [ + "import sys", "from tht.cli import app", "if __name__ == '__main__':", + " if sys.argv[0].endswith('.exe'):", " sys.argv[0] = sys.argv[0][:-4]", + " sys.exit(app())", "", + ].join("\n"); + const venvBin = join(repo, "harness", ".venv", "bin"); + if (dirname(pythonLexical) !== venvBin || !/^python3(?:\.\d+)?$/.test(basename(pythonLexical)) + || realpathSync(pythonLexical) !== realpathSync(join(venvBin, "python")) + || lines.join("\n") !== expectedBody) throw new Error("trusted THT generated entrypoint is invalid"); + const sourceRoot = join(repo, "harness", "tht"); + const pyproject = await readFile(join(repo, "harness", "pyproject.toml"), "utf8"); + if (!/^tht\s*=\s*["']tht\.cli:app["']$/m.test(pyproject)) throw new Error("trusted THT console-script declaration is invalid"); + const status = await runCommand({ + executable: gitPath, + argv: ["-C", repo, "status", "--porcelain=v1", "--untracked-files=all", "--", "harness/tht", "harness/pyproject.toml"], + env: { + PATH: dirname(gitPath), HOME: "/nonexistent", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", + GIT_CONFIG_COUNT: "1", GIT_CONFIG_KEY_0: "core.fsmonitor", GIT_CONFIG_VALUE_0: "false", + }, + }); + if (status.stdout !== "") throw new Error("trusted THT source is not tracked and clean"); + const pythonVersion = basename(pythonLexical); + const sitePackages = join(repo, "harness", ".venv", "lib", pythonVersion, "site-packages"); + const siteEntries = await readdir(sitePackages); + const allPthFiles = siteEntries.filter((name) => name.endsWith(".pth")); + const pthFiles = allPthFiles.filter((name) => /^__editable__\.tht-.*\.pth$/.test(name)); + const finderFiles = siteEntries.filter((name) => /^__editable___tht_.*_finder\.py$/.test(name)); + if (pthFiles.length !== 1 || allPthFiles.length !== 1 || finderFiles.length !== 1 + || siteEntries.some((name) => /^(?:sitecustomize|usercustomize|tht)\.py$/.test(name) || name === "tht")) { + throw new Error("trusted THT editable binding is ambiguous"); + } + const pth = await readFile(join(sitePackages, pthFiles[0]), "utf8"); + const finder = await readFile(join(sitePackages, finderFiles[0]), "utf8"); + const finderModule = finderFiles[0].slice(0, -3); + if (pth.trim() !== `import ${finderModule}; ${finderModule}.install()` + || !finder.includes(`MAPPING: dict[str, str] = {'tht': '${sourceRoot}'}`) + || /\b(?:subprocess|socket|requests|httpx|urllib|multiprocessing)\b|\bos\.system\b|\bPopen\b/.test(finder)) { + throw new Error("trusted THT editable binding is invalid"); + } + return { + gitPath, pythonPath, thtPath, + thtIdentity: { + entrypoint: "generated-console-script", entrypointSha256: sha256(entrypointBytes), + pythonPath: pythonLexical, pythonCanonicalPath: realpathSync(pythonLexical), + sourceRoot, sourceStatus: "tracked-clean", editableFinderSha256: sha256(finder), + }, + }; } -const GIT_VERBS = new Set([ - "--version", "add", "cat-file", "checkout", "clean", "clone", "commit", "config", "fetch", "for-each-ref", - "init", "ls-tree", "merge", "merge-base", "push", "remote", "reset", "rev-list", "rev-parse", "show", - "show-ref", "status", "symbolic-ref", "write-tree", +const FIXTURE_GIT_CONFIG = new Map([ + ["user.name", new Set(["P1 Fixture Curator", "P1 Context Curator"])], + ["user.email", new Set(["p1-curator@example.invalid", "p1-context@example.invalid"])], ]); -function gitVerb(argv) { - if (argv[0] === "--version") return "--version"; - let index = 0; - while (index < argv.length) { - if (["-C", "--git-dir", "--work-tree", "-c"].includes(argv[index])) { index += 2; continue; } - if (argv[index].startsWith("--git-dir=") || argv[index].startsWith("--work-tree=")) { index += 1; continue; } - return argv[index]; - } - return undefined; +function ownedGitPath(value, runRoot) { + if (typeof value !== "string" || !isAbsolute(value) || value.includes("\0")) return false; + if (!runRoot) return true; + const lexical = resolve(value); const rel = relative(runRoot, lexical); + if (rel.startsWith("..") || isAbsolute(rel)) return false; + try { validateNoSymlinkAncestors(runRoot, lexical); } catch { return false; } + return true; } -function validateGitInvocation(argv) { - const verb = gitVerb(argv); - if (!verb || !GIT_VERBS.has(verb)) throw new Error("Git command is prohibited"); - if (argv.some((value) => /^[a-z][a-z0-9+.-]*:\/\//i.test(value) || /^[^/\s]+@[^:\s]+:/.test(value))) { - throw new Error("Git network URL is prohibited"); +function ownedEmptyHooksPath(value, runRoot) { + if (!ownedGitPath(value, runRoot) || !/(?:^|\/)registry\/locks\/empty-hooks$/.test(value)) return false; + try { + const entry = lstatSync(value); + return entry.isDirectory() && !entry.isSymbolicLink() && realpathSync(value) === value; + } catch { return false; } +} +function safeGitOperand(value) { return typeof value === "string" && value.length > 0 && !value.startsWith("-") && !/[\0\n\r]/.test(value); } +function exactArray(value, expected) { return value.length === expected.length && value.every((item, index) => item === expected[index]); } +export function validateGitInvocation(argv, { runRoot } = {}) { + if (!Array.isArray(argv) || argv.some((value) => typeof value !== "string")) throw new Error("Git command is prohibited"); + if (exactArray(argv, ["--version"])) return "--version"; + let index = 0; let prefix; + if (["-C", "--git-dir"].includes(argv[0])) { + if (!ownedGitPath(argv[1], runRoot)) throw new Error("Git command is prohibited"); + prefix = argv[0]; index = 2; + } else if (argv[0] === "-c") { + if (typeof argv[1] !== "string" || !argv[1].startsWith("core.hooksPath=")) throw new Error("Git command is prohibited"); + const hooksPath = argv[1].slice("core.hooksPath=".length); + if (!ownedEmptyHooksPath(hooksPath, runRoot)) throw new Error("Git command is prohibited"); + prefix = "hooks"; index = 2; + } else if (argv[0]?.startsWith("-")) throw new Error("Git command is prohibited"); + const verb = argv[index]; const args = argv.slice(index + 1); + const branch = (value) => /^(?:main|invalid-context)$/.test(value ?? ""); + const object = (value) => safeGitOperand(value) && !/^[a-z][a-z0-9+.-]*:\/\//i.test(value) && !/^[^/\s]+@[^:\s]+:/.test(value); + const ownedPair = (values) => values.length === 2 && values.every((value) => ownedGitPath(value, runRoot)); + let valid = false; + switch (verb) { + case "init": valid = !prefix && args.length === 3 && args[0] === "--bare" && args[1] === "--initial-branch=main" && ownedGitPath(args[2], runRoot); break; + case "clone": valid = (!prefix && ownedPair(args)) + || (!prefix && args[0] === "--bare" && ownedPair(args.slice(1))) + || (prefix === "hooks" && args.length === 6 && args[0] === "--branch" && branch(args[1]) && args[2] === "--single-branch" && args[3] === "--" && ownedPair(args.slice(4))); break; + case "config": valid = !prefix && args.length === 2 && FIXTURE_GIT_CONFIG.get(args[0])?.has(args[1]) === true; break; + case "add": valid = (!prefix || prefix === "hooks") && ((args.length === 1 && /^(?:workspace-content|workspace-content\/p1-filesystem\/evidence\/guide\.md)$/.test(args[0])) + || (args.length === 2 && args[0] === "-A" && args[1] === "workspace-content/p1-filesystem/evidence") + || (args[0] === "--" && args.length >= 2 && args.slice(1).every((value) => /^(?:workspaces|workspace-docs)\/[A-Za-z0-9./-]+$/.test(value)))); break; + case "commit": valid = (!prefix || prefix === "hooks") && args.length === 2 && args[0] === "-m" && /^(?:Bootstrap curated P1 content|Update curated Evidence only|Invalid contextual Evidence state|Publish workspace p1-(?:filesystem|http|s3))$/.test(args[1]); break; + case "push": valid = (!prefix || prefix === "hooks") && (exactArray(args, ["origin", "main"]) || exactArray(args, ["origin", "invalid-context"]) + || exactArray(args, ["-u", "origin", "invalid-context"]) || exactArray(args, ["origin", "HEAD:main"])); break; + case "checkout": valid = !prefix && exactArray(args, ["-b", "invalid-context"]); break; + case "fetch": valid = (!prefix || prefix === "hooks") && (exactArray(args, ["origin", "main"]) || (args.length === 3 && args[0] === "--no-tags" && args[1] === "origin" && branch(args[2]))); break; + case "remote": valid = prefix === "hooks" && args.length === 4 && exactArray(args.slice(0, 3), ["set-url", "origin", "--"]) && ownedGitPath(args[3], runRoot); break; + case "merge": valid = prefix === "hooks" && exactArray(args, ["--ff-only", "FETCH_HEAD"]); break; + case "merge-base": valid = prefix === "hooks" && exactArray(args, ["HEAD", "FETCH_HEAD"]); break; + case "reset": valid = (!prefix || prefix === "hooks") && args.length === 2 && args[0] === "--hard" && /^(?:origin\/main|refs\/remotes\/origin\/(?:main|invalid-context))$/.test(args[1]); break; + case "clean": valid = prefix === "hooks" && exactArray(args, ["-fd", "--", "workspaces", "workspace-docs"]); break; + case "status": valid = (!prefix && (exactArray(args, ["--porcelain=v1"]) || exactArray(args, ["--porcelain"]))) + || (prefix === "hooks" && exactArray(args, ["--porcelain"])) + || (prefix === "-C" && exactArray(args, ["--porcelain=v1", "--untracked-files=all", "--", "harness/tht", "harness/pyproject.toml"])); break; + case "write-tree": valid = !prefix && args.length === 0; break; + case "show-ref": valid = (!prefix || prefix === "--git-dir" || prefix === "hooks") && args.length === 0; break; + case "symbolic-ref": valid = (!prefix || prefix === "hooks") && exactArray(args, ["--short", "HEAD"]); break; + case "rev-list": valid = ((prefix === "--git-dir" || prefix === "-C") && exactArray(args, ["--objects", "--all"])) + || (prefix === "hooks" && exactArray(args, ["--left-right", "--count", "HEAD...@{upstream}"])); break; + case "ls-tree": valid = prefix === "hooks" && exactArray(args, ["-r", "--name-only", "HEAD", "--", "workspaces"]); break; + case "cat-file": valid = (!prefix || prefix === "--git-dir" || prefix === "-C" || prefix === "hooks") && args.length === 2 + && ((args[0] === "-e" || args[0] === "-t" || args[0] === "blob") && object(args[1])); break; + case "show": valid = prefix === "hooks" && args.length === 1 && object(args[0]); break; + case "rev-parse": valid = (!prefix || prefix === "--git-dir" || prefix === "hooks") && args.length === 1 && object(args[0]); break; + default: valid = false; } + if (!valid) throw new Error("Git command is prohibited"); return verb; } function boundedChildEnvironment(value, expected) { @@ -278,19 +380,50 @@ function boundedChildEnvironment(value, expected) { for (const [key, value] of Object.entries(expected)) if (environment[key] !== value) throw new Error("child environment changed acceptance bounds"); return environment; } +function sanitizedArgvLabels(argv = []) { + return Array.isArray(argv) ? argv.filter((value) => typeof value === "string").map((value) => + isAbsolute(value) || value.includes(sep) ? "[path]" : /^[a-z]+:\/\//i.test(value) ? "[url]" : value.length > 80 ? "[value]" : value) : []; +} function safeChildEvent(events, { surface = "child_process", api, executable, argv = [], outcome, detail, bounds }) { events.push({ surface, api, executable: executable ? basename(executable) : undefined, - argvLabels: argv.map((value) => isAbsolute(value) || value.includes(sep) ? "[path]" : /^[a-z]+:\/\//i.test(value) ? "[url]" : value.length > 80 ? "[value]" : value), + argvLabels: sanitizedArgvLabels(argv), outcome, ...(activeCommandCheckId ? { checkId: activeCommandCheckId } : {}), ...(detail ? { detail } : {}), ...(bounds ? { bounds } : {}), }); } +function recordPolicyRejection({ executable, argv, api = "validation", detail = "policy" } = {}) { + const event = { + surface: "child_process", api, executable: typeof executable === "string" ? basename(executable) : undefined, + argvLabels: sanitizedArgvLabels(argv), outcome: "REJECTED", detail, + ...(activeCommandCheckId ? { checkId: activeCommandCheckId } : {}), + }; + if (activePolicyRejectionSink) activePolicyRejectionSink.push(event); + else if (commandEventSink) commandEventSink.push(event); + return event; +} +function policyError(message, details) { recordPolicyRejection(details); throw new Error(message); } +function validateThtInvocation(argv, { thtPath, runRoot, cwd } = {}) { + const configPath = Array.isArray(argv) ? argv[3] : undefined; + let configEntry; + try { configEntry = typeof configPath === "string" ? lstatSync(configPath) : undefined; } catch { configEntry = undefined; } + if (!configEntry || !exactArray(argv, ["config", "check", "-c", configPath]) || !ownedGitPath(configPath, runRoot) + || !/\.ya?ml$/.test(configPath) || !configEntry.isFile() || configEntry.isSymbolicLink() + || realpathSync(configPath) !== configPath || cwd !== dirname(dirname(dirname(thtPath)))) { + throw new Error("THT command is prohibited"); + } + return "config-check"; +} -export function installProductionSurfaceGuard({ gitPath, pythonPath, thtPath, runRoot, environment, originalFetch = globalThis.fetch }) { +export function installProductionSurfaceGuard({ + gitPath, pythonPath, thtPath, thtIdentity, runRoot, environment, + originalFetch = globalThis.fetch, failPatchAt, +}) { if (productionSurfaceOwner) throw new Error("production surface guard is already active"); for (const value of [gitPath, pythonPath, thtPath, runRoot]) if (!isAbsolute(value)) throw new Error("production guard paths must be absolute"); + if (typeof originalFetch !== "function") throw new Error("global fetch is unavailable"); + if (!thtIdentity || thtIdentity.sourceStatus !== "tracked-clean") throw new Error("trusted THT identity is absent"); + if (failPatchAt !== undefined && (!Number.isInteger(failPatchAt) || failPatchAt < 1 || failPatchAt > 12)) throw new Error("invalid production patch failure probe"); const token = Symbol("p1-production-surface"); - productionSurfaceOwner = token; const events = []; const originals = { execFile: mutableChildProcess.execFile, spawn: mutableChildProcess.spawn, @@ -299,12 +432,29 @@ export function installProductionSurfaceGuard({ gitPath, pythonPath, thtPath, ru createSocket: mutableDgram.createSocket, Worker: mutableWorkerThreads.Worker, dns: new Map(), dnsPromises: new Map(), dlopen: process.dlopen, }; - const resolveChild = (executable, argv) => { + for (const [name, value] of Object.entries(originals)) { + if (!["dns", "dnsPromises"].includes(name) && typeof value !== "function") throw new Error("production patch prerequisite is unavailable"); + } + const validateOptions = (options, allowed, api) => { + if (!options || typeof options !== "object" || Array.isArray(options)) throw new Error(`${api} options are invalid`); + for (const key of Object.keys(options)) if (!allowed.has(key)) throw new Error(`${api} option is prohibited`); + if ("timeout" in options && (!Number.isSafeInteger(options.timeout) || options.timeout < 1 || options.timeout > 300_000)) throw new Error("command bounds are invalid"); + if ("maxBuffer" in options && (!Number.isSafeInteger(options.maxBuffer) || options.maxBuffer < 1 || options.maxBuffer > MAX_OUTPUT)) throw new Error("command bounds are invalid"); + if ("shell" in options && options.shell !== false) throw new Error(`${api} shell is prohibited`); + }; + const resolveChild = (executable, argv, options, api) => { const canonical = executable === "git" ? gitPath : executable === "python3" ? pythonPath : executable; - if (canonical === gitPath) return { executable: gitPath, kind: "git", verb: validateGitInvocation(argv) }; - if (canonical === thtPath) return { executable: thtPath, kind: "tht" }; + if (canonical === gitPath) { + validateGitInvocation(argv, { runRoot }); + if (options.cwd !== undefined && !ownedGitPath(options.cwd, runRoot)) throw new Error("Git working directory is prohibited"); + return { executable: gitPath, kind: "git" }; + } + if (canonical === thtPath) { + validateThtInvocation(argv, { thtPath, runRoot, cwd: options.cwd }); + return { executable: thtPath, kind: "tht" }; + } if (canonical === pythonPath) { - if (argv.length !== 3 || argv[0] !== "-c" || argv[1] !== PYTHON_LOCK_HOLDER_PROGRAM + if (api !== "spawn" || argv.length !== 3 || argv[0] !== "-c" || argv[1] !== PYTHON_LOCK_HOLDER_PROGRAM || !isAbsolute(argv[2]) || relative(runRoot, argv[2]).startsWith("..") || basename(argv[2]) !== "repository.lock") { throw new Error("child command is prohibited"); } @@ -312,18 +462,21 @@ export function installProductionSurfaceGuard({ gitPath, pythonPath, thtPath, ru } throw new Error("child command is prohibited"); }; - const rejectChild = (api, args) => { - safeChildEvent(events, { api, executable: typeof args[0] === "string" ? args[0] : undefined, outcome: "REJECTED" }); - throw new Error("child command is prohibited"); + const rejectChild = (api, args, message = "child command is prohibited") => { + safeChildEvent(events, { api, executable: typeof args[0] === "string" ? args[0] : undefined, argv: Array.isArray(args[1]) ? args[1] : [], outcome: "REJECTED" }); + throw new Error(message); }; const guardedExecFile = function guardedExecFile(executable, argv, options, callback) { if (!Array.isArray(argv)) return rejectChild("execFile", [executable]); if (typeof options === "function") { callback = options; options = {}; } options ??= {}; let resolved; - try { resolved = resolveChild(executable, argv); boundedChildEnvironment(options.env, environment); } - catch (error) { safeChildEvent(events, { api: "execFile", executable, argv, outcome: "REJECTED" }); throw error; } - const bounded = { ...options, env: options.env ?? environment, timeout: Math.min(options.timeout ?? 30_000, 300_000), maxBuffer: Math.min(options.maxBuffer ?? MAX_OUTPUT, MAX_OUTPUT), shell: false }; + try { + validateOptions(options, new Set(["cwd", "env", "timeout", "maxBuffer", "encoding", "shell"]), "execFile"); + resolved = resolveChild(executable, argv, options, "execFile"); + boundedChildEnvironment(options.env, environment); + } catch (error) { safeChildEvent(events, { api: "execFile", executable, argv, outcome: "REJECTED" }); throw error; } + const bounded = { ...options, env: options.env ?? environment, timeout: options.timeout ?? 30_000, maxBuffer: options.maxBuffer ?? MAX_OUTPUT, shell: false }; safeChildEvent(events, { api: "execFile", executable: resolved.executable, argv, outcome: "STARTED", detail: resolved.kind, bounds: { timeoutMs: bounded.timeout, maxOutputBytes: bounded.maxBuffer, environment: "owned" } }); return originals.execFile(resolved.executable, argv, bounded, (error, stdout, stderr) => { @@ -337,8 +490,12 @@ export function installProductionSurfaceGuard({ gitPath, pythonPath, thtPath, ru const guardedSpawn = function guardedSpawn(executable, argv, options = {}) { if (!Array.isArray(argv)) return rejectChild("spawn", [executable]); let resolved; - try { resolved = resolveChild(executable, argv); boundedChildEnvironment(options.env, environment); } - catch (error) { safeChildEvent(events, { api: "spawn", executable, argv, outcome: "REJECTED" }); throw error; } + try { + validateOptions(options, new Set(["cwd", "env", "stdio", "shell"]), "spawn"); + if ("stdio" in options && JSON.stringify(options.stdio) !== JSON.stringify(["pipe", "pipe", "pipe"])) throw new Error("spawn stdio is prohibited"); + resolved = resolveChild(executable, argv, options, "spawn"); + boundedChildEnvironment(options.env, environment); + } catch (error) { safeChildEvent(events, { api: "spawn", executable, argv, outcome: "REJECTED" }); throw error; } const bounded = { ...options, env: options.env ?? environment, shell: false }; safeChildEvent(events, { api: "spawn", executable: resolved.executable, argv, outcome: "STARTED", detail: resolved.kind, bounds: { timeoutMs: 300_000, maxOutputBytes: MAX_OUTPUT, environment: "owned" } }); @@ -351,100 +508,104 @@ export function installProductionSurfaceGuard({ gitPath, pythonPath, thtPath, ru child.once("error", () => { clearTimeout(timer); }); return child; }; - const owned = { execFile: guardedExecFile, spawn: guardedSpawn, child: new Map(), dns: new Map(), dnsPromises: new Map() }; - mutableChildProcess.execFile = guardedExecFile; - mutableChildProcess.spawn = guardedSpawn; - for (const api of ["exec", "execSync", "execFileSync", "spawnSync", "fork"]) { - const wrapper = (...args) => rejectChild(api, args); owned.child.set(api, wrapper); mutableChildProcess[api] = wrapper; - } + const childWrappers = new Map(); + for (const api of ["exec", "execSync", "execFileSync", "spawnSync", "fork"]) childWrappers.set(api, (...args) => rejectChild(api, args)); const guardedCreateSocket = (..._args) => { safeChildEvent(events, { surface: "dgram", api: "createSocket", outcome: "REJECTED" }); throw new Error("prohibited production surface: dgram"); }; class ProhibitedWorker { constructor() { safeChildEvent(events, { surface: "worker_threads", api: "Worker", outcome: "REJECTED" }); throw new Error("prohibited production surface: worker_threads"); } } - mutableDgram.createSocket = guardedCreateSocket; - mutableWorkerThreads.Worker = ProhibitedWorker; + const dnsWrappers = new Map(); const dnsPromiseWrappers = new Map(); for (const name of ["lookup", "resolve", "resolve4", "resolve6", "resolveAny", "resolveCaa", "resolveCname", "resolveMx", "resolveNaptr", "resolveNs", "resolvePtr", "resolveSoa", "resolveSrv", "resolveTxt", "reverse", "Resolver"]) { if (typeof mutableDns[name] !== "function") continue; - originals.dns.set(name, mutableDns[name]); - const original = originals.dns.get(name); - const wrapper = (...args) => { + const original = mutableDns[name]; originals.dns.set(name, original); + dnsWrappers.set(name, (...args) => { if (name === "lookup" && ["127.0.0.1", "::1"].includes(args[0])) { - safeChildEvent(events, { surface: "dns", api: name, outcome: "PASS", detail: "owned-loopback-literal" }); - return original(...args); + safeChildEvent(events, { surface: "dns", api: name, outcome: "PASS", detail: "owned-loopback-literal" }); return original(...args); } - safeChildEvent(events, { surface: "dns", api: name, outcome: "REJECTED" }); - throw new Error("prohibited production surface: dns"); - }; - owned.dns.set(name, wrapper); mutableDns[name] = wrapper; + safeChildEvent(events, { surface: "dns", api: name, outcome: "REJECTED" }); throw new Error("prohibited production surface: dns"); + }); } for (const [name, value] of Object.entries(mutableDns.promises ?? {})) if (typeof value === "function") { originals.dnsPromises.set(name, value); - const original = originals.dnsPromises.get(name); - const wrapper = async (...args) => { + dnsPromiseWrappers.set(name, async (...args) => { if (name === "lookup" && ["127.0.0.1", "::1"].includes(args[0])) { - safeChildEvent(events, { surface: "dns", api: `promises.${name}`, outcome: "PASS", detail: "owned-loopback-literal" }); - return await original(...args); + safeChildEvent(events, { surface: "dns", api: `promises.${name}`, outcome: "PASS", detail: "owned-loopback-literal" }); return await value(...args); } - safeChildEvent(events, { surface: "dns", api: `promises.${name}`, outcome: "REJECTED" }); - throw new Error("prohibited production surface: dns"); - }; - owned.dnsPromises.set(name, wrapper); mutableDns.promises[name] = wrapper; + safeChildEvent(events, { surface: "dns", api: `promises.${name}`, outcome: "REJECTED" }); throw new Error("prohibited production surface: dns"); + }); } const guardedDlopen = (..._args) => { safeChildEvent(events, { surface: "native_addon", api: "dlopen", outcome: "REJECTED" }); throw new Error("prohibited production surface: native addon"); }; - process.dlopen = guardedDlopen; - syncBuiltinESMExports(); - const network = installNetworkGuard(originalFetch); - activeExecutablePolicy = { gitPath, pythonPath, thtPath }; - let restored = false; + const changes = []; let network; let restored = false; let patchStep = 0; + const assign = (target, key, value) => { const original = target[key]; target[key] = value; changes.push({ target, key, value, original }); }; + const checkpoint = () => { patchStep += 1; if (failPatchAt === patchStep) throw new Error("injected production patch failure"); }; + const rollback = () => { + const errors = []; + if (network) { try { network.restore(); } catch (error) { errors.push(error); } network = undefined; } + for (const { target, key, original } of [...changes].reverse()) { try { target[key] = original; } catch (error) { errors.push(error); } } + try { syncBuiltinESMExports(); } catch (error) { errors.push(error); } + if (productionSurfaceOwner === token) productionSurfaceOwner = undefined; + if (activePolicyRejectionSink === events) activePolicyRejectionSink = undefined; + if (activeExecutablePolicy?.owner === token) activeExecutablePolicy = undefined; + return errors; + }; + try { + productionSurfaceOwner = token; checkpoint(); + assign(mutableChildProcess, "execFile", guardedExecFile); checkpoint(); + assign(mutableChildProcess, "spawn", guardedSpawn); checkpoint(); + for (const [api, wrapper] of childWrappers) assign(mutableChildProcess, api, wrapper); checkpoint(); + assign(mutableDgram, "createSocket", guardedCreateSocket); checkpoint(); + assign(mutableWorkerThreads, "Worker", ProhibitedWorker); checkpoint(); + for (const [name, wrapper] of dnsWrappers) assign(mutableDns, name, wrapper); checkpoint(); + for (const [name, wrapper] of dnsPromiseWrappers) assign(mutableDns.promises, name, wrapper); checkpoint(); + assign(process, "dlopen", guardedDlopen); checkpoint(); + syncBuiltinESMExports(); checkpoint(); + network = installNetworkGuard(originalFetch); checkpoint(); + activePolicyRejectionSink = events; + activeExecutablePolicy = { gitPath, pythonPath, thtPath, thtIdentity, runRoot, owner: token }; checkpoint(); + } catch (error) { + const rollbackErrors = rollback(); + if (rollbackErrors.length) throw new Error("production surface guard installation rollback failed", { cause: error }); + throw error; + } return { events, externalAttempts: network.externalAttempts, addOwnedOrigin: network.addOwnedOrigin, hasOwnedOrigin: network.hasOwnedOrigin, restore() { if (restored) throw new Error("production surface guard restored twice"); restored = true; - let tampered = productionSurfaceOwner !== token; - const ownsToken = productionSurfaceOwner === token; - const restoreOwned = (target, key, wrapper, original) => { - if (target[key] !== wrapper) tampered = true; - if (ownsToken) target[key] = original; - }; - const errors = []; - try { network.restore(); } catch (error) { errors.push(error); } - restoreOwned(mutableChildProcess, "execFile", guardedExecFile, originals.execFile); - restoreOwned(mutableChildProcess, "spawn", guardedSpawn, originals.spawn); - for (const [api, wrapper] of owned.child) restoreOwned(mutableChildProcess, api, wrapper, originals[api]); - restoreOwned(mutableDgram, "createSocket", guardedCreateSocket, originals.createSocket); - restoreOwned(mutableWorkerThreads, "Worker", ProhibitedWorker, originals.Worker); - for (const [name, wrapper] of owned.dns) restoreOwned(mutableDns, name, wrapper, originals.dns.get(name)); - for (const [name, wrapper] of owned.dnsPromises) restoreOwned(mutableDns.promises, name, wrapper, originals.dnsPromises.get(name)); - restoreOwned(process, "dlopen", guardedDlopen, originals.dlopen); syncBuiltinESMExports(); - if (productionSurfaceOwner === token) productionSurfaceOwner = undefined; - if (activeExecutablePolicy?.gitPath === gitPath) activeExecutablePolicy = undefined; + let tampered = productionSurfaceOwner !== token || activePolicyRejectionSink !== events || activeExecutablePolicy?.owner !== token; + for (const { target, key, value } of changes) if (target[key] !== value) tampered = true; + const errors = rollback(); if (tampered || errors.length) throw new Error("production surface guard ownership restoration failed"); }, }; } export async function runCommand(options) { - if (!options || typeof options !== "object" || Array.isArray(options)) throw new Error("command requires an options object"); + const details = () => ({ executable: options && typeof options === "object" ? options.executable : undefined, + argv: options && typeof options === "object" ? options.argv : undefined, api: "runCommand", detail: "policy" }); + if (!options || typeof options !== "object" || Array.isArray(options)) policyError("command requires an options object", details()); const allowed = new Set(["executable", "argv", "cwd", "env", "timeoutMs", "stdin", "maxOutputBytes"]); - for (const key of Object.keys(options)) if (!allowed.has(key)) throw new Error(`unsupported command option ${key}`); + for (const key of Object.keys(options)) if (!allowed.has(key)) policyError(`unsupported command option ${key}`, details()); const { executable, argv, cwd, env, timeoutMs = 30_000, stdin, maxOutputBytes = MAX_OUTPUT } = options; - if (typeof executable !== "string" || !isAbsolute(executable) || /[;&|`$><\n\r]/.test(executable)) throw new Error("command executable is invalid"); + if (typeof executable !== "string" || !isAbsolute(executable) || /[;&|`$><\n\r]/.test(executable)) policyError("command executable is invalid", details()); let canonical; - try { canonical = realpathSync(executable); } catch { throw new Error("command executable is not allowlisted"); } + try { canonical = realpathSync(executable); } catch { policyError("command executable is not allowlisted", details()); } const allowedGit = activeExecutablePolicy?.gitPath ?? resolveTrustedSystemExecutableSync("git"); const allowedTht = activeExecutablePolicy?.thtPath; - if (canonical !== allowedGit && canonical !== allowedTht) throw new Error("command executable is not allowlisted"); - if (!Array.isArray(argv) || argv.some((value) => typeof value !== "string")) throw new Error("command argv must be a string array"); - if (canonical === allowedGit) validateGitInvocation(argv); - if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 300_000 || !Number.isSafeInteger(maxOutputBytes) || maxOutputBytes < 1 || maxOutputBytes > MAX_OUTPUT) throw new Error("command bounds are invalid"); + if (canonical !== allowedGit && canonical !== allowedTht) policyError("command executable is not allowlisted", details()); + if (!Array.isArray(argv) || argv.some((value) => typeof value !== "string")) policyError("command argv must be a string array", details()); + try { + if (canonical === allowedGit) validateGitInvocation(argv, { runRoot: activeExecutablePolicy?.runRoot }); + if (canonical === allowedTht) validateThtInvocation(argv, { thtPath: allowedTht, runRoot: activeExecutablePolicy.runRoot, cwd }); + } catch (error) { policyError(error.message, details()); } + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 300_000 || !Number.isSafeInteger(maxOutputBytes) || maxOutputBytes < 1 || maxOutputBytes > MAX_OUTPUT) { + policyError("command bounds are invalid", details()); + } return await new Promise((resolvePromise, reject) => { const child = mutableChildProcess.execFile(canonical, argv, { cwd, env, timeout: timeoutMs, maxBuffer: maxOutputBytes, encoding: "utf8", shell: false }, (error, stdout, stderr) => { const code = error && typeof error.code === "number" ? error.code : error ? 1 : 0; const result = { code, stdout: stdout ?? "", stderr: stderr ?? "" }; if (commandEventSink) commandEventSink.push({ - executable: basename(canonical), - argvLabels: argv.map((value) => isAbsolute(value) || value.includes(sep) ? "[path]" : /^[a-z]+:\/\//i.test(value) ? "[url]" : value.length > 80 ? "[value]" : value), - outcome: error ? "FAIL" : "PASS", + executable: basename(canonical), argvLabels: sanitizedArgvLabels(argv), outcome: error ? "FAIL" : "PASS", ...(activeCommandCheckId ? { checkId: activeCommandCheckId } : {}), }); if (error) Object.assign(error, { result }); @@ -618,8 +779,15 @@ export function installNetworkGuard(fetchImplementation = globalThis.fetch) { } return originalConnect.apply(this, args); }; - globalThis.fetch = guardedFetch; - Socket.prototype.connect = guardedSocketConnect; + let fetchChanged = false; let socketChanged = false; + try { + globalThis.fetch = guardedFetch; fetchChanged = true; + Socket.prototype.connect = guardedSocketConnect; socketChanged = true; + } catch (error) { + if (socketChanged) Socket.prototype.connect = originalConnect; + if (fetchChanged) globalThis.fetch = originalFetch; + throw error; + } let restored = false; return { externalAttempts, @@ -893,9 +1061,7 @@ export function buildSafeEnvironment({ ambient = process.env, fixture = {} } = { } async function setupContext(run, repositoryRoot, env, ctx = {}) { - const executables = await resolveProductionExecutables({ - repositoryRoot, thtBin: env.THT_BIN ?? join(repositoryRoot, "harness", ".venv", "bin", "tht"), - }); + const executables = await resolveProductionExecutables({ repositoryRoot }); const harnessDir = realpathSync(join(repositoryRoot, "harness")); const gitTracePath = join(run.root, "logs", "production-git-trace.jsonl"); const ownedHome = join(run.root, "installation", "runtime", "acceptance-home"); @@ -907,9 +1073,10 @@ async function setupContext(run, repositoryRoot, env, ctx = {}) { PATH: executablePath, HOME: ownedHome, TMPDIR: ownedTmp, GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", GIT_TERMINAL_PROMPT: "0", GIT_ASKPASS: "/bin/false", SSH_ASKPASS: "/bin/false", GIT_ALLOW_PROTOCOL: "file", GIT_PROTOCOL_FROM_USER: "0", - GIT_CONFIG_COUNT: "3", GIT_CONFIG_KEY_0: "commit.gpgSign", GIT_CONFIG_VALUE_0: "false", + GIT_CONFIG_COUNT: "4", GIT_CONFIG_KEY_0: "commit.gpgSign", GIT_CONFIG_VALUE_0: "false", GIT_CONFIG_KEY_1: "tag.gpgSign", GIT_CONFIG_VALUE_1: "false", GIT_CONFIG_KEY_2: "credential.helper", GIT_CONFIG_VALUE_2: "", + GIT_CONFIG_KEY_3: "core.fsmonitor", GIT_CONFIG_VALUE_3: "false", GIT_PAGER: "/bin/cat", HOST: "127.0.0.1", PORT: "0", AUTH_MODE: "none", THT_BIN: executables.thtPath, THT_HARNESS_DIR: harnessDir, THT_DATA_ROOT: join(run.root, "installation", "data"), SETTINGS_FILE: join(run.root, "installation", "data", "settings.json"), @@ -1397,7 +1564,10 @@ function productionChecks(ctx) { const gitTraceProof = await assertProductionGitTrace(ctx); assert(present.length === 0 && prohibitedRoutesCalled.length === 0 && prohibitedCommands.length === 0, "prohibited P1 scope operation observed"); assert(ctx.networkGuard.externalAttempts.length === 0, "external network connection attempted"); - const rejectedSurfaces = ctx.networkGuard.events.filter(({ outcome }) => outcome === "REJECTED"); + const rejectedSurfaces = [ + ...ctx.networkGuard.events, + ...(commandEventSink ?? []).filter((event) => event.outcome === "REJECTED" && !ctx.networkGuard.events.includes(event)), + ].filter(({ outcome }) => outcome === "REJECTED"); const rejectedChildren = rejectedSurfaces.filter(({ surface }) => surface === "child_process"); const childKinds = new Set(ctx.networkGuard.events.filter(({ surface }) => surface === "child_process").map(({ detail }) => detail).filter(Boolean)); assert(rejectedSurfaces.length === 0 && rejectedChildren.length === 0 && ["git", "python-lock-holder", "tht"].every((kind) => childKinds.has(kind)), @@ -1589,9 +1759,11 @@ export async function runIntegration({ attachResultArtifact(results, "preflight", commandArtifact); if (ctx.networkGuard) { const executablePolicy = {}; - for (const [name, executablePath] of Object.entries(ctx.executables)) { + for (const name of ["gitPath", "pythonPath", "thtPath"]) { + const executablePath = ctx.executables[name]; executablePolicy[name] = { path: executablePath, sha256: sha256(await readFile(executablePath)) }; } + executablePolicy.thtIdentity = ctx.executables.thtIdentity; const childArtifact = await evidence(run, "logs/production-child-events.json", { executablePolicy, environmentPolicy: { PATH: ctx.env.PATH, HOME: ctx.env.HOME, TMPDIR: ctx.env.TMPDIR, diff --git a/backend/scripts/p1-acceptance.test.mjs b/backend/scripts/p1-acceptance.test.mjs index 8b33b4df..dd52dc45 100644 --- a/backend/scripts/p1-acceptance.test.mjs +++ b/backend/scripts/p1-acceptance.test.mjs @@ -280,15 +280,13 @@ test("command helper accepts only executable plus separate argv", async () => { await assert.rejects(runCommand({ executable: "git status; rm -rf /", argv: [] })); await assert.rejects(runCommand({ executable: "/tmp/git", argv: ["--version"] }), /command executable is not allowlisted/); await assert.rejects(runCommand({ executable: "tht", argv: ["config", "check"] }), /command executable is invalid/); - const repositoryRoot = await fakeRepository(); - const executable = join(repositoryRoot, "executable with spaces"); + const scratchRoot = await fakeRepository(); + const executable = join(scratchRoot, "executable with spaces"); await writeFile(executable, "#!/bin/sh\nprintf '%s' \"$1\"\n", { mode: 0o700 }); await chmod(executable, 0o700); await assert.rejects(runCommand({ executable, argv: ["literal;not-a-shell"] }), /command executable is not allowlisted/); - const tht = join(repositoryRoot, "harness", ".venv", "bin", "tht"); - await mkdir(dirname(tht), { recursive: true }); - await writeFile(tht, "#!/bin/sh\nexit 0\n", { mode: 0o700 }); - const { gitPath } = await resolveProductionExecutables({ repositoryRoot, thtBin: tht, ambientPath: process.env.PATH }); + const repositoryRoot = await realpath(join(dirname(fileURLToPath(import.meta.url)), "..", "..")); + const { gitPath } = await resolveProductionExecutables({ repositoryRoot }); const result = await runCommand({ executable: gitPath, argv: ["--version"] }); assert.match(result.stdout, /^git version /); assert.equal(result.code, 0); @@ -350,7 +348,7 @@ test("announce callback observes PASS and manual pending before non-keep cleanup test("public wrapper replaces ambient environment before invoking the runner", async () => { const wrapper = await readFile(join(dirname(fileURLToPath(import.meta.url)), "..", "..", "scripts", "p1-acceptance.sh"), "utf8"); - assert.match(wrapper, /safe_env=\(env -i/); + assert.match(wrapper, /safe_env=\(\/usr\/bin\/env -i/); assert.match(wrapper, /P1_ACCEPTANCE_FAIL_AT/); assert.doesNotMatch(wrapper, /export THT_BIN/); }); @@ -473,14 +471,11 @@ test("runIntegration fails closed when a later duplicate overwrites stale artifa }); test("production surface guard rejects and records UDP, Worker, git ls-remote, and unexpected python", async () => { - const repositoryRoot = await fakeRepository(); - const thtPath = join(repositoryRoot, "harness", ".venv", "bin", "tht"); - await mkdir(dirname(thtPath), { recursive: true }); - await writeFile(thtPath, "#!/bin/sh\nexit 0\n", { mode: 0o700 }); - await chmod(thtPath, 0o700); - const executables = await resolveProductionExecutables({ repositoryRoot, thtBin: thtPath, ambientPath: process.env.PATH }); + const runRoot = await fakeRepository(); + const repositoryRoot = await realpath(join(dirname(fileURLToPath(import.meta.url)), "..", "..")); + const executables = await resolveProductionExecutables({ repositoryRoot }); const guard = installProductionSurfaceGuard({ - ...executables, runRoot: repositoryRoot, environment: { ...process.env }, originalFetch: globalThis.fetch, + ...executables, runRoot, environment: { ...process.env }, originalFetch: globalThis.fetch, }); try { assert.throws(() => dgram.createSocket("udp4"), /prohibited production surface/); @@ -576,16 +571,140 @@ test("environment tampering fails the audit and restores the caller environment" }); test("production guard detects global tampering and restores without stranding patches", async () => { - const repositoryRoot = await fakeRepository(); - const thtPath = join(repositoryRoot, "harness", ".venv", "bin", "tht"); - await mkdir(dirname(thtPath), { recursive: true }); - await writeFile(thtPath, "#!/bin/sh\nexit 0\n", { mode: 0o700 }); - const executables = await resolveProductionExecutables({ repositoryRoot, thtBin: thtPath }); + const runRoot = await fakeRepository(); + const repositoryRoot = await realpath(join(dirname(fileURLToPath(import.meta.url)), "..", "..")); + const executables = await resolveProductionExecutables({ repositoryRoot }); const originalFetch = globalThis.fetch; - const guard = installProductionSurfaceGuard({ ...executables, runRoot: repositoryRoot, environment: { ...process.env }, originalFetch }); + const guard = installProductionSurfaceGuard({ ...executables, runRoot, environment: { ...process.env }, originalFetch }); globalThis.fetch = originalFetch; assert.throws(() => guard.restore(), /ownership restoration failed/); assert.equal(globalThis.fetch, originalFetch); const childProcess = await import("node:child_process"); assert.doesNotThrow(() => childProcess.spawn); }); + + +test("Git grammar rejects helper, config, alias, and network-capable spellings with one event each", async () => { + const repositoryRoot = await realpath(join(dirname(fileURLToPath(import.meta.url)), "..", "..")); + const runRoot = await fakeRepository(); + const executables = await resolveProductionExecutables({ repositoryRoot, thtBin: "/tmp/hostile-tht" }); + const guard = installProductionSurfaceGuard({ ...executables, runRoot, environment: { ...process.env } }); + const source = join(runRoot, "source.git"); + const destination = join(runRoot, "destination"); + const marker = join(runRoot, "helper-ran"); + await execFileAsync(executables.gitPath, ["init", "--bare", source]); + const helper = join(runRoot, "upload-helper"); + await writeFile(helper, `#!/bin/sh\nprintf ran > "${marker}"\nexit 99\n`, { mode: 0o700 }); + const prohibited = [ + ["clone", `--upload-pack=${helper}`, source, destination], + ["clone", "--receive-pack=/tmp/helper", source, destination], + ["--exec-path=/tmp", "status"], + ["-c", "alias.status=!touch /tmp/pwn", "status"], + ["-c", "core.hooksPath=/tmp/hooks", "status"], + ["-c", "diff.external=/tmp/helper", "status"], + ["config", "filter.bad.clean", "/tmp/helper"], + ["ls-remote", "https://example.com/repo.git"], + ]; + try { + for (const argv of prohibited) { + const before = guard.events.length; + await assert.rejects(runCommand({ executable: executables.gitPath, argv }), /Git command is prohibited/); + assert.equal(guard.events.length - before, 1); + assert.equal(guard.events.at(-1).outcome, "REJECTED"); + } + await assert.rejects(lstat(marker)); + } finally { guard.restore(); } +}); + +test("production executables ignore ambient THT and bind the generated tht entrypoint to reviewed source", async () => { + const repositoryRoot = await realpath(join(dirname(fileURLToPath(import.meta.url)), "..", "..")); + const hostile = join(await fakeRepository(), "tht"); + await writeFile(hostile, "#!/bin/sh\nexit 0\n", { mode: 0o700 }); + const executables = await resolveProductionExecutables({ repositoryRoot, thtBin: hostile }); + assert.equal(executables.thtPath, join(repositoryRoot, "harness", ".venv", "bin", "tht")); + assert.equal(executables.thtIdentity.sourceRoot, join(repositoryRoot, "harness", "tht")); + assert.equal(executables.thtIdentity.sourceStatus, "tracked-clean"); + assert.equal(executables.thtIdentity.entrypoint, "generated-console-script"); + assert.match(executables.thtIdentity.pythonPath, /python3(?:\.\d+)?$/); +}); + +test("tht accepts only config check for one owned rendered yaml", async () => { + const repositoryRoot = await realpath(join(dirname(fileURLToPath(import.meta.url)), "..", "..")); + const runRoot = await fakeRepository(); + const rendered = join(runRoot, "rendered", "workspace.yaml"); + await mkdir(dirname(rendered), { recursive: true }); + await writeFile(rendered, "profile: acceptance\n"); + const executables = await resolveProductionExecutables({ repositoryRoot }); + const guard = installProductionSurfaceGuard({ ...executables, runRoot, environment: { ...process.env } }); + const childProcess = await import("node:child_process"); + try { + for (const argv of [ + ["config", "check"], ["config", "check", "-c", "/tmp/unowned.yaml"], + ["doctor"], ["config", "check", "-c", rendered, "--extra"], + ]) { + const before = guard.events.length; + assert.throws(() => childProcess.execFile(executables.thtPath, argv), /THT command is prohibited/); + assert.equal(guard.events.length - before, 1); + } + } finally { guard.restore(); } +}); + +test("production guard installation rolls back every patch and owner on every injected patch failure", async () => { + const repositoryRoot = await realpath(join(dirname(fileURLToPath(import.meta.url)), "..", "..")); + const runRoot = await fakeRepository(); + const executables = await resolveProductionExecutables({ repositoryRoot }); + const childProcess = await import("node:child_process"); + const originalSpawn = childProcess.spawn; + const originalDgram = dgram.createSocket; + const originalFetch = globalThis.fetch; + for (let failPatchAt = 1; failPatchAt <= 12; failPatchAt += 1) { + assert.throws(() => installProductionSurfaceGuard({ + ...executables, runRoot, environment: { ...process.env }, failPatchAt, + }), /injected production patch failure/); + assert.equal(childProcess.spawn, originalSpawn); + assert.equal(dgram.createSocket, originalDgram); + assert.equal(globalThis.fetch, originalFetch); + const reacquired = installProductionSurfaceGuard({ ...executables, runRoot, environment: { ...process.env } }); + reacquired.restore(); + } +}); + +test("command bounds reject zero, negative, fractional, and nonnumeric timeouts with one sanitized event", async () => { + const repositoryRoot = await realpath(join(dirname(fileURLToPath(import.meta.url)), "..", "..")); + const runRoot = await fakeRepository(); + const executables = await resolveProductionExecutables({ repositoryRoot }); + const guard = installProductionSurfaceGuard({ ...executables, runRoot, environment: { ...process.env } }); + try { + for (const timeoutMs of [0, -1, 1.5, NaN]) { + const before = guard.events.length; + await assert.rejects(runCommand({ executable: executables.gitPath, argv: ["--version"], timeoutMs }), /command bounds are invalid/); + assert.equal(guard.events.length - before, 1); + } + } finally { guard.restore(); } +}); + +test("public wrapper has no ambient command resolution and isolates the build and runner", async () => { + const wrapper = await readFile(join(dirname(fileURLToPath(import.meta.url)), "..", "..", "scripts", "p1-acceptance.sh"), "utf8"); + assert.doesNotMatch(wrapper, /command\s+-v/); + assert.doesNotMatch(wrapper, /\b(?:node|npm)\s+--prefix/); + assert.match(wrapper, /env -i/); + assert.match(wrapper, /npm-cli\.js/); + assert.match(wrapper, /"\$node_path" "\$npm_path"/); +}); + + +test("hostile PATH Node npm and THT substitutes never execute before a real wrapper integration", async () => { + const hostileRoot = await fakeRepository(); + const marker = join(hostileRoot, "ambient-tool-ran"); + for (const name of ["node", "npm", "tht"]) { + const path = join(hostileRoot, name); + await writeFile(path, `#!/bin/sh\nprintf '%s' '${name}' >> '${marker}'\nexit 97\n`, { mode: 0o700 }); + await chmod(path, 0o700); + } + const wrapper = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "scripts", "p1-acceptance.sh"); + const { stdout } = await execFileAsync(wrapper, ["integration"], { + env: { ...process.env, PATH: hostileRoot, THT_BIN: join(hostileRoot, "tht") }, timeout: 120_000, maxBuffer: 4 * 1024 * 1024, + }); + assert.match(stdout, /automated integration: PASS/); + await assert.rejects(lstat(marker)); +}); diff --git a/scripts/p1-acceptance.sh b/scripts/p1-acceptance.sh index 49ae25cc..b43e15a5 100755 --- a/scripts/p1-acceptance.sh +++ b/scripts/p1-acceptance.sh @@ -1,23 +1,70 @@ -#!/usr/bin/env bash +#!/bin/bash set -euo pipefail -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +script_path=${BASH_SOURCE[0]} +script_dir=${script_path%/*} +[[ "$script_dir" != "$script_path" ]] || script_dir=. +repo_root="$(cd -P -- "$script_dir/.." && pwd)" if [[ $# -lt 1 || "$1" != "integration" || $# -gt 2 || ( $# -eq 2 && "$2" != "--keep" ) ]]; then printf 'usage: %s integration [--keep]\n' "$0" >&2 exit 2 fi -for command in node npm git python3; do command -v "$command" >/dev/null || { printf 'missing command: %s\n' "$command" >&2; exit 127; }; done -node_command="$(command -v node)" -node_bin="$(cd "$(dirname "$node_command")" && pwd -P)/$(basename "$node_command")" -THT_BIN="${THT_BIN:-$repo_root/harness/.venv/bin/tht}" -[[ "$THT_BIN" = /* && -x "$THT_BIN" ]] || { printf 'THT_BIN must be an absolute executable path\n' >&2; exit 127; } -npm --prefix "$repo_root/backend" run build -safe_env=(env -i "PATH=/usr/bin:/bin" "HOME=/nonexistent" "TMPDIR=/tmp" "LANG=${LANG:-C}" "THT_BIN=$THT_BIN") + +canonical_file() { + local path=$1 target parent leaf + [[ "$path" = /* ]] || return 1 + while [[ -L "$path" ]]; do + target=$(/usr/bin/readlink "$path") || return 1 + if [[ "$target" = /* ]]; then path=$target; else path="${path%/*}/$target"; fi + done + parent=${path%/*}; leaf=${path##*/} + parent=$(cd -P -- "$parent" && pwd) || return 1 + printf '%s/%s\n' "$parent" "$leaf" +} + +node_path= npm_path= toolchain_prefix= +for pair in \ + "/usr/bin/node|/usr/bin/npm|/usr" \ + "/opt/homebrew/bin/node|/opt/homebrew/bin/npm|/opt/homebrew" \ + "/usr/local/bin/node|/usr/local/bin/npm|/usr/local"; do + node_candidate=${pair%%|*}; remainder=${pair#*|}; npm_candidate=${remainder%%|*}; prefix=${remainder##*|} + [[ -e "$node_candidate" && -e "$npm_candidate" ]] || continue + resolved_node=$(canonical_file "$node_candidate") || continue + resolved_npm=$(canonical_file "$npm_candidate") || continue + [[ -f "$resolved_node" && ! -L "$resolved_node" && -x "$resolved_node" ]] || continue + [[ -f "$resolved_npm" && ! -L "$resolved_npm" ]] || continue + [[ "${resolved_npm##*/}" = "npm-cli.js" ]] || continue + case "$prefix|$resolved_node|$resolved_npm" in + "/usr|/usr/bin/node|/usr/"*"/npm/bin/npm-cli.js"| "/opt/homebrew|/opt/homebrew/Cellar/node/"*"/bin/node|/opt/homebrew/lib/node_modules/npm/bin/npm-cli.js"| "/usr/local|/usr/local/"*"node"*"|/usr/local/lib/node_modules/npm/bin/npm-cli.js") ;; + *) continue ;; + esac + { IFS= read -r npm_header; IFS= read -r npm_bootstrap; } < "$resolved_npm" + [[ "$npm_header" = '#!/usr/bin/env node' && "$npm_bootstrap" = "require('../lib/cli.js')(process)" ]] || continue + node_path=$resolved_node; npm_path=$resolved_npm; toolchain_prefix=$prefix + break +done +[[ -n "$node_path" && -n "$npm_path" && -n "$toolchain_prefix" ]] || { + printf 'trusted fixed Node/npm toolchain is unavailable\n' >&2 + exit 127 +} + +wrapper_root=$(/usr/bin/mktemp -d /tmp/thoth-p1-wrapper.XXXXXXXX) +trap '/bin/rm -rf -- "$wrapper_root"' EXIT HUP INT TERM +/bin/mkdir -m 700 "$wrapper_root/home" "$wrapper_root/tmp" +owned_path="${node_path%/*}:/usr/bin:/bin" +build_env=(/usr/bin/env -i "PATH=$owned_path" "HOME=$wrapper_root/home" "TMPDIR=$wrapper_root/tmp" "LANG=${LANG:-C}") +for name in LC_ALL TZ; do + [[ -n "${!name:-}" ]] && build_env+=("$name=${!name}") +done +"${build_env[@]}" "$node_path" "$npm_path" --prefix "$repo_root/backend" run build + +safe_env=(/usr/bin/env -i "PATH=$owned_path" "HOME=$wrapper_root/home" "TMPDIR=$wrapper_root/tmp" "LANG=${LANG:-C}" + "THT_BIN=$repo_root/harness/.venv/bin/tht" "P1_ACCEPTANCE_NODE_PATH=$node_path" "P1_ACCEPTANCE_NPM_PATH=$npm_path") for name in LC_ALL TZ; do [[ -n "${!name:-}" ]] && safe_env+=("$name=${!name}") done [[ -n "${P1_ACCEPTANCE_FAIL_AT:-}" ]] && safe_env+=("P1_ACCEPTANCE_FAIL_AT=$P1_ACCEPTANCE_FAIL_AT") set +e -"${safe_env[@]}" "$node_bin" "$repo_root/backend/scripts/p1-acceptance.mjs" "$@" +"${safe_env[@]}" "$node_path" "$repo_root/backend/scripts/p1-acceptance.mjs" "$@" status=$? set -e exit "$status"