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
3 changes: 2 additions & 1 deletion .github/actions/run-claude-cli/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,8 @@ outputs:
description: >-
The leg's CLI messages as a JSON array (the execution_file shape
claude-code-action writes). It holds every tool result and model reply
and is never scanned for credentials, so do not upload it.
and this action never scans it for credentials, so upload it only
through upload-lane-transcript, which scans and seals it.
value: ${{ steps.run.outputs.execution-file }}
log-file:
description: JSONL execution log, one object for the leg; empty when the credential scan failed.
Expand Down
66 changes: 66 additions & 0 deletions .github/actions/upload-lane-transcript/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
name: Upload lane transcript
description: >
Keep one lane leg's transcript (run-claude-cli's execution-file) for 7 days
as an encrypted artifact, so a puller outside CI can copy it to a private
sink. CI holds only the public key: it can seal a transcript but never open
one. The plaintext is scanned first (../scan-upload); a credential in it
fails the step with nothing sealed. It is then gzipped and sealed in memory
as one record, and only the sealed file is uploaded, as
`lane-transcript-<job-id>-<run attempt>` holding `transcript.json.gz.sealed`.
The record is claude-code-plugins' seal-lane-output format for the payload
name `transcript.json.gz`, bound to GITHUB_RUN_ID and job-id, so a record
moved to another run or job name does not open. An empty public key skips
with a notice and scans nothing; a missing, empty or linked execution file
uploads nothing; a malformed key or job-id fails the step. Callers run it
with `if: always()` and `continue-on-error: true` after the leg, so a failed
leg's transcript is kept and no failure here changes the lane's outcome.
Needs node on PATH.

inputs:
execution-file:
description: The leg's execution file, run-claude-cli's execution-file output; empty when no leg ran.
required: true
public-key:
description: "The caller's LANE_TRANSCRIPT_PUBLIC_KEY variable: one line, `x25519 <key id> <base64 raw key>`."
required: true
job-id:
description: >-
A fixed key for the leg, unique in the run, matching
`^[A-Za-z_][A-Za-z0-9_-]{0,99}$`; the puller reads it back from the
artifact name (everything between `lane-transcript-` and the last `-`).
required: true

runs:
using: composite
steps:
# The plaintext, before the seal: the sealed upload is ciphertext, which
# no scan can read. A hit fails here and nothing is sealed or uploaded.
- name: Scan the transcript for credentials
id: scan
if: inputs.public-key != ''
shell: bash
env:
SCAN_PATHS: ${{ inputs.execution-file }}
run: node "$GITHUB_ACTION_PATH/../scan-upload/scan.cjs"

- name: Seal the transcript
id: seal
if: inputs.public-key == '' || steps.scan.outputs.clean == 'true'
shell: bash
env:
EXECUTION_FILE: ${{ inputs.execution-file }}
PUBLIC_KEY: ${{ inputs.public-key }}
JOB_ID: ${{ inputs.job-id }}
run: node "$GITHUB_ACTION_PATH/seal-transcript.cjs"

- name: Upload the sealed transcript
if: steps.seal.outputs.file != ''
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: lane-transcript-${{ inputs.job-id }}-${{ github.run_attempt }}
path: ${{ steps.seal.outputs.file }}
retention-days: 7
# Ciphertext does not compress.
compression-level: 0
if-no-files-found: error
overwrite: false
139 changes: 139 additions & 0 deletions .github/actions/upload-lane-transcript/seal-transcript.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// Seal one lane leg's transcript, gzipped, to the transcript public key as one
// record, written under RUNNER_TEMP for the upload step. The plaintext stays in
// memory: only the sealed record reaches disk, and nothing here prints it.
//
// A record is version (1) | key id (8) | ephemeral public key (32) | nonce (12)
// | ciphertext | tag (16). Each record gets its own ephemeral X25519 key and
// random nonce; the key comes from HKDF-SHA256 over the shared secret, and
// AES-256-GCM binds the header, the payload name, the run id and the job id.
// The format is claude-code-plugins' seal-lane-output; its open-lane-output
// opens these records, so a change here must keep them byte-compatible.
"use strict";

const crypto = require("node:crypto");
const fs = require("node:fs");
const path = require("node:path");
const zlib = require("node:zlib");

