diff --git a/.github/workflows/runner-full-stack-e2e.yml b/.github/workflows/runner-full-stack-e2e.yml index 66b8de5fb06..772d61f062e 100644 --- a/.github/workflows/runner-full-stack-e2e.yml +++ b/.github/workflows/runner-full-stack-e2e.yml @@ -1085,6 +1085,7 @@ jobs: PAPERCLIP_E2E_DAYTONA_IMAGE: ${{ needs.daytona_image.outputs.image }} PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH: ${{ github.workspace }}/packages/paperclip-runner/provider-pack PAPERCLIP_E2E_CAMPAIGN_ID: gha-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.executionId }} + PAPERCLIP_RUNNER_E2E_LOCK_SHA256: ${{ needs.target_lock.outputs.lock_sha256 }} PAPERCLIP_RUNNER_E2E_SOURCE_SHA: ${{ needs.authorize.outputs.target_sha }} PAPERCLIP_RUNNER_E2E_SOURCE_REF: ${{ needs.authorize.outputs.target_ref }} PAPERCLIP_PLAYWRIGHT_CHANNEL: ${{ needs.authorize.outputs.playwright_channel }} diff --git a/doc/plans/2026-10-06-hermes-native-runner.md b/doc/plans/2026-10-06-hermes-native-runner.md index 546981c3315..7b6e3f5a4f4 100644 --- a/doc/plans/2026-10-06-hermes-native-runner.md +++ b/doc/plans/2026-10-06-hermes-native-runner.md @@ -855,3 +855,12 @@ billing. The initial sandbox socket denial is a separate infrastructure outcome. Hermes remains pending the full original Mac/Daytona, subscription, connection, attachment/control/state/routine and public-consumer gates. The ledger still retains $23 against the $25 cap; unknown charges are not counted as free. + +Review of `12098ba3` found that the source check rejected the trusted paid +workflow's resolved target lock. Admission now allows only its unstaged tracked +`pnpm-lock.yaml` replacement after independently hashing the complete regular +file against the workflow's approved SHA-256. The campaign receipt preserves +that digest and reports the actual dirty state instead of calling the working +tree clean. Other edits, staged replacements, missing/mismatched approval, +deletions and symlinks remain rejected. All eighteen source-admission tests and +Product E2E TypeScript pass. The root lockfile itself remains unchanged. diff --git a/tests/runner-e2e/README.md b/tests/runner-e2e/README.md index b42f76672c6..3dbd9183a4c 100644 --- a/tests/runner-e2e/README.md +++ b/tests/runner-e2e/README.md @@ -1489,6 +1489,10 @@ Detached checkouts record `HEAD` unless an explicitly matched target SHA supplie its ref. Read-only `--list` and `--matrix-json` discovery require no source admission or credentials. This receipt identifies the controller; runtime/image provenance and live behavior still require their independent checks. +The paid workflow may restore its reviewed target lockfile: only an unstaged +modification of the tracked `pnpm-lock.yaml` is allowed, and its complete bytes +must match `PAPERCLIP_RUNNER_E2E_LOCK_SHA256`. The receipt records that approved +digest and the actual working-tree cleanliness. Other edits remain rejected. When a Hermes cell is selected, the trusted workflow provisions its pinned Python closure on Linux before exposing credentials, and selects the same diff --git a/tests/runner-e2e/hermes-source.test.ts b/tests/runner-e2e/hermes-source.test.ts index 19a12f0c2f5..43452025681 100644 --- a/tests/runner-e2e/hermes-source.test.ts +++ b/tests/runner-e2e/hermes-source.test.ts @@ -1,5 +1,6 @@ import { execFileSync } from "node:child_process"; -import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { devNull, tmpdir } from "node:os"; import { afterEach, describe, expect, it } from "vitest"; @@ -16,7 +17,8 @@ function checkout() { env: { PATH: process.env.PATH, GIT_CONFIG_GLOBAL: devNull, GIT_CONFIG_NOSYSTEM: "1" } }).trim(); git("init", "-b", "qualification"); writeFileSync(join(root, "source.txt"), "original\n"); - git("add", "source.txt"); + writeFileSync(join(root, "pnpm-lock.yaml"), "original lock\n"); + git("add", "source.txt", "pnpm-lock.yaml"); git("-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "-c", "commit.gpgsign=false", "-c", `core.hooksPath=${devNull}`, "commit", "-m", "fixture"); return { root, git, sha: git("rev-parse", "HEAD") }; } @@ -62,3 +64,41 @@ describe("Hermes qualification controller source admission", () => { expect(() => prepareHermesQualificationSource(selected, "/does-not-exist", env)).toThrow("readable Git source before credentials"); }); }); + +describe("approved qualification dependency lock", () => { + const resolved = "reviewed resolved lock\n"; + const digest = createHash("sha256").update(resolved).digest("hex"); + it("admits only the independently verified lock replacement and records its digest and dirty state", () => { + const f = checkout(); writeFileSync(join(f.root, "pnpm-lock.yaml"), resolved); + const env = { PATH: process.env.PATH, PAPERCLIP_RUNNER_E2E_LOCK_SHA256: digest }; + expect(prepareHermesQualificationSource(selected, f.root, env)).toMatchObject({ + sha: f.sha, workingTreeClean: false, approvedLockSha256: digest, + }); + expect(resolveRunnerE2ESource(null, env).sha).toBe(f.sha); + }); + it("still records a verified lock when it already matches the commit", () => { + const f = checkout(); + const originalDigest = createHash("sha256").update("original lock\n").digest("hex"); + expect(prepareHermesQualificationSource(selected, f.root, { + PATH: process.env.PATH, PAPERCLIP_RUNNER_E2E_LOCK_SHA256: originalDigest, + })).toMatchObject({ workingTreeClean: true, approvedLockSha256: originalDigest }); + }); + it.each(["missing", "incorrect", "malformed"])("rejects a replacement with %s approval before recording source", approval => { + const f = checkout(); writeFileSync(join(f.root, "pnpm-lock.yaml"), resolved); + const env: NodeJS.ProcessEnv = { PATH: process.env.PATH, + ...(approval === "missing" ? {} : { PAPERCLIP_RUNNER_E2E_LOCK_SHA256: approval === "incorrect" ? "a".repeat(64) : "not-a-digest" }) }; + expect(() => prepareHermesQualificationSource(selected, f.root, env)).toThrow(); + expect(env.PAPERCLIP_RUNNER_E2E_SOURCE_SHA).toBeUndefined(); + }); + it.each(["source", "untracked", "staged", "deleted", "symlink"])("rejects %s changes even alongside an approved digest", kind => { + const f = checkout(); const lock = join(f.root, "pnpm-lock.yaml"); writeFileSync(lock, resolved); + if (kind === "source") writeFileSync(join(f.root, "source.txt"), "changed\n"); + if (kind === "untracked") writeFileSync(join(f.root, "extra.txt"), "changed\n"); + if (kind === "staged") f.git("add", "pnpm-lock.yaml"); + if (kind === "deleted" || kind === "symlink") rmSync(lock); + if (kind === "symlink") { writeFileSync(join(f.root, "resolved.txt"), resolved); symlinkSync("resolved.txt", lock); } + expect(() => prepareHermesQualificationSource(selected, f.root, { + PATH: process.env.PATH, PAPERCLIP_RUNNER_E2E_LOCK_SHA256: digest, + })).toThrow(); + }); +}); diff --git a/tests/runner-e2e/hermes-source.ts b/tests/runner-e2e/hermes-source.ts index 3e35f75eb26..a6a31af0471 100644 --- a/tests/runner-e2e/hermes-source.ts +++ b/tests/runner-e2e/hermes-source.ts @@ -1,5 +1,8 @@ import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { closeSync, constants, fstatSync, openSync, readFileSync } from "node:fs"; import { devNull } from "node:os"; +import { join } from "node:path"; import type { MatrixExecution } from "./types.js"; import { resolveRunnerE2ESource } from "./source.js"; @@ -20,27 +23,44 @@ export function prepareHermesQualificationSource( return execFileSync("git", ["-c", "core.fsmonitor=false", ...args], { cwd: repositoryRoot, env: gitEnvironment, encoding: "utf8", timeout: 10_000, maxBuffer: 1024 * 1024, stdio: ["ignore", "pipe", "pipe"], - }).trim(); + }); } catch { throw new Error("Hermes qualification needs readable Git source before credentials; use a clean checkout."); } }; - const sha = git(["rev-parse", "HEAD"]); + const sha = git(["rev-parse", "HEAD"]).trim(); if (!/^[a-f0-9]{40}$/.test(sha)) throw new Error("Hermes qualification source SHA is invalid."); - if (git(["status", "--porcelain", "--untracked-files=normal"])) { + const changes = git(["status", "--porcelain", "-z", "--untracked-files=normal"]).split("\0").filter(Boolean); + const approvedLock = environment.PAPERCLIP_RUNNER_E2E_LOCK_SHA256?.trim(); + if (approvedLock && !/^[a-f0-9]{64}$/.test(approvedLock)) throw new Error("Hermes qualification approved lock digest is invalid."); + if (changes.some(change => change !== " M pnpm-lock.yaml" || !approvedLock)) { throw new Error("Hermes qualification needs a clean checkout before credentials; commit or remove source changes."); } + if (approvedLock) { + let fd: number | undefined; + try { + fd = openSync(join(repositoryRoot, "pnpm-lock.yaml"), constants.O_RDONLY | constants.O_NOFOLLOW); + const stat = fstatSync(fd); + if (!stat.isFile() || stat.size > 16 * 1024 * 1024 + || createHash("sha256").update(readFileSync(fd)).digest("hex") !== approvedLock) throw new Error(); + } catch { + throw new Error("Hermes qualification lockfile differs from the approved digest before credentials."); + } finally { + if (fd !== undefined) closeSync(fd); + } + } const requestedSha = environment.PAPERCLIP_RUNNER_E2E_SOURCE_SHA?.trim(); if (requestedSha && requestedSha !== sha) { throw new Error("Hermes qualification source SHA differs from the checked-out controller before credentials."); } - const ref = git(["rev-parse", "--symbolic-full-name", "HEAD"]); - if (git(["rev-parse", "HEAD"]) !== sha) throw new Error("Hermes qualification source changed during admission."); + const ref = git(["rev-parse", "--symbolic-full-name", "HEAD"]).trim(); + if (git(["rev-parse", "HEAD"]).trim() !== sha) throw new Error("Hermes qualification source changed during admission."); // These explicit values survive local-env loading and reach every child and // synthetic failure. A workflow's own GITHUB_SHA may identify another commit. environment.PAPERCLIP_RUNNER_E2E_SOURCE_SHA = sha; environment.PAPERCLIP_RUNNER_E2E_SOURCE_REF = (requestedSha && environment.PAPERCLIP_RUNNER_E2E_SOURCE_REF?.trim()) || ref; - return { schema: "paperclip.hermes.e2e-source/v1", workingTreeClean: true, + return { schema: "paperclip.hermes.e2e-source/v1", workingTreeClean: changes.length === 0, + ...(approvedLock ? { approvedLockSha256: approvedLock } : {}), ...resolveRunnerE2ESource(null, environment) }; }