fix: bind P1 acceptance evidence to reviewed runtime
This commit is contained in:
@@ -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, "<SOURCE_ROOT>");
|
||||
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,
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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}"
|
||||
|
||||
Reference in New Issue
Block a user