diff --git a/.github/actions/run-claude-cli/action.yml b/.github/actions/run-claude-cli/action.yml index c522cd5..a5c02c9 100644 --- a/.github/actions/run-claude-cli/action.yml +++ b/.github/actions/run-claude-cli/action.yml @@ -31,7 +31,8 @@ description: >- step environment sets NODE_OPTIONS, LD_PRELOAD, LD_LIBRARY_PATH, BASH_ENV, NODE_EXTRA_CA_CERTS, NODE_TLS_REJECT_UNAUTHORIZED or a `*_PROXY` variable other than NO_PROXY. It writes the outputs report-check-run reads, a JSONL - execution log (one object for the leg) written only after a scan finds no + execution log (one object for the leg) and, with json-schema-file, the + result's structured output, each written only after a scan finds no credential in it, and a job summary table of closed tokens. Uploading the log is the caller's choice. The step fails, after writing its outputs, whenever lane-class is not empty. @@ -119,7 +120,8 @@ inputs: json-schema-file: description: >- Path to a JSON Schema the final result must match (`--json-schema`); the - result's `structured_output` is in execution-file. Empty passes none. + result's `structured_output` is in structured-output-file. Empty passes + none. required: false default: "" cli-version: @@ -201,6 +203,13 @@ outputs: log-file: description: JSONL execution log, one object for the leg; empty when the credential scan failed. value: ${{ steps.run.outputs.log-file }} + structured-output-file: + description: >- + The final result's `structured_output` as a JSON file, written only when + json-schema-file is set, lane-class is empty, the result carries an + object, and a scan finds no credential in it; else empty. A scan hit + fails the step. + value: ${{ steps.run.outputs.structured-output-file }} cli-version: description: The Claude Code CLI version that ran. value: ${{ steps.install.outputs.cli-version }} diff --git a/.github/actions/run-claude-cli/lane.cjs b/.github/actions/run-claude-cli/lane.cjs index 46d3409..32ab5f9 100644 --- a/.github/actions/run-claude-cli/lane.cjs +++ b/.github/actions/run-claude-cli/lane.cjs @@ -1156,6 +1156,19 @@ function install( }); } +// The final result's structured_output as JSON text, when the leg ran with +// json-schema-file and succeeded; else null. +function structuredOutputText(config, laneClass, executionFile) { + if (config.jsonSchema === "" || laneClass !== "") return null; + const result = (readMessages(executionFile) ?? []).findLast( + (message) => message?.type === "result", + ); + const value = result?.structured_output; + return value !== null && typeof value === "object" + ? JSON.stringify(value) + : null; +} + function run(env) { verifyEnvironment(env); const config = readConfig(env); @@ -1171,9 +1184,21 @@ function run(env) { const logText = `${outcome.records.map((r) => JSON.stringify(r)).join("\n")}\n`; const clean = !holdsCredential(logText, [config.leg.value]); if (clean) fs.writeFileSync(logFile, logText); + const structuredText = structuredOutputText( + config, + outcome.outputs["lane-class"], + outcome.outputs["execution-file"], + ); + const structuredClean = + structuredText === null || + !holdsCredential(structuredText, [config.leg.value]); + const structuredFile = path.join(config.workDir, "structured-output.json"); + const structuredWritten = structuredText !== null && structuredClean; + if (structuredWritten) fs.writeFileSync(structuredFile, structuredText); writeOutputs(env.GITHUB_OUTPUT, { ...outcome.outputs, "log-file": clean ? logFile : "", + "structured-output-file": structuredWritten ? structuredFile : "", }); if (env.GITHUB_STEP_SUMMARY && clean) { fs.appendFileSync( @@ -1186,6 +1211,11 @@ function run(env) { "the execution log held a credential and was not written", ); } + if (!structuredClean) { + throw new LaneError( + "the structured output held a credential and was not written", + ); + } const laneClass = outcome.outputs["lane-class"]; if (laneClass !== "") { const facts = errorTokens(outcome.records[0]); diff --git a/.github/actions/run-claude-cli/lane.test.cjs b/.github/actions/run-claude-cli/lane.test.cjs index 56662a8..cf153cf 100644 --- a/.github/actions/run-claude-cli/lane.test.cjs +++ b/.github/actions/run-claude-cli/lane.test.cjs @@ -918,7 +918,7 @@ test("a process the leg leaves behind cannot add a lane-class after the launcher lines.filter((line) => line.startsWith("lane-class=")), ["lane-class="], ); - assert.equal(lines.at(-1).startsWith("log-file="), true); + assert.equal(lines.at(-1), "structured-output-file="); }); test("stdout is captured in memory and a maxBuffer overflow is class other", () => { @@ -1374,6 +1374,98 @@ test("run on a 403 key stops with no handoff and names the error in the log, sum ); }); +function structuredRun(name, messages, exit, overrides = {}) { + const dir = path.join(root, name); + const schema = path.join(root, `${name}-schema.json`); + fs.writeFileSync(schema, '{"type":"object"}'); + const env = runEnv(dir, { LANE_JSON_SCHEMA_FILE: schema, ...overrides }); + env.FAKE_KEY = legFile(dir, "key.jsonl", messages); + env.FAKE_KEY_EXIT = String(exit); + return env; +} + +const FINDINGS = { findings: 2, summary: "Two findings.", comments: [] }; + +test("a successful leg with json-schema-file writes the structured output to its own file", () => { + const env = structuredRun( + "run-structured", + [ + { type: "system", subtype: "init" }, + result({ structured_output: FINDINGS }), + ], + 0, + ); + run(env); + const outputs = readOutputs(env.GITHUB_OUTPUT); + assert.deepEqual( + JSON.parse(fs.readFileSync(outputs["structured-output-file"], "utf8")), + FINDINGS, + ); + assert.notEqual(outputs["log-file"], ""); +}); + +test("no structured-output-file without json-schema-file, on a failed leg, or with no object", () => { + const maxTurns = result({ + subtype: "error_max_turns", + is_error: true, + structured_output: FINDINGS, + }); + for (const [name, messages, exit, overrides] of [ + [ + "run-structured-no-schema", + [result({ structured_output: FINDINGS })], + 0, + { LANE_JSON_SCHEMA_FILE: "" }, + ], + ["run-structured-max-turns", [maxTurns], 1, {}], + [ + "run-structured-string", + [result({ structured_output: "not an object" })], + 0, + {}, + ], + ]) { + const env = structuredRun(name, messages, exit, overrides); + try { + run(env); + } catch (error) { + assert.equal(name, "run-structured-max-turns", error.message); + } + const outputs = readOutputs(env.GITHUB_OUTPUT); + assert.equal(outputs["structured-output-file"], "", name); + assert.equal( + fs.existsSync(path.join(env.LANE_WORK_DIR, "structured-output.json")), + false, + name, + ); + } +}); + +test("a structured output holding the credential or a key shape is not written and fails the step", () => { + for (const planted of [KEY, "sk-ant-api03-abcdefghijkl"]) { + const env = structuredRun( + `run-structured-leak-${planted.length}`, + [ + result({ + structured_output: { ...FINDINGS, summary: `see ${planted}` }, + }), + ], + 0, + ); + assert.throws( + () => run(env), + /the structured output held a credential and was not written/u, + ); + const outputs = readOutputs(env.GITHUB_OUTPUT); + assert.equal(outputs["structured-output-file"], ""); + assert.equal( + fs.existsSync(path.join(env.LANE_WORK_DIR, "structured-output.json")), + false, + ); + assert.notEqual(outputs["log-file"], ""); + } +}); + test("run refuses both credentials before any leg starts", () => { const dir = path.join(root, "run-two-credentials"); const env = runEnv(dir, { LANE_SUBSCRIPTION_TOKEN: TOKEN }); diff --git a/README.md b/README.md index a9204db..65d718b 100644 --- a/README.md +++ b/README.md @@ -655,8 +655,12 @@ consumer to audit it. `NODE_TLS_REJECT_UNAUTHORIZED` or a `*_PROXY` variable other than `NO_PROXY`. The CLI installs from the public npm registry, outside any checkout's `.npmrc`. Its outputs match report-check-run's `lane-*` inputs, plus - `credential` (a secret name), `execution-file` and a JSONL `log-file` that is - written only after a credential scan; every log line records the `lane` and + `credential` (a secret name), `execution-file`, a JSONL `log-file`, and, + on a successful leg with `json-schema-file`, a `structured-output-file` + holding the result's `structured_output`; the action writes each of the + last two only after a credential scan, and a hit on the structured output + fails the step. Read the structured output from that file, never from the + unscanned `execution-file`. Every log line records the `lane` and the funding `role` whose secrets the caller passed. The secret names are always the role's, `_CLAUDE_API_KEY` and `_CLAUDE_CODE_OAUTH_TOKEN` with the role upper-snake. The job summary