@vibe-commit/capture 0.8.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.
Files changed (101) hide show
  1. package/README.md +124 -0
  2. package/dist/agents/claude_code.js +13 -0
  3. package/dist/agents/claude_code.js.map +1 -1
  4. package/dist/agents/codex_cli.js +11 -0
  5. package/dist/agents/codex_cli.js.map +1 -1
  6. package/dist/agents/cursor.js +7 -1
  7. package/dist/agents/cursor.js.map +1 -1
  8. package/dist/agents/registry.d.ts +7 -0
  9. package/dist/agents/registry.js +9 -0
  10. package/dist/agents/registry.js.map +1 -1
  11. package/dist/agents/types.d.ts +32 -0
  12. package/dist/agents/types.js.map +1 -1
  13. package/dist/bounded_id.d.ts +29 -0
  14. package/dist/bounded_id.js +42 -0
  15. package/dist/bounded_id.js.map +1 -0
  16. package/dist/codex_folders.d.ts +340 -0
  17. package/dist/codex_folders.js +1448 -0
  18. package/dist/codex_folders.js.map +1 -0
  19. package/dist/commands/backfill.d.ts +29 -7
  20. package/dist/commands/backfill.js +387 -109
  21. package/dist/commands/backfill.js.map +1 -1
  22. package/dist/commands/connect.d.ts +1 -1
  23. package/dist/commands/connect.js +1 -1
  24. package/dist/commands/connect.js.map +1 -1
  25. package/dist/commands/connect_picker.js +48 -7
  26. package/dist/commands/connect_picker.js.map +1 -1
  27. package/dist/commands/finalize.d.ts +67 -0
  28. package/dist/commands/finalize.js +450 -114
  29. package/dist/commands/finalize.js.map +1 -1
  30. package/dist/commands/scan.d.ts +28 -0
  31. package/dist/commands/scan.js +11 -0
  32. package/dist/commands/scan.js.map +1 -1
  33. package/dist/commands/status_machine.js +47 -10
  34. package/dist/commands/status_machine.js.map +1 -1
  35. package/dist/consent.d.ts +22 -0
  36. package/dist/consent.js +38 -1
  37. package/dist/consent.js.map +1 -1
  38. package/dist/copy/strings.d.ts +43 -1
  39. package/dist/copy/strings.js +48 -4
  40. package/dist/copy/strings.js.map +1 -1
  41. package/dist/folder_grants.d.ts +44 -0
  42. package/dist/folder_grants.js +101 -0
  43. package/dist/folder_grants.js.map +1 -0
  44. package/dist/git.d.ts +26 -4
  45. package/dist/git.js +82 -11
  46. package/dist/git.js.map +1 -1
  47. package/dist/hooks/entry.d.ts +30 -50
  48. package/dist/hooks/entry.js +593 -559
  49. package/dist/hooks/entry.js.map +1 -1
  50. package/dist/hooks/fire_lock.d.ts +31 -0
  51. package/dist/hooks/fire_lock.js +120 -12
  52. package/dist/hooks/fire_lock.js.map +1 -1
  53. package/dist/machine_state.d.ts +44 -1
  54. package/dist/machine_state.js +75 -12
  55. package/dist/machine_state.js.map +1 -1
  56. package/dist/oauth/discovery.js +2 -1
  57. package/dist/oauth/discovery.js.map +1 -1
  58. package/dist/oauth/pkce.js +2 -1
  59. package/dist/oauth/pkce.js.map +1 -1
  60. package/dist/paths.d.ts +34 -1
  61. package/dist/paths.js +62 -12
  62. package/dist/paths.js.map +1 -1
  63. package/dist/physical_path.d.ts +46 -0
  64. package/dist/physical_path.js +151 -0
  65. package/dist/physical_path.js.map +1 -0
  66. package/dist/picker.d.ts +52 -3
  67. package/dist/picker.js +129 -5
  68. package/dist/picker.js.map +1 -1
  69. package/dist/picker_checklist.d.ts +9 -1
  70. package/dist/picker_checklist.js +41 -2
  71. package/dist/picker_checklist.js.map +1 -1
  72. package/dist/policy.d.ts +9 -1
  73. package/dist/policy.js +11 -2
  74. package/dist/policy.js.map +1 -1
  75. package/dist/post.d.ts +14 -0
  76. package/dist/post.js +32 -10
  77. package/dist/post.js.map +1 -1
  78. package/dist/project.d.ts +89 -0
  79. package/dist/project.js +123 -0
  80. package/dist/project.js.map +1 -1
  81. package/dist/redact.d.ts +77 -2
  82. package/dist/redact.js +713 -47
  83. package/dist/redact.js.map +1 -1
  84. package/dist/session_targets.d.ts +713 -0
  85. package/dist/session_targets.js +2602 -0
  86. package/dist/session_targets.js.map +1 -0
  87. package/dist/spool.d.ts +12 -0
  88. package/dist/spool.js +29 -1
  89. package/dist/spool.js.map +1 -1
  90. package/dist/spool_union.d.ts +51 -0
  91. package/dist/spool_union.js +57 -0
  92. package/dist/spool_union.js.map +1 -0
  93. package/dist/state.d.ts +5 -0
  94. package/dist/state.js +19 -1
  95. package/dist/state.js.map +1 -1
  96. package/dist/text_tail.d.ts +8 -0
  97. package/dist/text_tail.js +19 -0
  98. package/dist/text_tail.js.map +1 -0
  99. package/dist/version.d.ts +1 -1
  100. package/dist/version.js +1 -1
  101. 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
- const counts = { projectRoots, resolver, rules, redacted: 0, skipped: 0, slots: null };
347
- walk(parsed, counts);
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
- out.push(redactSegment(segment, projectRoots, rules, resolver, counts));
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
- if (outOfTree && ctx.rules.contentKeys.has(key) && typeof value === "string") {
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
- for (const value of Object.values(record)) {
460
- walk(value, ctx, outOfTree);
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
- // arithmetic below is only exact if everything this function does not touch
509
- // re-serialises to the same bytes.
510
- let baseline;
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
- baseline = JSON.stringify(inner);
1102
+ roundTrips = JSON.stringify(inner) === value;
513
1103
  }
514
1104
  catch {
515
- return;
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
- ctx.skipped += nested.skipped;
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
- const out = pad(inner, slots, target);
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
- // Everything withheld inside is put back. Counted as skipped, because the
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
- const value = lines[i];
608
- if (typeof value !== "string")
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
- const marker = markerFor(value);
622
- if (marker === null) {
623
- ctx.skipped += 1;
624
- return;
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.