@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.
- package/LICENSE +21 -0
- package/README.md +66 -0
- package/dist/agents/claude_code.d.ts +11 -0
- package/dist/agents/claude_code.js +162 -0
- package/dist/agents/claude_code.js.map +1 -0
- package/dist/agents/codex_cli.d.ts +15 -0
- package/dist/agents/codex_cli.js +67 -0
- package/dist/agents/codex_cli.js.map +1 -0
- package/dist/agents/cursor.d.ts +21 -0
- package/dist/agents/cursor.js +89 -0
- package/dist/agents/cursor.js.map +1 -0
- package/dist/agents/registry.d.ts +184 -0
- package/dist/agents/registry.js +212 -0
- package/dist/agents/registry.js.map +1 -0
- package/dist/agents/types.d.ts +279 -0
- package/dist/agents/types.js +67 -0
- package/dist/agents/types.js.map +1 -0
- package/dist/commands/connect.d.ts +36 -0
- package/dist/commands/connect.js +499 -0
- package/dist/commands/connect.js.map +1 -0
- package/dist/commands/context.d.ts +45 -0
- package/dist/commands/context.js +14 -0
- package/dist/commands/context.js.map +1 -0
- package/dist/commands/report.d.ts +105 -0
- package/dist/commands/report.js +430 -0
- package/dist/commands/report.js.map +1 -0
- package/dist/commands/status.d.ts +29 -0
- package/dist/commands/status.js +236 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/commands/why.d.ts +349 -0
- package/dist/commands/why.js +937 -0
- package/dist/commands/why.js.map +1 -0
- package/dist/consent.d.ts +59 -0
- package/dist/consent.js +111 -0
- package/dist/consent.js.map +1 -0
- package/dist/copy/claims.d.ts +79 -0
- package/dist/copy/claims.js +343 -0
- package/dist/copy/claims.js.map +1 -0
- package/dist/copy/grades.d.ts +117 -0
- package/dist/copy/grades.js +96 -0
- package/dist/copy/grades.js.map +1 -0
- package/dist/copy/index.d.ts +12 -0
- package/dist/copy/index.js +13 -0
- package/dist/copy/index.js.map +1 -0
- package/dist/copy/strings.d.ts +1328 -0
- package/dist/copy/strings.js +1243 -0
- package/dist/copy/strings.js.map +1 -0
- package/dist/copy/time.d.ts +27 -0
- package/dist/copy/time.js +43 -0
- package/dist/copy/time.js.map +1 -0
- package/dist/credential.d.ts +211 -0
- package/dist/credential.js +298 -0
- package/dist/credential.js.map +1 -0
- package/dist/exit.d.ts +59 -0
- package/dist/exit.js +60 -0
- package/dist/exit.js.map +1 -0
- package/dist/git.d.ts +299 -0
- package/dist/git.js +653 -0
- package/dist/git.js.map +1 -0
- package/dist/hooks/entry.d.ts +277 -0
- package/dist/hooks/entry.js +1092 -0
- package/dist/hooks/entry.js.map +1 -0
- package/dist/hooks/post_commit.d.ts +59 -0
- package/dist/hooks/post_commit.js +160 -0
- package/dist/hooks/post_commit.js.map +1 -0
- package/dist/hooks/post_rewrite.d.ts +59 -0
- package/dist/hooks/post_rewrite.js +161 -0
- package/dist/hooks/post_rewrite.js.map +1 -0
- package/dist/hooks/session_end.d.ts +101 -0
- package/dist/hooks/session_end.js +120 -0
- package/dist/hooks/session_end.js.map +1 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.js +360 -0
- package/dist/index.js.map +1 -0
- package/dist/install.d.ts +153 -0
- package/dist/install.js +284 -0
- package/dist/install.js.map +1 -0
- package/dist/json.d.ts +100 -0
- package/dist/json.js +94 -0
- package/dist/json.js.map +1 -0
- package/dist/oauth/discovery.d.ts +96 -0
- package/dist/oauth/discovery.js +163 -0
- package/dist/oauth/discovery.js.map +1 -0
- package/dist/oauth/lock.d.ts +100 -0
- package/dist/oauth/lock.js +287 -0
- package/dist/oauth/lock.js.map +1 -0
- package/dist/oauth/loopback.d.ts +85 -0
- package/dist/oauth/loopback.js +124 -0
- package/dist/oauth/loopback.js.map +1 -0
- package/dist/oauth/pkce.d.ts +58 -0
- package/dist/oauth/pkce.js +77 -0
- package/dist/oauth/pkce.js.map +1 -0
- package/dist/oauth/read.d.ts +120 -0
- package/dist/oauth/read.js +144 -0
- package/dist/oauth/read.js.map +1 -0
- package/dist/oauth/session.d.ts +80 -0
- package/dist/oauth/session.js +112 -0
- package/dist/oauth/session.js.map +1 -0
- package/dist/oauth/signin.d.ts +178 -0
- package/dist/oauth/signin.js +225 -0
- package/dist/oauth/signin.js.map +1 -0
- package/dist/oauth/token.d.ts +139 -0
- package/dist/oauth/token.js +183 -0
- package/dist/oauth/token.js.map +1 -0
- package/dist/paths.d.ts +366 -0
- package/dist/paths.js +460 -0
- package/dist/paths.js.map +1 -0
- package/dist/policy.d.ts +195 -0
- package/dist/policy.js +244 -0
- package/dist/policy.js.map +1 -0
- package/dist/post.d.ts +302 -0
- package/dist/post.js +384 -0
- package/dist/post.js.map +1 -0
- package/dist/project.d.ts +99 -0
- package/dist/project.js +192 -0
- package/dist/project.js.map +1 -0
- package/dist/redact.d.ts +175 -0
- package/dist/redact.js +700 -0
- package/dist/redact.js.map +1 -0
- package/dist/runtime.d.ts +30 -0
- package/dist/runtime.js +37 -0
- package/dist/runtime.js.map +1 -0
- package/dist/spawn_budget.d.ts +101 -0
- package/dist/spawn_budget.js +124 -0
- package/dist/spawn_budget.js.map +1 -0
- package/dist/spool.d.ts +390 -0
- package/dist/spool.js +556 -0
- package/dist/spool.js.map +1 -0
- package/dist/state.d.ts +109 -0
- package/dist/state.js +217 -0
- package/dist/state.js.map +1 -0
- package/dist/system_message.d.ts +101 -0
- package/dist/system_message.js +141 -0
- package/dist/system_message.js.map +1 -0
- package/dist/term.d.ts +112 -0
- package/dist/term.js +134 -0
- package/dist/term.js.map +1 -0
- package/dist/version.d.ts +16 -0
- package/dist/version.js +17 -0
- package/dist/version.js.map +1 -0
- 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
|