From a9895fcf3b7fd055e961540d261bd5b53537d3db Mon Sep 17 00:00:00 2001 From: EauDoon <47585778+EauDoon@users.noreply.github.com> Date: Sun, 30 Aug 2026 08:06:23 +0800 Subject: [PATCH] fix: validate remaining child JSON and demo input fields Cap child stdout before parse, type-check remaining report-facing payload fields, allowlist MandateBound prove scenarios, and reject unsupported --fault values as usage errors. --- bin/aas.mjs | 58 ++++++++++++++++++++++++++- test/stack.test.mjs | 98 ++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 150 insertions(+), 6 deletions(-) diff --git a/bin/aas.mjs b/bin/aas.mjs index 358eca3..c9606b7 100644 --- a/bin/aas.mjs +++ b/bin/aas.mjs @@ -105,6 +105,20 @@ const STAGE_NAMES = ["decide", "act", "prove"]; const DEMO_FLAG_OPTIONS = new Set(["--dispute", "--json"]); const DEMO_VALUE_OPTIONS = new Set(["--response", "--fault"]); const STDERR_LIMIT = 800; +/** Child stdout is capped so a runaway tool cannot inflate the run bundle. */ +export const CHILD_JSON_LIMIT = 1024 * 1024; +const DEMO_FAULTS = new Set(["none", "duplicate"]); +const PROVE_SCENARIOS = new Set([ + "principal", + "operator", + "model_vendor", + "unresolved", + "expiry", + "replay", + "tamper", + "conflict", + "appeal", +]); export const DIAGNOSTIC = Object.freeze({ CHILD_SPAWN: "AAS_CHILD_SPAWN", CHILD_EXIT: "AAS_CHILD_EXIT", @@ -269,9 +283,16 @@ function stageErrorFields(error) { * * @param {string} text * @param {string} label Stage name used in error messages. + * @param {number} [limit] * @returns {unknown} */ -export function parseJsonOutput(text, label) { +export function parseJsonOutput(text, label, limit = CHILD_JSON_LIMIT) { + if (typeof text !== "string") { + throw new Error(`${label} produced empty output.`); + } + if (text.length > limit) { + throw new Error(`${label} JSON output exceeds ${limit} characters.`); + } const trimmed = text.trim(); if (!trimmed) { throw new Error(`${label} produced empty output.`); @@ -305,6 +326,19 @@ function booleanField(payload, field, label) { return payload[field]; } +function jsonType(value) { + if (value === null) return "null"; + if (Array.isArray(value)) return "array"; + return typeof value; +} + +function optionalField(payload, field, allowed, label) { + if (payload[field] === undefined) return; + if (!allowed.includes(jsonType(payload[field]))) { + throw new Error(`${label} did not return a valid ${field} field`); + } +} + function parseStageJson(label, result) { try { return parseJsonOutput(result.stdout, label); @@ -388,6 +422,8 @@ export function runDecide( } try { const ok = booleanField(evaluation, "passed", "decide"); + optionalField(evaluation, "policy_id", ["string"], "decide"); + optionalField(evaluation, "rule_results", ["array"], "decide"); return { ok, raw: evaluation, status: 0, ...(ok ? {} : failedStderr(result)) }; } catch (error) { throw attachChildDiagnostics(error, { @@ -434,6 +470,19 @@ export function runAct( stderr: result.stderr, }); } + try { + optionalField(payload, "state", ["string", "null"], "act"); + optionalField(payload, "fault", ["string", "null"], "act"); + optionalField(payload, "action_id", ["string", "null"], "act"); + optionalField(payload, "assurance_mode", ["string", "null"], "act"); + optionalField(payload, "bundle_verification", ["string", "null"], "act"); + } catch (error) { + throw attachChildDiagnostics(error, { + stage: "act", + code: DIAGNOSTIC.CHILD_JSON, + stderr: result.stderr, + }); + } return { ok: true, raw: payload, status: 0 }; } @@ -450,6 +499,9 @@ export function runProve( if (typeof scenario !== "string" || scenario.trim() === "") { throw new Error("prove requires a non-empty scenario"); } + if (!PROVE_SCENARIOS.has(scenario)) { + throw new Error(`prove scenario is not supported: ${scenario}`); + } const cli = join(depsDir, "mandatebound", "dist", "cli.js"); if (runner === runCapture && !existsSync(cli)) { throw missingChildTool("prove CLI (deps/mandatebound/dist/cli.js)"); @@ -466,6 +518,7 @@ export function runProve( } try { const ok = booleanField(payload, "ok", "prove"); + optionalField(payload, "result", ["object"], "prove"); return { ok, raw: payload, status: 0, ...(ok ? {} : failedStderr(result)) }; } catch (error) { throw attachChildDiagnostics(error, { @@ -624,6 +677,9 @@ export async function runDemo(args = [], options = {}) { throw new UsageError("--response must be pass or fail"); } const fault = option(args, "--fault", "none"); + if (!DEMO_FAULTS.has(fault)) { + throw new UsageError("--fault must be none or duplicate"); + } const forceDispute = has(args, "--dispute"); const asJson = has(args, "--json"); const responsePath = join(paths.fixtures, `response.${responseName}.json`); diff --git a/test/stack.test.mjs b/test/stack.test.mjs index 4668bb5..bc2f67d 100644 --- a/test/stack.test.mjs +++ b/test/stack.test.mjs @@ -6,6 +6,7 @@ import test from "node:test"; import { fileURLToPath } from "node:url"; import { loadComponentLock, inspectDependencyDirectory, npmInvocation } from "../scripts/bootstrap.mjs"; import { + CHILD_JSON_LIMIT, DIAGNOSTIC, clipChildStderr, helpText, @@ -171,7 +172,7 @@ test("decide and prove reject non-boolean success fields", () => { }, ); assert.throws( - () => runProve("unused", { runner }), + () => runProve("operator", { runner }), (error) => { assert.match(error.message, /boolean ok field/); assert.equal(error.code, DIAGNOSTIC.CHILD_JSON); @@ -196,7 +197,7 @@ test("decide reads logged JSON on a nonzero exit", () => { }); test("prove treats nonzero JSON as an unsuccessful proof", () => { - const result = runProve("unused", { + const result = runProve("operator", { runner: () => ({ status: 2, stdout: '{"ok":false,"error":{"code":"ALB_CLI_USAGE","message":"Simulate accepts one scenario."}}\n', @@ -211,7 +212,7 @@ test("prove treats nonzero JSON as an unsuccessful proof", () => { }); test("prove fail-closes a nonzero payload that claims success", () => { - const result = runProve("unused", { + const result = runProve("operator", { runner: () => ({ status: 5, stdout: '{"ok":true,"result":{}}\n', @@ -225,7 +226,7 @@ test("prove fail-closes a nonzero payload that claims success", () => { test("prove still throws when a nonzero child has no JSON", () => { assert.throws( - () => runProve("unused", { + () => runProve("operator", { runner: () => ({ status: 2, stdout: "simulate failed\n", @@ -290,6 +291,83 @@ test("child output parser accepts JSON before a trailing log", () => { ); }); +test("child JSON larger than the defensive limit is rejected", () => { + assert.throws( + () => parseJsonOutput('{"ok":true}', "fixture", 4), + /fixture JSON output exceeds 4 characters/, + ); + assert.deepEqual(parseJsonOutput('{"ok":true}', "fixture", 20), { ok: true }); + const oversized = `{"passed":true,"pad":"${"x".repeat(CHILD_JSON_LIMIT)}"}`; + assert.throws( + () => runDecide("unused", { + runner: () => ({ status: 0, stdout: oversized, stderr: "child: huge json\n", error: null }), + }), + (error) => { + assert.match(error.message, new RegExp(`exceeds ${CHILD_JSON_LIMIT} characters`)); + assert.equal(error.code, DIAGNOSTIC.CHILD_JSON); + assert.equal(error.stage, "decide"); + return true; + }, + ); +}); + +test("decide and act reject mistyped remaining payload fields", () => { + assert.throws( + () => runDecide("unused", { + runner: () => ({ + status: 0, + stdout: '{"passed":true,"policy_id":["refund-v1"]}\n', + stderr: "child: bad policy id\n", + error: null, + }), + }), + (error) => { + assert.match(error.message, /valid policy_id field/); + assert.equal(error.code, DIAGNOSTIC.CHILD_JSON); + assert.equal(error.stage, "decide"); + return true; + }, + ); + assert.throws( + () => runDecide("unused", { + runner: () => ({ + status: 0, + stdout: '{"passed":true,"rule_results":{}}\n', + stderr: "", + error: null, + }), + }), + /valid rule_results field/, + ); + assert.throws( + () => runAct("none", { + runner: () => ({ + status: 0, + stdout: '{"outcome":"settled","state":{"name":"CLOSED"}}\n', + stderr: "crctl: nested state\n", + error: null, + }), + }), + (error) => { + assert.match(error.message, /valid state field/); + assert.equal(error.code, DIAGNOSTIC.CHILD_JSON); + assert.equal(error.stage, "act"); + return true; + }, + ); + assert.throws( + () => runProve("operator", { + runner: () => ({ + status: 0, + stdout: '{"ok":true,"result":["evidence"]}\n', + stderr: "", + error: null, + }), + }), + /valid result field/, + ); +}); + test("README pass-path sample matches printHuman field order", async () => { const readme = readFileSync(join(ROOT, "README.md"), "utf8"); const match = readme.match(/Expected human output \(pass path, no fault\):\n\n```text\n([\s\S]*?)```/); @@ -511,14 +589,19 @@ test("demo arguments reject unknown, duplicate, missing-value, and empty options () => runDemo(["--response", "pass", "--response", "fail"], stubOptions(tempRoot())), /Duplicate/, ); + await assert.rejects(() => runDemo(["--response", "maybe"], stubOptions(tempRoot())), /must be pass or fail/); + await assert.rejects(() => runDemo(["--fault", "explode"], stubOptions(tempRoot())), /must be none or duplicate/); }); -test("prove rejects an empty scenario before spawning a child", () => { +test("prove rejects an empty or unknown scenario before spawning a child", () => { const runner = () => { throw new Error("should not spawn"); }; assert.throws(() => runProve("", { runner }), /non-empty scenario/); assert.throws(() => runProve(" ", { runner }), /non-empty scenario/); + assert.throws(() => runProve("all", { runner }), /not supported: all/); + assert.throws(() => runProve("../operator", { runner }), /not supported/); + assert.throws(() => runProve("operator;id", { runner }), /not supported/); }); test("missing child tools fail closed with a bootstrap hint", () => { @@ -610,4 +693,9 @@ test("CLI reports unknown commands, flags, and empty values as usage errors", as const helpAsValue = await captureMain(["demo", "--response", "--help"]); assert.equal(helpAsValue.exitCode, 2); assert.match(helpAsValue.stderr, /Missing value for demo option: --response/); + + const badFault = await captureMain(["demo", "--fault", "explode"]); + assert.equal(badFault.exitCode, 2); + assert.match(badFault.stderr, /--fault must be none or duplicate/); + assert.match(badFault.stderr, /Try `aas help` for usage/); });