const VERSION = 1;
const PAYLOAD = "transcript.json.gz";
const JOB_ID = /^[A-Za-z_][A-Za-z0-9_-]{0,99}$/u;
const RUN_ID = /^[1-9][0-9]{0,19}$/u;
const KEY_LINE = /^x25519 ([0-9a-f]{16}) ([A-Za-z0-9+/]{43}=)$/u;
const SPKI_PREFIX = Buffer.from("302a300506032b656e032100", "hex");

class SealError extends Error {}

const keyIdOf = (raw) =>
crypto.createHash("sha256").update(raw).digest("hex").slice(0, 16);

/** The one-line key `x25519 <key id> <base64 raw key>`; the id is the first 16 hex digits of the raw key's SHA-256. */
function parsePublicKey(text) {
const match = KEY_LINE.exec(String(text).trim());
const raw = match && Buffer.from(match[2], "base64");
if (!match || raw.length !== 32 || keyIdOf(raw) !== match[1]) {
throw new SealError(
"the public key holds no x25519 key line with a matching key id",
);
}
return {
keyId: match[1],
raw,
key: crypto.createPublicKey({
key: Buffer.concat([SPKI_PREFIX, raw]),
format: "der",
type: "spki",
}),
};
}

function sealRecord(recipient, name, data, runId, jobId) {
if (!RUN_ID.test(String(runId))) {
throw new SealError("GITHUB_RUN_ID is not a run id");
}
const bound = Buffer.from(
`lane-output/v${VERSION}\0${name}\0${runId}\0${jobId}`,
);
const ephemeral = crypto.generateKeyPairSync("x25519");
const epk = ephemeral.publicKey
.export({ format: "der", type: "spki" })
.subarray(-32);
const nonce = crypto.randomBytes(12);
const header = Buffer.concat([
Buffer.from([VERSION]),
Buffer.from(recipient.keyId, "hex"),
epk,
nonce,
]);
const shared = crypto.diffieHellman({
privateKey: ephemeral.privateKey,
publicKey: recipient.key,
});
const key = Buffer.from(
crypto.hkdfSync(
"sha256",
shared,
Buffer.concat([epk, recipient.raw]),
Buffer.concat([bound, Buffer.from([0]), header]),
32,
),
);
const cipher = crypto.createCipheriv("aes-256-gcm", key, nonce);
cipher.setAAD(Buffer.concat([header, bound]));
return Buffer.concat([
header,
cipher.update(data),
cipher.final(),
cipher.getAuthTag(),
]);
}

function main(env = process.env) {
if (String(env.PUBLIC_KEY ?? "").trim() === "") {
process.stdout.write(
"::notice::upload-lane-transcript: the public key input is empty, so this leg's transcript is not uploaded\n",
);
return;
}
// The key first: a malformed key fails whether or not a leg ran.
const recipient = parsePublicKey(env.PUBLIC_KEY);
const jobId = env.JOB_ID ?? "";
if (!JOB_ID.test(jobId)) {
throw new SealError("the job-id input is not a job key");
}
const file = env.EXECUTION_FILE ?? "";
let stat;
try {
stat = file === "" ? undefined : fs.lstatSync(file);
} catch {
stat = undefined;
}
// No regular, non-empty file: no leg ran, so there is nothing to keep.
if (!stat?.isFile() || stat.size === 0) return;
const record = sealRecord(
recipient,
PAYLOAD,
zlib.gzipSync(fs.readFileSync(file)),
env.GITHUB_RUN_ID,
jobId,
);
const directory = path.join(env.RUNNER_TEMP, "lane-transcript", jobId);
fs.rmSync(directory, { recursive: true, force: true });
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
const sealed = path.join(directory, `${PAYLOAD}.sealed`);
fs.writeFileSync(sealed, record, { flag: "wx" });
fs.appendFileSync(env.GITHUB_OUTPUT, `file=${sealed}\n`);
}

module.exports = { main, parsePublicKey, sealRecord };

if (require.main === module) {
try {
main();
} catch (error) {
process.stdout.write(
`::error::upload-lane-transcript: ${error instanceof SealError ? error.message : `the transcript could not be sealed (${error.name})`}\n`,
);
process.exitCode = 1;
}
}
42 changes: 42 additions & 0 deletions .github/scripts/intake-triage-legs.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,48 @@ test("the fallback job repeats the subscription job's steps but for the credenti
}
});

