diff --git a/backend/scripts/p1-acceptance.mjs b/backend/scripts/p1-acceptance.mjs index 9130c3a9..192eeeee 100755 --- a/backend/scripts/p1-acceptance.mjs +++ b/backend/scripts/p1-acceptance.mjs @@ -1,7 +1,8 @@ #!/usr/bin/env node import { createHash, randomBytes } from "node:crypto"; import { - accessSync, closeSync, constants as fsConstants, existsSync, fsyncSync, lstatSync, openSync, realpathSync, statSync, + accessSync, closeSync, constants as fsConstants, existsSync, fsyncSync, lstatSync, mkdirSync, mkdtempSync, + openSync, readFileSync, readdirSync, realpathSync, statSync, } from "node:fs"; import { access, chmod, lstat, mkdir, open, readFile, readdir, realpath, rename, rm, stat, symlink, writeFile, @@ -228,14 +229,135 @@ function resolveTrustedSystemExecutableSync(name) { throw new Error(`cannot resolve trusted system executable: ${name}`); } +const THT_EDITABLE_FINDER_NORMALIZED_SHA256 = "3489b09b63511e27e1ae4d3f858d2bc576fe77beb5ea97607673781a32d2723e"; + +function assertRegularNonSymlink(path, label) { + let entry; + try { entry = lstatSync(path); } catch { throw new Error(`${label} is unavailable`); } + if (!entry.isFile() || entry.isSymbolicLink() || realpathSync(path) !== path) throw new Error(`${label} identity is invalid`); + return entry; +} + +function assertSafeSitePackages(identity) { + const entries = readdirSync(identity.sitePackages, { withFileTypes: true }); + const pthNames = entries.filter(({ name }) => name.endsWith(".pth")).map(({ name }) => name); + const finderNames = entries.filter(({ name }) => /^__editable___tht_.*_finder\.py$/.test(name)).map(({ name }) => name); + if (pthNames.length !== 1 || pthNames[0] !== identity.pthName || finderNames.length !== 1 + || finderNames[0] !== identity.finderName || entries.some((entry) => entry.isSymbolicLink())) { + throw new Error("trusted THT editable binding is ambiguous"); + } + const startup = /^(?:sitecustomize|usercustomize|tht)(?:\..*)?$/; + if (entries.some(({ name }) => startup.test(name))) throw new Error("trusted THT editable binding has an import startup override"); + const cache = join(identity.sitePackages, "__pycache__"); + if (existsSync(cache) && readdirSync(cache).some((name) => startup.test(name) + || /^__editable___tht_.*_finder\..*\.pyc$/.test(name))) { + throw new Error("trusted THT editable binding has an import startup override"); + } + assertRegularNonSymlink(identity.pthPath, "trusted THT editable pth"); + assertRegularNonSymlink(identity.finderPath, "trusted THT editable finder"); + const pth = readFileSync(identity.pthPath, "utf8"); + const rawFinder = readFileSync(identity.finderPath, "utf8"); + const finder = rawFinder.replaceAll("\r\n", "\n"); + const occurrences = finder.split(identity.sourceRoot).length - 1; + const normalized = finder.replaceAll(identity.sourceRoot, ""); + if (pth !== identity.expectedPth || occurrences !== 5 || sha256(normalized) !== THT_EDITABLE_FINDER_NORMALIZED_SHA256) { + throw new Error("trusted THT editable binding is invalid"); + } + return { pth, finder: rawFinder }; +} + +function sourceTreeFilesSync(root, current = root, files = []) { + for (const entry of readdirSync(current, { withFileTypes: true })) { + const path = join(current, entry.name); + if (entry.isSymbolicLink()) throw new Error("trusted THT source contains a symlink"); + if (entry.isDirectory()) sourceTreeFilesSync(root, path, files); + else if (entry.isFile()) files.push(relative(root, path).split(sep).join("/")); + else throw new Error("trusted THT source contains a special file"); + } + return files; +} + +function assertBoundThtFiles(identity) { + assertRegularNonSymlink(identity.entrypointPath, "trusted THT entrypoint"); + assertRegularNonSymlink(identity.pythonCanonicalPath, "trusted THT interpreter"); + if (realpathSync(identity.pythonPath) !== identity.pythonCanonicalPath) throw new Error("trusted THT identity changed: interpreter"); + if (sha256(readFileSync(identity.entrypointPath)) !== identity.entrypointSha256 + || sha256(readFileSync(identity.pythonCanonicalPath)) !== identity.pythonSha256) { + throw new Error("trusted THT identity changed: entrypoint or interpreter"); + } + for (const root of [dirname(identity.sourceRoot), dirname(identity.entrypointPath)]) { + for (const name of ["sitecustomize.py", "sitecustomize.pyc", "usercustomize.py", "usercustomize.pyc", "tht.py", "tht.pyc"]) { + if (existsSync(join(root, name))) throw new Error("trusted THT identity changed: import startup override"); + } + } + const { pth, finder } = assertSafeSitePackages(identity); + if (sha256(pth) !== identity.pthSha256 || sha256(finder) !== identity.editableFinderSha256) { + throw new Error("trusted THT identity changed: editable binding"); + } + const actualPaths = sourceTreeFilesSync(identity.sourceRoot).map((path) => `harness/tht/${path}`); + actualPaths.push("harness/pyproject.toml"); + actualPaths.sort(); + const expectedPaths = identity.sourceManifest.map(({ path }) => path).sort(); + if (JSON.stringify(actualPaths) !== JSON.stringify(expectedPaths)) throw new Error("trusted THT identity changed: source manifest"); + for (const file of identity.sourceManifest) { + const path = join(identity.repositoryRoot, ...file.path.split("/")); + assertRegularNonSymlink(path, "trusted THT source file"); + if (sha256(readFileSync(path)) !== file.sha256) throw new Error("trusted THT identity changed: source bytes"); + } +} + +export function revalidateThtIdentity(identity) { + try { assertBoundThtFiles(identity); } catch (error) { + if (/^trusted THT identity changed/.test(error.message)) throw error; + throw new Error(`trusted THT identity changed: ${error.message}`); + } + return true; +} + +async function gitTrackedSourceManifest(repo, gitPath) { + const listing = await runCommand({ + executable: gitPath, + argv: ["-C", repo, "ls-files", "-s", "-z", "--", "harness/tht", "harness/pyproject.toml"], + env: baseSafeGitEnvironment(gitPath), + }); + const treeListing = await runCommand({ + executable: gitPath, + argv: ["-C", repo, "ls-tree", "-r", "-z", "HEAD", "--", "harness/tht", "harness/pyproject.toml"], + env: baseSafeGitEnvironment(gitPath), + }); + const treeEntries = new Map(treeListing.stdout.split("\0").filter(Boolean).map((record) => { + const match = /^(100644|100755) blob ([0-9a-f]{40,64})\t(harness\/(?:tht\/[^\0]+|pyproject\.toml))$/.exec(record); + if (!match) throw new Error("trusted THT Git tree identity is invalid"); + return [match[3], { mode: match[1], oid: match[2] }]; + })); + const manifest = []; + for (const record of listing.stdout.split("\0").filter(Boolean)) { + const match = /^(100644|100755) ([0-9a-f]{40,64}) 0\t(harness\/(?:tht\/[^\0]+|pyproject\.toml))$/.exec(record); + if (!match) throw new Error("trusted THT Git index identity is invalid"); + const [, mode, oid, path] = match; + const tree = treeEntries.get(path); + if (!tree || tree.mode !== mode || tree.oid !== oid) throw new Error(`trusted THT Git index differs from HEAD tree: ${path}`); + treeEntries.delete(path); + const blob = await runCommand({ executable: gitPath, argv: ["-C", repo, "cat-file", "blob", oid], env: baseSafeGitEnvironment(gitPath) }); + const bytes = Buffer.from(blob.stdout); + const worktreePath = join(repo, ...path.split("/")); + assertRegularNonSymlink(worktreePath, "trusted THT source file"); + if (!bytes.equals(readFileSync(worktreePath))) throw new Error(`trusted THT source bytes differ from Git: ${path}`); + manifest.push({ path, mode, gitBlob: oid, sha256: sha256(bytes) }); + } + if (treeEntries.size !== 0) throw new Error("trusted THT Git index differs from HEAD tree"); + manifest.sort((left, right) => left.path.localeCompare(right.path)); + if (!manifest.some(({ path }) => path === "harness/pyproject.toml") + || !manifest.some(({ path }) => path === "harness/tht/cli/__init__.py")) throw new Error("trusted THT tracked source manifest is incomplete"); + return manifest; +} + export async function resolveProductionExecutables({ repositoryRoot } = {}) { const repo = canonicalRoot(repositoryRoot); const gitPath = resolveTrustedSystemExecutableSync("git"); const pythonPath = resolveTrustedSystemExecutableSync("python3"); 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"); + assertRegularNonSymlink(thtPath, "trusted THT entrypoint"); accessSync(thtPath, fsConstants.X_OK); const entrypointBytes = await readFile(thtPath, "utf8"); const lines = entrypointBytes.replaceAll("\r\n", "\n").split("\n"); @@ -253,41 +375,163 @@ export async function resolveProductionExecutables({ repositoryRoot } = {}) { 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 pthNames = siteEntries.filter((name) => name.endsWith(".pth")); + const finderNames = siteEntries.filter((name) => /^__editable___tht_.*_finder\.py$/.test(name)); + if (pthNames.length !== 1 || finderNames.length !== 1) throw new Error("trusted THT editable binding is ambiguous"); + const finderModule = finderNames[0].slice(0, -3); + const identity = { + repositoryRoot: repo, entrypoint: "generated-console-script", entrypointPath: thtPath, + entrypointSha256: sha256(entrypointBytes), pythonPath: pythonLexical, + pythonCanonicalPath: realpathSync(pythonLexical), pythonSha256: sha256(await readFile(realpathSync(pythonLexical))), + sourceRoot, sourceStatus: "git-index-byte-identical", sitePackages, + pthName: pthNames[0], pthPath: join(sitePackages, pthNames[0]), expectedPth: `import ${finderModule}; ${finderModule}.install()`, + finderName: finderNames[0], finderPath: join(sitePackages, finderNames[0]), }; + const binding = assertSafeSitePackages(identity); + identity.pthSha256 = sha256(binding.pth); + identity.editableFinderSha256 = sha256(binding.finder); + identity.sourceManifest = await gitTrackedSourceManifest(repo, gitPath); + assertBoundThtFiles(identity); + return { gitPath, pythonPath, thtPath, thtIdentity: identity }; +} + +let fallbackGitHooksPath; +function ownedFallbackGitHooksPath() { + if (!fallbackGitHooksPath) fallbackGitHooksPath = mkdtempSync(join(tmpdir(), `p1-git-hooks-${process.pid}-`)); + return fallbackGitHooksPath; +} +function gitSafeConfig(hooksPath) { + return [ + ["core.hooksPath", hooksPath], ["core.attributesFile", "/dev/null"], ["core.fsmonitor", "false"], + ["core.pager", "/bin/cat"], ["pager.status", "false"], ["diff.external", ""], + ["interactive.diffFilter", ""], ["commit.gpgSign", "false"], ["tag.gpgSign", "false"], + ["user.signingKey", ""], ["gpg.program", "/bin/false"], ["credential.helper", ""], + ["core.askPass", "/bin/false"], ["sequence.editor", "/bin/false"], ["core.editor", "/bin/false"], + ["protocol.allow", "never"], ["protocol.file.allow", "always"], ["protocol.ext.allow", "never"], + ]; +} +function hardenedGitArgv(argv, hooksPath) { + return [...gitSafeConfig(hooksPath).flatMap(([key, value]) => ["-c", `${key}=${value}`]), ...argv]; +} +function baseSafeGitEnvironment(gitPath) { + return { + PATH: dirname(gitPath), HOME: "/nonexistent", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_SYSTEM: "/dev/null", + GIT_CONFIG_GLOBAL: "/dev/null", GIT_ATTR_NOSYSTEM: "1", GIT_TERMINAL_PROMPT: "0", + GIT_ASKPASS: "/bin/false", SSH_ASKPASS: "/bin/false", GIT_ALLOW_PROTOCOL: "file", + GIT_PROTOCOL_FROM_USER: "0", GIT_PAGER: "/bin/cat", + }; +} +function assertEmptyHooksDirectory(path) { + let entry; + try { entry = lstatSync(path); } catch { throw new Error("unsafe Git repository state: hooks directory is unavailable"); } + if (!entry.isDirectory() || entry.isSymbolicLink() || realpathSync(path) !== path || readdirSync(path).length !== 0) { + throw new Error("unsafe Git repository state: hooks directory is not owned and empty"); + } +} +function parseLocalGitConfig(bytes) { + let section; + const entries = []; + for (const raw of bytes.replaceAll("\r\n", "\n").split("\n")) { + const line = raw.trim(); + if (!line || line.startsWith("#") || line.startsWith(";")) continue; + const sectionMatch = /^\[([A-Za-z0-9.-]+)(?:\s+"([^"\\]*)")?\]$/.exec(line); + if (sectionMatch) { section = sectionMatch[2] ? `${sectionMatch[1].toLowerCase()}.${sectionMatch[2]}` : sectionMatch[1].toLowerCase(); continue; } + const valueMatch = /^([A-Za-z0-9.-]+)\s*=\s*(.*)$/.exec(line); + if (!section || !valueMatch || /[\\\0]/.test(valueMatch[2])) throw new Error("unsafe Git repository state: local config is malformed"); + entries.push([`${section}.${valueMatch[1].toLowerCase()}`, valueMatch[2]]); + } + return entries; +} +function safeLocalGitConfigEntry(key, value, runRoot) { + if (key === "core.repositoryformatversion") return value === "0"; + if (["core.filemode", "core.bare", "core.logallrefupdates", "core.ignorecase", "core.precomposeunicode"].includes(key)) return /^(?:true|false)$/.test(value); + if (key === "remote.origin.url") return ownedGitPath(value, runRoot); + if (key === "remote.origin.fetch") return /^\+refs\/heads\/(?:\*|main|invalid-context):refs\/remotes\/origin\/(?:\*|main|invalid-context)$/.test(value); + if (/^branch\.(?:main|invalid-context)\.remote$/.test(key)) return value === "origin"; + if (/^branch\.(?:main|invalid-context)\.merge$/.test(key)) return /^refs\/heads\/(?:main|invalid-context)$/.test(value); + if (key === "user.name") return FIXTURE_GIT_CONFIG.get("user.name")?.has(value) === true; + if (key === "user.email") return FIXTURE_GIT_CONFIG.get("user.email")?.has(value) === true; + return false; +} +function repositoryGitDirectory(argv, cwd, runRoot) { + let candidate; + if (argv[0] === "--git-dir") candidate = argv[1]; + else if (argv[0] === "-C") candidate = join(argv[1], ".git"); + else if (cwd) candidate = join(cwd, ".git"); + if (!candidate || !ownedGitPath(resolve(candidate), runRoot) || !existsSync(candidate)) return undefined; + const entry = lstatSync(candidate); + if (entry.isFile() && !entry.isSymbolicLink()) { + const match = /^gitdir: (.+)\n?$/.exec(readFileSync(candidate, "utf8")); + if (!match) throw new Error("unsafe Git repository state: gitdir file is malformed"); + candidate = resolve(dirname(candidate), match[1]); + } else if (!entry.isDirectory() || entry.isSymbolicLink()) throw new Error("unsafe Git repository state: git directory is unsafe"); + return realpathSync(candidate); +} +function findAttributes(current, gitDirectory, findings = []) { + for (const entry of readdirSync(current, { withFileTypes: true })) { + const path = join(current, entry.name); + if (path === gitDirectory || (entry.name === ".git" && (entry.isDirectory() || entry.isFile()))) continue; + if (entry.isSymbolicLink()) throw new Error("unsafe Git repository state: worktree contains a symlink"); + if (entry.isDirectory()) findAttributes(path, gitDirectory, findings); + else if (entry.name === ".gitattributes") findings.push(path); + } + return findings; +} +function validateGitDirectoryState(gitDirectory, runRoot) { + const configPath = join(gitDirectory, "config"); + const configEntry = lstatSync(configPath); + if (!configEntry.isFile() || configEntry.isSymbolicLink()) throw new Error("unsafe Git repository state: local config is unsafe"); + const entries = parseLocalGitConfig(readFileSync(configPath, "utf8")); + if (entries.some(([key, value]) => !safeLocalGitConfigEntry(key, value, runRoot))) { + throw new Error("unsafe Git repository state: local config is not exact"); + } + const infoAttributes = join(gitDirectory, "info", "attributes"); + if (existsSync(infoAttributes)) { + const entry = lstatSync(infoAttributes); + if (!entry.isFile() || entry.isSymbolicLink() || readFileSync(infoAttributes).length !== 0) { + throw new Error("unsafe Git repository state: info attributes are not empty"); + } + } + const hooks = join(gitDirectory, "hooks"); + if (existsSync(hooks)) for (const entry of readdirSync(hooks, { withFileTypes: true })) { + if (entry.isSymbolicLink() || !entry.isFile() || !entry.name.endsWith(".sample")) { + throw new Error("unsafe Git repository state: repository hook is present"); + } + } + const worktree = dirname(gitDirectory); + if (basename(gitDirectory) === ".git" && findAttributes(worktree, gitDirectory).length > 0) { + throw new Error("unsafe Git repository state: worktree attributes are present"); + } + return entries; +} +function exactRemoteTarget(argv, entries) { + const offset = argv[0] === "-c" || argv[0] === "-C" || argv[0] === "--git-dir" ? 2 : 0; + const verb = argv[offset]; const args = argv.slice(offset + 1); + if (verb === "clone") { + const operands = args.filter((value) => value !== "--bare" && value !== "--single-branch" && value !== "--" + && value !== "--branch" && value !== "main" && value !== "invalid-context"); + return operands.at(-2); + } + if (["fetch", "push"].includes(verb) && args.includes("origin")) { + return entries.find(([key]) => key === "remote.origin.url")?.[1]; + } + return undefined; +} +function assertSafeGitRepositoryState(argv, cwd, runRoot, fixedHooksPath) { + assertEmptyHooksDirectory(fixedHooksPath); + if (argv[0] === "-c") assertEmptyHooksDirectory(argv[1].slice("core.hooksPath=".length)); + const gitDirectory = repositoryGitDirectory(argv, cwd, runRoot); + const entries = gitDirectory ? validateGitDirectoryState(gitDirectory, runRoot) : []; + const target = exactRemoteTarget(argv, entries); + if (target !== undefined) { + if (!ownedGitPath(target, runRoot) || !existsSync(join(target, "config"))) { + throw new Error("unsafe Git repository state: remote target is not exact and owned"); + } + validateGitDirectoryState(realpathSync(target), runRoot); + } } const FIXTURE_GIT_CONFIG = new Map([ @@ -350,17 +594,20 @@ export function validateGitInvocation(argv, { runRoot } = {}) { 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; + || (prefix === "-C" && (exactArray(args, ["--porcelain=v1", "--untracked-files=all"]) + || exactArray(args, ["--porcelain=v1", "--untracked-files=all", "--", "harness/tht", "harness/pyproject.toml"]))); break; + case "ls-files": valid = prefix === "-C" && exactArray(args, ["-s", "-z", "--", "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 "ls-tree": valid = (prefix === "hooks" && exactArray(args, ["-r", "--name-only", "HEAD", "--", "workspaces"])) + || (prefix === "-C" && exactArray(args, ["-r", "-z", "HEAD", "--", "harness/tht", "harness/pyproject.toml"])); 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; + case "rev-parse": valid = (!prefix || prefix === "--git-dir" || prefix === "hooks" || prefix === "-C") && args.length === 1 && object(args[0]); break; default: valid = false; } if (!valid) throw new Error("Git command is prohibited"); @@ -421,7 +668,10 @@ export function installProductionSurfaceGuard({ 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 (!thtIdentity || thtIdentity.sourceStatus !== "git-index-byte-identical") throw new Error("trusted THT identity is absent"); + const gitHooksPath = join(runRoot, "installation", "runtime", "acceptance-git-hooks"); + mkdirSync(gitHooksPath, { recursive: true, mode: 0o700 }); + assertEmptyHooksDirectory(gitHooksPath); if (failPatchAt !== undefined && (!Number.isInteger(failPatchAt) || failPatchAt < 1 || failPatchAt > 12)) throw new Error("invalid production patch failure probe"); const token = Symbol("p1-production-surface"); const events = []; @@ -447,18 +697,21 @@ export function installProductionSurfaceGuard({ 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" }; + assertSafeGitRepositoryState(argv, options.cwd, runRoot, gitHooksPath); + const logical = argv[0] === "-c" ? argv.slice(2) : argv; + return { executable: gitPath, kind: "git", argv: hardenedGitArgv(logical, gitHooksPath) }; } if (canonical === thtPath) { validateThtInvocation(argv, { thtPath, runRoot, cwd: options.cwd }); - return { executable: thtPath, kind: "tht" }; + revalidateThtIdentity(thtIdentity); + return { executable: thtPath, kind: "tht", argv }; } if (canonical === pythonPath) { 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"); } - return { executable: pythonPath, kind: "python-lock-holder" }; + return { executable: pythonPath, kind: "python-lock-holder", argv }; } throw new Error("child command is prohibited"); }; @@ -479,7 +732,7 @@ export function installProductionSurfaceGuard({ 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) => { + return originals.execFile(resolved.executable, resolved.argv, bounded, (error, stdout, stderr) => { safeChildEvent(events, { api: "execFile", executable: resolved.executable, argv, outcome: error ? "FAIL" : "PASS", detail: resolved.kind }); callback?.(error, stdout, stderr); }); @@ -499,7 +752,7 @@ export function installProductionSurfaceGuard({ 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); + const child = originals.spawn(resolved.executable, resolved.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); @@ -567,6 +820,7 @@ export function installProductionSurfaceGuard({ } return { events, externalAttempts: network.externalAttempts, + gitPolicy: { hooksPath: gitHooksPath, fixedConfig: gitSafeConfig(gitHooksPath) }, addOwnedOrigin: network.addOwnedOrigin, hasOwnedOrigin: network.hasOwnedOrigin, restore() { if (restored) throw new Error("production surface guard restored twice"); @@ -601,7 +855,10 @@ export async function runCommand(options) { 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 guardedByProduction = productionSurfaceOwner !== undefined; + const childArgv = canonical === allowedGit && !guardedByProduction ? hardenedGitArgv(argv, ownedFallbackGitHooksPath()) : argv; + const childEnv = canonical === allowedGit && !guardedByProduction ? { ...(env ?? {}), ...baseSafeGitEnvironment(canonical) } : env; + const child = mutableChildProcess.execFile(canonical, childArgv, { cwd, env: childEnv, 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({ @@ -671,7 +928,7 @@ async function walkFiles(root, current = root, out = []) { for (const entry of await readdir(current, { withFileTypes: true })) { const path = join(current, entry.name); const rel = relative(root, path).split(sep).join("/"); - if (entry.isSymbolicLink()) continue; + if (entry.isSymbolicLink()) throw new Error(`secret scan failed closed: symlink outside fixture-secrets: ${rel}`); if (entry.isDirectory()) { if (rel === "fixture-secrets") continue; await walkFiles(root, path, out); @@ -1060,7 +1317,50 @@ export function buildSafeEnvironment({ ambient = process.env, fixture = {} } = { return safe; } +async function manifestFiles(root, paths) { + const files = []; + const visit = async (absolute, rel) => { + const entry = await lstat(absolute); + if (entry.isSymbolicLink()) throw new Error(`provenance path is a symlink: ${rel}`); + if (entry.isDirectory()) { + for (const child of (await readdir(absolute, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) { + await visit(join(absolute, child.name), rel ? `${rel}/${child.name}` : child.name); + } + } else if (entry.isFile()) { + const bytes = await readFile(absolute); + files.push({ path: rel, bytes: bytes.length, sha256: sha256(bytes) }); + } else throw new Error(`provenance path is not a regular file: ${rel}`); + }; + for (const path of paths) await visit(join(root, path), path); + files.sort((a, b) => a.path.localeCompare(b.path)); + return { files, manifestSha256: sha256(JSON.stringify(files)) }; +} + +export async function collectRepositoryProvenance({ repositoryRoot, gitPath = resolveTrustedSystemExecutableSync("git") }) { + const repo = canonicalRoot(repositoryRoot); + const gitEnv = baseSafeGitEnvironment(gitPath); + const readIdentity = async () => { + const [head, tree, status] = await Promise.all([ + runCommand({ executable: gitPath, argv: ["-C", repo, "rev-parse", "HEAD"], env: gitEnv }), + runCommand({ executable: gitPath, argv: ["-C", repo, "rev-parse", "HEAD^{tree}"], env: gitEnv }), + runCommand({ executable: gitPath, argv: ["-C", repo, "status", "--porcelain=v1", "--untracked-files=all"], env: gitEnv }), + ]); + return { head: head.stdout.trim(), tree: tree.stdout.trim(), status: status.stdout }; + }; + const before = await readIdentity(); + if (!HEX40.test(before.head) || !HEX40.test(before.tree) || before.status !== "") throw new Error("repository is not clean at exact HEAD"); + const backendRoot = join(repo, "backend"); + const backendSource = await manifestFiles(backendRoot, [ + "src", "scripts/p1-acceptance.mjs", "package.json", "package-lock.json", "tsconfig.json", + ]); + const backendDist = await manifestFiles(backendRoot, ["dist"]); + const after = await readIdentity(); + if (JSON.stringify(after) !== JSON.stringify(before)) throw new Error("repository provenance changed during binding"); + return { schemaVersion: 1, head: before.head, tree: before.tree, clean: true, backendSource, backendDist }; +} + async function setupContext(run, repositoryRoot, env, ctx = {}) { + const provenance = await collectRepositoryProvenance({ repositoryRoot, gitPath: resolveTrustedSystemExecutableSync("git") }); const executables = await resolveProductionExecutables({ repositoryRoot }); const harnessDir = realpathSync(join(repositoryRoot, "harness")); const gitTracePath = join(run.root, "logs", "production-git-trace.jsonl"); @@ -1071,8 +1371,8 @@ async function setupContext(run, repositoryRoot, env, ctx = {}) { const executablePath = [...new Set([dirname(executables.gitPath), dirname(executables.pythonPath), dirname(executables.thtPath)])].join(":"); const fixtureEnv = { 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_NOSYSTEM: "1", GIT_CONFIG_SYSTEM: "/dev/null", GIT_CONFIG_GLOBAL: "/dev/null", GIT_ATTR_NOSYSTEM: "1", + GIT_TERMINAL_PROMPT: "0", GIT_ASKPASS: "/bin/false", SSH_ASKPASS: "/bin/false", GIT_ALLOW_PROTOCOL: "file", GIT_PROTOCOL_FROM_USER: "0", 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: "", @@ -1085,13 +1385,13 @@ async function setupContext(run, repositoryRoot, env, ctx = {}) { THT_WORKSPACE_GIT_REMOTE: join(run.root, "remote.git"), THT_WORKSPACE_GIT_BRANCH: "main", THT_WORKSPACE_GIT_AUTHOR_NAME: "P1 API Publisher", THT_WORKSPACE_GIT_AUTHOR_EMAIL: "p1-api@example.invalid", THT_WORKSPACE_INSTALLATION_ID: "p1-acceptance", THT_WORKSPACE_SECRET_ROOTS: join(run.root, "fixture-secrets"), - THT_HOME: join(run.root, "installation", "runtime", "tht-home"), + THT_HOME: join(run.root, "installation", "runtime", "tht-home"), PYTHONDONTWRITEBYTECODE: "1", PYTHONNOUSERSITE: "1", GIT_TRACE2_EVENT: gitTracePath, }; Object.assign(ctx, { run, repositoryRoot, descriptors: descriptors(), forbiddenValues: ctx.forbiddenValues ?? [], env: buildSafeEnvironment({ ambient: env, fixture: fixtureEnv }), - httpRequests: [], services: [], gitTracePath, executables, + httpRequests: [], services: [], gitTracePath, executables, provenance, expectedGitRepositories: ["remote.git", "author"], }); await createTopology(run); @@ -1293,7 +1593,10 @@ function productionChecks(ctx) { const scenarios = [ { id: "preflight", run: async () => { const gitVersion = await git(["--version"]); await access(ctx.env.THT_BIN, fsConstants.X_OK); - return await log("preflight", { git: gitVersion.stdout.trim(), node: process.version, thtExecutable: true }); + const preflight = await log("preflight", { git: gitVersion.stdout.trim(), node: process.version, thtExecutable: true, + repositoryHead: ctx.provenance.head, repositoryTree: ctx.provenance.tree, repositoryClean: ctx.provenance.clean }); + preflight.artifacts.push(await evidence(ctx.run, "logs/provenance.json", ctx.provenance)); + return preflight; } }, { id: "clean_state", run: async () => { assert(RUN_ID.test(ctx.run.runId), "run identity invalid"); @@ -1697,7 +2000,7 @@ export async function runIntegration({ try { run = await createOwnedRun({ repositoryRoot }); ctx = { - run, repositoryRoot, forbiddenValues: [], expectedGitRepositories: [], services: [], + run, repositoryRoot, forbiddenValues: [], expectedGitRepositories: [], services: [], listenerClosureEvidence: [], originalFetch: globalThis.fetch, }; commandEventSink = []; @@ -1733,6 +2036,7 @@ export async function runIntegration({ if (!closed) closeError = new Error("listener still accepts connections after close"); } catch (error) { closeError = error; } const state = closed ? "closed" : "close_failed"; + ctx.listenerClosureEvidence.push({ name: service.name, host: "127.0.0.1", actualPort, state, listenerRefusedConnection: closed }); try { await ownershipWriter(run, { name: service.name, kind: "fastify", host: "127.0.0.1", requestedPort: 0, @@ -1755,6 +2059,18 @@ export async function runIntegration({ activeCommandCheckId = undefined; try { assertUniqueResultArtifacts(results); + const finalOwnershipBytes = await readFile(join(run.root, "ownership.json")); + const finalOwnership = await readAndValidateOwnership({ repositoryRoot, runRoot: run.root, expectedNonce: run.nonce }); + if (ctx.services.length > 0) { + assert(ctx.listenerClosureEvidence.length === ctx.services.length + && ctx.listenerClosureEvidence.every(({ state, listenerRefusedConnection }) => state === "closed" && listenerRefusedConnection), + "final listener closure evidence is incomplete"); + } + const finalOwnershipArtifact = await evidence(run, "logs/final-ownership.json", { + ownershipSha256: sha256(finalOwnershipBytes), listeners: finalOwnership.listeners, + listenerRefusalChecks: ctx.listenerClosureEvidence, + }, ctx.forbiddenValues); + attachResultArtifact(results, "ownership", finalOwnershipArtifact); const commandArtifact = await evidence(run, "logs/command-events.json", { eventCount: commandEvents.length, events: commandEvents }, ctx.forbiddenValues); attachResultArtifact(results, "preflight", commandArtifact); if (ctx.networkGuard) { @@ -1767,7 +2083,9 @@ export async function runIntegration({ 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, + gitGlobalConfigDisabled: true, gitSystemConfigDisabled: true, gitSystemAttributesDisabled: true, + gitPromptsHelpersSigningDisabled: true, gitLocalConfigAttributesHooksValidatedBeforeInvocation: true, + gitFixedConfigPrefix: ctx.networkGuard.gitPolicy.fixedConfig, gitOwnedEmptyHooksPath: ctx.networkGuard.gitPolicy.hooksPath, gitAllowedProtocol: "file", maxOutputBytes: MAX_OUTPUT, maxTimeoutMs: 300_000, }, eventCount: ctx.networkGuard.events.length, events: ctx.networkGuard.events, diff --git a/backend/scripts/p1-acceptance.test.mjs b/backend/scripts/p1-acceptance.test.mjs index dd52dc45..36050403 100644 --- a/backend/scripts/p1-acceptance.test.mjs +++ b/backend/scripts/p1-acceptance.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { execFile } from "node:child_process"; import { - chmod, lstat, mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile, + chmod, cp, lstat, mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; @@ -16,6 +16,7 @@ import { canonicalIntegrationBase, CHECK_IDS, buildSafeEnvironment, + collectRepositoryProvenance, installExternalFetchGuard, installNetworkGuard, installProductionSurfaceGuard, @@ -623,7 +624,7 @@ test("production executables ignore ambient THT and bind the generated tht entry 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.sourceStatus, "git-index-byte-identical"); assert.equal(executables.thtIdentity.entrypoint, "generated-console-script"); assert.match(executables.thtIdentity.pythonPath, /python3(?:\.\d+)?$/); }); @@ -690,10 +691,11 @@ test("public wrapper has no ambient command resolution and isolates the build an assert.match(wrapper, /env -i/); assert.match(wrapper, /npm-cli\.js/); assert.match(wrapper, /"\$node_path" "\$npm_path"/); + assert.match(wrapper, /\/bin\/rm -rf -- "\$repo_root\/backend\/dist"/); }); -test("hostile PATH Node npm and THT substitutes never execute before a real wrapper integration", async () => { +test("hostile PATH Node npm and THT substitutes never execute at the public wrapper boundary", async () => { const hostileRoot = await fakeRepository(); const marker = join(hostileRoot, "ambient-tool-ran"); for (const name of ["node", "npm", "tht"]) { @@ -702,9 +704,189 @@ test("hostile PATH Node npm and THT substitutes never execute before a real wrap 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(execFileAsync(wrapper, ["invalid"], { + env: { ...process.env, PATH: hostileRoot, THT_BIN: join(hostileRoot, "tht") }, timeout: 30_000, + })); await assert.rejects(lstat(marker)); }); + + +async function fakeTrustedThtRepository() { + const repositoryRoot = await fakeRepository(); + const realRepository = await realpath(join(dirname(fileURLToPath(import.meta.url)), "..", "..")); + const harness = join(repositoryRoot, "harness"); + const sourceRoot = join(harness, "tht"); + await mkdir(harness, { recursive: true }); + await cp(join(realRepository, "harness", "tht"), sourceRoot, { + recursive: true, filter: (path) => !path.split("/").includes("__pycache__") && !path.endsWith(".pyc"), + }); + await cp(join(realRepository, "harness", "pyproject.toml"), join(harness, "pyproject.toml")); + const realExecutables = await resolveProductionExecutables({ repositoryRoot: realRepository }); + const pythonName = realExecutables.thtIdentity.pythonPath.split("/").at(-1); + const venvBin = join(harness, ".venv", "bin"); + const sitePackages = join(harness, ".venv", "lib", pythonName, "site-packages"); + await mkdir(venvBin, { recursive: true }); + await mkdir(sitePackages, { recursive: true }); + await symlink(realExecutables.thtIdentity.pythonCanonicalPath, join(venvBin, "python")); + await symlink(realExecutables.thtIdentity.pythonCanonicalPath, join(venvBin, pythonName)); + const entrypoint = `#!${join(venvBin, pythonName)}\nimport sys\nfrom tht.cli import app\nif __name__ == '__main__':\n if sys.argv[0].endswith('.exe'):\n sys.argv[0] = sys.argv[0][:-4]\n sys.exit(app())\n`; + await writeFile(join(venvBin, "tht"), entrypoint, { mode: 0o700 }); + const realSite = join(realRepository, "harness", ".venv", "lib", pythonName, "site-packages"); + const realFinderName = (await import("node:fs/promises")).readdir(realSite).then((entries) => entries.find((name) => /^__editable___tht_.*_finder\.py$/.test(name))); + const finderName = await realFinderName; + const realFinder = await readFile(join(realSite, finderName), "utf8"); + const finder = realFinder.replaceAll(join(realRepository, "harness", "tht"), sourceRoot); + await writeFile(join(sitePackages, finderName), finder); + const moduleName = finderName.slice(0, -3); + await writeFile(join(sitePackages, "__editable__.tht-0.1.0.pth"), `import ${moduleName}; ${moduleName}.install()`); + await execFileAsync("/usr/bin/git", ["init", "--initial-branch=main"], { cwd: repositoryRoot }); + await execFileAsync("/usr/bin/git", ["config", "user.name", "P1 Test"], { cwd: repositoryRoot }); + await execFileAsync("/usr/bin/git", ["config", "user.email", "p1-test@example.invalid"], { cwd: repositoryRoot }); + await execFileAsync("/usr/bin/git", ["add", "harness/tht", "harness/pyproject.toml"], { cwd: repositoryRoot }); + await execFileAsync("/usr/bin/git", ["commit", "-m", "trusted source"], { cwd: repositoryRoot }); + return { repositoryRoot, sourceRoot, sitePackages, finderName }; +} + +test("Git rejects configured upload-pack, clean filter, and hook state before exact allowed operations", async () => { + const repositoryRoot = await realpath(join(dirname(fileURLToPath(import.meta.url)), "..", "..")); + const runRoot = await fakeRepository(); + const remote = join(runRoot, "remote.git"); + const author = join(runRoot, "author"); + await execFileAsync("/usr/bin/git", ["init", "--bare", "--initial-branch=main", remote]); + await execFileAsync("/usr/bin/git", ["init", "--initial-branch=main", author]); + await execFileAsync("/usr/bin/git", ["config", "user.name", "P1 Fixture Curator"], { cwd: author }); + await execFileAsync("/usr/bin/git", ["config", "user.email", "p1-curator@example.invalid"], { cwd: author }); + await writeFile(join(author, "seed"), "seed\n"); + await execFileAsync("/usr/bin/git", ["add", "seed"], { cwd: author }); + await execFileAsync("/usr/bin/git", ["commit", "-m", "seed"], { cwd: author }); + await execFileAsync("/usr/bin/git", ["remote", "add", "origin", remote], { cwd: author }); + await execFileAsync("/usr/bin/git", ["push", "origin", "main"], { cwd: author }); + const executables = await resolveProductionExecutables({ repositoryRoot }); + const guard = installProductionSurfaceGuard({ ...executables, runRoot, environment: { ...process.env } }); + try { + for (const [kind, configure, argv] of [ + ["upload", async (helper) => execFileAsync("/usr/bin/git", ["config", "remote.origin.uploadpack", helper], { cwd: author }), ["fetch", "origin", "main"]], + ["filter", async (helper) => { + await mkdir(join(author, "workspace-content"), { recursive: true }); + await writeFile(join(author, ".gitattributes"), "workspace-content/** filter=bad\n"); + await execFileAsync("/usr/bin/git", ["config", "filter.bad.clean", helper], { cwd: author }); + }, ["add", "workspace-content"]], + ["hook", async (helper) => { await cp(helper, join(author, ".git", "hooks", "pre-commit")); }, ["commit", "-m", "Bootstrap curated P1 content"]], + ]) { + await execFileAsync("/usr/bin/git", ["config", "--unset-all", "remote.origin.uploadpack"], { cwd: author }).catch(() => {}); + await execFileAsync("/usr/bin/git", ["config", "--remove-section", "filter.bad"], { cwd: author }).catch(() => {}); + await rm(join(author, ".gitattributes"), { force: true }); + await rm(join(author, ".git", "hooks", "pre-commit"), { force: true }); + const marker = join(runRoot, `${kind}-marker`); + const helper = join(runRoot, `${kind}-helper`); + await writeFile(helper, `#!/bin/sh\nprintf ran > '${marker}'\nexec /usr/bin/git-upload-pack \"$@\"\n`, { mode: 0o700 }); + await configure(helper); + const before = guard.events.length; + await assert.rejects(runCommand({ executable: executables.gitPath, argv, cwd: author, env: { ...process.env } }), /unsafe Git repository state/); + assert.equal(guard.events.length - before, 1); + assert.equal(guard.events.at(-1).outcome, "REJECTED"); + await assert.rejects(lstat(marker)); + } + } finally { guard.restore(); } +}); + +test("trusted tht rejects executable finder code and Git-hidden source changes", async () => { + const maliciousFinder = await fakeTrustedThtRepository(); + const finderPath = join(maliciousFinder.sitePackages, maliciousFinder.finderName); + await writeFile(finderPath, `open('${join(maliciousFinder.repositoryRoot, "finder-marker")}', 'w').write('ran')\n${await readFile(finderPath, "utf8")}`); + await assert.rejects(resolveProductionExecutables({ repositoryRoot: maliciousFinder.repositoryRoot }), /editable binding is invalid/); + + const ignoredPyc = await fakeTrustedThtRepository(); + await mkdir(join(ignoredPyc.sitePackages, "__pycache__")); + await writeFile(join(ignoredPyc.sitePackages, "__pycache__", `${ignoredPyc.finderName.slice(0, -3)}.cpython-313.pyc`), "malicious bytecode"); + await assert.rejects(resolveProductionExecutables({ repositoryRoot: ignoredPyc.repositoryRoot }), /import startup override/); + + const hiddenSource = await fakeTrustedThtRepository(); + const sourcePath = join(hiddenSource.sourceRoot, "cli", "__init__.py"); + await execFileAsync("/usr/bin/git", ["update-index", "--assume-unchanged", "harness/tht/cli/__init__.py"], { cwd: hiddenSource.repositoryRoot }); + await writeFile(sourcePath, `${await readFile(sourcePath, "utf8")}\n# malicious hidden swap\n`); + await assert.rejects(resolveProductionExecutables({ repositoryRoot: hiddenSource.repositoryRoot }), /source bytes differ from Git/); +}); + +test("trusted tht guard rejects and records post-resolution entrypoint finder and source swaps at spawn", async () => { + for (const target of ["entrypoint", "finder", "source"]) { + const fixture = await fakeTrustedThtRepository(); + const executables = await resolveProductionExecutables({ repositoryRoot: fixture.repositoryRoot }); + const runRoot = await fakeRepository(); + const configPath = join(runRoot, "rendered", "workspace.yaml"); + await mkdir(dirname(configPath), { recursive: true }); + await writeFile(configPath, "profile: acceptance\n"); + const guard = installProductionSurfaceGuard({ ...executables, runRoot, environment: { ...process.env } }); + try { + const path = target === "entrypoint" ? executables.thtPath + : target === "finder" ? join(fixture.sitePackages, fixture.finderName) + : join(fixture.sourceRoot, "cli", "__init__.py"); + await writeFile(path, `${await readFile(path, "utf8")}\n# post-resolution swap\n`, target === "entrypoint" ? { mode: 0o700 } : undefined); + const childProcess = await import("node:child_process"); + assert.throws(() => childProcess.execFile(executables.thtPath, ["config", "check", "-c", configPath], { + cwd: join(fixture.repositoryRoot, "harness"), env: { ...process.env }, + }), /trusted THT identity changed/); + assert.equal(guard.events.at(-1).outcome, "REJECTED"); + } finally { guard.restore(); } + } +}); + +test("secret scan fails closed on a recoverable symlink outside fixture-secrets", async () => { + const repositoryRoot = await fakeRepository(); + const run = await createOwnedRun({ repositoryRoot }); + const canary = "CANARY-symlink-secret-123456"; + await mkdir(join(run.root, "fixture-secrets")); + await writeFile(join(run.root, "fixture-secrets", "token"), canary); + await mkdir(join(run.root, "responses")); + await symlink(join(run.root, "fixture-secrets", "token"), join(run.root, "responses", "leak")); + await assert.rejects(scanSecrets({ runRoot: run.root, forbiddenValues: [canary], expectedGitRepositories: [] }), /symlink outside fixture-secrets/); +}); + +test("direct public wrapper execution cannot source ambient BASH_ENV or ENV", async () => { + const root = await fakeRepository(); + const startup = join(root, "startup"); + const marker = join(root, "ambient-shell-ran"); + await writeFile(startup, `printf sourced > '${marker}'\n`); + const wrapper = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "scripts", "p1-acceptance.sh"); + await assert.rejects(execFileAsync(wrapper, ["invalid"], { env: { ...process.env, BASH_ENV: startup, ENV: startup } })); + await assert.rejects(lstat(marker)); + assert.match(await readFile(wrapper, "utf8"), /^#!\/usr\/bin\/env -S -u BASH_ENV -u ENV \/bin\/bash\n/); +}); + +test("final listener ownership state is a declared hash-bound report artifact", async () => { + const repositoryRoot = await fakeRepository(); + const result = await runIntegration({ repositoryRoot, keep: true, checks: exactScenarios() }); + const artifact = result.report.checks.flatMap(({ artifacts }) => artifacts).find(({ path }) => path === "logs/final-ownership.json"); + assert(artifact); + const bytes = await readFile(join(result.runRoot, artifact.path)); + const { createHash } = await import("node:crypto"); + assert.equal(createHash("sha256").update(bytes).digest("hex"), artifact.sha256); + const value = JSON.parse(bytes); + assert.deepEqual(value.listeners.map(({ state }) => state), ["not_started", "not_started"]); +}); + +test("repository provenance binds clean HEAD tree and backend source/dist manifests and rejects dirty state", async () => { + const repositoryRoot = await fakeRepository(); + await mkdir(join(repositoryRoot, "backend", "src"), { recursive: true }); + await mkdir(join(repositoryRoot, "backend", "scripts"), { recursive: true }); + await mkdir(join(repositoryRoot, "backend", "dist"), { recursive: true }); + await writeFile(join(repositoryRoot, "backend", "src", "app.ts"), "export const value = 1;\n"); + await writeFile(join(repositoryRoot, "backend", "scripts", "p1-acceptance.mjs"), "export {};\n"); + await writeFile(join(repositoryRoot, "backend", "dist", "app.js"), "export const value = 1;\n"); + await writeFile(join(repositoryRoot, "backend", "package.json"), "{}\n"); + await writeFile(join(repositoryRoot, "backend", "package-lock.json"), "{}\n"); + await writeFile(join(repositoryRoot, "backend", "tsconfig.json"), "{}\n"); + await execFileAsync("/usr/bin/git", ["init", "--initial-branch=main"], { cwd: repositoryRoot }); + await execFileAsync("/usr/bin/git", ["config", "user.name", "P1 Test"], { cwd: repositoryRoot }); + await execFileAsync("/usr/bin/git", ["config", "user.email", "p1-test@example.invalid"], { cwd: repositoryRoot }); + await execFileAsync("/usr/bin/git", ["add", "backend"], { cwd: repositoryRoot }); + await execFileAsync("/usr/bin/git", ["commit", "-m", "clean tree"], { cwd: repositoryRoot }); + const provenance = await collectRepositoryProvenance({ repositoryRoot, gitPath: "/usr/bin/git" }); + assert.match(provenance.head, /^[0-9a-f]{40}$/); + assert.match(provenance.tree, /^[0-9a-f]{40}$/); + assert.equal(provenance.clean, true); + assert.equal(provenance.backendSource.files.some(({ path }) => path === "src/app.ts"), true); + assert.equal(provenance.backendDist.files.some(({ path }) => path === "dist/app.js"), true); + await writeFile(join(repositoryRoot, "backend", "src", "app.ts"), "dirty\n"); + await assert.rejects(collectRepositoryProvenance({ repositoryRoot, gitPath: "/usr/bin/git" }), /repository is not clean/); +}); diff --git a/scripts/p1-acceptance.sh b/scripts/p1-acceptance.sh index b43e15a5..2114d27c 100755 --- a/scripts/p1-acceptance.sh +++ b/scripts/p1-acceptance.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env -S -u BASH_ENV -u ENV /bin/bash set -euo pipefail script_path=${BASH_SOURCE[0]} script_dir=${script_path%/*} @@ -55,6 +55,7 @@ build_env=(/usr/bin/env -i "PATH=$owned_path" "HOME=$wrapper_root/home" "TMPDIR= for name in LC_ALL TZ; do [[ -n "${!name:-}" ]] && build_env+=("$name=${!name}") done +/bin/rm -rf -- "$repo_root/backend/dist" "${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}"