Files
PaperClipAI/packages/paperclip-runner/src/eval/eval-bundle.ts
Dotta 5458940a6e feat(runner): add offline evaluation tooling (#12653)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Paperclip Runner needs repeatable evaluation contracts.
> - Evaluation code must stay separate from provider launch and
production orchestration.
> - Offline fixtures need stable compatibility, scoring, traceability,
and report rules.
> - Published Runner consumers need only the supported evaluation
contract surface.
> - This pull request adds offline evaluation tooling and a
workspace-private matrix kernel.
> - The benefit is deterministic evaluation without credentials or paid
provider calls.

## Linked Issues or Issue Description

Refs #11297

This pull request extracts the offline evaluation unit from the earlier
aggregate Runner work.

## What Changed

- Add a workspace-private, provider-neutral evaluation matrix kernel.
- Add the public `@paperclipai/paperclip-runner/evals` compatibility and
native execution contracts.
- Add fail-closed runnerd artifact and protocol compatibility checks.
- Add deterministic workflow catalogs, scoring, traceability, and report
generation.
- Add sanitized Codex, OpenCode, and ACPX fixtures.
- Add package-boundary and clean-consumer checks.
- Add the eval package manifest to the Docker dependency stage.
- Add the generated protocol fixture digest without changing the
lockfile.

## Verification

GitHub Actions must run:

- Runner TypeScript and Rust type checks.
- Runner unit and protocol tests.
- Evaluation kernel tests.
- Workflow traceability checks.
- Clean-consumer and package-boundary checks.
- Repository test, type-check, build, policy, and security gates.

No local test command was run. The repository owner requested
GitHub-only verification.

## Risks

This is a large greenfield review surface with 51 files. The code does
not launch a live provider or load credentials. Package and protocol
drift fail closed. The workspace lockfile remains under the existing
CI-owned process.

## Model Used

OpenAI Codex with the GPT-5 agent model. The work used high reasoning,
repository inspection, tool use, and parallel code review.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [ ] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
2026-09-01 05:19:47 -05:00

321 lines
11 KiB
TypeScript

import { createHash } from "node:crypto";
/**
* A versioned candidate bundle for the runner eval vertical slice.
*
* A bundle declares every reproducibility input for one evaluated candidate —
* provider/runtime, model, launch context, prompt policy, grants, runner, and
* control-plane adapter — plus any deterministic fault injection. Two runs with
* the same {@link bundleId} were driven by the same declared configuration, so a
* scorecard is only comparable against another scorecard carrying the same id.
*
* The bundle is a *declaration*, not a secret store: it must never carry a
* credential, hidden company identifier, or raw secret payload. Grants are typed
* canonical claim strings (`domain:action` or `domain:resource:action`), not
* secret material. Callers should
* run {@link assertBundleSecretFree} before use and persist only the digested
* record returned by {@link bundleEvidenceDeclaration}.
*/
export const EVAL_BUNDLE_SCHEMA = "paperclip.runner.eval-bundle.v1" as const;
export const EVAL_BUNDLE_EVIDENCE_SCHEMA =
"paperclip.runner.eval-bundle-evidence.v1" as const;
export interface EvalBundleProvider {
/** Session runtime that owns the provider process, e.g. `runnerd`. */
runtime: string;
/** Wire transport to the provider, e.g. `codex-app-server`. */
transport: string;
/** Negotiated provider protocol/capability version. */
protocolVersion: string;
}
export interface EvalBundleModel {
/** Provider model id, e.g. `gpt-5-codex`. */
id: string;
reasoningEffort?: string;
temperature?: number;
}
export interface EvalBundleLaunchContext {
/**
* Class of working directory the candidate launched into. A *class*, never an
* absolute host path — absolute paths can leak usernames and layout secrets.
*/
workingDirectoryClass: "ephemeral-fixture" | "workspace-checkout" | "clean-room";
scenarioId: string;
turnTimeoutMs: number;
}
export interface EvalBundlePromptPolicy {
id: string;
/** Prompt template used when a semantic call is required. */
callTemplate: string;
/** Prompt template used when restraint (no call) is the correct behavior. */
restraintTemplate: string;
}
export interface EvalBundleRunner {
/** Runner package, e.g. `@paperclipai/paperclip-runner`. */
package: string;
/** Runner binary that hosts the provider session, e.g. `paperclip-runnerd`. */
binary: string;
version: string;
}
export interface EvalBundleControlPlaneAdapter {
kind: "mock";
/** Adapter contract/schema id the observations are normalized against. */
contract: string;
}
export type EvalFaultClass =
| "authorization"
| "conflict"
| "retry"
| "provider_capability";
export interface EvalBundleFaultInjection {
id: string;
class: EvalFaultClass;
description: string;
}
export interface EvalBundle {
schema: typeof EVAL_BUNDLE_SCHEMA;
provider: EvalBundleProvider;
model: EvalBundleModel;
launchContext: EvalBundleLaunchContext;
promptPolicy: EvalBundlePromptPolicy;
/** Typed canonical claim strings unlocked for the candidate. */
grants: string[];
runner: EvalBundleRunner;
controlPlaneAdapter: EvalBundleControlPlaneAdapter;
/** Deterministic faults injected for this candidate; empty for a clean run. */
faultInjection: EvalBundleFaultInjection[];
}
/**
* Persistable bundle evidence. Free-form declaration strings are represented by
* content digests so report artifacts cannot become an accidental secret store.
*/
export interface EvalBundleEvidenceDeclaration {
schema: typeof EVAL_BUNDLE_EVIDENCE_SCHEMA;
sourceSchema: typeof EVAL_BUNDLE_SCHEMA;
contentSha256: string;
provider: {
runtimeSha256: string;
transportSha256: string;
protocolVersionSha256: string;
};
model: {
idSha256: string;
reasoningEffortSha256?: string;
temperature?: number;
};
launchContext: {
workingDirectoryClassSha256: string;
scenarioIdSha256: string;
turnTimeoutMs: number;
};
promptPolicy: {
idSha256: string;
callTemplateSha256: string;
restraintTemplateSha256: string;
};
grants: { count: number; claimsSha256: string };
runner: {
packageSha256: string;
binarySha256: string;
versionSha256: string;
};
controlPlaneAdapter: { kind: "mock"; contractSha256: string };
faultInjection: { count: number; declarationSha256: string };
}
export class EvalBundleSecretError extends Error {
constructor(
message: string,
readonly path: string,
) {
super(message);
this.name = "EvalBundleSecretError";
}
}
/** Stable, key-sorted JSON so the bundle id is independent of field order. */
function canonicalJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
if (typeof value === "object" && value !== null) {
const record = value as Record<string, unknown>;
return `{${Object.keys(record)
.sort()
.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`)
.join(",")}}`;
}
return JSON.stringify(value) ?? "null";
}
/**
* Deterministic content-addressed id for a bundle. Same declared configuration
* (in any field order) always yields the same id; any change yields a new one.
*/
export function bundleId(bundle: EvalBundle): string {
const digest = createHash("sha256").update(canonicalJson(bundle)).digest("hex");
return `evb-${digest.slice(0, 16)}`;
}
function sha256(value: unknown): string {
return `sha256:${createHash("sha256").update(canonicalJson(value)).digest("hex")}`;
}
/** Object keys that must never appear in a declared bundle. */
const FORBIDDEN_KEY = /(^|[-_])(api[-_]?key|secret|token|password|passwd|credential|private[-_]?key|authorization|bearer|session[-_]?token)($|[-_])/i;
/** Value shapes that look like leaked credentials regardless of their key. */
const SECRET_VALUE_PATTERNS: Array<{ id: string; pattern: RegExp }> = [
{ id: "provider-key", pattern: /\bsk-[A-Za-z0-9_-]{12,}\b/ },
{ id: "github-token", pattern: /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/ },
{ id: "slack-token", pattern: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/ },
{ id: "google-api-key", pattern: /\bAIza[A-Za-z0-9_-]{20,}\b/ },
{ id: "stripe-live-key", pattern: /\b(?:sk|rk)_live_[A-Za-z0-9]{12,}\b/ },
{ id: "npm-token", pattern: /\bnpm_[A-Za-z0-9]{20,}\b/ },
{ id: "pypi-token", pattern: /\bpypi-[A-Za-z0-9_-]{20,}\b/ },
{ id: "bearer-token", pattern: /\bBearer\s+[^\s"'`]{12,}/i },
{ id: "aws-access-key", pattern: /\bAKIA[0-9A-Z]{16}\b/ },
{ id: "jwt", pattern: /\bey[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/ },
{ id: "pem-block", pattern: /-----BEGIN (?:RSA |EC )?PRIVATE KEY-----/ },
{
id: "credential-assignment",
pattern: /\b(?:api[-_]?key|access[-_]?token|auth[-_]?token|session[-_]?token|token|authorization|bearer|secret|password|passwd|credential|private[-_]?key)\s*(?:=|:)\s*["']?[^\s"'`,}]{8,}/i,
},
{
id: "json-credential-field",
pattern: /"(?:api[-_]?key|access[-_]?token|auth[-_]?token|session[-_]?token|token|authorization|bearer|secret|password|passwd|credential|private[-_]?key)"\s*:\s*"[^"\\]{4,}/i,
},
{
id: "escaped-json-credential-field",
pattern: /\\"(?:api[-_]?key|access[-_]?token|auth[-_]?token|session[-_]?token|token|authorization|bearer|secret|password|passwd|credential|private[-_]?key)\\"\s*:\s*\\"(?:\\\\.|[^"\\]){4,}/i,
},
];
/**
* Central fail-closed scanner for eval inputs and serialized evidence. Error
* messages identify only the pattern and location; rejected content is never
* echoed back into logs.
*/
export function assertEvalArtifactSecretFree(
value: unknown,
rootPath = "eval artifact",
): void {
const visit = (entry: unknown, path: string): void => {
if (typeof entry === "string") {
for (const { id, pattern } of SECRET_VALUE_PATTERNS) {
if (pattern.test(entry)) {
throw new EvalBundleSecretError(
`secret-shaped ${id} detected at ${path}`,
path,
);
}
}
return;
}
if (Array.isArray(entry)) {
entry.forEach((item, index) => visit(item, `${path}[${index}]`));
return;
}
if (typeof entry === "object" && entry !== null) {
for (const [key, child] of Object.entries(entry)) {
const childPath = `${path}.${key}`;
if (FORBIDDEN_KEY.test(key)) {
throw new EvalBundleSecretError(
`forbidden credential field detected at ${childPath}`,
childPath,
);
}
visit(child, childPath);
}
}
};
visit(value, rootPath);
}
/**
* Throws {@link EvalBundleSecretError} if the bundle carries a secret-shaped key
* or value, or an untyped grant. Persist the output of
* {@link bundleEvidenceDeclaration}, never the input bundle itself.
*/
export function assertBundleSecretFree(bundle: EvalBundle): void {
assertEvalArtifactSecretFree(bundle, "bundle");
for (const [index, grant] of bundle.grants.entries()) {
if (!/^[a-z0-9_]+(?::[a-z0-9_]+){1,2}$/.test(grant)) {
throw new EvalBundleSecretError(
`grant at grants[${index}] is not a typed canonical capability claim`,
`grants[${index}]`,
);
}
}
}
/** Convert a validated bundle into an explicit, free-form-content-free record. */
export function bundleEvidenceDeclaration(
bundle: EvalBundle,
): EvalBundleEvidenceDeclaration {
assertBundleSecretFree(bundle);
return {
schema: EVAL_BUNDLE_EVIDENCE_SCHEMA,
sourceSchema: EVAL_BUNDLE_SCHEMA,
contentSha256: sha256(bundle),
provider: {
runtimeSha256: sha256(bundle.provider.runtime),
transportSha256: sha256(bundle.provider.transport),
protocolVersionSha256: sha256(bundle.provider.protocolVersion),
},
model: {
idSha256: sha256(bundle.model.id),
...(bundle.model.reasoningEffort === undefined
? {}
: { reasoningEffortSha256: sha256(bundle.model.reasoningEffort) }),
...(bundle.model.temperature === undefined
? {}
: { temperature: bundle.model.temperature }),
},
launchContext: {
workingDirectoryClassSha256: sha256(bundle.launchContext.workingDirectoryClass),
scenarioIdSha256: sha256(bundle.launchContext.scenarioId),
turnTimeoutMs: bundle.launchContext.turnTimeoutMs,
},
promptPolicy: {
idSha256: sha256(bundle.promptPolicy.id),
callTemplateSha256: sha256(bundle.promptPolicy.callTemplate),
restraintTemplateSha256: sha256(bundle.promptPolicy.restraintTemplate),
},
grants: {
count: bundle.grants.length,
claimsSha256: sha256(bundle.grants),
},
runner: {
packageSha256: sha256(bundle.runner.package),
binarySha256: sha256(bundle.runner.binary),
versionSha256: sha256(bundle.runner.version),
},
controlPlaneAdapter: {
kind: "mock",
contractSha256: sha256(bundle.controlPlaneAdapter.contract),
},
faultInjection: {
count: bundle.faultInjection.length,
declarationSha256: sha256(bundle.faultInjection),
},
};
}
/** A redacted, one-line human description safe to print in a report header. */
export function describeBundle(bundle: EvalBundle): { bundleId: string; summary: string } {
assertBundleSecretFree(bundle);
const id = bundleId(bundle);
return {
bundleId: id,
summary: `eval bundle ${id} · ${bundle.grants.length} grants · ${bundle.faultInjection.length} faults`,
};
}