test("every model job's leg uploads its sealed transcript under a job id unique in the run", () => {
const artifact = /^lane-transcript-(.+)-([1-9][0-9]{0,4})$/u;
const jobId = /^[A-Za-z_][A-Za-z0-9_-]{0,99}$/u;
const seen = new Set();
for (const [name, job] of Object.entries({
claude,
subscription,
fallback,
})) {
const uploads = job.steps.filter(
(step) => step.uses === "$/.github/actions/upload-lane-transcript",
);
assert.equal(uploads.length, 1, name);
const [upload] = uploads;
const outcome = job.steps.findIndex((step) => step.id === "review-outcome");
assert.ok(outcome >= 0, name);
assert.ok(job.steps.indexOf(upload) > outcome, name);
assert.equal(upload["continue-on-error"], true);
assert.equal(
upload.if,
"always() && steps.claude.outputs.execution-file != ''",
);
assert.equal(
upload.with["execution-file"],
`\${{ steps.claude.outputs.execution-file }}`,
);
assert.equal(
upload.with["public-key"],
`\${{ inputs.lane-transcript-public-key }}`,
);
const id = upload.with["job-id"].replace(`\${{ github.job }}`, name);
assert.match(id, jobId);
assert.equal(seen.has(id), false, id);
seen.add(id);
assert.equal(artifact.exec(`lane-transcript-${id}-1`)?.[1], id);
}
assert.equal(
workflow.on.workflow_call.inputs["lane-transcript-public-key"].default,
"",
);
});

test("the subscription job runs on a fixed GitHub-hosted label, after the claude job", () => {
assert.equal(subscription["runs-on"], "ubuntu-24.04");
assert.equal(subscription.needs, "claude");
Expand Down
55 changes: 55 additions & 0 deletions .github/scripts/pr-review-cli.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -3467,6 +3467,61 @@ test("a chunk that failed, did not run or returned no verdict keeps the review i
assert.equal(JSON.parse(one.review).coverage.over_budget, 700000);
});

// --- every model leg keeps a sealed transcript ---

test("every model leg uploads its sealed transcript under a job id unique in the run", () => {
const artifact = /^lane-transcript-(.+)-([1-9][0-9]{0,4})$/u;
const jobId = /^[A-Za-z_][A-Za-z0-9_-]{0,99}$/u;
const seen = new Set();
for (const [name, job] of Object.entries({
"review-key": key,
"review-subscription": subscription,
"review-fallback": fallback,
})) {
const outcome = job.steps.findIndex((s) => s.id === "review-outcome");
assert.ok(outcome >= 0, `${name} has the outcome step`);
const uploads = job.steps
.map((s, index) => ({ s, index }))
.filter(({ s }) => s.uses === "$/.github/actions/upload-lane-transcript");
const legs = job.steps.filter(
(s) => s.uses === "$/.github/actions/run-claude-cli",
);
assert.equal(uploads.length, legs.length, name);
assert.equal(legs.length, 4, name);
legs.forEach((leg, i) => {
const { s, index } = uploads[i];
assert.ok(index > outcome, `${name}: ${s.name} follows the outcome step`);
assert.equal(s["continue-on-error"], true);
assert.equal(
s.if,
`always() && steps.${leg.id}.outputs.execution-file != ''`,
);
assert.equal(
s.with["execution-file"],
`\${{ steps.${leg.id}.outputs.execution-file }}`,
);
assert.equal(
s.with["public-key"],
`\${{ inputs.lane-transcript-public-key }}`,
);
for (const lane of ["code", "security"]) {
const id = s.with["job-id"]
.replace(`\${{ inputs.lane }}`, lane)
.replace(`\${{ github.job }}`, name);
assert.match(id, jobId);
assert.equal(seen.has(id), false, id);
seen.add(id);
assert.equal(artifact.exec(`lane-transcript-${id}-1`)?.[1], id);
}
});
}
assert.equal(seen.size, 24);
assert.equal(
workflow.on.workflow_call.inputs["lane-transcript-public-key"].default,
"",
);
});

// --- every artifact the lane uploads is scanned for credentials first ---

const SCAN_ACTION = "$/.github/actions/scan-upload";
Expand Down
Loading
Loading