diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index cd3a04f4..90b04fde 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -5,7 +5,7 @@ ## P1 configuration-process automated integration — PASS 2026-08-09 -- Retained evidence: `.artifacts/p1-integration/p1-846a4d90b815a382b916d01d354fa8cd/report.md` +- Retained evidence: `.artifacts/p1-integration/p1-f3aec4a737050877be429885b0d7b06d/report.md` - automated integration: PASS - manual acceptance: PENDING diff --git a/backend/scripts/p1-acceptance.mjs b/backend/scripts/p1-acceptance.mjs index 04612e06..78ec4ebd 100755 --- a/backend/scripts/p1-acceptance.mjs +++ b/backend/scripts/p1-acceptance.mjs @@ -15,19 +15,20 @@ import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; const execFileAsync = promisify(execFile); +let commandEventSink; const RUN_ID = /^p1-[0-9a-f]{32}$/; const HEX40 = /^[0-9a-f]{40}$/; const HEX64 = /^[0-9a-f]{64}$/; const ISO_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; const SAFE_RELATIVE = /^(?!\/)(?!.*(?:^|\/)\.\.(?:\/|$))(?!.*\\)[A-Za-z0-9._/-]+$/; const COMMAND = /^[A-Za-z0-9._+-]+$/; -const CHECK_IDS = [ +export const CHECK_IDS = Object.freeze([ "preflight", "clean_state", "ownership", "local_git_bootstrap", "http_validate_publish_pull_read_export", "same_revision_git_objects", "content_only_revision", "snapshot_and_docs", "runtime_render_determinism", "tht_config_check", "negative_schema_cases", "negative_context_case", "no_p1_scope_artifacts", "secret_scan", "cleanup_confinement", -]; +]); const TOPOLOGY = [ "remote.git", "author", "installation/registry", "installation/data", "installation/runtime", "fixture-secrets", "fixtures/descriptors", "fixtures/requests", "requests", "responses", @@ -168,6 +169,11 @@ export async function runCommand(options) { const child = execFile(executable, argv, { cwd, env, timeout: timeoutMs, maxBuffer: maxOutputBytes, encoding: "utf8" }, (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({ + executable: basename(executable), + argvLabels: argv.map((value) => isAbsolute(value) || value.includes(sep) ? "[path]" : /^[a-z]+:\/\//i.test(value) ? "[url]" : value.length > 80 ? "[value]" : value), + outcome: error ? "FAIL" : "PASS", + }); if (error) Object.assign(error, { result }); error ? reject(error) : resolvePromise(result); }); @@ -193,10 +199,13 @@ function forbiddenKey(value) { return false; } export function deriveOverall(checks) { return checks.length > 0 && checks.every(({ status }) => status === "PASS") ? "PASS" : "FAIL"; } +function hasExactCheckIds(checks) { + return checks.length === CHECK_IDS.length && checks.every(({ id }, index) => id === CHECK_IDS[index]); +} export function validateReport(report) { if (!report || report.schemaVersion !== 1 || !RUN_ID.test(report.runId ?? "") || !ISO_UTC.test(report.startedAt ?? "") || !ISO_UTC.test(report.finishedAt ?? "") || typeof report.command !== "string" || forbiddenKey(report) - || !Array.isArray(report.checks) || report.checks.length === 0) throw new Error("report is invalid"); + || !Array.isArray(report.checks) || !hasExactCheckIds(report.checks)) throw new Error("report is invalid"); const ids = new Set(); for (const check of report.checks) { if (!check || !/^[a-z0-9_]+$/.test(check.id ?? "") || ids.has(check.id) || !["PASS", "FAIL"].includes(check.status) @@ -237,12 +246,18 @@ async function gitObjectFindings(runRoot, forbiddenValues) { if (!existsSync(directory)) continue; const args = basename(directory) === "remote.git" ? ["--git-dir", directory] : ["-C", directory]; let objects; - try { objects = (await git([...args, "rev-list", "--objects", "--all"])).stdout.trim().split("\n").filter(Boolean); } catch { continue; } + try { + objects = (await git([...args, "rev-list", "--objects", "--all"])).stdout.trim().split("\n").filter(Boolean); + } catch { throw new Error(`Git secret scan failed closed during enumeration: ${basename(directory)}`); } for (const line of objects) { const oid = line.split(" ", 1)[0]; - const type = (await git([...args, "cat-file", "-t", oid])).stdout.trim(); - if (type !== "blob") continue; - const bytes = Buffer.from((await git([...args, "cat-file", "blob", oid], { maxOutputBytes: 16 * 1024 * 1024 })).stdout); + let type; let bytes; + try { + type = (await git([...args, "cat-file", "-t", oid])).stdout.trim(); + if (!/^(blob|tree|commit|tag)$/.test(type)) throw new Error("invalid object type"); + if (type !== "blob") continue; + bytes = Buffer.from((await git([...args, "cat-file", "blob", oid], { maxOutputBytes: 16 * 1024 * 1024 })).stdout); + } catch { throw new Error(`Git secret scan failed closed during object inspection: ${basename(directory)}:${oid}`); } if (containsAny(bytes, forbiddenValues)) findings.push({ path: `git-object:${basename(directory)}:${oid}` }); } } @@ -256,6 +271,26 @@ export async function scanSecrets({ runRoot, forbiddenValues, virtualFiles = [] findings.push(...await gitObjectFindings(runRoot, values)); return findings; } +export function negativeRequestEvidence(caseLabel, expectedInputField) { + if (!/^[a-z0-9-]+$/.test(caseLabel) || !/^[a-z_]+(?:\.[a-z_]+)*$/.test(expectedInputField)) throw new Error("unsafe negative-case evidence"); + return { case: caseLabel, expectedInputField }; +} + +export function installExternalFetchGuard(ownedBaseUrl, fetchImplementation = globalThis.fetch) { + const owned = new URL(ownedBaseUrl); + if (owned.protocol !== "http:" || owned.hostname !== "127.0.0.1" || !owned.port) throw new Error("owned API must be loopback HTTP"); + const externalAttempts = []; + const guardedFetch = async (input, init) => { + const candidate = new URL(typeof input === "string" || input instanceof URL ? input : input.url); + if (candidate.origin !== owned.origin) { + externalAttempts.push({ protocol: candidate.protocol, loopback: candidate.hostname === "127.0.0.1" }); + throw new Error("external fetch prohibited"); + } + return await fetchImplementation(input, init); + }; + return { fetch: guardedFetch, externalAttempts }; +} + function sanitizeForEvidence(value, forbiddenValues = []) { if (typeof value === "string") { let safe = value; @@ -272,23 +307,27 @@ async function evidence(run, path, value, forbiddenValues = []) { return await fileArtifact(run.root, path); } export async function executeChecks({ checks, failAt, recorder } = {}) { + if (!Array.isArray(checks) || !hasExactCheckIds(checks)) throw new Error("scenarios must match the exact ordered check set"); + if (failAt !== undefined && !CHECK_IDS.includes(failAt)) throw new Error("failure hook must name an exact check"); const results = []; - const ids = new Set(); + let stopped = false; for (const scenario of checks) { - if (ids.has(scenario.id)) throw new Error("duplicate scenario id"); - ids.add(scenario.id); const startedAt = nowIso(); let result; - try { - const output = await scenario.run(); - if (scenario.id === failAt) throw new Error("injected acceptance failure"); - result = { id: scenario.id, status: "PASS", startedAt, finishedAt: nowIso(), commands: output.commands ?? [], artifacts: output.artifacts ?? [] }; - } catch (error) { - result = { id: scenario.id, status: "FAIL", startedAt, finishedAt: nowIso(), commands: [], artifacts: [], error: "Acceptance scenario failed safely." }; + if (stopped) { + result = { id: scenario.id, status: "FAIL", startedAt, finishedAt: nowIso(), commands: [], artifacts: [], error: "Not executed after earlier failure." }; + } else { + try { + const output = await scenario.run(); + if (scenario.id === failAt) throw new Error("injected acceptance failure"); + result = { id: scenario.id, status: "PASS", startedAt, finishedAt: nowIso(), commands: output.commands ?? [], artifacts: output.artifacts ?? [] }; + } catch { + result = { id: scenario.id, status: "FAIL", startedAt, finishedAt: nowIso(), commands: [], artifacts: [], error: "Acceptance scenario failed safely." }; + stopped = true; + } } results.push(result); if (recorder) await recorder(result); - if (result.status === "FAIL") break; } return results; } @@ -352,8 +391,9 @@ async function setupSecrets(ctx) { THT_WS_P1_S3_EVIDENCE_SESSION_TOKEN_FILE: paths.session, }); Object.assign(ctx.env, env); + env.THT_WORKSPACE_SECRET_ROOTS = secretDir; await atomicWrite(join(ctx.run.root, "installation", "bindings.env"), `${Object.entries(env).map(([key, value]) => `${key}=${value}`).join("\n")}\n`); - await atomicWrite(join(ctx.run.root, "installation", "base.yaml"), "{}\n"); + await atomicWrite(join(ctx.run.root, "installation", "runtime", "base.yaml"), "{}\n"); } async function initializeGit(ctx) { await git(["init", "--bare", "--initial-branch=main", join(ctx.run.root, "remote.git")], { cwd: ctx.run.root }); @@ -381,7 +421,7 @@ async function startBackend(ctx) { ctx.registryConfig = config.workspaceRegistry; ctx.registry = new WorkspaceRegistry(ctx.registryConfig); ctx.thtRunner = new ThtRunner({ - thtBin: config.thtBin, harnessDir: config.harnessDir, configPath: join(ctx.run.root, "installation", "base.yaml"), + thtBin: config.thtBin, harnessDir: config.harnessDir, configPath: join(ctx.run.root, "installation", "runtime", "base.yaml"), dataRoot: config.dataRoot, runtimeSnapshotRoot: join(config.workspaceRegistry.root, "snapshots", "runtime"), secretRoots: config.workspaceRegistry.secretRoots, secretsFile: config.secretsFile, secretFiles: config.secretFiles, semanticRuntime: { internalQdrantUrl: config.internalQdrantUrl, internalEmbeddingUrl: config.internalEmbeddingUrl, @@ -390,18 +430,28 @@ async function startBackend(ctx) { ctx.app = buildApp(config, { thtRunner: ctx.thtRunner, workspaceRegistry: ctx.registry }); const address = await ctx.app.listen({ host: "127.0.0.1", port: 0 }); const url = new URL(address); ctx.baseUrl = `http://127.0.0.1:${url.port}`; + const fetchGuard = installExternalFetchGuard(ctx.baseUrl, ctx.originalFetch); + ctx.guardedFetch = fetchGuard.fetch; ctx.externalAttempts = fetchGuard.externalAttempts; + globalThis.fetch = ctx.guardedFetch; await writeOwnership(ctx.run, { kind: "fastify", host: "127.0.0.1", requestedPort: 0, actualPort: Number(url.port), pid: process.pid, state: "listening" }); } -async function request(ctx, id, method, path, body, binary = false) { - const requestSummary = { method, path, ...(body === undefined ? {} : { body: sanitizeForEvidence(body, ctx.forbiddenValues) }) }; +export function exportArchiveEvidencePath(requestId) { + if (!/^export-[a-z0-9-]+$/.test(requestId)) throw new Error("invalid export request id"); + return `exports/raw/${requestId}.zip`; +} +async function request(ctx, id, method, path, body, binary = false, requestEvidence) { + const requestSummary = requestEvidence === undefined + ? { method, path, ...(body === undefined ? {} : { body: sanitizeForEvidence(body, ctx.forbiddenValues) }) } + : { method, path, input: requestEvidence }; await evidence(ctx.run, `requests/${id}.json`, requestSummary, ctx.forbiddenValues); - const response = await fetch(`${ctx.baseUrl}${path}`, { + ctx.httpRequests.push({ method, path }); + const response = await ctx.guardedFetch(`${ctx.baseUrl}${path}`, { method, headers: body === undefined ? {} : { "content-type": "application/json" }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(15_000), }); if (binary) { const bytes = Buffer.from(await response.arrayBuffer()); - await atomicWrite(join(ctx.run.root, `exports/raw/${id}.zip`), bytes); + await atomicWrite(join(ctx.run.root, exportArchiveEvidencePath(id)), bytes); await evidence(ctx.run, `responses/${id}.json`, { status: response.status, contentType: response.headers.get("content-type"), bytes: bytes.length }); return { status: response.status, bytes }; } @@ -444,11 +494,22 @@ async function snapshotDigest(path) { for (const file of files) result[file.rel] = sha256(await readFile(file.path)); return result; } +const SAFE_AMBIENT_ENV = Object.freeze(["PATH", "HOME", "LANG", "LC_ALL", "TMPDIR", "TZ", "NODE_EXTRA_CA_CERTS"]); +export function buildSafeEnvironment({ ambient = process.env, fixture = {} } = {}) { + const safe = {}; + for (const key of SAFE_AMBIENT_ENV) if (typeof ambient[key] === "string") safe[key] = ambient[key]; + for (const [key, value] of Object.entries(fixture)) { + if (typeof value !== "string") throw new Error(`fixture environment value must be a string: ${key}`); + safe[key] = value; + } + return safe; +} + async function setupContext(run, repositoryRoot, env) { const thtBin = realpathSync(env.THT_BIN ?? join(repositoryRoot, "harness", ".venv", "bin", "tht")); const harnessDir = realpathSync(join(repositoryRoot, "harness")); - const ctx = { run, repositoryRoot, descriptors: descriptors(), forbiddenValues: [], env: { - ...env, HOST: "127.0.0.1", PORT: "0", AUTH_MODE: "none", THT_BIN: thtBin, + const fixtureEnv = { + HOST: "127.0.0.1", PORT: "0", AUTH_MODE: "none", THT_BIN: thtBin, THT_HARNESS_DIR: harnessDir, THT_DATA_ROOT: join(run.root, "installation", "data"), SETTINGS_FILE: join(run.root, "installation", "data", "settings.json"), MAINTENANCE_STATE_FILE: join(run.root, "installation", "data", "maintenance.json"), @@ -457,11 +518,69 @@ async function setupContext(run, repositoryRoot, env) { 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"), - } }; + }; + const ctx = { + run, repositoryRoot, descriptors: descriptors(), forbiddenValues: [], + env: buildSafeEnvironment({ ambient: env, fixture: fixtureEnv }), + originalFetch: globalThis.fetch, httpRequests: [], externalAttempts: [], prohibitedInvocations: [], + }; await createTopology(run); await setupSecrets(ctx); for (const [name, value] of Object.entries(ctx.env)) process.env[name] = value; return ctx; } +async function registryState(ctx) { + const root = join(ctx.run.root, "installation", "registry"); + const bytes = async (path) => sha256(await readFile(path)); + return { + active: await bytes(join(root, "state", "active.json")), + snapshots: sha256(JSON.stringify(await snapshotDigest(join(root, "snapshots")))), + checkoutHead: await bytes(join(root, "repo", ".git", "refs", "heads", "main")), + remoteHead: await bytes(join(ctx.run.root, "remote.git", "refs", "heads", "main")), + }; +} +function assertByteIdentical(left, right, label) { + assert(JSON.stringify(left) === JSON.stringify(right), `${label} state changed`); +} +async function currentSnapshotManifest(ctx, commit) { + const path = join(ctx.run.root, "installation", "registry", "snapshots", commit, "snapshot.json"); + const manifest = JSON.parse(await readFile(path, "utf8")); + assert(manifest.head === commit, "snapshot manifest head mismatch"); + return { path, manifest }; +} +function revisionFromManifest(manifest, id) { + const revision = manifest.revisions.find((candidate) => candidate.id === id); + assert(revision, `manifest revision absent ${id}`); + return revision; +} +function renderedRoot(parsed) { return parsed.evidence.sources[0].root; } +function assertRuntimeContract(ctx, id, parsed, revision) { + assert(parsed.runtime_identity.workspace_id === id, "runtime workspace identity mismatch"); + assert(parsed.runtime_identity.workspace_revision === revision.commit, "runtime revision mismatch"); + assert(parsed.runtime_identity.source_identity === `workspace://${id}`, "runtime source identity mismatch"); + assert(parsed.vector.max_chunk_chars === 4000 && parsed.vector.retain_published_generations === 3, "runtime policy mismatch"); + const source = parsed.evidence.sources[0]; + if (id === "p1-filesystem") { + const exactRoot = join(dirname(revision.snapshotPath), "workspace-content", id, "evidence"); + assert(source.type === "filesystem" && source.root === exactRoot, "filesystem root mismatch"); + assert(JSON.stringify(source.patterns) === JSON.stringify(["**/*.md"]) && source.max_bytes === 10485760, "filesystem source contract mismatch"); + assert(!existsSync(source.root), "filesystem Evidence root was materialized"); + } else if (id === "p1-http") { + assert(source.type === "http", "HTTP source type mismatch"); + assert(JSON.stringify(source.provenance_urls) === JSON.stringify(["https://evidence.example.test/guide.md"]), "HTTP provenance mismatch"); + assert(source.signed_urls_file === join(ctx.run.root, "fixture-secrets", "evidence-signed-urls.json"), "HTTP binding mismatch"); + assert(source.connect_timeout === 1.25 && source.read_timeout === 30.001 && source.max_bytes === 12345 + && source.max_redirects === 2 && source.allow_private_hosts === false && source.max_cache_bytes === 67890, "HTTP limits mismatch"); + } else if (id === "p1-s3") { + assert(source.type === "s3" && source.bucket === "p1-evidence" && source.prefix === "published/", "S3 identity mismatch"); + assert(source.endpoint_url === "https://s3.example.test/" && source.region === "eu-west-1", "S3 endpoint mismatch"); + assert(source.access_key_file === join(ctx.run.root, "fixture-secrets", "evidence-access") + && source.secret_key_file === join(ctx.run.root, "fixture-secrets", "evidence-secret") + && source.session_token_file === join(ctx.run.root, "fixture-secrets", "evidence-session"), "S3 bindings mismatch"); + assert(source.trusted_endpoint === true && source.allow_private_endpoint === false && source.allow_insecure_endpoint === false + && source.max_bytes === 12345 && source.max_objects === 33 && source.max_pages === 4 && source.page_size === 5, "S3 limits mismatch"); + } +} + function productionChecks(ctx) { const log = async (id, value) => ({ commands: [], artifacts: [await evidence(ctx.run, `logs/${id}.json`, value, ctx.forbiddenValues)] }); return [ @@ -497,24 +616,32 @@ function productionChecks(ctx) { base = published.body.revision.commit; } ctx.publicationHead = base; - const pulled = await request(ctx, "registry-pull", "POST", "/workspace-registry/pull"); assert(pulled.status === 200 && pulled.body.head === base, "pull failed"); - const listed = await request(ctx, "workspace-list", "GET", "/workspaces"); assert(listed.status === 200 && listed.body.length === 3, "list failed"); - ctx.reads = {}; + const pulled = await request(ctx, "registry-pull", "POST", "/workspace-registry/pull"); + assert(pulled.status === 200 && pulled.body.head === base, "pull failed"); + const listed = await request(ctx, "workspace-list", "GET", "/workspaces"); + assert(listed.status === 200 && listed.body.length === 3, "list failed"); + ctx.reads = {}; ctx.exportManifests = {}; + const artifacts = []; for (const workspace of ctx.descriptors) { const id = workspace.workspace.id; const read = await request(ctx, `read-${id}`, "GET", `/workspaces/${id}`); assert(read.status === 200, `read failed ${id}`); ctx.reads[id] = read.body; const exported = await request(ctx, `export-${id}`, "GET", `/workspaces/${id}/export`, undefined, true); - assert(exported.status === 200, `export failed ${id}`); await extractZip(ctx, id, exported.bytes); + assert(exported.status === 200, `export failed ${id}`); ctx.exportManifests[id] = await extractZip(ctx, id, exported.bytes); + artifacts.push(await fileArtifact(ctx.run.root, exportArchiveEvidencePath(`export-${id}`))); + for (const name of ZIP_FILES) artifacts.push(await fileArtifact(ctx.run.root, `exports/extracted/${id}/${name}`)); } - return await log("http_flow", { workspaceIds: Object.keys(ctx.reads), head: base, realListener: true, fetch: true }); + artifacts.unshift(await evidence(ctx.run, "logs/http-flow.json", { workspaceIds: Object.keys(ctx.reads), head: base, realListener: true, fetch: true })); + return { commands: [], artifacts }; } }, { id: "same_revision_git_objects", run: async () => { - const read = await request(ctx, "read-filesystem-identity", "GET", "/workspaces/p1-filesystem"); - const revision = read.body.revision; ctx.oldRevision = revision; ctx.oldSnapshotDigest = await snapshotDigest(dirname(revision.snapshotPath)); + const revision = ctx.reads["p1-filesystem"].revision; + ctx.oldRevision = revision; ctx.oldSnapshotDigest = await snapshotDigest(dirname(revision.snapshotPath)); const checkoutHead = (await git(["rev-parse", "HEAD"], { cwd: join(ctx.run.root, "installation", "registry", "repo") })).stdout.trim(); - const manifest = JSON.parse(await readFile(join(dirname(revision.snapshotPath), "snapshot.json"), "utf8")); + const manifestPath = join(dirname(revision.snapshotPath), "snapshot.json"); + const manifest = JSON.parse(await readFile(manifestPath, "utf8")); const lease = ctx.thtRunner.acquireWorkspaceRuntime(revision.snapshotPath); let rendered; try { rendered = (await import("yaml")).parse(await readFile(lease.path, "utf8")); } finally { lease.release(); } + ctx.oldFilesystemRoot = renderedRoot(rendered); const identities = [revision.commit, checkoutHead, manifest.head, rendered.runtime_identity.workspace_revision]; assert(new Set(identities).size === 1, "revision identities diverged"); const repo = join(ctx.run.root, "installation", "registry", "repo"); @@ -523,7 +650,10 @@ function productionChecks(ctx) { const type = (await git(["cat-file", "-t", `${revision.commit}:workspace-content/p1-filesystem/evidence`], { cwd: repo })).stdout.trim(); assert(type === "tree", "Evidence object is not a tree"); assert(!existsSync(join(dirname(revision.snapshotPath), "workspace-content")), "snapshot materialized workspace-content"); - return { commands: ["git"], artifacts: [await evidence(ctx.run, "logs/git-object-proof.json", { commit: revision.commit, checkoutHead, manifestHead: manifest.head, runtimeRevision: rendered.runtime_identity.workspace_revision, evidenceType: type })] }; + return { commands: ["git"], artifacts: [ + await evidence(ctx.run, "logs/git-object-proof.json", { commit: revision.commit, checkoutHead, manifestHead: manifest.head, runtimeRevision: rendered.runtime_identity.workspace_revision, filesystemRoot: ctx.oldFilesystemRoot, evidenceType: type }), + await fileArtifact(ctx.run.root, relative(ctx.run.root, manifestPath)), await fileArtifact(ctx.run.root, relative(ctx.run.root, revision.snapshotPath)), + ] }; } }, { id: "content_only_revision", run: async () => { const author = join(ctx.run.root, "author"); @@ -532,34 +662,52 @@ function productionChecks(ctx) { await writeFile(join(author, "workspace-content", "p1-filesystem", "evidence", "guide.md"), "# P1 curated Evidence v2\n"); await git(["add", "workspace-content/p1-filesystem/evidence/guide.md"], { cwd: author }); await git(["commit", "-m", "Update curated Evidence only"], { cwd: author }); await git(["push", "origin", "main"], { cwd: author }); ctx.contentCommit = (await git(["rev-parse", "HEAD"], { cwd: author })).stdout.trim(); - const pulled = await request(ctx, "content-only-pull", "POST", "/workspace-registry/pull"); assert(pulled.status === 200 && pulled.body.head === ctx.contentCommit, "content pull failed"); - const current = (await request(ctx, "read-filesystem-content", "GET", "/workspaces/p1-filesystem")).body.revision; + const pulled = await request(ctx, "content-only-pull", "POST", "/workspace-registry/pull"); + assert(pulled.status === 200 && pulled.body.head === ctx.contentCommit, "content pull failed"); + const currentRead = (await request(ctx, "read-filesystem-content", "GET", "/workspaces/p1-filesystem")).body; + const current = currentRead.revision; const descriptorAfter = (await git(["rev-parse", "HEAD:workspaces/p1-filesystem.yaml"], { cwd: author })).stdout.trim(); assert(current.commit === ctx.contentCommit && current.blob === ctx.oldRevision.blob && descriptorAfter === descriptorBefore, "content revision identity failed"); - assert(JSON.stringify(await snapshotDigest(dirname(ctx.oldRevision.snapshotPath))) === JSON.stringify(ctx.oldSnapshotDigest), "old snapshot changed"); + assertByteIdentical(await snapshotDigest(dirname(ctx.oldRevision.snapshotPath)), ctx.oldSnapshotDigest, "old snapshot"); + const lease = ctx.thtRunner.acquireWorkspaceRuntime(current.snapshotPath); let rendered; + try { rendered = (await import("yaml")).parse(await readFile(lease.path, "utf8")); } finally { lease.release(); } + const newRoot = renderedRoot(rendered); + const expectedOldRoot = join(dirname(ctx.oldRevision.snapshotPath), "workspace-content", "p1-filesystem", "evidence"); + const expectedNewRoot = join(dirname(current.snapshotPath), "workspace-content", "p1-filesystem", "evidence"); + assert(ctx.oldFilesystemRoot === expectedOldRoot, "old filesystem root was not old commit-addressed root"); + assert(newRoot === expectedNewRoot && newRoot !== ctx.oldFilesystemRoot, "new filesystem root did not change exactly with commit"); ctx.currentRevision = current; - return { commands: ["git"], artifacts: [await evidence(ctx.run, "logs/content-only-revision.json", { oldCommit: ctx.oldRevision.commit, newCommit: current.commit, descriptorBlob: current.blob, oldSnapshotImmutable: true })] }; + const currentSnapshot = await currentSnapshotManifest(ctx, current.commit); ctx.currentManifest = currentSnapshot.manifest; + return { commands: ["git"], artifacts: [ + await evidence(ctx.run, "logs/content-only-revision.json", { oldCommit: ctx.oldRevision.commit, newCommit: current.commit, descriptorBlob: current.blob, oldFilesystemRoot: ctx.oldFilesystemRoot, newFilesystemRoot: newRoot, oldSnapshotImmutable: true }), + await fileArtifact(ctx.run.root, relative(ctx.run.root, currentSnapshot.path)), await fileArtifact(ctx.run.root, relative(ctx.run.root, current.snapshotPath)), + ] }; } }, { id: "snapshot_and_docs", run: async () => { + const artifacts = []; for (const id of ctx.descriptors.map((item) => item.workspace.id)) { const extracted = join(ctx.run.root, "exports", "extracted", id); - for (const name of ZIP_FILES) assert((await lstat(join(extracted, name))).isFile(), `missing extracted ${name}`); - const read = (await request(ctx, `read-${id}-snapshot`, "GET", `/workspaces/${id}`)).body; - for (const suffix of [".yaml", ".env.example", ".md", "snapshot.json"]) { - const file = suffix === "snapshot.json" ? join(dirname(read.revision.snapshotPath), suffix) : join(dirname(read.revision.snapshotPath), `${id}${suffix}`); - assert(existsSync(file), `snapshot artifact absent ${file}`); + for (const name of ZIP_FILES) { + assert((await lstat(join(extracted, name))).isFile(), `missing extracted ${name}`); + artifacts.push(await fileArtifact(ctx.run.root, `exports/extracted/${id}/${name}`)); } + const revision = revisionFromManifest(ctx.currentManifest, id); + for (const suffix of [".yaml", ".env.example", ".md", "snapshot.json"]) { + const file = suffix === "snapshot.json" ? join(dirname(revision.snapshotPath), suffix) : join(dirname(revision.snapshotPath), `${id}${suffix}`); + assert(existsSync(file), `snapshot artifact absent ${file}`); + artifacts.push(await fileArtifact(ctx.run.root, relative(ctx.run.root, file))); + } + assert(!existsSync(join(dirname(revision.snapshotPath), "workspace-content")), "snapshot materialized source tree"); } - return await log("snapshot_and_docs", { exactBundleFiles: ZIP_FILES, generatedDocs: true, immutableSnapshots: true }); + artifacts.unshift(await evidence(ctx.run, "logs/snapshot-and-docs.json", { exactBundleFiles: ZIP_FILES, generatedDocs: true, immutableSnapshots: true, derivedFromManifest: true })); + return { commands: [], artifacts }; } }, { id: "runtime_render_determinism", run: async () => { - ctx.configChecks = []; - const YAML = await import("yaml"); + ctx.configChecks = []; const artifacts = []; const YAML = await import("yaml"); for (const id of ctx.descriptors.map((item) => item.workspace.id)) { - const read = (await request(ctx, `read-${id}-runtime`, "GET", `/workspaces/${id}`)).body; - const bytes = []; + const revision = revisionFromManifest(ctx.currentManifest, id); const bytes = []; for (let n = 1; n <= 2; n += 1) { - const lease = ctx.thtRunner.acquireWorkspaceRuntime(read.revision.snapshotPath); + const lease = ctx.thtRunner.acquireWorkspaceRuntime(revision.snapshotPath); try { const contents = await readFile(lease.path); bytes.push(contents); await atomicWrite(join(ctx.run.root, "rendered", `${id}-${n}.yaml`), contents); @@ -568,19 +716,19 @@ function productionChecks(ctx) { } finally { lease.release(); } const runtimeDir = join(ctx.run.root, "installation", "registry", "snapshots", "runtime"); if (existsSync(runtimeDir)) assert((await readdir(runtimeDir)).length === 0, "runtime lease leaked"); + artifacts.push(await fileArtifact(ctx.run.root, `rendered/${id}-${n}.yaml`)); } assert(bytes[0].equals(bytes[1]), `render nondeterministic ${id}`); - const parsed = YAML.parse(bytes[0].toString("utf8")); - assert(parsed.runtime_identity.workspace_id === id && parsed.runtime_identity.workspace_revision === read.revision.commit, "render identity mismatch"); + assertRuntimeContract(ctx, id, YAML.parse(bytes[0].toString("utf8")), revision); } - return { commands: ["tht"], artifacts: [await evidence(ctx.run, "logs/runtime-render.json", { deterministic: true, released: true, workspaces: ctx.descriptors.map((item) => item.workspace.id) })] }; + artifacts.unshift(await evidence(ctx.run, "logs/runtime-render.json", { deterministic: true, released: true, fullSourcePolicyAssertions: true, rootsUnmaterialized: true, workspaces: ctx.descriptors.map((item) => item.workspace.id) })); + return { commands: ["tht"], artifacts }; } }, { id: "tht_config_check", run: async () => { assert(ctx.configChecks.length === 6 && ctx.configChecks.every(({ code }) => code === 0), "tht config checks incomplete"); return { commands: ["tht"], artifacts: [await evidence(ctx.run, "logs/tht-config-check.json", ctx.configChecks)] }; } }, { id: "negative_schema_cases", run: async () => { - const baselineHead = (await git(["rev-parse", "HEAD"], { cwd: join(ctx.run.root, "installation", "registry", "repo") })).stdout.trim(); const base = structuredClone(ctx.descriptors[0]); const cases = [ ["absolute", (w) => { w.evidence.source.uri = "/tmp/evidence"; }, "evidence.source.uri"], @@ -588,96 +736,182 @@ function productionChecks(ctx) { ["backslash", (w) => { w.evidence.source.uri = "workspace-content\\p1-filesystem\\evidence"; }, "evidence.source.uri"], ["cross-workspace", (w) => { w.evidence.source.uri = "workspace-content/other/evidence"; }, "evidence.source.uri"], ["unsupported-source", (w) => { w.evidence.source.type = "ftp"; w.evidence.source.uri = "ftp://example.test/file"; }, "evidence.source.type"], + ["unsupported-protocol", (w) => { w.evidence.source = { type: "http", uris: ["ftp://evidence.example.test/file"], authentication: "none" }; }, "evidence.source.uris"], ["credential-field", (w) => { w.evidence.source.password = ctx.secretValues.rejected; }, "evidence.source.password"], ["http-userinfo-query", (w) => { w.evidence.source = { type: "http", uris: [`https://user:${ctx.secretValues.rejected}@evidence.example.test/guide?x=${ctx.secretValues.rejected}`], authentication: "none" }; }, "evidence.source.uris"], ["malformed-policy", (w) => { w.evidence.policy.max_chunk_chars = 0; }, "evidence.policy.max_chunk_chars"], + ["malformed-limit", (w) => { w.evidence.source.max_bytes = 0; }, "evidence.source.max_bytes"], ]; const outcomes = []; for (const [id, mutate, field] of cases) { - const workspace = structuredClone(base); mutate(workspace); - await evidence(ctx.run, `fixtures/requests/negative-${id}.json`, { case: id, expectedField: field, rawCredentialPersisted: false }); - const response = await request(ctx, `negative-${id}`, "POST", "/workspaces/validate", { workspace }); - assert(response.status === 400 && response.body.code === "workspace_invalid", `negative accepted ${id}`); + const before = await registryState(ctx); const workspace = structuredClone(base); mutate(workspace); + const safeInput = negativeRequestEvidence(id, field); + await evidence(ctx.run, `fixtures/requests/negative-${id}.json`, safeInput); + const response = await request(ctx, `negative-${id}`, "POST", "/workspaces/validate", { workspace }, false, safeInput); + assert(response.status === 400 && response.body?.code === "workspace_invalid", `negative accepted ${id}`); + assert(Object.keys(response.body).sort().join(",") === "code,message", `negative response envelope unsafe ${id}`); + assert(response.body.message === "Workspace request or bundle is invalid.", `negative response message unsafe ${id}`); assert(JSON.stringify(response.body).includes(ctx.secretValues.rejected) === false, `negative leaked ${id}`); - const currentHead = (await git(["rev-parse", "HEAD"], { cwd: join(ctx.run.root, "installation", "registry", "repo") })).stdout.trim(); - assert(currentHead === baselineHead, `negative mutated head ${id}`); outcomes.push({ id, status: 400, code: "workspace_invalid", field }); + assertByteIdentical(await registryState(ctx), before, `negative ${id}`); + outcomes.push({ case: id, status: response.status, code: response.body.code, expectedInputField: field, genericSafeEnvelope: true, stateByteIdentical: true }); } - return { commands: ["git"], artifacts: [await evidence(ctx.run, "logs/negative-schema.json", outcomes, ctx.forbiddenValues)] }; + return { commands: ["git"], artifacts: [await evidence(ctx.run, "logs/negative-schema.json", outcomes)] }; } }, { id: "negative_context_case", run: async () => { - const { WorkspaceRegistry } = await loadProductionBackend(); const author = join(ctx.run.root, "author"); - await git(["checkout", "-b", "invalid-context", ctx.contentCommit], { cwd: author }); await git(["push", "-u", "origin", "invalid-context"], { cwd: author }); - const isolatedRoot = join(ctx.run.root, "installation", "registry-context"); - const registry = new WorkspaceRegistry({ ...ctx.registryConfig, root: isolatedRoot, branch: "invalid-context" }); - await registry.bootstrap(); const before = await registry.read("p1-filesystem"); + const author = join(ctx.run.root, "author"); const missing = baseWorkspace("missing-context", { type: "filesystem", uri: "workspace-content/missing-context/evidence", patterns: ["**/*.md"], max_bytes: 100 }); - await assertRejectsCode(() => registry.publish({ action: "create", workspace: missing, baseCommit: ctx.contentCommit }), "workspace_invalid"); - await rm(join(author, "workspace-content", "p1-filesystem", "evidence"), { recursive: true }); await git(["add", "-A", "workspace-content/p1-filesystem/evidence"], { cwd: author }); await git(["commit", "-m", "Invalid contextual Evidence state"], { cwd: author }); await git(["push", "origin", "invalid-context"], { cwd: author }); - await assertRejectsCode(() => registry.pull(), "workspace_invalid"); const after = await registry.read("p1-filesystem"); - assert(after.revision.commit === before.revision.commit, "isolated active snapshot changed"); - const primary = (await request(ctx, "primary-after-context", "GET", "/workspaces/p1-filesystem")).body; - assert(primary.revision.commit === ctx.contentCommit, "primary state changed"); - return { commands: ["git"], artifacts: [await evidence(ctx.run, "logs/negative-context.json", { missingCreateRejected: true, invalidPullRejected: true, lastValidCommit: after.revision.commit, primaryCommit: primary.revision.commit })] }; + const beforePublish = await registryState(ctx); + const rejectedPublish = await request(ctx, "context-missing-publish", "POST", "/workspaces/publish", { action: "create", workspace: missing, baseCommit: ctx.contentCommit }); + assert(rejectedPublish.status === 400 && rejectedPublish.body?.code === "workspace_invalid", "context publish was not rejected through HTTP"); + assertByteIdentical(await registryState(ctx), beforePublish, "failed contextual publish"); + await rm(join(author, "workspace-content", "p1-filesystem", "evidence"), { recursive: true }); + await git(["add", "-A", "workspace-content/p1-filesystem/evidence"], { cwd: author }); + await git(["commit", "-m", "Invalid contextual Evidence state"], { cwd: author }); + await git(["push", "origin", "main"], { cwd: author }); + const invalidRemoteCommit = (await git(["rev-parse", "HEAD"], { cwd: author })).stdout.trim(); + const remoteBeforePull = sha256(await readFile(join(ctx.run.root, "remote.git", "refs", "heads", "main"))); + const activeBeforePull = sha256(await readFile(join(ctx.run.root, "installation", "registry", "state", "active.json"))); + const snapshotsBeforePull = sha256(JSON.stringify(await snapshotDigest(join(ctx.run.root, "installation", "registry", "snapshots")))); + const rejectedPull = await request(ctx, "context-invalid-pull", "POST", "/workspace-registry/pull"); + assert(rejectedPull.status === 400 && rejectedPull.body?.code === "workspace_invalid", "invalid pull was not rejected through HTTP"); + const remoteAfterPull = sha256(await readFile(join(ctx.run.root, "remote.git", "refs", "heads", "main"))); + const activeAfterPull = sha256(await readFile(join(ctx.run.root, "installation", "registry", "state", "active.json"))); + const snapshotsAfterPull = sha256(JSON.stringify(await snapshotDigest(join(ctx.run.root, "installation", "registry", "snapshots")))); + const checkoutAfterPull = (await git(["rev-parse", "HEAD"], { cwd: join(ctx.run.root, "installation", "registry", "repo") })).stdout.trim(); + assert(remoteAfterPull === remoteBeforePull, "pull mutated fixture-author remote"); + assert(activeAfterPull === activeBeforePull && snapshotsAfterPull === snapshotsBeforePull, "invalid pull changed last-valid active snapshots"); + assert(checkoutAfterPull === invalidRemoteCommit, "invalid checkout did not advance as expected"); + return { commands: ["git"], artifacts: [await evidence(ctx.run, "logs/negative-context.json", { realHttp: true, missingPublishStateByteIdentical: true, invalidRemoteCommit, checkoutAdvancedInvalid: true, remoteUnchangedByRequest: true, lastValidCommit: ctx.contentCommit, activeAndSnapshotsByteIdentical: true })] }; } }, { id: "no_p1_scope_artifacts", run: async () => { - const forbidden = ["artifacts/evidence", "corpus/ACTIVE", "embeddings", "qdrant-records", "preprocessing-invocation"]; - const present = (await walkFiles(ctx.run.root)).map(({ rel }) => rel).filter((path) => forbidden.some((part) => path.includes(part))); - assert(present.length === 0, "P6 scope artifact created"); return await log("no-p1-scope-artifacts", { absent: forbidden }); + const forbidden = ["artifacts/evidence", "corpus/ACTIVE", "embedding-output", "qdrant-records", "preprocessing-invocation"]; + const files = (await walkFiles(ctx.run.root)).map(({ rel }) => rel); + const present = files.filter((path) => forbidden.some((part) => path.includes(part))); + const prohibitedRoutes = ctx.httpRequests.filter(({ path }) => /\/test$|\/evidence|preprocess|acquire/i.test(path)); + const prohibitedCommands = (commandEventSink ?? []).filter(({ argvLabels }) => argvLabels.some((label) => /preprocess|acquire.*evidence|embedding|qdrant/i.test(label))); + const productionWorkspaceModules = await readdir(join(ctx.repositoryRoot, "backend", "dist", "workspaces")); + const adapterConstructorModules = productionWorkspaceModules.filter((name) => /adapter|acquisition/i.test(name)); + assert(present.length === 0 && prohibitedRoutes.length === 0 && prohibitedCommands.length === 0 && ctx.prohibitedInvocations.length === 0, "prohibited P1 scope operation observed"); + assert(adapterConstructorModules.length === 0, "unexpected P1 adapter constructor surface present"); + assert(ctx.externalAttempts.length === 0, "external fetch attempted"); + return await log("no-p1-scope-artifacts", { absentArtifacts: forbidden, prohibitedRouteInvocations: 0, prohibitedCommandInvocations: prohibitedCommands.length, evidenceAcquisitionInvocations: 0, preprocessingInvocations: 0, adapterConstructorModules, globalFetchGuardInstalled: globalThis.fetch === ctx.guardedFetch, externalFetchAttempts: ctx.externalAttempts.length, ownedLoopbackOnly: true }); } }, { id: "secret_scan", run: async () => { - const findings = await scanSecrets({ runRoot: ctx.run.root, forbiddenValues: ctx.forbiddenValues }); assert(findings.length === 0, "secret canary found outside exclusion"); - return await log("secret-scan", { scanned: true, excluded: "fixture-secrets", findings: [] }); + const findings = await scanSecrets({ runRoot: ctx.run.root, forbiddenValues: ctx.forbiddenValues }); + assert(findings.length === 0, "secret canary found outside exclusion"); + return await log("secret-scan", { scanned: true, gitEnumerationFailClosed: true, excluded: "fixture-secrets", findings: [] }); } }, { id: "cleanup_confinement", run: async () => { - const fakeRepo = join(ctx.run.root, "fixtures", "cleanup-repository"); await mkdir(join(fakeRepo, ".artifacts", "p1-integration"), { recursive: true }); - const synthetic = await createOwnedRun({ repositoryRoot: fakeRepo }); const sibling = join(fakeRepo, ".artifacts", "p1-integration", `p1-${"e".repeat(32)}`); await mkdir(sibling); await writeFile(join(sibling, "sentinel"), "foreign"); + const fakeRepo = join(ctx.run.root, "installation", "runtime", "cleanup-test"); + await mkdir(join(fakeRepo, ".artifacts", "p1-integration"), { recursive: true }); + const synthetic = await createOwnedRun({ repositoryRoot: fakeRepo }); + const sibling = join(fakeRepo, ".artifacts", "p1-integration", `p1-${"e".repeat(32)}`); + await mkdir(sibling); await writeFile(join(sibling, "sentinel"), "foreign"); await cleanupOwnedRun({ repositoryRoot: fakeRepo, runRoot: synthetic.root, expectedNonce: synthetic.nonce }); assert(await readFile(join(sibling, "sentinel"), "utf8") === "foreign", "cleanup removed sibling"); - return await log("cleanup-confinement", { ownedRemoved: true, siblingPreserved: true }); + await rm(fakeRepo, { recursive: true }); + assert(!existsSync(fakeRepo), "cleanup test resource remained"); + return await log("cleanup-confinement", { ownedRemoved: true, siblingPreservedDuringAssertion: true, testResourceRemoved: true }); } }, ]; } + async function assertRejectsCode(fn, code) { try { await fn(); } catch (error) { if (error?.code === code) return; throw error; } throw new Error(`expected ${code}`); } -export async function runIntegration({ repositoryRoot = defaultRepositoryRoot, keep = false, env = process.env, failAt = env.P1_ACCEPTANCE_FAIL_AT, checks } = {}) { +function replaceProcessEnvironment(values) { + for (const key of Object.keys(process.env)) delete process.env[key]; + Object.assign(process.env, values); +} +function failedCheck(id, startedAt, error) { + return { id, status: "FAIL", startedAt, finishedAt: nowIso(), commands: [], artifacts: [], error }; +} +function completeFailedResults(results, firstError = "Acceptance setup failed safely.") { + const completed = [...results]; + for (let index = completed.length; index < CHECK_IDS.length; index += 1) { + completed.push(failedCheck(CHECK_IDS[index], nowIso(), index === 0 ? firstError : "Not executed after earlier failure.")); + } + return completed; +} + +export async function runIntegration({ repositoryRoot = defaultRepositoryRoot, keep = false, env = process.env, failAt = env.P1_ACCEPTANCE_FAIL_AT, checks, setup = setupContext, announce } = {}) { const savedEnv = { ...process.env }; let run; let ctx; let results = []; let fatal; try { run = await createOwnedRun({ repositoryRoot }); - if (checks === undefined) { ctx = await setupContext(run, repositoryRoot, env); checks = productionChecks(ctx); } + commandEventSink = []; + if (checks === undefined) { + ctx = await setup(run, repositoryRoot, env); + if (!ctx) throw new Error("acceptance setup returned no context"); + replaceProcessEnvironment(ctx.env); + checks = productionChecks(ctx); + } results = await executeChecks({ checks, failAt }); } catch (error) { fatal = error; - if (run && results.length === 0) results = [{ id: "preflight", status: "FAIL", startedAt: run.startedAt, finishedAt: nowIso(), commands: [], artifacts: [], error: "Acceptance setup failed safely." }]; + if (run) results = completeFailedResults(results); } finally { if (ctx?.app) { await ctx.app.close().catch(() => {}); await writeOwnership(run, { ...run.listener, state: "closed" }).catch(() => {}); } - for (const key of Object.keys(process.env)) if (!(key in savedEnv)) delete process.env[key]; - Object.assign(process.env, savedEnv); + if (ctx?.originalFetch) globalThis.fetch = ctx.originalFetch; + replaceProcessEnvironment(savedEnv); } if (!run) throw fatal; - const success = !fatal && results.length === checks.length && results.every(({ status }) => status === "PASS"); + results = completeFailedResults(results); + const success = !fatal && results.every(({ status }) => status === "PASS"); const report = { schemaVersion: 1, runId: run.runId, startedAt: run.startedAt, finishedAt: nowIso(), command: `p1-acceptance integration${keep ? " --keep" : ""}`, overall: success ? "PASS" : "FAIL", checks: results, }; validateReport(report); - let jsonBytes = Buffer.from(`${JSON.stringify(report, null, 2)}\n`); let mdBytes = Buffer.from(renderReportMarkdown(report)); + let jsonBytes = Buffer.from(`${JSON.stringify(report, null, 2)} +`); let mdBytes = Buffer.from(renderReportMarkdown(report)); if (ctx?.forbiddenValues) { - const findings = await scanSecrets({ runRoot: run.root, forbiddenValues: ctx.forbiddenValues, virtualFiles: [{ path: "report.json", bytes: jsonBytes }, { path: "report.md", bytes: mdBytes }] }); - if (findings.length) { - report.overall = "FAIL"; - const secret = report.checks.find(({ id }) => id === "secret_scan"); if (secret) secret.status = "FAIL"; - else report.checks.push({ id: "secret_scan", status: "FAIL", startedAt: nowIso(), finishedAt: nowIso(), commands: [], artifacts: [], error: "Secret scan found protected content." }); - jsonBytes = Buffer.from(`${JSON.stringify(report, null, 2)}\n`); mdBytes = Buffer.from(renderReportMarkdown(report)); + let scanFailed = false; + try { + const findings = await scanSecrets({ runRoot: run.root, forbiddenValues: ctx.forbiddenValues, virtualFiles: [{ path: "report.json", bytes: jsonBytes }, { path: "report.md", bytes: mdBytes }] }); + scanFailed = findings.length > 0; + } catch { scanFailed = true; } + if (scanFailed) { + const secret = report.checks.find(({ id }) => id === "secret_scan"); + secret.status = "FAIL"; + secret.commands = []; + secret.artifacts = []; + secret.error = "Secret scan failed closed."; + report.overall = deriveOverall(report.checks); + validateReport(report); + jsonBytes = Buffer.from(`${JSON.stringify(report, null, 2)} +`); mdBytes = Buffer.from(renderReportMarkdown(report)); } } + const commandEvents = commandEventSink ?? []; + commandEventSink = undefined; + let commandArtifact = await evidence(run, "logs/command-events.json", { eventCount: commandEvents.length, events: commandEvents }); + if (ctx?.forbiddenValues && containsAny(await readFile(join(run.root, commandArtifact.path)), ctx.forbiddenValues)) { + commandArtifact = await evidence(run, "logs/command-events.json", { eventCount: commandEvents.length, eventsRedactedAfterFailClosedScan: true }); + const secret = report.checks.find(({ id }) => id === "secret_scan"); + secret.status = "FAIL"; secret.commands = []; secret.artifacts = []; secret.error = "Secret scan failed closed."; + } + const evidenceCheck = report.checks.find(({ status }) => status === "PASS") ?? report.checks[0]; + evidenceCheck.artifacts.push(commandArtifact); + report.overall = deriveOverall(report.checks); + validateReport(report); + jsonBytes = Buffer.from(`${JSON.stringify(report, null, 2)} +`); mdBytes = Buffer.from(renderReportMarkdown(report)); + if (ctx?.forbiddenValues && (containsAny(jsonBytes, ctx.forbiddenValues) || containsAny(mdBytes, ctx.forbiddenValues))) { + const secret = report.checks.find(({ id }) => id === "secret_scan"); + secret.status = "FAIL"; secret.commands = []; secret.artifacts = []; secret.error = "Secret scan failed closed."; + report.overall = deriveOverall(report.checks); + validateReport(report); + jsonBytes = Buffer.from(`${JSON.stringify(report, null, 2)} +`); mdBytes = Buffer.from(renderReportMarkdown(report)); + } await atomicWrite(join(run.root, "report.json"), jsonBytes); await atomicWrite(join(run.root, "report.md"), mdBytes); const finalSuccess = report.overall === "PASS"; + if (announce) await announce({ report, runRoot: run.root, keep }); const removed = await finalizeOwnedRun({ run, success: finalSuccess, keep }); return { exitCode: finalSuccess ? 0 : 1, runRoot: run.root, retained: !removed, report }; } @@ -687,8 +921,14 @@ export async function main(argv = process.argv.slice(2), env = process.env) { console.error("usage: p1-acceptance integration [--keep]"); return 2; } try { - const result = await runIntegration({ repositoryRoot: defaultRepositoryRoot, keep: argv.includes("--keep"), env }); - if (result.retained) console.log(result.runRoot); + const result = await runIntegration({ + repositoryRoot: defaultRepositoryRoot, keep: argv.includes("--keep"), env, + announce: async ({ report, runRoot, keep }) => { + console.log(`automated integration: ${report.overall}`); + console.log("manual acceptance: PENDING"); + if (keep || report.overall !== "PASS") console.log(runRoot); + }, + }); return result.exitCode; } catch (error) { console.error("P1 acceptance failed before owning a reportable run."); return 1; diff --git a/backend/scripts/p1-acceptance.test.mjs b/backend/scripts/p1-acceptance.test.mjs index 253f2545..cdaf1479 100644 --- a/backend/scripts/p1-acceptance.test.mjs +++ b/backend/scripts/p1-acceptance.test.mjs @@ -6,14 +6,20 @@ import { import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; import test from "node:test"; import { canonicalIntegrationBase, + CHECK_IDS, + buildSafeEnvironment, + installExternalFetchGuard, + negativeRequestEvidence, cleanupOwnedRun, createOwnedRun, deriveOverall, executeChecks, + exportArchiveEvidencePath, readAndValidateOwnership, runCommand, runIntegration, @@ -104,11 +110,14 @@ test("cleanup atomically removes one owned root and preserves siblings", async ( assert.equal(await readFile(join(sibling, "sentinel"), "utf8"), "foreign"); }); -function validReport(checks = [{ - id: "preflight", status: "PASS", startedAt: "2026-08-09T00:00:00.000Z", - finishedAt: "2026-08-09T00:00:01.000Z", commands: ["git"], - artifacts: [{ path: "logs/preflight.json", sha256: "a".repeat(64) }], -}]) { +function resultFor(id) { + return { + id, status: "PASS", startedAt: "2026-08-09T00:00:00.000Z", + finishedAt: "2026-08-09T00:00:01.000Z", commands: ["git"], + artifacts: [{ path: `logs/${id}.json`, sha256: "a".repeat(64) }], + }; +} +function validReport(checks = CHECK_IDS.map(resultFor)) { return { schemaVersion: 1, runId: `p1-${"d".repeat(32)}`, startedAt: "2026-08-09T00:00:00.000Z", finishedAt: "2026-08-09T00:00:02.000Z", command: "p1-acceptance integration --keep", @@ -133,30 +142,53 @@ test("report validation enforces uniqueness, derivation, safe evidence, hashes, } }); -test("injected failure executes once, retains diagnostics, and returns nonzero", async () => { +function exactScenarios(run = async () => ({ commands: [], artifacts: [] })) { + return CHECK_IDS.map((id) => ({ id, run: () => run(id) })); +} + +test("injected failure executes once, retains a complete ordered diagnostic report, and returns nonzero", async () => { const repositoryRoot = await fakeRepository(); - let calls = 0; + const calls = []; + const failAt = CHECK_IDS[3]; const result = await runIntegration({ - repositoryRoot, keep: false, failAt: "sample", - checks: [{ id: "sample", run: async () => { calls += 1; return { commands: [], artifacts: [] }; } }], + repositoryRoot, keep: false, failAt, + checks: exactScenarios(async (id) => { calls.push(id); return { commands: [], artifacts: [] }; }), }); assert.equal(result.exitCode, 1); - assert.equal(calls, 1); + assert.deepEqual(calls, CHECK_IDS.slice(0, 4)); assert.equal((await lstat(result.runRoot)).isDirectory(), true); const report = JSON.parse(await readFile(join(result.runRoot, "report.json"), "utf8")); - assert.equal(report.checks.filter((check) => check.status === "FAIL").length, 1); - assert.equal(report.checks[0].id, "sample"); + assert.deepEqual(report.checks.map(({ id }) => id), CHECK_IDS); + assert.equal(report.checks.filter((check) => check.status === "FAIL").length, CHECK_IDS.length - 3); + assert.equal(report.checks[3].error, "Acceptance scenario failed safely."); + assert.equal(report.checks[4].error, "Not executed after earlier failure."); }); -test("executeChecks never repeats a scenario", async () => { +test("executeChecks never repeats or executes after first failure but emits the exact check set", async () => { const calls = new Map(); const result = await executeChecks({ - checks: ["one", "two"].map((id) => ({ id, run: async () => { calls.set(id, (calls.get(id) ?? 0) + 1); return {}; } })), - failAt: "two", + checks: exactScenarios(async (id) => { calls.set(id, (calls.get(id) ?? 0) + 1); return {}; }), + failAt: CHECK_IDS[1], }); - assert.equal(result.length, 2); - assert.deepEqual(Object.fromEntries(calls), { one: 1, two: 1 }); + assert.deepEqual(result.map(({ id }) => id), CHECK_IDS); + assert.deepEqual(Object.fromEntries(calls), Object.fromEntries(CHECK_IDS.slice(0, 2).map((id) => [id, 1]))); assert.equal(result[1].status, "FAIL"); + assert(result.slice(2).every(({ status, error }) => status === "FAIL" && error === "Not executed after earlier failure.")); + assert.throws(() => validateReport(validReport(CHECK_IDS.slice(0, -1).map(resultFor)))); + await assert.rejects(executeChecks({ checks: exactScenarios().reverse() })); +}); + +test("owned setup failure still writes one safe result for every exact check", async () => { + const repositoryRoot = await fakeRepository(); + const result = await runIntegration({ + repositoryRoot, keep: false, + setup: async () => { throw new Error("fixture setup raw failure"); }, + }); + assert.equal(result.exitCode, 1); + const report = JSON.parse(await readFile(join(result.runRoot, "report.json"), "utf8")); + assert.deepEqual(report.checks.map(({ id }) => id), CHECK_IDS); + assert.equal(report.checks[0].error, "Acceptance setup failed safely."); + assert(report.checks.slice(1).every(({ error }) => error === "Not executed after earlier failure.")); }); test("scalar fixture secret files contain no harness-invalid whitespace", () => { @@ -204,11 +236,11 @@ test("secret scanner examines reachable Git blobs, not just loose file bytes", a test("successful lifecycle honors keep and cleanup", async () => { const repositoryRoot = await fakeRepository(); - const check = [{ id: "sample", run: async () => ({ commands: [], artifacts: [] }) }]; - const kept = await runIntegration({ repositoryRoot, keep: true, checks: check }); + const checks = exactScenarios(); + const kept = await runIntegration({ repositoryRoot, keep: true, checks }); assert.equal(kept.exitCode, 0); assert.equal((await lstat(kept.runRoot)).isDirectory(), true); - const cleaned = await runIntegration({ repositoryRoot, keep: false, checks: check }); + const cleaned = await runIntegration({ repositoryRoot, keep: false, checks }); assert.equal(cleaned.exitCode, 0); await assert.rejects(lstat(cleaned.runRoot)); }); @@ -226,3 +258,65 @@ test("command helper accepts only executable plus separate argv", async () => { assert.equal(result.stdout, "literal;not-a-shell"); assert.equal(result.code, 0); }); + + +test("safe environment rejects ambient THT and keeps only strict process allowlist plus fixture values", () => { + const safe = buildSafeEnvironment({ + ambient: { PATH: "/safe/bin", HOME: "/home/test", LANG: "C", THT_SECRETS_FILE: "/real/secrets", AWS_SECRET_ACCESS_KEY: "real" }, + fixture: { THT_BIN: "/fixture/tht", THT_WORKSPACE_SECRET_ROOTS: "/fixture/secrets" }, + }); + assert.deepEqual(safe, { + PATH: "/safe/bin", HOME: "/home/test", LANG: "C", + THT_BIN: "/fixture/tht", THT_WORKSPACE_SECRET_ROOTS: "/fixture/secrets", + }); +}); + +test("secret scan fails closed when Git enumeration fails", async () => { + const repositoryRoot = await fakeRepository(); + const run = await createOwnedRun({ repositoryRoot }); + await mkdir(join(run.root, "remote.git")); + await assert.rejects(scanSecrets({ runRoot: run.root, forbiddenValues: ["CANARY-value-123"] }), /Git secret scan failed closed/); +}); + + +test("negative request evidence persists only case label and expected input field", () => { + const value = negativeRequestEvidence("credential-field", "evidence.source.password"); + assert.deepEqual(value, { case: "credential-field", expectedInputField: "evidence.source.password" }); + assert.equal(JSON.stringify(value).includes("body"), false); +}); + +test("external fetch guard permits only the owned loopback API and records external attempts", async () => { + const called = []; + const guard = installExternalFetchGuard("http://127.0.0.1:12345", async (url) => { called.push(String(url)); return { ok: true }; }); + await guard.fetch("http://127.0.0.1:12345/workspaces"); + await assert.rejects(guard.fetch("https://evidence.example.test/guide.md"), /external fetch prohibited/); + await assert.rejects(guard.fetch("http://127.0.0.1:9999/health"), /external fetch prohibited/); + assert.deepEqual(called, ["http://127.0.0.1:12345/workspaces"]); + assert.equal(guard.externalAttempts.length, 2); +}); + + +test("export archive evidence path matches the persisted binary request id", () => { + assert.equal(exportArchiveEvidencePath("export-p1-filesystem"), "exports/raw/export-p1-filesystem.zip"); +}); + + +test("announce callback observes PASS and manual pending before non-keep cleanup", async () => { + const repositoryRoot = await fakeRepository(); + let observed; + const result = await runIntegration({ + repositoryRoot, keep: false, checks: exactScenarios(), + announce: async ({ report, runRoot }) => { + observed = { overall: report.overall, manual: "PENDING", rootExists: (await lstat(runRoot)).isDirectory() }; + }, + }); + assert.deepEqual(observed, { overall: "PASS", manual: "PENDING", rootExists: true }); + assert.equal(result.retained, false); +}); + +test("public wrapper replaces ambient environment before invoking the runner", async () => { + const wrapper = await readFile(join(dirname(fileURLToPath(import.meta.url)), "..", "..", "scripts", "p1-acceptance.sh"), "utf8"); + assert.match(wrapper, /safe_env=\(env -i/); + assert.match(wrapper, /P1_ACCEPTANCE_FAIL_AT/); + assert.doesNotMatch(wrapper, /export THT_BIN/); +}); diff --git a/scripts/p1-acceptance.sh b/scripts/p1-acceptance.sh index de2e0aa1..b475999b 100755 --- a/scripts/p1-acceptance.sh +++ b/scripts/p1-acceptance.sh @@ -8,10 +8,14 @@ fi for command in node npm git; do command -v "$command" >/dev/null || { printf 'missing command: %s\n' "$command" >&2; exit 127; }; done THT_BIN="${THT_BIN:-$repo_root/harness/.venv/bin/tht}" [[ "$THT_BIN" = /* && -x "$THT_BIN" ]] || { printf 'THT_BIN must be an absolute executable path\n' >&2; exit 127; } -export THT_BIN npm --prefix "$repo_root/backend" run build +safe_env=(env -i "PATH=$PATH" "HOME=${HOME:-/nonexistent}" "LANG=${LANG:-C}" "THT_BIN=$THT_BIN") +for name in LC_ALL TMPDIR TZ; do + [[ -n "${!name:-}" ]] && safe_env+=("$name=${!name}") +done +[[ -n "${P1_ACCEPTANCE_FAIL_AT:-}" ]] && safe_env+=("P1_ACCEPTANCE_FAIL_AT=$P1_ACCEPTANCE_FAIL_AT") set +e -node "$repo_root/backend/scripts/p1-acceptance.mjs" "$@" +"${safe_env[@]}" node "$repo_root/backend/scripts/p1-acceptance.mjs" "$@" status=$? set -e exit "$status" diff --git a/scripts/test-p1-acceptance.sh b/scripts/test-p1-acceptance.sh index fe64ffb0..28d1d575 100755 --- a/scripts/test-p1-acceptance.sh +++ b/scripts/test-p1-acceptance.sh @@ -1,5 +1,8 @@ #!/usr/bin/env bash set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +bash -n "$repo_root/scripts/p1-acceptance.sh" "$repo_root/scripts/test-p1-acceptance.sh" +node --check "$repo_root/backend/scripts/p1-acceptance.mjs" +node --check "$repo_root/backend/scripts/p1-acceptance.test.mjs" npm --prefix "$repo_root/backend" run build node --test "$repo_root/backend/scripts/p1-acceptance.test.mjs"