@bli-cockpit/cli 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.
package/README.md CHANGED
@@ -95,7 +95,9 @@ What happens:
95
95
  Supabase invite flow automatically.
96
96
  5. The CLI starts general ambient capture, uploads private raw evidence objects
97
97
  when present (chunked and resumable, with identical content acknowledged
98
- instead of re-uploaded), then uploads one safe metadata/ref envelope.
98
+ instead of re-uploaded), then uploads one safe metadata/ref envelope. Text
99
+ evidence with secret-shaped values is sanitized before storage and marked as
100
+ partial evidence with redaction metadata; env files are still never read.
99
101
  6. The CLI prints `PASS: Cockpit collector is ready for harvest.`
100
102
 
101
103
  `--device-name` is optional and only a readable label in Cockpit. Pass it only
@@ -173,7 +175,9 @@ Remote dashboard:
173
175
  - raw evidence refs accepted by `/api/ambient/ingest`;
174
176
  - durable private Storage objects accepted by the chunked
175
177
  `/api/ambient/evidence/upload/begin|chunk|commit` endpoints, tracked in a
176
- durable per-object upload ledger;
178
+ durable per-object upload ledger. When deterministic sanitization was
179
+ applied, the evidence ref stores the redaction metadata and sanitized object
180
+ hash/size;
177
181
  - attached image artifact metadata accepted by `/api/ambient/agent-artifacts`
178
182
  for screenshots/images explicitly attached into Codex or Claude sessions;
