fix: harden P1 manual acceptance guards
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { chmod, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import net from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -44,6 +45,27 @@ test("prepare requires Task 8 and prerequisites before creating state", async ()
|
||||
await assert.rejects(lstat(fixedManualRoot(repo)));
|
||||
});
|
||||
|
||||
test("public wrapper exposes only four actions and rejects automated-run prepare input", async () => {
|
||||
const wrapper=new URL("../../scripts/p1-manual-acceptance.sh",import.meta.url),source=await readFile(wrapper,"utf8");
|
||||
assert.match(source,/prepare\|serve\|stop\|cleanup/); assert.doesNotMatch(source,/integration\|automated|prepare\|serve\|stop\|cleanup\|/);
|
||||
await assert.rejects(execFileAsync("bash",[wrapper.pathname,"prepare",".artifacts/p1-integration/run"]),error=>error.code===2&&/usage:/.test(error.stderr));
|
||||
});
|
||||
|
||||
test("prepare rejects unknown automated-run input before creating its root", async () => {
|
||||
const repo = await fakeRepo();
|
||||
await assert.rejects(
|
||||
prepareManual({ repositoryRoot: repo, skipBuild: true, automatedRun: join(repo, ".artifacts", "p1-integration") }),
|
||||
/unknown|automated/i,
|
||||
);
|
||||
await assert.rejects(lstat(fixedManualRoot(repo)));
|
||||
});
|
||||
|
||||
test("prepare requires the non-Task-8 tht prerequisite before creating state", async () => {
|
||||
const repo = await fakeRepo(); await rm(join(repo, "harness", ".venv", "bin", "tht"));
|
||||
await assert.rejects(prepareManual({ repositoryRoot: repo, skipBuild: true }), /missing prerequisite.*tht/);
|
||||
await assert.rejects(lstat(fixedManualRoot(repo)));
|
||||
});
|
||||
|
||||
test("prepare creates independent pending topology, fixtures, commands and guide without verdict", async () => {
|
||||
const repo = await fakeRepo(); const run = await prepareManual({ repositoryRoot: repo, skipBuild: true });
|
||||
assert.equal(run.root, fixedManualRoot(repo));
|
||||
@@ -113,6 +135,59 @@ test("serve and cleanup refuse stale or mismatched PID records without signaling
|
||||
assert.equal((await lstat(run.root)).isDirectory(),true);
|
||||
});
|
||||
|
||||
test("serve refuses non-loopback ownership without creating process state", async () => {
|
||||
const repo=await fakeRepo(); const run=await prepareManual({repositoryRoot:repo,skipBuild:true});
|
||||
const ownershipPath=join(run.root,"ownership.json"),owned=JSON.parse(await readFile(ownershipPath,"utf8"));
|
||||
owned.listener.host="0.0.0.0"; await writeFile(ownershipPath,JSON.stringify(owned));
|
||||
await assert.rejects(serveManual({repositoryRoot:repo}),/identity|loopback|bind/);
|
||||
await assert.rejects(lstat(join(run.root,"backend.pid")));
|
||||
});
|
||||
|
||||
test("cleanup refuses a correctly owned live server until guarded stop", { concurrency: false }, async () => {
|
||||
const repo=await fakeRepo(); await installFakeServer(repo); const run=await prepareManual({repositoryRoot:repo,skipBuild:true});
|
||||
const pid=await serveManual({repositoryRoot:repo});
|
||||
try {
|
||||
await assert.rejects(cleanupManual({repositoryRoot:repo}),/owned backend is live|stop first/);
|
||||
assert.doesNotThrow(()=>process.kill(pid,0));
|
||||
} finally {
|
||||
try { await stopManual({repositoryRoot:repo}); } catch { try { process.kill(pid,"SIGTERM"); } catch {} }
|
||||
}
|
||||
await cleanupManual({repositoryRoot:repo}); await assert.rejects(lstat(run.root));
|
||||
});
|
||||
|
||||
async function processStartIdentity(pid) {
|
||||
return (await execFileAsync("ps",["-p",String(pid),"-o","lstart="])).stdout.trim();
|
||||
}
|
||||
async function stopTestProcess(child) {
|
||||
if (child.exitCode === null) child.kill("SIGTERM");
|
||||
if (child.exitCode === null) await new Promise(resolvePromise=>child.once("exit",resolvePromise));
|
||||
}
|
||||
|
||||
test("live foreign executable, cwd, start and args mismatches are never signaled", async () => {
|
||||
const repo=await fakeRepo(); const run=await prepareManual({repositoryRoot:repo,skipBuild:true});
|
||||
const owned=JSON.parse(await readFile(join(run.root,"ownership.json"),"utf8"));
|
||||
const script=join(repo,"backend/dist/server.js"),nonceArg=`--p1-manual-nonce=${owned.nonce}`,rootArg=`--p1-root=${run.root}`;
|
||||
await writeFile(script,"setInterval(()=>{},1000);\n");
|
||||
const cases=[
|
||||
["executable",()=>spawn("bash",["-c","while :; do sleep 1; done",script,nonceArg,rootArg],{cwd:repo,stdio:"ignore"}),{}],
|
||||
["cwd",()=>spawn(process.execPath,[script,nonceArg,rootArg],{cwd:tmpdir(),stdio:"ignore"}),{}],
|
||||
["start",()=>spawn(process.execPath,[script,nonceArg,rootArg],{cwd:repo,stdio:"ignore"}),{startIdentity:"foreign-start"}],
|
||||
["args",()=>spawn(process.execPath,[script],{cwd:repo,stdio:"ignore"}),{}],
|
||||
];
|
||||
for(const [name,start,override] of cases){
|
||||
const child=start();
|
||||
try {
|
||||
let actualStart=""; for(let n=0;n<50&&!actualStart;n++){try{actualStart=await processStartIdentity(child.pid);}catch{} if(!actualStart)await new Promise(r=>setTimeout(r,20));}
|
||||
assert.ok(actualStart,`live ${name} process started`);
|
||||
const record={schemaVersion:1,pid:child.pid,nonce:owned.nonce,root:run.root,repositoryRoot:repo,executable:process.execPath,script,startIdentity:actualStart,...override};
|
||||
await writeFile(join(run.root,"backend.pid"),JSON.stringify(record));
|
||||
await assert.rejects(stopManual({repositoryRoot:repo}),/process identity mismatch|refusing to signal/);
|
||||
assert.doesNotThrow(()=>process.kill(child.pid,0));
|
||||
await rm(join(run.root,"backend.pid"));
|
||||
} finally { await stopTestProcess(child); await rm(join(run.root,"backend.pid"),{force:true}); }
|
||||
}
|
||||
});
|
||||
|
||||
test("generated render command validates saved responses and owned snapshot before renderer", async () => {
|
||||
const repo=await fakeRepo(); const run=await prepareManual({repositoryRoot:repo,skipBuild:true}); const script=join(run.root,"commands/render-1.sh"), output=join(run.root,"rendered/runtime-1.yaml");
|
||||
const invoke=()=>execFileAsync("bash",[script],{cwd:repo});
|
||||
@@ -127,9 +202,56 @@ test("generated render command validates saved responses and owned snapshot befo
|
||||
});
|
||||
|
||||
|
||||
test("generated secret scan checks reachable Git blobs without printing contents", async () => {
|
||||
const repo=await fakeRepo(); const run=await prepareManual({repositoryRoot:repo,skipBuild:true}); const installed=join(run.root,"installation/registry/repo"),author=join(run.root,"author"),scan=join(run.root,"commands/secret-scan.sh");
|
||||
const sha256=bytes=>createHash("sha256").update(bytes).digest("hex");
|
||||
async function makeExportZip(directory,name,{payloads={},manifest,extra=false,symlinkReadme=false}={}) {
|
||||
const source=join(directory,`${name}-source`),zip=join(directory,`${name}.zip`); await mkdir(source,{recursive:true});
|
||||
const files={"workspace.yaml":"workspace: safe\n","contract.env.example":"SAFE=path\n","README.md":"# Safe\n",...payloads};
|
||||
const value=manifest??{schema_version:1,workspace_id:"p1-filesystem",files:Object.fromEntries(Object.entries(files).map(([n,b])=>[n,sha256(b)]))};
|
||||
await writeFile(join(source,"manifest.json"),JSON.stringify(value));
|
||||
for(const [file,bytes] of Object.entries(files))if(!(symlinkReadme&&file==="README.md"))await writeFile(join(source,file),bytes);
|
||||
if(symlinkReadme)await symlink("workspace.yaml",join(source,"README.md"));
|
||||
if(extra)await writeFile(join(source,"extra.txt"),"extra");
|
||||
const names=["manifest.json","workspace.yaml","contract.env.example","README.md",...(extra?["extra.txt"]:[])];
|
||||
await execFileAsync("zip",["-q",...(symlinkReadme?["-y"]:[]),zip,...names],{cwd:source}); return zip;
|
||||
}
|
||||
|
||||
test("generated ZIP verifier enforces exact manifest mapping, hashes, entries and regular files", async () => {
|
||||
const repo=await fakeRepo(); const run=await prepareManual({repositoryRoot:repo,skipBuild:true}),extract=join(run.root,"commands/extract-export.sh");
|
||||
const invoke=async(name,options={})=>execFileAsync("bash",[extract,await makeExportZip(run.root,name,options),join(run.root,"exports/extracted",name)],{cwd:repo});
|
||||
await invoke("valid");
|
||||
const safe={"workspace.yaml":"workspace: safe\n","contract.env.example":"SAFE=path\n","README.md":"# Safe\n"};
|
||||
const hashes=Object.fromEntries(Object.entries(safe).map(([n,b])=>[n,sha256(b)]));
|
||||
await assert.rejects(invoke("missing-map",{manifest:{schema_version:1,workspace_id:"p1-filesystem",files:{"workspace.yaml":hashes["workspace.yaml"],"contract.env.example":hashes["contract.env.example"]}}}),/manifest/i);
|
||||
await assert.rejects(invoke("short-hash",{manifest:{schema_version:1,workspace_id:"p1-filesystem",files:{...hashes,"README.md":"abc"}}}),/manifest/i);
|
||||
await assert.rejects(invoke("extra-entry",{extra:true}),/unsafe-zip/);
|
||||
await assert.rejects(invoke("nonregular",{symlinkReadme:true}),/symlink|nonregular/);
|
||||
});
|
||||
|
||||
test("generated ZIP verifier scans all four extracted byte streams for Evidence and canaries", async () => {
|
||||
const repo=await fakeRepo(); const run=await prepareManual({repositoryRoot:repo,skipBuild:true}),extract=join(run.root,"commands/extract-export.sh");
|
||||
const markers=["P1 manually curated Evidence","DWH-"+"d".repeat(32),"CANARY-MUST-BE-REJECTED"];
|
||||
for(const marker of markers)for(const target of ["manifest.json","workspace.yaml","contract.env.example","README.md"]){
|
||||
const name=`scan-${markers.indexOf(marker)}-${target.replaceAll(".","-")}`,payloads=target==="manifest.json"?{}:{[target]:marker};
|
||||
const files={"workspace.yaml":"workspace: safe\n","contract.env.example":"SAFE=path\n","README.md":"# Safe\n",...payloads};
|
||||
const manifest={schema_version:1,workspace_id:target==="manifest.json"?marker:"p1-filesystem",files:Object.fromEntries(Object.entries(files).map(([n,b])=>[n,sha256(b)]))};
|
||||
const zip=await makeExportZip(run.root,name,{payloads,manifest});
|
||||
await assert.rejects(execFileAsync("bash",[extract,zip,join(run.root,"exports/extracted",name)],{cwd:repo}),error=>/Evidence|canary/.test(error.stderr)&&!error.stderr.includes(marker),`${target} must reject ${marker.slice(0,8)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("generated secret scan excludes only the exact request fixture and hides fixed canary", async () => {
|
||||
const repo=await fakeRepo(); const run=await prepareManual({repositoryRoot:repo,skipBuild:true}); const installed=join(run.root,"installation/registry/repo"),scan=join(run.root,"commands/secret-scan.sh"),canary="CANARY-MUST-BE-REJECTED";
|
||||
await execFileAsync("git",["clone",join(run.root,"remote.git"),installed]); await execFileAsync("bash",[scan],{cwd:repo});
|
||||
const canary="DWH-"+"c".repeat(32); await writeFile(join(author,"temporary-secret"),canary); await execFileAsync("git",["add","temporary-secret"],{cwd:author}); await execFileAsync("git",["commit","-m","temporary canary"],{cwd:author}); await execFileAsync("git",["rm","temporary-secret"],{cwd:author}); await execFileAsync("git",["commit","-m","remove canary"],{cwd:author});
|
||||
await assert.rejects(execFileAsync("bash",[scan],{cwd:repo}),error=>/reachable Git blob/.test(error.stderr)&&!error.stderr.includes(canary));
|
||||
const leak=join(run.root,"responses/requests/invalid-credential.json"); await mkdir(dirname(leak),{recursive:true}); await writeFile(leak,canary);
|
||||
await assert.rejects(execFileAsync("bash",[scan],{cwd:repo}),error=>/secret canary found/.test(error.stderr)&&!error.stderr.includes(canary));
|
||||
});
|
||||
|
||||
test("generated secret scan checks randomized and fixed canaries in reachable Git without printing values", async () => {
|
||||
for(const canary of ["DWH-"+"c".repeat(32),"CANARY-MUST-BE-REJECTED"]){
|
||||
const repo=await fakeRepo(); const run=await prepareManual({repositoryRoot:repo,skipBuild:true}); const installed=join(run.root,"installation/registry/repo"),author=join(run.root,"author"),scan=join(run.root,"commands/secret-scan.sh");
|
||||
await execFileAsync("git",["clone",join(run.root,"remote.git"),installed]);
|
||||
await writeFile(join(author,"temporary-secret"),canary); await execFileAsync("git",["add","temporary-secret"],{cwd:author}); await execFileAsync("git",["commit","-m","temporary canary"],{cwd:author}); await execFileAsync("git",["rm","temporary-secret"],{cwd:author}); await execFileAsync("git",["commit","-m","remove canary"],{cwd:author});
|
||||
await assert.rejects(execFileAsync("bash",[scan],{cwd:repo}),error=>/reachable Git blob/.test(error.stderr)&&!error.stderr.includes(canary));
|
||||
await rm(run.root,{recursive:true,force:true});
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user