humanish 0.121.0 → 0.123.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/CONTEXT.md +1 -1
- package/README.md +33 -6
- package/dist/actors/computer-use/actor.d.ts +3 -0
- package/dist/actors/computer-use/actor.js +1 -0
- package/dist/actors/computer-use/loop/backstop.d.ts +6 -1
- package/dist/actors/computer-use/loop/backstop.js +13 -1
- package/dist/actors/computer-use/loop/clock.d.ts +20 -0
- package/dist/actors/computer-use/loop/clock.js +64 -0
- package/dist/actors/computer-use/loop/ending.d.ts +1 -1
- package/dist/actors/computer-use/loop/session.d.ts +1 -1
- package/dist/actors/computer-use/loop/session.js +2 -1
- package/dist/actors/computer-use/loop/types.d.ts +16 -0
- package/dist/actors/computer-use/loop.d.ts +1 -1
- package/dist/actors/computer-use/loop.js +43 -8
- package/dist/actors/computer-use/wait.d.ts +3 -2
- package/dist/actors/computer-use/wait.js +5 -4
- package/dist/actors/contract.d.ts +3 -3
- package/dist/actors/contract.js +3 -0
- package/dist/actors/stop-cause.js +1 -0
- package/dist/analysis/automatic.js +2 -1
- package/dist/analysis/evidence.js +41 -21
- package/dist/cli/commands/study-run.js +9 -3
- package/dist/feedback/derivative-text.d.ts +12 -0
- package/dist/feedback/derivative-text.js +130 -0
- package/dist/feedback/export-bundle.js +38 -131
- package/dist/feedback/export.js +9 -1
- package/dist/observer/run-routes.js +61 -18
- package/dist/routes/computer-use/bundle.d.ts +0 -1
- package/dist/routes/computer-use/bundle.js +0 -1
- package/dist/routes/computer-use/diagnostics.d.ts +1 -1
- package/dist/routes/computer-use/diagnostics.js +4 -0
- package/dist/routes/computer-use/fanout-bundle.d.ts +2 -3
- package/dist/routes/computer-use/fanout-bundle.js +2 -12
- package/dist/routes/computer-use/live-phase.d.ts +0 -2
- package/dist/routes/computer-use/live-phase.js +2 -4
- package/dist/routes/computer-use/participant-execution.d.ts +9 -15
- package/dist/routes/computer-use/participant-execution.js +22 -31
- package/dist/routes/computer-use/participant-model.js +17 -0
- package/dist/routes/computer-use/participant-runs.d.ts +11 -1
- package/dist/routes/computer-use/participant-runs.js +31 -4
- package/dist/routes/computer-use/plan.js +8 -1
- package/dist/routes/computer-use/result.js +1 -2
- package/dist/routes/computer-use/types.d.ts +17 -5
- package/dist/routes/shared-world/provisioned.js +2 -2
- package/dist/run/bundle-shape.js +2 -2
- package/dist/run/bundle.d.ts +5 -2
- package/dist/run/bundle.js +4 -1
- package/dist/run/detail.d.ts +6 -0
- package/dist/run/detail.js +14 -1
- package/dist/run/host-suspension.d.ts +2 -2
- package/dist/run/host-suspension.js +2 -2
- package/dist/run/judge.d.ts +1 -1
- package/dist/run/locate.js +3 -1
- package/dist/run/projection.d.ts +7 -0
- package/dist/run/projection.js +10 -0
- package/dist/run/run.js +7 -1
- package/dist/run/stored-runs.js +10 -4
- package/dist/run/stream-shape.d.ts +11 -1
- package/dist/run/stream-shape.js +26 -2
- package/dist/run/stream-trace.d.ts +68 -0
- package/dist/run/stream-trace.js +260 -0
- package/dist/run/streams.d.ts +20 -0
- package/dist/study/arrivals.d.ts +52 -5
- package/dist/study/arrivals.js +105 -15
- package/dist/study/bundle-estimate.d.ts +48 -0
- package/dist/study/bundle-estimate.js +159 -0
- package/dist/study/doctor.js +4 -0
- package/dist/study/keys.d.ts +6 -0
- package/dist/study/keys.js +4 -0
- package/dist/study/migrate/v2.js +8 -5
- package/dist/study/parse/actors.d.ts +1 -1
- package/dist/study/parse/actors.js +33 -4
- package/dist/study/plan-participants.d.ts +10 -1
- package/dist/study/plan-participants.js +10 -1
- package/dist/study/plan-types.d.ts +3 -0
- package/dist/study/plan.js +22 -2
- package/dist/study/preflight.js +19 -1
- package/dist/study/types.d.ts +26 -0
- package/dist/study/validation.d.ts +3 -2
- package/dist/study/validation.js +22 -4
- package/dist/tui-app.js +87 -87
- package/dist/verify/artifacts.d.ts +17 -10
- package/dist/verify/artifacts.js +22 -13
- package/dist/verify/scan-limit.d.ts +6 -0
- package/dist/verify/scan-limit.js +25 -0
- package/dist/verify/verify.d.ts +1 -1
- package/dist/verify/verify.js +68 -18
- package/docs/architecture/github-feedback-loop.md +1 -1
- package/docs/contracts/README.md +1 -1
- package/docs/contracts/run-bundle.md +89 -8
- package/docs/contracts/schemas.md +38 -11
- package/package.json +6 -3
|
@@ -3,7 +3,8 @@ import { createHash } from "node:crypto";
|
|
|
3
3
|
import { screenshotEvidenceError } from "../evidence/image.js";
|
|
4
4
|
import { runIdOf } from "../run/paths.js";
|
|
5
5
|
import { isRecord } from "../run/type-guards.js";
|
|
6
|
-
import { RUN_BUNDLE_FILE } from "../run/bundle.js";
|
|
6
|
+
import { RUN_BUNDLE_FILE, RUN_BUNDLE_SCHEMA, RUN_BUNDLE_V1_SCHEMA, } from "../run/bundle.js";
|
|
7
|
+
import { loadStreamTraces } from "../run/stream-trace.js";
|
|
7
8
|
import { ACTION_CAPTURE_VERSION, } from "./types.js";
|
|
8
9
|
import { digestAnalysisInput, hashAnalysisValue, validateAnalysisInputMetadata, } from "./validation.js";
|
|
9
10
|
import { boundedText, fairShares, hasUnmappedCaptures, participantAssignment, participantSource, sourceEntries, sourceOrder, impressionEventIds, } from "./evidence-sources.js";
|
|
@@ -29,7 +30,7 @@ function parseSource(prepared, bytes) {
|
|
|
29
30
|
throw new Error("ANALYSIS_SOURCE_TOO_LARGE");
|
|
30
31
|
const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
31
32
|
if (!isRecord(value) ||
|
|
32
|
-
value.schema !==
|
|
33
|
+
(value.schema !== RUN_BUNDLE_SCHEMA && value.schema !== RUN_BUNDLE_V1_SCHEMA) ||
|
|
33
34
|
value.runId !== runIdOf(prepared) ||
|
|
34
35
|
!Array.isArray(value.streams) ||
|
|
35
36
|
value.streams.length > EVIDENCE_LIMITS.participants ||
|
|
@@ -49,24 +50,8 @@ function parseSource(prepared, bytes) {
|
|
|
49
50
|
throw new Error("ANALYSIS_SOURCE_INVALID");
|
|
50
51
|
}
|
|
51
52
|
ids.add(stream.id);
|
|
52
|
-
|
|
53
|
-
if (actor !== undefined &&
|
|
54
|
-
(!isRecord(actor) || !Array.isArray(actor.items) || actor.items.length > 100000)) {
|
|
53
|
+
if (stream.actor !== undefined && !isRecord(stream.actor))
|
|
55
54
|
throw new Error("ANALYSIS_SOURCE_INVALID");
|
|
56
|
-
}
|
|
57
|
-
const eventIds = new Set();
|
|
58
|
-
for (const item of isRecord(actor) ? actor.items : []) {
|
|
59
|
-
if (!isRecord(item) ||
|
|
60
|
-
typeof item.id !== "string" ||
|
|
61
|
-
item.id.length === 0 ||
|
|
62
|
-
item.id.length > 256 ||
|
|
63
|
-
eventIds.has(item.id) ||
|
|
64
|
-
typeof item.kind !== "string" ||
|
|
65
|
-
typeof item.title !== "string" ||
|
|
66
|
-
(item.text !== undefined && typeof item.text !== "string"))
|
|
67
|
-
throw new Error("ANALYSIS_SOURCE_INVALID");
|
|
68
|
-
eventIds.add(item.id);
|
|
69
|
-
}
|
|
70
55
|
}
|
|
71
56
|
for (const event of value.events) {
|
|
72
57
|
if (!isRecord(event) ||
|
|
@@ -77,6 +62,41 @@ function parseSource(prepared, bytes) {
|
|
|
77
62
|
}
|
|
78
63
|
return value;
|
|
79
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* run.json's bundle with each stream's trace loaded. The source limit applies to each trace file
|
|
67
|
+
* as it does to run.json; run.json's digest, which the analysis record binds, binds each trace's
|
|
68
|
+
* sha256.
|
|
69
|
+
*/
|
|
70
|
+
async function readSource(prepared, bytes, traceBytes) {
|
|
71
|
+
const stored = parseSource(prepared, bytes);
|
|
72
|
+
if (stored.streams.some((stream) => (stream.trace?.bytes ?? 0) > traceBytes))
|
|
73
|
+
throw new Error("ANALYSIS_SOURCE_TOO_LARGE");
|
|
74
|
+
const loaded = await loadStreamTraces(prepared, stored, { maxBytes: traceBytes });
|
|
75
|
+
if (loaded.refused.length > 0)
|
|
76
|
+
throw new Error("ANALYSIS_SOURCE_INVALID");
|
|
77
|
+
for (const stream of loaded.bundle.streams)
|
|
78
|
+
validateTraceItems(stream.actor);
|
|
79
|
+
return loaded.bundle;
|
|
80
|
+
}
|
|
81
|
+
function validateTraceItems(actor) {
|
|
82
|
+
if (actor !== undefined &&
|
|
83
|
+
(!isRecord(actor) || !Array.isArray(actor.items) || actor.items.length > 100000)) {
|
|
84
|
+
throw new Error("ANALYSIS_SOURCE_INVALID");
|
|
85
|
+
}
|
|
86
|
+
const eventIds = new Set();
|
|
87
|
+
for (const item of isRecord(actor) ? actor.items : []) {
|
|
88
|
+
if (!isRecord(item) ||
|
|
89
|
+
typeof item.id !== "string" ||
|
|
90
|
+
item.id.length === 0 ||
|
|
91
|
+
item.id.length > 256 ||
|
|
92
|
+
eventIds.has(item.id) ||
|
|
93
|
+
typeof item.kind !== "string" ||
|
|
94
|
+
typeof item.title !== "string" ||
|
|
95
|
+
(item.text !== undefined && typeof item.text !== "string"))
|
|
96
|
+
throw new Error("ANALYSIS_SOURCE_INVALID");
|
|
97
|
+
eventIds.add(item.id);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
80
100
|
/** Select once from retained source. Models receive no filesystem or network resolver. */
|
|
81
101
|
export async function captureEvidence(prepared, bundleBytes, requested = {}) {
|
|
82
102
|
const limits = { ...EVIDENCE_LIMITS, ...requested };
|
|
@@ -92,7 +112,7 @@ export async function captureEvidence(prepared, bundleBytes, requested = {}) {
|
|
|
92
112
|
const current = await readBoundedFile(prepared, RUN_BUNDLE_FILE, limits.sourceBytes);
|
|
93
113
|
if (!current || !current.equals(bundleBytes))
|
|
94
114
|
throw new Error("ANALYSIS_SOURCE_CHANGED");
|
|
95
|
-
const bundle =
|
|
115
|
+
const bundle = await readSource(prepared, bundleBytes, limits.sourceBytes);
|
|
96
116
|
const captureVersion = ACTION_CAPTURE_VERSION;
|
|
97
117
|
if (bundle.streams.some((stream) => stream.liveActor !== undefined || UNFINISHED_STREAM_STATUSES.has(stream.status))) {
|
|
98
118
|
throw new Error("ANALYSIS_RUN_UNFINISHED");
|
|
@@ -393,7 +413,7 @@ function packEvidence(selected, packings, textByEntry, evidence, images) {
|
|
|
393
413
|
export async function validateAnalysisEvidence(prepared, artifact, bundleBytes) {
|
|
394
414
|
if (artifact.captureVersion !== undefined && artifact.captureVersion !== ACTION_CAPTURE_VERSION)
|
|
395
415
|
throw new Error("ANALYSIS_CAPTURE_VERSION_INVALID");
|
|
396
|
-
const bundle =
|
|
416
|
+
const bundle = await readSource(prepared, bundleBytes, EVIDENCE_LIMITS.sourceBytes);
|
|
397
417
|
if (artifact.runId !== bundle.runId || artifact.sourceRunSha256 !== sha256(bundleBytes))
|
|
398
418
|
throw new Error("ANALYSIS_SOURCE_CHANGED");
|
|
399
419
|
const included = new Set(artifact.coverage.includedStreamIds);
|
|
@@ -84,7 +84,14 @@ export async function runStudyCommand(args) {
|
|
|
84
84
|
// the keys its plan reads. This comes after the option refusals above, which read no key, and
|
|
85
85
|
// before the route's CLI setup and prepareStudy, which do.
|
|
86
86
|
if (resolveStudyDryRun(config, args.options.dryRun, true) === false) {
|
|
87
|
-
const
|
|
87
|
+
const planned = planStudy(config, { cwd: args.options.cwd, dryRun: false });
|
|
88
|
+
// The plan's warnings, such as a bundle estimated past a limit, before anything is created.
|
|
89
|
+
// The study file's own were printed above.
|
|
90
|
+
if (planned.ok)
|
|
91
|
+
for (const warning of planned.planned.plan.warnings ?? [])
|
|
92
|
+
if (!resolved.warnings.includes(warning))
|
|
93
|
+
warnAndQueue(args.command, args.io, warning);
|
|
94
|
+
const announced = announcedKeyNames(config, args.options, planned);
|
|
88
95
|
await discoverCliKeys({
|
|
89
96
|
io: args.io,
|
|
90
97
|
cwd: args.options.cwd,
|
|
@@ -150,10 +157,9 @@ export async function runStudyCommand(args) {
|
|
|
150
157
|
* change who runs, not which keys. Undefined, so every fill is printed, when a scorer is declared,
|
|
151
158
|
* since its host code may read any key, and when the study does not plan.
|
|
152
159
|
*/
|
|
153
|
-
function announcedKeyNames(config, options) {
|
|
160
|
+
function announcedKeyNames(config, options, planned) {
|
|
154
161
|
if (options.scorer !== undefined || config.review?.scorer !== undefined)
|
|
155
162
|
return undefined;
|
|
156
|
-
const planned = planStudy(config, { cwd: options.cwd, dryRun: false });
|
|
157
163
|
return planned.ok ? keyNamesOf(planned.planned.plan) : undefined;
|
|
158
164
|
}
|
|
159
165
|
/** Each route's CLI setup. It returns undefined when it has already written its own result. */
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { RunBundle } from "../run/bundle.js";
|
|
2
|
+
/** A source file's path and bytes. */
|
|
3
|
+
interface TextFile {
|
|
4
|
+
path: string;
|
|
5
|
+
bytes: Buffer;
|
|
6
|
+
}
|
|
7
|
+
export declare function jsonBytes(value: unknown): Buffer;
|
|
8
|
+
export declare function transformText(file: TextFile, imagePaths: ReadonlySet<string>): Buffer;
|
|
9
|
+
export declare function assertRewritableNumbers(json: string): void;
|
|
10
|
+
/** The loaded source bundle as the copy records it: screenshots declared and labelled blurred. */
|
|
11
|
+
export declare function derivedBundle(bundle: RunBundle, imagePaths: ReadonlySet<string>): RunBundle;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// How a redacted copy rewrites a run's text: screenshots declared blurred, sandbox ids redacted,
|
|
2
|
+
// inline images and secret-shaped text refused. src/feedback/export-bundle.ts copies the files.
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { parse as parseYaml } from "yaml";
|
|
5
|
+
import { ACTOR_TRACE_SCHEMA } from "../actors/contract.js";
|
|
6
|
+
import { containsSensitive, redactSandboxIds } from "../evidence/redaction.js";
|
|
7
|
+
import { decodeEscapes, readingsOf } from "../evidence/encoded-text.js";
|
|
8
|
+
import { readPlainText } from "../evidence/plain-text.js";
|
|
9
|
+
export function jsonBytes(value) {
|
|
10
|
+
return Buffer.from(`${JSON.stringify(value, null, 2)}\n`);
|
|
11
|
+
}
|
|
12
|
+
function assertNoInlineRaster(text) {
|
|
13
|
+
// Decode the escapes verify decodes (decodeEscapes) before checking. Unknown embedded bytes are
|
|
14
|
+
// refused: copying a text extension must not smuggle untransformed pixels.
|
|
15
|
+
if (readingsOf(text).some(containsSensitive))
|
|
16
|
+
throw new Error("Decoded text contains a secret-shaped value or private path; export refused.");
|
|
17
|
+
const decoded = decodeEscapes(text);
|
|
18
|
+
if (/data\s*:\s*(?:image\/|application\/|text\/|[^,\s]*;base64,|,)/i.test(decoded) ||
|
|
19
|
+
/iVBORw0KGgo|\/9j\/[A-Za-z0-9+/]|R0lGOD|UklGR[A-Za-z0-9+/]{4}|<svg\b/i.test(decoded)) {
|
|
20
|
+
throw new Error("Text contains an inline image/data payload; embedded raster redaction is unsupported.");
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function decodeText(file) {
|
|
24
|
+
const decoded = readPlainText(file.bytes);
|
|
25
|
+
if (!decoded.ok)
|
|
26
|
+
throw new Error(decoded.reason === "invalid-utf8"
|
|
27
|
+
? "Source text contains invalid UTF-8 or binary bytes."
|
|
28
|
+
: "Source text contains binary control bytes.");
|
|
29
|
+
assertNoInlineRaster(decoded.text);
|
|
30
|
+
return decoded.text;
|
|
31
|
+
}
|
|
32
|
+
function updateScreenshotDeclarations(value, imagePaths) {
|
|
33
|
+
if (Array.isArray(value))
|
|
34
|
+
return value.map((entry) => updateScreenshotDeclarations(entry, imagePaths));
|
|
35
|
+
if (value === null || typeof value !== "object")
|
|
36
|
+
return value;
|
|
37
|
+
const object = value;
|
|
38
|
+
const result = Object.create(null);
|
|
39
|
+
for (const [key, entry] of Object.entries(object))
|
|
40
|
+
result[key] = updateScreenshotDeclarations(entry, imagePaths);
|
|
41
|
+
if (object.schema === ACTOR_TRACE_SCHEMA &&
|
|
42
|
+
object.redaction !== null &&
|
|
43
|
+
typeof object.redaction === "object") {
|
|
44
|
+
const original = object.redaction;
|
|
45
|
+
if (original.screenshots === "raw" || original.screenshots === "blurred") {
|
|
46
|
+
result.redaction = {
|
|
47
|
+
...original,
|
|
48
|
+
screenshots: "blurred",
|
|
49
|
+
notes: `${typeof original.notes === "string" ? original.notes : ""} Exported copy: screenshots blurred during export; original capture posture: ${original.screenshots}. See derivation.json.`,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
if (Array.isArray(result.items)) {
|
|
53
|
+
result.items = result.items.map((item) => {
|
|
54
|
+
if (item === null || typeof item !== "object")
|
|
55
|
+
return item;
|
|
56
|
+
const entry = item;
|
|
57
|
+
if (entry.screenshotRef === undefined)
|
|
58
|
+
return item;
|
|
59
|
+
if (entry.screenshotRef === null || typeof entry.screenshotRef !== "object")
|
|
60
|
+
throw new Error("Actor screenshot reference is malformed.");
|
|
61
|
+
const ref = entry.screenshotRef;
|
|
62
|
+
if (typeof ref.path !== "string" || !imagePaths.has(ref.path)) {
|
|
63
|
+
throw new Error("Actor screenshot reference must resolve to a PNG transformed from this run's inventory.");
|
|
64
|
+
}
|
|
65
|
+
return { ...entry, screenshotRef: { ...ref, redaction: "blurred" } };
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
export function transformText(file, imagePaths) {
|
|
72
|
+
const text = decodeText(file);
|
|
73
|
+
const extension = path.extname(file.path).toLowerCase();
|
|
74
|
+
if (extension === ".json") {
|
|
75
|
+
const parsed = JSON.parse(text);
|
|
76
|
+
// Parsing also resolves escape sequences in string values before inspection.
|
|
77
|
+
assertNoInlineRaster(JSON.stringify(parsed));
|
|
78
|
+
// The copy names no sandbox: an id is local operational evidence, and the copy is shared.
|
|
79
|
+
const transformed = redactSandboxIds(updateScreenshotDeclarations(parsed, imagePaths));
|
|
80
|
+
if (JSON.stringify(transformed) === JSON.stringify(parsed))
|
|
81
|
+
return file.bytes;
|
|
82
|
+
assertRewritableNumbers(text);
|
|
83
|
+
return jsonBytes(transformed);
|
|
84
|
+
}
|
|
85
|
+
if (extension === ".ndjson" || extension === ".jsonl") {
|
|
86
|
+
const lines = text.split("\n");
|
|
87
|
+
const transformed = lines.map((line) => {
|
|
88
|
+
if (line.trim().length === 0)
|
|
89
|
+
return line;
|
|
90
|
+
const parsed = JSON.parse(line);
|
|
91
|
+
assertNoInlineRaster(JSON.stringify(parsed));
|
|
92
|
+
const updated = JSON.stringify(redactSandboxIds(updateScreenshotDeclarations(parsed, imagePaths)));
|
|
93
|
+
if (updated === JSON.stringify(parsed))
|
|
94
|
+
return line;
|
|
95
|
+
assertRewritableNumbers(line);
|
|
96
|
+
return updated;
|
|
97
|
+
});
|
|
98
|
+
return Buffer.from(transformed.join("\n"));
|
|
99
|
+
}
|
|
100
|
+
if (extension === ".yaml" || extension === ".yml") {
|
|
101
|
+
assertNoInlineRaster(JSON.stringify(parseYaml(text, { maxAliasCount: 100, logLevel: "silent" })) ?? "");
|
|
102
|
+
}
|
|
103
|
+
return file.bytes;
|
|
104
|
+
}
|
|
105
|
+
export function assertRewritableNumbers(json) {
|
|
106
|
+
// Opaque adapter JSON stays byte-identical. A known trace that actually needs
|
|
107
|
+
// rewriting must not silently round an identifier outside JS's integer range.
|
|
108
|
+
const unquoted = json.replace(/"(?:\\.|[^"\\])*"/g, '""');
|
|
109
|
+
for (const match of unquoted.matchAll(/-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/g)) {
|
|
110
|
+
const number = Number(match[0]);
|
|
111
|
+
if (!Number.isFinite(number) || (Number.isInteger(number) && !Number.isSafeInteger(number))) {
|
|
112
|
+
throw new Error("A trace requiring redaction contains an unsafe numeric value; represent large identifiers as strings before capture.");
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/** The loaded source bundle as the copy records it: screenshots declared and labelled blurred. */
|
|
117
|
+
export function derivedBundle(bundle, imagePaths) {
|
|
118
|
+
const transformed = redactSandboxIds(updateScreenshotDeclarations(bundle, imagePaths));
|
|
119
|
+
transformed.redaction = {
|
|
120
|
+
status: "passed",
|
|
121
|
+
notes: `${bundle.redaction.notes} Exported copy: screenshots were blurred during export; the original capture is retained separately. See derivation.json.`,
|
|
122
|
+
};
|
|
123
|
+
for (const stream of transformed.streams) {
|
|
124
|
+
for (const artifact of stream.artifacts) {
|
|
125
|
+
if (artifact.kind === "screenshot")
|
|
126
|
+
artifact.label = artifact.label.replace(/\((raw|blurred)\)/g, "(blurred at export)");
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return transformed;
|
|
130
|
+
}
|
|
@@ -4,18 +4,18 @@ import { createHash } from "node:crypto";
|
|
|
4
4
|
import { lstat, mkdir, mkdtemp, readdir, realpath, rename, rm, rmdir } from "node:fs/promises";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { tmpdir } from "node:os";
|
|
7
|
-
import { parse as parseYaml } from "yaml";
|
|
8
|
-
import { ACTOR_TRACE_SCHEMA } from "../actors/contract.js";
|
|
9
7
|
import { renderObserver } from "../observer/render.js";
|
|
10
8
|
import { buildObserverData } from "../observer/data.js";
|
|
11
|
-
import { inventorySandboxIds, scrubSandboxIdBytes } from "../run/sandbox-ids.js";
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
9
|
+
import { inventorySandboxIds, publicSandboxView, scrubSandboxIdBytes } from "../run/sandbox-ids.js";
|
|
10
|
+
import { splitStreamTraces } from "../run/stream-trace.js";
|
|
11
|
+
import { redactScreenshot, redactText } from "../evidence/redaction.js";
|
|
12
|
+
import { assertRewritableNumbers, derivedBundle, jsonBytes, transformText, } from "./derivative-text.js";
|
|
13
|
+
import { streamScreenshotPaths } from "../verify/artifacts.js";
|
|
14
|
+
import { runEntryLimit } from "../verify/scan-limit.js";
|
|
16
15
|
import { verifyRunPrepared } from "../verify/verify.js";
|
|
17
|
-
import { loadRunBundlePrepared, resolveRunPath } from "../run/locate.js";
|
|
18
|
-
import {} from "../run/bundle.js";
|
|
16
|
+
import { loadRunBundlePrepared, readRunJsonIfExists, resolveRunPath, runJsonValue, } from "../run/locate.js";
|
|
17
|
+
import { RUN_BUNDLE_FILE } from "../run/bundle.js";
|
|
18
|
+
import { isRecord } from "../run/type-guards.js";
|
|
19
19
|
import { runIdOf, isPathInside, prepareRunArtifactPaths, validatePreparedRunArtifactPaths, } from "../run/paths.js";
|
|
20
20
|
import { isAnalysisRecordPath } from "../analysis/sharing.js";
|
|
21
21
|
import { LOCAL_ONLY_RUN_FILES } from "../run/local-only-files.js";
|
|
@@ -43,9 +43,6 @@ const PNG_MAGIC = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
|
43
43
|
function hash(bytes) {
|
|
44
44
|
return createHash("sha256").update(bytes).digest("hex");
|
|
45
45
|
}
|
|
46
|
-
function jsonBytes(value) {
|
|
47
|
-
return Buffer.from(`${JSON.stringify(value, null, 2)}\n`);
|
|
48
|
-
}
|
|
49
46
|
function omittedReason(relative) {
|
|
50
47
|
if (relative.startsWith("analysis-automatic/") && isAnalysisRecordPath(relative))
|
|
51
48
|
return "automatic execution intent and liveness do not transfer to a derivative";
|
|
@@ -63,14 +60,17 @@ async function inventory(root, maxBytes) {
|
|
|
63
60
|
const files = [];
|
|
64
61
|
let total = 0;
|
|
65
62
|
let entriesSeen = 0;
|
|
63
|
+
// The scan's limit for the participants run.json records, so a run verify reads in full exports.
|
|
64
|
+
const stored = runJsonValue(await readRunJsonIfExists(root, RUN_BUNDLE_FILE));
|
|
65
|
+
const entryLimit = runEntryLimit(isRecord(stored) && Array.isArray(stored.simulations) ? stored.simulations.length : 0);
|
|
66
66
|
const walk = async (relative) => {
|
|
67
67
|
const directory = path.join(root.physicalRunRoot, relative);
|
|
68
68
|
const before = await lstat(directory, { bigint: true });
|
|
69
69
|
if (!before.isDirectory() || before.isSymbolicLink())
|
|
70
70
|
throw new Error("Source contains an unsafe directory.");
|
|
71
71
|
for (const name of (await readdir(directory)).sort()) {
|
|
72
|
-
if (++entriesSeen >
|
|
73
|
-
throw new Error(`Source exceeds ${
|
|
72
|
+
if (++entriesSeen > entryLimit)
|
|
73
|
+
throw new Error(`Source exceeds ${entryLimit} inventory entries.`);
|
|
74
74
|
if (name.includes("\\") || name.includes("\0"))
|
|
75
75
|
throw new Error("Source contains an unsafe path segment.");
|
|
76
76
|
const rel = relative ? `${relative}/${name}` : name;
|
|
@@ -147,65 +147,6 @@ async function prospectivePhysicalPath(requested) {
|
|
|
147
147
|
return path.join(await prospectivePhysicalPath(parent), path.basename(requested));
|
|
148
148
|
}
|
|
149
149
|
}
|
|
150
|
-
function assertNoInlineRaster(text) {
|
|
151
|
-
// Decode the escapes verify decodes (decodeEscapes) before checking. Unknown embedded bytes are
|
|
152
|
-
// refused: copying a text extension must not smuggle untransformed pixels.
|
|
153
|
-
if (readingsOf(text).some(containsSensitive))
|
|
154
|
-
throw new Error("Decoded text contains a secret-shaped value or private path; export refused.");
|
|
155
|
-
const decoded = decodeEscapes(text);
|
|
156
|
-
if (/data\s*:\s*(?:image\/|application\/|text\/|[^,\s]*;base64,|,)/i.test(decoded) ||
|
|
157
|
-
/iVBORw0KGgo|\/9j\/[A-Za-z0-9+/]|R0lGOD|UklGR[A-Za-z0-9+/]{4}|<svg\b/i.test(decoded)) {
|
|
158
|
-
throw new Error("Text contains an inline image/data payload; embedded raster redaction is unsupported.");
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
function decodeText(file) {
|
|
162
|
-
const decoded = readPlainText(file.bytes);
|
|
163
|
-
if (!decoded.ok)
|
|
164
|
-
throw new Error(decoded.reason === "invalid-utf8"
|
|
165
|
-
? "Source text contains invalid UTF-8 or binary bytes."
|
|
166
|
-
: "Source text contains binary control bytes.");
|
|
167
|
-
assertNoInlineRaster(decoded.text);
|
|
168
|
-
return decoded.text;
|
|
169
|
-
}
|
|
170
|
-
function updateScreenshotDeclarations(value, imagePaths) {
|
|
171
|
-
if (Array.isArray(value))
|
|
172
|
-
return value.map((entry) => updateScreenshotDeclarations(entry, imagePaths));
|
|
173
|
-
if (value === null || typeof value !== "object")
|
|
174
|
-
return value;
|
|
175
|
-
const object = value;
|
|
176
|
-
const result = Object.create(null);
|
|
177
|
-
for (const [key, entry] of Object.entries(object))
|
|
178
|
-
result[key] = updateScreenshotDeclarations(entry, imagePaths);
|
|
179
|
-
if (object.schema === ACTOR_TRACE_SCHEMA &&
|
|
180
|
-
object.redaction !== null &&
|
|
181
|
-
typeof object.redaction === "object") {
|
|
182
|
-
const original = object.redaction;
|
|
183
|
-
if (original.screenshots === "raw" || original.screenshots === "blurred") {
|
|
184
|
-
result.redaction = {
|
|
185
|
-
...original,
|
|
186
|
-
screenshots: "blurred",
|
|
187
|
-
notes: `${typeof original.notes === "string" ? original.notes : ""} Exported copy: screenshots blurred during export; original capture posture: ${original.screenshots}. See derivation.json.`,
|
|
188
|
-
};
|
|
189
|
-
}
|
|
190
|
-
if (Array.isArray(result.items)) {
|
|
191
|
-
result.items = result.items.map((item) => {
|
|
192
|
-
if (item === null || typeof item !== "object")
|
|
193
|
-
return item;
|
|
194
|
-
const entry = item;
|
|
195
|
-
if (entry.screenshotRef === undefined)
|
|
196
|
-
return item;
|
|
197
|
-
if (entry.screenshotRef === null || typeof entry.screenshotRef !== "object")
|
|
198
|
-
throw new Error("Actor screenshot reference is malformed.");
|
|
199
|
-
const ref = entry.screenshotRef;
|
|
200
|
-
if (typeof ref.path !== "string" || !imagePaths.has(ref.path)) {
|
|
201
|
-
throw new Error("Actor screenshot reference must resolve to a PNG transformed from this run's inventory.");
|
|
202
|
-
}
|
|
203
|
-
return { ...entry, screenshotRef: { ...ref, redaction: "blurred" } };
|
|
204
|
-
});
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
return result;
|
|
208
|
-
}
|
|
209
150
|
function assertFeedbackReferences(bundle, source) {
|
|
210
151
|
const retained = new Set(source.files.filter((file) => omittedReason(file.path) === undefined).map((file) => file.path));
|
|
211
152
|
retained.add("observer/index.html");
|
|
@@ -220,51 +161,6 @@ function assertFeedbackReferences(bundle, source) {
|
|
|
220
161
|
}
|
|
221
162
|
}
|
|
222
163
|
}
|
|
223
|
-
function transformText(file, imagePaths) {
|
|
224
|
-
const text = decodeText(file);
|
|
225
|
-
const extension = path.extname(file.path).toLowerCase();
|
|
226
|
-
if (extension === ".json") {
|
|
227
|
-
const parsed = JSON.parse(text);
|
|
228
|
-
// Parsing also resolves escape sequences in string values before inspection.
|
|
229
|
-
assertNoInlineRaster(JSON.stringify(parsed));
|
|
230
|
-
// The copy names no sandbox: an id is local operational evidence, and the copy is shared.
|
|
231
|
-
const transformed = redactSandboxIds(updateScreenshotDeclarations(parsed, imagePaths));
|
|
232
|
-
if (JSON.stringify(transformed) === JSON.stringify(parsed))
|
|
233
|
-
return file.bytes;
|
|
234
|
-
assertRewritableNumbers(text);
|
|
235
|
-
return jsonBytes(transformed);
|
|
236
|
-
}
|
|
237
|
-
if (extension === ".ndjson" || extension === ".jsonl") {
|
|
238
|
-
const lines = text.split("\n");
|
|
239
|
-
const transformed = lines.map((line) => {
|
|
240
|
-
if (line.trim().length === 0)
|
|
241
|
-
return line;
|
|
242
|
-
const parsed = JSON.parse(line);
|
|
243
|
-
assertNoInlineRaster(JSON.stringify(parsed));
|
|
244
|
-
const updated = JSON.stringify(redactSandboxIds(updateScreenshotDeclarations(parsed, imagePaths)));
|
|
245
|
-
if (updated === JSON.stringify(parsed))
|
|
246
|
-
return line;
|
|
247
|
-
assertRewritableNumbers(line);
|
|
248
|
-
return updated;
|
|
249
|
-
});
|
|
250
|
-
return Buffer.from(transformed.join("\n"));
|
|
251
|
-
}
|
|
252
|
-
if (extension === ".yaml" || extension === ".yml") {
|
|
253
|
-
assertNoInlineRaster(JSON.stringify(parseYaml(text, { maxAliasCount: 100, logLevel: "silent" })) ?? "");
|
|
254
|
-
}
|
|
255
|
-
return file.bytes;
|
|
256
|
-
}
|
|
257
|
-
function assertRewritableNumbers(json) {
|
|
258
|
-
// Opaque adapter JSON stays byte-identical. A known trace that actually needs
|
|
259
|
-
// rewriting must not silently round an identifier outside JS's integer range.
|
|
260
|
-
const unquoted = json.replace(/"(?:\\.|[^"\\])*"/g, '""');
|
|
261
|
-
for (const match of unquoted.matchAll(/-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/g)) {
|
|
262
|
-
const number = Number(match[0]);
|
|
263
|
-
if (!Number.isFinite(number) || (Number.isInteger(number) && !Number.isSafeInteger(number))) {
|
|
264
|
-
throw new Error("A trace requiring redaction contains an unsafe numeric value; represent large identifiers as strings before capture.");
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
164
|
/** PNG paths that run.json cites outside actor traces: feedback, adapter and stream artifacts. */
|
|
269
165
|
function citedPngPaths(bundle) {
|
|
270
166
|
const cited = [
|
|
@@ -304,6 +200,13 @@ async function writeDerivative(source, stagePaths, bundle) {
|
|
|
304
200
|
const entries = [];
|
|
305
201
|
const imagePaths = keptPngPaths(source, bundle);
|
|
306
202
|
const sandboxIds = inventorySandboxIds(source.files);
|
|
203
|
+
// run.json and each trace file come from one rewritten bundle, so each trace's index binds the
|
|
204
|
+
// bytes written here. A v1 source is written as v2.
|
|
205
|
+
const derived = splitStreamTraces(publicSandboxView(derivedBundle(bundle, imagePaths), sandboxIds));
|
|
206
|
+
const derivedFiles = new Map([
|
|
207
|
+
["run.json", jsonBytes(derived.bundle)],
|
|
208
|
+
...derived.files.map((file) => [file.path, file.bytes]),
|
|
209
|
+
]);
|
|
307
210
|
let images = 0;
|
|
308
211
|
for (const file of source.files) {
|
|
309
212
|
const isPng = path.extname(file.path).toLowerCase() === ".png";
|
|
@@ -332,20 +235,11 @@ async function writeDerivative(source, stagePaths, bundle) {
|
|
|
332
235
|
if (!TEXT_EXTENSIONS.has(extension))
|
|
333
236
|
throw new Error(`Unsupported artifact format ${extension || "(no extension)"}; bundle export supports PNG and UTF-8 text only.`);
|
|
334
237
|
bytes = transformText(file, imagePaths);
|
|
335
|
-
|
|
238
|
+
const rewritten = derivedFiles.get(file.path);
|
|
239
|
+
if (rewritten !== undefined) {
|
|
336
240
|
assertRewritableNumbers(file.bytes.toString("utf8"));
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
status: "passed",
|
|
340
|
-
notes: `${bundle.redaction.notes} Exported copy: screenshots were blurred during export; the original capture is retained separately. See derivation.json.`,
|
|
341
|
-
};
|
|
342
|
-
for (const stream of transformed.streams) {
|
|
343
|
-
for (const artifact of stream.artifacts) {
|
|
344
|
-
if (artifact.kind === "screenshot")
|
|
345
|
-
artifact.label = artifact.label.replace(/\((raw|blurred)\)/g, "(blurred at export)");
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
bytes = jsonBytes(transformed);
|
|
241
|
+
bytes = rewritten;
|
|
242
|
+
derivedFiles.delete(file.path);
|
|
349
243
|
}
|
|
350
244
|
bytes = scrubSandboxIdBytes(file.path, bytes, sandboxIds);
|
|
351
245
|
action = bytes.equals(file.bytes) ? "copied" : "updated";
|
|
@@ -353,6 +247,19 @@ async function writeDerivative(source, stagePaths, bundle) {
|
|
|
353
247
|
await writeContainedOutputFile(stagePaths, file.path, bytes);
|
|
354
248
|
entries.push({ path: file.path, sourceSha256: file.sha256, outputSha256: hash(bytes), action });
|
|
355
249
|
}
|
|
250
|
+
// A v1 source stream with no actor trace file gets one; its content came from run.json.
|
|
251
|
+
const runJson = source.files.find((file) => file.path === "run.json");
|
|
252
|
+
for (const [relative, bytes] of derivedFiles) {
|
|
253
|
+
if (relative === "run.json" || runJson === undefined)
|
|
254
|
+
continue;
|
|
255
|
+
await writeContainedOutputFile(stagePaths, relative, bytes);
|
|
256
|
+
entries.push({
|
|
257
|
+
path: relative,
|
|
258
|
+
sourceSha256: runJson.sha256,
|
|
259
|
+
outputSha256: hash(bytes),
|
|
260
|
+
action: "updated",
|
|
261
|
+
});
|
|
262
|
+
}
|
|
356
263
|
return { images, entries };
|
|
357
264
|
}
|
|
358
265
|
async function removeOwnedDirectory(root, strict = false) {
|
package/dist/feedback/export.js
CHANGED
|
@@ -15,6 +15,7 @@ import { renderObserver } from "../observer/render.js";
|
|
|
15
15
|
import { renderObserverHtml } from "../observer/artifact.js";
|
|
16
16
|
import { buildObserverData } from "../observer/data.js";
|
|
17
17
|
import { resolveRunPath } from "../run/locate.js";
|
|
18
|
+
import { loadRunTraces, StreamTraceRefusedError } from "../run/stream-trace.js";
|
|
18
19
|
import { verifyRun } from "../verify/verify.js";
|
|
19
20
|
import {} from "../run/bundle.js";
|
|
20
21
|
import { exportRedactedBundle } from "./export-bundle.js";
|
|
@@ -186,7 +187,14 @@ async function currentObserverData(runPaths, analysis, slot) {
|
|
|
186
187
|
if (!source ||
|
|
187
188
|
createHash("sha256").update(source).digest("hex") !== analysis.analysis.sourceRunSha256)
|
|
188
189
|
return undefined;
|
|
189
|
-
|
|
190
|
+
const stored = JSON.parse(source.toString("utf8"));
|
|
191
|
+
// A trace that no longer matches run.json's index is a source that no longer matches.
|
|
192
|
+
const bundle = await loadRunTraces(runPaths, stored).catch((error) => {
|
|
193
|
+
if (error instanceof StreamTraceRefusedError)
|
|
194
|
+
return undefined;
|
|
195
|
+
throw error;
|
|
196
|
+
});
|
|
197
|
+
return bundle === undefined ? undefined : { value: buildObserverData(bundle) };
|
|
190
198
|
}
|
|
191
199
|
/** Portable HTML keeps the lightweight evidence; media remains in the source run. */
|
|
192
200
|
function omitRecordings(data) {
|