@bli-cockpit/telemetry-core 0.1.22 → 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.
@@ -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.ZodLiteral<"sanitized">;
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.ZodLiteral<"sanitized">;
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.ZodLiteral<"sanitized">;
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.ZodLiteral<"sanitized">;
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";
@@ -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.ZodLiteral<"sanitized">;
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.ZodLiteral<"sanitized">;
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.ZodLiteral<"sanitized">;
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.ZodLiteral<"sanitized">;
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";
@@ -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.ZodLiteral<"sanitized">;
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";
@@ -153,14 +153,28 @@ export const RawEvidenceRedactionRangeSchema = z
153
153
  });
154
154
  }
155
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
+ ]);
156
170
  export const RawEvidenceRedactionMetadataSchema = z
157
171
  .object({
158
172
  schema_version: z.literal("raw-evidence-redaction.v1"),
159
- status: z.literal("sanitized"),
173
+ status: RawEvidenceRedactionStatusSchema,
160
174
  mode: z.literal("deterministic_text_replacement"),
161
175
  applied_by: z.array(RawEvidenceRedactionSourceSchema).min(1),
162
- rule_counts: z.array(RawEvidenceRedactionRuleCountSchema).min(1),
163
- secret_like_match_count: z.number().int().positive(),
176
+ rule_counts: z.array(RawEvidenceRedactionRuleCountSchema),
177
+ secret_like_match_count: z.number().int().nonnegative(),
164
178
  redacted_fields: z.array(z.string().trim().min(1).max(160)).default([]),
165
179
  redacted_ranges: z.array(RawEvidenceRedactionRangeSchema).max(200).default([]),
166
180
  original_content_hash_sha256: z.string().regex(/^[a-f0-9]{64}$/).optional(),
@@ -168,7 +182,77 @@ export const RawEvidenceRedactionMetadataSchema = z
168
182
  original_byte_size: z.number().int().nonnegative().optional(),
169
183
  sanitized_byte_size: z.number().int().nonnegative().optional(),
170
184
  })
171
- .strict();
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
+ });
172
256
  // Same fence as the detector (BLI-3116) — the two must agree or a prefixed
173
257
  // name would be detected and then left unredacted, which costs whole sessions
174
258
  // (see findSecretRedactionMatches).
@@ -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.ZodLiteral<"sanitized">;
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.ZodLiteral<"sanitized">;
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";
@@ -66,6 +66,41 @@ export declare const COMMIT_CRASHED_PLATFORM = "commit_crashed_platform";
66
66
  * eligible sync offers the same bytes again.
67
67
  */
68
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;
69
104
  /**
70
105
  * The listed reason a composed label is a variant of, or the label unchanged.
71
106
  *
@@ -72,6 +72,53 @@ export const COMMIT_CRASHED_PLATFORM = "commit_crashed_platform";
72
72
  * eligible sync offers the same bytes again.
73
73
  */
74
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
+ }
75
122
  const UPLOAD_FAILURE_CLASSES = {
76
123
  // Budgets and locks: the file was fine and the run simply ran out of room or
77
124
  // was beaten to it. The next pass has room.
@@ -117,6 +164,10 @@ const UPLOAD_FAILURE_CLASSES = {
117
164
  cwd_not_a_repo: "deterministic",
118
165
  no_repo_signals: "deterministic",
119
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",
120
171
  // Working as designed. Never retry these — a retry here is an attempt to get
121
172
  // a redaction guard to change its mind.
122
173
  secret_like_content_guard: "withheld_by_policy",
@@ -137,10 +188,22 @@ const HTTP_STATUS_SUFFIX = /_http_\d{3}$/u;
137
188
  * still classifies as `unknown`, exactly as before.
138
189
  */
139
190
  export function baseUploadFailureReason(reason) {
140
- const stem = reason.replace(HTTP_STATUS_SUFFIX, "");
141
- if (stem === reason)
142
- return reason;
143
- return Object.hasOwn(UPLOAD_FAILURE_CLASSES, stem) ? stem : 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;
144
207
  }
145
208
  /** Classify a persisted upload reason. Unlisted reasons stay `unknown`. */
146
209
  export function classifyUploadFailure(reason) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/telemetry-core",
3
- "version": "0.1.22",
3
+ "version": "0.1.23",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",