diff --git a/README.md b/README.md index 0214ab5..2f8c276 100644 --- a/README.md +++ b/README.md @@ -280,3 +280,23 @@ The export budget counts the formatted JSON bytes actually downloaded, including Saved-file reads use nonblocking descriptors and validate regular-file type and size before reading, so a named pipe cannot hold the reader open. Invalid UTF-8 is rejected rather than silently replacing evidence bytes. History summaries and run listings use the same bounded saved-file reader, including when a malformed entry is skipped as unavailable. + +Replay checks actual UTF-8 bytes for both files and pipes, stops oversized streams as soon as the budget is crossed, and rejects nonregular files, invalid encoding, and malformed JSON without echoing document contents. + +Exporting with --out creates a new handoff atomically and refuses to replace an existing file. Pass --overwrite with --out to explicitly replace it. --json supports machine-readable export success and error output. + +All saved-case commands (runs, cases, compare, inspect, export, and prune) accept --root output-directory. Use a copied case store without moving it into the checkout or bootstrapping components; paths with spaces are supported when quoted. Prune still requires an explicit --keep and supports --dry-run. + +Human-readable cases output includes scanned count, each unavailable case ID, and next_cursor. An entirely damaged page still gives its continuation cursor, so older readable cases remain reachable. Continue with --before and the same root/page options. + +Use aas compare left-id right-id --root output-directory --markdown to print the GUI comparison handoff from the terminal. All formats return exit 1 when cases cannot be compared; JSON also sets ok to false. Comparable differences remain exit 0 and never establish causation. + +Use aas verify run-id --root output-directory --json to verify a saved case directly. It performs the same receipt and review checks as imported replay, with the same pinned component requirements, and never executes an action or modifies the case store. Refused or simulation-only cases without same-case evidence return unavailable and exit 1. + +Use aas latest --root output-directory to print the ID named by the latest complete-bundle pointer, or add --json for machine output. It validates the referenced saved bundle and fails closed for missing, inconsistent, or unreadable pointers/cases. It never guesses by sorting directory names. + +Case-review Markdown now carries all five requested settings and a policy-failure summary with rule identity, path, kind, and reason code. It includes at most 50 failed rules with bounded text fields and an explicit omitted count; raw response values remain in the original artifact only. Missing rule records never imply a policy pass. + +Case reviews classify verification readiness as unavailable, conflicting, or ready and explain the next read-only step. Ready means that recorded action identity and recomputed digest agree; it does not mean receipts are verified. Missing rail evidence, missing reviews, and conflicting bindings receive separate recovery guidance. + +Terminal case history supports --domain refund|inventory|unknown, --outcome settled|compensated|none, and --search text (case-insensitive, 1 to 200 characters). Filters combine and inspect summary metadata only. They apply within each bounded page: an empty filtered page can still have next_cursor, and callers must keep the same filters when continuing. diff --git a/bin/aas.mjs b/bin/aas.mjs index ad547f7..8bd58a6 100644 --- a/bin/aas.mjs +++ b/bin/aas.mjs @@ -18,7 +18,7 @@ import { mkdtempSync, readFileSync, readdirSync, - renameSync, + renameSync, linkSync, rmSync, unlinkSync, writeFileSync, @@ -240,17 +240,21 @@ export function helpText() { Usage: aas demo [--response pass|fail] [--fault none|duplicate] [--dispute] [--prove simulate|rail] [--domain refund|inventory] [--json] - aas export [--out ] + aas export [--out ] [--overwrite] [--json] aas replay [--json] + aas latest [--root output-dir] [--json] + aas verify [--root output-dir] [--json] aas inspect [--root output-dir] [--json|--markdown] - aas cases [--before run-id] [--limit 1..50] [--json] - aas compare [--json] + aas cases [--root output-dir] [--before run-id] [--limit 1..50] [--domain refund|inventory|unknown] [--outcome settled|compensated|none] [--search text] [--json] + aas compare [--root output-dir] [--json|--markdown] aas help Commands: demo Run decide, act, and prove and persist one run bundle export Print one run bundle as portable JSON (or write it with --out) replay Re-verify an exported bundle offline without rerunning the action + verify Re-verify one saved case without exporting or rerunning actions + latest Print the latest complete saved run identity runs List persisted runs newest-first cases List bounded case summaries (outcome, policy, review, digest) compare Compare two cases and classify identical, different, or not comparable @@ -263,6 +267,7 @@ Options: --prove simulate|rail Prove path: canned operator simulation (default) or review of the same-case rail bundle --domain refund|inventory Synthetic action domain (default: refund) + --root output-dir Select saved-case storage for inspect/runs/cases/compare/export/prune --json Print the run report as JSON -h, --help Show this help @@ -1129,9 +1134,11 @@ export function listRuns({ outputRoot = DEFAULT_PATHS.outputRoot, limit = Number function readLatestRunId(outputRoot) { try { - const pointer = JSON.parse(readFileSync(join(outputRoot, "latest.json"), "utf8")); + const pointer = readBoundedCaseJson(join(outputRoot, "latest.json")); const runId = pointer?.run_id; - return typeof runId === "string" && RUN_ID_PATTERN.test(runId) ? runId : null; + if (!isValidRunId(runId) || pointer.manifest !== `runs/${runId}/manifest.json`) return null; + if (pointer.schema_version !== undefined && pointer.schema_version !== "agent-action-stack.latest/v1") return null; + return runId; } catch { return null; } @@ -1332,6 +1339,8 @@ export function writeAtomicFile( target, data, { + replace = true, + link = linkSync, writeFile = writeFileSync, rename = renameSync, unlink = unlinkSync, @@ -1344,7 +1353,8 @@ export function writeAtomicFile( try { writeFile(temporary, data, { encoding: "utf8", flag: "wx" }); written = true; - rename(temporary, target); + if (replace) rename(temporary, target); + else link(temporary, target); } catch (error) { throw error; } finally { @@ -1685,37 +1695,51 @@ function writeCliError(error, { asJson = false, usage = false } = {}) { } async function readReplayInput(source, { stdin = process.stdin } = {}) { + let bytes; if (source === "-") { if (stdin.isTTY) throw new UsageError("replay reads stdin only from a pipe; pass a bundle file instead"); const chunks = []; + let size = 0; for await (const chunk of stdin) { - chunks.push(typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk); - } - const text = Buffer.concat(chunks).toString("utf8"); - if (text.length > CHILD_JSON_LIMIT) { - throw new Error(`replay bundle exceeds the ${CHILD_JSON_LIMIT} byte limit`); + const buffer = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk; + size += buffer.length; + if (size > CHILD_JSON_LIMIT) throw new Error(`replay bundle exceeds the ${CHILD_JSON_LIMIT} byte limit`); + chunks.push(buffer); } - if (text.trim() === "") throw new Error("replay received an empty bundle document"); - return text; + bytes = Buffer.concat(chunks); + } else { + let fd; + try { + fd = openSync(source, constants.O_RDONLY | constants.O_NONBLOCK); + const stat = fstatSync(fd); + if (!stat.isFile() || stat.size > CHILD_JSON_LIMIT) throw new Error("replay file exceeds the byte limit or is not a regular file"); + bytes = Buffer.alloc(CHILD_JSON_LIMIT + 1); + let size = 0; + while (size < bytes.length) { + const read = readSync(fd, bytes, size, bytes.length - size, null); + if (!read) break; + size += read; + } + if (size > CHILD_JSON_LIMIT) throw new Error("replay bundle exceeds the byte limit"); + bytes = bytes.subarray(0, size); + } catch (error) { + if (error.code) throw new Error("replay cannot read bundle file"); + throw error; + } finally { if (fd !== undefined) closeSync(fd); } } let text; - try { - text = readFileSync(source, "utf8"); - } catch { - throw new Error(`replay cannot read bundle file: ${source}`); - } - if (text.length > CHILD_JSON_LIMIT) { - throw new Error(`replay bundle exceeds the ${CHILD_JSON_LIMIT} byte limit`); - } - if (text.trim() === "") throw new Error("replay received an empty bundle document"); - return text; + try { text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); } + catch { throw new Error("replay requires valid UTF-8"); } + if (!text.trim()) throw new Error("replay received an empty bundle document"); + try { return JSON.parse(text); } + catch { throw new Error("replay bundle contains invalid JSON"); } } -function runRunsCommand(args, { asJson } = {}) { +function runRunsCommand(args, { asJson, outputRoot = DEFAULT_PATHS.outputRoot } = {}) { if (args.some((token) => token !== "--json")) { throw new UsageError(`Unsupported runs option (expected [--json])`); } - const runs = listRuns({}); + const runs = listRuns({ outputRoot }); if (asJson) { process.stdout.write(`${JSON.stringify({ ok: true, runs }, null, 2)}\n`); } else if (runs.length === 0) { @@ -1730,17 +1754,17 @@ function runRunsCommand(args, { asJson } = {}) { process.exitCode = 0; } -async function runCasesCommand(args, { asJson } = {}) { +async function runCasesCommand(args, { asJson, outputRoot = DEFAULT_PATHS.outputRoot } = {}) { const { listCasePage, parseCasePageArgs } = await import("./case-review.mjs"); let pageOptions; try { pageOptions = parseCasePageArgs(args); } catch (error) { throw new UsageError(error.message); } if ((pageOptions.limit !== undefined && (pageOptions.limit < 1 || pageOptions.limit > 50)) || (pageOptions.before !== undefined && (!isValidRunId(pageOptions.before) || pageOptions.before.length > 200))) throw new UsageError("Invalid history page options."); - const page = listCasePage(pageOptions); + const page = listCasePage({ ...pageOptions, outputRoot }); const cases = page.cases; if (asJson) { process.stdout.write(`${JSON.stringify({ ok: true, ...page }, null, 2)}\n`); } else if (cases.length === 0) { - process.stdout.write("no cases yet\n"); + process.stdout.write(page.scanned ? "no readable cases in this page\n" : "no cases in this page\n"); } else { for (const entry of cases) { process.stdout.write( @@ -1749,12 +1773,19 @@ async function runCasesCommand(args, { asJson } = {}) { ); } } + if (!asJson) { + process.stdout.write(`scanned: ${page.scanned}\n`); + for (const runId of page.unavailable) process.stdout.write(`unavailable: ${runId}\n`); + process.stdout.write(`next_cursor: ${page.next_cursor ?? "none"}\n`); + if (page.next_cursor) process.stdout.write(`Continue with --before ${page.next_cursor} and the same root and page options.\n`); + } process.exitCode = 0; } function printComparison(result, asJson) { + process.exitCode = result.classification === "not-comparable" ? 1 : 0; if (asJson) { - process.stdout.write(`${JSON.stringify({ ok: true, ...result }, null, 2)}\n`); + process.stdout.write(`${JSON.stringify({ ok: result.classification !== "not-comparable", ...result }, null, 2)}\n`); return; } process.stdout.write(`comparison: ${result.classification}\n`); @@ -1774,17 +1805,23 @@ function printComparison(result, asJson) { process.exitCode = result.classification === "not-comparable" ? 1 : 0; } -function runCompareCommand(args, { asJson } = {}) { - const ids = args.filter((token) => token !== "--json"); +async function runCompareCommand(args, { asJson, outputRoot = DEFAULT_PATHS.outputRoot } = {}) { + const formats = args.filter(token => token === "--json" || token === "--markdown"); + if (formats.length > 1) throw new UsageError("Choose one comparison output format"); + const ids = args.filter((token) => token !== "--json" && token !== "--markdown"); if (ids.some((token) => typeof token === "string" && token.startsWith("-"))) { throw new UsageError(`Unsupported compare option: ${args.find((token) => String(token).startsWith("-"))}`); } - if (ids.length !== 2) throw new UsageError("Usage: aas compare [--json]"); - const result = compareRuns(ids[0], ids[1], {}); - printComparison(result, asJson); + if (ids.length !== 2) throw new UsageError("Usage: aas compare [--root output-dir] [--json|--markdown]"); + const result = compareRuns(ids[0], ids[1], { outputRoot }); + if (formats[0] === "--markdown") { + const { renderComparisonMarkdown } = await import("./case-review.mjs"); + process.stdout.write(renderComparisonMarkdown(result)); + process.exitCode = result.classification === "not-comparable" ? 1 : 0; + } else printComparison(result, asJson); } -function runPruneCommand(args, { asJson } = {}) { +function runPruneCommand(args, { asJson, outputRoot = DEFAULT_PATHS.outputRoot } = {}) { let keep = null; let dryRun = false; for (let index = 0; index < args.length; index += 1) { @@ -1803,7 +1840,7 @@ function runPruneCommand(args, { asJson } = {}) { } } if (keep === null) throw new UsageError("Usage: aas prune --keep [--dry-run] [--json]"); - const result = pruneRuns({ keep, dryRun }); + const result = pruneRuns({ keep, dryRun, outputRoot }); if (asJson) { process.stdout.write(`${JSON.stringify({ ok: true, ...result }, null, 2)}\n`); } else if (dryRun) { @@ -1836,12 +1873,18 @@ function printReplayReport(result, asJson) { process.stdout.write(`${lines.join("\n")}\n`); } -function runExportCommand(args, { asJson } = {}) { +function runExportCommand(args, { asJson, outputRoot = DEFAULT_PATHS.outputRoot } = {}) { let runId = null; let out = null; + let overwrite = false; for (let index = 0; index < args.length; index += 1) { const token = args[index]; - if (token === "--out") { + if (token === "--overwrite") { + if (overwrite) throw new UsageError("Duplicate export option: --overwrite"); + overwrite = true; + } else if (token === "--json") { + continue; + } else if (token === "--out") { const value = args[index + 1]; if (value === undefined || value.startsWith("-")) throw new UsageError("Missing value for export option: --out"); if (out !== null) throw new UsageError("Duplicate export option: --out"); @@ -1857,10 +1900,11 @@ function runExportCommand(args, { asJson } = {}) { runId = token; } } - if (runId === null) throw new UsageError("Usage: aas export [--out ]"); + if (runId === null) throw new UsageError("Usage: aas export [--out ] [--overwrite] [--json]"); + if (overwrite && out === null) throw new UsageError("--overwrite requires --out"); let bundle; try { - bundle = exportRunBundle(runId, {}); + bundle = exportRunBundle(runId, { outputRoot }); } catch (error) { writeCliError(error, { asJson, usage: false }); process.exitCode = 1; @@ -1873,7 +1917,7 @@ function runExportCommand(args, { asJson } = {}) { return; } try { - writeAtomicFile(out, text, {}); + writeAtomicFile(out, text, { replace: overwrite }); } catch (error) { writeCliError(error, { asJson, usage: false }); process.exitCode = 1; @@ -1902,7 +1946,7 @@ async function runReplayCommand(args, { asJson, nodeVersion, stdin } = {}) { assertFullStackNodeVersion(nodeVersion === undefined ? {} : { version: nodeVersion }); let bundleDoc; try { - bundleDoc = JSON.parse(await readReplayInput(source, { stdin })); + bundleDoc = await readReplayInput(source, { stdin }); } catch (error) { if (error instanceof UsageError) throw error; writeCliError(error, { asJson: json || asJson, usage: false }); @@ -1921,6 +1965,18 @@ async function runReplayCommand(args, { asJson, nodeVersion, stdin } = {}) { process.exitCode = result.ok ? 0 : 1; } +function parseRootArgs(args) { + const remaining = []; + let outputRoot = DEFAULT_PATHS.outputRoot, seen = false; + for (let index = 0; index < args.length; index++) { + if (args[index] !== "--root") { remaining.push(args[index]); continue; } + if (seen || !args[index + 1]?.trim() || args[index + 1].startsWith("--")) throw new UsageError("--root requires one output directory path"); + seen = true; + outputRoot = args[++index]; + } + return { args: remaining, outputRoot }; +} + export async function main(argv = process.argv.slice(2), options = {}) { const command = argv[0] ?? "help"; const asJson = has(argv, "--json"); @@ -1929,6 +1985,36 @@ export async function main(argv = process.argv.slice(2), options = {}) { process.exitCode = 0; return; } + if (command === "latest") { + try { + const { args, outputRoot } = parseRootArgs(argv.slice(1)); + if (args.length > 1 || args.some(token => token !== "--json")) throw new UsageError("Usage: aas latest [--root output-dir] [--json]"); + const runId = readLatestRunId(outputRoot); + if (runId === null) throw new Error("Latest case pointer is missing or invalid"); + exportRunBundle(runId, {outputRoot}); + process.stdout.write(asJson ? JSON.stringify({ok:true,run_id:runId}) + "\n" : runId + "\n"); + process.exitCode = 0; + } catch (error) { + const usage = error instanceof UsageError; + writeCliError(error, {asJson,usage}); process.exitCode = usage ? 2 : 1; + } + return; + } + if (command === "verify") { + try { + const { args, outputRoot } = parseRootArgs(argv.slice(1)); + const ids = args.filter(token => token !== "--json"); + if (ids.length !== 1 || !isValidRunId(ids[0]) || args.filter(token => token === "--json").length > 1) throw new UsageError("Usage: aas verify [--root output-dir] [--json]"); + assertFullStackNodeVersion(options.nodeVersion === undefined ? {} : {version:options.nodeVersion}); + const result = replayBundle(exportRunBundle(ids[0], {outputRoot})); + printReplayReport(result, asJson); + process.exitCode = result.ok ? 0 : 1; + } catch (error) { + const usage = error instanceof UsageError; + writeCliError(error, {asJson, usage}); process.exitCode = usage ? 2 : 1; + } + return; + } if (command === "inspect") { try { const { parseInspectArgs, inspectCase, renderCaseMarkdown } = await import("./case-review.mjs"); @@ -1944,10 +2030,11 @@ export async function main(argv = process.argv.slice(2), options = {}) { } if (command === "runs" || command === "cases" || command === "compare" || command === "prune") { try { - if (command === "runs") runRunsCommand(argv.slice(1), { asJson }); - else if (command === "cases") await runCasesCommand(argv.slice(1), { asJson }); - else if (command === "compare") runCompareCommand(argv.slice(1), { asJson }); - else runPruneCommand(argv.slice(1), { asJson }); + const { args, outputRoot } = parseRootArgs(argv.slice(1)); + if (command === "runs") runRunsCommand(args, { asJson, outputRoot }); + else if (command === "cases") await runCasesCommand(args, { asJson, outputRoot }); + else if (command === "compare") await runCompareCommand(args, { asJson, outputRoot }); + else runPruneCommand(args, { asJson, outputRoot }); } catch (error) { const usage = error instanceof UsageError; writeCliError(error, { asJson, usage }); @@ -1957,7 +2044,10 @@ export async function main(argv = process.argv.slice(2), options = {}) { } if (command === "export" || command === "replay") { try { - if (command === "export") runExportCommand(argv.slice(1), { asJson }); + if (command === "export") { + const { args, outputRoot } = parseRootArgs(argv.slice(1)); + runExportCommand(args, { asJson, outputRoot }); + } else { await runReplayCommand(argv.slice(1), { asJson, diff --git a/bin/case-review.mjs b/bin/case-review.mjs index 563b9a7..4a99594 100644 --- a/bin/case-review.mjs +++ b/bin/case-review.mjs @@ -3,9 +3,12 @@ import { readdirSync } from "node:fs"; import { join } from "node:path"; import { DEFAULT_PATHS, UsageError, exportRunBundle, isValidRunId, summarizeRun } from "./aas.mjs"; -export function listCasePage({ outputRoot = DEFAULT_PATHS.outputRoot, before = null, limit = 25 } = {}) { +export function listCasePage({ outputRoot = DEFAULT_PATHS.outputRoot, before = null, limit = 25, domain = null, outcome = null, search = null } = {}) { if (!Number.isInteger(limit) || limit < 1 || limit > 50) throw new Error("History limit must be an integer from 1 to 50."); if (before !== null && (!isValidRunId(before) || before.length > 200)) throw new Error("Invalid history cursor."); + if (domain !== null && !['refund','inventory','unknown'].includes(domain)) throw new Error('Domain filter must be refund, inventory, or unknown.'); + if (outcome !== null && !['settled','compensated','none'].includes(outcome)) throw new Error('Outcome filter must be settled, compensated, or none.'); + if (search !== null && (typeof search !== 'string' || !search.trim() || search.length > 200)) throw new Error('Search must contain 1 to 200 characters.'); let entries; try { entries = readdirSync(join(outputRoot, "runs"), { withFileTypes: true }); } catch (error) { if (error.code === "ENOENT") return { cases: [], next_cursor: null, scanned: 0, unavailable: [] }; throw error; } @@ -14,7 +17,13 @@ export function listCasePage({ outputRoot = DEFAULT_PATHS.outputRoot, before = n const candidates = ids.slice(0, limit); const cases = [], unavailable = []; for (const runId of candidates) { - try { cases.push(summarizeRun(runId, { outputRoot })); } + try { + const summary = summarizeRun(runId, { outputRoot }); + if (domain !== null && (summary.domain ?? 'unknown') !== domain) continue; + if (outcome !== null && (summary.outcome ?? 'none') !== outcome) continue; + if (search !== null && !['run_id','domain','policy_id','action_id','outcome','review_verdict','evidence_digest'].some(key => typeof summary[key] === 'string' && summary[key].toLowerCase().includes(search.trim().toLowerCase()))) continue; + cases.push(summary); + } catch { unavailable.push(runId); } } return { cases, next_cursor: ids.length > limit ? candidates.at(-1) : null, scanned: candidates.length, unavailable }; @@ -25,8 +34,11 @@ export function parseCasePageArgs(args) { for (let i = 0; i < args.length; i++) { const key = args[i]; if (key === "--json") continue; - if (!["--before", "--limit"].includes(key) || i + 1 >= args.length || Object.hasOwn(result, key.slice(2))) throw new Error("Usage: aas cases [--before run-id] [--limit 1..50] [--json]"); + if (!["--before", "--limit", "--domain", "--outcome", "--search"].includes(key) || i + 1 >= args.length || Object.hasOwn(result, key.slice(2))) throw new Error("Usage: aas cases [--before run-id] [--limit 1..50] [--json]"); const value = args[++i]; + if (key === '--domain' && !['refund','inventory','unknown'].includes(value)) throw new Error('Invalid domain filter.'); + if (key === '--outcome' && !['settled','compensated','none'].includes(value)) throw new Error('Invalid outcome filter.'); + if (key === '--search' && (!value.trim() || value.length > 200)) throw new Error('Search must contain 1 to 200 characters.'); if (key === "--limit" && !/^[1-9][0-9]?$/.test(value)) throw new Error("Invalid history limit."); result[key.slice(2)] = key === "--limit" ? Number(value) : value; } @@ -38,11 +50,20 @@ export function inspectCase(runId, options = {}) { const report = bundle.report; const review = bundle.stages.prove?.result; const rail = bundle.stages.act?.rail_bundle; + const failedRules = (Array.isArray(bundle.stages.decide?.rule_results) ? bundle.stages.decide.rule_results : []).filter(rule => rule?.passed === false); + const policyFailures = { total: failedRules.length, omitted: Math.max(0, failedRules.length - 50), rules: failedRules.slice(0, 50).map(rule => Object.fromEntries(['rule_id','path','kind','reason_code'].map(key => [key, typeof rule[key] === 'string' ? rule[key].slice(0, 300) : null]))) }; const computed = rail && typeof rail === 'object' ? 'sha256:' + createHash('sha256').update(JSON.stringify(rail)).digest('hex') : null; + let readiness; + if (!rail || typeof rail !== 'object' || Array.isArray(rail)) readiness = {state:'unavailable',reason:'No same-case rail bundle was saved.',next_step:'Inspect stage records. A skipped action or simulation-only case has no same-case evidence to verify.'}; + else if (review?.verdict !== 'recorded') readiness = {state:'unavailable',reason:'No recorded same-case review was saved.',next_step:'Inspect the prove stage diagnostic. Existing evidence cannot be repaired by rerunning verification.'}; + else if (computed !== review.evidenceDigest || typeof bundle.stages.act?.action_id !== 'string' || review.actionId !== bundle.stages.act.action_id) readiness = {state:'conflicting',reason:'Recorded review identity or digest differs from the saved action evidence.',next_step:'Preserve the case and compare it with the original handoff; do not treat it as verified.'}; + else readiness = {state:'ready',reason:'The saved action identity and evidence digest agree. Receipts remain unverified.',next_step:'Run aas verify '+runId+' with the correct --root and installed pinned components.'}; return { + verification_readiness: readiness, schema_version: 'agent-action-stack.case-review/v1', run_id: runId, created_at: bundle.manifest.created_at ?? null, domain: report.domain ?? null, requested_options: report.requested_options ?? null, + policy_failures: policyFailures, stages: ['decide','act','prove'].map(name => ({ name, status: bundle.manifest.stages[name]?.status ?? 'unknown', reason: bundle.manifest.stages[name]?.reason ?? null, code: bundle.manifest.stages[name]?.code ?? null, artifact_available: Object.hasOwn(bundle.stages,name) })), policy_id: report.stages?.decide?.policy_id ?? null, action_id: bundle.stages.act?.action_id ?? null, outcome: report.stages?.act?.outcome ?? null, @@ -70,9 +91,21 @@ export function renderCaseMarkdown(review) { '- Run ID: '+markdownText(review.run_id), '- Created: '+markdownText(review.created_at), '- Domain: '+markdownText(review.domain), '- Policy: '+markdownText(review.policy_id), '- Action: '+markdownText(review.action_id), '- Outcome: '+markdownText(review.outcome), '- Recorded review verdict: '+markdownText(review.review_verdict), '', '## Stage record', '']; + const stageHeading = lines.splice(-2); + lines.push('## Requested settings', ''); + for (const key of ['response','domain','fault','prove','dispute']) { + const value = review.requested_options?.[key]; + lines.push('- '+key+': '+markdownText(typeof value === 'string' || typeof value === 'boolean' ? value : null)); + } + lines.push('', '## Policy failures', ''); + if (!review.policy_failures?.total) lines.push('No failed rule records are available; this alone does not prove a policy pass.'); + for (const rule of review.policy_failures?.rules ?? []) lines.push('- '+markdownText(rule.rule_id)+': '+markdownText(rule.path)+'; '+markdownText(rule.kind)+'; '+markdownText(rule.reason_code)); + if (review.policy_failures?.omitted) lines.push(review.policy_failures.omitted+' additional failures omitted; inspect the decide artifact for all records.'); + lines.push('', ...stageHeading); for(const stage of review.stages??[]) lines.push('- '+markdownText(stage.name)+': '+markdownText(stage.status)+'; artifact '+(stage.artifact_available?'present':'absent')+'; reason '+markdownText(stage.reason)+'; code '+markdownText(stage.code)); lines.push('', '## Evidence binding', '', '- Recorded digest: '+markdownText(review.recorded_evidence_digest), '- Recomputed digest: '+markdownText(review.recomputed_evidence_digest), '- Digests match: '+markdownText(review.digest_matches), '', '## Component revisions', ''); for(const entry of review.component_provenance??[]) lines.push('- '+markdownText(entry.name)+': '+markdownText(entry.commit)); + lines.push('', '## Verification next step', '', '- Readiness: '+markdownText(review.verification_readiness?.state), '- Reason: '+markdownText(review.verification_readiness?.reason), '- Next step: '+markdownText(review.verification_readiness?.next_step)); lines.push('', '## Limits', ''); for(const limit of review.limits??[]) lines.push('- '+markdownText(limit)); return lines.join('\n')+'\n'; diff --git a/examples/README.md b/examples/README.md index 57c3a95..ed30ad2 100644 --- a/examples/README.md +++ b/examples/README.md @@ -92,3 +92,5 @@ treats an observed effect as proof of external truth. explicitly; it never reports success for an unverified binding. - Legal effect is always `not-determined`. These examples make no claim of AP2/UCP compliance or of real-world reversibility. + +The review-handoff example uses the same bounded Python discovery as the CLI, including AAS_PYTHON and the Windows py launcher. An invalid explicit override fails before any component executes. diff --git a/examples/review-handoff.mjs b/examples/review-handoff.mjs index 0dc0d4a..42558f2 100644 --- a/examples/review-handoff.mjs +++ b/examples/review-handoff.mjs @@ -18,6 +18,7 @@ * when policy refuses, verification fails, or a binding mismatches. */ import { spawnSync } from "node:child_process"; +import { selectPython } from "../bin/aas.mjs"; import { createHash } from "node:crypto"; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -68,17 +69,6 @@ function parseArgs(argv) { return options; } -function resolvePython() { - for (const bin of ["python3", "python"]) { - const probe = spawnSync(bin, ["-c", "import sys; print(sys.version_info[0] * 100 + sys.version_info[1])"], { - encoding: "utf8", - shell: false, - }); - if (probe.error || probe.status !== 0) continue; - if (Number.parseInt(probe.stdout.trim(), 10) >= 311) return bin; - } - fail("decide needs Python 3.11+ on PATH as python3 (the testbench declares requires-python >= 3.11)"); -} function run(bin, args, { cwd, env }) { const result = spawnSync(bin, args, { cwd, env, encoding: "utf8", shell: false }); @@ -103,13 +93,14 @@ function note(text) { function main() { const { domain, response, fault } = parseArgs(process.argv.slice(2)); const fixture = DOMAIN_FIXTURES[domain]; - const python = resolvePython(); + const python = selectPython(); + if (!python) fail("decide needs Python 3.11+; set AAS_PYTHON to a working interpreter."); const scratch = mkdtempSync(join(tmpdir(), "aas-integrator-")); try { note(`domain: ${domain} (${fixture.action})`); // 1. Policy evaluation. - const decided = run(python, [ + const decided = run(python.bin, [...python.prefix, "-m", "constitutional_agent_testbench.cli", "evaluate", diff --git a/test/stack.test.mjs b/test/stack.test.mjs index 703671b..fd09591 100644 --- a/test/stack.test.mjs +++ b/test/stack.test.mjs @@ -1673,3 +1673,139 @@ test('saved manifests reject missing records arrays and invalid artifact fields assert.equal(Object.keys(exportRunBundle('complete-stages',{outputRoot}).stages).length,3); assert.equal(full.manifest.stages.prove.artifact,'stages/prove.json'); }); + +test('replay bounds UTF-8 bytes and redacts malformed JSON for files and streams', async () => { + const root = tempRoot(), path = join(root, 'replay.json'); + const sources = [Buffer.from(JSON.stringify({pad: 'é'.repeat(CHILD_JSON_LIMIT / 2)})), Buffer.from('{"SYNTHETIC_PARSE_MARKER":invalid}'), Buffer.from([0xff])]; + for (const input of sources) { + writeFileSync(path, input); + for (const source of [path, '-']) { + const result = await captureMain(['replay', source, '--json'], {stdin: Readable.from([input])}); + assert.equal(result.exitCode, 1); + assert.doesNotMatch(result.stderr, /SYNTHETIC_PARSE_MARKER/); + assert.match(result.stderr, input.length > CHILD_JSON_LIMIT ? /byte limit/ : /invalid JSON|UTF-8/); + } + } + let consumed = 0; + async function* oversized() { consumed++; yield Buffer.alloc(CHILD_JSON_LIMIT + 1); consumed++; yield Buffer.from('{}'); } + const bounded = await captureMain(['replay', '-', '--json'], {stdin: oversized()}); + assert.equal(bounded.exitCode, 1); assert.equal(consumed, 1); +}); + +test('handoff export preserves existing files unless replacement is explicit', () => { + const root = tempRoot(), target = join(root, 'case.json'); + writeAtomicFile(target, 'original'); + assert.throws(() => writeAtomicFile(target, 'replacement', {replace: false}), /exist|EEXIST/); + assert.equal(readFileSync(target, 'utf8'), 'original'); + assert.deepEqual(readdirSync(root), ['case.json']); + writeAtomicFile(join(root, 'new.json'), 'new', {replace: false}); + assert.equal(readFileSync(join(root, 'new.json'), 'utf8'), 'new'); + writeAtomicFile(target, 'replacement', {replace: true}); + assert.equal(readFileSync(target, 'utf8'), 'replacement'); +}); + +test('saved-case commands use an explicit output root without component setup', async () => { + const outputRoot = tempRoot(); + for (const runId of ['root-a', 'root-b']) await runDemo([], {paths:{outputRoot},runId,componentResolver:()=>[],runDecideFn:async()=>({ok:false,raw:{passed:false},status:0})}); + for (const command of [['runs'], ['cases'], ['compare', 'root-a', 'root-b'], ['export', 'root-a'], ['prune', '--keep', '1', '--dry-run']]) { + const result = await captureMain([...command, '--root', outputRoot, '--json']); + assert.equal(result.exitCode, 0, result.stderr); + assert.ok(JSON.parse(result.stdout)); + } + const target = join(outputRoot, 'handoff.json'); + assert.equal((await captureMain(['export','root-a','--root',outputRoot,'--out',target,'--json'])).exitCode, 0); + assert.equal((await captureMain(['export','root-b','--root',outputRoot,'--out',target,'--json'])).exitCode, 1); + assert.equal(JSON.parse(readFileSync(target,'utf8')).report.run_id, 'root-a'); + for (const args of [['--root'], ['--root',''], ['--root',outputRoot,'--root',outputRoot]]) { + assert.equal((await captureMain(['cases',...args,'--json'])).exitCode, 2); + } +}); + +test('human history reports damaged entries and continuation even on empty pages', async () => { + const outputRoot = tempRoot(); writeCase(outputRoot, 'case-a'); + mkdirSync(join(outputRoot, 'runs', 'case-z'), {recursive:true}); + const first = await captureMain(['cases','--root',outputRoot,'--limit','1']); + assert.equal(first.exitCode, 0); assert.doesNotMatch(first.stdout, /no cases yet/); + assert.match(first.stdout, /unavailable: case-z/); assert.match(first.stdout, /next_cursor: case-z/); + const next = await captureMain(['cases','--root',outputRoot,'--limit','1','--before','case-z']); + assert.match(next.stdout, /case-a outcome=/); assert.match(next.stdout, /next_cursor: none/); +}); + +test('comparison handoffs support Markdown and fail machine callers on unavailable cases', async () => { + const outputRoot = tempRoot(); writeCase(outputRoot, 'compare-a'); writeCase(outputRoot, 'compare-b'); + const good = await captureMain(['compare','compare-a','compare-b','--root',outputRoot,'--markdown']); + assert.equal(good.exitCode, 0, good.stderr); assert.match(good.stdout, /# Saved case comparison/); + assert.match(good.stdout, /do not establish causation/); + for (const format of ['--json','--markdown']) { + const bad = await captureMain(['compare','compare-a','missing','--root',outputRoot,format]); + assert.equal(bad.exitCode, 1); + if (format === '--json') assert.equal(JSON.parse(bad.stdout).ok, false); + } + assert.equal((await captureMain(['compare','compare-a','compare-b','--markdown','--json'])).exitCode, 2); +}); + +test('verify reads a saved case without executing actions or changing its files', async () => { + const outputRoot=tempRoot(), runId='verify-refused'; + const result=await runDemo([], {paths:{outputRoot},runId,componentResolver:()=>[],runDecideFn:async()=>({ok:false,raw:{passed:false},status:0})}); + const before=readFileSync(join(result.bundleDir,'manifest.json'),'utf8'); + const verified=await captureMain(['verify',runId,'--root',outputRoot,'--json']); + assert.equal(verified.exitCode,1); const report=JSON.parse(verified.stdout); + assert.equal(report.ok,false); assert.match(report.reason,/unavailable/); + assert.equal(readFileSync(join(result.bundleDir,'manifest.json'),'utf8'),before); + assert.deepEqual(readdirSync(join(outputRoot,'runs')),[runId]); + assert.equal((await captureMain(['verify',runId,'--root',outputRoot,'--json'],{nodeVersion:'20.19.0'})).exitCode,1); + for(const args of [[],['a','b'],['a','--markdown']]) assert.equal((await captureMain(['verify',...args])).exitCode,2); +}); + +test('latest resolves the persisted pointer and fails closed on unavailable identities', async () => { + const outputRoot=tempRoot(); + for(const runId of ['z-case','a-case']) await runDemo([], {paths:{outputRoot},runId,componentResolver:()=>[],runDecideFn:async()=>({ok:false,raw:{passed:false},status:0})}); + const latest=await captureMain(['latest','--root',outputRoot]); + assert.equal(latest.exitCode,0); assert.equal(latest.stdout.trim(),'a-case'); + assert.equal(JSON.parse((await captureMain(['latest','--root',outputRoot,'--json'])).stdout).run_id,'a-case'); + for(const pointer of [{run_id:'missing'},{run_id:'../escape'},{run_id:'z-case',manifest:'runs/a-case/manifest.json'}]) { + writeFileSync(join(outputRoot,'latest.json'),JSON.stringify(pointer)); + assert.equal((await captureMain(['latest','--root',outputRoot,'--json'])).exitCode,1); + } + assert.equal((await captureMain(['latest','extra'])).exitCode,2); +}); + +test('case review handoffs include requested settings and bounded policy failures', async () => { + const {inspectCase,renderCaseMarkdown}=await import('../bin/case-review.mjs'); + const outputRoot=tempRoot(), runId='policy-review'; + const rules=[{rule_id:'allowed',passed:true},...Array.from({length:55},(_,index)=>({rule_id:'failed-'+index,path:'decision',kind:'equals',passed:false,reason_code:'not_equal',raw:'OMIT_RAW'}))]; + await runDemo(['--response','fail','--domain','inventory'],{paths:{outputRoot},runId,componentResolver:()=>[],runDecideFn:async()=>({ok:false,raw:{passed:false,rule_results:rules},status:0})}); + const review=inspectCase(runId,{outputRoot}); + assert.equal(review.policy_failures.total,55);assert.equal(review.policy_failures.rules.length,50);assert.equal(review.policy_failures.omitted,5); + const markdown=renderCaseMarkdown(review);assert.match(markdown,/## Requested settings/);assert.match(markdown,/domain: inventory/);assert.match(markdown,/not\\_equal/);assert.match(markdown,/5 additional failures omitted/);assert.doesNotMatch(markdown,/OMIT_RAW/); +}); + +test('case reviews explain verification readiness without claiming receipt verification', async () => { + const {inspectCase,renderCaseMarkdown}=await import('../bin/case-review.mjs'); + const outputRoot=tempRoot(),rail={synthetic:true},digest='sha256:'+createHash('sha256').update(JSON.stringify(rail)).digest('hex'); + const variants=[['no-rail',{},null,'unavailable'],['no-review',{action_id:'a',rail_bundle:rail},null,'unavailable'],['conflict',{action_id:'a',rail_bundle:rail},{verdict:'recorded',actionId:'a',evidenceDigest:'wrong'},'conflicting'],['ready',{action_id:'a',rail_bundle:rail},{verdict:'recorded',actionId:'a',evidenceDigest:digest},'ready']]; + for(const [runId,act,review,state] of variants){ + persistRunBundle({outputRoot,runId,report:{run_id:runId,stages:{}},stages:{act:{status:'passed',raw:act},prove:{status:'passed',raw:{result:review}}},componentProvenance:[],exitCode:0}); + const report=inspectCase(runId,{outputRoot});assert.equal(report.verification_readiness.state,state); + const md=renderCaseMarkdown(report);assert.match(md,/## Verification next step/);assert.match(md,/Receipt verification was not performed/); + if(state==='ready') assert.match(md,/aas verify ready/); + } +}); + +test('filtered case pages preserve scan bounds and continuation across nonmatches', async () => { + const {listCasePage}=await import('../bin/case-review.mjs'); + const outputRoot=tempRoot(); + for(const [runId,domain] of [['case-z','refund'],['case-a','inventory']]) await runDemo(['--domain',domain],{paths:{outputRoot},runId,componentResolver:()=>[],runDecideFn:async()=>({ok:false,raw:{passed:false,policy_id:'bounded-policy'},status:0})}); + const first=listCasePage({outputRoot,limit:1,domain:'inventory'}); + assert.deepEqual(first.cases,[]);assert.equal(first.next_cursor,'case-z');assert.equal(first.scanned,1); + const next=listCasePage({outputRoot,limit:1,before:first.next_cursor,domain:'inventory',outcome:'none',search:'BOUNDED-POLICY'}); + assert.equal(next.cases[0].run_id,'case-a');assert.equal(next.next_cursor,null); + const cli=await captureMain(['cases','--root',outputRoot,'--domain','inventory','--outcome','none','--search','bounded-policy','--json']); + assert.equal(cli.exitCode,0,cli.stderr);assert.equal(JSON.parse(cli.stdout).cases.length,1); + for(const args of [['--domain','real'],['--outcome','paid'],['--search',''],['--search','x'.repeat(201)],['--domain','refund','--domain','inventory']]) assert.equal((await captureMain(['cases',...args,'--json'])).exitCode,2); +}); + +test('review handoff example honors the explicit interpreter override', () => { + const child=spawnSync(process.execPath,[join(ROOT,'examples/review-handoff.mjs')],{cwd:ROOT,encoding:'utf8',timeout:5000,env:{...process.env,AAS_PYTHON:'aas-synthetic-missing-python'}}); + assert.equal(child.status,1);assert.match(child.stderr,/AAS_PYTHON/);assert.match(child.stderr,/did not report a usable Python version/); +});