@vibe-commit/capture 0.1.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 (141) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +66 -0
  3. package/dist/agents/claude_code.d.ts +11 -0
  4. package/dist/agents/claude_code.js +162 -0
  5. package/dist/agents/claude_code.js.map +1 -0
  6. package/dist/agents/codex_cli.d.ts +15 -0
  7. package/dist/agents/codex_cli.js +67 -0
  8. package/dist/agents/codex_cli.js.map +1 -0
  9. package/dist/agents/cursor.d.ts +21 -0
  10. package/dist/agents/cursor.js +89 -0
  11. package/dist/agents/cursor.js.map +1 -0
  12. package/dist/agents/registry.d.ts +184 -0
  13. package/dist/agents/registry.js +212 -0
  14. package/dist/agents/registry.js.map +1 -0
  15. package/dist/agents/types.d.ts +279 -0
  16. package/dist/agents/types.js +67 -0
  17. package/dist/agents/types.js.map +1 -0
  18. package/dist/commands/connect.d.ts +36 -0
  19. package/dist/commands/connect.js +499 -0
  20. package/dist/commands/connect.js.map +1 -0
  21. package/dist/commands/context.d.ts +45 -0
  22. package/dist/commands/context.js +14 -0
  23. package/dist/commands/context.js.map +1 -0
  24. package/dist/commands/report.d.ts +105 -0
  25. package/dist/commands/report.js +430 -0
  26. package/dist/commands/report.js.map +1 -0
  27. package/dist/commands/status.d.ts +29 -0
  28. package/dist/commands/status.js +236 -0
  29. package/dist/commands/status.js.map +1 -0
  30. package/dist/commands/why.d.ts +349 -0
  31. package/dist/commands/why.js +937 -0
  32. package/dist/commands/why.js.map +1 -0
  33. package/dist/consent.d.ts +59 -0
  34. package/dist/consent.js +111 -0
  35. package/dist/consent.js.map +1 -0
  36. package/dist/copy/claims.d.ts +79 -0
  37. package/dist/copy/claims.js +343 -0
  38. package/dist/copy/claims.js.map +1 -0
  39. package/dist/copy/grades.d.ts +117 -0
  40. package/dist/copy/grades.js +96 -0
  41. package/dist/copy/grades.js.map +1 -0
  42. package/dist/copy/index.d.ts +12 -0
  43. package/dist/copy/index.js +13 -0
  44. package/dist/copy/index.js.map +1 -0
  45. package/dist/copy/strings.d.ts +1328 -0
  46. package/dist/copy/strings.js +1243 -0
  47. package/dist/copy/strings.js.map +1 -0
  48. package/dist/copy/time.d.ts +27 -0
  49. package/dist/copy/time.js +43 -0
  50. package/dist/copy/time.js.map +1 -0
  51. package/dist/credential.d.ts +211 -0
  52. package/dist/credential.js +298 -0
  53. package/dist/credential.js.map +1 -0
  54. package/dist/exit.d.ts +59 -0
  55. package/dist/exit.js +60 -0
  56. package/dist/exit.js.map +1 -0
  57. package/dist/git.d.ts +299 -0
  58. package/dist/git.js +653 -0
  59. package/dist/git.js.map +1 -0
  60. package/dist/hooks/entry.d.ts +277 -0
  61. package/dist/hooks/entry.js +1092 -0
  62. package/dist/hooks/entry.js.map +1 -0
  63. package/dist/hooks/post_commit.d.ts +59 -0
  64. package/dist/hooks/post_commit.js +160 -0
  65. package/dist/hooks/post_commit.js.map +1 -0
  66. package/dist/hooks/post_rewrite.d.ts +59 -0
  67. package/dist/hooks/post_rewrite.js +161 -0
  68. package/dist/hooks/post_rewrite.js.map +1 -0
  69. package/dist/hooks/session_end.d.ts +101 -0
  70. package/dist/hooks/session_end.js +120 -0
  71. package/dist/hooks/session_end.js.map +1 -0
  72. package/dist/index.d.ts +34 -0
  73. package/dist/index.js +360 -0
  74. package/dist/index.js.map +1 -0
  75. package/dist/install.d.ts +153 -0
  76. package/dist/install.js +284 -0
  77. package/dist/install.js.map +1 -0
  78. package/dist/json.d.ts +100 -0
  79. package/dist/json.js +94 -0
  80. package/dist/json.js.map +1 -0
  81. package/dist/oauth/discovery.d.ts +96 -0
  82. package/dist/oauth/discovery.js +163 -0
  83. package/dist/oauth/discovery.js.map +1 -0
  84. package/dist/oauth/lock.d.ts +100 -0
  85. package/dist/oauth/lock.js +287 -0
  86. package/dist/oauth/lock.js.map +1 -0
  87. package/dist/oauth/loopback.d.ts +85 -0
  88. package/dist/oauth/loopback.js +124 -0
  89. package/dist/oauth/loopback.js.map +1 -0
  90. package/dist/oauth/pkce.d.ts +58 -0
  91. package/dist/oauth/pkce.js +77 -0
  92. package/dist/oauth/pkce.js.map +1 -0
  93. package/dist/oauth/read.d.ts +120 -0
  94. package/dist/oauth/read.js +144 -0
  95. package/dist/oauth/read.js.map +1 -0
  96. package/dist/oauth/session.d.ts +80 -0
  97. package/dist/oauth/session.js +112 -0
  98. package/dist/oauth/session.js.map +1 -0
  99. package/dist/oauth/signin.d.ts +178 -0
  100. package/dist/oauth/signin.js +225 -0
  101. package/dist/oauth/signin.js.map +1 -0
  102. package/dist/oauth/token.d.ts +139 -0
  103. package/dist/oauth/token.js +183 -0
  104. package/dist/oauth/token.js.map +1 -0
  105. package/dist/paths.d.ts +366 -0
  106. package/dist/paths.js +460 -0
  107. package/dist/paths.js.map +1 -0
  108. package/dist/policy.d.ts +195 -0
  109. package/dist/policy.js +244 -0
  110. package/dist/policy.js.map +1 -0
  111. package/dist/post.d.ts +302 -0
  112. package/dist/post.js +384 -0
  113. package/dist/post.js.map +1 -0
  114. package/dist/project.d.ts +99 -0
  115. package/dist/project.js +192 -0
  116. package/dist/project.js.map +1 -0
  117. package/dist/redact.d.ts +175 -0
  118. package/dist/redact.js +700 -0
  119. package/dist/redact.js.map +1 -0
  120. package/dist/runtime.d.ts +30 -0
  121. package/dist/runtime.js +37 -0
  122. package/dist/runtime.js.map +1 -0
  123. package/dist/spawn_budget.d.ts +101 -0
  124. package/dist/spawn_budget.js +124 -0
  125. package/dist/spawn_budget.js.map +1 -0
  126. package/dist/spool.d.ts +390 -0
  127. package/dist/spool.js +556 -0
  128. package/dist/spool.js.map +1 -0
  129. package/dist/state.d.ts +109 -0
  130. package/dist/state.js +217 -0
  131. package/dist/state.js.map +1 -0
  132. package/dist/system_message.d.ts +101 -0
  133. package/dist/system_message.js +141 -0
  134. package/dist/system_message.js.map +1 -0
  135. package/dist/term.d.ts +112 -0
  136. package/dist/term.js +134 -0
  137. package/dist/term.js.map +1 -0
  138. package/dist/version.d.ts +16 -0
  139. package/dist/version.js +17 -0
  140. package/dist/version.js.map +1 -0
  141. package/package.json +50 -0
