diff --git a/packages/cli/src/pipeline/validation.ts b/packages/cli/src/pipeline/validation.ts index 87512d2..642dfc4 100644 --- a/packages/cli/src/pipeline/validation.ts +++ b/packages/cli/src/pipeline/validation.ts @@ -125,6 +125,21 @@ function collectPipelineSemanticIssues( `semantic: step "${stepLabel}" timeout must be a positive number of seconds or duration string`, ); } + + // `script/js` executes its `code` as host JavaScript. Require it to be a + // literal string in the pipeline definition: code sourced from another step + // ($from) or any expression is rejected, so untrusted/model-generated text + // can never become the body of the executed function. + if (step.type === "script/js") { + const code = isRecord(step.input) ? step.input.code : undefined; + if (typeof code !== "string") { + issues.push( + `semantic: step "${stepLabel}" (script/js) requires a literal string "code"; ` + + `code sourced from another step ($from) or an expression is not allowed, ` + + `since it would execute untrusted text as host code`, + ); + } + } } // Check dependency references diff --git a/packages/cli/tests/index.test.ts b/packages/cli/tests/index.test.ts index 507a100..f6853c0 100644 --- a/packages/cli/tests/index.test.ts +++ b/packages/cli/tests/index.test.ts @@ -1,6 +1,9 @@ import { expect, test } from "vite-plus/test"; import { createStepDispatcher } from "../src/pipeline/dispatcher.ts"; import { executePipeline } from "../src/pipeline/executor.ts"; +import { collectPipelineIssues } from "../src/pipeline/validation.ts"; +import { getByJsonPointer } from "../src/pipeline/schema.ts"; +import { normalizeConcurrency } from "../src/pipeline/scheduler.ts"; import { WORKFLOW_VERSION, type PipelineDefinition } from "../src/pipeline/types.ts"; test("cli package skeleton", () => { @@ -34,3 +37,73 @@ test("pipeline execution can use an isolated step dispatcher", async () => { hasSignal: true, }); }); + +test("dry-run never executes $js expressions (preview must not run code)", async () => { + const dispatcher = createStepDispatcher(); + dispatcher.registerStep("test/echo", (input) => ({ data: input })); + const flag = "__bailian_dryrun_should_not_run__"; + delete (globalThis as Record)[flag]; + + const pipeline: PipelineDefinition = { + version: WORKFLOW_VERSION, + steps: [ + { + id: "s1", + type: "test/echo", + input: { probe: { $js: `(globalThis[${JSON.stringify(flag)}] = true), 1` } }, + }, + ], + }; + + const report = await executePipeline(pipeline, {}, { stepDispatcher: dispatcher, dryRun: true }); + expect(report.status).toBe("planned"); + expect((globalThis as Record)[flag]).toBeUndefined(); +}); + +test("script/js rejects non-literal code sourced from another step ($from)", () => { + const dispatcher = createStepDispatcher(); + dispatcher.registerStep("test/echo", (input) => ({ data: input })); + dispatcher.registerStep("script/js", () => ({ data: {} })); + + const pipeline: PipelineDefinition = { + version: WORKFLOW_VERSION, + steps: [ + { id: "gen", type: "test/echo", input: { message: "x" } }, + { + id: "run", + type: "script/js", + input: { code: { $from: "gen", path: "/data/message" } as never }, + }, + ], + }; + + const issues = collectPipelineIssues(pipeline, dispatcher); + expect(issues.some((issue) => issue.includes('literal string "code"'))).toBe(true); +}); + +test("script/js accepts a literal string code", () => { + const dispatcher = createStepDispatcher(); + dispatcher.registerStep("script/js", () => ({ data: {} })); + + const pipeline: PipelineDefinition = { + version: WORKFLOW_VERSION, + steps: [{ id: "run", type: "script/js", input: { code: "return 1" } }], + }; + + expect(collectPipelineIssues(pipeline, dispatcher)).toEqual([]); +}); + +test("getByJsonPointer refuses prototype keys and inherited properties", () => { + const obj = { a: { b: 1 } }; + expect(getByJsonPointer(obj, "/a/b")).toBe(1); + expect(getByJsonPointer(obj, "/__proto__")).toBeUndefined(); + expect(getByJsonPointer(obj, "/constructor")).toBeUndefined(); + expect(getByJsonPointer(obj, "/a/constructor/constructor")).toBeUndefined(); + expect(getByJsonPointer(obj, "/toString")).toBeUndefined(); +}); + +test("normalizeConcurrency clamps to a safe maximum", () => { + expect(normalizeConcurrency(undefined)).toBe(1); + expect(normalizeConcurrency(4)).toBe(4); + expect(normalizeConcurrency(100000)).toBe(64); +});