179
183
  - Codex session attribution records (session id, file hash, attribution state
@@ -1,4 +1,4 @@
1
- import { EvidenceCompletenessPayloadSchema, SECRET_FILE_SEGMENT_PATTERN, SourceScanResultSchema, containsSecretLikeContent, } from "@bli-cockpit/telemetry-core";
1
+ import { EvidenceCompletenessPayloadSchema, SECRET_FILE_SEGMENT_PATTERN, SourceScanResultSchema, containsSecretLikeContent, redactSecretLikeContent, } from "@bli-cockpit/telemetry-core";
2
2
  import { spawn } from "node:child_process";
3
3
  import crypto from "node:crypto";
4
4
  import fs from "node:fs/promises";
@@ -29,6 +29,7 @@ export async function collectRawEvidencePack(context, options) {
29
29
  const skipped = [];
30
30
  const truncated = [];
31
31
  const failed = [];
32
+ const redacted = [];
32
33
  const reused = [];
33
34
  const sinceMinutes = options.sinceMinutes ?? DEFAULT_SINCE_MINUTES;
34
35
  const sessionLimit = options.sessionLimit ?? DEFAULT_SESSION_LIMIT;
@@ -42,6 +43,7 @@ export async function collectRawEvidencePack(context, options) {
42
43
  skipped,
43
44
  truncated,
44
45
  failed,
46
+ redacted,
45
47
  reused,
46
48
  scanned: new Map(),
47
49
  caps: [
@@ -126,6 +128,7 @@ export async function collectRawEvidencePack(context, options) {
126
128
  file_count: 0,
127
129
  byte_size: 0,
128
130
  skipped_count: countEvidenceEntries(skipped),
131
+ sanitized_count: redacted.length,
129
132
  reused_count: reused.length,
130
133
  deferred_byte_budget_count: deferredByteBudgetCount,
131
134
  deferred_object_budget_count: deferredObjectBudgetCount,
@@ -150,6 +153,7 @@ export async function collectRawEvidencePack(context, options) {
150
153
  packId,
151
154
  entries,
152
155
  skipped,
156
+ redacted,
153
157
  reused,
154
158
  });
155
159
  const manifestPath = path.join(evidenceDir, "manifest.json");
@@ -180,6 +184,7 @@ export async function collectRawEvidencePack(context, options) {
180
184
  file_count: entries.length,
181
185
  byte_size: entries.reduce((sum, entry) => sum + entry.byte_size, 0),
182
186
  skipped_count: countEvidenceEntries(skipped),
187
+ sanitized_count: redacted.length,
183
188
  reused_count: reused.length,
184
189
  deferred_byte_budget_count: deferredByteBudgetCount,
185
190
  deferred_object_budget_count: deferredObjectBudgetCount,
@@ -225,6 +230,7 @@ export async function collectRawEvidencePack(context, options) {
225
230
  file_count: 0,
226
231
  byte_size: 0,
227
232
  skipped_count: countEvidenceEntries(skipped),
233
+ sanitized_count: redacted.length,
228
234
  reused_count: reused.length,
229
235
  deferred_byte_budget_count: skipped
230
236
  .filter((entry) => entry.reason === "deferred_byte_budget")
@@ -603,15 +609,29 @@ async function collectOneEvidenceFile(collection, options) {
603
609
  });
604
610
  return false;
605
611
  }
606
- if (containsSecretLikeContent(raw.toString("utf8"))) {
612
+ const sanitized = sanitizeTextEvidenceForUpload({
613
+ text: raw.toString("utf8"),
614
+ originalBytes: raw,
615
+ redactedFields: [`${options.kind}.body`],
616
+ });
617
+ if (sanitized.status === "blocked") {
607
618
  collection.skipped.push({
608
619
  kind: options.kind,
609
620
  label: fileName,
610
- reason: "secret_like_content_guard",
621
+ reason: sanitized.reason,
611
622
  });
612
623
  return false;
613
624
  }
614
- const contentHash = sha256(raw);
625
+ if (sanitized.status === "redacted") {
626
+ collection.redacted.push({
627
+ kind: options.kind,
628
+ label: fileName,
629
+ redaction: sanitized.redaction,
630
+ });
631
+ }
632
+ const evidenceBytes = sanitized.bytes;
633
+ const redaction = sanitized.redaction;
634
+ const contentHash = sha256(evidenceBytes);
615
635
  if (collection.skipContentHashes.has(contentHash)) {
616
636
  collection.reused.push({
617
637
  kind: options.kind,
@@ -621,7 +641,7 @@ async function collectOneEvidenceFile(collection, options) {
621
641
  });
622
642
  return true;
623
643
  }
624
- const deferReason = admitToBudget(collection.budget, raw.byteLength);
644
+ const deferReason = admitToBudget(collection.budget, evidenceBytes.byteLength);
625
645
  if (deferReason) {
626
646
  markBudgetCapApplied(collection, deferReason);
627
647
  collection.skipped.push({
@@ -634,7 +654,7 @@ async function collectOneEvidenceFile(collection, options) {
634
654
  collection.index.value += 1;
635
655
  const relativePath = path.join("files", `${String(collection.index.value).padStart(3, "0")}-${shortHash(options.filePath)}-${fileName}`);
636
656
  const destination = path.join(collection.filesDir, path.basename(relativePath));
637
- await fs.writeFile(destination, raw, { mode: 0o600 });
657
+ await fs.writeFile(destination, evidenceBytes, { mode: 0o600 });
638
658
  await chmodPrivate(destination, 0o600);
639
659
  collection.entries.push(evidenceEntry({
640
660
  context: collection.context,
@@ -643,8 +663,11 @@ async function collectOneEvidenceFile(collection, options) {
643
663
  localPath: destination,
644
664
  relativePath,
645
665
  mediaType: options.mediaType,
646
- redactedSummary: options.redactedSummary,
647
- bytes: raw,
666
+ redactedSummary: redaction
667
+ ? `${options.redactedSummary} Secret-like values were deterministically redacted before upload.`
668
+ : options.redactedSummary,
669
+ redaction,
670
+ bytes: evidenceBytes,
648
671
  codexSessionId: options.sessionId,
649
672
  contentAddress: options.contentAddress(contentHash.slice(0, 16)),
650
673
  }));
@@ -664,6 +687,31 @@ function admitToBudget(budget, byteLength) {
664
687
  budget.remainingBytes -= byteLength;
665
688
  return null;
666
689
  }
690
+ function sanitizeTextEvidenceForUpload(options) {
691
+ const originalBytes = options.originalBytes ?? Buffer.from(options.text, "utf8");
692
+ const redactionResult = redactSecretLikeContent(options.text, {
693
+ appliedBy: "local_collector",
694
+ redactedFields: options.redactedFields,
695
+ });
696
+ if (redactionResult.redacted) {
697
+ if (containsSecretLikeContent(redactionResult.text)) {
698
+ return { status: "blocked", reason: "secret_redaction_failed" };
699
+ }
700
+ const sanitizedBytes = Buffer.from(redactionResult.text, "utf8");
701
+ return {
702
+ status: "redacted",
703
+ bytes: sanitizedBytes,
704
+ redaction: withRedactionContentMetadata(redactionResult.metadata, {
705
+ originalBytes,
706
+ sanitizedBytes,
707
+ }),
708
+ };
709
+ }
710
+ if (containsSecretLikeContent(options.text)) {
711
+ return { status: "blocked", reason: "secret_like_content_guard" };
712
+ }
713
+ return { status: "clean", bytes: originalBytes };
714
+ }
667
715
  async function collectGitDiffFiles(collection, repoRoot) {
668
716
  const diffTargets = [
669
717
  { label: "unstaged", args: ["diff", "--no-ext-diff", "--"] },
@@ -697,15 +745,27 @@ async function collectGitDiffFiles(collection, repoRoot) {
697
745
  }
698
746
  if (!diff.stdout.trim())
699
747
  continue;
700
- if (containsSecretLikeContent(diff.stdout)) {
748
+ const sanitized = sanitizeTextEvidenceForUpload({
749
+ text: diff.stdout,
750
+ redactedFields: [`git_diff.${target.label}`],
751
+ });
752
+ if (sanitized.status === "blocked") {
701
753
  collection.skipped.push({
702
754
  kind: "git_diff",
703
755
  label: target.label,
704
- reason: "secret_like_content_guard",
756
+ reason: sanitized.reason,
705
757
  });
706
758
  continue;
707
759
  }
708
- const raw = Buffer.from(diff.stdout, "utf8");
760
+ if (sanitized.status === "redacted") {
761
+ collection.redacted.push({
762
+ kind: "git_diff",
763
+ label: target.label,
764
+ redaction: sanitized.redaction,
765
+ });
766
+ }
767
+ const raw = sanitized.bytes;
768
+ const redaction = sanitized.redaction;
709
769
  const contentHash = sha256(raw);
710
770
  if (collection.skipContentHashes.has(contentHash)) {
711
771
  collection.reused.push({
@@ -737,9 +797,12 @@ async function collectGitDiffFiles(collection, repoRoot) {
737
797
  localPath: destination,
738
798
  relativePath,
739
799
  mediaType: "text/x-diff",
740
- redactedSummary: diff.truncated
741
- ? `Raw git ${target.label} diff truncated to the capture cap and preserved locally with env/secret paths excluded.`
742
- : `Raw git ${target.label} diff preserved locally with env/secret paths excluded.`,
800
+ redactedSummary: redaction
801
+ ? `Raw git ${target.label} diff preserved locally with env/secret paths excluded and secret-like values deterministically redacted.`
802
+ : diff.truncated
803
+ ? `Raw git ${target.label} diff truncated to the capture cap and preserved locally with env/secret paths excluded.`
804
+ : `Raw git ${target.label} diff preserved locally with env/secret paths excluded.`,
805
+ redaction,
743
806
  bytes: raw,
744
807
  contentAddress: `git-diff/${target.label}-${contentHash.slice(0, 16)}.diff`,
745
808
  }));
@@ -864,6 +927,8 @@ function makeEvidenceCompleteness(collection, options) {
864
927
  sources.add(entry.kind);
865
928
  for (const entry of collection.failed)
866
929
  sources.add(entry.kind);
930
+ for (const entry of collection.redacted)
931
+ sources.add(entry.kind);
867
932
  const sourceCounts = [...sources].sort().map((source) => {
868
933
  const skipped = collection.skipped.filter((entry) => entry.kind === source);
869
934
  return {
@@ -950,10 +1015,30 @@ function makeEvidenceCompleteness(collection, options) {
950
1015
  });
951
1016
  }
952
1017
  }
1018
+ const redactionCounts = new Map();
1019
+ for (const redacted of collection.redacted) {
1020
+ const ruleIds = redacted.redaction.rule_counts.map((rule) => rule.rule_id);
1021
+ const key = `${redacted.kind}:${redacted.redaction.mode}:${ruleIds.sort().join(",")}`;
1022
+ const existing = redactionCounts.get(key);
1023
+ if (existing) {
1024
+ existing.count += 1;
1025
+ existing.rule_ids = [...new Set([...existing.rule_ids, ...ruleIds])].sort();
1026
+ }
1027
+ else {
1028
+ redactionCounts.set(key, {
1029
+ source: redacted.kind,
1030
+ status: "sanitized",
1031
+ mode: redacted.redaction.mode,
1032
+ count: 1,
1033
+ rule_ids: [...new Set(ruleIds)].sort(),
1034
+ });
1035
+ }
1036
+ }
953
1037
  const hasGaps = totals.skipped_count > 0 ||
954
1038
  totals.truncated_count > 0 ||
955
1039
  totals.deferred_count > 0 ||
956
1040
  totals.failed_count > 0 ||
1041
+ collection.redacted.length > 0 ||
957
1042
  collection.caps.some((cap) => cap.applied);
958
1043
  const status = totals.failed_count > 0 &&
959
1044
  totals.included_count + totals.reused_count === 0
@@ -978,11 +1063,14 @@ function makeEvidenceCompleteness(collection, options) {
978
1063
  skip_reasons: [...skipReasonCounts.values()].sort((a, b) => `${a.source}:${a.reason}`.localeCompare(`${b.source}:${b.reason}`)),
979
1064
  failure_reasons: [...failureReasonCounts.values()].sort((a, b) => `${a.source}:${a.reason}`.localeCompare(`${b.source}:${b.reason}`)),
980
1065
  truncation_markers: [...truncationCounts.values()].sort((a, b) => `${a.source}:${a.reason}`.localeCompare(`${b.source}:${b.reason}`)),
1066
+ redaction_markers: [...redactionCounts.values()].sort((a, b) => `${a.source}:${a.mode}`.localeCompare(`${b.source}:${b.mode}`)),
981
1067
  notes: totals.failed_count > 0
982
1068
  ? ["Evidence collection failed; downstream analysis should not infer confidence."]
983
- : hasGaps
984
- ? ["Evidence is incomplete; downstream analysis should lower confidence."]
985
- : [],
1069
+ : collection.redacted.length > 0
1070
+ ? ["Evidence was sanitized before upload; downstream analysis should not treat it as raw-complete."]
1071
+ : hasGaps
1072
+ ? ["Evidence is incomplete; downstream analysis should lower confidence."]
1073
+ : [],
986
1074
  });
987
1075
  }
988
1076
  async function walkJsonlFiles(dir, cutoffMs) {
@@ -1045,6 +1133,7 @@ function makeManifest(options) {
1045
1133
  },
1046
1134
  files: options.entries.map(redactManifestEntry),
1047
1135
  skipped: options.skipped,
1136
+ redacted: options.redacted,
1048
1137
  reused: options.reused,
1049
1138
  };
1050
1139
  }
@@ -1065,6 +1154,7 @@ function evidenceEntry(options) {
1065
1154
  byte_size: options.bytes.byteLength,
1066
1155
  media_type: options.mediaType,
1067
1156
  redacted_summary: options.redactedSummary,
1157
+ ...(options.redaction ? { redaction: options.redaction } : {}),
1068
1158
  codex_session_id: options.codexSessionId ?? null,
1069
1159
  ...(options.artifactMetadata
1070
1160
  ? {
@@ -1089,6 +1179,7 @@ function redactManifestEntry(entry) {
1089
1179
  byte_size: entry.byte_size,
1090
1180
  media_type: entry.media_type,
1091
1181
  redacted_summary: entry.redacted_summary,
1182
+ ...(entry.redaction ? { redaction: entry.redaction } : {}),
1092
1183
  ...(entry.artifact_metadata
1093
1184
  ? { artifact_metadata: entry.artifact_metadata }
1094
1185
  : {}),
@@ -1109,6 +1200,7 @@ function pointerFromEntry(entry) {
1109
1200
  byte_size: entry.byte_size,
1110
1201
  media_type: entry.media_type,
1111
1202
  redacted_summary: entry.redacted_summary,
1203
+ ...(entry.redaction ? { redaction: entry.redaction } : {}),
1112
1204
  };
1113
1205
  }
1114
1206
  /**
@@ -1193,6 +1285,18 @@ function safeKeySegment(value) {
1193
1285
  function sha256(value) {
1194
1286
  return crypto.createHash("sha256").update(value).digest("hex");
1195
1287
  }
1288
+ function withRedactionContentMetadata(metadata, options) {
1289
+ if (!metadata) {
1290
+ throw new Error("redacted evidence is missing redaction metadata");
1291
+ }
1292
+ return {
1293
+ ...metadata,
1294
+ original_content_hash_sha256: sha256(options.originalBytes),
1295
+ sanitized_content_hash_sha256: sha256(options.sanitizedBytes),
1296
+ original_byte_size: options.originalBytes.byteLength,
1297
+ sanitized_byte_size: options.sanitizedBytes.byteLength,
1298
+ };
1299
+ }
1196
1300
  async function ensurePrivateDir(dir) {
1197
1301
  await fs.mkdir(dir, { recursive: true, mode: 0o700 });
1198
1302
  await chmodPrivate(dir, 0o700);
@@ -106,9 +106,9 @@ export function cockpitAgentRulesBlock(options = {}) {
106
106
  "## Cockpit Ticket Binding",
107
107
  "",
108
108
  scopeLine,
109
- "- For implementation, debugging, review, PR, or ship work tied to a clear Linear ticket, run `cockpit start --ticket <ticket-id> --workspace \"$PWD\"` before the first code edit or mutating tool call. This starts attributing the session's work to that specific ticket in Cockpit.",
109
+ "- For implementation, debugging, review, PR, or ship work that already has a clear Linear ticket, run `cockpit start --ticket <ticket-id> --workspace \"$PWD\"` before the first code edit or mutating tool call. This starts attributing the session's work to that specific ticket in Cockpit.",
110
110
  "- Use `--ticket`; do not invent `--ticketId` or other flag shapes.",
111
- "- If the user mentions ticketed work but no ticket ID is visible, ask once for the Linear ticket ID before editing. Agents cannot reliably infer it from context.",
111
+ "- If the user mentions ticketed work but no ticket ID is visible, search Linear for an existing ticket before editing. If none exists and the work is ticket-worthy, create a narrow Linear ticket, then run the canonical `cockpit start --ticket ...` command. Ask the user only when ticket creation would be ambiguous or the user has forbidden creating one.",
112
112
  "- If there is truly no ticket, state that the work remains in general ambient capture and do not invent a ticket.",
113
113
  "- After the first meaningful checkpoint, run `cockpit sync --workspace \"$PWD\" --json` so Cockpit has fresh ticket/session binding metadata.",
114
114
  MANAGED_BLOCK_END,
@@ -208,16 +208,20 @@ function hasEquivalentUnmanagedTicketBinding(contents, scopePath) {
208
208
  return false;
209
209
  if (scopePath && !hasScopePath(text, scopePath))
210
210
  return false;
211
+ if (!hasTicketLookupOrCreationCue(text))
212
+ return false;
211
213
  const signals = [
212
214
  /cockpit\s+start\s+--ticket\b/u,
213
215
  /before\s+(?:the\s+)?first\s+code\s+edit|before\s+ticketed\s+implementation/u,
214
- /ticket\s+id\s+(?:is\s+)?(?:missing|visible)|ask\s+once/u,
215
216
  /general\s+ambient/u,
216
217
  /cockpit\s+sync\s+--repo|fresh\s+ticket\/session\s+binding\s+metadata/u,
217
218
  /use\s+--ticket|do\s+not\s+invent\s+--ticketid/u,
218
219
  ];
219
220
  const score = signals.filter((signal) => signal.test(text)).length;
220
- return score >= 5;
221
+ return score >= 4;
222
+ }
223
+ function hasTicketLookupOrCreationCue(text) {
224
+ return /search\s+linear|create\s+(?:a\s+)?(?:narrow\s+)?linear\s+ticket|new\s+linear\s+ticket/u.test(text);
221
225
  }
222
226
  function hasScopePath(text, scopePath) {
223
227
  return text.includes(normalizeRuleText(path.resolve(scopePath)));
@@ -1,4 +1,4 @@
1
- import { RAW_EVIDENCE_UPLOAD_DEFAULT_CHUNK_BYTES, RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES, RAW_EVIDENCE_UPLOAD_MAX_OBJECTS_PER_BEGIN, } from "@bli-cockpit/telemetry-core";
1
+ import { RAW_EVIDENCE_UPLOAD_DEFAULT_CHUNK_BYTES, RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES, RAW_EVIDENCE_UPLOAD_MAX_OBJECTS_PER_BEGIN, RawEvidenceRedactionMetadataSchema, RawEvidenceUploadCommitResponseSchema, } from "@bli-cockpit/telemetry-core";
2
2
  import crypto from "node:crypto";
3
3
  import fs from "node:fs/promises";
4
4
  const DEFAULT_MAX_ATTEMPTS = 3;
@@ -114,7 +114,7 @@ export async function uploadRawEvidenceFilesChunked(options) {
114
114
  function duplicateOutcome(primary, duplicate) {
115
115
  return {
116
116
  ...primary,
117
- pointer: duplicate.pointer,
117
+ pointer: pointerWithUploadedMetadata(duplicate.pointer, primary.pointer),
118
118
  codex_session_id: duplicate.codex_session_id ?? null,
119
119
  kind: duplicate.kind ?? "unknown",
120
120
  artifact_metadata: duplicate.artifact_metadata,
@@ -181,9 +181,10 @@ async function uploadOneObject(options, entry, disposition, chunkSizeBytes) {
181
181
  const commitStatus = commit.body && typeof commit.body === "object"
182
182
  ? commit.body.status
183
183
  : undefined;
184
+ const committedPointer = pointerWithCommitResponse(entry.file.pointer, commit.body);
184
185
  if (commitStatus === "already_committed") {
185
186
  return {
186
- pointer: entry.file.pointer,
187
+ pointer: committedPointer,
187
188
  object_key: objectKey,
188
189
  codex_session_id: entry.file.codex_session_id ?? null,
189
190
  kind: entry.file.kind ?? "unknown",
@@ -194,7 +195,7 @@ async function uploadOneObject(options, entry, disposition, chunkSizeBytes) {
194
195
  };
195
196
  }
196
197
  return {
197
- pointer: entry.file.pointer,
198
+ pointer: committedPointer,
198
199
  object_key: objectKey,
199
200
  codex_session_id: entry.file.codex_session_id ?? null,
200
201
  kind: entry.file.kind ?? "unknown",
@@ -227,8 +228,9 @@ async function uploadWithLegacyFallback(options, loaded, outcomes) {
227
228
  ],
228
229
  });
229
230
  if (response.ok) {
231
+ const pointer = pointerWithLegacyUploadResponse(entry.file.pointer, response.body);
230
232
  outcome = {
231
- pointer: entry.file.pointer,
233
+ pointer,
232
234
  object_key: entry.file.pointer.object_key ?? "",
233
235
  codex_session_id: entry.file.codex_session_id ?? null,
234
236
  kind: entry.file.kind ?? "unknown",
@@ -331,6 +333,50 @@ function failedOutcome(file, reason, uploadedChunks = 0) {
331
333
  uploaded_chunk_count: uploadedChunks,
332
334
  };
333
335
  }
336
+ function pointerWithCommitResponse(pointer, body) {
337
+ const parsed = RawEvidenceUploadCommitResponseSchema.safeParse(body);
338
+ if (!parsed.success)
339
+ return pointer;
340
+ return pointerWithUploadedMetadata(pointer, {
341
+ content_hash_sha256: parsed.data.content_hash_sha256,
342
+ byte_size: parsed.data.byte_size,
343
+ redaction: parsed.data.redaction,
344
+ });
345
+ }
346
+ function pointerWithLegacyUploadResponse(pointer, body) {
347
+ if (!body || typeof body !== "object")
348
+ return pointer;
349
+ const uploaded = body.uploaded;
350
+ if (!Array.isArray(uploaded))
351
+ return pointer;
352
+ const entry = uploaded.find((candidate) => {
353
+ if (!candidate || typeof candidate !== "object")
354
+ return false;
355
+ return (candidate
356
+ .raw_evidence_pointer_id === pointer.raw_evidence_pointer_id);
357
+ });
358
+ if (!entry || typeof entry !== "object")
359
+ return pointer;
360
+ const record = entry;
361
+ return pointerWithUploadedMetadata(pointer, {
362
+ content_hash_sha256: typeof record["content_hash_sha256"] === "string"
363
+ ? record["content_hash_sha256"]
364
+ : undefined,
365
+ byte_size: typeof record["byte_size"] === "number" ? record["byte_size"] : undefined,
366
+ redaction: record["redaction"],
367
+ });
368
+ }
369
+ function pointerWithUploadedMetadata(pointer, metadata) {
370
+ const redaction = RawEvidenceRedactionMetadataSchema.safeParse(metadata.redaction);
371
+ return {
372
+ ...pointer,
373
+ content_hash_sha256: typeof metadata.content_hash_sha256 === "string"
374
+ ? metadata.content_hash_sha256
375
+ : pointer.content_hash_sha256,
376
+ byte_size: typeof metadata.byte_size === "number" ? metadata.byte_size : pointer.byte_size,
377
+ redaction: redaction.success ? redaction.data : pointer.redaction,
378
+ };
379
+ }
334
380
  function summarizeOutcomes(outcomes, usedLegacyFallback) {
335
381
  const uploaded = outcomes.filter((outcome) => outcome.upload_state === "uploaded");
336
382
  return {
package/dist/upload.js CHANGED
@@ -162,7 +162,7 @@ export async function syncLocalAmbientEnvelope(options = {}) {
162
162
  uploadOutcomes = upload.outcomes;
163
163
  uploadedChunkCount = upload.uploaded_chunk_count;
164
164
  }
165
- const envelope = pruneUndurablePointers(built.envelope, uploadOutcomes);
165
+ const envelope = applyRawEvidenceUploadOutcomes(built.envelope, uploadOutcomes);
166
166
  const response = await fetchImpl(`${built.dashboard_url}/api/ambient/ingest`, {
167
167
  method: "POST",
168
168
  headers: {
@@ -499,6 +499,7 @@ function rawEvidenceSummary(built, outcomes, uploadedChunkCount, cursor) {
499
499
  raw_evidence_uploaded_chunk_count: uploadedChunkCount,
500
500
  raw_evidence_reused_count: cursorReused.length + serverReusedCount,
501
501
  raw_evidence_failed_count: failed.length,
502
+ raw_evidence_sanitized_count: built.raw_evidence_facts?.sanitized_count ?? 0,
502
503
  raw_evidence_failure_reasons: [
503
504
  ...new Set(failed.map((outcome) => outcome.reason ?? "unknown")),
504
505
  ],
@@ -523,32 +524,40 @@ function rawEvidenceSummary(built, outcomes, uploadedChunkCount, cursor) {
523
524
  }
524
525
  /**
525
526
  * Ingest refuses pointers whose objects never became durable, so failed
526
- * uploads are pruned from the envelope instead of failing the whole sync;
527
- * the failures stay visible via reason labels and the server-side ledger.
527
+ * uploads are pruned from the envelope instead of failing the whole sync.
528
+ * Successful upload responses can also carry server-side sanitized hash and
529
+ * redaction metadata; apply those before ingest so refs describe the bytes
530
+ * actually stored in the durable bucket.
528
531
  */
529
- function pruneUndurablePointers(envelope, outcomes) {
532
+ function applyRawEvidenceUploadOutcomes(envelope, outcomes) {
530
533
  // Content-addressed keys mean one pointer id can carry several outcomes
531
534
  // (byte-identical files); the pointer is durable if ANY outcome succeeded.
532
- const durablePointerIds = new Set(outcomes
533
- .filter((outcome) => outcome.upload_state !== "upload_failed")
534
- .map((outcome) => outcome.pointer.raw_evidence_pointer_id));
535
+ const durablePointers = new Map();
536
+ for (const outcome of outcomes) {
537
+ if (outcome.upload_state === "upload_failed")
538
+ continue;
539
+ durablePointers.set(outcome.pointer.raw_evidence_pointer_id, outcome.pointer);
540
+ }
541
+ const durablePointerIds = new Set([...durablePointers.keys()]);
535
542
  const failedPointerIds = new Set(outcomes
536
543
  .filter((outcome) => outcome.upload_state === "upload_failed" &&
537
544
  !durablePointerIds.has(outcome.pointer.raw_evidence_pointer_id))
538
545
  .map((outcome) => outcome.pointer.raw_evidence_pointer_id));
539
546
  const failedOutcomes = outcomes.filter((outcome) => failedPointerIds.has(outcome.pointer.raw_evidence_pointer_id));
540
- if (failedPointerIds.size === 0)
547
+ if (failedPointerIds.size === 0 && durablePointers.size === 0)
541
548
  return envelope;
542
549
  return {
543
550
  ...envelope,
544
551
  events: envelope.events.map((event) => ({
545
552
  ...event,
546
- metrics: {
547
- ...event.metrics,
548
- evidence_failed_count: (event.metrics["evidence_failed_count"] ?? 0) +
549
- failedOutcomes.length,
550
- },
551
- attributes: event.evidence_completeness
553
+ metrics: failedPointerIds.size > 0
554
+ ? {
555
+ ...event.metrics,
556
+ evidence_failed_count: (event.metrics["evidence_failed_count"] ?? 0) +
557
+ failedOutcomes.length,
558
+ }
559
+ : event.metrics,
560
+ attributes: failedPointerIds.size > 0 && event.evidence_completeness
552
561
  ? {
553
562
  ...event.attributes,
554
563
  evidence_completeness_schema_version: event.evidence_completeness.schema_version,
@@ -556,14 +565,21 @@ function pruneUndurablePointers(envelope, outcomes) {
556
565
  evidence_incomplete: true,
557
566
  }
558
567
  : event.attributes,
559
- evidence_completeness: event.evidence_completeness
568
+ evidence_completeness: failedPointerIds.size > 0 && event.evidence_completeness
560
569
  ? markCompletenessUploadFailures(event.evidence_completeness, failedOutcomes)
561
570
  : event.evidence_completeness,
562
- raw_evidence_pointers: event.raw_evidence_pointers.filter((pointer) => !failedPointerIds.has(pointer.raw_evidence_pointer_id)),
563
- redaction: {
564
- ...event.redaction,
565
- raw_evidence_pointer_ids: event.redaction.raw_evidence_pointer_ids.filter((pointerId) => !failedPointerIds.has(pointerId)),
566
- },
571
+ raw_evidence_pointers: event.raw_evidence_pointers.flatMap((pointer) => {
572
+ const pointerId = pointer.raw_evidence_pointer_id;
573
+ if (failedPointerIds.has(pointerId))
574
+ return [];
575
+ return [durablePointers.get(pointerId) ?? pointer];
576
+ }),
577
+ redaction: failedPointerIds.size > 0
578
+ ? {
579
+ ...event.redaction,
580
+ raw_evidence_pointer_ids: event.redaction.raw_evidence_pointer_ids.filter((pointerId) => !failedPointerIds.has(pointerId)),
581
+ }
582
+ : event.redaction,
567
583
  })),
568
584
  };
569
585
  }
@@ -701,6 +717,7 @@ function makeSourceScanCompletedEvent(options) {
701
717
  raw_evidence_file_count: options.rawEvidenceFacts?.file_count ?? 0,
702
718
  raw_evidence_byte_size: options.rawEvidenceFacts?.byte_size ?? 0,
703
719
  raw_evidence_skipped_count: options.rawEvidenceFacts?.skipped_count ?? 0,
720
+ raw_evidence_sanitized_count: options.rawEvidenceFacts?.sanitized_count ?? 0,
704
721
  raw_evidence_reused_count: options.rawEvidenceFacts?.reused_count ?? 0,
705
722
  evidence_scanned_count: evidenceCompleteness?.totals.scanned_count ?? 0,
706
723
  evidence_included_count: evidenceCompleteness?.totals.included_count ?? 0,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.1.22",
3
+ "version": "0.1.23",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {