From c1ca81444086248381f45c37585cc12d2390e02a Mon Sep 17 00:00:00 2001 From: mptyl Date: Sun, 9 Aug 2026 22:16:37 +0200 Subject: [PATCH] fix: close P1 acceptance audit gaps --- PROJECT_STATE.md | 15 +- backend/scripts/p1-acceptance.mjs | 638 +++++++++++++++++++------ backend/scripts/p1-acceptance.test.mjs | 163 ++++++- scripts/p1-acceptance.sh | 10 +- 4 files changed, 676 insertions(+), 150 deletions(-) diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 0f760def..279f06ab 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-f9327d6f41dafbd8a8cd8f6c9efc0276/report.md` +- Retained evidence: `.artifacts/p1-integration/p1-70727c7802050c76978d5007a634ede1/report.md` - automated integration: PASS - manual acceptance: PENDING - The contextual negatives use a separate `invalid-context` branch, remote, checkout, data, @@ -13,10 +13,15 @@ Persisted before/after semantic-state proofs show primary `main` remains valid, a missing-tree publish is state-neutral, and an invalid pull advances only its disposable checkout while the remote and last-valid active/snapshot/data/runtime state remain unchanged. -- The retained audit hashes every request/response and descriptor/negative fixture, production - Git Trace2 plus runner command events, and all declared artifacts with unique paths. The final - whole-run, reachable-Git, and virtual-report scan found no fixture canaries outside the excluded - secret fixture directory. +- The retained audit has 132 unique declared artifacts whose final bytes match every SHA-256 + declaration. It records canonical Git, Python lock-holder, and `tht` children under bounded + process/environment policy, both production listeners closed, and no rejected child/network + surface events. The in-capture reachable-object scan covered all four expected repositories + (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`. ## Internal Qdrant + Ollama semantic infrastructure — LIVE 2026-08-08 diff --git a/backend/scripts/p1-acceptance.mjs b/backend/scripts/p1-acceptance.mjs index 54a213b8..21eeaf94 100755 --- a/backend/scripts/p1-acceptance.mjs +++ b/backend/scripts/p1-acceptance.mjs @@ -1,13 +1,13 @@ #!/usr/bin/env node -import { execFile } from "node:child_process"; import { createHash, randomBytes } from "node:crypto"; import { - closeSync, constants as fsConstants, existsSync, fsyncSync, lstatSync, openSync, realpathSync, + accessSync, closeSync, constants as fsConstants, existsSync, fsyncSync, lstatSync, openSync, realpathSync, statSync, } from "node:fs"; import { access, chmod, lstat, mkdir, open, readFile, readdir, realpath, rename, rm, stat, symlink, writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; +import { createRequire, syncBuiltinESMExports } from "node:module"; import { Socket, isIP } from "node:net"; import { basename, dirname, isAbsolute, join, relative, resolve, sep, @@ -15,9 +15,16 @@ import { import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; -const execFileAsync = promisify(execFile); +const require = createRequire(import.meta.url); +const mutableChildProcess = require("node:child_process"); +const mutableDgram = require("node:dgram"); +const mutableDns = require("node:dns"); +const mutableWorkerThreads = require("node:worker_threads"); let commandEventSink; let activeCommandCheckId; +let activeExecutablePolicy; +let integrationOwner; +let productionSurfaceOwner; const RUN_ID = /^p1-[0-9a-f]{32}$/; const HEX40 = /^[0-9a-f]{40}$/; const HEX64 = /^[0-9a-f]{64}$/; @@ -37,7 +44,18 @@ const TOPOLOGY = [ "exports/raw", "exports/extracted", "rendered", "logs", ]; const ZIP_FILES = ["manifest.json", "workspace.yaml", "contract.env.example", "README.md"]; -const MAX_OUTPUT = 1024 * 1024; +const MAX_OUTPUT = 16 * 1024 * 1024; +const PYTHON_LOCK_HOLDER_PROGRAM = [ + "import fcntl, os, sys", + "fd = os.open(sys.argv[1], os.O_RDWR | os.O_CREAT | getattr(os, 'O_NOFOLLOW', 0), 0o600)", + "try:", + " fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)", + "except BlockingIOError:", + " sys.exit(73)", + "sys.stdout.write('locked\\n')", + "sys.stdout.flush()", + "sys.stdin.buffer.read()", +].join("\n"); const modulePath = fileURLToPath(import.meta.url); const defaultRepositoryRoot = realpathSync(resolve(dirname(modulePath), "../..")); @@ -83,9 +101,12 @@ async function atomicWrite(path, bytes, mode = 0o600) { const directory = openSync(dirname(path), fsConstants.O_RDONLY); try { fsyncSync(directory); } finally { closeSync(directory); } } catch (error) { - if (handle) await handle.close().catch(() => {}); - await rm(staging, { force: true }); - throw error; + let failure = error; + if (handle) { + try { await handle.close(); } catch (closeError) { failure = closeError; } + } + try { await rm(staging, { force: true }); } catch (cleanupError) { failure = cleanupError; } + throw failure; } } function exactOwnedResources(run) { @@ -119,10 +140,11 @@ function ownership(run, listeners = run.listeners) { }; } async function writeOwnership(run, listenerUpdate) { - if (listenerUpdate) { - run.listeners = run.listeners.map((listener) => listener.name === listenerUpdate.name ? listenerUpdate : listener); - } - await atomicWrite(join(run.root, "ownership.json"), `${JSON.stringify(ownership(run), null, 2)}\n`); + const listeners = listenerUpdate + ? run.listeners.map((listener) => listener.name === listenerUpdate.name ? listenerUpdate : listener) + : run.listeners; + await atomicWrite(join(run.root, "ownership.json"), `${JSON.stringify(ownership(run, listeners), null, 2)}\n`); + run.listeners = listeners; } export async function createOwnedRun({ repositoryRoot, runId, nonce, now, pid } = {}) { const repo = canonicalRoot(repositoryRoot); @@ -149,7 +171,7 @@ function strictOwnership(value, run, expectedNonce) { && value.listeners.every((listener, index) => { const expectedName = ["primary", "contextual"][index]; const common = listener?.name === expectedName && listener.kind === "fastify" && listener.host === "127.0.0.1" - && listener.requestedPort === 0 && listener.pid === process.pid && ["not_started", "listening", "closed"].includes(listener.state); + && listener.requestedPort === 0 && listener.pid === process.pid && ["not_started", "listening", "closed", "close_failed"].includes(listener.state); return common && (listener.state === "not_started" ? !("actualPort" in listener) : Number.isInteger(listener.actualPort) && listener.actualPort >= 1 && listener.actualPort <= 65535); @@ -191,22 +213,236 @@ export async function finalizeOwnedRun({ run, success, keep }) { return true; } +function resolveTrustedSystemExecutableSync(name) { + const candidates = process.platform === "win32" + ? [] + : [`/usr/bin/${name}`, `/bin/${name}`, `/opt/homebrew/bin/${name}`, `/usr/local/bin/${name}`]; + for (const candidate of candidates) { + try { + accessSync(candidate, fsConstants.X_OK); + const canonical = realpathSync(candidate); + if (statSync(canonical).isFile()) return canonical; + } catch { /* try the next fixed trusted executable location */ } + } + throw new Error(`cannot resolve trusted system executable: ${name}`); +} + +export async function resolveProductionExecutables({ repositoryRoot, thtBin } = {}) { + 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 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", +]); +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 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"); + } + return verb; +} +function boundedChildEnvironment(value, expected) { + const environment = value ?? process.env; + if (!environment || typeof environment !== "object" || Array.isArray(environment)) throw new Error("child environment is invalid"); + const allowedExtra = new Set([ + "GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL", + "THT_AUTH_USER_ID", "THT_AUTH_USERNAME", "THT_AUTH_IS_ADMIN", "THT_DWH_API_KEY", "THT_VEC_API_KEY", + "THT_VEC_WRITE_API_KEY", "THT_CA", "THT_SSL_CA", + ]); + for (const [key, value] of Object.entries(environment)) { + if (typeof value !== "string" || (!(key in expected) && !allowedExtra.has(key))) throw new Error("child environment exceeds acceptance bounds"); + } + for (const [key, value] of Object.entries(expected)) if (environment[key] !== value) throw new Error("child environment changed acceptance bounds"); + return environment; +} +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), + outcome, ...(activeCommandCheckId ? { checkId: activeCommandCheckId } : {}), ...(detail ? { detail } : {}), ...(bounds ? { bounds } : {}), + }); +} + +export function installProductionSurfaceGuard({ gitPath, pythonPath, thtPath, runRoot, environment, originalFetch = globalThis.fetch }) { + 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"); + const token = Symbol("p1-production-surface"); + productionSurfaceOwner = token; + const events = []; + const originals = { + execFile: mutableChildProcess.execFile, spawn: mutableChildProcess.spawn, + exec: mutableChildProcess.exec, execSync: mutableChildProcess.execSync, execFileSync: mutableChildProcess.execFileSync, + spawnSync: mutableChildProcess.spawnSync, fork: mutableChildProcess.fork, + createSocket: mutableDgram.createSocket, Worker: mutableWorkerThreads.Worker, + dns: new Map(), dnsPromises: new Map(), dlopen: process.dlopen, + }; + const resolveChild = (executable, argv) => { + 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 === pythonPath) { + if (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"); + } + return { executable: pythonPath, kind: "python-lock-holder" }; + } + 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 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 }; + 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) => { + safeChildEvent(events, { api: "execFile", executable: resolved.executable, argv, outcome: error ? "FAIL" : "PASS", detail: resolved.kind }); + callback?.(error, stdout, stderr); + }); + }; + Object.defineProperty(guardedExecFile, promisify.custom, { value: (executable, argv, options) => new Promise((resolvePromise, reject) => { + guardedExecFile(executable, argv, options, (error, stdout, stderr) => error ? reject(Object.assign(error, { stdout, stderr })) : resolvePromise({ stdout, stderr })); + }) }); + 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; } + 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" } }); + const child = originals.spawn(resolved.executable, argv, bounded); + let bytes = 0; + const count = (chunk) => { bytes += chunk.length; if (bytes > MAX_OUTPUT) child.kill("SIGKILL"); }; + child.stdout?.on("data", count); child.stderr?.on("data", count); + const timer = setTimeout(() => child.kill("SIGKILL"), 300_000); timer.unref(); + child.once("exit", (code) => { clearTimeout(timer); safeChildEvent(events, { api: "spawn", executable: resolved.executable, argv, outcome: code === 0 ? "PASS" : "FAIL", detail: resolved.kind }); }); + 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 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; + 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) => { + 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: "REJECTED" }); + throw new Error("prohibited production surface: dns"); + }; + owned.dns.set(name, wrapper); mutableDns[name] = wrapper; + } + 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) => { + 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: "REJECTED" }); + throw new Error("prohibited production surface: dns"); + }; + owned.dnsPromises.set(name, wrapper); mutableDns.promises[name] = wrapper; + } + 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; + 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; + 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 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}`); const { executable, argv, cwd, env, timeoutMs = 30_000, stdin, maxOutputBytes = MAX_OUTPUT } = options; - if (typeof executable !== "string" || executable.length === 0 || /[;&|`$><\n\r]/.test(executable)) throw new Error("command executable is invalid"); - const allowlistedExecutable = executable === "git" || (isAbsolute(executable) && basename(executable) === "tht"); - if (!allowlistedExecutable) throw new Error("command executable is not allowlisted"); + if (typeof executable !== "string" || !isAbsolute(executable) || /[;&|`$><\n\r]/.test(executable)) throw new Error("command executable is invalid"); + let canonical; + try { canonical = realpathSync(executable); } catch { throw new Error("command executable is not allowlisted"); } + 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 (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 300_000) throw new Error("command timeout is invalid"); + 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"); return await new Promise((resolvePromise, reject) => { - const child = execFile(executable, argv, { cwd, env, timeout: timeoutMs, maxBuffer: maxOutputBytes, encoding: "utf8" }, (error, stdout, stderr) => { + 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(executable), + 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", ...(activeCommandCheckId ? { checkId: activeCommandCheckId } : {}), @@ -217,7 +453,7 @@ export async function runCommand(options) { if (stdin !== undefined) { child.stdin.end(stdin); } }); } -async function git(argv, options = {}) { return await runCommand({ executable: "git", argv, ...options }); } +async function git(argv, options = {}) { return await runCommand({ executable: activeExecutablePolicy?.gitPath ?? resolveTrustedSystemExecutableSync("git"), argv, ...options }); } async function tht(executable, argv, options = {}) { return await runCommand({ executable, argv, ...options }); } function safeArtifactPath(path) { if (typeof path !== "string" || !SAFE_RELATIVE.test(path) || path.startsWith(".") || path.includes("//")) throw new Error("unsafe artifact path"); @@ -282,8 +518,8 @@ async function walkFiles(root, current = root, out = []) { } return out; } -async function gitObjectFindings(runRoot, forbiddenValues, expectedGitRepositories) { - const findings = []; +async function gitObjectScan(runRoot, forbiddenValues, expectedGitRepositories) { + const findings = []; const repositories = []; for (const rel of expectedGitRepositories) { const directory = join(runRoot, rel); if (!existsSync(directory)) throw new Error(`Git secret scan failed closed: missing expected Git repository: ${rel}`); @@ -292,6 +528,7 @@ async function gitObjectFindings(runRoot, forbiddenValues, expectedGitRepositori try { objects = (await git([...args, "rev-list", "--objects", "--all"])).stdout.trim().split("\n").filter(Boolean); } catch { throw new Error(`Git secret scan failed closed during enumeration: ${basename(directory)}`); } + let blobCount = 0; for (const line of objects) { const oid = line.split(" ", 1)[0]; let type; let bytes; @@ -299,21 +536,26 @@ async function gitObjectFindings(runRoot, forbiddenValues, expectedGitRepositori type = (await git([...args, "cat-file", "-t", oid])).stdout.trim(); if (!/^(blob|tree|commit|tag)$/.test(type)) throw new Error("invalid object type"); if (type !== "blob") continue; + blobCount += 1; bytes = Buffer.from((await git([...args, "cat-file", "blob", oid], { maxOutputBytes: 16 * 1024 * 1024 })).stdout); } catch { throw new Error(`Git secret scan failed closed during object inspection: ${basename(directory)}:${oid}`); } - if (containsAny(bytes, forbiddenValues)) findings.push({ path: `git-object:${basename(directory)}:${oid}` }); + if (containsAny(bytes, forbiddenValues)) findings.push({ path: `git-object:${rel}:${oid}` }); } + repositories.push({ path: rel, objectCount: objects.length, blobCount }); } - return findings; + return { findings, repositories }; } -export async function scanSecrets({ runRoot, forbiddenValues, virtualFiles = [], expectedGitRepositories = ["remote.git", "author"] }) { +export async function scanSecretsDetailed({ runRoot, forbiddenValues, virtualFiles = [], expectedGitRepositories = ["remote.git", "author"] }) { const values = forbiddenValues.filter((value) => typeof value === "string" && value.length >= 8); const findings = []; for (const file of await walkFiles(runRoot)) if (containsAny(await readFile(file.path), values)) findings.push({ path: file.rel }); for (const file of virtualFiles) if (containsAny(Buffer.from(file.bytes), values)) findings.push({ path: file.path }); - findings.push(...await gitObjectFindings(runRoot, values, expectedGitRepositories)); - return findings; + const gitScan = await gitObjectScan(runRoot, values, expectedGitRepositories); + findings.push(...gitScan.findings); + return { findings, repositories: gitScan.repositories }; } +export async function scanSecrets(options) { return (await scanSecretsDetailed(options)).findings; } + export function negativeRequestEvidence(caseLabel, expectedInputField) { if (!/^[a-z0-9-]+$/.test(caseLabel) || !/^[a-z_]+(?:\.[a-z_]+)*$/.test(expectedInputField)) throw new Error("unsafe negative-case evidence"); return { case: caseLabel, expectedInputField }; @@ -360,7 +602,7 @@ export function installNetworkGuard(fetchImplementation = globalThis.fetch) { const normalized = host === "localhost" || host === "::1" ? "127.0.0.1" : host; return isIP(normalized) !== 0 && normalized === "127.0.0.1" && ownedOrigins.has(`http://127.0.0.1:${port}`); }; - globalThis.fetch = async (input, init) => { + const guardedFetch = async (input, init) => { const candidate = new URL(typeof input === "string" || input instanceof URL ? input : input.url); if (!ownedOrigins.has(candidate.origin)) { externalAttempts.push({ transport: "fetch", protocol: candidate.protocol, loopback: candidate.hostname === "127.0.0.1" }); @@ -368,7 +610,7 @@ export function installNetworkGuard(fetchImplementation = globalThis.fetch) { } return await fetchImplementation(input, init); }; - Socket.prototype.connect = function guardedSocketConnect(...args) { + const guardedSocketConnect = function guardedSocketConnect(...args) { const destination = socketDestination(args); if (!("host" in destination) || !isOwned(destination.host, destination.port)) { externalAttempts.push({ transport: "socket", loopback: destination.host === "127.0.0.1" }); @@ -376,16 +618,20 @@ export function installNetworkGuard(fetchImplementation = globalThis.fetch) { } return originalConnect.apply(this, args); }; + globalThis.fetch = guardedFetch; + Socket.prototype.connect = guardedSocketConnect; let restored = false; return { externalAttempts, addOwnedOrigin(value) { ownedOrigins.add(loopbackOrigin(value)); }, hasOwnedOrigin(value) { return ownedOrigins.has(loopbackOrigin(value)); }, restore() { - if (restored) return; + if (restored) throw new Error("network guard restored twice"); restored = true; + const tampered = globalThis.fetch !== guardedFetch || Socket.prototype.connect !== guardedSocketConnect; globalThis.fetch = originalFetch; Socket.prototype.connect = originalConnect; + if (tampered) throw new Error("network guard ownership changed"); }, }; } @@ -535,7 +781,7 @@ async function startProductionBackend(ctx, { name, env, runtimeConfigPath }) { try { address = await app.listen({ host: "127.0.0.1", port: 0 }); } catch (error) { - await app.close().catch(() => {}); + try { await app.close(); } catch { throw new Error("production listener start and close both failed"); } throw error; } const url = new URL(address); @@ -567,7 +813,10 @@ export function exportArchiveEvidencePath(requestId) { return `exports/raw/${requestId}.zip`; } function trackArtifact(ctx, artifact) { - if (ctx.activeArtifacts && !ctx.activeArtifacts.some(({ path }) => path === artifact.path)) ctx.activeArtifacts.push(artifact); + if (ctx.activeArtifacts) { + if (ctx.activeArtifacts.some(({ path }) => path === artifact.path)) throw new Error("artifact path is duplicated within check"); + ctx.activeArtifacts.push(artifact); + } return artifact; } @@ -632,7 +881,7 @@ async function snapshotDigest(path) { for (const file of files) result[file.rel] = sha256(await readFile(file.path)); return result; } -const SAFE_AMBIENT_ENV = Object.freeze(["PATH", "HOME", "LANG", "LC_ALL", "TMPDIR", "TZ", "NODE_EXTRA_CA_CERTS"]); +const SAFE_AMBIENT_ENV = Object.freeze(["LANG", "LC_ALL", "TZ"]); export function buildSafeEnvironment({ ambient = process.env, fixture = {} } = {}) { const safe = {}; for (const key of SAFE_AMBIENT_ENV) if (typeof ambient[key] === "string") safe[key] = ambient[key]; @@ -644,11 +893,24 @@ export function buildSafeEnvironment({ ambient = process.env, fixture = {} } = { } async function setupContext(run, repositoryRoot, env, ctx = {}) { - const thtBin = realpathSync(env.THT_BIN ?? join(repositoryRoot, "harness", ".venv", "bin", "tht")); + const executables = await resolveProductionExecutables({ + repositoryRoot, thtBin: env.THT_BIN ?? join(repositoryRoot, "harness", ".venv", "bin", "tht"), + }); 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"); + const ownedTmp = join(run.root, "installation", "runtime", "tmp"); + await mkdir(ownedHome, { recursive: true, mode: 0o700 }); + await mkdir(ownedTmp, { recursive: true, mode: 0o700 }); + const executablePath = [...new Set([dirname(executables.gitPath), dirname(executables.pythonPath), dirname(executables.thtPath)])].join(":"); const fixtureEnv = { - HOST: "127.0.0.1", PORT: "0", AUTH_MODE: "none", THT_BIN: thtBin, + 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_KEY_1: "tag.gpgSign", GIT_CONFIG_VALUE_1: "false", + GIT_CONFIG_KEY_2: "credential.helper", GIT_CONFIG_VALUE_2: "", + 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"), MAINTENANCE_STATE_FILE: join(run.root, "installation", "data", "maintenance.json"), @@ -662,7 +924,7 @@ async function setupContext(run, repositoryRoot, env, ctx = {}) { Object.assign(ctx, { run, repositoryRoot, descriptors: descriptors(), forbiddenValues: ctx.forbiddenValues ?? [], env: buildSafeEnvironment({ ambient: env, fixture: fixtureEnv }), - httpRequests: [], services: [], gitTracePath, + httpRequests: [], services: [], gitTracePath, executables, expectedGitRepositories: ["remote.git", "author"], }); await createTopology(run); @@ -773,7 +1035,11 @@ async function traceSize(path) { } function uniqueArtifacts(artifacts) { const seen = new Set(); - return artifacts.filter((artifact) => !seen.has(artifact.path) && seen.add(artifact.path)); + for (const artifact of artifacts) { + if (seen.has(artifact.path)) throw new Error("artifact path is duplicated within check"); + seen.add(artifact.path); + } + return artifacts; } async function commandsObservedForCheck(ctx, checkId, traceBefore) { const commands = new Set((commandEventSink ?? []).filter((event) => event.checkId === checkId).map((event) => event.executable)); @@ -832,9 +1098,27 @@ async function assertNoP1ScopeEntrypoints(ctx) { const packageJson = JSON.parse(await readFile(join(ctx.repositoryRoot, "backend", "package.json"), "utf8")); const entrypointBytes = JSON.stringify({ main: packageJson.main, bin: packageJson.bin, exports: packageJson.exports, scripts: packageJson.scripts }); const forbiddenPackageEntrypoints = /(?:acquire|preprocess)Evidence|Evidence(?:Adapter|Acquisition|Preprocessor)/i.test(entrypointBytes); + const auditedSurfaceFiles = []; + for (const group of ["workspaces", "routes"]) { + const root = join(ctx.repositoryRoot, "backend", "dist", group); + for (const name of (await readdir(root)).filter((value) => value.endsWith(".js")).sort()) { + auditedSurfaceFiles.push({ path: `${group}/${name}`, source: await readFile(join(root, name), "utf8") }); + } + } + const prohibitedProcessSurfaces = auditedSurfaceFiles.filter(({ source }) => /node:(?:dgram|worker_threads|dns)|\.node(?:["']|$)|process\.dlopen|node-gyp|bindings\s*\(/.test(source)); + const networkAdapterModules = auditedSurfaceFiles.filter(({ source }) => /node:(?:net|http|https)|globalThis\.fetch|\bfetch\s*\(/.test(source)).map(({ path }) => path); + const childProcessModules = auditedSurfaceFiles.filter(({ source }) => /node:child_process/.test(source)).map(({ path }) => path); + assert(JSON.stringify(networkAdapterModules) === JSON.stringify(["workspaces/diagnostics.js"]) + && JSON.stringify(childProcessModules) === JSON.stringify(["workspaces/diagnostics.js", "workspaces/git-repository.js"]) + && prohibitedProcessSurfaces.length === 0, + "unexpected production process/network adapter entrypoint surface present"); assert(forbiddenModuleNames.length === 0 && forbiddenExports.length === 0 && forbiddenRoutes.length === 0 && !forbiddenPackageEntrypoints, "prohibited P1 adapter/acquisition/preprocessing entrypoint surface present"); - return { moduleFilesAudited: modules.sort(), routeAppsAudited: routeSurfaces.map(({ name }) => name), packageEntrypointsAudited: true }; + return { + moduleFilesAudited: modules.sort(), routeAppsAudited: routeSurfaces.map(({ name }) => name), packageEntrypointsAudited: true, + productionSurfaceFilesAudited: auditedSurfaceFiles.map(({ path }) => path), networkAdapterModules, + childProcessModules, workerDgramDnsNativeEntrypoints: [], + }; } function productionChecks(ctx) { @@ -941,7 +1225,6 @@ function productionChecks(ctx) { const currentSnapshot = await currentSnapshotManifest(ctx, current.commit); ctx.currentManifest = currentSnapshot.manifest; return { commands: ["git"], artifacts: [ await evidence(ctx.run, "logs/content-only-revision.json", { oldCommit: ctx.oldRevision.commit, newCommit: current.commit, descriptorBlob: current.blob, oldFilesystemRoot: ctx.oldFilesystemRoot, newFilesystemRoot: newRoot, oldSnapshotImmutable: true }), - await fileArtifact(ctx.run.root, relative(ctx.run.root, currentSnapshot.path)), await fileArtifact(ctx.run.root, relative(ctx.run.root, current.snapshotPath)), ] }; } }, { id: "snapshot_and_docs", run: async () => { @@ -950,16 +1233,17 @@ function productionChecks(ctx) { const extracted = join(ctx.run.root, "exports", "extracted", id); for (const name of ZIP_FILES) { assert((await lstat(join(extracted, name))).isFile(), `missing extracted ${name}`); - artifacts.push(await fileArtifact(ctx.run.root, `exports/extracted/${id}/${name}`)); + await access(join(extracted, name), fsConstants.R_OK); } const revision = revisionFromManifest(ctx.currentManifest, id); - for (const suffix of [".yaml", ".env.example", ".md", "snapshot.json"]) { - const file = suffix === "snapshot.json" ? join(dirname(revision.snapshotPath), suffix) : join(dirname(revision.snapshotPath), `${id}${suffix}`); + for (const suffix of [".yaml", ".env.example", ".md"]) { + const file = join(dirname(revision.snapshotPath), `${id}${suffix}`); assert(existsSync(file), `snapshot artifact absent ${file}`); artifacts.push(await fileArtifact(ctx.run.root, relative(ctx.run.root, file))); } assert(!existsSync(join(dirname(revision.snapshotPath), "workspace-content")), "snapshot materialized source tree"); } + artifacts.push(await fileArtifact(ctx.run.root, relative(ctx.run.root, join(dirname(ctx.currentRevision.snapshotPath), "snapshot.json")))); artifacts.unshift(await evidence(ctx.run, "logs/snapshot-and-docs.json", { exactBundleFiles: ZIP_FILES, generatedDocs: true, immutableSnapshots: true, derivedFromManifest: true })); return { commands: [], artifacts }; } }, @@ -1113,17 +1397,30 @@ 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 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)), + "production child allowlist evidence is incomplete"); assert(ctx.services.length === 2 && ctx.services.every(({ baseUrl }) => ctx.networkGuard.hasOwnedOrigin(baseUrl)), "listener was not an owned loopback origin"); return await log("no-p1-scope-artifacts", { absentArtifacts: forbidden, moduleEntrypointSurfaceAbsent: true, routeEntrypointSurfaceAbsent: true, ...surfaceAudit, productionGitTrace: gitTraceProof, networkGuardInstalledBeforeProduction: true, externalNetworkAttempts: [], + exactProductionChildApi: true, productionChildKinds: [...childKinds].sort(), rejectedProductionChildren: [], ownedLoopbackOrigins: ctx.services.map(({ name }) => name), }); } }, { id: "secret_scan", run: async () => { - const findings = await scanSecrets({ runRoot: ctx.run.root, forbiddenValues: ctx.forbiddenValues }); - assert(findings.length === 0, "secret canary found outside exclusion"); - return await log("secret-scan", { scanned: true, gitEnumerationFailClosed: true, excluded: "fixture-secrets", findings: [] }); + const scan = await scanSecretsDetailed({ + runRoot: ctx.run.root, forbiddenValues: ctx.forbiddenValues, + expectedGitRepositories: ctx.expectedGitRepositories, + }); + assert(scan.findings.length === 0, "secret canary found outside exclusion"); + ctx.gitSecretScanFrozen = true; + return await log("secret-scan", { + scanned: true, gitEnumerationFailClosed: true, excluded: "fixture-secrets", + expectedGitRepositories: [...ctx.expectedGitRepositories], repositories: scan.repositories, findings: [], + }); } }, { id: "cleanup_confinement", run: async () => { const fakeRepo = join(ctx.run.root, "installation", "runtime", "cleanup-test"); @@ -1161,11 +1458,22 @@ function completeFailedResults(results, firstError = "Acceptance setup failed sa return completed; } -function deduplicateResultArtifacts(results) { +function assertUniqueResultArtifacts(results) { const seen = new Set(); - for (const result of results) result.artifacts = result.artifacts.filter(({ path }) => !seen.has(path) && seen.add(path)); + for (const result of results) for (const artifact of result.artifacts) { + if (seen.has(artifact.path)) throw new Error("artifact path is duplicated across checks"); + seen.add(artifact.path); + } return results; } +async function verifyDeclaredArtifacts(runRoot, results) { + assertUniqueResultArtifacts(results); + for (const result of results) for (const artifact of result.artifacts) { + safeArtifactPath(artifact.path); + const current = sha256(await readFile(join(runRoot, artifact.path))); + if (current !== artifact.sha256) throw new Error("declared artifact hash mismatch"); + } +} function minimalFailClosedReport(run, keep) { const checks = CHECK_IDS.map((id, index) => failedCheck( id, nowIso(), index === 0 ? "Acceptance audit failed closed." : "Not executed after fail-closed audit.", @@ -1189,99 +1497,155 @@ function attachResultArtifact(results, checkId, artifact) { result.artifacts.push(artifact); } -export async function runIntegration({ repositoryRoot = defaultRepositoryRoot, keep = false, env = process.env, failAt = env.P1_ACCEPTANCE_FAIL_AT, checks, setup, announce } = {}) { - const savedEnv = { ...process.env }; - let run; let ctx; let results = []; let fatal; - try { - run = await createOwnedRun({ repositoryRoot }); - ctx = { - run, repositoryRoot, forbiddenValues: [], expectedGitRepositories: [], services: [], - originalFetch: globalThis.fetch, - }; - commandEventSink = []; - const selectedSetup = setup ?? (checks === undefined ? setupContext : undefined); - if (selectedSetup) { - const configured = await selectedSetup(run, repositoryRoot, env, ctx); - if (configured && configured !== ctx) Object.assign(ctx, configured); - } - if (checks === undefined) { - if (!ctx.env) throw new Error("acceptance setup returned no environment"); - replaceProcessEnvironment(ctx.env); - ctx.networkGuard = installNetworkGuard(ctx.originalFetch); - checks = productionChecks(ctx); - } else if (ctx.env) { - replaceProcessEnvironment(ctx.env); - } - results = await executeChecks({ checks, failAt }); - } catch (error) { - fatal = error; - if (run) results = completeFailedResults(results); - } finally { - if (ctx?.services) { - for (const service of [...ctx.services].reverse()) { - await service.app.close().catch(() => {}); - await writeOwnership(run, { - name: service.name, kind: "fastify", host: "127.0.0.1", requestedPort: 0, - actualPort: Number(new URL(service.baseUrl).port), pid: process.pid, state: "closed", - }).catch(() => {}); - } - } - ctx?.networkGuard?.restore(); - replaceProcessEnvironment(savedEnv); - } - if (!run) throw fatal; - results = deduplicateResultArtifacts(completeFailedResults(results)); - - let auditFailed = false; - const commandEvents = commandEventSink ?? []; - commandEventSink = undefined; - activeCommandCheckId = undefined; - try { - const commandArtifact = await evidence(run, "logs/command-events.json", { eventCount: commandEvents.length, events: commandEvents }, ctx.forbiddenValues); - attachResultArtifact(results, "preflight", commandArtifact); - if (ctx.gitTracePath) { - const gitTraceBytes = await readFile(ctx.gitTracePath); - for (const line of gitTraceBytes.toString("utf8").split("\n").filter(Boolean)) JSON.parse(line); - attachResultArtifact(results, "no_p1_scope_artifacts", await fileArtifact(run.root, relative(run.root, ctx.gitTracePath))); - } - } catch { - auditFailed = true; - } - deduplicateResultArtifacts(results); - - let report = { - schemaVersion: 1, runId: run.runId, startedAt: run.startedAt, finishedAt: nowIso(), - command: `p1-acceptance integration${keep ? " --keep" : ""}`, - overall: !fatal && deriveOverall(results) === "PASS" ? "PASS" : "FAIL", checks: results, - }; - let bytes; - try { - bytes = reportBytes(report); - const findings = await scanSecrets({ - runRoot: run.root, - forbiddenValues: ctx.forbiddenValues, - expectedGitRepositories: ctx.expectedGitRepositories, - virtualFiles: [{ path: "report.json", bytes: bytes.json }, { path: "report.md", bytes: bytes.markdown }], - }); - if (findings.length > 0) auditFailed = true; - } catch { - auditFailed = true; - } - if (auditFailed) { - report = minimalFailClosedReport(run, keep); - bytes = reportBytes(report); - if (containsAny(bytes.json, ctx.forbiddenValues) || containsAny(bytes.markdown, ctx.forbiddenValues)) { - throw new Error("sanitized fail-closed report unexpectedly contains a forbidden value"); - } - } - await atomicWrite(join(run.root, "report.json"), bytes.json); - await atomicWrite(join(run.root, "report.md"), bytes.markdown); - const finalSuccess = report.overall === "PASS"; - if (announce) await announce({ report, runRoot: run.root, keep }); - const removed = await finalizeOwnedRun({ run, success: finalSuccess, keep }); - return { exitCode: finalSuccess ? 0 : 1, runRoot: run.root, retained: !removed, report }; +async function listenerRefuses(baseUrl, timeoutMs = 1_000) { + const url = new URL(baseUrl); + return await new Promise((resolvePromise) => { + const socket = new Socket(); let settled = false; + const finish = (refuses) => { if (settled) return; settled = true; socket.destroy(); resolvePromise(refuses); }; + const timer = setTimeout(() => finish(false), timeoutMs); timer.unref(); + socket.once("connect", () => { clearTimeout(timer); finish(false); }); + socket.once("error", () => { clearTimeout(timer); finish(true); }); + try { socket.connect({ host: "127.0.0.1", port: Number(url.port) }); } catch { clearTimeout(timer); finish(true); } + }); +} +function environmentMatches(expected) { + const currentKeys = Object.keys(process.env).sort(); const expectedKeys = Object.keys(expected).sort(); + return JSON.stringify(currentKeys) === JSON.stringify(expectedKeys) + && expectedKeys.every((key) => process.env[key] === expected[key]); } +export async function runIntegration({ + repositoryRoot = defaultRepositoryRoot, keep = false, env = process.env, + failAt = env.P1_ACCEPTANCE_FAIL_AT, checks, setup, announce, ownershipWriter = writeOwnership, +} = {}) { + if (integrationOwner) throw new Error("P1 acceptance integration is already active"); + const acquisitionToken = Symbol("p1-integration-owner"); + integrationOwner = acquisitionToken; + const savedEnv = { ...process.env }; + let installedEnv; let run; let ctx; let results = []; let fatal; let auditFailed = false; + try { + try { + run = await createOwnedRun({ repositoryRoot }); + ctx = { + run, repositoryRoot, forbiddenValues: [], expectedGitRepositories: [], services: [], + originalFetch: globalThis.fetch, + }; + commandEventSink = []; + const selectedSetup = setup ?? (checks === undefined ? setupContext : undefined); + if (selectedSetup) { + const configured = await selectedSetup(run, repositoryRoot, env, ctx); + if (configured && configured !== ctx) Object.assign(ctx, configured); + } + if (checks === undefined) { + if (!ctx.env || !ctx.executables) throw new Error("acceptance setup returned no bounded environment"); + installedEnv = { ...ctx.env }; + replaceProcessEnvironment(installedEnv); + ctx.networkGuard = installProductionSurfaceGuard({ + ...ctx.executables, runRoot: run.root, environment: installedEnv, originalFetch: ctx.originalFetch, + }); + checks = productionChecks(ctx); + } else if (ctx.env) { + installedEnv = { ...ctx.env }; + replaceProcessEnvironment(installedEnv); + } + results = await executeChecks({ checks, failAt }); + } catch (error) { + fatal = error; + if (run) results = completeFailedResults(results); + } finally { + if (ctx?.services) { + for (const service of [...ctx.services].reverse()) { + const actualPort = Number(new URL(service.baseUrl).port); + let closed = false; let closeError; + try { + await service.app.close(); + closed = await listenerRefuses(service.baseUrl); + if (!closed) closeError = new Error("listener still accepts connections after close"); + } catch (error) { closeError = error; } + const state = closed ? "closed" : "close_failed"; + try { + await ownershipWriter(run, { + name: service.name, kind: "fastify", host: "127.0.0.1", requestedPort: 0, + actualPort, pid: process.pid, state, + }); + } catch (error) { closeError ??= error; } + if (closeError) { fatal ??= closeError; auditFailed = true; } + } + } + try { ctx?.networkGuard?.restore(); } catch (error) { fatal ??= error; auditFailed = true; } + if (installedEnv && !environmentMatches(installedEnv)) { fatal ??= new Error("acceptance environment ownership changed"); auditFailed = true; } + try { replaceProcessEnvironment(savedEnv); } catch (error) { fatal ??= error; auditFailed = true; } + if (!environmentMatches(savedEnv)) { fatal ??= new Error("acceptance environment restoration failed"); auditFailed = true; } + } + if (!run) throw fatal; + results = completeFailedResults(results); + + const commandEvents = commandEventSink ?? []; + commandEventSink = undefined; + activeCommandCheckId = undefined; + try { + assertUniqueResultArtifacts(results); + const commandArtifact = await evidence(run, "logs/command-events.json", { eventCount: commandEvents.length, events: commandEvents }, ctx.forbiddenValues); + attachResultArtifact(results, "preflight", commandArtifact); + if (ctx.networkGuard) { + const executablePolicy = {}; + for (const [name, executablePath] of Object.entries(ctx.executables)) { + executablePolicy[name] = { path: executablePath, sha256: sha256(await readFile(executablePath)) }; + } + const childArtifact = await evidence(run, "logs/production-child-events.json", { + executablePolicy, environmentPolicy: { + PATH: ctx.env.PATH, HOME: ctx.env.HOME, TMPDIR: ctx.env.TMPDIR, + gitGlobalConfigDisabled: true, gitSystemConfigDisabled: true, gitPromptsHelpersSigningDisabled: true, + gitAllowedProtocol: "file", maxOutputBytes: MAX_OUTPUT, maxTimeoutMs: 300_000, + }, + eventCount: ctx.networkGuard.events.length, events: ctx.networkGuard.events, + }, ctx.forbiddenValues); + attachResultArtifact(results, "no_p1_scope_artifacts", childArtifact); + } + if (ctx.gitTracePath) { + const gitTraceBytes = await readFile(ctx.gitTracePath); + for (const line of gitTraceBytes.toString("utf8").split("\n").filter(Boolean)) JSON.parse(line); + attachResultArtifact(results, "no_p1_scope_artifacts", await fileArtifact(run.root, relative(run.root, ctx.gitTracePath))); + } + assertUniqueResultArtifacts(results); + if (ctx.executables && !ctx.gitSecretScanFrozen) throw new Error("expected Git secret scan was not frozen inside secret_scan"); + } catch { auditFailed = true; } + + let report = { + schemaVersion: 1, runId: run.runId, startedAt: run.startedAt, finishedAt: nowIso(), + command: `p1-acceptance integration${keep ? " --keep" : ""}`, + overall: !fatal && !auditFailed && deriveOverall(results) === "PASS" ? "PASS" : "FAIL", checks: results, + }; + let bytes; + try { + bytes = reportBytes(report); + const findings = await scanSecrets({ + runRoot: run.root, forbiddenValues: ctx.forbiddenValues, expectedGitRepositories: [], + virtualFiles: [{ path: "report.json", bytes: bytes.json }, { path: "report.md", bytes: bytes.markdown }], + }); + if (findings.length > 0) throw new Error("final filesystem or virtual secret scan failed"); + await verifyDeclaredArtifacts(run.root, results); + } catch { auditFailed = true; } + if (auditFailed) { + report = minimalFailClosedReport(run, keep); + bytes = reportBytes(report); + if (containsAny(bytes.json, ctx.forbiddenValues) || containsAny(bytes.markdown, ctx.forbiddenValues)) { + throw new Error("sanitized fail-closed report unexpectedly contains a forbidden value"); + } + } + await atomicWrite(join(run.root, "report.json"), bytes.json); + await atomicWrite(join(run.root, "report.md"), bytes.markdown); + const finalSuccess = report.overall === "PASS"; + if (announce) await announce({ report, runRoot: run.root, keep }); + const removed = await finalizeOwnedRun({ run, success: finalSuccess, keep }); + return { exitCode: finalSuccess ? 0 : 1, runRoot: run.root, retained: !removed, report }; + } finally { + commandEventSink = undefined; + activeCommandCheckId = undefined; + if (integrationOwner === acquisitionToken) integrationOwner = undefined; + else if (integrationOwner !== undefined) throw new Error("P1 acceptance integration ownership changed"); + } +} export async function main(argv = process.argv.slice(2), env = process.env) { if (argv.length < 1 || argv[0] !== "integration" || argv.length > 2 || (argv.length === 2 && argv[1] !== "--keep")) { console.error("usage: p1-acceptance integration [--keep]"); return 2; diff --git a/backend/scripts/p1-acceptance.test.mjs b/backend/scripts/p1-acceptance.test.mjs index 3e7e5d57..8b33b4df 100644 --- a/backend/scripts/p1-acceptance.test.mjs +++ b/backend/scripts/p1-acceptance.test.mjs @@ -8,6 +8,8 @@ import { dirname, join } from "node:path"; import { promisify } from "node:util"; import { fileURLToPath } from "node:url"; import { createServer, connect } from "node:net"; +import dgram from "node:dgram"; +import { Worker } from "node:worker_threads"; import test from "node:test"; import { @@ -16,6 +18,8 @@ import { buildSafeEnvironment, installExternalFetchGuard, installNetworkGuard, + installProductionSurfaceGuard, + resolveProductionExecutables, negativeRequestEvidence, cleanupOwnedRun, createOwnedRun, @@ -275,13 +279,17 @@ test("command helper accepts only executable plus separate argv", async () => { await assert.rejects(runCommand({ executable: "/bin/echo", argv: [], shell: true })); 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 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"); 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 result = await runCommand({ executable: "git", argv: ["--version"] }); + 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 result = await runCommand({ executable: gitPath, argv: ["--version"] }); assert.match(result.stdout, /^git version /); assert.equal(result.code, 0); }); @@ -293,8 +301,7 @@ test("safe environment rejects ambient THT and keeps only strict process allowli fixture: { THT_BIN: "/fixture/tht", THT_WORKSPACE_SECRET_ROOTS: "/fixture/secrets" }, }); assert.deepEqual(safe, { - PATH: "/safe/bin", HOME: "/home/test", LANG: "C", - THT_BIN: "/fixture/tht", THT_WORKSPACE_SECRET_ROOTS: "/fixture/secrets", + LANG: "C", THT_BIN: "/fixture/tht", THT_WORKSPACE_SECRET_ROOTS: "/fixture/secrets", }); }); @@ -434,3 +441,151 @@ test("secret scan fails closed when either expected Git repository is missing", await assert.rejects(scanSecrets({ runRoot: run.root, forbiddenValues: ["CANARY-value-123"] }), new RegExp(`missing expected Git repository: ${missing.replace(".", "\\.")}`)); } }); + + +test("runIntegration fails closed when a later duplicate overwrites stale artifact evidence", async () => { + const repositoryRoot = await fakeRepository(); + const checks = exactScenarios(async (id) => { + if (id === CHECK_IDS[0]) { + await mkdir(join(repositoryRoot, ".artifacts", "p1-integration", "scratch"), { recursive: true }); + } + return { commands: [], artifacts: [] }; + }); + const result = await runIntegration({ + repositoryRoot, keep: true, + setup: async (run, _repositoryRoot, _env, ctx) => { + const path = join(run.root, "logs", "overwritten.json"); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, "first"); + const stale = { path: "logs/overwritten.json", sha256: "a7937b64b8caa58f03721bb6bacf9e92a2c78987f5d1692a065a4698e006c4ca" }; + checks[0].run = async () => ({ commands: [], artifacts: [stale] }); + checks[1].run = async () => { + await writeFile(path, "second"); + return { commands: [], artifacts: [{ path: stale.path, sha256: "16367aacb67a4a017c8da8ab95682ccb389c61bb315f3425e2f2666f2476d1ce" }] }; + }; + return ctx; + }, + checks, + }); + assert.equal(result.exitCode, 1); + assert.equal(result.report.checks.length, 15); + assert(result.report.checks.every(({ status, artifacts }) => status === "FAIL" && artifacts.length === 0)); +}); + +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 guard = installProductionSurfaceGuard({ + ...executables, runRoot: repositoryRoot, environment: { ...process.env }, originalFetch: globalThis.fetch, + }); + try { + assert.throws(() => dgram.createSocket("udp4"), /prohibited production surface/); + assert.throws(() => new Worker("", { eval: true }), /prohibited production surface/); + await assert.rejects(runCommand({ executable: executables.gitPath, argv: ["ls-remote", "https://example.com/repo.git"] }), /Git command is prohibited/); + const childProcess = await import("node:child_process"); + assert.throws(() => childProcess.spawn(executables.pythonPath, ["-c", "print('unexpected')"]), /child command is prohibited/); + assert.deepEqual(new Set(guard.events.filter(({ outcome }) => outcome === "REJECTED").map(({ surface }) => surface)), + new Set(["dgram", "worker_threads", "child_process"])); + } finally { + guard.restore(); + } +}); + +test("listener close rejection retains listening truth and forces exact-15 FAIL", async () => { + const repositoryRoot = await fakeRepository(); + const server = createServer(); + await new Promise((resolvePromise, reject) => server.listen(0, "127.0.0.1", (error) => error ? reject(error) : resolvePromise())); + const address = server.address(); + assert(address && typeof address === "object"); + const result = await runIntegration({ + repositoryRoot, keep: false, checks: exactScenarios(), + setup: async (run, _repositoryRoot, _env, ctx) => { + ctx.services = [{ name: "primary", baseUrl: `http://127.0.0.1:${address.port}`, app: { close: async () => { throw new Error("close rejected"); } } }]; + const value = JSON.parse(await readFile(join(run.root, "ownership.json"), "utf8")); + value.listeners[0] = { name: "primary", kind: "fastify", host: "127.0.0.1", requestedPort: 0, actualPort: address.port, pid: process.pid, state: "listening" }; + await writeFile(join(run.root, "ownership.json"), `${JSON.stringify(value, null, 2)}\n`); + return ctx; + }, + }); + assert.equal(result.exitCode, 1); + assert.equal(result.retained, true); + const owner = JSON.parse(await readFile(join(result.runRoot, "ownership.json"), "utf8")); + assert.notEqual(owner.listeners[0].state, "closed"); + assert(result.report.checks.every(({ status }) => status === "FAIL")); + await new Promise((resolvePromise) => server.close(resolvePromise)); +}); + +test("ownership close write failure forces retained exact-15 FAIL", async () => { + const repositoryRoot = await fakeRepository(); + const server = createServer(); + await new Promise((resolvePromise, reject) => server.listen(0, "127.0.0.1", (error) => error ? reject(error) : resolvePromise())); + const address = server.address(); + assert(address && typeof address === "object"); + const result = await runIntegration({ + repositoryRoot, keep: false, checks: exactScenarios(), + ownershipWriter: async (_run, update) => { if (update?.state === "closed") throw new Error("owned write rejected"); }, + setup: async (_run, _repositoryRoot, _env, ctx) => { + ctx.services = [{ name: "primary", baseUrl: `http://127.0.0.1:${address.port}`, app: { close: async () => await new Promise((resolvePromise) => server.close(resolvePromise)) } }]; + return ctx; + }, + }); + assert.equal(result.exitCode, 1); + assert.equal(result.retained, true); + assert(result.report.checks.every(({ status }) => status === "FAIL")); +}); + +test("nested runIntegration is rejected before process-global mutation and outer restoration remains owned", async () => { + const repositoryRoot = await fakeRepository(); + const originalFetch = globalThis.fetch; + const originalPath = process.env.PATH; + let nestedError; + const result = await runIntegration({ + repositoryRoot, keep: true, checks: exactScenarios(), + setup: async (_run, _repositoryRoot, _env, ctx) => { + try { await runIntegration({ repositoryRoot, keep: true, checks: exactScenarios() }); } catch (error) { nestedError = error; } + assert.equal(globalThis.fetch, originalFetch); + assert.equal(process.env.PATH, originalPath); + return ctx; + }, + }); + assert.match(nestedError?.message ?? "", /already active/); + assert.equal(result.exitCode, 0); + assert.equal(globalThis.fetch, originalFetch); + assert.equal(process.env.PATH, originalPath); +}); + + +test("environment tampering fails the audit and restores the caller environment", async () => { + const repositoryRoot = await fakeRepository(); + const before = { ...process.env }; + const checks = exactScenarios(async (id) => { + if (id === CHECK_IDS[0]) process.env.P1_ACCEPTANCE_UNOWNED = "tampered"; + return { commands: [], artifacts: [] }; + }); + const result = await runIntegration({ + repositoryRoot, keep: true, checks, + setup: async (_run, _repositoryRoot, _env, ctx) => { ctx.env = { P1_ACCEPTANCE_OWNED: "yes" }; return ctx; }, + }); + assert.equal(result.exitCode, 1); + assert(result.report.checks.every(({ status }) => status === "FAIL")); + assert.deepEqual({ ...process.env }, before); +}); + +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 originalFetch = globalThis.fetch; + const guard = installProductionSurfaceGuard({ ...executables, runRoot: repositoryRoot, 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); +}); diff --git a/scripts/p1-acceptance.sh b/scripts/p1-acceptance.sh index b475999b..49ae25cc 100755 --- a/scripts/p1-acceptance.sh +++ b/scripts/p1-acceptance.sh @@ -5,17 +5,19 @@ if [[ $# -lt 1 || "$1" != "integration" || $# -gt 2 || ( $# -eq 2 && "$2" != "-- printf 'usage: %s integration [--keep]\n' "$0" >&2 exit 2 fi -for command in node npm git; do command -v "$command" >/dev/null || { printf 'missing command: %s\n' "$command" >&2; exit 127; }; done +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=$PATH" "HOME=${HOME:-/nonexistent}" "LANG=${LANG:-C}" "THT_BIN=$THT_BIN") -for name in LC_ALL TMPDIR TZ; do +safe_env=(env -i "PATH=/usr/bin:/bin" "HOME=/nonexistent" "TMPDIR=/tmp" "LANG=${LANG:-C}" "THT_BIN=$THT_BIN") +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 "$repo_root/backend/scripts/p1-acceptance.mjs" "$@" +"${safe_env[@]}" "$node_bin" "$repo_root/backend/scripts/p1-acceptance.mjs" "$@" status=$? set -e exit "$status"