@bli-cockpit/cli 0.2.263 → 0.2.265
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/dist/adapters/agent-image-evidence.js +4 -4
- package/dist/adapters/raw-evidence-collection-state.js +14 -2
- package/dist/adapters/raw-evidence-sanitize-held.js +191 -0
- package/dist/adapters/raw-evidence-sanitize.js +10 -10
- package/dist/adapters/raw-evidence-transcript-body.js +80 -0
- package/dist/adapters/raw-evidence-transcript-file.js +43 -56
- package/dist/adapters/raw-evidence-transcript-stream.js +63 -0
- package/dist/adapters/transcript-line-stream.js +47 -0
- package/dist/adapters/transcript-text-stream.js +124 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/image-census-stream.js +37 -0
- package/dist/image-description-session.js +116 -24
- package/dist/slim/slim-stream.js +77 -0
- package/dist/slim/slim-transcript.js +99 -22
- package/package.json +1 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { SECRET_FILE_SEGMENT_PATTERN } from "@bli-cockpit/telemetry-core";
|
|
2
|
+
import { linesOfFile } from "./transcript-line-stream.js";
|
|
2
3
|
import crypto from "node:crypto";
|
|
3
4
|
import fs from "node:fs/promises";
|
|
4
5
|
import path from "node:path";
|
|
@@ -14,13 +15,12 @@ export async function collectAgentImageEvidenceFromJsonlFile(options) {
|
|
|
14
15
|
skipped: [{ label: "transcript", reason: "file_too_large" }],
|
|
15
16
|
};
|
|
16
17
|
}
|
|
17
|
-
const content = await fs.readFile(options.filePath, "utf8").catch(() => null);
|
|
18
|
-
if (content === null)
|
|
19
|
-
return { images: [], skipped: [] };
|
|
20
18
|
const images = [];
|
|
21
19
|
const skipped = [];
|
|
22
20
|
let lineIndex = 0;
|
|
23
|
-
|
|
21
|
+
// BLI-23317: lines stream off the disk (the same lines `readFile` + `split("\n")` gave), so a 200 MB
|
|
22
|
+
// transcript is never held as bytes, string and lines at once; only the longest line is in memory.
|
|
23
|
+
for await (const line of linesOfFile(options.filePath)) {
|
|
24
24
|
if (!line.trim())
|
|
25
25
|
continue;
|
|
26
26
|
lineIndex += 1;
|
|
@@ -29,6 +29,7 @@ import { RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES } from "@bli-cockpit/telemetry-core"
|
|
|
29
29
|
import crypto from "node:crypto";
|
|
30
30
|
import fs from "node:fs/promises";
|
|
31
31
|
import path from "node:path";
|
|
32
|
+
import { describeError } from "../health-detail.js";
|
|
32
33
|
import { DELIVERY_BACKOFF_BYPASS_REASON, deliveryBackoffApplies, heldSourceKeys, readRawEvidenceStagingState, resolveStagedObject, } from "../raw-evidence-staging.js";
|
|
33
34
|
import { openStagingAllowance, } from "../staging-gate.js";
|
|
34
35
|
import { chmodPrivate } from "./raw-evidence-pack-store.js";
|
|
@@ -201,8 +202,19 @@ export async function stageEvidenceBytes(collection, options) {
|
|
|
201
202
|
return { local_path: existing.local_path, staged_in_pack: false };
|
|
202
203
|
}
|
|
203
204
|
const destination = path.join(collection.filesDir, options.fileName);
|
|
204
|
-
|
|
205
|
-
|
|
205
|
+
// BLI-23317: written beside the destination and renamed in, so a crash or a full disk mid-write
|
|
206
|
+
// leaves a `.partial` file and never a truncated object under the name the manifest points at.
|
|
207
|
+
const partial = `${destination}.partial-${process.pid}`;
|
|
208
|
+
try {
|
|
209
|
+
await fs.writeFile(partial, options.bytes, { mode: 0o600 });
|
|
210
|
+
await chmodPrivate(partial, 0o600);
|
|
211
|
+
await fs.rename(partial, destination);
|
|
212
|
+
}
|
|
213
|
+
catch (error) {
|
|
214
|
+
await fs.rm(partial, { force: true }).catch(() => undefined);
|
|
215
|
+
console.error("[raw-evidence] staging write failed, nothing was published", JSON.stringify({ reason: "staging_write_failed", kind: options.kind, byte_size: options.bytes.byteLength, ...describeError(error) }));
|
|
216
|
+
throw error;
|
|
217
|
+
}
|
|
206
218
|
collection.stagedNewCount += 1;
|
|
207
219
|
return { local_path: destination, staged_in_pack: true };
|
|
208
220
|
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { containsSecretLikeContent, redactSecretLikeContent, scannedCleanRedactionMetadata, } from "@bli-cockpit/telemetry-core";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { WHOLE_FILE_MASK_LINE, fallbackRedactionMetadata, logRedactionCrash, maskSecretBearingLines, } from "./raw-evidence-sanitize.js";
|
|
4
|
+
/** Code units per encode window: 4 Mi units encode to at most 12 MiB, and the window is garbage right after. */
|
|
5
|
+
const WINDOW_UNITS = 4 * 1024 * 1024;
|
|
6
|
+
/**
|
|
7
|
+
* The sha256 and byte length of `Buffer.from(text, "utf8")`, without that buffer: the text is encoded
|
|
8
|
+
* a window at a time. A window never ends between the halves of a surrogate pair, so the bytes fed to
|
|
9
|
+
* the hash are the bytes a whole-string encode produces (a lone surrogate encodes the same either way).
|
|
10
|
+
*/
|
|
11
|
+
export function factsOfText(text, windowUnits = WINDOW_UNITS) {
|
|
12
|
+
const hash = createHash("sha256");
|
|
13
|
+
let byteLength = 0;
|
|
14
|
+
for (let at = 0; at < text.length;) {
|
|
15
|
+
let end = Math.min(text.length, at + windowUnits);
|
|
16
|
+
if (end < text.length && isHighSurrogate(text.charCodeAt(end - 1)) && isLowSurrogate(text.charCodeAt(end)))
|
|
17
|
+
end += 1;
|
|
18
|
+
const chunk = Buffer.from(text.slice(at, end), "utf8");
|
|
19
|
+
hash.update(chunk);
|
|
20
|
+
byteLength += chunk.byteLength;
|
|
21
|
+
at = end;
|
|
22
|
+
}
|
|
23
|
+
return { sha256: hash.digest("hex"), byteLength };
|
|
24
|
+
}
|
|
25
|
+
function isHighSurrogate(unit) {
|
|
26
|
+
return unit >= 0xd800 && unit <= 0xdbff;
|
|
27
|
+
}
|
|
28
|
+
function isLowSurrogate(unit) {
|
|
29
|
+
return unit >= 0xdc00 && unit <= 0xdfff;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Same decision table as `sanitizeTextEvidenceForUpload`. The holder's text is cleared before the
|
|
33
|
+
* bytes of the answer are built, so the answer is the only large thing left alive when this returns.
|
|
34
|
+
*/
|
|
35
|
+
export function sanitizeHeldTextForUpload(held, options) {
|
|
36
|
+
// No local in this frame points at the text: only `held` does, so clearing it frees the transcript.
|
|
37
|
+
let facts;
|
|
38
|
+
const scan = {
|
|
39
|
+
textLength: held.text.length,
|
|
40
|
+
facts: () => (facts ??= held.exactBytes ? { sha256: sha256Of(held.exactBytes), byteLength: held.exactBytes.byteLength } : factsOfText(held.text)),
|
|
41
|
+
exactBytes: held.exactBytes,
|
|
42
|
+
redactedFields: options.redactedFields,
|
|
43
|
+
secretLikeFileName: options.secretLikeFileName === true,
|
|
44
|
+
};
|
|
45
|
+
try {
|
|
46
|
+
const found = scanHeldText(held, options.redactedFields, options.redact ?? redactSecretLikeContent);
|
|
47
|
+
if (scan.secretLikeFileName)
|
|
48
|
+
return maskNamedFile(held, scan, found.attempt);
|
|
49
|
+
if (found.secretLikeContent) {
|
|
50
|
+
// This branch needs the redactor's receipt, never its text; dropping the rest lets the rope of
|
|
51
|
+
// slices (which points into the transcript) go with the transcript.
|
|
52
|
+
const metadata = found.attempt.metadata;
|
|
53
|
+
found.attempt = { redacted: false, text: "", metadata: null };
|
|
54
|
+
return maskSecretBearingLinesOnly(held, scan, metadata);
|
|
55
|
+
}
|
|
56
|
+
if (found.attempt.redacted)
|
|
57
|
+
return keepDeterministicRedaction(held, scan, found.attempt);
|
|
58
|
+
const bytes = held.exactBytes ?? Buffer.from(held.text, "utf8");
|
|
59
|
+
held.text = null;
|
|
60
|
+
return { status: "clean", bytes, redaction: scannedCleanRedactionMetadata(bytes) };
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
// Reads the facts while the text is still held; a crash inside them lands on the same stub.
|
|
64
|
+
const stubFacts = safeFacts(scan);
|
|
65
|
+
held.text = null;
|
|
66
|
+
logRedactionCrash(error, stubFacts?.byteLength ?? 0, options.redactedFields.length);
|
|
67
|
+
return stubForCrashedRedaction(scan, stubFacts);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** The content guard's verdict and the redactor's answer, in one frame that ends before any bytes are built. */
|
|
71
|
+
function scanHeldText(held, redactedFields, redact) {
|
|
72
|
+
// BLI-11094: inside the caller's try. The content guard runs regexes over the whole file.
|
|
73
|
+
const secretLikeContent = containsSecretLikeContent(held.text);
|
|
74
|
+
const attempt = redact(held.text, { appliedBy: "local_collector", redactedFields });
|
|
75
|
+
return { secretLikeContent, attempt };
|
|
76
|
+
}
|
|
77
|
+
function safeFacts(scan) {
|
|
78
|
+
try {
|
|
79
|
+
return scan.facts();
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function sha256Of(bytes) {
|
|
86
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
87
|
+
}
|
|
88
|
+
function withFacts(metadata, original, sanitizedBytes) {
|
|
89
|
+
if (!metadata)
|
|
90
|
+
throw new Error("redacted evidence is missing redaction metadata");
|
|
91
|
+
return {
|
|
92
|
+
...metadata,
|
|
93
|
+
original_content_hash_sha256: original.sha256,
|
|
94
|
+
sanitized_content_hash_sha256: sha256Of(sanitizedBytes),
|
|
95
|
+
original_byte_size: original.byteLength,
|
|
96
|
+
sanitized_byte_size: sanitizedBytes.byteLength,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
/** The name itself is the signal: whatever the redactor left is re-checked, and a still-secret result becomes one marker line. */
|
|
100
|
+
function maskNamedFile(held, scan, attempt) {
|
|
101
|
+
const original = scan.facts();
|
|
102
|
+
const sanitizedText = attempt.redacted ? attempt.text : held.text;
|
|
103
|
+
const safeText = containsSecretLikeContent(sanitizedText) ? WHOLE_FILE_MASK_LINE : sanitizedText;
|
|
104
|
+
const metadata = attempt.metadata ?? fallbackRedactionMetadata({
|
|
105
|
+
ruleId: "secret_like_file_name",
|
|
106
|
+
originalLength: scan.textLength,
|
|
107
|
+
redactedFields: scan.redactedFields,
|
|
108
|
+
fullContentRedacted: false,
|
|
109
|
+
});
|
|
110
|
+
held.text = null;
|
|
111
|
+
const sanitizedBytes = Buffer.from(safeText, "utf8");
|
|
112
|
+
return {
|
|
113
|
+
status: "redacted",
|
|
114
|
+
bytes: sanitizedBytes,
|
|
115
|
+
redaction: withFacts(metadata, original, sanitizedBytes),
|
|
116
|
+
completenessLabel: "secret_like_name_masked",
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/** Only the lines that matched are replaced; every other line uploads intact. */
|
|
120
|
+
function maskSecretBearingLinesOnly(held, scan, metadata) {
|
|
121
|
+
const original = scan.facts();
|
|
122
|
+
const redaction = metadata ?? fallbackRedactionMetadata({
|
|
123
|
+
ruleId: "secret_like_content_guard",
|
|
124
|
+
originalLength: scan.textLength,
|
|
125
|
+
redactedFields: scan.redactedFields,
|
|
126
|
+
});
|
|
127
|
+
let maskedText = maskSecretBearingLines(held.text, redaction);
|
|
128
|
+
held.text = null;
|
|
129
|
+
if (containsSecretLikeContent(maskedText))
|
|
130
|
+
maskedText = WHOLE_FILE_MASK_LINE;
|
|
131
|
+
const sanitizedBytes = Buffer.from(maskedText, "utf8");
|
|
132
|
+
return {
|
|
133
|
+
status: "redacted",
|
|
134
|
+
bytes: sanitizedBytes,
|
|
135
|
+
redaction: withFacts(redaction, original, sanitizedBytes),
|
|
136
|
+
completenessLabel: "secret_content_masked",
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
/** The redactor found and replaced something the content guard did not flag. */
|
|
140
|
+
function keepDeterministicRedaction(held, scan, attempt) {
|
|
141
|
+
const original = scan.facts();
|
|
142
|
+
const metadata = attempt.metadata ?? fallbackRedactionMetadata({
|
|
143
|
+
ruleId: "secret_redaction_failed",
|
|
144
|
+
originalLength: scan.textLength,
|
|
145
|
+
redactedFields: scan.redactedFields,
|
|
146
|
+
});
|
|
147
|
+
held.text = null;
|
|
148
|
+
const sanitizedBytes = Buffer.from(attempt.text, "utf8");
|
|
149
|
+
return {
|
|
150
|
+
status: "redacted",
|
|
151
|
+
bytes: sanitizedBytes,
|
|
152
|
+
redaction: withFacts(metadata, original, sanitizedBytes),
|
|
153
|
+
completenessLabel: "secret_content_masked",
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
function stubForCrashedRedaction(scan, facts) {
|
|
157
|
+
const sanitizedBytes = Buffer.from("[REDACTED_LINE:redaction_crashed_stubbed]\n", "utf8");
|
|
158
|
+
return {
|
|
159
|
+
status: "redacted",
|
|
160
|
+
bytes: sanitizedBytes,
|
|
161
|
+
redaction: withFacts(fallbackRedactionMetadata({ ruleId: "redaction_crashed_stubbed", originalLength: scan.textLength, redactedFields: scan.redactedFields }),
|
|
162
|
+
// The old path would have thrown here (hashing the original) only if the original were unreadable; it never is.
|
|
163
|
+
facts ?? { sha256: sha256Of(Buffer.alloc(0)), byteLength: 0 }, sanitizedBytes),
|
|
164
|
+
completenessLabel: "redaction_crashed_stubbed",
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Mask secret-like values in a transcript and record that it happened (see `maskSecretsInTranscript`
|
|
169
|
+
* in raw-evidence-sanitize.ts, whose answer this one matches), from text the caller holds once.
|
|
170
|
+
*/
|
|
171
|
+
export function maskSecretsInHeldTranscript(collection, held, options) {
|
|
172
|
+
const sanitized = sanitizeHeldTextForUpload(held, {
|
|
173
|
+
redactedFields: [`${options.kind}.body`],
|
|
174
|
+
secretLikeFileName: options.secretLikeFileName,
|
|
175
|
+
});
|
|
176
|
+
if (sanitized.status === "redacted") {
|
|
177
|
+
collection.redacted.push({
|
|
178
|
+
kind: options.kind,
|
|
179
|
+
label: options.evidenceLabel,
|
|
180
|
+
redaction: sanitized.redaction,
|
|
181
|
+
completenessLabel: sanitized.completenessLabel,
|
|
182
|
+
});
|
|
183
|
+
console.error("[raw-evidence] text evidence sanitized", JSON.stringify({
|
|
184
|
+
kind: options.kind,
|
|
185
|
+
mode: sanitized.completenessLabel,
|
|
186
|
+
original_bytes: sanitized.redaction.original_byte_size,
|
|
187
|
+
uploaded_bytes: sanitized.bytes.byteLength,
|
|
188
|
+
}));
|
|
189
|
+
}
|
|
190
|
+
return sanitized;
|
|
191
|
+
}
|
|
@@ -64,7 +64,7 @@ export function sanitizeTextEvidenceForUpload(options) {
|
|
|
64
64
|
});
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
|
-
function logRedactionCrash(error, byteSize, redactedFieldCount) {
|
|
67
|
+
export function logRedactionCrash(error, byteSize, redactedFieldCount) {
|
|
68
68
|
// The redactor itself crashed. The stub below is the safe answer — a
|
|
69
69
|
// placeholder goes up instead of unredacted bytes — but it is also
|
|
70
70
|
// indistinguishable downstream from a file that was legitimately masked,
|
|
@@ -97,7 +97,7 @@ function maskFileWithSecretLikeName(input) {
|
|
|
97
97
|
redaction: withRedactionContentMetadata(input.redactionResult.metadata ??
|
|
98
98
|
fallbackRedactionMetadata({
|
|
99
99
|
ruleId: "secret_like_file_name",
|
|
100
|
-
|
|
100
|
+
originalLength: input.text.length,
|
|
101
101
|
redactedFields: input.redactedFields,
|
|
102
102
|
fullContentRedacted: false,
|
|
103
103
|
}), { originalBytes: input.originalBytes, sanitizedBytes }),
|
|
@@ -109,7 +109,7 @@ function maskSecretBearingLinesOnly(input) {
|
|
|
109
109
|
const redaction = input.redactionResult.metadata ??
|
|
110
110
|
fallbackRedactionMetadata({
|
|
111
111
|
ruleId: "secret_like_content_guard",
|
|
112
|
-
|
|
112
|
+
originalLength: input.text.length,
|
|
113
113
|
redactedFields: input.redactedFields,
|
|
114
114
|
});
|
|
115
115
|
let maskedText = maskSecretBearingLines(input.text, redaction);
|
|
@@ -136,7 +136,7 @@ function keepDeterministicRedaction(input) {
|
|
|
136
136
|
redaction: withRedactionContentMetadata(input.redactionResult.metadata ??
|
|
137
137
|
fallbackRedactionMetadata({
|
|
138
138
|
ruleId: "secret_redaction_failed",
|
|
139
|
-
|
|
139
|
+
originalLength: input.text.length,
|
|
140
140
|
redactedFields: input.redactedFields,
|
|
141
141
|
}), { originalBytes: input.originalBytes, sanitizedBytes }),
|
|
142
142
|
completenessLabel: "secret_content_masked",
|
|
@@ -149,7 +149,7 @@ function stubForCrashedRedaction(input) {
|
|
|
149
149
|
bytes: sanitizedBytes,
|
|
150
150
|
redaction: withRedactionContentMetadata(fallbackRedactionMetadata({
|
|
151
151
|
ruleId: "redaction_crashed_stubbed",
|
|
152
|
-
|
|
152
|
+
originalLength: input.text.length,
|
|
153
153
|
redactedFields: input.redactedFields,
|
|
154
154
|
}), { originalBytes: input.originalBytes, sanitizedBytes }),
|
|
155
155
|
completenessLabel: "redaction_crashed_stubbed",
|
|
@@ -174,7 +174,7 @@ function stubForCrashedRedaction(input) {
|
|
|
174
174
|
* offsets are UTF-16 and file-absolute, and a line takes the rule of the FIRST
|
|
175
175
|
* range in array order that overlaps it.
|
|
176
176
|
*/
|
|
177
|
-
function maskSecretBearingLines(text, redaction) {
|
|
177
|
+
export function maskSecretBearingLines(text, redaction) {
|
|
178
178
|
if (redaction.redacted_ranges.length === 0) {
|
|
179
179
|
return "[REDACTED_LINE:secret_like_content_guard]\n";
|
|
180
180
|
}
|
|
@@ -240,7 +240,7 @@ function lineEndingOf(text, start, end) {
|
|
|
240
240
|
return "\n";
|
|
241
241
|
return "";
|
|
242
242
|
}
|
|
243
|
-
function fallbackRedactionMetadata(options) {
|
|
243
|
+
export function fallbackRedactionMetadata(options) {
|
|
244
244
|
const fullContentRedacted = options.fullContentRedacted !== false;
|
|
245
245
|
return {
|
|
246
246
|
schema_version: "raw-evidence-redaction.v1",
|
|
@@ -252,17 +252,17 @@ function fallbackRedactionMetadata(options) {
|
|
|
252
252
|
rule_id: options.ruleId,
|
|
253
253
|
match_count: 1,
|
|
254
254
|
redacted_char_count: fullContentRedacted
|
|
255
|
-
? options.
|
|
255
|
+
? options.originalLength
|
|
256
256
|
: 0,
|
|
257
257
|
},
|
|
258
258
|
],
|
|
259
259
|
secret_like_match_count: 1,
|
|
260
260
|
redacted_fields: options.redactedFields,
|
|
261
|
-
redacted_ranges: fullContentRedacted && options.
|
|
261
|
+
redacted_ranges: fullContentRedacted && options.originalLength > 0
|
|
262
262
|
? [
|
|
263
263
|
{
|
|
264
264
|
start: 0,
|
|
265
|
-
end: options.
|
|
265
|
+
end: options.originalLength,
|
|
266
266
|
rule_id: options.ruleId,
|
|
267
267
|
},
|
|
268
268
|
]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The transcript text that the mask step reads, and the image and slim step that produce it
|
|
3
|
+
* (BLI-16977 for the steps, BLI-23317 for how few copies of a 200 MB file they leave behind).
|
|
4
|
+
*
|
|
5
|
+
* The file's bytes are decoded ONCE and handed on in a holder (`HeldText`); the bytes themselves
|
|
6
|
+
* are let go the moment the text exists, because for valid UTF-8 the text encodes back to exactly
|
|
7
|
+
* those bytes (`Buffer.from(text)` is the original, so nothing needs the buffer to stay). A file
|
|
8
|
+
* that is not valid UTF-8 keeps its bytes in the holder: the decoding replaced characters, so the
|
|
9
|
+
* bytes cannot be rebuilt, and the old path uploaded the original bytes when nothing changed.
|
|
10
|
+
* The order stays image replacement, slim, redact.
|
|
11
|
+
*/
|
|
12
|
+
import { isUtf8 } from "node:buffer";
|
|
13
|
+
import { mightHoldEmbeddedImages } from "@bli-cockpit/telemetry-core";
|
|
14
|
+
import { describeError } from "../health-detail.js";
|
|
15
|
+
import { readTranscriptWhole, streamTranscriptBody, volatilityOf } from "./raw-evidence-transcript-stream.js";
|
|
16
|
+
/** Which agent's transcript rules apply to this evidence kind; null for anything the image step does not know. */
|
|
17
|
+
export function agentOfKind(kind) {
|
|
18
|
+
return kind.startsWith("codex") ? "codex" : kind.startsWith("claude") ? "claude_code" : null;
|
|
19
|
+
}
|
|
20
|
+
/** Decode once and release the bytes, unless the decoding was lossy and the bytes are the only exact copy. */
|
|
21
|
+
export function holdDecodedText(source) {
|
|
22
|
+
const bytes = source.bytes;
|
|
23
|
+
const text = bytes.toString("utf8");
|
|
24
|
+
const lossless = isUtf8(bytes);
|
|
25
|
+
source.bytes = null;
|
|
26
|
+
if (!lossless) {
|
|
27
|
+
console.error("[raw-evidence] transcript is not valid UTF-8; keeping its bytes beside the text so an unchanged file uploads as it is", JSON.stringify({ reason: "invalid_utf8_bytes_kept", byte_size: bytes.byteLength }));
|
|
28
|
+
}
|
|
29
|
+
return { text, exactBytes: lossless ? null : bytes };
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* BLI-16977: describe each distinct embedded image once and, when the one switch says so, swap the
|
|
33
|
+
* base64 for a short marker BEFORE masking, so the masker and the content hash see the final bytes.
|
|
34
|
+
* The text is unchanged (the very same decoding) unless this sync has an image session AND the switch
|
|
35
|
+
* is `describe_and_drop`; in `describe_and_keep` it always comes back untouched. An image step
|
|
36
|
+
* that breaks hands back the original text and says so. The one throw: a streamed source (BLI-23317)
|
|
37
|
+
* whose file cannot be read to the end raises `TranscriptReadError` or `TranscriptTooLargeError`, which
|
|
38
|
+
* the caller turns into the `file_read_failed` and `file_too_large` skips.
|
|
39
|
+
*/
|
|
40
|
+
export async function prepareTranscriptBody(collection, file) {
|
|
41
|
+
const session = collection.budget.images;
|
|
42
|
+
const agent = agentOfKind(file.kind);
|
|
43
|
+
if (file.source.stream) {
|
|
44
|
+
const streamed = await streamTranscriptBody(session, agent, { ...file.source.stream, sessionId: file.sessionId, kind: file.kind });
|
|
45
|
+
if (streamed) {
|
|
46
|
+
file.source.readBytes = streamed.readBytes;
|
|
47
|
+
return { held: streamed.held, volatile: streamed.volatile, pendingImages: streamed.pendingImages };
|
|
48
|
+
}
|
|
49
|
+
file.source.bytes = await readTranscriptWhole(file.source.stream);
|
|
50
|
+
file.source.readBytes = file.source.bytes.byteLength;
|
|
51
|
+
}
|
|
52
|
+
const untouched = (volatile, pendingImages) => ({ held: holdDecodedText(file.source), volatile, pendingImages });
|
|
53
|
+
if (!session || !agent)
|
|
54
|
+
return untouched(false, null);
|
|
55
|
+
// Drop mode slims every transcript, image-bearing or not; keep mode only describes the ones that can hold an image.
|
|
56
|
+
if (session.mode !== "describe_and_drop" && !mightHoldEmbeddedImages(file.source.bytes))
|
|
57
|
+
return untouched(false, null);
|
|
58
|
+
let held;
|
|
59
|
+
try {
|
|
60
|
+
held = holdDecodedText(file.source);
|
|
61
|
+
const prepared = await session.prepareTranscript({ text: held.text, source: agent, sessionId: file.sessionId });
|
|
62
|
+
// BLI-3936: "volatile" = the next tick could build different bytes, or do image work, from
|
|
63
|
+
// this very file: an image still without a stored description (keep mode never replaces, so
|
|
64
|
+
// any image counts) or a slim step that failed and should be retried.
|
|
65
|
+
// BLI-22794: volatile ONLY because of images without a stored description (the slim step held
|
|
66
|
+
// up): the bytes change only when one of these gains one, so the memo may keep the entry.
|
|
67
|
+
const { volatile, pendingImages } = volatilityOf(session.mode, prepared);
|
|
68
|
+
if (prepared.changed) {
|
|
69
|
+
// The old path encoded the new text and read it back; a text with no lone surrogate (slim output
|
|
70
|
+
// re-escapes them) is the same either way, so the new text is the exact decoding of the new bytes.
|
|
71
|
+
held.text = prepared.text;
|
|
72
|
+
held.exactBytes = null;
|
|
73
|
+
}
|
|
74
|
+
return { held, volatile, pendingImages };
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
console.error("[raw-evidence] image or slim step failed, uploading the transcript as it was", JSON.stringify({ reason: "image_step_failed", kind: file.kind, ...describeError(error) }));
|
|
78
|
+
return { held: held ?? holdDecodedText(file.source), volatile: true, pendingImages: null };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -21,8 +21,9 @@ import path from "node:path";
|
|
|
21
21
|
import { describeError } from "../health-detail.js";
|
|
22
22
|
import { DELIVERY_BACKOFF_HOLDING_REASON, evidenceSourceKey, } from "../raw-evidence-staging.js";
|
|
23
23
|
import { isSecretLikePath, sha256, shortHash, } from "./raw-evidence-keys.js";
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
24
|
+
import { maskSecretsInHeldTranscript } from "./raw-evidence-sanitize-held.js";
|
|
25
|
+
import { agentOfKind, prepareTranscriptBody } from "./raw-evidence-transcript-body.js";
|
|
26
|
+
import { TranscriptReadError, TranscriptTooLargeError } from "./transcript-text-stream.js";
|
|
26
27
|
import { markBudgetCapApplied, markCapApplied, } from "./raw-evidence-completeness.js";
|
|
27
28
|
import { evidenceEntry } from "./raw-evidence-manifest.js";
|
|
28
29
|
import { prepareRecipe, statSourceForMemo, } from "./raw-evidence-prepare-memo.js";
|
|
@@ -57,16 +58,30 @@ export async function collectOneEvidenceFile(collection, options) {
|
|
|
57
58
|
const recipe = prepareRecipe({ imageMode: collection.budget.images?.mode, kind: options.kind });
|
|
58
59
|
if (await reuseUnchangedTranscript(collection, options, names, stamp, recipe))
|
|
59
60
|
return true;
|
|
60
|
-
const read = await readEvidenceFileWithinCap(options.filePath, options.maxFileBytes);
|
|
61
|
+
const read = await readEvidenceFileWithinCap(options.filePath, options.maxFileBytes, streamMinBytesFor(collection, options.kind));
|
|
61
62
|
if (read.status === "read_failed")
|
|
62
63
|
return skip("file_read_failed");
|
|
63
64
|
if (read.status === "too_large") {
|
|
64
65
|
markCapApplied(collection, options.kind, "max_file_bytes");
|
|
65
66
|
return skip("file_too_large");
|
|
66
67
|
}
|
|
67
|
-
|
|
68
|
+
let prepared;
|
|
69
|
+
try {
|
|
70
|
+
prepared = await prepareTranscriptForUpload(collection, options, names, read.source);
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
// BLI-23317: a streamed or fallback read that fails ends exactly as a whole-file read that failed.
|
|
74
|
+
if (error instanceof TranscriptReadError)
|
|
75
|
+
return skip("file_read_failed");
|
|
76
|
+
if (error instanceof TranscriptTooLargeError) {
|
|
77
|
+
markCapApplied(collection, options.kind, "max_file_bytes");
|
|
78
|
+
return skip("file_too_large");
|
|
79
|
+
}
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
const { sanitized, contentHash, stubClaim, volatile, pendingImages, redactedEntry } = prepared;
|
|
68
83
|
await rememberPreparedTranscript(collection, options, names, {
|
|
69
|
-
stamp, recipe, readBytes: read.
|
|
84
|
+
stamp, recipe, readBytes: read.source.readBytes ?? read.byteLength, contentHash, byteSize: sanitized.bytes.byteLength, volatile, pendingImages, redactedEntry,
|
|
70
85
|
});
|
|
71
86
|
if (collection.skipContentHashes.has(contentHash) && !stubClaim) {
|
|
72
87
|
collection.reused.push({
|
|
@@ -95,17 +110,14 @@ function recordEvidenceSkip(collection, kind, label, reason) {
|
|
|
95
110
|
collection.skipped.push({ kind, label, reason });
|
|
96
111
|
return false;
|
|
97
112
|
}
|
|
98
|
-
async function prepareTranscriptForUpload(collection, options, names,
|
|
113
|
+
async function prepareTranscriptForUpload(collection, options, names, source) {
|
|
99
114
|
// BLI-16977: describe each distinct embedded image once and, when the one switch says so, swap the
|
|
100
115
|
// base64 for a short marker BEFORE masking, so the masker and the content hash see the final bytes.
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
sessionId: options.sessionId,
|
|
105
|
-
});
|
|
116
|
+
// BLI-23317: the text is decoded once and travels in a holder the mask step clears, so the file's
|
|
117
|
+
// bytes, the decoded text and the masked text are never all alive together.
|
|
118
|
+
const body = await prepareTranscriptBody(collection, { source, kind: options.kind, sessionId: options.sessionId });
|
|
106
119
|
const redactedBefore = collection.redacted.length;
|
|
107
|
-
const sanitized =
|
|
108
|
-
raw: images.bytes,
|
|
120
|
+
const sanitized = maskSecretsInHeldTranscript(collection, body.held, {
|
|
109
121
|
kind: options.kind,
|
|
110
122
|
evidenceLabel: names.evidenceLabel,
|
|
111
123
|
secretLikeFileName: names.secretLikeFileName,
|
|
@@ -122,47 +134,7 @@ async function prepareTranscriptForUpload(collection, options, names, bytes) {
|
|
|
122
134
|
byteSize: sanitized.bytes.byteLength,
|
|
123
135
|
alreadyDurable: collection.skipContentHashes.has(contentHash),
|
|
124
136
|
});
|
|
125
|
-
return { sanitized, contentHash, stubClaim, volatile:
|
|
126
|
-
}
|
|
127
|
-
/**
|
|
128
|
-
* The transcript bytes after the image step. Unchanged (the very same buffer) unless this sync has
|
|
129
|
-
* an image session AND the switch is `describe_and_drop` (images become markers where a description
|
|
130
|
-
* is stored, then the slim rules run, see src/slim/). In `describe_and_keep` the bytes always come
|
|
131
|
-
* back untouched. Never throws: an image step that breaks hands back the original bytes and says so.
|
|
132
|
-
*/
|
|
133
|
-
async function prepareEmbeddedImages(collection, file) {
|
|
134
|
-
const session = collection.budget.images;
|
|
135
|
-
const source = file.kind.startsWith("codex") ? "codex" : file.kind.startsWith("claude") ? "claude_code" : null;
|
|
136
|
-
if (!session || !source)
|
|
137
|
-
return { bytes: file.bytes, volatile: false, pendingImages: null };
|
|
138
|
-
// Drop mode slims every transcript, image-bearing or not; keep mode only describes the ones that can hold an image.
|
|
139
|
-
if (session.mode !== "describe_and_drop" && !mightHoldEmbeddedImages(file.bytes))
|
|
140
|
-
return { bytes: file.bytes, volatile: false, pendingImages: null };
|
|
141
|
-
try {
|
|
142
|
-
const prepared = await session.prepareTranscript({
|
|
143
|
-
text: file.bytes.toString("utf8"),
|
|
144
|
-
source,
|
|
145
|
-
sessionId: file.sessionId,
|
|
146
|
-
});
|
|
147
|
-
// BLI-3936: "volatile" = the next tick could build different bytes, or do image work, from
|
|
148
|
-
// this very file: an image still without a stored description (keep mode never replaces, so
|
|
149
|
-
// any image counts) or a slim step that failed and should be retried.
|
|
150
|
-
const imagesPending = session.mode === "describe_and_drop"
|
|
151
|
-
? prepared.imageCount > prepared.replacedCount
|
|
152
|
-
: prepared.imageCount > 0;
|
|
153
|
-
const slimFailed = session.mode === "describe_and_drop" && prepared.slim === null;
|
|
154
|
-
const volatile = imagesPending || slimFailed;
|
|
155
|
-
// BLI-22794: volatile ONLY because of images without a stored description (the slim step held
|
|
156
|
-
// up): the bytes change only when one of these gains one, so the memo may keep the entry.
|
|
157
|
-
const pendingImages = volatile && !slimFailed ? prepared.pendingImages : null;
|
|
158
|
-
if (!prepared.changed)
|
|
159
|
-
return { bytes: file.bytes, volatile, pendingImages };
|
|
160
|
-
return { bytes: Buffer.from(prepared.text, "utf8"), volatile, pendingImages };
|
|
161
|
-
}
|
|
162
|
-
catch (error) {
|
|
163
|
-
console.error("[raw-evidence] image or slim step failed, uploading the transcript as it was", JSON.stringify({ reason: "image_step_failed", kind: file.kind, ...describeError(error) }));
|
|
164
|
-
return { bytes: file.bytes, volatile: true, pendingImages: null };
|
|
165
|
-
}
|
|
137
|
+
return { sanitized, contentHash, stubClaim, volatile: body.volatile, pendingImages: body.pendingImages, redactedEntry };
|
|
166
138
|
}
|
|
167
139
|
/**
|
|
168
140
|
* BLI-3936: the answer for a transcript whose file has not changed since a tick that found its
|
|
@@ -307,12 +279,19 @@ async function stageOneTranscript(collection, file) {
|
|
|
307
279
|
}),
|
|
308
280
|
}));
|
|
309
281
|
}
|
|
282
|
+
/** The size from which this pass streams a transcript instead of reading it whole; null when its image session cannot (BLI-23317). */
|
|
283
|
+
function streamMinBytesFor(collection, kind) {
|
|
284
|
+
const session = collection.budget.images;
|
|
285
|
+
if (!session?.prepareTranscriptFile || !agentOfKind(kind))
|
|
286
|
+
return null;
|
|
287
|
+
return session.streamMinBytes ?? null;
|
|
288
|
+
}
|
|
310
289
|
/**
|
|
311
290
|
* Size is checked twice on purpose: once by `stat` so an oversized transcript
|
|
312
291
|
* is never buffered at all, and once on the bytes actually read, because a live
|
|
313
292
|
* session can grow between the two.
|
|
314
293
|
*/
|
|
315
|
-
async function readEvidenceFileWithinCap(filePath, maxFileBytes) {
|
|
294
|
+
async function readEvidenceFileWithinCap(filePath, maxFileBytes, streamMinBytes) {
|
|
316
295
|
if (maxFileBytes) {
|
|
317
296
|
let stat;
|
|
318
297
|
try {
|
|
@@ -333,6 +312,14 @@ async function readEvidenceFileWithinCap(filePath, maxFileBytes) {
|
|
|
333
312
|
if (stat.size > maxFileBytes)
|
|
334
313
|
return { status: "too_large" };
|
|
335
314
|
}
|
|
315
|
+
if (streamMinBytes !== null) {
|
|
316
|
+
// BLI-23317: a big transcript is streamed by the prepare step, not buffered here. A stat that fails
|
|
317
|
+
// falls through to the read below, which reports the failure itself.
|
|
318
|
+
const size = await fs.stat(filePath).then((stat) => stat.size, () => null);
|
|
319
|
+
if (size !== null && size >= streamMinBytes) {
|
|
320
|
+
return { status: "ok", source: { bytes: null, stream: { filePath, maxFileBytes } }, byteLength: size };
|
|
321
|
+
}
|
|
322
|
+
}
|
|
336
323
|
let bytes;
|
|
337
324
|
try {
|
|
338
325
|
bytes = await fs.readFile(filePath);
|
|
@@ -348,5 +335,5 @@ async function readEvidenceFileWithinCap(filePath, maxFileBytes) {
|
|
|
348
335
|
if (maxFileBytes && bytes.byteLength > maxFileBytes) {
|
|
349
336
|
return { status: "too_large" };
|
|
350
337
|
}
|
|
351
|
-
return { status: "ok", bytes };
|
|
338
|
+
return { status: "ok", source: { bytes }, byteLength: bytes.byteLength };
|
|
352
339
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The transcript that is never read whole (BLI-23317): its path goes to the image session's two
|
|
3
|
+
* streaming passes, and what comes back is the one slimmed string the sanitizer takes.
|
|
4
|
+
*
|
|
5
|
+
* Three endings, each named:
|
|
6
|
+
* - a body: the text is the slim form of the file (valid UTF-8 was checked across every chunk, so
|
|
7
|
+
* `exactBytes` is null: the text encodes back to the bytes a clean file would upload);
|
|
8
|
+
* - null: the streamed path cannot give the whole-file answer (invalid UTF-8, a file that changed
|
|
9
|
+
* between the passes, an image step that broke), and the caller reads the file whole, which is
|
|
10
|
+
* what ran before this existed and still keeps the original bytes for the clean branch;
|
|
11
|
+
* - a throw of `TranscriptReadError` or `TranscriptTooLargeError`: the same outcomes a whole-file
|
|
12
|
+
* read gives (`file_read_failed`, `file_too_large`), never a shorter file.
|
|
13
|
+
*/
|
|
14
|
+
import fs from "node:fs/promises";
|
|
15
|
+
import { describeError } from "../health-detail.js";
|
|
16
|
+
import { TranscriptNotStreamable, TranscriptReadError, TranscriptTooLargeError, } from "./transcript-text-stream.js";
|
|
17
|
+
const TAG = "[raw-evidence]";
|
|
18
|
+
/**
|
|
19
|
+
* BLI-3936: "volatile" = the next tick could build different bytes, or do image work, from this very
|
|
20
|
+
* file: an image still without a stored description (keep mode never replaces, so any image counts) or
|
|
21
|
+
* a slim step that failed and should be retried. BLI-22794: volatile ONLY because of images without a
|
|
22
|
+
* stored description means the bytes change only when one of them gains one, so the memo may keep the entry.
|
|
23
|
+
*/
|
|
24
|
+
export function volatilityOf(mode, prepared) {
|
|
25
|
+
const imagesPending = mode === "describe_and_drop"
|
|
26
|
+
? prepared.imageCount > prepared.replacedCount
|
|
27
|
+
: prepared.imageCount > 0;
|
|
28
|
+
const slimFailed = mode === "describe_and_drop" && prepared.slim === null;
|
|
29
|
+
const volatile = imagesPending || slimFailed;
|
|
30
|
+
return { volatile, pendingImages: volatile && !slimFailed ? prepared.pendingImages : null };
|
|
31
|
+
}
|
|
32
|
+
export async function streamTranscriptBody(session, agent, file) {
|
|
33
|
+
if (!session?.prepareTranscriptFile || !agent) {
|
|
34
|
+
console.error(TAG + " streamed read not available, reading the transcript whole", JSON.stringify({ reason: "stream_unavailable", kind: file.kind }));
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
const prepared = await session.prepareTranscriptFile({ filePath: file.filePath, source: agent, sessionId: file.sessionId, maxFileBytes: file.maxFileBytes });
|
|
39
|
+
const { volatile, pendingImages } = volatilityOf(session.mode, prepared);
|
|
40
|
+
console.error(TAG + " transcript streamed through image swap and slim", JSON.stringify({ kind: file.kind, byte_size: prepared.readBytes, changed: prepared.changed, images: prepared.imageCount, volatile }));
|
|
41
|
+
return { held: { text: prepared.text, exactBytes: null }, volatile, pendingImages, readBytes: prepared.readBytes };
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
if (error instanceof TranscriptReadError || error instanceof TranscriptTooLargeError)
|
|
45
|
+
throw error;
|
|
46
|
+
console.error(TAG + " streamed read set aside, reading the transcript whole", JSON.stringify({ reason: error instanceof TranscriptNotStreamable ? error.reason : "stream_step_failed", kind: file.kind, ...(error instanceof TranscriptNotStreamable ? {} : describeError(error)) }));
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** The whole-file read the streamed path falls back to: same failures, same labels, as the reader that did not stream. */
|
|
51
|
+
export async function readTranscriptWhole(file) {
|
|
52
|
+
let bytes;
|
|
53
|
+
try {
|
|
54
|
+
bytes = await fs.readFile(file.filePath);
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
console.error(TAG + " evidence file could not be read", JSON.stringify({ reason: "file_read_failed", stage: "read", ...describeError(error) }));
|
|
58
|
+
throw new TranscriptReadError("read", error);
|
|
59
|
+
}
|
|
60
|
+
if (file.maxFileBytes && bytes.byteLength > file.maxFileBytes)
|
|
61
|
+
throw new TranscriptTooLargeError(file.maxFileBytes);
|
|
62
|
+
return bytes;
|
|
63
|
+
}
|