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.
173 lines
11 KiB
JavaScript
Executable File
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; }
|
|
}
|