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
13 changes: 11 additions & 2 deletions .github/actions/run-claude-cli/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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 }}
Expand Down
30 changes: 30 additions & 0 deletions .github/actions/run-claude-cli/lane.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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(
Expand All @@ -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]);
Expand Down
94 changes: 93 additions & 1 deletion .github/actions/run-claude-cli/lane.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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 });
Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, `<ROLE>_CLAUDE_API_KEY` and
`<ROLE>_CLAUDE_CODE_OAUTH_TOKEN` with the role upper-snake. The job summary
Expand Down
Loading