Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 57 additions & 1 deletion bin/aas.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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.`);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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");
Comment on lines +425 to +426

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Nonzero child JSON bypasses validation

On nonzero child exits, runDecide and runProve return before validating optional fields. Malformed decision metadata enters reports, while malformed proof results enter bundles.

Prompt for agents
In bin/aas.mjs, runDecide and runProve validate optional payload fields only after returning early for a nonzero child status. Reorder payload-shape validation so policy_id/rule_results and result are checked for every successfully parsed child payload, while preserving the existing behavior that nonzero valid JSON produces a failed stage rather than a child-process error.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

return { ok, raw: evaluation, status: 0, ...(ok ? {} : failedStderr(result)) };
} catch (error) {
throw attachChildDiagnostics(error, {
Expand Down Expand Up @@ -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 };
}

Expand All @@ -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)");
Expand All @@ -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, {
Expand Down Expand Up @@ -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`);
Expand Down
98 changes: 93 additions & 5 deletions test/stack.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand All @@ -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',
Expand All @@ -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',
Expand All @@ -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",
Expand Down Expand Up @@ -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]*?)```/);
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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/);
});
Loading