@bli-cockpit/cli 0.1.21 → 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 +39 -23
- package/dist/adapters/raw-evidence.js +121 -17
- package/dist/agent-rules.js +8 -4
- package/dist/evidence-upload-client.js +51 -5
- package/dist/upload.js +37 -20
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -14,24 +14,24 @@ device pairing.
|
|
|
14
14
|
Run this from the repo where work will happen:
|
|
15
15
|
|
|
16
16
|
```bash
|
|
17
|
-
COCKPIT_DEVICE_NAME="$(scutil --get ComputerName 2>/dev/null || hostname -s)"
|
|
18
17
|
npm install -g @bli-cockpit/cli@latest
|
|
19
|
-
cockpit onboard
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
cockpit onboard --workspace "$PWD"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Interactive onboarding prompts for the dashboard email. Enter the approved
|
|
22
|
+
intern/operator email. For reused laptops, VMs, or headless/agent-run setup,
|
|
23
|
+
pass the email explicitly:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
cockpit onboard --email <APPROVED_EMAIL> --workspace "$PWD"
|
|
23
27
|
```
|
|
24
28
|
|
|
25
29
|
If a work folder contains multiple repos, run the same command from the parent:
|
|
26
30
|
|
|
27
31
|
```bash
|
|
28
32
|
cd ~/Downloads/bluepearl-workspace
|
|
29
|
-
COCKPIT_DEVICE_NAME="$(scutil --get ComputerName 2>/dev/null || hostname -s)"
|
|
30
33
|
npm install -g @bli-cockpit/cli@latest
|
|
31
|
-
cockpit onboard
|
|
32
|
-
--email <APPROVED_EMAIL> \
|
|
33
|
-
--device-name "$COCKPIT_DEVICE_NAME" \
|
|
34
|
-
--workspace "$PWD"
|
|
34
|
+
cockpit onboard --workspace "$PWD"
|
|
35
35
|
```
|
|
36
36
|
|
|
37
37
|
The CLI defaults to the production dashboard. Normal intern/operator setup,
|
|
@@ -50,6 +50,11 @@ to install or replace it. The managed guidance is scoped to the workspace path
|
|
|
50
50
|
used for onboarding, so agents should ignore it in private chats or unrelated
|
|
51
51
|
repos.
|
|
52
52
|
|
|
53
|
+
For intern machines, accept the onboarding prompt to keep Cockpit syncing in the
|
|
54
|
+
background. It installs the launchd autostart agent so Cockpit syncs at login
|
|
55
|
+
and every 15 minutes. If skipped, run
|
|
56
|
+
`cockpit autostart install --workspace "$PWD"` later.
|
|
57
|
+
|
|
53
58
|
```bash
|
|
54
59
|
# Repair/manual path, or headless/json onboarding where Cockpit cannot prompt.
|
|
55
60
|
cockpit agent-rules install --workspace "$PWD"
|
|
@@ -79,33 +84,42 @@ What happens:
|
|
|
79
84
|
1. npm installs or updates the public `@bli-cockpit/cli` package.
|
|
80
85
|
2. `cockpit onboard` writes local user config.
|
|
81
86
|
3. Cockpit prints a dashboard pairing URL and code.
|
|
82
|
-
4. Admin approves the
|
|
83
|
-
|
|
84
|
-
|
|
87
|
+
4. Admin approves the persistent email access request from Ambient -> Collector
|
|
88
|
+
approvals. Pair codes still expire, but the access request remains. If the
|
|
89
|
+
terminal is still polling, approval pairs it immediately; if not, the intern
|
|
90
|
+
reruns `cockpit onboard --email <APPROVED_EMAIL> --workspace "$PWD"` and the
|
|
91
|
+
stored approval auto-pairs without another Admin action. Admin can still
|
|
92
|
+
paste the printed code there as a rescue path. The signed-in intern can still
|
|
93
|
+
use the printed URL. If the claimed email is not an app user yet, a super
|
|
94
|
+
admin approval creates the user, adds team membership, and sends the
|
|
95
|
+
Supabase invite flow automatically.
|
|
85
96
|
5. The CLI starts general ambient capture, uploads private raw evidence objects
|
|
86
97
|
when present (chunked and resumable, with identical content acknowledged
|
|
87
|
-
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.
|
|
88
101
|
6. The CLI prints `PASS: Cockpit collector is ready for harvest.`
|
|
89
102
|
|
|
90
|
-
`--device-name` is
|
|
91
|
-
`"Savina MacBook"
|
|
103
|
+
`--device-name` is optional and only a readable label in Cockpit. Pass it only
|
|
104
|
+
when you want a specific label such as `"Savina MacBook"` or `"Box VM 42"`.
|
|
92
105
|
On reused laptops or VMs, keep `--email <APPROVED_EMAIL>` in the command.
|
|
93
106
|
`cockpit onboard` skips pairing only when the existing valid session belongs to
|
|
94
107
|
that same email and dashboard URL (the production default unless overridden); a
|
|
95
108
|
different email or dashboard forces a new approval.
|
|
96
109
|
|
|
97
|
-
Before the command runs, an Admin
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
110
|
+
Before the command runs, an Admin does not need to open Supabase or pre-create
|
|
111
|
+
ordinary interns in Settings -> Team. Approval of the claimed email in Ambient
|
|
112
|
+
creates the missing app user and stores the durable access approval. Settings
|
|
113
|
+
-> Team remains available for manual pre-provisioning, including
|
|
114
|
+
temporary-password launch. For temporary-password accounts, the intern signs in
|
|
115
|
+
directly; there is no separate invite acceptance step.
|
|
102
116
|
|
|
103
117
|
When ticket work starts later:
|
|
104
118
|
|
|
105
119
|
```bash
|
|
106
120
|
cockpit start \
|
|
107
121
|
--ticket <ticket-id> \
|
|
108
|
-
--
|
|
122
|
+
--workspace "$PWD"
|
|
109
123
|
|
|
110
124
|
cockpit sync \
|
|
111
125
|
--workspace "$PWD" \
|
|
@@ -161,7 +175,9 @@ Remote dashboard:
|
|
|
161
175
|
- raw evidence refs accepted by `/api/ambient/ingest`;
|
|
162
176
|
- durable private Storage objects accepted by the chunked
|
|
163
177
|
`/api/ambient/evidence/upload/begin|chunk|commit` endpoints, tracked in a
|
|
164
|
-
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;
|
|
165
181
|
- attached image artifact metadata accepted by `/api/ambient/agent-artifacts`
|
|
166
182
|
for screenshots/images explicitly attached into Codex or Claude sessions;
|
|
167
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
|
-
|
|
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:
|
|
621
|
+
reason: sanitized.reason,
|
|
611
622
|
});
|
|
612
623
|
return false;
|
|
613
624
|
}
|
|
614
|
-
|
|
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,
|
|
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,
|
|
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:
|
|
647
|
-
|
|
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
|
-
|
|
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:
|
|
756
|
+
reason: sanitized.reason,
|
|
705
757
|
});
|
|
706
758
|
continue;
|
|
707
759
|
}
|
|
708
|
-
|
|
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:
|
|
741
|
-
? `Raw git ${target.label} diff
|
|
742
|
-
:
|
|
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
|
-
:
|
|
984
|
-
? ["Evidence
|
|
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);
|
package/dist/agent-rules.js
CHANGED
|
@@ -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
|
|
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,
|
|
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 >=
|
|
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:
|
|
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:
|
|
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
|
|
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 =
|
|
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
|
-
*
|
|
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
|
|
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
|
|
533
|
-
|
|
534
|
-
|
|
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
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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.
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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,
|