@bli-cockpit/telemetry-core 0.1.20 → 0.1.23
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/evidence-upload.d.ts +16 -4
- package/dist/ingest-dto.d.ts +12 -3
- package/dist/privacy.d.ts +4 -1
- package/dist/secret-guards.d.ts +19 -1
- package/dist/secret-guards.js +136 -6
- package/dist/source-adapter.d.ts +8 -2
- package/dist/upload-failure-class.d.ts +78 -0
- package/dist/upload-failure-class.js +135 -1
- package/package.json +1 -1
|
@@ -28,7 +28,10 @@ export declare const RawEvidenceLegacyUploadResponseSchema: z.ZodObject<{
|
|
|
28
28
|
byte_size: z.ZodNumber;
|
|
29
29
|
redaction: z.ZodOptional<z.ZodObject<{
|
|
30
30
|
schema_version: z.ZodLiteral<"raw-evidence-redaction.v1">;
|
|
31
|
-
status: z.
|
|
31
|
+
status: z.ZodEnum<{
|
|
32
|
+
sanitized: "sanitized";
|
|
33
|
+
scanned_clean: "scanned_clean";
|
|
34
|
+
}>;
|
|
32
35
|
mode: z.ZodLiteral<"deterministic_text_replacement">;
|
|
33
36
|
applied_by: z.ZodArray<z.ZodEnum<{
|
|
34
37
|
local_collector: "local_collector";
|
|
@@ -90,7 +93,10 @@ export declare const RawEvidenceUploadBeginObjectSchema: z.ZodObject<{
|
|
|
90
93
|
redacted_summary: z.ZodOptional<z.ZodString>;
|
|
91
94
|
redaction: z.ZodOptional<z.ZodObject<{
|
|
92
95
|
schema_version: z.ZodLiteral<"raw-evidence-redaction.v1">;
|
|
93
|
-
status: z.
|
|
96
|
+
status: z.ZodEnum<{
|
|
97
|
+
sanitized: "sanitized";
|
|
98
|
+
scanned_clean: "scanned_clean";
|
|
99
|
+
}>;
|
|
94
100
|
mode: z.ZodLiteral<"deterministic_text_replacement">;
|
|
95
101
|
applied_by: z.ZodArray<z.ZodEnum<{
|
|
96
102
|
local_collector: "local_collector";
|
|
@@ -186,7 +192,10 @@ export declare const RawEvidenceUploadBeginRequestSchema: z.ZodObject<{
|
|
|
186
192
|
redacted_summary: z.ZodOptional<z.ZodString>;
|
|
187
193
|
redaction: z.ZodOptional<z.ZodObject<{
|
|
188
194
|
schema_version: z.ZodLiteral<"raw-evidence-redaction.v1">;
|
|
189
|
-
status: z.
|
|
195
|
+
status: z.ZodEnum<{
|
|
196
|
+
sanitized: "sanitized";
|
|
197
|
+
scanned_clean: "scanned_clean";
|
|
198
|
+
}>;
|
|
190
199
|
mode: z.ZodLiteral<"deterministic_text_replacement">;
|
|
191
200
|
applied_by: z.ZodArray<z.ZodEnum<{
|
|
192
201
|
local_collector: "local_collector";
|
|
@@ -351,7 +360,10 @@ export declare const RawEvidenceUploadCommitResponseSchema: z.ZodObject<{
|
|
|
351
360
|
chunk_count: z.ZodNumber;
|
|
352
361
|
redaction: z.ZodOptional<z.ZodObject<{
|
|
353
362
|
schema_version: z.ZodLiteral<"raw-evidence-redaction.v1">;
|
|
354
|
-
status: z.
|
|
363
|
+
status: z.ZodEnum<{
|
|
364
|
+
sanitized: "sanitized";
|
|
365
|
+
scanned_clean: "scanned_clean";
|
|
366
|
+
}>;
|
|
355
367
|
mode: z.ZodLiteral<"deterministic_text_replacement">;
|
|
356
368
|
applied_by: z.ZodArray<z.ZodEnum<{
|
|
357
369
|
local_collector: "local_collector";
|
package/dist/ingest-dto.d.ts
CHANGED
|
@@ -290,7 +290,10 @@ export declare const TelemetryIngestEventDtoSchema: z.ZodObject<{
|
|
|
290
290
|
redacted_summary: z.ZodOptional<z.ZodString>;
|
|
291
291
|
redaction: z.ZodOptional<z.ZodObject<{
|
|
292
292
|
schema_version: z.ZodLiteral<"raw-evidence-redaction.v1">;
|
|
293
|
-
status: z.
|
|
293
|
+
status: z.ZodEnum<{
|
|
294
|
+
sanitized: "sanitized";
|
|
295
|
+
scanned_clean: "scanned_clean";
|
|
296
|
+
}>;
|
|
294
297
|
mode: z.ZodLiteral<"deterministic_text_replacement">;
|
|
295
298
|
applied_by: z.ZodArray<z.ZodEnum<{
|
|
296
299
|
local_collector: "local_collector";
|
|
@@ -763,7 +766,10 @@ export declare const TelemetryIngestEnvelopeSchema: z.ZodObject<{
|
|
|
763
766
|
redacted_summary: z.ZodOptional<z.ZodString>;
|
|
764
767
|
redaction: z.ZodOptional<z.ZodObject<{
|
|
765
768
|
schema_version: z.ZodLiteral<"raw-evidence-redaction.v1">;
|
|
766
|
-
status: z.
|
|
769
|
+
status: z.ZodEnum<{
|
|
770
|
+
sanitized: "sanitized";
|
|
771
|
+
scanned_clean: "scanned_clean";
|
|
772
|
+
}>;
|
|
767
773
|
mode: z.ZodLiteral<"deterministic_text_replacement">;
|
|
768
774
|
applied_by: z.ZodArray<z.ZodEnum<{
|
|
769
775
|
local_collector: "local_collector";
|
|
@@ -913,7 +919,10 @@ export declare const TelemetryIngestEnvelopeSchema: z.ZodObject<{
|
|
|
913
919
|
redacted_summary: z.ZodOptional<z.ZodString>;
|
|
914
920
|
redaction: z.ZodOptional<z.ZodObject<{
|
|
915
921
|
schema_version: z.ZodLiteral<"raw-evidence-redaction.v1">;
|
|
916
|
-
status: z.
|
|
922
|
+
status: z.ZodEnum<{
|
|
923
|
+
sanitized: "sanitized";
|
|
924
|
+
scanned_clean: "scanned_clean";
|
|
925
|
+
}>;
|
|
917
926
|
mode: z.ZodLiteral<"deterministic_text_replacement">;
|
|
918
927
|
applied_by: z.ZodArray<z.ZodEnum<{
|
|
919
928
|
local_collector: "local_collector";
|
package/dist/privacy.d.ts
CHANGED
|
@@ -68,7 +68,10 @@ export declare const RawEvidencePointerSchema: z.ZodObject<{
|
|
|
68
68
|
redacted_summary: z.ZodOptional<z.ZodString>;
|
|
69
69
|
redaction: z.ZodOptional<z.ZodObject<{
|
|
70
70
|
schema_version: z.ZodLiteral<"raw-evidence-redaction.v1">;
|
|
71
|
-
status: z.
|
|
71
|
+
status: z.ZodEnum<{
|
|
72
|
+
sanitized: "sanitized";
|
|
73
|
+
scanned_clean: "scanned_clean";
|
|
74
|
+
}>;
|
|
72
75
|
mode: z.ZodLiteral<"deterministic_text_replacement">;
|
|
73
76
|
applied_by: z.ZodArray<z.ZodEnum<{
|
|
74
77
|
local_collector: "local_collector";
|
package/dist/secret-guards.d.ts
CHANGED
|
@@ -43,9 +43,27 @@ export declare const RawEvidenceRedactionRangeSchema: z.ZodObject<{
|
|
|
43
43
|
rule_id: z.ZodString;
|
|
44
44
|
}, z.core.$strict>;
|
|
45
45
|
export type RawEvidenceRedactionRange = z.infer<typeof RawEvidenceRedactionRangeSchema>;
|
|
46
|
+
/**
|
|
47
|
+
* The verdict of a secret scan — NOT "did the bytes change" (BLI-3277).
|
|
48
|
+
*
|
|
49
|
+
* `sanitized` means the scan matched and the uploaded bytes differ from what was
|
|
50
|
+
* read. `scanned_clean` means the same scan ran over the same bytes and matched
|
|
51
|
+
* nothing, so the uploaded bytes are the original ones. The second value exists
|
|
52
|
+
* because every reader downstream asks "was this scanned?", and for 70% of
|
|
53
|
+
* transcripts — the ones with no secret in them — the honest answer used to be
|
|
54
|
+
* an absent record, which reads identically to "nobody ever looked".
|
|
55
|
+
*/
|
|
56
|
+
export declare const RawEvidenceRedactionStatusSchema: z.ZodEnum<{
|
|
57
|
+
sanitized: "sanitized";
|
|
58
|
+
scanned_clean: "scanned_clean";
|
|
59
|
+
}>;
|
|
60
|
+
export type RawEvidenceRedactionStatus = z.infer<typeof RawEvidenceRedactionStatusSchema>;
|
|
46
61
|
export declare const RawEvidenceRedactionMetadataSchema: z.ZodObject<{
|
|
47
62
|
schema_version: z.ZodLiteral<"raw-evidence-redaction.v1">;
|
|
48
|
-
status: z.
|
|
63
|
+
status: z.ZodEnum<{
|
|
64
|
+
sanitized: "sanitized";
|
|
65
|
+
scanned_clean: "scanned_clean";
|
|
66
|
+
}>;
|
|
49
67
|
mode: z.ZodLiteral<"deterministic_text_replacement">;
|
|
50
68
|
applied_by: z.ZodArray<z.ZodEnum<{
|
|
51
69
|
local_collector: "local_collector";
|
package/dist/secret-guards.js
CHANGED
|
@@ -48,6 +48,41 @@ const SECRET_NAME_PATTERN_SOURCE = [
|
|
|
48
48
|
"client[_-]?secret",
|
|
49
49
|
"secret[_-]?access[_-]?key",
|
|
50
50
|
].join("|");
|
|
51
|
+
/**
|
|
52
|
+
* Credential names live INSIDE longer identifiers, so `\b` is the wrong fence
|
|
53
|
+
* (BLI-3116).
|
|
54
|
+
*
|
|
55
|
+
* Every name fragment above used to be wrapped in `\b(?:…)\b`. Underscore is a
|
|
56
|
+
* word character, so there is no word boundary between `AWS_` and `SECRET`:
|
|
57
|
+
* `AWS_SECRET_ACCESS_KEY=AKIA…` — the most common real spelling of the most
|
|
58
|
+
* common real leak — never matched and uploaded unmasked from every fleet
|
|
59
|
+
* machine, while the bare `SECRET_ACCESS_KEY=…` matched fine. The same hole hid
|
|
60
|
+
* `AZURE_OPENAI_API_KEY`, `VITE_SUPABASE_ANON_KEY`, `GITHUB_ACCESS_TOKEN`,
|
|
61
|
+
* `MY_APP_CLIENT_SECRET` and every other prefixed spelling: the bug was the
|
|
62
|
+
* fence, not the vocabulary, so the whole list is fenced differently now.
|
|
63
|
+
*
|
|
64
|
+
* `_`, `-` and `.` are identifier JOINERS here, not boundaries:
|
|
65
|
+
*
|
|
66
|
+
* - leading `(?<![A-Za-z0-9])` — the fragment may begin right after a joiner or
|
|
67
|
+
* at a real boundary, but a letter or digit immediately before it still
|
|
68
|
+
* blocks the match (`notapikey=…` stays out, as before).
|
|
69
|
+
* - trailing `[A-Za-z0-9_-]{0,40}` — a bounded identifier tail, so
|
|
70
|
+
* `OPENAI_API_KEY_2=…` and `AWS_SECRET_ACCESS_KEY_ID=…` are seen too.
|
|
71
|
+
*
|
|
72
|
+
* Prose is unaffected because the assignment requirement below is unchanged:
|
|
73
|
+
* "rotate your secret access key" has no `=`/`:` + opaque value and is not a
|
|
74
|
+
* leak. The guard's job is assignments, not vocabulary.
|
|
75
|
+
*/
|
|
76
|
+
const SECRET_NAME_MATCH_SOURCE = `(?<![A-Za-z0-9])(?:${SECRET_NAME_PATTERN_SOURCE})[A-Za-z0-9_-]{0,40}`;
|
|
77
|
+
/**
|
|
78
|
+
* The assignment operator, with the closing quote of a JSON/YAML key allowed
|
|
79
|
+
* before it. Found while auditing the fence above: `{"aws_secret_access_key":
|
|
80
|
+
* "…"}` never matched either, because the name was followed by `"` and the
|
|
81
|
+
* pattern demanded `[:=]` immediately. Transcripts are JSONL, so this is the
|
|
82
|
+
* shape a leaked credential most often has on the way in. The sibling redactor
|
|
83
|
+
* in harvest-analysis (`study/prepare.ts`) already allowed it; this one did not.
|
|
84
|
+
*/
|
|
85
|
+
const SECRET_ASSIGNMENT_SOURCE = `["']?\\s*[:=]\\s*["']?`;
|
|
51
86
|
/**
|
|
52
87
|
* Credential names that are only a leak when assigned a value (D13). The value
|
|
53
88
|
* shape mirrors the long-standing generic pattern: an assignment operator
|
|
@@ -55,10 +90,18 @@ const SECRET_NAME_PATTERN_SOURCE = [
|
|
|
55
90
|
* `GRANT ... TO service_role` (no assignment) do not match; the same name with
|
|
56
91
|
* an assigned value does.
|
|
57
92
|
*/
|
|
58
|
-
const SECRET_NAME_WITH_VALUE_PATTERN = new RegExp(
|
|
93
|
+
const SECRET_NAME_WITH_VALUE_PATTERN = new RegExp(`${SECRET_NAME_MATCH_SOURCE}${SECRET_ASSIGNMENT_SOURCE}[A-Za-z0-9_./+=-]{12,}`, "i");
|
|
59
94
|
/**
|
|
60
95
|
* Self-identifying secret values — blocked regardless of context because the
|
|
61
96
|
* token shape itself is the credential, with no benign reading.
|
|
97
|
+
*
|
|
98
|
+
* `\b` is deliberately KEPT here, unlike the name fragments above (BLI-3116).
|
|
99
|
+
* These patterns are whole tokens, not fragments of a longer identifier: a
|
|
100
|
+
* `ghp_`/`sk-`/`AKIA…` run that continues into surrounding alphanumerics is not
|
|
101
|
+
* that provider's token, and unanchoring them would mask ordinary identifiers
|
|
102
|
+
* (`chart_m0_2024_revenue…`) for no safety gain. A real leak of any of these
|
|
103
|
+
* shapes is preceded by a quote, whitespace, `=`, `:` or `/` — all of which
|
|
104
|
+
* `\b` already admits.
|
|
62
105
|
*/
|
|
63
106
|
const SECRET_VALUE_PATTERNS = [
|
|
64
107
|
/-----BEGIN [A-Z ]*PRIVATE KEY-----/,
|
|
@@ -110,14 +153,28 @@ export const RawEvidenceRedactionRangeSchema = z
|
|
|
110
153
|
});
|
|
111
154
|
}
|
|
112
155
|
});
|
|
156
|
+
/**
|
|
157
|
+
* The verdict of a secret scan — NOT "did the bytes change" (BLI-3277).
|
|
158
|
+
*
|
|
159
|
+
* `sanitized` means the scan matched and the uploaded bytes differ from what was
|
|
160
|
+
* read. `scanned_clean` means the same scan ran over the same bytes and matched
|
|
161
|
+
* nothing, so the uploaded bytes are the original ones. The second value exists
|
|
162
|
+
* because every reader downstream asks "was this scanned?", and for 70% of
|
|
163
|
+
* transcripts — the ones with no secret in them — the honest answer used to be
|
|
164
|
+
* an absent record, which reads identically to "nobody ever looked".
|
|
165
|
+
*/
|
|
166
|
+
export const RawEvidenceRedactionStatusSchema = z.enum([
|
|
167
|
+
"sanitized",
|
|
168
|
+
"scanned_clean",
|
|
169
|
+
]);
|
|
113
170
|
export const RawEvidenceRedactionMetadataSchema = z
|
|
114
171
|
.object({
|
|
115
172
|
schema_version: z.literal("raw-evidence-redaction.v1"),
|
|
116
|
-
status:
|
|
173
|
+
status: RawEvidenceRedactionStatusSchema,
|
|
117
174
|
mode: z.literal("deterministic_text_replacement"),
|
|
118
175
|
applied_by: z.array(RawEvidenceRedactionSourceSchema).min(1),
|
|
119
|
-
rule_counts: z.array(RawEvidenceRedactionRuleCountSchema)
|
|
120
|
-
secret_like_match_count: z.number().int().
|
|
176
|
+
rule_counts: z.array(RawEvidenceRedactionRuleCountSchema),
|
|
177
|
+
secret_like_match_count: z.number().int().nonnegative(),
|
|
121
178
|
redacted_fields: z.array(z.string().trim().min(1).max(160)).default([]),
|
|
122
179
|
redacted_ranges: z.array(RawEvidenceRedactionRangeSchema).max(200).default([]),
|
|
123
180
|
original_content_hash_sha256: z.string().regex(/^[a-f0-9]{64}$/).optional(),
|
|
@@ -125,8 +182,81 @@ export const RawEvidenceRedactionMetadataSchema = z
|
|
|
125
182
|
original_byte_size: z.number().int().nonnegative().optional(),
|
|
126
183
|
sanitized_byte_size: z.number().int().nonnegative().optional(),
|
|
127
184
|
})
|
|
128
|
-
.strict()
|
|
129
|
-
|
|
185
|
+
.strict()
|
|
186
|
+
// The per-status invariants the old `z.literal("sanitized")` + `.min(1)` +
|
|
187
|
+
// `.positive()` shape enforced structurally. They still hold for a sanitized
|
|
188
|
+
// record; a clean one has to prove the opposite — no rule fired, no range was
|
|
189
|
+
// replaced, no field was masked, and the bytes came out the way they went in.
|
|
190
|
+
.superRefine((metadata, context) => {
|
|
191
|
+
if (metadata.status === "sanitized") {
|
|
192
|
+
if (metadata.rule_counts.length === 0) {
|
|
193
|
+
context.addIssue({
|
|
194
|
+
code: "custom",
|
|
195
|
+
message: "a sanitized redaction must name at least one rule",
|
|
196
|
+
path: ["rule_counts"],
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
if (metadata.secret_like_match_count < 1) {
|
|
200
|
+
context.addIssue({
|
|
201
|
+
code: "custom",
|
|
202
|
+
message: "a sanitized redaction must count at least one match",
|
|
203
|
+
path: ["secret_like_match_count"],
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (metadata.rule_counts.length > 0) {
|
|
209
|
+
context.addIssue({
|
|
210
|
+
code: "custom",
|
|
211
|
+
message: "a clean scan cannot name a rule that fired",
|
|
212
|
+
path: ["rule_counts"],
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
if (metadata.secret_like_match_count !== 0) {
|
|
216
|
+
context.addIssue({
|
|
217
|
+
code: "custom",
|
|
218
|
+
message: "a clean scan cannot count a match",
|
|
219
|
+
path: ["secret_like_match_count"],
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
if (metadata.redacted_ranges.length > 0) {
|
|
223
|
+
context.addIssue({
|
|
224
|
+
code: "custom",
|
|
225
|
+
message: "a clean scan cannot redact a range",
|
|
226
|
+
path: ["redacted_ranges"],
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
if (metadata.redacted_fields.length > 0) {
|
|
230
|
+
context.addIssue({
|
|
231
|
+
code: "custom",
|
|
232
|
+
message: "a clean scan cannot redact a field",
|
|
233
|
+
path: ["redacted_fields"],
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
if (metadata.original_content_hash_sha256 !== undefined &&
|
|
237
|
+
metadata.sanitized_content_hash_sha256 !== undefined &&
|
|
238
|
+
metadata.original_content_hash_sha256 !==
|
|
239
|
+
metadata.sanitized_content_hash_sha256) {
|
|
240
|
+
context.addIssue({
|
|
241
|
+
code: "custom",
|
|
242
|
+
message: "a clean scan cannot change the content hash",
|
|
243
|
+
path: ["sanitized_content_hash_sha256"],
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
if (metadata.original_byte_size !== undefined &&
|
|
247
|
+
metadata.sanitized_byte_size !== undefined &&
|
|
248
|
+
metadata.original_byte_size !== metadata.sanitized_byte_size) {
|
|
249
|
+
context.addIssue({
|
|
250
|
+
code: "custom",
|
|
251
|
+
message: "a clean scan cannot change the byte size",
|
|
252
|
+
path: ["sanitized_byte_size"],
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
// Same fence as the detector (BLI-3116) — the two must agree or a prefixed
|
|
257
|
+
// name would be detected and then left unredacted, which costs whole sessions
|
|
258
|
+
// (see findSecretRedactionMatches).
|
|
259
|
+
const SECRET_ASSIGNMENT_REDACTION_PATTERN = new RegExp(`(${SECRET_NAME_MATCH_SOURCE}${SECRET_ASSIGNMENT_SOURCE})([A-Za-z0-9_./+=-]{12,})`, "gi");
|
|
130
260
|
const SECRET_REDACTION_RULES = [
|
|
131
261
|
{
|
|
132
262
|
ruleId: "credential_assignment",
|
package/dist/source-adapter.d.ts
CHANGED
|
@@ -76,7 +76,10 @@ export declare const SourceScanEventSummarySchema: z.ZodObject<{
|
|
|
76
76
|
redacted_summary: z.ZodOptional<z.ZodString>;
|
|
77
77
|
redaction: z.ZodOptional<z.ZodObject<{
|
|
78
78
|
schema_version: z.ZodLiteral<"raw-evidence-redaction.v1">;
|
|
79
|
-
status: z.
|
|
79
|
+
status: z.ZodEnum<{
|
|
80
|
+
sanitized: "sanitized";
|
|
81
|
+
scanned_clean: "scanned_clean";
|
|
82
|
+
}>;
|
|
80
83
|
mode: z.ZodLiteral<"deterministic_text_replacement">;
|
|
81
84
|
applied_by: z.ZodArray<z.ZodEnum<{
|
|
82
85
|
local_collector: "local_collector";
|
|
@@ -190,7 +193,10 @@ export declare const SourceScanResultSchema: z.ZodObject<{
|
|
|
190
193
|
redacted_summary: z.ZodOptional<z.ZodString>;
|
|
191
194
|
redaction: z.ZodOptional<z.ZodObject<{
|
|
192
195
|
schema_version: z.ZodLiteral<"raw-evidence-redaction.v1">;
|
|
193
|
-
status: z.
|
|
196
|
+
status: z.ZodEnum<{
|
|
197
|
+
sanitized: "sanitized";
|
|
198
|
+
scanned_clean: "scanned_clean";
|
|
199
|
+
}>;
|
|
194
200
|
mode: z.ZodLiteral<"deterministic_text_replacement">;
|
|
195
201
|
applied_by: z.ZodArray<z.ZodEnum<{
|
|
196
202
|
local_collector: "local_collector";
|
|
@@ -34,6 +34,84 @@ export declare const NO_UPLOAD_ATTEMPT_RECORDED = "no_upload_attempt_recorded";
|
|
|
34
34
|
export declare const STORAGE_REJECTED_OBJECT_TOO_LARGE = "storage_rejected_object_too_large";
|
|
35
35
|
/** Storage refused the finished object because of its declared media type. */
|
|
36
36
|
export declare const STORAGE_REJECTED_MEDIA_TYPE = "storage_rejected_media_type";
|
|
37
|
+
/**
|
|
38
|
+
* The commit route never survived the object (BLI-3067).
|
|
39
|
+
*
|
|
40
|
+
* Fires when the commit call answers `>= 500` with a body that is not the
|
|
41
|
+
* route's JSON envelope — an HTML error page from the platform. The handler
|
|
42
|
+
* always answers `{ code, message, … }`, so a non-JSON 5xx means the serverless
|
|
43
|
+
* process was killed mid-assembly (out of memory, or a hard wall-clock timeout)
|
|
44
|
+
* and no catch ran, no ledger reason was written and no server log line exists.
|
|
45
|
+
*
|
|
46
|
+
* It is a distinct label because the operator-visible difference is total: a
|
|
47
|
+
* refusal means the server looked at these bytes and said no, this means the
|
|
48
|
+
* server died holding them. The collector composes the observed status onto the
|
|
49
|
+
* end (`commit_crashed_platform_http_502`) so a gateway timeout can still be
|
|
50
|
+
* told from an out-of-memory; `classifyUploadFailure` strips that suffix.
|
|
51
|
+
*/
|
|
52
|
+
export declare const COMMIT_CRASHED_PLATFORM = "commit_crashed_platform";
|
|
53
|
+
/**
|
|
54
|
+
* The collector deliberately did not offer this object on this sync.
|
|
55
|
+
*
|
|
56
|
+
* Fires in the delivery-backoff partition: an object whose delivery keeps
|
|
57
|
+
* failing is retried on a 15-minute-to-6-hour schedule (~4 attempts a day)
|
|
58
|
+
* instead of on every sync, after one object failed 1,030 times in nine days
|
|
59
|
+
* (BLI-3066). The held object is reported as a failed outcome carrying this
|
|
60
|
+
* label rather than quietly omitted — a hold that read as success is exactly
|
|
61
|
+
* the green-status-hiding-missing-collection failure the fleet contract
|
|
62
|
+
* forbids.
|
|
63
|
+
*
|
|
64
|
+
* Not `withheld_by_policy`: nothing was refused and nothing needs a human. The
|
|
65
|
+
* hold expires on a clock, so it is transient in the strict sense — the next
|
|
66
|
+
* eligible sync offers the same bytes again.
|
|
67
|
+
*/
|
|
68
|
+
export declare const DELIVERY_BACKOFF_HOLDING = "delivery_backoff_holding";
|
|
69
|
+
/**
|
|
70
|
+
* Raw evidence was never offered because the session's attribution state is not
|
|
71
|
+
* one the upload policy accepts (BLI-3272).
|
|
72
|
+
*
|
|
73
|
+
* `isRawEvidenceUploadableAttributionState` refuses a session whose attribution
|
|
74
|
+
* produced neither a repository identity nor an approved-root synthetic
|
|
75
|
+
* workspace. That refusal is a real, explainable decision — but until this label
|
|
76
|
+
* existed the report builders answered it by spreading `{}`, so the row reached
|
|
77
|
+
* `ambient_codex_sessions` with `upload_state = NULL` AND `upload_reason = NULL`.
|
|
78
|
+
* 1,256 sessions sat in that hole on 2026-08-29, 197 of them first seen inside a
|
|
79
|
+
* week from current 0.2.30 machines, every one `unattributed` /
|
|
80
|
+
* `cwd_outside_scanned_worktrees`. A withheld session that says nothing is the
|
|
81
|
+
* exact failure the session-first contract forbids, and the migration comment
|
|
82
|
+
* claiming "every write path now sets it" was false for all of them.
|
|
83
|
+
*
|
|
84
|
+
* Written with the deciding attribution reason appended after a colon
|
|
85
|
+
* (`not_uploadable_attribution_state:cwd_outside_scanned_worktrees`) so the
|
|
86
|
+
* upload half of the row explains itself without a join;
|
|
87
|
+
* `baseUploadFailureReason` strips that qualifier for classification.
|
|
88
|
+
*
|
|
89
|
+
* `deterministic` for the same reason as the bare attribution labels below: the
|
|
90
|
+
* scan completed and produced no target, so repeating it changes nothing.
|
|
91
|
+
* Widening the operator's approved roots can change it — a human decision, not a
|
|
92
|
+
* retry.
|
|
93
|
+
*/
|
|
94
|
+
export declare const NOT_UPLOADABLE_ATTRIBUTION_STATE = "not_uploadable_attribution_state";
|
|
95
|
+
/**
|
|
96
|
+
* Compose the withheld-by-attribution reason for one session.
|
|
97
|
+
*
|
|
98
|
+
* The detail is the attribution reason that decided it. Anything outside the
|
|
99
|
+
* safe-label charset — colons included, so the stem always ends at the first one
|
|
100
|
+
* — collapses to `_`, and an empty or unusable detail degrades to the bare stem
|
|
101
|
+
* rather than inventing one.
|
|
102
|
+
*/
|
|
103
|
+
export declare function notUploadableAttributionStateReason(attributionReason: string | null | undefined): string;
|
|
104
|
+
/**
|
|
105
|
+
* The listed reason a composed label is a variant of, or the label unchanged.
|
|
106
|
+
*
|
|
107
|
+
* The collector appends the observed status to some labels so an operator can
|
|
108
|
+
* tell a 502 from a 500 (`commit_crashed_platform_http_502`). Stripping it is
|
|
109
|
+
* deliberately NOT pattern matching on the label's words: the suffix is only
|
|
110
|
+
* removed when what remains is a reason somebody has already classified by
|
|
111
|
+
* hand, so `commit_failed_http_503` — whose stem nobody has reasoned about —
|
|
112
|
+
* still classifies as `unknown`, exactly as before.
|
|
113
|
+
*/
|
|
114
|
+
export declare function baseUploadFailureReason(reason: string): string;
|
|
37
115
|
/** Classify a persisted upload reason. Unlisted reasons stay `unknown`. */
|
|
38
116
|
export declare function classifyUploadFailure(reason: string | null | undefined): UploadFailureClass;
|
|
39
117
|
/**
|
|
@@ -13,6 +13,12 @@
|
|
|
13
13
|
// future reason somebody adds, which is the same class of guessing the person
|
|
14
14
|
// resolver refuses to do. An unlisted reason is `unknown` and stays visible
|
|
15
15
|
// until a human decides which bucket it belongs in.
|
|
16
|
+
//
|
|
17
|
+
// The one concession is a trailing `_http_<status>`, which the collector
|
|
18
|
+
// appends to some labels so an operator can tell a 502 from a 500. It is
|
|
19
|
+
// stripped only when the remaining stem is itself a listed reason — see
|
|
20
|
+
// `baseUploadFailureReason` — so the table still decides everything and an
|
|
21
|
+
// unreasoned-about stem stays `unknown`.
|
|
16
22
|
/**
|
|
17
23
|
* The reason written when nothing in the pipeline explained itself.
|
|
18
24
|
*
|
|
@@ -34,6 +40,85 @@ export const NO_UPLOAD_ATTEMPT_RECORDED = "no_upload_attempt_recorded";
|
|
|
34
40
|
export const STORAGE_REJECTED_OBJECT_TOO_LARGE = "storage_rejected_object_too_large";
|
|
35
41
|
/** Storage refused the finished object because of its declared media type. */
|
|
36
42
|
export const STORAGE_REJECTED_MEDIA_TYPE = "storage_rejected_media_type";
|
|
43
|
+
/**
|
|
44
|
+
* The commit route never survived the object (BLI-3067).
|
|
45
|
+
*
|
|
46
|
+
* Fires when the commit call answers `>= 500` with a body that is not the
|
|
47
|
+
* route's JSON envelope — an HTML error page from the platform. The handler
|
|
48
|
+
* always answers `{ code, message, … }`, so a non-JSON 5xx means the serverless
|
|
49
|
+
* process was killed mid-assembly (out of memory, or a hard wall-clock timeout)
|
|
50
|
+
* and no catch ran, no ledger reason was written and no server log line exists.
|
|
51
|
+
*
|
|
52
|
+
* It is a distinct label because the operator-visible difference is total: a
|
|
53
|
+
* refusal means the server looked at these bytes and said no, this means the
|
|
54
|
+
* server died holding them. The collector composes the observed status onto the
|
|
55
|
+
* end (`commit_crashed_platform_http_502`) so a gateway timeout can still be
|
|
56
|
+
* told from an out-of-memory; `classifyUploadFailure` strips that suffix.
|
|
57
|
+
*/
|
|
58
|
+
export const COMMIT_CRASHED_PLATFORM = "commit_crashed_platform";
|
|
59
|
+
/**
|
|
60
|
+
* The collector deliberately did not offer this object on this sync.
|
|
61
|
+
*
|
|
62
|
+
* Fires in the delivery-backoff partition: an object whose delivery keeps
|
|
63
|
+
* failing is retried on a 15-minute-to-6-hour schedule (~4 attempts a day)
|
|
64
|
+
* instead of on every sync, after one object failed 1,030 times in nine days
|
|
65
|
+
* (BLI-3066). The held object is reported as a failed outcome carrying this
|
|
66
|
+
* label rather than quietly omitted — a hold that read as success is exactly
|
|
67
|
+
* the green-status-hiding-missing-collection failure the fleet contract
|
|
68
|
+
* forbids.
|
|
69
|
+
*
|
|
70
|
+
* Not `withheld_by_policy`: nothing was refused and nothing needs a human. The
|
|
71
|
+
* hold expires on a clock, so it is transient in the strict sense — the next
|
|
72
|
+
* eligible sync offers the same bytes again.
|
|
73
|
+
*/
|
|
74
|
+
export const DELIVERY_BACKOFF_HOLDING = "delivery_backoff_holding";
|
|
75
|
+
/**
|
|
76
|
+
* Raw evidence was never offered because the session's attribution state is not
|
|
77
|
+
* one the upload policy accepts (BLI-3272).
|
|
78
|
+
*
|
|
79
|
+
* `isRawEvidenceUploadableAttributionState` refuses a session whose attribution
|
|
80
|
+
* produced neither a repository identity nor an approved-root synthetic
|
|
81
|
+
* workspace. That refusal is a real, explainable decision — but until this label
|
|
82
|
+
* existed the report builders answered it by spreading `{}`, so the row reached
|
|
83
|
+
* `ambient_codex_sessions` with `upload_state = NULL` AND `upload_reason = NULL`.
|
|
84
|
+
* 1,256 sessions sat in that hole on 2026-08-29, 197 of them first seen inside a
|
|
85
|
+
* week from current 0.2.30 machines, every one `unattributed` /
|
|
86
|
+
* `cwd_outside_scanned_worktrees`. A withheld session that says nothing is the
|
|
87
|
+
* exact failure the session-first contract forbids, and the migration comment
|
|
88
|
+
* claiming "every write path now sets it" was false for all of them.
|
|
89
|
+
*
|
|
90
|
+
* Written with the deciding attribution reason appended after a colon
|
|
91
|
+
* (`not_uploadable_attribution_state:cwd_outside_scanned_worktrees`) so the
|
|
92
|
+
* upload half of the row explains itself without a join;
|
|
93
|
+
* `baseUploadFailureReason` strips that qualifier for classification.
|
|
94
|
+
*
|
|
95
|
+
* `deterministic` for the same reason as the bare attribution labels below: the
|
|
96
|
+
* scan completed and produced no target, so repeating it changes nothing.
|
|
97
|
+
* Widening the operator's approved roots can change it — a human decision, not a
|
|
98
|
+
* retry.
|
|
99
|
+
*/
|
|
100
|
+
export const NOT_UPLOADABLE_ATTRIBUTION_STATE = "not_uploadable_attribution_state";
|
|
101
|
+
/** Longest label the ambient envelope's `SafeLabelSchema` accepts. */
|
|
102
|
+
const MAX_UPLOAD_REASON_LENGTH = 120;
|
|
103
|
+
/**
|
|
104
|
+
* Compose the withheld-by-attribution reason for one session.
|
|
105
|
+
*
|
|
106
|
+
* The detail is the attribution reason that decided it. Anything outside the
|
|
107
|
+
* safe-label charset — colons included, so the stem always ends at the first one
|
|
108
|
+
* — collapses to `_`, and an empty or unusable detail degrades to the bare stem
|
|
109
|
+
* rather than inventing one.
|
|
110
|
+
*/
|
|
111
|
+
export function notUploadableAttributionStateReason(attributionReason) {
|
|
112
|
+
const detail = (attributionReason ?? "")
|
|
113
|
+
.trim()
|
|
114
|
+
.toLowerCase()
|
|
115
|
+
.replace(/[^a-z0-9_.-]+/gu, "_")
|
|
116
|
+
.replace(/^_+|_+$/gu, "");
|
|
117
|
+
if (!detail)
|
|
118
|
+
return NOT_UPLOADABLE_ATTRIBUTION_STATE;
|
|
119
|
+
const budget = MAX_UPLOAD_REASON_LENGTH - NOT_UPLOADABLE_ATTRIBUTION_STATE.length - 1;
|
|
120
|
+
return `${NOT_UPLOADABLE_ATTRIBUTION_STATE}:${detail.slice(0, budget)}`;
|
|
121
|
+
}
|
|
37
122
|
const UPLOAD_FAILURE_CLASSES = {
|
|
38
123
|
// Budgets and locks: the file was fine and the run simply ran out of room or
|
|
39
124
|
// was beaten to it. The next pass has room.
|
|
@@ -51,6 +136,16 @@ const UPLOAD_FAILURE_CLASSES = {
|
|
|
51
136
|
// now name themselves instead of going silent. Transient because the object
|
|
52
137
|
// was never offered a chance to upload — nothing about it was refused.
|
|
53
138
|
begin_batch_server_error: "transient",
|
|
139
|
+
// The platform killed the commit process before the route could answer
|
|
140
|
+
// (BLI-3067). Transient for the same reason as the batch error above: nothing
|
|
141
|
+
// decided anything about these bytes, so the next attempt is not a repeat of
|
|
142
|
+
// a refusal. It is bounded rather than infinite because a repeatedly failing
|
|
143
|
+
// object falls into delivery backoff after the first failure, so a genuinely
|
|
144
|
+
// un-committable object costs ~4 attempts a day instead of 96.
|
|
145
|
+
[COMMIT_CRASHED_PLATFORM]: "transient",
|
|
146
|
+
// Held on purpose by that same backoff. The bytes are staged and eligible;
|
|
147
|
+
// the clock has not come round yet.
|
|
148
|
+
[DELIVERY_BACKOFF_HOLDING]: "transient",
|
|
54
149
|
// The file itself is the problem, and it will be the same size and the same
|
|
55
150
|
// shape on the next pass. Retrying is a promise nobody can keep.
|
|
56
151
|
file_too_large: "deterministic",
|
|
@@ -69,6 +164,10 @@ const UPLOAD_FAILURE_CLASSES = {
|
|
|
69
164
|
cwd_not_a_repo: "deterministic",
|
|
70
165
|
no_repo_signals: "deterministic",
|
|
71
166
|
multiple_worktrees_close_scores: "deterministic",
|
|
167
|
+
// The upload policy declined the session's attribution state outright
|
|
168
|
+
// (BLI-3272). Same shape of answer as the four above — the scan finished and
|
|
169
|
+
// there is nowhere to put the object — so the same class.
|
|
170
|
+
[NOT_UPLOADABLE_ATTRIBUTION_STATE]: "deterministic",
|
|
72
171
|
// Working as designed. Never retry these — a retry here is an attempt to get
|
|
73
172
|
// a redaction guard to change its mind.
|
|
74
173
|
secret_like_content_guard: "withheld_by_policy",
|
|
@@ -77,11 +176,46 @@ const UPLOAD_FAILURE_CLASSES = {
|
|
|
77
176
|
// The server-side name for the same guard, returned by the commit route.
|
|
78
177
|
secret_guard_rejected: "withheld_by_policy",
|
|
79
178
|
};
|
|
179
|
+
const HTTP_STATUS_SUFFIX = /_http_\d{3}$/u;
|
|
180
|
+
/**
|
|
181
|
+
* The listed reason a composed label is a variant of, or the label unchanged.
|
|
182
|
+
*
|
|
183
|
+
* The collector appends the observed status to some labels so an operator can
|
|
184
|
+
* tell a 502 from a 500 (`commit_crashed_platform_http_502`). Stripping it is
|
|
185
|
+
* deliberately NOT pattern matching on the label's words: the suffix is only
|
|
186
|
+
* removed when what remains is a reason somebody has already classified by
|
|
187
|
+
* hand, so `commit_failed_http_503` — whose stem nobody has reasoned about —
|
|
188
|
+
* still classifies as `unknown`, exactly as before.
|
|
189
|
+
*/
|
|
190
|
+
export function baseUploadFailureReason(reason) {
|
|
191
|
+
const httpStem = reason.replace(HTTP_STATUS_SUFFIX, "");
|
|
192
|
+
if (httpStem !== reason) {
|
|
193
|
+
return Object.hasOwn(UPLOAD_FAILURE_CLASSES, httpStem) ? httpStem : reason;
|
|
194
|
+
}
|
|
195
|
+
// Same contract for the `:detail` qualifier the withheld-by-attribution label
|
|
196
|
+
// carries (BLI-3272): the colon is only a separator when what precedes it is
|
|
197
|
+
// already a classified reason, so nothing an unlisted label happens to
|
|
198
|
+
// contain can smuggle it into a class.
|
|
199
|
+
const separator = reason.indexOf(":");
|
|
200
|
+
if (separator > 0) {
|
|
201
|
+
const qualifierStem = reason.slice(0, separator);
|
|
202
|
+
if (Object.hasOwn(UPLOAD_FAILURE_CLASSES, qualifierStem)) {
|
|
203
|
+
return qualifierStem;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return reason;
|
|
207
|
+
}
|
|
80
208
|
/** Classify a persisted upload reason. Unlisted reasons stay `unknown`. */
|
|
81
209
|
export function classifyUploadFailure(reason) {
|
|
82
210
|
if (!reason)
|
|
83
211
|
return "unknown";
|
|
84
|
-
|
|
212
|
+
const label = baseUploadFailureReason(reason);
|
|
213
|
+
// `hasOwn` rather than a bare index: a reason called `constructor` or
|
|
214
|
+
// `toString` would otherwise resolve to an inherited property and answer
|
|
215
|
+
// something that is not an UploadFailureClass at all.
|
|
216
|
+
if (!Object.hasOwn(UPLOAD_FAILURE_CLASSES, label))
|
|
217
|
+
return "unknown";
|
|
218
|
+
return UPLOAD_FAILURE_CLASSES[label] ?? "unknown";
|
|
85
219
|
}
|
|
86
220
|
/**
|
|
87
221
|
* Whether repeating the identical attempt could plausibly succeed.
|