@vibe-commit/capture 0.7.0 → 0.9.0
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 +238 -7
- package/dist/account_not_here.d.ts +15 -0
- package/dist/account_not_here.js +25 -0
- package/dist/account_not_here.js.map +1 -0
- package/dist/agents/claude_code.js +13 -0
- package/dist/agents/claude_code.js.map +1 -1
- package/dist/agents/codex_cli.js +11 -0
- package/dist/agents/codex_cli.js.map +1 -1
- package/dist/agents/cursor.js +7 -1
- package/dist/agents/cursor.js.map +1 -1
- package/dist/agents/registry.d.ts +7 -0
- package/dist/agents/registry.js +9 -0
- package/dist/agents/registry.js.map +1 -1
- package/dist/agents/types.d.ts +32 -0
- package/dist/agents/types.js.map +1 -1
- package/dist/bounded_id.d.ts +29 -0
- package/dist/bounded_id.js +42 -0
- package/dist/bounded_id.js.map +1 -0
- package/dist/codex_folders.d.ts +340 -0
- package/dist/codex_folders.js +1448 -0
- package/dist/codex_folders.js.map +1 -0
- package/dist/commands/auth.d.ts +7 -0
- package/dist/commands/auth.js +21 -0
- package/dist/commands/auth.js.map +1 -1
- package/dist/commands/backfill.d.ts +29 -7
- package/dist/commands/backfill.js +387 -109
- package/dist/commands/backfill.js.map +1 -1
- package/dist/commands/browser_credential.d.ts +4 -2
- package/dist/commands/browser_credential.js +5 -2
- package/dist/commands/browser_credential.js.map +1 -1
- package/dist/commands/connect.d.ts +10 -1
- package/dist/commands/connect.js +14 -9
- package/dist/commands/connect.js.map +1 -1
- package/dist/commands/connect_account.d.ts +135 -0
- package/dist/commands/connect_account.js +321 -0
- package/dist/commands/connect_account.js.map +1 -0
- package/dist/commands/connect_picker.js +204 -29
- package/dist/commands/connect_picker.js.map +1 -1
- package/dist/commands/device_credential.d.ts +3 -1
- package/dist/commands/device_credential.js +1 -0
- package/dist/commands/device_credential.js.map +1 -1
- package/dist/commands/finalize.d.ts +67 -0
- package/dist/commands/finalize.js +450 -114
- package/dist/commands/finalize.js.map +1 -1
- package/dist/commands/finish_mint.d.ts +28 -0
- package/dist/commands/finish_mint.js +16 -4
- package/dist/commands/finish_mint.js.map +1 -1
- package/dist/commands/scan.d.ts +28 -0
- package/dist/commands/scan.js +11 -0
- package/dist/commands/scan.js.map +1 -1
- package/dist/commands/status.d.ts +19 -1
- package/dist/commands/status.js +123 -14
- package/dist/commands/status.js.map +1 -1
- package/dist/commands/status_machine.d.ts +12 -3
- package/dist/commands/status_machine.js +111 -13
- package/dist/commands/status_machine.js.map +1 -1
- package/dist/commands/sync_reason.d.ts +17 -0
- package/dist/commands/sync_reason.js +25 -0
- package/dist/commands/sync_reason.js.map +1 -0
- package/dist/consent.d.ts +131 -3
- package/dist/consent.js +248 -14
- package/dist/consent.js.map +1 -1
- package/dist/copy/strings.d.ts +165 -4
- package/dist/copy/strings.js +190 -7
- package/dist/copy/strings.js.map +1 -1
- package/dist/folder_grants.d.ts +44 -0
- package/dist/folder_grants.js +101 -0
- package/dist/folder_grants.js.map +1 -0
- package/dist/git.d.ts +26 -4
- package/dist/git.js +82 -11
- package/dist/git.js.map +1 -1
- package/dist/grants_sync.d.ts +165 -0
- package/dist/grants_sync.js +289 -0
- package/dist/grants_sync.js.map +1 -0
- package/dist/grants_verb.d.ts +70 -0
- package/dist/grants_verb.js +121 -0
- package/dist/grants_verb.js.map +1 -0
- package/dist/hooks/entry.d.ts +30 -50
- package/dist/hooks/entry.js +601 -561
- package/dist/hooks/entry.js.map +1 -1
- package/dist/hooks/fire_lock.d.ts +31 -0
- package/dist/hooks/fire_lock.js +120 -12
- package/dist/hooks/fire_lock.js.map +1 -1
- package/dist/hooks/session_grants.d.ts +59 -0
- package/dist/hooks/session_grants.js +91 -0
- package/dist/hooks/session_grants.js.map +1 -0
- package/dist/hooks/session_start.d.ts +28 -7
- package/dist/hooks/session_start.js +55 -11
- package/dist/hooks/session_start.js.map +1 -1
- package/dist/index.js +22 -5
- package/dist/index.js.map +1 -1
- package/dist/machine_state.d.ts +44 -1
- package/dist/machine_state.js +75 -12
- package/dist/machine_state.js.map +1 -1
- package/dist/oauth/discovery.js +2 -1
- package/dist/oauth/discovery.js.map +1 -1
- package/dist/oauth/pkce.js +2 -1
- package/dist/oauth/pkce.js.map +1 -1
- package/dist/oauth/retire.d.ts +10 -0
- package/dist/oauth/retire.js +13 -0
- package/dist/oauth/retire.js.map +1 -1
- package/dist/paths.d.ts +54 -1
- package/dist/paths.js +88 -12
- package/dist/paths.js.map +1 -1
- package/dist/pending_revoke.d.ts +117 -0
- package/dist/pending_revoke.js +233 -0
- package/dist/pending_revoke.js.map +1 -0
- package/dist/pending_revoke_file.d.ts +55 -0
- package/dist/pending_revoke_file.js +74 -0
- package/dist/pending_revoke_file.js.map +1 -0
- package/dist/physical_path.d.ts +46 -0
- package/dist/physical_path.js +151 -0
- package/dist/physical_path.js.map +1 -0
- package/dist/picker.d.ts +82 -5
- package/dist/picker.js +183 -10
- package/dist/picker.js.map +1 -1
- package/dist/picker_checklist.d.ts +27 -3
- package/dist/picker_checklist.js +81 -6
- package/dist/picker_checklist.js.map +1 -1
- package/dist/policy.d.ts +24 -1
- package/dist/policy.js +30 -2
- package/dist/policy.js.map +1 -1
- package/dist/post.d.ts +55 -2
- package/dist/post.js +72 -22
- package/dist/post.js.map +1 -1
- package/dist/project.d.ts +89 -0
- package/dist/project.js +123 -0
- package/dist/project.js.map +1 -1
- package/dist/redact.d.ts +77 -2
- package/dist/redact.js +713 -47
- package/dist/redact.js.map +1 -1
- package/dist/session_targets.d.ts +713 -0
- package/dist/session_targets.js +2602 -0
- package/dist/session_targets.js.map +1 -0
- package/dist/spool.d.ts +12 -0
- package/dist/spool.js +29 -1
- package/dist/spool.js.map +1 -1
- package/dist/spool_union.d.ts +51 -0
- package/dist/spool_union.js +57 -0
- package/dist/spool_union.js.map +1 -0
- package/dist/state.d.ts +5 -0
- package/dist/state.js +19 -1
- package/dist/state.js.map +1 -1
- package/dist/system_message.d.ts +25 -2
- package/dist/system_message.js +29 -5
- package/dist/system_message.js.map +1 -1
- package/dist/text_tail.d.ts +8 -0
- package/dist/text_tail.js +19 -0
- package/dist/text_tail.js.map +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -3
package/dist/redact.js
CHANGED
|
@@ -94,9 +94,105 @@ const CONTENT_KEYS = new Set([
|
|
|
94
94
|
"old_source",
|
|
95
95
|
"newSource",
|
|
96
96
|
"oldSource",
|
|
97
|
+
// C39 addendum: an `ExitPlanMode` result is `{plan, isAgent, filePath}`: the plan file's own text beside its out-of-tree path (3 records in the
|
|
98
|
+
// C39 re-measure carried it through, the only same-record sibling that remained).
|
|
99
|
+
"plan",
|
|
97
100
|
]);
|
|
98
101
|
/** Hunk arrays whose `lines` carry file content. */
|
|
99
102
|
const PATCH_KEY = "structuredPatch";
|
|
103
|
+
/** C39 (ii): Claude's record-level second copy of every tool input, keyed by tool_use id. */
|
|
104
|
+
const WIRE_KEY = "wireToolInputs";
|
|
105
|
+
/**
|
|
106
|
+
* C38: record types whose content duplicates the conversation records and is withheld in EVERY copy, always, with no flag (P1, 2026-11-01).
|
|
107
|
+
* `last-prompt`, `queue-operation` and `api-request-blob` repeated an out-of-tree file's text in 870 of 907 in-repo files in the first measurement;
|
|
108
|
+
* the extension adds the three other request/snapshot types. None is read by the server (its reader skips them by type or never names them).
|
|
109
|
+
* Their path-shaped KEYS stay (`trackedFileBackups`' paths): only string values are withheld, and the README says so.
|
|
110
|
+
*/
|
|
111
|
+
const ALWAYS_WITHHELD_TYPES = new Set([
|
|
112
|
+
"last-prompt",
|
|
113
|
+
"queue-operation",
|
|
114
|
+
"api-request-blob",
|
|
115
|
+
"api-request",
|
|
116
|
+
"api-request-shape",
|
|
117
|
+
"file-history-snapshot",
|
|
118
|
+
"file-history-delta",
|
|
119
|
+
]);
|
|
120
|
+
function claudeAlwaysWithheld(record) {
|
|
121
|
+
// C39 (iv): a model-written digest of the whole conversation, file text included. Strict: a data-shaped key makes the whole line go.
|
|
122
|
+
if (record.isCompactSummary === true)
|
|
123
|
+
return "strict";
|
|
124
|
+
return typeof record.type === "string" && ALWAYS_WITHHELD_TYPES.has(record.type) ? "values" : null;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* C27: the cwd-less record types that carry NO conversation content, each bound to the key paths measured on a corpus of 346,120 cwd-less records.
|
|
128
|
+
* A string under a key a KEEP type does not have today is withheld in a multi-repo copy; every other cwd-less type (and any type Claude Code adds
|
|
129
|
+
* later) is AGGREGATE: default deny. `modelUsage`'s keys are model ids, normalised to `<datakey>`.
|
|
130
|
+
*/
|
|
131
|
+
const KEEP_KEYS = {
|
|
132
|
+
mode: new Set(["type", "mode", "sessionId"]),
|
|
133
|
+
"permission-mode": new Set(["type", "permissionMode", "sessionId"]),
|
|
134
|
+
"atis-latch": new Set(["type", "atis", "sessionId"]),
|
|
135
|
+
"bridge-session": new Set(["type", "bridgeSessionId", "lastSequenceNum", "ownerAccountUuid", "ownerOrganizationUuid", "sessionId"]),
|
|
136
|
+
launched: new Set(["type"]),
|
|
137
|
+
"tools-used": new Set(["type", "tools", "sessionId"]),
|
|
138
|
+
"fork-context-ref": new Set(["type", "agentId", "contextLength", "parentLastUuid", "parentSessionId"]),
|
|
139
|
+
"cost-state": new Set([
|
|
140
|
+
"type",
|
|
141
|
+
"sessionId",
|
|
142
|
+
"startTime",
|
|
143
|
+
"hasUnknownModelCost",
|
|
144
|
+
"totalCostUSD",
|
|
145
|
+
"totalAPIDuration",
|
|
146
|
+
"totalAPIDurationWithoutRetries",
|
|
147
|
+
"totalToolDuration",
|
|
148
|
+
"totalDuration",
|
|
149
|
+
"totalLinesAdded",
|
|
150
|
+
"totalLinesRemoved",
|
|
151
|
+
"modelUsage",
|
|
152
|
+
"modelUsage.<datakey>",
|
|
153
|
+
"modelUsage.<datakey>.inputTokens",
|
|
154
|
+
"modelUsage.<datakey>.outputTokens",
|
|
155
|
+
"modelUsage.<datakey>.cacheReadInputTokens",
|
|
156
|
+
"modelUsage.<datakey>.cacheCreationInputTokens",
|
|
157
|
+
"modelUsage.<datakey>.cacheCreationEphemeral5mInputTokens",
|
|
158
|
+
"modelUsage.<datakey>.cacheCreationEphemeral1hInputTokens",
|
|
159
|
+
"modelUsage.<datakey>.webSearchRequests",
|
|
160
|
+
"modelUsage.<datakey>.costUSD",
|
|
161
|
+
"modelUsage.<datakey>.thinkingTokens",
|
|
162
|
+
]),
|
|
163
|
+
};
|
|
164
|
+
/** The `attachment` and `system` subtypes measured on the corpus (cwd-bearing). One not listed is a FUTURE subtype: withheld in a multi-repo copy. */
|
|
165
|
+
const KNOWN_ATTACHMENT_TYPES = new Set(("hook_success total_tokens_reminder deferred_tools_record environment queued_command prompt_snapshot silent_turn_reminder date remote_session_change " +
|
|
166
|
+
"model session_context credential_org deferred_tools_delta mcp_instructions_delta instructions skill_listing auto_mode nested_memory edited_text_file " +
|
|
167
|
+
"structured_output agent_listing_delta file hook_additional_context compact_file_reference command_permissions read_truncation_notice " +
|
|
168
|
+
"hook_non_blocking_error plan_mode plan_mode_exit peer_mention thinking_drop task_status invoked_skills hook_system_message plan_file_reference " +
|
|
169
|
+
"hook_cancelled batching_reminder_sent auto_mode_exit task_reminder bash_output_audience_note ultra_effort_enter plan_mode_reentry skill_mention " +
|
|
170
|
+
"workflow_keyword_request ultra_effort_exit already_read_file dynamic_skill").split(" "));
|
|
171
|
+
const KNOWN_SYSTEM_SUBTYPES = new Set("stop_hook_summary turn_duration away_summary informational local_command compact_boundary bridge_status scheduled_task_fire agents_killed".split(" "));
|
|
172
|
+
/** Subtypes whose text digests other turns (P1 C38 (b)): withheld in a multi-repo copy even though they carry a cwd. */
|
|
173
|
+
const AGGREGATE_ATTACHMENT_TYPES = new Set(["prompt_snapshot"]);
|
|
174
|
+
const AGGREGATE_SYSTEM_SUBTYPES = new Set(["away_summary"]);
|
|
175
|
+
function claudeAggregate(record) {
|
|
176
|
+
const type = typeof record.type === "string" ? record.type : "";
|
|
177
|
+
const hasCwd = typeof record.cwd === "string" && record.cwd !== "";
|
|
178
|
+
if (!hasCwd) {
|
|
179
|
+
// No folder of its own: nothing says whose it is. A KEEP type keeps only its measured keys; every other type is withheld.
|
|
180
|
+
const keep = KEEP_KEYS[type];
|
|
181
|
+
return keep === undefined ? { kind: "withhold" } : { kind: "keepKeys", paths: keep };
|
|
182
|
+
}
|
|
183
|
+
if (type === "attachment") {
|
|
184
|
+
const attachment = record.attachment;
|
|
185
|
+
const subtype = attachment !== null && typeof attachment === "object" ? attachment.type : undefined;
|
|
186
|
+
if (typeof subtype !== "string" || !KNOWN_ATTACHMENT_TYPES.has(subtype) || AGGREGATE_ATTACHMENT_TYPES.has(subtype))
|
|
187
|
+
return { kind: "withhold" };
|
|
188
|
+
}
|
|
189
|
+
else if (type === "system") {
|
|
190
|
+
const subtype = record.subtype;
|
|
191
|
+
if (typeof subtype !== "string" || !KNOWN_SYSTEM_SUBTYPES.has(subtype) || AGGREGATE_SYSTEM_SUBTYPES.has(subtype))
|
|
192
|
+
return { kind: "withhold" };
|
|
193
|
+
}
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
100
196
|
/**
|
|
101
197
|
* `claude-code` — the rules this module shipped with, unchanged.
|
|
102
198
|
*
|
|
@@ -108,6 +204,19 @@ const CLAUDE_CODE_RULES = {
|
|
|
108
204
|
contentKeys: CONTENT_KEYS,
|
|
109
205
|
nestedJsonKeys: new Set(),
|
|
110
206
|
patchKeys: new Set([PATCH_KEY]),
|
|
207
|
+
// C37 (a HIGH that shipped in 0.8.0): the path rule never fired on these attachment shapes, so a global ~/.claude/CLAUDE.md, a memory
|
|
208
|
+
// file or an edited file outside the repository went out unredacted in EVERY copy, single-repo sessions included.
|
|
209
|
+
attachment: {
|
|
210
|
+
// `planFilePath` and `data.targetFile`/`data.path` (a plan file, a structured output about a file) name their subject the same way.
|
|
211
|
+
pathKeys: new Set(["path", "filename", "planFilePath", "targetFile"]),
|
|
212
|
+
// `planContent` is a plan file's text. `reason`, `summary`, `notes` and `gaps` are a structured output's prose ABOUT its `path`
|
|
213
|
+
// (measured: attachment.structured_output.data). Each is withheld only when the path beside it is out of tree.
|
|
214
|
+
contentKeys: new Set(["content", "rawContent", "snippet", "planContent", "reason", "summary", "notes", "gaps"]),
|
|
215
|
+
},
|
|
216
|
+
// C39 (iv): after a compaction Claude writes a `user` record flagged `isCompactSummary` whose text digests the conversation so far, including
|
|
217
|
+
// what it read from files outside the repository. Nothing in it names a file, so the path rule cannot judge it.
|
|
218
|
+
alwaysWithheld: claudeAlwaysWithheld,
|
|
219
|
+
aggregate: claudeAggregate,
|
|
111
220
|
};
|
|
112
221
|
/**
|
|
113
222
|
* `codex-cli` — ⛔ **the key set is D164 §8's MEASUREMENT, not a guess.**
|
|
@@ -148,11 +257,35 @@ const CLAUDE_CODE_RULES = {
|
|
|
148
257
|
* today, symmetric across agents.** Widening the control is a separate decision
|
|
149
258
|
* about the control; reaching parity with it is this task. See the report.
|
|
150
259
|
*/
|
|
260
|
+
/** The Codex records that are injected instructions, never the user's or the agent's own words (C40 (2), P1 ruling 2026-11-01). */
|
|
261
|
+
function isCodexInstructionRecord(record) {
|
|
262
|
+
if (record.type === "world_state")
|
|
263
|
+
return true;
|
|
264
|
+
const payload = record.payload;
|
|
265
|
+
if (payload === null || typeof payload !== "object")
|
|
266
|
+
return false;
|
|
267
|
+
const p = payload;
|
|
268
|
+
if (record.type === "response_item") {
|
|
269
|
+
// The WHOLE role:user and role:developer streams, not a prefix match: it is the injected superset (`# AGENTS.md instructions for ...`,
|
|
270
|
+
// `<user_action>` synthetics), and a prefix breaks when the wording changes. The human prompt is `event_msg/user_message`.
|
|
271
|
+
if (p.type === "message")
|
|
272
|
+
return p.role === "user" || p.role === "developer";
|
|
273
|
+
return p.type === "additional_tools";
|
|
274
|
+
}
|
|
275
|
+
return record.type === "event_msg" && p.type === "entered_review_mode";
|
|
276
|
+
}
|
|
151
277
|
const CODEX_CLI_RULES = {
|
|
152
278
|
pathKeys: new Set(["workdir", "cwd", "absolute_file_path"]),
|
|
153
279
|
contentKeys: new Set(["cmd"]),
|
|
154
280
|
nestedJsonKeys: new Set(["arguments"]),
|
|
155
281
|
patchKeys: new Set(),
|
|
282
|
+
// C40 (2): Codex injects the user's instruction files and its own prompts into the record stream, and none of those records names a path
|
|
283
|
+
// for the path rule to judge. Measured on one machine (367 rollouts): the GLOBAL ~/.codex/AGENTS.md text is in all 161 `world_state`
|
|
284
|
+
// records (`state.agents_md.text`) and in the `response_item/message` role:user injections. Nothing reads them: the human prompt comes
|
|
285
|
+
// from `event_msg/user_message`, and the server's reader skips the model-input mirror. So they are withheld in EVERY copy, by TYPE, and
|
|
286
|
+
// the next instruction field Codex adds is not a leak. Assistant prose (`event_msg/agent_message`, role:assistant) is NOT here.
|
|
287
|
+
alwaysWithheld: (record) => (isCodexInstructionRecord(record) ? "strict" : null),
|
|
288
|
+
withheldKeys: (key) => key === "base_instructions" || key.endsWith("developer_instructions"),
|
|
156
289
|
};
|
|
157
290
|
/**
|
|
158
291
|
* ⛔ **Every profile, keyed by its label — and the `Record` is the lock.**
|
|
@@ -266,6 +399,27 @@ const STRICTEST_RULES = {
|
|
|
266
399
|
contentKeys: unionOf((rules) => rules.contentKeys),
|
|
267
400
|
nestedJsonKeys: unionOf((rules) => rules.nestedJsonKeys),
|
|
268
401
|
patchKeys: unionOf((rules) => rules.patchKeys),
|
|
402
|
+
attachment: {
|
|
403
|
+
pathKeys: unionOf((rules) => rules.attachment?.pathKeys ?? new Set()),
|
|
404
|
+
contentKeys: unionOf((rules) => rules.attachment?.contentKeys ?? new Set()),
|
|
405
|
+
},
|
|
406
|
+
alwaysWithheld: (record) => {
|
|
407
|
+
for (const rules of Object.values(RULES)) {
|
|
408
|
+
const verdict = rules.alwaysWithheld?.(record) ?? null;
|
|
409
|
+
if (verdict !== null)
|
|
410
|
+
return verdict;
|
|
411
|
+
}
|
|
412
|
+
return null;
|
|
413
|
+
},
|
|
414
|
+
aggregate: (record) => {
|
|
415
|
+
for (const rules of Object.values(RULES)) {
|
|
416
|
+
const verdict = rules.aggregate?.(record) ?? null;
|
|
417
|
+
if (verdict !== null)
|
|
418
|
+
return verdict;
|
|
419
|
+
}
|
|
420
|
+
return null;
|
|
421
|
+
},
|
|
422
|
+
withheldKeys: (key) => Object.values(RULES).some((rules) => rules.withheldKeys?.(key) === true),
|
|
269
423
|
};
|
|
270
424
|
function unionOf(pick) {
|
|
271
425
|
return new Set(Object.values(RULES).flatMap((rules) => [...pick(rules)]));
|
|
@@ -312,6 +466,40 @@ export function rulesForTranscript(home, env, transcriptPath) {
|
|
|
312
466
|
return matches.length === 1 ? RULES[matches[0].redaction] : STRICTEST_RULES;
|
|
313
467
|
}
|
|
314
468
|
const NONE = { redacted: 0, skipped: 0 };
|
|
469
|
+
/**
|
|
470
|
+
* Keys that stay readable in an isolated record. Everything else that is a string is content. The list is of STRUCTURE (what a
|
|
471
|
+
* record is, who spoke, when, which tool by NAME) and not of anything a person typed or a tool printed; an unknown key is
|
|
472
|
+
* withheld, so the failure direction is a thinner record and never a leak. `cwd` stays: it is how the record's origin is told
|
|
473
|
+
* apart from the launch folder's, and it is a path, not a conversation (its residual is documented).
|
|
474
|
+
*/
|
|
475
|
+
const STRUCTURAL_KEYS = new Set([
|
|
476
|
+
"type",
|
|
477
|
+
"subtype",
|
|
478
|
+
"role",
|
|
479
|
+
"uuid",
|
|
480
|
+
"parentUuid",
|
|
481
|
+
"logicalParentUuid",
|
|
482
|
+
"sessionId",
|
|
483
|
+
"session_id",
|
|
484
|
+
"timestamp",
|
|
485
|
+
"version",
|
|
486
|
+
"userType",
|
|
487
|
+
"entrypoint",
|
|
488
|
+
"requestId",
|
|
489
|
+
"id",
|
|
490
|
+
"call_id",
|
|
491
|
+
"tool_use_id",
|
|
492
|
+
"toolUseID",
|
|
493
|
+
"name",
|
|
494
|
+
"model",
|
|
495
|
+
"status",
|
|
496
|
+
"stop_reason",
|
|
497
|
+
"stop_sequence",
|
|
498
|
+
"level",
|
|
499
|
+
"originator",
|
|
500
|
+
"media_type",
|
|
501
|
+
"cwd",
|
|
502
|
+
]);
|
|
315
503
|
/**
|
|
316
504
|
* Redact one NDJSON line. **Pure, total, and length-preserving.**
|
|
317
505
|
*
|
|
@@ -320,7 +508,7 @@ const NONE = { redacted: 0, skipped: 0 };
|
|
|
320
508
|
* (DESIGN.md §13.7), and a redactor that dropped a line it could not parse would
|
|
321
509
|
* be silent data loss on a path where D56 §D8 makes loss permanent.
|
|
322
510
|
*/
|
|
323
|
-
export function redactLine(line, projectRoots, rules, resolver = containment()) {
|
|
511
|
+
export function redactLine(line, projectRoots, rules, resolver = containment(), scope) {
|
|
324
512
|
if (line === "")
|
|
325
513
|
return { line, ...NONE };
|
|
326
514
|
let parsed;
|
|
@@ -332,6 +520,8 @@ export function redactLine(line, projectRoots, rules, resolver = containment())
|
|
|
332
520
|
// that is not JSON. Unchanged is the only honest answer.
|
|
333
521
|
return { line, ...NONE };
|
|
334
522
|
}
|
|
523
|
+
if (scope?.observe !== undefined && parsed !== null && typeof parsed === "object" && !Array.isArray(parsed))
|
|
524
|
+
scope.observe(parsed);
|
|
335
525
|
// The round-trip gate. Padding arithmetic is only exact if everything this
|
|
336
526
|
// module does NOT touch re-serialises to the same bytes.
|
|
337
527
|
let baseline;
|
|
@@ -341,10 +531,57 @@ export function redactLine(line, projectRoots, rules, resolver = containment())
|
|
|
341
531
|
catch {
|
|
342
532
|
return { line, ...NONE };
|
|
343
533
|
}
|
|
344
|
-
if (baseline !== line)
|
|
534
|
+
if (baseline !== line) {
|
|
535
|
+
// ⛔ A line that does not round-trip cannot be padded string by string, but it must not bypass redaction (C18 #4b, C20). It is
|
|
536
|
+
// withheld as a WHOLE line, length-preservingly, when it is another repo's record (R17), or when the structured-path rule
|
|
537
|
+
// (with a folder target's exclusion roots) would have redacted ANYTHING in it: an out-of-tree file's content, a path the target
|
|
538
|
+
// may not hold. A line with nothing to redact keeps today's answer: unchanged. (0.8.0 passed EVERY such line through; on one
|
|
539
|
+
// machine's corpus these are Codex `token_count` records whose `used_percent` is written `N.0`, and none named a path, but the
|
|
540
|
+
// next one could.)
|
|
541
|
+
if (mustWithholdWhole(parsed, projectRoots, rules, resolver, scope))
|
|
542
|
+
return { line: withheldLine(line), redacted: 1, skipped: 0 };
|
|
345
543
|
return { line, ...NONE };
|
|
346
|
-
|
|
347
|
-
|
|
544
|
+
}
|
|
545
|
+
const counts = { projectRoots, resolver, rules, scope, redacted: 0, skipped: 0, slots: null };
|
|
546
|
+
// C39 (iv) / C38 / C40 (2): a record the dialect withholds BY KIND (a digest of the conversation, a request blob, injected instructions) is
|
|
547
|
+
// withheld in every copy, by the same `isolate` a foreign record gets. Like a foreign record, a length mismatch below withholds the WHOLE
|
|
548
|
+
// line instead of sending the original. `strict` records that hold a data-shaped KEY (a skill name, a path) are withheld whole at once.
|
|
549
|
+
const isObject = parsed !== null && typeof parsed === "object" && !Array.isArray(parsed);
|
|
550
|
+
const always = isObject ? (rules.alwaysWithheld?.(parsed) ?? null) : null;
|
|
551
|
+
let withheldWhole = always !== null;
|
|
552
|
+
if (always !== null) {
|
|
553
|
+
if (always === "strict" && hasDataKeyInToolSubtree(parsed, true))
|
|
554
|
+
return { line: withheldLine(line), redacted: 1, skipped: 0 };
|
|
555
|
+
isolate(parsed, counts, false);
|
|
556
|
+
}
|
|
557
|
+
else if (isObject &&
|
|
558
|
+
((scope?.foreignRecord !== undefined && scope.foreignRecord(parsed)) ||
|
|
559
|
+
namesFileOutOfTree(parsed, projectRoots, resolver, scope))) {
|
|
560
|
+
withheldWhole = true;
|
|
561
|
+
// R17: the whole record is another work tree's. Nothing is left for the path rule to decide.
|
|
562
|
+
//
|
|
563
|
+
// ⛔ C26: a KEY inside a tool subtree can itself be content (a result keyed by file name or path, `{"customers/bigbank.pdf": {...}}`),
|
|
564
|
+
// and `isolate` withholds string VALUES, never keys. Such a record is withheld as a WHOLE line, the same length-preserving shape as
|
|
565
|
+
// C20's. On one machine's corpus: 827 of 1,229,349 records (716 path-like, 256 with a space), every one under `toolUseResult`.
|
|
566
|
+
if (hasDataKeyInToolSubtree(parsed))
|
|
567
|
+
return { line: withheldLine(line), redacted: 1, skipped: 0 };
|
|
568
|
+
isolate(parsed, counts, false);
|
|
569
|
+
}
|
|
570
|
+
else {
|
|
571
|
+
const aggregate = scope?.multi === true && isObject ? (rules.aggregate?.(parsed) ?? null) : null;
|
|
572
|
+
if (aggregate !== null) {
|
|
573
|
+
// C27: a record whose origin the record cannot tell, in a copy of a session that named another work tree: withheld (or, for a KEEP type,
|
|
574
|
+
// withheld outside its measured keys). A length mismatch below withholds the whole line.
|
|
575
|
+
withheldWhole = true;
|
|
576
|
+
if (aggregate.kind === "withhold")
|
|
577
|
+
isolate(parsed, counts, false);
|
|
578
|
+
else
|
|
579
|
+
withholdOutsideKeys(parsed, aggregate.paths, counts);
|
|
580
|
+
}
|
|
581
|
+
else {
|
|
582
|
+
scrubRecord(parsed, counts);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
348
585
|
if (counts.redacted === 0)
|
|
349
586
|
return { line, redacted: 0, skipped: counts.skipped };
|
|
350
587
|
let out;
|
|
@@ -358,10 +595,53 @@ export function redactLine(line, projectRoots, rules, resolver = containment())
|
|
|
358
595
|
// not, the accounting matters more than the redaction (see the header) and
|
|
359
596
|
// the caller gets the original bytes plus an honest skip count.
|
|
360
597
|
if (Buffer.byteLength(out) !== Buffer.byteLength(line)) {
|
|
598
|
+
// A record that is withheld by KIND (another work tree's, or a conversation digest) is withheld whole rather than sent as written.
|
|
599
|
+
if (withheldWhole)
|
|
600
|
+
return { line: withheldLine(line), redacted: 1, skipped: 0 };
|
|
361
601
|
return { line, redacted: 0, skipped: counts.redacted + counts.skipped };
|
|
362
602
|
}
|
|
363
603
|
return { line: out, redacted: counts.redacted, skipped: counts.skipped };
|
|
364
604
|
}
|
|
605
|
+
/** C48 (11): does the record's call name a file (see `RedactionScope.namedFiles`) that is outside this copy's tree or inside a hole? */
|
|
606
|
+
function namesFileOutOfTree(record, projectRoots, resolver, scope) {
|
|
607
|
+
const files = scope?.namedFiles?.(record);
|
|
608
|
+
return files !== undefined && files.some((file) => !resolver(projectRoots, file) || scope?.hole?.(file) === true);
|
|
609
|
+
}
|
|
610
|
+
function mustWithholdWhole(parsed, projectRoots, rules, resolver, scope) {
|
|
611
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
|
|
612
|
+
return false;
|
|
613
|
+
if (scope?.foreignRecord?.(parsed) === true)
|
|
614
|
+
return true;
|
|
615
|
+
if (namesFileOutOfTree(parsed, projectRoots, resolver, scope))
|
|
616
|
+
return true;
|
|
617
|
+
if ((rules.alwaysWithheld?.(parsed) ?? null) !== null)
|
|
618
|
+
return true;
|
|
619
|
+
if (scope?.multi === true && (rules.aggregate?.(parsed) ?? null) !== null)
|
|
620
|
+
return true;
|
|
621
|
+
// Would the path rule (with a folder target's exclusion roots, when there is one) have redacted anything here? Walk a COPY.
|
|
622
|
+
let copy;
|
|
623
|
+
try {
|
|
624
|
+
copy = JSON.parse(JSON.stringify(parsed));
|
|
625
|
+
}
|
|
626
|
+
catch {
|
|
627
|
+
return false;
|
|
628
|
+
}
|
|
629
|
+
const probe = { projectRoots, resolver, rules, scope, redacted: 0, skipped: 0, slots: null };
|
|
630
|
+
scrubRecord(copy, probe);
|
|
631
|
+
return probe.redacted > 0 || probe.skipped > 0;
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* A line the same BYTE length as `line` that withholds all of it and is still one valid JSON object: `{"_":"****"}` for 8+ bytes,
|
|
635
|
+
* `{}` plus spaces below that (JSON allows trailing whitespace). Pure ASCII, so its length is exactly its character count.
|
|
636
|
+
*/
|
|
637
|
+
function withheldLine(line) {
|
|
638
|
+
const length = Buffer.byteLength(line);
|
|
639
|
+
if (length >= 8)
|
|
640
|
+
return `{"_":"${"*".repeat(length - 8)}"}`;
|
|
641
|
+
if (length >= 2)
|
|
642
|
+
return `{}${" ".repeat(length - 2)}`;
|
|
643
|
+
return "*".repeat(length);
|
|
644
|
+
}
|
|
365
645
|
/**
|
|
366
646
|
* Redact every complete line in a raw transcript span.
|
|
367
647
|
*
|
|
@@ -371,7 +651,9 @@ export function redactLine(line, projectRoots, rules, resolver = containment())
|
|
|
371
651
|
* which is what keeps a span that bisects a multi-byte character from changing
|
|
372
652
|
* length when it is re-encoded.
|
|
373
653
|
*/
|
|
374
|
-
export function redactSpan(span, projectRoots, rules
|
|
654
|
+
export function redactSpan(span, projectRoots, rules, scope,
|
|
655
|
+
/** The absolute offset of `span[0]` in its file (`scope.at` is told each segment's). */
|
|
656
|
+
spanStart = 0) {
|
|
375
657
|
const resolver = containment();
|
|
376
658
|
const counts = { redacted: 0, skipped: 0 };
|
|
377
659
|
const NEWLINE = 0x0a;
|
|
@@ -381,7 +663,8 @@ export function redactSpan(span, projectRoots, rules) {
|
|
|
381
663
|
if (i !== span.length && span[i] !== NEWLINE)
|
|
382
664
|
continue;
|
|
383
665
|
const segment = span.subarray(start, i);
|
|
384
|
-
|
|
666
|
+
scope?.at?.(spanStart + start);
|
|
667
|
+
out.push(redactSegment(segment, projectRoots, rules, resolver, counts, scope));
|
|
385
668
|
if (i !== span.length)
|
|
386
669
|
out.push(span.subarray(i, i + 1));
|
|
387
670
|
start = i + 1;
|
|
@@ -394,13 +677,13 @@ export function redactSpan(span, projectRoots, rules) {
|
|
|
394
677
|
}
|
|
395
678
|
return { bytes, ...counts };
|
|
396
679
|
}
|
|
397
|
-
function redactSegment(segment, projectRoots, rules, resolver, counts) {
|
|
680
|
+
function redactSegment(segment, projectRoots, rules, resolver, counts, scope) {
|
|
398
681
|
const text = segment.toString("utf8");
|
|
399
682
|
// A lossy decode (the span bisected a character) would re-encode to a
|
|
400
683
|
// different length. Pass the raw bytes through instead.
|
|
401
684
|
if (Buffer.byteLength(text) !== segment.length)
|
|
402
685
|
return segment;
|
|
403
|
-
const result = redactLine(text, projectRoots, rules, resolver);
|
|
686
|
+
const result = redactLine(text, projectRoots, rules, resolver, scope);
|
|
404
687
|
counts.redacted += result.redacted;
|
|
405
688
|
counts.skipped += result.skipped;
|
|
406
689
|
if (result.line === text)
|
|
@@ -408,6 +691,178 @@ function redactSegment(segment, projectRoots, rules, resolver, counts) {
|
|
|
408
691
|
const replaced = Buffer.from(result.line, "utf8");
|
|
409
692
|
return replaced.length === segment.length ? replaced : segment;
|
|
410
693
|
}
|
|
694
|
+
/**
|
|
695
|
+
* C27: withhold every string in `node` whose KEY PATH is not in `allowed`. A path is the object keys from the root joined by `.`, with a key that is
|
|
696
|
+
* not an identifier (a model id under `modelUsage`) written `<datakey>`; arrays add nothing to the path. A KEEP type is bound to the paths measured
|
|
697
|
+
* for it, so a string under a key it does not have today is withheld, whatever the key is called.
|
|
698
|
+
*/
|
|
699
|
+
function withholdOutsideKeys(node, allowed, ctx, path = "") {
|
|
700
|
+
if (Array.isArray(node)) {
|
|
701
|
+
for (let i = 0; i < node.length; i += 1) {
|
|
702
|
+
const item = node[i];
|
|
703
|
+
if (typeof item === "string") {
|
|
704
|
+
if (!allowed.has(path))
|
|
705
|
+
withholdLeaf(node, i, ctx);
|
|
706
|
+
}
|
|
707
|
+
else {
|
|
708
|
+
withholdOutsideKeys(item, allowed, ctx, path);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
if (node === null || typeof node !== "object")
|
|
714
|
+
return;
|
|
715
|
+
const record = node;
|
|
716
|
+
for (const key of Object.keys(record)) {
|
|
717
|
+
const here = path === "" ? (SCHEMA_KEY.test(key) ? key : "<datakey>") : `${path}.${SCHEMA_KEY.test(key) ? key : "<datakey>"}`;
|
|
718
|
+
const value = record[key];
|
|
719
|
+
if (typeof value === "string") {
|
|
720
|
+
if (!allowed.has(here))
|
|
721
|
+
withholdLeaf(record, key, ctx);
|
|
722
|
+
}
|
|
723
|
+
else {
|
|
724
|
+
withholdOutsideKeys(value, allowed, ctx, here);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
/**
|
|
729
|
+
* The path rule over one whole record: `walk` (the object-local rule), then C39 (i), the record-level sibling rule.
|
|
730
|
+
*
|
|
731
|
+
* ⛔ C39 (i): Claude records a Read twice in ONE record: the structured copy (`toolUseResult.file {filePath, content}`) that the path rule
|
|
732
|
+
* judges, and the text the model was shown (`message.content[{type: "tool_result", content}]`, line-numbered), which has no path beside it
|
|
733
|
+
* and was sent as written (a HIGH since 0.8.0; the same shape for Write and Edit results). When the record's `toolUseResult`, or a
|
|
734
|
+
* `tool_use` input in its `message.content`, names a file outside the tree, EVERY `tool_result` block of that record is withheld, whatever
|
|
735
|
+
* its encoding (a string, or an array of text items; an image's data too), length-preservingly. A result that names no file at all (a
|
|
736
|
+
* shell's stdout, a hook's output, a Grep over a folder, a fetch) has nothing to judge by and stays as written: a stated residual.
|
|
737
|
+
*/
|
|
738
|
+
function scrubRecord(node, ctx) {
|
|
739
|
+
walk(node, ctx);
|
|
740
|
+
if (node === null || typeof node !== "object" || Array.isArray(node))
|
|
741
|
+
return;
|
|
742
|
+
const record = node;
|
|
743
|
+
const message = record.message;
|
|
744
|
+
if (message === null || typeof message !== "object")
|
|
745
|
+
return;
|
|
746
|
+
const blocks = message.content;
|
|
747
|
+
if (!Array.isArray(blocks))
|
|
748
|
+
return;
|
|
749
|
+
const results = blocks.filter((b) => b !== null && typeof b === "object" && b.type === "tool_result");
|
|
750
|
+
if (results.length === 0)
|
|
751
|
+
return;
|
|
752
|
+
const named = namesOutOfTree(record.toolUseResult, ctx) ||
|
|
753
|
+
blocks.some((b) => b !== null && typeof b === "object" && b.type === "tool_use" && namesOutOfTree(b.input, ctx));
|
|
754
|
+
if (named) {
|
|
755
|
+
for (const block of results)
|
|
756
|
+
withholdResultBlock(block, ctx);
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
// C40 (1): a result whose file is named only in the CALL (an image Read has no path in its result). The call's paths were recorded when the
|
|
760
|
+
// transcript was scanned; they are judged HERE, per copy, with this copy's roots and holes. A call that is unknown fails closed.
|
|
761
|
+
const lookup = ctx.scope?.calls;
|
|
762
|
+
if (lookup === undefined)
|
|
763
|
+
return;
|
|
764
|
+
let condemned = false;
|
|
765
|
+
for (const block of results) {
|
|
766
|
+
const id = block.tool_use_id;
|
|
767
|
+
if (typeof id !== "string")
|
|
768
|
+
continue;
|
|
769
|
+
const verdict = lookup(id);
|
|
770
|
+
const out = verdict === "unknown" || (verdict !== "none" && verdict.some((path) => !ctx.resolver(ctx.projectRoots, path) || ctx.scope?.hole?.(path) === true));
|
|
771
|
+
if (!out)
|
|
772
|
+
continue;
|
|
773
|
+
condemned = true;
|
|
774
|
+
withholdResultBlock(block, ctx);
|
|
775
|
+
}
|
|
776
|
+
// The record's structured copy of the same result (an image Read's `toolUseResult.file.base64`) goes with it; the selector paths and `type` stay.
|
|
777
|
+
if (condemned && record.toolUseResult !== null && typeof record.toolUseResult === "object")
|
|
778
|
+
withholdResultLeavesExcept(record.toolUseResult, ctx, ctx.rules.pathKeys);
|
|
779
|
+
}
|
|
780
|
+
/** `withholdResultLeaves`, keeping the strings under `kept` keys too (the selector paths of a result). */
|
|
781
|
+
function withholdResultLeavesExcept(node, ctx, kept) {
|
|
782
|
+
if (Array.isArray(node)) {
|
|
783
|
+
for (let i = 0; i < node.length; i += 1) {
|
|
784
|
+
const item = node[i];
|
|
785
|
+
if (typeof item === "string")
|
|
786
|
+
withholdLeaf(node, i, ctx);
|
|
787
|
+
else
|
|
788
|
+
withholdResultLeavesExcept(item, ctx, kept);
|
|
789
|
+
}
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
if (node === null || typeof node !== "object")
|
|
793
|
+
return;
|
|
794
|
+
const record = node;
|
|
795
|
+
for (const key of Object.keys(record)) {
|
|
796
|
+
const value = record[key];
|
|
797
|
+
if (typeof value === "string") {
|
|
798
|
+
if (!RESULT_KEPT_KEYS.has(key) && !kept.has(key))
|
|
799
|
+
withholdLeaf(record, key, ctx);
|
|
800
|
+
}
|
|
801
|
+
else {
|
|
802
|
+
withholdResultLeavesExcept(value, ctx, kept);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
/**
|
|
807
|
+
* Replace `container[key]` (a string leaf) with its withheld form and REGISTER A SLOT for it (C41), so a nested document's `pad` can lengthen
|
|
808
|
+
* it when the encoded form needs a few more bytes. A replacement that registers no slot is one `pad` cannot balance.
|
|
809
|
+
*/
|
|
810
|
+
function withholdLeaf(container, key, ctx) {
|
|
811
|
+
const original = container[key];
|
|
812
|
+
if (typeof original !== "string")
|
|
813
|
+
return;
|
|
814
|
+
const withheld = withholdString(original);
|
|
815
|
+
if (withheld === null)
|
|
816
|
+
return;
|
|
817
|
+
container[key] = withheld;
|
|
818
|
+
ctx.redacted += 1;
|
|
819
|
+
ctx.slots?.push({ record: container, key: String(key) });
|
|
820
|
+
}
|
|
821
|
+
/** Keys inside a `tool_result` block that stay: what KIND of block this is, never what it holds. */
|
|
822
|
+
const RESULT_KEPT_KEYS = new Set(["type", "media_type"]);
|
|
823
|
+
function withholdResultBlock(block, ctx) {
|
|
824
|
+
const content = block.content;
|
|
825
|
+
if (typeof content === "string") {
|
|
826
|
+
withholdLeaf(block, "content", ctx);
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
withholdResultLeaves(content, ctx);
|
|
830
|
+
}
|
|
831
|
+
function withholdResultLeaves(node, ctx) {
|
|
832
|
+
if (Array.isArray(node)) {
|
|
833
|
+
for (let i = 0; i < node.length; i += 1) {
|
|
834
|
+
const item = node[i];
|
|
835
|
+
if (typeof item === "string")
|
|
836
|
+
withholdLeaf(node, i, ctx);
|
|
837
|
+
else
|
|
838
|
+
withholdResultLeaves(item, ctx);
|
|
839
|
+
}
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
if (node === null || typeof node !== "object")
|
|
843
|
+
return;
|
|
844
|
+
const record = node;
|
|
845
|
+
for (const key of Object.keys(record)) {
|
|
846
|
+
const value = record[key];
|
|
847
|
+
if (typeof value === "string") {
|
|
848
|
+
if (RESULT_KEPT_KEYS.has(key))
|
|
849
|
+
continue;
|
|
850
|
+
withholdLeaf(record, key, ctx);
|
|
851
|
+
}
|
|
852
|
+
else {
|
|
853
|
+
withholdResultLeaves(value, ctx);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
/** Withhold every string under `record[key]` (the value itself when it is a string), length-preserving. */
|
|
858
|
+
function withholdUnder(record, key, ctx) {
|
|
859
|
+
const value = record[key];
|
|
860
|
+
if (typeof value === "string") {
|
|
861
|
+
withholdLeaf(record, key, ctx);
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
withholdResultLeaves(value, ctx);
|
|
865
|
+
}
|
|
411
866
|
/**
|
|
412
867
|
* Walk the record, redacting content that sits beside an out-of-tree path.
|
|
413
868
|
*
|
|
@@ -421,28 +876,54 @@ function redactSegment(segment, projectRoots, rules, resolver, counts) {
|
|
|
421
876
|
*/
|
|
422
877
|
function walk(node, ctx,
|
|
423
878
|
/** True once an ancestor named a file outside the tree. */
|
|
424
|
-
inherited = false
|
|
879
|
+
inherited = false,
|
|
880
|
+
/** C37: True inside a record's `attachment` subtree, where `path`/`filename` and `rawContent`/`snippet` join the key sets. */
|
|
881
|
+
inAttachment = false) {
|
|
425
882
|
if (Array.isArray(node)) {
|
|
426
883
|
for (const item of node)
|
|
427
|
-
walk(item, ctx, inherited);
|
|
884
|
+
walk(item, ctx, inherited, inAttachment);
|
|
428
885
|
return;
|
|
429
886
|
}
|
|
430
887
|
if (node === null || typeof node !== "object")
|
|
431
888
|
return;
|
|
432
889
|
const record = node;
|
|
890
|
+
const extra = inAttachment ? ctx.rules.attachment : undefined;
|
|
433
891
|
// A nested object that names its OWN file decides for its own subtree: an
|
|
434
892
|
// in-tree read quoted inside an out-of-tree record is still the user's own
|
|
435
893
|
// content, and the reverse is still somebody else's.
|
|
436
|
-
const named = namesAFile(record, ctx.rules.pathKeys);
|
|
437
|
-
const outOfTree = named === null ? inherited : !ctx.resolver(ctx.projectRoots, named);
|
|
894
|
+
const named = namesAFile(record, ctx.rules.pathKeys) ?? (extra === undefined ? null : namesAFile(record, extra.pathKeys));
|
|
895
|
+
const outOfTree = named === null ? inherited : !ctx.resolver(ctx.projectRoots, named) || ctx.scope?.hole?.(named) === true;
|
|
438
896
|
for (const key of Object.keys(record)) {
|
|
439
897
|
const value = record[key];
|
|
440
|
-
|
|
898
|
+
// C38 (b): a `skills[]` item with NO path of its own (nothing to judge its content by) is withheld in EVERY copy, failing closed.
|
|
899
|
+
if (inAttachment && key === "skills" && Array.isArray(value))
|
|
900
|
+
withholdPathlessSkills(value, ctx);
|
|
901
|
+
if (ctx.rules.withheldKeys?.(key) === true) {
|
|
902
|
+
// C40 (2): injected instruction text, withheld whether or not a path is in scope.
|
|
903
|
+
withholdUnder(record, key, ctx);
|
|
904
|
+
}
|
|
905
|
+
else if (outOfTree && (ctx.rules.contentKeys.has(key) || extra?.contentKeys.has(key) === true) && typeof value === "string") {
|
|
441
906
|
replaceString(record, key, value, ctx);
|
|
442
907
|
}
|
|
908
|
+
else if (extra?.contentKeys.has(key) === true && value !== null && typeof value === "object") {
|
|
909
|
+
// C41 (3) / C45 (1): an attachment's content key can hold ANY shape (a list of strings, a list of `{type, text}` blocks, nested lists), and an object
|
|
910
|
+
// inside it can name a file of its own. Judged RECURSIVELY by the nearest file-naming object, whatever the leaf keys are called: under an out-of-tree
|
|
911
|
+
// verdict every string leaf is withheld; under an in-tree one they are kept; an object naming a file replaces the verdict for its subtree.
|
|
912
|
+
withholdUnlessOwnFile(value, ctx, outOfTree);
|
|
913
|
+
}
|
|
443
914
|
else if (outOfTree && ctx.rules.patchKeys.has(key)) {
|
|
444
915
|
redactPatch(value, ctx);
|
|
445
916
|
}
|
|
917
|
+
else if (key === WIRE_KEY && value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
918
|
+
// C39 (ii): `wireToolInputs` is `{<tool_use id>: {file_path, content}}`, a second copy of every tool input. An entry that is an
|
|
919
|
+
// object is reached by the object-local rule below; an entry that is a JSON STRING is opened here, like Codex's `arguments`.
|
|
920
|
+
const entries = value;
|
|
921
|
+
for (const id of Object.keys(entries)) {
|
|
922
|
+
const entry = entries[id];
|
|
923
|
+
if (typeof entry === "string")
|
|
924
|
+
redactNested(entries, id, entry, ctx, outOfTree);
|
|
925
|
+
}
|
|
926
|
+
}
|
|
446
927
|
else if (ctx.rules.nestedJsonKeys.has(key) && typeof value === "string") {
|
|
447
928
|
// ⛔ NOT gated on `outOfTree`. The path key that decides a Codex exec
|
|
448
929
|
// (`workdir`) lives INSIDE the nested document, so the outer object has
|
|
@@ -456,10 +937,119 @@ inherited = false) {
|
|
|
456
937
|
// new_string}]` sit one level BELOW the object carrying `file_path`, so a rule
|
|
457
938
|
// that only redacted an object's own keys walked straight past a third party's
|
|
458
939
|
// file content. Verified leaking before the change and redacted after.
|
|
459
|
-
|
|
460
|
-
|
|
940
|
+
// C37: every attachment record carries `rendered: [{content}]`, the text the model was shown for it, which REPEATS the attachment's file
|
|
941
|
+
// content (an `instructions` record's `rendered` holds the whole CLAUDE.md). `rendered` is a sibling of `attachment`, not inside it, so
|
|
942
|
+
// the verdict of the file(s) the attachment names must reach it explicitly.
|
|
943
|
+
const renderedOut = !inAttachment && ctx.rules.attachment !== undefined && typeof record.attachment === "object" && record.attachment !== null
|
|
944
|
+
? attachmentNamesOutOfTree(record.attachment, ctx)
|
|
945
|
+
: false;
|
|
946
|
+
for (const [key, value] of Object.entries(record)) {
|
|
947
|
+
if (key === "rendered" && !inAttachment && ctx.rules.attachment !== undefined && typeof record.attachment === "object" && record.attachment !== null) {
|
|
948
|
+
// C41 / P2's (3b) / C45 (1): `rendered` repeats the attachment's content, whatever its shape (`[{content}]` in every real record, `[{type, text}]`
|
|
949
|
+
// possible). Under an out-of-tree attachment verdict every string leaf is withheld except `type`/`media_type`; under an in-tree one they are kept; in
|
|
950
|
+
// both, a nested object that names its own file decides for its subtree.
|
|
951
|
+
withholdValueUnlessOwnFile(record, key, ctx, renderedOut);
|
|
952
|
+
// C46: IN ADDITION to the normal walk, never instead of it. The verdict above withholds string leaves; it does not open an encoded carrier
|
|
953
|
+
// (`wireToolInputs`, `arguments`) nested inside `rendered`, which the walk decodes and redacts. Walking over markers already written is idempotent.
|
|
954
|
+
// inAttachment=true (C47): `rendered` repeats the attachment, so its objects name their files by the attachment's selectors too (path, filename, planFilePath, targetFile).
|
|
955
|
+
walk(record[key], ctx, outOfTree || renderedOut, true);
|
|
956
|
+
continue;
|
|
957
|
+
}
|
|
958
|
+
walk(value, ctx, outOfTree, inAttachment || (ctx.rules.attachment !== undefined && key === "attachment"));
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
/** C38 (b): the content of a `skills[]` item that names no file of its own, withheld whatever copy it is in. */
|
|
962
|
+
function withholdPathlessSkills(items, ctx) {
|
|
963
|
+
const contentKeys = new Set([...ctx.rules.contentKeys, ...(ctx.rules.attachment?.contentKeys ?? [])]);
|
|
964
|
+
for (const item of items) {
|
|
965
|
+
if (item === null || typeof item !== "object" || Array.isArray(item))
|
|
966
|
+
continue;
|
|
967
|
+
const skill = item;
|
|
968
|
+
if ((namesAFile(skill, ctx.rules.pathKeys) ?? namesAFile(skill, ctx.rules.attachment?.pathKeys ?? new Set())) !== null)
|
|
969
|
+
continue;
|
|
970
|
+
for (const key of Object.keys(skill)) {
|
|
971
|
+
if (!contentKeys.has(key))
|
|
972
|
+
continue;
|
|
973
|
+
const value = skill[key];
|
|
974
|
+
if (typeof value === "string")
|
|
975
|
+
withholdLeaf(skill, key, ctx);
|
|
976
|
+
else
|
|
977
|
+
withholdResultLeaves(value, ctx);
|
|
978
|
+
}
|
|
461
979
|
}
|
|
462
980
|
}
|
|
981
|
+
/** `withholdUnlessOwnFile` for `container[key]`, which may itself be a bare string (`rendered: "S"`): that is withheld too (C44 (1)). */
|
|
982
|
+
function withholdValueUnlessOwnFile(container, key, ctx, outOfTree = true) {
|
|
983
|
+
if (typeof container[key] === "string") {
|
|
984
|
+
if (outOfTree)
|
|
985
|
+
withholdLeaf(container, key, ctx);
|
|
986
|
+
}
|
|
987
|
+
else {
|
|
988
|
+
withholdUnlessOwnFile(container[key], ctx, outOfTree);
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
/** Is the file an object names inside the tree, and not in a hole? Only then does the object decide for itself (C44 (1)). */
|
|
992
|
+
function ownFileInTree(own, ctx) {
|
|
993
|
+
return ctx.resolver(ctx.projectRoots, own) && ctx.scope?.hole?.(own) !== true;
|
|
994
|
+
}
|
|
995
|
+
/**
|
|
996
|
+
* Withhold the string leaves under `node` that belong to somebody else's file, except under `type`/`media_type`. The verdict is INHERITED down the subtree
|
|
997
|
+
* (`outOfTree`, true at the call sites, which run under an out-of-tree verdict) and an object that names a file REPLACES it with its own: an object naming
|
|
998
|
+
* an IN-TREE file is the user's own (its strings stay) and an object naming an OUT-OF-TREE file has every leaf withheld, its path selectors excepted.
|
|
999
|
+
* Neither is an exemption for what is nested inside it (C45 (1)): a child object that names its own file decides for itself, so an out-of-tree file nested
|
|
1000
|
+
* in an in-tree object is withheld and an in-tree one nested in an out-of-tree object is kept.
|
|
1001
|
+
*/
|
|
1002
|
+
function withholdUnlessOwnFile(node, ctx, outOfTree = true) {
|
|
1003
|
+
if (Array.isArray(node)) {
|
|
1004
|
+
for (let i = 0; i < node.length; i += 1) {
|
|
1005
|
+
const item = node[i];
|
|
1006
|
+
if (typeof item === "string") {
|
|
1007
|
+
if (outOfTree)
|
|
1008
|
+
withholdLeaf(node, i, ctx);
|
|
1009
|
+
}
|
|
1010
|
+
else {
|
|
1011
|
+
withholdUnlessOwnFile(item, ctx, outOfTree);
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
if (node === null || typeof node !== "object")
|
|
1017
|
+
return;
|
|
1018
|
+
const record = node;
|
|
1019
|
+
const own = namesAFile(record, ctx.rules.pathKeys) ?? namesAFile(record, ctx.rules.attachment?.pathKeys ?? new Set());
|
|
1020
|
+
const here = own === null ? outOfTree : !ownFileInTree(own, ctx);
|
|
1021
|
+
const attachmentKeys = ctx.rules.attachment?.pathKeys;
|
|
1022
|
+
for (const key of Object.keys(record)) {
|
|
1023
|
+
const value = record[key];
|
|
1024
|
+
if (typeof value === "string") {
|
|
1025
|
+
const selector = own !== null && (ctx.rules.pathKeys.has(key) || attachmentKeys?.has(key) === true);
|
|
1026
|
+
if (here && !RESULT_KEPT_KEYS.has(key) && !selector)
|
|
1027
|
+
withholdLeaf(record, key, ctx);
|
|
1028
|
+
}
|
|
1029
|
+
else {
|
|
1030
|
+
withholdUnlessOwnFile(value, ctx, here);
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
/** Does any object in this attachment subtree name a file (by `path`, `filename`, `filePath`, ...) that is out of tree? Pure: it redacts nothing. */
|
|
1035
|
+
function attachmentNamesOutOfTree(node, ctx) {
|
|
1036
|
+
return namesOutOfTree(node, ctx, ctx.rules.attachment?.pathKeys);
|
|
1037
|
+
}
|
|
1038
|
+
/**
|
|
1039
|
+
* Does any object in this subtree name a file outside the tree, by a path key of the profile (and `extraKeys`)? Pure: it redacts nothing.
|
|
1040
|
+
* C27: NO depth cap (it failed OPEN-then-closed: past 12 levels it condemned a deeply nested in-tree MCP result for nothing). `walk` has none and parsed JSON is acyclic.
|
|
1041
|
+
*/
|
|
1042
|
+
function namesOutOfTree(node, ctx, extraKeys) {
|
|
1043
|
+
if (Array.isArray(node))
|
|
1044
|
+
return node.some((item) => namesOutOfTree(item, ctx, extraKeys));
|
|
1045
|
+
if (node === null || typeof node !== "object")
|
|
1046
|
+
return false;
|
|
1047
|
+
const record = node;
|
|
1048
|
+
const named = namesAFile(record, ctx.rules.pathKeys) ?? (extraKeys === undefined ? null : namesAFile(record, extraKeys));
|
|
1049
|
+
if (named !== null && (!ctx.resolver(ctx.projectRoots, named) || ctx.scope?.hole?.(named) === true))
|
|
1050
|
+
return true;
|
|
1051
|
+
return Object.values(record).some((value) => namesOutOfTree(value, ctx, extraKeys));
|
|
1052
|
+
}
|
|
463
1053
|
/**
|
|
464
1054
|
* Descend into a key whose STRING value is itself a JSON document — `CR-185`.
|
|
465
1055
|
*
|
|
@@ -504,34 +1094,34 @@ function redactNested(record, key, value, ctx, inherited) {
|
|
|
504
1094
|
}
|
|
505
1095
|
if (inner === null || typeof inner !== "object")
|
|
506
1096
|
return;
|
|
507
|
-
// The INNER round-trip gate, for the reason the outer one exists: the
|
|
508
|
-
//
|
|
509
|
-
//
|
|
510
|
-
let
|
|
1097
|
+
// The INNER round-trip gate, for the reason the outer one exists: the arithmetic below is only exact if everything this function does not
|
|
1098
|
+
// touch re-serialises to the same bytes. ⛔ C41 (1): a document that fails it is NOT left alone: it is judged first (below), and when it
|
|
1099
|
+
// needs redaction the WHOLE encoded string is withheld.
|
|
1100
|
+
let roundTrips = true;
|
|
511
1101
|
try {
|
|
512
|
-
|
|
1102
|
+
roundTrips = JSON.stringify(inner) === value;
|
|
513
1103
|
}
|
|
514
1104
|
catch {
|
|
515
|
-
|
|
1105
|
+
roundTrips = false;
|
|
516
1106
|
}
|
|
517
|
-
if (baseline !== value)
|
|
518
|
-
return;
|
|
519
1107
|
const target = Buffer.byteLength(JSON.stringify(value));
|
|
520
1108
|
const slots = [];
|
|
521
1109
|
const nested = { ...ctx, redacted: 0, skipped: 0, slots };
|
|
522
1110
|
walk(inner, nested, inherited);
|
|
523
|
-
|
|
524
|
-
if (nested.redacted === 0)
|
|
1111
|
+
// Nothing in it needs redaction: it ships as written (a long encoded document with no out-of-tree content is the common case).
|
|
1112
|
+
if (nested.redacted === 0 && nested.skipped === 0)
|
|
525
1113
|
return;
|
|
526
|
-
|
|
1114
|
+
// ⛔ C41 (1)(2), fail closed: the document NEEDS redaction, and either it does not round-trip (whitespace, key spacing) or `pad` cannot
|
|
1115
|
+
// balance it. The original is NEVER put back: the whole encoded string becomes one withheld value, which keeps the outer length and the
|
|
1116
|
+
// outer JSON valid. (A record with an out-of-tree `workdir` in non-canonical arguments used to ship as written.)
|
|
1117
|
+
const out = roundTrips ? pad(inner, slots, target) : null;
|
|
527
1118
|
if (out === null) {
|
|
528
|
-
|
|
529
|
-
// bytes are leaving unredacted and the number has to be reportable.
|
|
530
|
-
ctx.skipped += nested.redacted;
|
|
1119
|
+
withholdLeaf(record, key, ctx);
|
|
531
1120
|
return;
|
|
532
1121
|
}
|
|
533
1122
|
record[key] = out;
|
|
534
1123
|
ctx.redacted += nested.redacted;
|
|
1124
|
+
ctx.skipped += nested.skipped;
|
|
535
1125
|
}
|
|
536
1126
|
/**
|
|
537
1127
|
* Re-encode the nested document at exactly `target` outer-encoded bytes.
|
|
@@ -582,6 +1172,94 @@ function widen(marker, extra) {
|
|
|
582
1172
|
? `${marker.slice(0, -REDACTION.tail.length)}${fill}${REDACTION.tail}`
|
|
583
1173
|
: `${marker}${fill}`;
|
|
584
1174
|
}
|
|
1175
|
+
/**
|
|
1176
|
+
* Keys whose subtree is a TOOL's arguments or a tool's result: what a command was given and what it printed. ⛔ **No key is
|
|
1177
|
+
* structural in here** (P1, A-T4 review): an MCP call from repo A such as `create_repository {name: "acme-secret-merger"}`, or a
|
|
1178
|
+
* todo list's `id` and `status`, is content of the work done there even though `name` and `id` are scaffolding on the record
|
|
1179
|
+
* itself. A tool_result's own `content` is the same kind of subtree.
|
|
1180
|
+
*/
|
|
1181
|
+
const TOOL_SUBTREE_KEYS = new Set(["input", "arguments", "toolUseResult", "tool_use_result"]);
|
|
1182
|
+
/**
|
|
1183
|
+
* A schema key is an identifier: letters, digits and underscores, starting with a letter or underscore, at most 64. Anything else as a
|
|
1184
|
+
* KEY inside a tool subtree (a path, a file name, a URL, an id, a flag such as `-n`, a name with a space or a hyphen) is data wearing a
|
|
1185
|
+
* key's clothes (C26). Deliberately the narrow side: measured against the wider "looks like a path, file name, URL or id" test, it adds
|
|
1186
|
+
* two keys in 1.2 million records (`-n`), and a thinner record is the failure direction this module chooses.
|
|
1187
|
+
*/
|
|
1188
|
+
const SCHEMA_KEY = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
|
|
1189
|
+
/** Does any key inside a tool-arguments or tool-result subtree of this record fail `SCHEMA_KEY`? Same subtree test as `isolate`. */
|
|
1190
|
+
function hasDataKeyInToolSubtree(node, inTool = false) {
|
|
1191
|
+
if (Array.isArray(node))
|
|
1192
|
+
return node.some((item) => hasDataKeyInToolSubtree(item, inTool));
|
|
1193
|
+
if (node === null || typeof node !== "object")
|
|
1194
|
+
return false;
|
|
1195
|
+
const record = node;
|
|
1196
|
+
const isToolResult = record.type === "tool_result";
|
|
1197
|
+
for (const key of Object.keys(record)) {
|
|
1198
|
+
if (inTool && !SCHEMA_KEY.test(key))
|
|
1199
|
+
return true;
|
|
1200
|
+
const childInTool = inTool || TOOL_SUBTREE_KEYS.has(key) || (isToolResult && key === "content");
|
|
1201
|
+
if (hasDataKeyInToolSubtree(record[key], childInTool))
|
|
1202
|
+
return true;
|
|
1203
|
+
}
|
|
1204
|
+
return false;
|
|
1205
|
+
}
|
|
1206
|
+
/**
|
|
1207
|
+
* R17: withhold every content-bearing string of a record that ran inside another work tree. On the record's OWN scaffolding,
|
|
1208
|
+
* strings under a STRUCTURAL key stay; everything else, and EVERYTHING inside a tool-arguments or tool-result subtree, becomes a
|
|
1209
|
+
* length-preserving marker; a string too short for a marker becomes a same-length run of `*` (`withholdString`), never kept.
|
|
1210
|
+
*/
|
|
1211
|
+
function isolate(node, ctx, keptKey, inTool = false) {
|
|
1212
|
+
if (Array.isArray(node)) {
|
|
1213
|
+
for (let i = 0; i < node.length; i += 1) {
|
|
1214
|
+
const item = node[i];
|
|
1215
|
+
if (typeof item === "string") {
|
|
1216
|
+
if (keptKey && !inTool)
|
|
1217
|
+
continue;
|
|
1218
|
+
const withheld = withholdString(item);
|
|
1219
|
+
if (withheld === null)
|
|
1220
|
+
continue;
|
|
1221
|
+
node[i] = withheld;
|
|
1222
|
+
ctx.redacted += 1;
|
|
1223
|
+
}
|
|
1224
|
+
else {
|
|
1225
|
+
isolate(item, ctx, keptKey, inTool);
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
return;
|
|
1229
|
+
}
|
|
1230
|
+
if (node === null || typeof node !== "object")
|
|
1231
|
+
return;
|
|
1232
|
+
const record = node;
|
|
1233
|
+
const isToolResult = record.type === "tool_result";
|
|
1234
|
+
for (const key of Object.keys(record)) {
|
|
1235
|
+
const value = record[key];
|
|
1236
|
+
const childInTool = inTool || TOOL_SUBTREE_KEYS.has(key) || (isToolResult && key === "content");
|
|
1237
|
+
if (typeof value === "string") {
|
|
1238
|
+
if (inTool || !STRUCTURAL_KEYS.has(key)) {
|
|
1239
|
+
const withheld = withholdString(value);
|
|
1240
|
+
if (withheld !== null) {
|
|
1241
|
+
record[key] = withheld;
|
|
1242
|
+
ctx.redacted += 1;
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
else {
|
|
1247
|
+
isolate(value, ctx, !childInTool && STRUCTURAL_KEYS.has(key), childInTool);
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
/**
|
|
1252
|
+
* What an ISOLATED record's string becomes: the usual length-preserving marker, or, when the string is too short to hold even the
|
|
1253
|
+
* smallest honest marker, a same-length run of `*` (C18 #4a). A foreign record is withheld, not sampled: a `"password":"secret"` too
|
|
1254
|
+
* short for a marker is still another repo's content. `null` only for the empty string, which has nothing to hide.
|
|
1255
|
+
*/
|
|
1256
|
+
function withholdString(value) {
|
|
1257
|
+
const marker = markerFor(value);
|
|
1258
|
+
if (marker !== null)
|
|
1259
|
+
return marker;
|
|
1260
|
+
const target = Buffer.byteLength(JSON.stringify(value)) - 2;
|
|
1261
|
+
return target > 0 ? "*".repeat(target) : null;
|
|
1262
|
+
}
|
|
585
1263
|
/** The path this object names, or null if it names none. */
|
|
586
1264
|
function namesAFile(record, pathKeys) {
|
|
587
1265
|
for (const key of Object.keys(record)) {
|
|
@@ -604,28 +1282,16 @@ function redactPatch(patch, ctx) {
|
|
|
604
1282
|
if (!Array.isArray(lines))
|
|
605
1283
|
continue;
|
|
606
1284
|
for (let i = 0; i < lines.length; i += 1) {
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
continue;
|
|
610
|
-
const marker = markerFor(value);
|
|
611
|
-
if (marker === null) {
|
|
612
|
-
ctx.skipped += 1;
|
|
613
|
-
continue;
|
|
614
|
-
}
|
|
615
|
-
lines[i] = marker;
|
|
616
|
-
ctx.redacted += 1;
|
|
1285
|
+
if (typeof lines[i] === "string")
|
|
1286
|
+
withholdLeaf(lines, i, ctx);
|
|
617
1287
|
}
|
|
618
1288
|
}
|
|
619
1289
|
}
|
|
620
1290
|
function replaceString(record, key, value, ctx) {
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
}
|
|
626
|
-
record[key] = marker;
|
|
627
|
-
ctx.redacted += 1;
|
|
628
|
-
ctx.slots?.push({ record, key });
|
|
1291
|
+
// C39 (b): a value too short for the 13-byte marker becomes a same-length run of `*` (`withholdString`), never kept. 0.8.0 kept it and
|
|
1292
|
+
// counted it as skipped; `isolate` already withheld such a value in another repo's record, so the trade is no longer needed.
|
|
1293
|
+
void value;
|
|
1294
|
+
withholdLeaf(record, key, ctx);
|
|
629
1295
|
}
|
|
630
1296
|
/**
|
|
631
1297
|
* A marker whose JSON-ENCODED byte length is exactly the original's.
|