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.
Files changed (93) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/CONTEXT.md +1 -1
  3. package/README.md +33 -6
  4. package/dist/actors/computer-use/actor.d.ts +3 -0
  5. package/dist/actors/computer-use/actor.js +1 -0
  6. package/dist/actors/computer-use/loop/backstop.d.ts +6 -1
  7. package/dist/actors/computer-use/loop/backstop.js +13 -1
  8. package/dist/actors/computer-use/loop/clock.d.ts +20 -0
  9. package/dist/actors/computer-use/loop/clock.js +64 -0
  10. package/dist/actors/computer-use/loop/ending.d.ts +1 -1
  11. package/dist/actors/computer-use/loop/session.d.ts +1 -1
  12. package/dist/actors/computer-use/loop/session.js +2 -1
  13. package/dist/actors/computer-use/loop/types.d.ts +16 -0
  14. package/dist/actors/computer-use/loop.d.ts +1 -1
  15. package/dist/actors/computer-use/loop.js +43 -8
  16. package/dist/actors/computer-use/wait.d.ts +3 -2
  17. package/dist/actors/computer-use/wait.js +5 -4
  18. package/dist/actors/contract.d.ts +3 -3
  19. package/dist/actors/contract.js +3 -0
  20. package/dist/actors/stop-cause.js +1 -0
  21. package/dist/analysis/automatic.js +2 -1
  22. package/dist/analysis/evidence.js +41 -21
  23. package/dist/cli/commands/study-run.js +9 -3
  24. package/dist/feedback/derivative-text.d.ts +12 -0
  25. package/dist/feedback/derivative-text.js +130 -0
  26. package/dist/feedback/export-bundle.js +38 -131
  27. package/dist/feedback/export.js +9 -1
  28. package/dist/observer/run-routes.js +61 -18
  29. package/dist/routes/computer-use/bundle.d.ts +0 -1
  30. package/dist/routes/computer-use/bundle.js +0 -1
  31. package/dist/routes/computer-use/diagnostics.d.ts +1 -1
  32. package/dist/routes/computer-use/diagnostics.js +4 -0
  33. package/dist/routes/computer-use/fanout-bundle.d.ts +2 -3
  34. package/dist/routes/computer-use/fanout-bundle.js +2 -12
  35. package/dist/routes/computer-use/live-phase.d.ts +0 -2
  36. package/dist/routes/computer-use/live-phase.js +2 -4
  37. package/dist/routes/computer-use/participant-execution.d.ts +9 -15
  38. package/dist/routes/computer-use/participant-execution.js +22 -31
  39. package/dist/routes/computer-use/participant-model.js +17 -0
  40. package/dist/routes/computer-use/participant-runs.d.ts +11 -1
  41. package/dist/routes/computer-use/participant-runs.js +31 -4
  42. package/dist/routes/computer-use/plan.js +8 -1
  43. package/dist/routes/computer-use/result.js +1 -2
  44. package/dist/routes/computer-use/types.d.ts +17 -5
  45. package/dist/routes/shared-world/provisioned.js +2 -2
  46. package/dist/run/bundle-shape.js +2 -2
  47. package/dist/run/bundle.d.ts +5 -2
  48. package/dist/run/bundle.js +4 -1
  49. package/dist/run/detail.d.ts +6 -0
  50. package/dist/run/detail.js +14 -1
  51. package/dist/run/host-suspension.d.ts +2 -2
  52. package/dist/run/host-suspension.js +2 -2
  53. package/dist/run/judge.d.ts +1 -1
  54. package/dist/run/locate.js +3 -1
  55. package/dist/run/projection.d.ts +7 -0
  56. package/dist/run/projection.js +10 -0
  57. package/dist/run/run.js +7 -1
  58. package/dist/run/stored-runs.js +10 -4
  59. package/dist/run/stream-shape.d.ts +11 -1
  60. package/dist/run/stream-shape.js +26 -2
  61. package/dist/run/stream-trace.d.ts +68 -0
  62. package/dist/run/stream-trace.js +260 -0
  63. package/dist/run/streams.d.ts +20 -0
  64. package/dist/study/arrivals.d.ts +52 -5
  65. package/dist/study/arrivals.js +105 -15
  66. package/dist/study/bundle-estimate.d.ts +48 -0
  67. package/dist/study/bundle-estimate.js +159 -0
  68. package/dist/study/doctor.js +4 -0
  69. package/dist/study/keys.d.ts +6 -0
  70. package/dist/study/keys.js +4 -0
  71. package/dist/study/migrate/v2.js +8 -5
  72. package/dist/study/parse/actors.d.ts +1 -1
  73. package/dist/study/parse/actors.js +33 -4
  74. package/dist/study/plan-participants.d.ts +10 -1
  75. package/dist/study/plan-participants.js +10 -1
  76. package/dist/study/plan-types.d.ts +3 -0
  77. package/dist/study/plan.js +22 -2
  78. package/dist/study/preflight.js +19 -1
  79. package/dist/study/types.d.ts +26 -0
  80. package/dist/study/validation.d.ts +3 -2
  81. package/dist/study/validation.js +22 -4
  82. package/dist/tui-app.js +87 -87
  83. package/dist/verify/artifacts.d.ts +17 -10
  84. package/dist/verify/artifacts.js +22 -13
  85. package/dist/verify/scan-limit.d.ts +6 -0
  86. package/dist/verify/scan-limit.js +25 -0
  87. package/dist/verify/verify.d.ts +1 -1
  88. package/dist/verify/verify.js +68 -18
  89. package/docs/architecture/github-feedback-loop.md +1 -1
  90. package/docs/contracts/README.md +1 -1
  91. package/docs/contracts/run-bundle.md +89 -8
  92. package/docs/contracts/schemas.md +38 -11
  93. 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 !== "humanish.run-bundle.v1" ||
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
- const actor = stream.actor;
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 = parseSource(prepared, bundleBytes);
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 = parseSource(prepared, bundleBytes);
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 announced = announcedKeyNames(config, args.options);
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 { containsSensitive, redactSandboxIds, redactScreenshot, redactText, } from "../evidence/redaction.js";
13
- import { decodeEscapes, readingsOf } from "../evidence/encoded-text.js";
14
- import { readPlainText } from "../evidence/plain-text.js";
15
- import { MAX_RUN_ENTRIES, streamScreenshotPaths } from "../verify/artifacts.js";
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 > MAX_RUN_ENTRIES)
73
- throw new Error(`Source exceeds ${MAX_RUN_ENTRIES} inventory entries.`);
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
- if (file.path === "run.json") {
238
+ const rewritten = derivedFiles.get(file.path);
239
+ if (rewritten !== undefined) {
336
240
  assertRewritableNumbers(file.bytes.toString("utf8"));
337
- const transformed = redactSandboxIds(updateScreenshotDeclarations(bundle, imagePaths));
338
- transformed.redaction = {
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) {
@@ -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
- return { value: buildObserverData(JSON.parse(source.toString("utf8"))) };
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) {