mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 04:02:18 +01:00
Merge additional retained-evidence review repairs
Preserve tracing behavior while incorporating immutable missing-provider binding and atomic credential-redaction replacement. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
1197348567
4 files changed
+65
-17
No files matched your search
@@ -125,23 +125,22 @@ export async function readAttemptControlledEvidence(directory, options) {
|
||||
if (reportBytes) {
|
||||
let report, provider;
|
||||
try { report = JSON.parse(reportBytes.toString('utf8')); provider = providerBytes ? JSON.parse(providerBytes.toString('utf8')) : undefined; }
|
||||
catch { return bundle; }
|
||||
if (providerBytes || report?.providerArtifactSha256 === null) {
|
||||
const expected = controlledResultArtifact(provider, report, allocation), actual = { ...artifact };
|
||||
delete expected.provenance; delete actual.provenance;
|
||||
if (actual.controllerEvidenceError !== undefined) {
|
||||
if (typeof actual.controllerEvidenceError !== 'string' || !actual.controllerEvidenceError.trim()
|
||||
|| typeof actual.accountingError !== 'string' || !actual.accountingError.trim()
|
||||
|| canonical(actual.accountingFailure) !== canonical({ class: 'provider_budget_coverage_unknown', category: 'provider_budget', retryable: false })) {
|
||||
throw new Error('Invalid immutable controller retention failure');
|
||||
}
|
||||
delete actual.controllerEvidenceError;
|
||||
if (expected.accountingFailure) { actual.accountingFailure = expected.accountingFailure; actual.accountingError = expected.accountingError; }
|
||||
else { delete actual.accountingFailure; delete actual.accountingError; }
|
||||
catch { throw new Error('Malformed immutable controller result proof'); }
|
||||
const expected = controlledResultArtifact(provider, report, allocation), actual = { ...artifact };
|
||||
delete expected.provenance; delete actual.provenance;
|
||||
if (actual.controllerEvidenceError !== undefined) {
|
||||
if (typeof actual.controllerEvidenceError !== 'string' || !actual.controllerEvidenceError.trim()
|
||||
|| typeof actual.accountingError !== 'string' || !actual.accountingError.trim()
|
||||
|| canonical(actual.accountingFailure) !== canonical({ class: 'provider_budget_coverage_unknown', category: 'provider_budget', retryable: false })) {
|
||||
throw new Error('Invalid immutable controller retention failure');
|
||||
}
|
||||
if (canonical(actual) !== canonical(expected)) throw new Error('Immutable result disagrees with its controller proof');
|
||||
delete actual.controllerEvidenceError;
|
||||
if (expected.accountingFailure) { actual.accountingFailure = expected.accountingFailure; actual.accountingError = expected.accountingError; }
|
||||
else { delete actual.accountingFailure; delete actual.accountingError; }
|
||||
}
|
||||
if (canonical(actual) !== canonical(expected)) throw new Error('Immutable result disagrees with its controller proof');
|
||||
}
|
||||
|
||||
return bundle;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { join } from 'node:path';
|
||||
import { canonical, costRuntime, observabilityBoundConfiguration, requestBindingSha256, sha256, validateManifest } from './manifest.mjs';
|
||||
import { attemptDirectory, runControlledEval } from './run.mjs';
|
||||
import { summarizeControlledCampaign } from './report.mjs';
|
||||
import { readRegularFile, replaceEvidenceFile } from './evidence-files.mjs';
|
||||
import { decodeControlledEvidence, packControlledAttempt, readControlledEvidence, readAttemptControlledEvidence, summarizeControlledEvidence } from './archive.mjs';
|
||||
import { aggregateProtocolEvalCampaign, sanitizeProtocolEvalRuns } from '../runner-protocol-eval-campaign.mjs';
|
||||
|
||||
@@ -693,3 +694,43 @@ for (const name of ['observability.json', 'otlp-native.jsonl', 'otlp-controller.
|
||||
assert.equal(summary.committedUsd, '0.014000000');
|
||||
});
|
||||
}
|
||||
|
||||
test('credential redaction replaces a swapped symlink without changing its target', async t => {
|
||||
const f = await fixture(t, { leakToken: true, requests: 0 });
|
||||
const result = await f.run();
|
||||
assert.equal(result.report.secretExcluded, false);
|
||||
const path = join(attemptDirectory(f.manifest, 'attempt-0'), 'provider-artifact.json');
|
||||
const bytes = await readRegularFile(path, 16_777_216);
|
||||
const target = join(f.root, 'untouched-target'); await writeFile(target, 'target must remain unchanged');
|
||||
await rm(path); await symlink(target, path);
|
||||
await replaceEvidenceFile(path, bytes);
|
||||
assert.equal(await readFile(target, 'utf8'), 'target must remain unchanged');
|
||||
assert.deepEqual(await readRegularFile(path, 16_777_216), bytes);
|
||||
await symlink(target, `${path}.tmp`);
|
||||
await assert.rejects(replaceEvidenceFile(path, bytes), { code: 'EEXIST' });
|
||||
assert.equal(await readFile(target, 'utf8'), 'target must remain unchanged');
|
||||
});
|
||||
|
||||
test('missing provider artifacts cannot bypass immutable result binding', async t => {
|
||||
const f = await fixture(t, { missingArtifact: true, requests: 0 }); await f.run();
|
||||
const bundle = await packControlledAttempt({ manifest: f.manifest, attemptId: 'attempt-0' });
|
||||
assert.equal(bundle.files['provider-artifact.json'], undefined);
|
||||
assert.equal(JSON.parse(Buffer.from(bundle.files['result.json'].base64, 'base64')).providerArtifactSha256, undefined);
|
||||
const directory = join(f.root, 'runs', 'attempt-0'); await mkdir(directory, { recursive: true });
|
||||
const artifact = JSON.parse(await readFile(f.requests[0].outputPath, 'utf8'));
|
||||
for (const [name, value] of Object.entries({ 'request.json': f.requests[0].request, 'artifact.json': artifact, 'controller-evidence.json': bundle })) await writeFile(join(directory, name), JSON.stringify(value));
|
||||
await readAttemptControlledEvidence(directory);
|
||||
for (const mutate of [
|
||||
value => { value.usage = { estimatedCostNanodollars: 0 }; },
|
||||
value => { value.turn = { status: 'completed', assistantText: 'forged success' }; },
|
||||
value => { value.controllerAccounting.cleanupVerified = true; },
|
||||
]) {
|
||||
const changed = structuredClone(artifact); mutate(changed);
|
||||
await writeFile(join(directory, 'artifact.json'), JSON.stringify(changed));
|
||||
await assert.rejects(readAttemptControlledEvidence(directory), /Immutable result disagrees/);
|
||||
}
|
||||
const malformed = structuredClone(bundle), bytes = Buffer.from('{');
|
||||
malformed.files['result.json'] = { sizeBytes: bytes.length, sha256: sha256(bytes), base64: bytes.toString('base64') };
|
||||
await writeFile(join(directory, 'controller-evidence.json'), JSON.stringify(malformed));
|
||||
await assert.rejects(readAttemptControlledEvidence(directory), /Malformed immutable controller result proof/);
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { constants } from 'node:fs';
|
||||
import { open } from 'node:fs/promises';
|
||||
import { open, rename } from 'node:fs/promises';
|
||||
|
||||
const MiB = 1_048_576;
|
||||
export const FILE_LIMITS = new Map([
|
||||
@@ -27,3 +27,11 @@ export async function readRegularFile(path, maximum) {
|
||||
return bytes.subarray(0, length);
|
||||
} finally { await handle.close(); }
|
||||
}
|
||||
|
||||
export async function replaceEvidenceFile(path, bytes) {
|
||||
const temporary = `${path}.tmp`;
|
||||
const handle = await open(temporary, 'wx', 0o600);
|
||||
try { await handle.writeFile(bytes); await handle.sync(); }
|
||||
finally { await handle.close(); }
|
||||
await rename(temporary, path);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { parseArgs } from 'node:util';
|
||||
import { startControlledObservability } from './observability.mjs';
|
||||
import { limitFor, readRegularFile } from './evidence-files.mjs';
|
||||
import { limitFor, readRegularFile, replaceEvidenceFile } from './evidence-files.mjs';
|
||||
import { bindRequest, canonical, costRuntime, readJson, sha256, validateManifest } from './manifest.mjs';
|
||||
|
||||
async function atomicJson(file, value) {
|
||||
@@ -149,7 +149,7 @@ registerHooks({ resolve(specifier, context, nextResolve) {
|
||||
for (const stream of ['stdout', 'stderr']) await writeFile(join(root, `${stream}.log`), redact(child[stream]), { flag: 'wx', mode: 0o600 });
|
||||
const raw = (await readRegularFile(artifactPath, limitFor('provider-artifact.json'))).toString('utf8');
|
||||
secretExcluded = secrets.every(secret => !raw.includes(secret));
|
||||
if (!secretExcluded) await writeFile(artifactPath, redact(raw), { mode: 0o600 });
|
||||
if (!secretExcluded) await replaceEvidenceFile(artifactPath, redact(raw));
|
||||
providerArtifactSha256 = sha256(redact(raw));
|
||||
artifact = JSON.parse(redact(raw));
|
||||
const expected = { mode: 'controller_gateway', provider, declaredUpstreamRoute: manifest.route, campaignId: manifest.campaignId,
|
||||
|
||||
Reference in new issue
Block a user