From a6f1114709f1188979d508cec6dbd49c8e58b5c5 Mon Sep 17 00:00:00 2001 From: Tal Zaccai Date: Mon, 27 Jul 2026 16:10:21 -0700 Subject: [PATCH 1/2] Fix out-of-range @ref result references in program interpreter Result references were validated only with 'index < length', accepting negative and non-integer indices. A negative @ref such as -1 passed the check and resolved to undefined, silently substituting undefined where a validated value was expected. Add Number.isInteger(index) && index >= 0 bounds checks to both evaluateJsonProgram and createModuleTextFromProgram, and throw on an invalid reference in the interpreter instead of falling through to undefined. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- typescript/package.json | 2 +- typescript/src/ts/program.ts | 5 +- typescript/test/program.test.ts | 96 +++++++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 typescript/test/program.test.ts diff --git a/typescript/package.json b/typescript/package.json index c283643e..e559e7ea 100644 --- a/typescript/package.json +++ b/typescript/package.json @@ -21,7 +21,7 @@ }, "scripts": { "build": "tsc -p src", - "test": "npm run build && tsc -p test && node --test out/validate.test.js out/zod.test.js tests/model.test.mjs", + "test": "npm run build && tsc -p test && node --test out/validate.test.js out/zod.test.js out/program.test.js tests/model.test.mjs", "build-all": "npm run build --workspaces", "prepare": "npm run build-all", "prepublishOnly": "node -e \"require('fs').copyFileSync('../SECURITY.md','SECURITY.md')\"", diff --git a/typescript/src/ts/program.ts b/typescript/src/ts/program.ts index 0621ed40..bd125b4f 100644 --- a/typescript/src/ts/program.ts +++ b/typescript/src/ts/program.ts @@ -108,7 +108,7 @@ export function createModuleTextFromProgram(jsonObject: object): Result function objectToString(obj: Record) { if (obj.hasOwnProperty("@ref")) { const index = obj["@ref"]; - if (typeof index === "number" && index < currentStep && Object.keys(obj).length === 1) { + if (typeof index === "number" && Number.isInteger(index) && index >= 0 && index < currentStep && Object.keys(obj).length === 1) { return `step${index + 1}`; } } @@ -160,9 +160,10 @@ export async function evaluateJsonProgram(program: Program, onCall: (func: strin async function evaluateObject(obj: Record) { if (obj.hasOwnProperty("@ref")) { const index = obj["@ref"]; - if (typeof index === "number" && index < results.length) { + if (typeof index === "number" && Number.isInteger(index) && index >= 0 && index < results.length) { return results[index]; } + throw new Error(`Invalid result reference: ${JSON.stringify(index)}`); } else if (obj.hasOwnProperty("@func")) { const func = obj["@func"]; diff --git a/typescript/test/program.test.ts b/typescript/test/program.test.ts new file mode 100644 index 00000000..4a2cb7cc --- /dev/null +++ b/typescript/test/program.test.ts @@ -0,0 +1,96 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { evaluateJsonProgram, createModuleTextFromProgram, Program } from "../dist/ts/index.js"; + +// --------------------------------------------------------------------------- +// evaluateJsonProgram result-reference bounds checking +// --------------------------------------------------------------------------- + +describe("evaluateJsonProgram result references", () => { + async function run(program: Program) { + const calls: Array<{ func: string; args: unknown[] }> = []; + const result = await evaluateJsonProgram(program, async (func, args) => { + calls.push({ func, args }); + return { func, args }; + }); + return { result, calls }; + } + + it("resolves a valid forward reference", async () => { + const program: Program = { + "@steps": [ + { "@func": "first" }, + { "@func": "second", "@args": [{ "@ref": 0 }] }, + ], + }; + const { calls } = await run(program); + assert.deepEqual(calls[1].args, [{ func: "first", args: [] }]); + }); + + it("throws on a negative reference instead of yielding undefined", async () => { + const program: Program = { + "@steps": [ + { "@func": "first" }, + { "@func": "second", "@args": [{ "@ref": -1 }] }, + ], + }; + await assert.rejects(run(program), /Invalid result reference/); + }); + + it("throws on a non-integer reference", async () => { + const program: Program = { + "@steps": [ + { "@func": "first" }, + { "@func": "second", "@args": [{ "@ref": 0.5 }] }, + ], + }; + await assert.rejects(run(program), /Invalid result reference/); + }); + + it("throws on an out-of-upper-bound reference", async () => { + const program: Program = { + "@steps": [ + { "@func": "first" }, + { "@func": "second", "@args": [{ "@ref": 5 }] }, + ], + }; + await assert.rejects(run(program), /Invalid result reference/); + }); +}); + +// --------------------------------------------------------------------------- +// createModuleTextFromProgram result-reference bounds checking +// --------------------------------------------------------------------------- + +describe("createModuleTextFromProgram result references", () => { + it("emits a step reference for a valid index", () => { + const result = createModuleTextFromProgram({ + "@steps": [ + { "@func": "first" }, + { "@func": "second", "@args": [{ "@ref": 0 }] }, + ], + }); + assert.equal(result.success, true); + assert.match((result as { data: string }).data, /step1/); + }); + + it("rejects a negative index", () => { + const result = createModuleTextFromProgram({ + "@steps": [ + { "@func": "first" }, + { "@func": "second", "@args": [{ "@ref": -1 }] }, + ], + }); + assert.equal(result.success, false); + }); + + it("rejects a non-integer index", () => { + const result = createModuleTextFromProgram({ + "@steps": [ + { "@func": "first" }, + { "@func": "second", "@args": [{ "@ref": 0.5 }] }, + ], + }); + assert.equal(result.success, false); + }); +}); From 4d7aaede2ee4d402088a6ff9f650b20b21ff64bd Mon Sep 17 00:00:00 2001 From: Tal Zaccai Date: Mon, 27 Jul 2026 16:18:52 -0700 Subject: [PATCH 2/2] Address review: require single-key @ref, safe error formatting, test coverage - Require Object.keys(obj).length === 1 in the interpreter's @ref path so it matches createModuleTextFromProgram and does not treat multi-key objects as references. - Use String(index) instead of JSON.stringify(index) to avoid throwing on non-JSON values (e.g. bigint) when formatting the error. - Rename the misleading 'forward reference' test and add an out-of-upper-bound test for createModuleTextFromProgram. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- typescript/src/ts/program.ts | 4 ++-- typescript/test/program.test.ts | 12 +++++++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/typescript/src/ts/program.ts b/typescript/src/ts/program.ts index bd125b4f..99231463 100644 --- a/typescript/src/ts/program.ts +++ b/typescript/src/ts/program.ts @@ -160,10 +160,10 @@ export async function evaluateJsonProgram(program: Program, onCall: (func: strin async function evaluateObject(obj: Record) { if (obj.hasOwnProperty("@ref")) { const index = obj["@ref"]; - if (typeof index === "number" && Number.isInteger(index) && index >= 0 && index < results.length) { + if (Object.keys(obj).length === 1 && typeof index === "number" && Number.isInteger(index) && index >= 0 && index < results.length) { return results[index]; } - throw new Error(`Invalid result reference: ${JSON.stringify(index)}`); + throw new Error(`Invalid result reference: ${String(index)}`); } else if (obj.hasOwnProperty("@func")) { const func = obj["@func"]; diff --git a/typescript/test/program.test.ts b/typescript/test/program.test.ts index 4a2cb7cc..f6a53156 100644 --- a/typescript/test/program.test.ts +++ b/typescript/test/program.test.ts @@ -16,7 +16,7 @@ describe("evaluateJsonProgram result references", () => { return { result, calls }; } - it("resolves a valid forward reference", async () => { + it("resolves a valid reference to a preceding step", async () => { const program: Program = { "@steps": [ { "@func": "first" }, @@ -93,4 +93,14 @@ describe("createModuleTextFromProgram result references", () => { }); assert.equal(result.success, false); }); + + it("rejects an out-of-upper-bound index", () => { + const result = createModuleTextFromProgram({ + "@steps": [ + { "@func": "first" }, + { "@func": "second", "@args": [{ "@ref": 5 }] }, + ], + }); + assert.equal(result.success, false); + }); });