Files
ThothII/backend/scripts/p1-render-snapshot.mjs
marcopan c7338969d7 fix: bind complete P1 manual dist graph and snapshot identity
prepare records an immutable manifest of every regular backend/dist file
(path/size/sha256/dev/ino) in the owned root and binds its record identity
in ownership; serve revalidates record and every file before spawn, passes
the manifest to the child on fd 4, and the immutable preload hash-verifies
all files at startup and serves only cached verified bytes for any import
below backend/dist, so imported dependency replacement is refused before
RUNNING or never executes. The render command validates the commit
snapshot.json manifest, binds snapshot bytes to the manifest digest and the
installed Git blob, and passes the expected digest to the renderer, which
revalidates head/files digest with bounded no-follow reads and renders only
verified bytes with lease release on refusal.
2026-08-10 17:36:24 +02:00

173 lines
11 KiB
JavaScript
Executable File

#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { constants, lstatSync, realpathSync } from "node:fs";
import { lstat, mkdir, open, readFile, realpath } from "node:fs/promises";
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
// This acceptance-only adapter deliberately imports the built production runner.
import { ThtRunner } from "../dist/tht/tht-runner.js";
const modulePath = fileURLToPath(import.meta.url);
const defaultRepositoryRoot = realpathSync(resolve(dirname(modulePath), "../.."));
const HEX40 = /^[0-9a-f]{40}$/;
const HEX64 = /^[0-9a-f]{64}$/;
function fixedRoot(repositoryRoot) { return join(realpathSync(repositoryRoot), ".artifacts", "manual-acceptance", "p1"); }
function below(parent, child) { const rel = relative(parent, child); return rel !== "" && !rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel); }
function assertNoSymlinks(root, path, allowMissingLeaf = false) {
const rel = relative(root, path);
if (rel.startsWith("..") || isAbsolute(rel)) throw new Error("path is outside owned root");
let cursor = root;
for (const [index, part] of rel.split(sep).filter(Boolean).entries()) {
cursor = join(cursor, part);
try { if (lstatSync(cursor).isSymbolicLink()) throw new Error("owned path contains a symlink"); }
catch (error) {
if (allowMissingLeaf && error.code === "ENOENT" && index === rel.split(sep).filter(Boolean).length - 1) return;
throw error;
}
}
}
async function ownership(repositoryRoot, ownershipPath) {
const root = fixedRoot(repositoryRoot);
const expected = join(root, "ownership.json");
if (resolve(ownershipPath) !== expected) throw new Error("ownership path is not owned");
const rootEntry = await lstat(root); const ownershipEntry = await lstat(expected);
if (!rootEntry.isDirectory() || rootEntry.isSymbolicLink() || !ownershipEntry.isFile() || ownershipEntry.isSymbolicLink()) throw new Error("ownership is unsafe");
if (await realpath(root) !== root) throw new Error("ownership root is not canonical");
let value; try { value = JSON.parse(await readFile(expected, "utf8")); } catch { throw new Error("ownership is malformed"); }
if (value?.schemaVersion !== 1 || value.kind !== "p1-manual-acceptance" || !HEX64.test(value.nonce ?? "")
|| value.repositoryRoot !== realpathSync(repositoryRoot) || value.root !== root || value.status !== "PENDING"
|| value.listener?.host !== "127.0.0.1" || value.listener?.port !== 8791) throw new Error("ownership identity mismatch");
return { root, value };
}
const ANCHORED_PUBLISH_SOURCE=String.raw`import os,secrets,stat,sys
parent,name,expected_dev,expected_ino=sys.argv[1:]
pfd=fd=None;stage=".render-stage-"+secrets.token_hex(16);published=False
def fail(): raise RuntimeError("anchored publication refused")
try:
pfd=os.open(parent,os.O_RDONLY|os.O_DIRECTORY|os.O_NOFOLLOW)
identity=os.fstat(pfd)
if (identity.st_dev,identity.st_ino)!=(int(expected_dev),int(expected_ino)): fail()
try: os.stat(name,dir_fd=pfd,follow_symlinks=False); fail()
except FileNotFoundError: pass
fd=os.open(stage,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600,dir_fd=pfd)
data=sys.stdin.buffer.read(33554433)
if len(data)>33554432: fail()
view=memoryview(data)
while view:
written=os.write(fd,view)
if written<=0: fail()
view=view[written:]
os.fsync(fd);os.close(fd);fd=None;os.rename(stage,name,src_dir_fd=pfd,dst_dir_fd=pfd);published=True;os.fsync(pfd)
current=os.stat(parent,follow_symlinks=False)
if not stat.S_ISDIR(current.st_mode) or (current.st_dev,current.st_ino)!=(identity.st_dev,identity.st_ino): fail()
except Exception:
if published:
try: os.unlink(name,dir_fd=pfd);os.fsync(pfd)
except Exception: pass
print("anchored output publication refused (details redacted)",file=sys.stderr);raise SystemExit(1)
finally:
if fd is not None: os.close(fd)
if pfd is not None:
try: os.unlink(stage,dir_fd=pfd)
except FileNotFoundError: pass
os.close(pfd)
`;
async function atomicCopy(source,output) {
const parent=dirname(output),entry=await lstat(parent);if(!entry.isDirectory()||entry.isSymbolicLink())throw new Error("rendered parent identity is unsafe");const bytes=await readFile(source);
const result=spawnSync("python3",["-c",ANCHORED_PUBLISH_SOURCE,parent,basename(output),String(entry.dev),String(entry.ino)],{input:bytes,encoding:"utf8",maxBuffer:1024*1024});
if(result.error||result.status!==0)throw new Error("anchored output publication refused; rendered parent identity changed or output is unsafe");
}
function sameEntry(actual, expected) { return actual.dev === expected.dev && actual.ino === expected.ino; }
async function readBounded(path, max, label) {
let handle;
try {
handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
const before = await handle.stat(), pathEntry = await lstat(path);
if (!before.isFile() || pathEntry.isSymbolicLink() || !pathEntry.isFile() || !sameEntry(before, pathEntry)) throw new Error(`${label} is unsafe`);
if (before.size < 1 || before.size > max) throw new Error(`${label} is unbounded`);
const bytes = Buffer.alloc(before.size); let offset = 0;
while (offset < bytes.length) {
const { bytesRead } = await handle.read(bytes, offset, bytes.length - offset, offset);
if (bytesRead < 1) throw new Error(`${label} changed while reading`);
offset += bytesRead;
}
const after = await handle.stat();
if (!sameEntry(before, after) || after.size !== before.size) throw new Error(`${label} changed while reading`);
return bytes;
} finally {
if (handle) await handle.close().catch(() => {});
}
}
async function readSnapshotManifest(root, manifestPath, commit, yamlName, expectedDigest) {
let manifestEntry;
try { assertNoSymlinks(root, manifestPath); manifestEntry = await lstat(manifestPath); }
catch (error) { if (error?.code === "ENOENT") throw new Error("snapshot manifest is missing or unbounded"); throw error; }
if (!manifestEntry.isFile() || manifestEntry.isSymbolicLink() || await realpath(manifestPath) !== manifestPath) throw new Error("snapshot manifest is unsafe");
const bytes = await readBounded(manifestPath, 1048576, "snapshot manifest");
let manifest; try { manifest = JSON.parse(bytes.toString("utf8")); } catch { throw new Error("snapshot manifest is malformed"); }
const files = manifest?.files;
if (manifest?.head !== commit || !files || typeof files !== "object" || Array.isArray(files)) throw new Error("snapshot manifest identity is unsafe");
if (!HEX64.test(files[yamlName] ?? "") || files[yamlName] !== expectedDigest) throw new Error("snapshot manifest digest is unsafe");
return manifest;
}
export async function renderOwnedSnapshot({ repositoryRoot = defaultRepositoryRoot, ownershipPath, snapshotPath, outputPath, snapshotSha256, env = process.env, beforePublish }) {
const repo = realpathSync(repositoryRoot); const { root } = await ownership(repo, resolve(repo, ownershipPath));
const snapshot = resolve(repo, snapshotPath); const output = resolve(repo, outputPath);
const snapshotsRoot = join(root, "installation", "registry", "snapshots");
const renderedRoot = join(root, "rendered");
if (!isAbsolute(snapshotPath) || !below(snapshotsRoot, snapshot)) throw new Error("snapshot is not an owned absolute path");
const match = /^([0-9a-f]{40})\/([a-z][a-z0-9-]{2,62})\.yaml$/.exec(relative(snapshotsRoot, snapshot).split(sep).join("/"));
if (!match || !HEX40.test(match[1])) throw new Error("snapshot is not commit addressed");
if (!HEX64.test(snapshotSha256 ?? "")) throw new Error("snapshot digest identity is unsafe");
assertNoSymlinks(root, snapshot); const snapshotEntry = await lstat(snapshot);
if (!snapshotEntry.isFile() || snapshotEntry.isSymbolicLink() || await realpath(snapshot) !== snapshot) throw new Error("snapshot is unsafe");
const yamlName = `${match[2]}.yaml`;
const manifestPath = join(snapshotsRoot, match[1], "snapshot.json");
await readSnapshotManifest(root, manifestPath, match[1], yamlName, snapshotSha256);
const snapshotBytes = await readBounded(snapshot, 1048576, "snapshot");
if (createHash("sha256").update(snapshotBytes).digest("hex") !== snapshotSha256) throw new Error("snapshot bytes changed");
if (!below(renderedRoot, output) || dirname(output) !== renderedRoot || !output.endsWith(".yaml")) throw new Error("output is not an owned rendered path");
assertNoSymlinks(root, dirname(output));
try { if ((await lstat(output)).isSymbolicLink()) throw new Error("output is unsafe"); } catch (error) { if (error.code !== "ENOENT") throw error; }
await mkdir(join(snapshotsRoot, "runtime"), { recursive: true, mode: 0o700 });
const prior = {};
for (const [key, value] of Object.entries(env)) { prior[key] = process.env[key]; if (value === undefined) delete process.env[key]; else process.env[key] = value; }
const runner = new ThtRunner({
thtBin: join(repo, "harness", ".venv", "bin", "tht"), harnessDir: join(repo, "harness"),
configPath: join(root, "installation", "base.yaml"), dataRoot: join(root, "installation", "data"),
runtimeSnapshotRoot: join(snapshotsRoot, "runtime"), secretRoots: [join(root, "fixture-secrets")],
semanticRuntime: { internalQdrantUrl: "http://qdrant:6333", internalEmbeddingUrl: "http://embedding:11434", internalEmbeddingModel: "qwen3-embedding:0.6b", internalEmbeddingDimensions: 1024 },
});
let lease;
try {
lease = runner.acquireWorkspaceRuntime(snapshot);
const verifySnapshot = async () => {
const current = await readBounded(snapshot, 1048576, "snapshot");
if (createHash("sha256").update(current).digest("hex") !== snapshotSha256) throw new Error("snapshot content changed during rendering");
};
await verifySnapshot();
if(beforePublish)await beforePublish({output,renderedRoot});
await verifySnapshot();
await atomicCopy(lease.path, output);
}
finally {
if (lease) lease.release();
for (const key of Object.keys(env)) { if (prior[key] === undefined) delete process.env[key]; else process.env[key] = prior[key]; }
}
return output;
}
function parseArgs(argv) {
if (argv.length !== 8) throw new Error("usage: p1-render-snapshot.mjs --ownership PATH --snapshot ABSOLUTE_PATH --output PATH --snapshot-sha256 HEX");
const result = {}; for (let i=0;i<argv.length;i+=2) { if (!["--ownership","--snapshot","--output","--snapshot-sha256"].includes(argv[i]) || result[argv[i]]) throw new Error("invalid arguments"); result[argv[i]]=argv[i+1]; }
if (!result["--ownership"] || !result["--snapshot"] || !result["--output"] || !result["--snapshot-sha256"]) throw new Error("missing arguments"); return result;
}
if (process.argv[1] && realpathSync(process.argv[1]) === modulePath) {
try { const args=parseArgs(process.argv.slice(2)); await renderOwnedSnapshot({ ownershipPath:args["--ownership"], snapshotPath:args["--snapshot"], outputPath:args["--output"], snapshotSha256:args["--snapshot-sha256"] }); console.log(`rendered ${resolve(args["--output"])}`); }
catch(error) { console.error(`p1 render refused: ${error.message}`); process.exitCode=1; }
}