mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 01:57:31 +01:00
fix(hermes): verify approved qualification lockfile
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
12098ba3fd
commit
805d787b16
5 files changed
+82
-8
No files matched your search
@@ -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 }}
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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) };
|
||||
}
|
||||
Reference in new issue
Block a user