diff --git a/CHANGELOG.md b/CHANGELOG.md index 0db828b..6a214dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ project adheres to [Semantic Versioning](https://semver.org/). - Playwright setup note in the Quick start section of `README.md`. ### Fixed +- The act stage now reads the rail bundle file with the same byte cap and + symlink refusal as a saved case. The previous read followed a link and + accepted a file larger than the child stdout cap. - Bootstrap and provenance now refuse a `deps` directory that is a symlink. Checkout and later reads followed the link, so a planted `deps` link was accepted as the component root. diff --git a/bin/aas.mjs b/bin/aas.mjs index 7bef7a7..871a48a 100644 --- a/bin/aas.mjs +++ b/bin/aas.mjs @@ -727,7 +727,9 @@ export function runAct( } if (scratch !== null) { try { - payload.rail_bundle = JSON.parse(readFileSync(bundlePath, "utf8")); + // Same bounds as a saved case file. readFileSync followed a symlink + // and accepted a bundle larger than the child stdout cap. + payload.rail_bundle = readBoundedCaseJson(bundlePath); } catch (error) { throw attachChildDiagnostics( new Error(`act did not persist a readable rail bundle: ${error.message}`), diff --git a/test/stack.test.mjs b/test/stack.test.mjs index 0b74777..0c93dab 100644 --- a/test/stack.test.mjs +++ b/test/stack.test.mjs @@ -1251,6 +1251,34 @@ test("runAct persists the rail bundle only when asked", () => { assert.equal("rail_bundle" in plain.raw, false); }); +test("runAct rejects a rail bundle file past the child output cap", () => { + const runner = (_bin, args) => { + const out = args[args.indexOf("--out") + 1]; + writeFileSync(out, Buffer.alloc(CHILD_JSON_LIMIT + 1, 0x78)); + return { status: 0, stdout: '{"outcome":"settled","state":"CLOSED","fault":"none","action_id":"a"}\n', stderr: "", error: null }; + }; + assert.throws( + () => runAct("none", { depsDir: "deps", runner, persistRailBundle: true }), + /byte limit/, + ); +}); + +test("runAct does not follow a symlinked rail bundle file", (t) => { + const probe = tempRoot(); + if (!linkOrSkip(t, join(probe, "missing"), join(probe, "link"))) return; + const runner = (_bin, args) => { + const out = args[args.indexOf("--out") + 1]; + const target = join(out, "..", "target.json"); + writeFileSync(target, JSON.stringify({ action: { action_id: "leaked" }, settlement_receipt: { outcome: "settled" } })); + symlinkSync(target, out); + return { status: 0, stdout: '{"outcome":"settled","state":"CLOSED","fault":"none","action_id":"a"}\n', stderr: "", error: null }; + }; + assert.throws( + () => runAct("none", { depsDir: "deps", runner, persistRailBundle: true }), + /symbolic link/, + ); +}); + test("demo rail mode records the review binding and fails closed without a bundle", async () => { const outputRoot = mkdtempSync(join(tmpdir(), "agent-action-stack-rail-")); const bundle = railBundleFixture();