package/dist/redact.js ADDED
@@ -0,0 +1,700 @@
1
+ /**
2
+ * Out-of-tree redaction — `CR-024d`, D57 §OV8d.
3
+ *
4
+ * A Claude Code transcript records structured file reads and writes VERBATIM:
5
+ * a `Read`, `Edit` or `Write` record carries a path and the file's content. When
6
+ * that path is outside the repository the user consented to, the content of a
7
+ * THIRD PARTY'S file is about to be uploaded under the user's credential and
8
+ * attached to a repo that has nothing to do with it. That is a confidentiality
9
+ * boundary, not a tidiness issue, and it is the whole reason this module exists.
10
+ *
11
+ * ## Why the client, permanently
12
+ *
13
+ * D57 §OV8d: *"Out-of-tree redaction is client-side. Server-side redaction would
14
+ * break `turn_hash == sha256(content)`, so it cannot live on the server at
15
+ * all."* A server that rewrote content after hashing would hold a hash chain
16
+ * over bytes it no longer has. There is no server-side variant to fall back on.
17
+ *
18
+ * ## The deviation this is allowed to make, and its limit
19
+ *
20
+ * D60 §D6 says the client holds no ANALYSIS, and OV8d is the ONE recorded
21
+ * deviation from it, because redacting requires parsing each line. The rule that
22
+ * survives is *"the client may render; it may not analyze, hash, or
23
+ * canonicalize."* So this module parses a line, finds a path, compares it to the
24
+ * project root, and replaces a span. It extracts no turns, computes no digest,
25
+ * canonicalises no record, and imports nothing from `vibecommit-mcp` — that last
26
+ * one is a LICENSING violation, not a style one (D60 §D1a, D64).
27
+ *
28
+ * ## ⚠ LENGTH PRESERVATION IS THE LOAD-BEARING PROPERTY
29
+ *
30
+ * The client sends a raw byte span with `X-Byte-Offset: from`, and the server
31
+ * advances its watermark by the DECOMPRESSED body's length
32
+ * (`vibecommit-mcp/src/conversation/ingest_session.ts`). The client's own
33
+ * `sentOffset` advances to `to`. If a redaction makes the body SHORTER, the two
34
+ * watermarks diverge and every subsequent delta arrives flagged `gap: true`,
35
+ * forever; longer, and every delta is `overlap: true`. A permanently
36
+ * gap-flagged stream is a capture-health surface reporting data loss that is
37
+ * not happening.
38
+ *
39
+ * So every replacement is padded to the exact byte length it replaced, and a
40
+ * span too short to hold even the shortest honest marker is LEFT ALONE. That
41
+ * trade — leak a sub-13-byte span rather than break the accounting — is the
42
+ * brief's, stated rather than smuggled, and `redactSpan` counts what it skipped
43
+ * so the number is reportable.
44
+ *
45
+ * ## Why re-serialising is safe here, measured rather than assumed
46
+ *
47
+ * `JSON.parse` → `JSON.stringify` was byte-identical on **39,910 of 39,910**
48
+ * real transcript lines across 25 sessions, because the transcript is written by
49
+ * `JSON.stringify` in the first place. That is what makes the padding
50
+ * arithmetic exact instead of approximate. It is not taken on faith: every line
51
+ * is round-tripped and compared BEFORE anything is changed, and a line that does
52
+ * not round-trip is returned untouched.
53
+ *
54
+ * @provenance vibecommit-mcp src/conversation/ingest_session.ts — offset accounting, read
55
+ */
56
+ import { Buffer } from "node:buffer";
57
+ import { DIALECTS } from "./agents/registry.js";
58
+ import { REDACTION } from "./copy/index.js";
59
+ import { isInside, isInsideAny } from "./paths.js";
60
+ /**
61
+ * Keys whose STRING value is a filesystem path naming the record's subject.
62
+ *
63
+ * `notebook_path` is here because `NotebookEdit` names its file that way and
64
+ * carries the cell source alongside — a shape that does NOT appear in the
65
+ * 59,464-line corpus this module was measured against, and was therefore found
66
+ * by attacking the rule rather than by observing traffic. Absence from a corpus
67
+ * is not absence from the product.
68
+ */
69
+ const PATH_KEYS = new Set(["file_path", "filePath", "notebook_path", "notebookPath"]);
70
+ /**
71
+ * Keys whose value is file CONTENT, redacted when a sibling path is out of tree.
72
+ *
73
+ * Measured against 59,464 real transcript lines rather than assumed, and the
74
+ * measurement is why this list is longer than "the file's content":
75
+ *
76
+ * content Read results (`toolUseResult.file.content`), Write inputs
77
+ * originalFile the WHOLE pre-edit file, on every Edit result — 2,017 of them
78
+ * in the sample, the largest single carrier by volume
79
+ * oldString/newString, old_string/new_string
80
+ * Edit inputs and results, both sides of the change
81
+ *
82
+ * `structuredPatch` is handled separately below: it is an array of hunks whose
83
+ * `lines` are file content too.
84
+ */
85
+ const CONTENT_KEYS = new Set([
86
+ "content",
87
+ "originalFile",
88
+ "oldString",
89
+ "newString",
90
+ "old_string",
91
+ "new_string",
92
+ // NotebookEdit's cell bodies. Same provenance as `notebook_path` above.
93
+ "new_source",
94
+ "old_source",
95
+ "newSource",
96
+ "oldSource",
97
+ ]);
98
+ /** Hunk arrays whose `lines` carry file content. */
99
+ const PATCH_KEY = "structuredPatch";
100
+ /**
101
+ * `claude-code` — the rules this module shipped with, unchanged.
102
+ *
103
+ * ⛔ **Byte-for-byte the pre-`CR-185` behaviour.** Nothing about the Claude path
104
+ * moves in this task; the sets are the same three constants above, named.
105
+ */
106
+ const CLAUDE_CODE_RULES = {
107
+ pathKeys: PATH_KEYS,
108
+ contentKeys: CONTENT_KEYS,
109
+ nestedJsonKeys: new Set(),
110
+ patchKeys: new Set([PATCH_KEY]),
111
+ };
112
+ /**
113
+ * `codex-cli` — ⛔ **the key set is D164 §8's MEASUREMENT, not a guess.**
114
+ *
115
+ * Measured over the 202-file / 14,109-line rollout corpus (M0.5). Two facts
116
+ * shape every entry here, and both are ABSENCES:
117
+ *
118
+ * - **`apply_patch` never appears.** The only `function_call` names are
119
+ * `exec_command` (436) and `write_stdin` (76). ⛔ **Codex writes files
120
+ * THROUGH THE SHELL**, so there is no Codex analogue of `PATH_KEYS` at the
121
+ * top level at all — the carrier is `cmd`, inside a JSON string, inside
122
+ * `arguments`.
123
+ * - **`cmd` is a `str` in 436/436, never an array** in that corpus. Nothing
124
+ * here depends on the array form, so no unexercised normaliser ships: a
125
+ * non-string `cmd` is simply not a content match and is left alone.
126
+ *
127
+ * ## Why `workdir` is the selector and `cmd` is the content
128
+ *
129
+ * `{workdir, cmd}` is the Codex shape of `{file_path, content}`. `workdir`
130
+ * names where the command ran (436/436, always a path); `cmd` is what carries a
131
+ * third party's bytes, because a shell write is a heredoc and the file's
132
+ * content is inside the command string. So the existing rule applies unchanged:
133
+ * an out-of-tree `workdir` makes its sibling `cmd` third-party content.
134
+ *
135
+ * ⚠ **`cwd` and `absolute_file_path` are selectors that fire nothing TODAY**,
136
+ * and that is stated rather than disguised. `cwd` appears on `session_meta` and
137
+ * `turn_context`, and `…review_output.findings[].code_location
138
+ * .absolute_file_path` on `exited_review_mode` — none of those records carries a
139
+ * content key or a nested document anywhere in its subtree, so each marks a
140
+ * verdict that reaches nothing. They are here because they are literal,
141
+ * measured path keys and absence from a corpus is not absence from the product
142
+ * — the same reason `notebook_path` is in `PATH_KEYS`.
143
+ *
144
+ * ⛔ **`output` and `chars` are NOT content keys, deliberately.** They are the
145
+ * shell's stdout and stdin — the Codex spelling of a Claude `tool_result`, which
146
+ * this module does not redact either (a `tool_result` has no `PATH_KEYS`
147
+ * sibling, so `inherited` stays false). ⚠ **That is a live hole on CLAUDE,
148
+ * today, symmetric across agents.** Widening the control is a separate decision
149
+ * about the control; reaching parity with it is this task. See the report.
150
+ */
151
+ const CODEX_CLI_RULES = {
152
+ pathKeys: new Set(["workdir", "cwd", "absolute_file_path"]),
153
+ contentKeys: new Set(["cmd"]),
154
+ nestedJsonKeys: new Set(["arguments"]),
155
+ patchKeys: new Set(),
156
+ };
157
+ /**
158
+ * ⛔ **Every profile, keyed by its label — and the `Record` is the lock.**
159
+ *
160
+ * `RedactionProfile` is a closed union (`agents/types.ts`), so a dialect that
161
+ * declares a profile with no rules here is a COMPILE ERROR in a diff a human
162
+ * reads, which is the same device `transport: "ndjson"` uses to lock the lane.
163
+ */
164
+ /**
165
+ * `cursor` — ⛔ **the key sets are `D177 §10`'s MEASUREMENT, re-measured here.**
166
+ *
167
+ * `CR-194`. Measured over **all 18 `tool_use` calls** of the live corpus, and
168
+ * the counts below were re-derived from those bytes by this task rather than
169
+ * copied from the decision — they reproduce it exactly:
170
+ *
171
+ * ```
172
+ * Shell 5 {command, description} ×4 · {command, description, working_directory} ×1
173
+ * Read 4 {limit, path} ×3 · {path} ×1
174
+ * Glob 4 {glob_pattern, target_directory} ×4
175
+ * Write 1 {contents, path}
176
+ * Grep 1 {glob, path, pattern}
177
+ * WebSearch 1 {explanation, search_term}
178
+ * GetMcpTools 1 {pattern}
179
+ * ConnectScm 1 {github_repo}
180
+ * ```
181
+ *
182
+ * ## ⛔ THREE SPELLINGS OF "PATH", NOT ONE
183
+ *
184
+ * `path`, `target_directory` and `working_directory` are three different tools'
185
+ * names for the same role, and a rule set carrying only `path` would leave
186
+ * `Glob` and the one `Shell` call that declares its directory unselected.
187
+ * `{path, contents}` is the Cursor shape of `{file_path, content}`, so the
188
+ * module's existing rule applies unchanged: an out-of-tree path key makes its
189
+ * content-bearing siblings third-party content.
190
+ *
191
+ * ## ⛔ `nestedJsonKeys` IS EMPTY, AND FOR THE OPPOSITE REASON TO CLAUDE'S
192
+ *
193
+ * Claude's is empty because its tool input is already a real object. Cursor's is
194
+ * empty for the *same* structural reason — `input` is a real object in **18/18**
195
+ * — which is what makes Cursor unlike Codex, whose whole argument list arrives
196
+ * as one escaped string in `arguments`. `walk` reaches it already.
197
+ *
198
+ * ## ⚠ `patchKeys` IS EMPTY BECAUSE THERE IS NO PATCH CARRIER
199
+ *
200
+ * No `structuredPatch` analogue appears anywhere in the 18 calls. ⛔ Not a
201
+ * decision to skip one — an absence, and if Cursor grows an edit tool with hunk
202
+ * arrays this is the line that will need re-measuring.
203
+ *
204
+ * ## ⛔ `description` AND `explanation` ARE NOT CONTENT KEYS
205
+ *
206
+ * Both are the model's own prose about what it is doing, not a third party's
207
+ * bytes. Redacting them would withhold the record's most readable half for no
208
+ * confidentiality gain, and `markerFor` would spend the span's budget on it.
209
+ *
210
+ * ## ⚠ `TODOS[123]` — THE HOLE THIS SHIPS WITH, MEASURED AND PINNED
211
+ *
212
+ * **4 of the 5 `Shell` calls carry no path key at all**, so their `command` has
213
+ * no sibling to condemn it and ships whatever it holds. Precisely, in the
214
+ * corpus: **2** begin `cd <abs> && …`, **1** embeds absolute paths inline
215
+ * (`for d in … ; git -C "$d" …`), and **1** names no path at all. ⛔ **The fix
216
+ * is NOT to treat `command` as a path key** — that hands a whole shell
217
+ * transcript to `realpath`, which is the mistake `D172 §2` already caught once
218
+ * (`payload.output` averaged 10 213 characters there). A `cd` inside a command
219
+ * string is a fact about the shell, not a key this module can read. Pinned by a
220
+ * PASSING cell in `test/redact-profiles.test.ts` so the hole is a recorded fact
221
+ * rather than something a reader might believe was closed here.
222
+ *
223
+ * ⚠ And Cursor is clean on `TODOS[117]` (unredacted shell OUTPUT) for a reason
224
+ * that is a **completeness loss, not a redaction win**: `tool_result` appears
225
+ * **0** times in the corpus, so there is nothing to leak because there is
226
+ * nothing captured (`D177 §5`).
227
+ */
228
+ const CURSOR_RULES = {
229
+ pathKeys: new Set(["path", "target_directory", "working_directory"]),
230
+ contentKeys: new Set(["contents", "command"]),
231
+ nestedJsonKeys: new Set(),
232
+ patchKeys: new Set(),
233
+ };
234
+ const RULES = {
235
+ "claude-code": CLAUDE_CODE_RULES,
236
+ "codex-cli": CODEX_CLI_RULES,
237
+ cursor: CURSOR_RULES,
238
+ };
239
+ /**
240
+ * The rules a profile LABEL names.
241
+ *
242
+ * For a caller that already knows the dialect — `test/` does, and nothing in
243
+ * `src/` should, because ⛔ **the profile is selected by the containing root and
244
+ * not by anything the caller believes about the agent** (see
245
+ * `rulesForTranscript`).
246
+ */
247
+ export function rulesFor(profile) {
248
+ return RULES[profile];
249
+ }
250
+ /**
251
+ * ⛔ **THE FAIL-CLOSED PROFILE — the union of every rule any profile has.**
252
+ *
253
+ * Not a hand-written third profile: DERIVED, so a dialect added later
254
+ * strengthens this automatically instead of quietly leaving a gap. A path
255
+ * inside no known root gets every selector and every content key at once, which
256
+ * over-redacts rather than under-redacts.
257
+ *
258
+ * ⚠ **Over-redaction is the cost, and it is real.** A Claude record's
259
+ * top-level `cwd` becomes a selector here, so a line whose session `cwd` sits
260
+ * outside the consented repo has its content withheld wholesale. That is the
261
+ * right direction for a transcript we cannot attribute, and the wrong direction
262
+ * for one we can — which is why it is the FALLBACK and not the default.
263
+ */
264
+ const STRICTEST_RULES = {
265
+ pathKeys: unionOf((rules) => rules.pathKeys),
266
+ contentKeys: unionOf((rules) => rules.contentKeys),
267
+ nestedJsonKeys: unionOf((rules) => rules.nestedJsonKeys),
268
+ patchKeys: unionOf((rules) => rules.patchKeys),
269
+ };
270
+ function unionOf(pick) {
271
+ return new Set(Object.values(RULES).flatMap((rules) => [...pick(rules)]));
272
+ }
273
+ /**
274
+ * ⛔ **THE PROFILE IS SELECTED BY WHICH REGISTRY ROOT CONTAINS THE RESOLVED
275
+ * TRANSCRIPT PATH — NEVER BY THE `--agent=` FLAG (D164 §5).**
276
+ *
277
+ * The flag stays the BEHAVIOUR selector (roots to search, events, budgets,
278
+ * delegated discovery). The scrubber stops depending on anyone remembering to
279
+ * set it — which is the whole defect: both no-flag install paths (the
280
+ * `.claude-plugin` bundle whose command carries no `--agent=`, and a
281
+ * hand-written `.codex/` config) resolved to `claude-code`, and a Codex record
282
+ * went through Claude's key set and was redacted NOT AT ALL.
283
+ *
284
+ * ## ⛔ This does NOT reopen `CR-183`'s `transcript_path`-as-selector rejection
285
+ *
286
+ * That rejection is about the **confinement boundary**, which is computed from
287
+ * the registry and is never derived from input — and it still is. The
288
+ * difference is the direction of the failure:
289
+ *
290
+ * - choosing a **stricter scrubber** because a path sits outside every known
291
+ * root is **fail-closed**;
292
+ * - choosing a **wider root** because a path *claims* to be there is not.
293
+ *
294
+ * A wrong answer here costs a suboptimal profile and can never make a file
295
+ * readable that was not readable before. `isInside` resolves both sides with
296
+ * `realpathSync`, so a symlink cannot claim a root it is not under.
297
+ *
298
+ * ⛔ **AMBIGUITY FAILS CLOSED TOO.** Two dialects can be pointed at one
299
+ * directory by environment (`CLAUDE_CONFIG_DIR` and `CODEX_HOME` are both
300
+ * operator-set, and both sides resolve through `realpath`), and a path inside
301
+ * two roots is a path whose producer is not known. Exactly one match selects a
302
+ * profile; zero or two select the strictest.
303
+ *
304
+ * ⚠ **Today no path reaching this function is unattributed**, because
305
+ * `isInsideAny(transcriptRoots(...))` refuses everything outside the union
306
+ * BEFORE `readSpan` opens it. So the fallback is a second, independent line
307
+ * rather than a live branch — stated plainly rather than left for a reader to
308
+ * assume it has a caller (`feedback_unexercisable_branch_not_verified`).
309
+ */
310
+ export function rulesForTranscript(home, env, transcriptPath) {
311
+ const matches = DIALECTS.filter((dialect) => isInside(dialect.transcriptRoot(home, env), transcriptPath));
312
+ return matches.length === 1 ? RULES[matches[0].redaction] : STRICTEST_RULES;
313
+ }
314
+ const NONE = { redacted: 0, skipped: 0 };
315
+ /**
316
+ * Redact one NDJSON line. **Pure, total, and length-preserving.**
317
+ *
318
+ * Returns the line UNCHANGED on anything it cannot handle — a blank line, a
319
+ * parse failure, a line that does not round-trip. A hook cannot report an error
320
+ * (DESIGN.md §13.7), and a redactor that dropped a line it could not parse would
321
+ * be silent data loss on a path where D56 §D8 makes loss permanent.
322
+ */
323
+ export function redactLine(line, projectRoots, rules, resolver = containment()) {
324
+ if (line === "")
325
+ return { line, ...NONE };
326
+ let parsed;
327
+ try {
328
+ parsed = JSON.parse(line);
329
+ }
330
+ catch {
331
+ // A partial line at a span boundary lands here, and so does anything else
332
+ // that is not JSON. Unchanged is the only honest answer.
333
+ return { line, ...NONE };
334
+ }
335
+ // The round-trip gate. Padding arithmetic is only exact if everything this
336
+ // module does NOT touch re-serialises to the same bytes.
337
+ let baseline;
338
+ try {
339
+ baseline = JSON.stringify(parsed);
340
+ }
341
+ catch {
342
+ return { line, ...NONE };
343
+ }
344
+ if (baseline !== line)
345
+ return { line, ...NONE };
346
+ const counts = { projectRoots, resolver, rules, redacted: 0, skipped: 0, slots: null };
347
+ walk(parsed, counts);
348
+ if (counts.redacted === 0)
349
+ return { line, redacted: 0, skipped: counts.skipped };
350
+ let out;
351
+ try {
352
+ out = JSON.stringify(parsed);
353
+ }
354
+ catch {
355
+ return { line, ...NONE };
356
+ }
357
+ // The backstop. Per-field arithmetic should make this exact; if it ever is
358
+ // not, the accounting matters more than the redaction (see the header) and
359
+ // the caller gets the original bytes plus an honest skip count.
360
+ if (Buffer.byteLength(out) !== Buffer.byteLength(line)) {
361
+ return { line, redacted: 0, skipped: counts.redacted + counts.skipped };
362
+ }
363
+ return { line: out, redacted: counts.redacted, skipped: counts.skipped };
364
+ }
365
+ /**
366
+ * Redact every complete line in a raw transcript span.
367
+ *
368
+ * Operates on BYTES because a span is a byte range and its edges need not fall
369
+ * on a line — or even on a character. Segments are split on `\n` and a segment
370
+ * that does not decode to exactly its own bytes is passed through untouched,
371
+ * which is what keeps a span that bisects a multi-byte character from changing
372
+ * length when it is re-encoded.
373
+ */
374
+ export function redactSpan(span, projectRoots, rules) {
375
+ const resolver = containment();
376
+ const counts = { redacted: 0, skipped: 0 };
377
+ const NEWLINE = 0x0a;
378
+ const out = [];
379
+ let start = 0;
380
+ for (let i = 0; i <= span.length; i += 1) {
381
+ if (i !== span.length && span[i] !== NEWLINE)
382
+ continue;
383
+ const segment = span.subarray(start, i);
384
+ out.push(redactSegment(segment, projectRoots, rules, resolver, counts));
385
+ if (i !== span.length)
386
+ out.push(span.subarray(i, i + 1));
387
+ start = i + 1;
388
+ }
389
+ const bytes = Buffer.concat(out);
390
+ // Length preservation is the property the server's offset accounting rests
391
+ // on, so it is checked here too rather than trusted from the parts.
392
+ if (bytes.length !== span.length) {
393
+ return { bytes: span, redacted: 0, skipped: counts.redacted + counts.skipped };
394
+ }
395
+ return { bytes, ...counts };
396
+ }
397
+ function redactSegment(segment, projectRoots, rules, resolver, counts) {
398
+ const text = segment.toString("utf8");
399
+ // A lossy decode (the span bisected a character) would re-encode to a
400
+ // different length. Pass the raw bytes through instead.
401
+ if (Buffer.byteLength(text) !== segment.length)
402
+ return segment;
403
+ const result = redactLine(text, projectRoots, rules, resolver);
404
+ counts.redacted += result.redacted;
405
+ counts.skipped += result.skipped;
406
+ if (result.line === text)
407
+ return segment;
408
+ const replaced = Buffer.from(result.line, "utf8");
409
+ return replaced.length === segment.length ? replaced : segment;
410
+ }
411
+ /**
412
+ * Walk the record, redacting content that sits beside an out-of-tree path.
413
+ *
414
+ * The rule is local and structural: within ONE object, a path key naming a file
415
+ * outside the project root makes that object's content-bearing siblings
416
+ * third-party content. That holds for every shape the transcript actually uses
417
+ * — the tool_use input (`{file_path, content}`), the flat Edit result
418
+ * (`{filePath, originalFile, oldString, newString, structuredPatch}`) and the
419
+ * nested Read result (`file: {filePath, content}`) — without this module
420
+ * needing to know which tool produced which.
421
+ */
422
+ function walk(node, ctx,
423
+ /** True once an ancestor named a file outside the tree. */
424
+ inherited = false) {
425
+ if (Array.isArray(node)) {
426
+ for (const item of node)
427
+ walk(item, ctx, inherited);
428
+ return;
429
+ }
430
+ if (node === null || typeof node !== "object")
431
+ return;
432
+ const record = node;
433
+ // A nested object that names its OWN file decides for its own subtree: an
434
+ // in-tree read quoted inside an out-of-tree record is still the user's own
435
+ // 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);
438
+ for (const key of Object.keys(record)) {
439
+ const value = record[key];
440
+ if (outOfTree && ctx.rules.contentKeys.has(key) && typeof value === "string") {
441
+ replaceString(record, key, value, ctx);
442
+ }
443
+ else if (outOfTree && ctx.rules.patchKeys.has(key)) {
444
+ redactPatch(value, ctx);
445
+ }
446
+ else if (ctx.rules.nestedJsonKeys.has(key) && typeof value === "string") {
447
+ // ⛔ NOT gated on `outOfTree`. The path key that decides a Codex exec
448
+ // (`workdir`) lives INSIDE the nested document, so the outer object has
449
+ // nothing to judge by — descending only when the outer record is already
450
+ // condemned would never open the one carrier that matters.
451
+ redactNested(record, key, value, ctx, outOfTree);
452
+ }
453
+ }
454
+ // ⚠ The verdict is INHERITED down the subtree, and that is the fix for a hole
455
+ // this module shipped with in review: `MultiEdit`'s `edits: [{old_string,
456
+ // new_string}]` sit one level BELOW the object carrying `file_path`, so a rule
457
+ // that only redacted an object's own keys walked straight past a third party's
458
+ // file content. Verified leaking before the change and redacted after.
459
+ for (const value of Object.values(record)) {
460
+ walk(value, ctx, outOfTree);
461
+ }
462
+ }
463
+ /**
464
+ * Descend into a key whose STRING value is itself a JSON document — `CR-185`.
465
+ *
466
+ * Codex hands the whole argument list to the model as one escaped string, so
467
+ * `{"cmd": …, "workdir": …}` is not reachable by `walk` at all: it is a leaf
468
+ * from the outer document's point of view. D164 §8 measured that the string is
469
+ * always JSON (512/512) and always parseable (0 failures), which is what makes
470
+ * this a descent rather than a heuristic.
471
+ *
472
+ * ## ⛔ THE ACCEPTANCE PREDICATE IS THE OUTER ENCODING
473
+ *
474
+ * ```
475
+ * byteLength(JSON.stringify(sOut)) === byteLength(JSON.stringify(s))
476
+ * ```
477
+ *
478
+ * and it is NOT the same test as the inner one, because the outer
479
+ * `JSON.stringify` escapes `"` and `\` a SECOND time. A `cmd` containing two
480
+ * newlines carries two backslashes inside `s`; each costs two bytes in the outer
481
+ * encoding and the marker's plain ASCII costs one, so an inner-exact
482
+ * replacement lands the whole line SHORT. Length preservation is load-bearing —
483
+ * a short body permanently gap-flags the stream (see the module header) — so the
484
+ * shortfall is measured against the outer encoding and padded out with the fill
485
+ * character, which costs exactly one byte at both levels.
486
+ *
487
+ * ⚠ **The deficit can only ever be non-negative**: replacing an escaped value
488
+ * with unescaped ASCII of the same INNER byte length removes escapes and can
489
+ * never add them. It is still measured rather than assumed, and a negative one
490
+ * abandons the replacement.
491
+ *
492
+ * Anything that does not parse, does not round-trip, or cannot be made to land
493
+ * on the exact byte count is LEFT ALONE and counted as skipped — the same trade
494
+ * `markerFor` makes, for the same reason.
495
+ */
496
+ function redactNested(record, key, value, ctx, inherited) {
497
+ let inner;
498
+ try {
499
+ inner = JSON.parse(value);
500
+ }
501
+ catch {
502
+ // Not a JSON document after all. A string is not content on its own.
503
+ return;
504
+ }
505
+ if (inner === null || typeof inner !== "object")
506
+ 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;
511
+ try {
512
+ baseline = JSON.stringify(inner);
513
+ }
514
+ catch {
515
+ return;
516
+ }
517
+ if (baseline !== value)
518
+ return;
519
+ const target = Buffer.byteLength(JSON.stringify(value));
520
+ const slots = [];
521
+ const nested = { ...ctx, redacted: 0, skipped: 0, slots };
522
+ walk(inner, nested, inherited);
523
+ ctx.skipped += nested.skipped;
524
+ if (nested.redacted === 0)
525
+ return;
526
+ const out = pad(inner, slots, target);
527
+ 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;
531
+ return;
532
+ }
533
+ record[key] = out;
534
+ ctx.redacted += nested.redacted;
535
+ }
536
+ /**
537
+ * Re-encode the nested document at exactly `target` outer-encoded bytes.
538
+ *
539
+ * Returns null when it cannot be done, which leaves the caller holding the
540
+ * original string.
541
+ */
542
+ function pad(inner, slots, target) {
543
+ let out;
544
+ try {
545
+ out = JSON.stringify(inner);
546
+ }
547
+ catch {
548
+ return null;
549
+ }
550
+ const deficit = target - Buffer.byteLength(JSON.stringify(out));
551
+ if (deficit < 0)
552
+ return null;
553
+ if (deficit > 0) {
554
+ // Any one marker will do: the fill character is ASCII and unescaped, so it
555
+ // costs one byte at both encoding levels wherever it lands.
556
+ if (slots.length === 0)
557
+ return null;
558
+ const slot = slots[slots.length - 1];
559
+ slot.record[slot.key] = widen(String(slot.record[slot.key]), deficit);
560
+ try {
561
+ out = JSON.stringify(inner);
562
+ }
563
+ catch {
564
+ return null;
565
+ }
566
+ }
567
+ // Belt and braces, exactly as `markerFor` does: the arithmetic above is
568
+ // exact, and this is what proves it for THIS document rather than in general.
569
+ return Buffer.byteLength(JSON.stringify(out)) === target ? out : null;
570
+ }
571
+ /**
572
+ * Lengthen a marker by `extra` fill characters, INSIDE its brackets.
573
+ *
574
+ * The marker is user-visible — it lands in the record a customer reads back
575
+ * (`copy/strings.ts`) — so the fill goes where `markerFor`'s own fill goes and
576
+ * the shape stays `[OUT-OF-TREE: N bytes withheld…]`. Appending after the
577
+ * closing bracket would be equally length-preserving and would read as a bug.
578
+ */
579
+ function widen(marker, extra) {
580
+ const fill = REDACTION.fill.repeat(extra);
581
+ return marker.endsWith(REDACTION.tail)
582
+ ? `${marker.slice(0, -REDACTION.tail.length)}${fill}${REDACTION.tail}`
583
+ : `${marker}${fill}`;
584
+ }
585
+ /** The path this object names, or null if it names none. */
586
+ function namesAFile(record, pathKeys) {
587
+ for (const key of Object.keys(record)) {
588
+ if (!pathKeys.has(key))
589
+ continue;
590
+ const value = record[key];
591
+ if (typeof value === "string" && value !== "")
592
+ return value;
593
+ }
594
+ return null;
595
+ }
596
+ /** `structuredPatch: [{ lines: [...] }]` — the changed lines of the file. */
597
+ function redactPatch(patch, ctx) {
598
+ if (!Array.isArray(patch))
599
+ return;
600
+ for (const hunk of patch) {
601
+ if (hunk === null || typeof hunk !== "object" || Array.isArray(hunk))
602
+ continue;
603
+ const lines = hunk.lines;
604
+ if (!Array.isArray(lines))
605
+ continue;
606
+ 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;
617
+ }
618
+ }
619
+ }
620
+ 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 });
629
+ }
630
+ /**
631
+ * A marker whose JSON-ENCODED byte length is exactly the original's.
632
+ *
633
+ * Encoded, not raw: what has to match is the bytes on the wire, and
634
+ * `JSON.stringify` escapes newlines, quotes and control characters, so a
635
+ * 400-byte source file is rarely 400 bytes of JSON. The marker is pure ASCII
636
+ * with nothing `JSON.stringify` escapes, so its encoded length is simply its
637
+ * own length plus the two quotes — which is what makes the padding exact.
638
+ *
639
+ * Null when even `[out-of-tree]` does not fit. The caller counts that and leaves
640
+ * the value alone: a short span left intact is a small leak, and a mis-sized one
641
+ * is a permanently gap-flagged stream.
642
+ */
643
+ export function markerFor(original) {
644
+ const target = Buffer.byteLength(JSON.stringify(original)) - 2;
645
+ if (target < 0)
646
+ return null;
647
+ const head = REDACTION.head(Buffer.byteLength(original));
648
+ const fill = target - head.length - REDACTION.tail.length;
649
+ const marker = fill >= 0
650
+ ? `${head}${REDACTION.fill.repeat(fill)}${REDACTION.tail}`
651
+ : target >= REDACTION.short.length
652
+ ? REDACTION.short + REDACTION.fill.repeat(target - REDACTION.short.length)
653
+ : null;
654
+ if (marker === null)
655
+ return null;
656
+ // Belt and braces: the arithmetic above is exact, and this is what proves it
657
+ // for THIS value rather than in general.
658
+ return Buffer.byteLength(JSON.stringify(marker)) === target + 2 ? marker : null;
659
+ }
660
+ /**
661
+ * `isInsideAny`, memoised for one span.
662
+ *
663
+ * `isInside` resolves BOTH sides with `realpathSync` — reused rather than
664
+ * reimplemented, because a string prefix test is exactly what misses
665
+ * `~/code/repo/x -> /etc`, the hole `/cso` finding 1 found in `paths.ts`. An
666
+ * unresolvable path resolves to "outside", which here means REDACT: fail closed.
667
+ *
668
+ * The cache exists because one span can carry hundreds of records naming a
669
+ * handful of paths, and each miss is two `realpath` syscalls PER ROOT against a
670
+ * hook's wall-clock budget (DESIGN.md §13.7). It lives for one span and is not
671
+ * shared, so a path that changes between invocations is re-resolved.
672
+ *
673
+ * ## ⛔ THE KEY IS LENGTH-PREFIXED NOW, because it carries a LIST
674
+ *
675
+ * `<root>NUL<candidate>` was injective while the root was ONE string. Joining a
676
+ * SET with that same separator is not: `["a" NUL "b"], "c"` and `["a"], "b" NUL
677
+ * "c"` produce the identical key, and one span would then serve a cached
678
+ * containment verdict for a boundary nobody asked about — silently, in the
679
+ * direction that leaks as readily as the direction that redacts.
680
+ *
681
+ * `<n>NUL<len>NUL<root>…<candidate>` decodes uniquely: the count says how many
682
+ * roots follow and each root's own length says where it ends, so nothing a root
683
+ * or a candidate can hold is able to impersonate a delimiter. The NUL stays the
684
+ * separator it always was — a POSIX path cannot contain one — with the lengths
685
+ * as the belt to its braces.
686
+ */
687
+ function containment() {
688
+ const seen = new Map();
689
+ return (roots, candidate) => {
690
+ const prefix = roots.map((root) => `${root.length}${root}`).join("");
691
+ const key = `${roots.length}${prefix}${candidate}`;
692
+ const hit = seen.get(key);
693
+ if (hit !== undefined)
694
+ return hit;
695
+ const inside = isInsideAny(roots, candidate);
696
+ seen.set(key, inside);
697
+ return inside;
698
+ };
699
+ }
700
+ //# sourceMappingURL=redact.js.map