redac---n 0.0.0-stage → 0.0.2

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 (102) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +323 -2
  3. package/THIRD_PARTY_NOTICES.md +59 -0
  4. package/dist/aho.d.ts +25 -0
  5. package/dist/aho.js +116 -0
  6. package/dist/base64.d.ts +10 -0
  7. package/dist/base64.js +29 -0
  8. package/dist/bip39-data.d.ts +2 -0
  9. package/dist/bip39-data.js +2 -0
  10. package/dist/cache.d.ts +14 -0
  11. package/dist/cache.js +40 -0
  12. package/dist/calls.d.ts +64 -0
  13. package/dist/calls.js +269 -0
  14. package/dist/charlm-data.d.ts +2 -0
  15. package/dist/charlm-data.js +3 -0
  16. package/dist/charlm.d.ts +7 -0
  17. package/dist/charlm.js +65 -0
  18. package/dist/checksums.d.ts +10 -0
  19. package/dist/checksums.js +98 -0
  20. package/dist/commands.d.ts +72 -0
  21. package/dist/commands.js +787 -0
  22. package/dist/context.d.ts +98 -0
  23. package/dist/context.js +469 -0
  24. package/dist/encoded.d.ts +11 -0
  25. package/dist/encoded.js +336 -0
  26. package/dist/environment.d.ts +10 -0
  27. package/dist/environment.js +62 -0
  28. package/dist/formats-data.d.ts +2 -0
  29. package/dist/formats-data.js +405 -0
  30. package/dist/formats.d.ts +5 -0
  31. package/dist/formats.js +470 -0
  32. package/dist/hmac.d.ts +3 -0
  33. package/dist/hmac.js +87 -0
  34. package/dist/index.d.ts +101 -0
  35. package/dist/index.js +488 -0
  36. package/dist/json.d.ts +82 -0
  37. package/dist/json.js +392 -0
  38. package/dist/known.d.ts +34 -0
  39. package/dist/known.js +340 -0
  40. package/dist/lines.d.ts +28 -0
  41. package/dist/lines.js +69 -0
  42. package/dist/lookup.d.ts +5 -0
  43. package/dist/lookup.js +7 -0
  44. package/dist/model-data.d.ts +3 -0
  45. package/dist/model-data.js +4 -0
  46. package/dist/model.d.ts +2 -0
  47. package/dist/model.js +19 -0
  48. package/dist/named.d.ts +20 -0
  49. package/dist/named.js +3257 -0
  50. package/dist/names.d.ts +106 -0
  51. package/dist/names.js +1517 -0
  52. package/dist/normalize.d.ts +25 -0
  53. package/dist/normalize.js +529 -0
  54. package/dist/pem.d.ts +32 -0
  55. package/dist/pem.js +269 -0
  56. package/dist/policy.d.ts +3 -0
  57. package/dist/policy.js +470 -0
  58. package/dist/public.d.ts +44 -0
  59. package/dist/public.js +362 -0
  60. package/dist/publickeys.d.ts +41 -0
  61. package/dist/publickeys.js +232 -0
  62. package/dist/random.d.ts +29 -0
  63. package/dist/random.js +165 -0
  64. package/dist/repeats.d.ts +21 -0
  65. package/dist/repeats.js +80 -0
  66. package/dist/rules.d.ts +42 -0
  67. package/dist/rules.js +58 -0
  68. package/dist/runs.d.ts +10 -0
  69. package/dist/runs.js +36 -0
  70. package/dist/secretfile.d.ts +52 -0
  71. package/dist/secretfile.js +722 -0
  72. package/dist/shell.d.ts +127 -0
  73. package/dist/shell.js +703 -0
  74. package/dist/spans.d.ts +61 -0
  75. package/dist/spans.js +287 -0
  76. package/dist/sql.d.ts +20 -0
  77. package/dist/sql.js +66 -0
  78. package/dist/stream.d.ts +46 -0
  79. package/dist/stream.js +150 -0
  80. package/dist/structure.d.ts +24 -0
  81. package/dist/structure.js +1625 -0
  82. package/dist/syntax.d.ts +156 -0
  83. package/dist/syntax.js +446 -0
  84. package/dist/transcript.d.ts +30 -0
  85. package/dist/transcript.js +713 -0
  86. package/dist/types.d.ts +81 -0
  87. package/dist/types.js +1 -0
  88. package/dist/unnamed.d.ts +59 -0
  89. package/dist/unnamed.js +939 -0
  90. package/dist/urls.d.ts +70 -0
  91. package/dist/urls.js +303 -0
  92. package/dist/values.d.ts +178 -0
  93. package/dist/values.js +1621 -0
  94. package/dist/yaml.d.ts +40 -0
  95. package/dist/yaml.js +174 -0
  96. package/docs/creddata.svg +318 -0
  97. package/docs/guessing.md +139 -0
  98. package/docs/holdout.svg +85 -0
  99. package/docs/layers.md +80 -0
  100. package/docs/models.svg +81 -0
  101. package/docs/real-text.svg +112 -0
  102. package/package.json +57 -5
@@ -0,0 +1,713 @@
1
+ /**
2
+ * Redacting a whole transcript or request body: any JSON value, typically a list of chat messages in
3
+ * the Anthropic or OpenAI format. Two passes:
4
+ *
5
+ * 1. Every string is scanned, with the command or path of the tool call that produced it as a hint
6
+ * (a `tool_result` is matched to its `tool_use` by id), and every confident finding is learned as a
7
+ * known value.
8
+ * 2. Every string is redacted, so a secret found once (`API_KEY=…` in a `cat .env`) is also hidden where
9
+ * it appears with no context at all (the model echoing it, a later `curl` using it).
10
+ *
11
+ * The value is redacted as it will be sent: what `JSON.stringify` makes of it (jsonShape). Object keys are scanned like
12
+ * strings, and a key that holds a secret is renamed to its redacted text. The envelope's IDs (unless one holds a vendor
13
+ * token), roles, model names and signatures (not a tool's payload's: isEnvelope), and binary base64 media are left
14
+ * alone; base64 of text (a `text/plain` document, an SVG) is decoded and redacted as text. Values under a
15
+ * credential-named key (`{"api_key": "…"}`) are judged by that name. With `learn: false`, what pass 1 finds is hidden
16
+ * across this value only, not remembered for later calls.
17
+ */
18
+ import { isSecretFile } from "./commands.js";
19
+ import { base64Binary } from "./base64.js";
20
+ import { findFormats } from "./formats.js";
21
+ import { parseJson, TOOL_COMMAND_KEYS, TOOL_CONTENT_KEYS, TOOL_PATH_KEYS } from "./json.js";
22
+ import { shellLine } from "./shell.js";
23
+ import { isLearnable, isNeverSecretShape, loginPassword, merge, secretValue } from "./spans.js";
24
+ import { occurrences } from "./repeats.js";
25
+ import { isCredentialName } from "./names.js";
26
+ import { isNamedSecret } from "./named.js";
27
+ /** Keys whose values are IDs the provider minted (`toolu_01Ab…`, `chatcmpl-B9…`): one token, no spaces. */
28
+ const ID_KEYS = new Set(["id", "tool_use_id", "tool_call_id", "call_id"]);
29
+ /** Keys whose values are opaque blobs the provider signed or encrypted, which no one reads. */
30
+ const OPAQUE_KEYS = new Set(["signature", "thoughtSignature", "thought_signature", "encrypted_content"]);
31
+ /** Keys whose values are a short enum or a model name (`assistant`, `end_turn`, `image/png`, `claude-sonnet-4-5-20250929`). */
32
+ const ENUM_KEYS = new Set([
33
+ "type",
34
+ "role",
35
+ "model",
36
+ "stop_reason",
37
+ "finish_reason",
38
+ "media_type",
39
+ "object",
40
+ "status",
41
+ "mime_type",
42
+ "detail",
43
+ "format",
44
+ ]);
45
+ /** Other keys of an envelope whose scalars are bookkeeping (`created`, `index`). */
46
+ const COUNTER_KEYS = new Set(["created", "index", "logprobs"]);
47
+ /** An ID's shape: no spaces, no quotes or other punctuation a sentence or a `name=value` pair has. */
48
+ const ID_VALUE = /^[\w.:@/+-]{1,256}={0,2}$/;
49
+ /** An enum's shape: words of at most 20 characters, joined by `-`, `_`, `.`, `:`, `/`, `@` or a space (a token pasted in is one long word). */
50
+ const ENUM_VALUE = /^[A-Za-z][A-Za-z0-9]{0,19}(?:[-_.:/@ ][A-Za-z0-9]{1,20}){0,8}$/;
51
+ /** Base64 or base64url, padded or not. */
52
+ const BASE64_VALUE = /^[A-Za-z0-9+/_-]*={0,2}$/;
53
+ /**
54
+ * Whether the scalar `x` under `key` on an envelope is structure that no secret sits in: the key says what it is and the
55
+ * value has that shape (a `status` holding `ghp_…`, or an `id` holding `password=…`, is scanned). An ID is also scanned for
56
+ * vendor token formats (`toolu_ghp_…`), but never by the random-token layer, which would break the pairing of a tool call
57
+ * and its result on a provider's ID.
58
+ */
59
+ function isStructure(key, x) {
60
+ if (x === null || x === undefined || typeof x === "number")
61
+ return ID_KEYS.has(key) || ENUM_KEYS.has(key) || OPAQUE_KEYS.has(key) || COUNTER_KEYS.has(key);
62
+ if (typeof x !== "string")
63
+ return false;
64
+ return ((ID_KEYS.has(key) && ID_VALUE.test(x) && findFormats(x).length === 0) ||
65
+ (OPAQUE_KEYS.has(key) && BASE64_VALUE.test(x)) ||
66
+ (ENUM_KEYS.has(key) && ENUM_VALUE.test(x)) ||
67
+ (COUNTER_KEYS.has(key) && x.length <= 20));
68
+ }
69
+ /** The `type`s of messages and content blocks, Anthropic's, OpenAI's (Chat and Responses) and Claude Code's session lines. */
70
+ const BLOCK_TYPES = new Set([
71
+ "message",
72
+ "text",
73
+ "tool_use",
74
+ "tool_result",
75
+ "server_tool_use",
76
+ "web_search_tool_result",
77
+ "mcp_tool_use",
78
+ "mcp_tool_result",
79
+ "thinking",
80
+ "redacted_thinking",
81
+ "image",
82
+ "document",
83
+ "image_url",
84
+ "input_image",
85
+ "input_text",
86
+ "output_text",
87
+ "refusal",
88
+ "reasoning",
89
+ "function",
90
+ "function_call",
91
+ "function_call_output",
92
+ "local_shell_call",
93
+ "local_shell_call_output",
94
+ "computer_call",
95
+ "computer_call_output",
96
+ "file_search_call",
97
+ "web_search_call",
98
+ "user",
99
+ "assistant",
100
+ "system",
101
+ "summary",
102
+ "result",
103
+ ]);
104
+ /**
105
+ * Whether an object is part of the envelope a transcript is carried in, a message, a content block or an API response,
106
+ * whose `id`, `type`, `status` or `detail` are structure; not a payload (a tool's structured result, an error body), where
107
+ * the same keys hold content (`"detail": "Invalid API key: sk-…"`).
108
+ */
109
+ function isEnvelope(o) {
110
+ return (typeof o.role === "string" ||
111
+ (typeof o.type === "string" && BLOCK_TYPES.has(o.type)) ||
112
+ typeof o.tool_call_id === "string" ||
113
+ typeof o.tool_use_id === "string" ||
114
+ (typeof o.object === "string" && typeof o.model === "string"));
115
+ }
116
+ /** Keys of tool inputs that hold what a tool writes to the file at its path (`Write`, `create_file`, `str_replace`). */
117
+ const CONTENT_KEYS = new Set(TOOL_CONTENT_KEYS);
118
+ /** Files a patch adds or changes: `*** Add File: x` (apply_patch), `+++ b/x` (unified diff). */
119
+ const PATCH_FILE = /^(?:\*\*\* (?:Add|Update) File: |\+\+\+ (?:b\/)?)(\S+)/gm;
120
+ function pathOf(step) {
121
+ const keys = [];
122
+ for (let s = step; s; s = s.parent)
123
+ keys.push(s.key);
124
+ return keys.reverse();
125
+ }
126
+ /**
127
+ * Walks a JSON value without recursion (any depth), calling `visit` on every value in document order, each object's
128
+ * members once: a shared object, or a cycle, is visited at every path but walked into at its first. The members are read
129
+ * after `visit` returns, so it may rewrite its object. `scope` is what the walk carries down, starting as `root`'s.
130
+ */
131
+ function walk(root, scope, visit) {
132
+ const walked = new WeakSet();
133
+ const stack = [{ value: root, step: undefined, scope, key: undefined, parent: undefined, repeat: false }];
134
+ while (stack.length) {
135
+ const at = stack.pop();
136
+ const v = at.value;
137
+ const isObject = !!v && typeof v === "object";
138
+ at.repeat = isObject && walked.has(v);
139
+ const next = visit(at);
140
+ if (next === "stop")
141
+ return;
142
+ if (!isObject || at.repeat)
143
+ continue;
144
+ walked.add(v);
145
+ if (next === "skip")
146
+ continue;
147
+ const isArray = Array.isArray(v);
148
+ const entries = Object.entries(v);
149
+ for (let i = entries.length - 1; i >= 0; i--) {
150
+ const [name, member] = entries[i];
151
+ const k = isArray && String(+name) === name ? +name : name;
152
+ const into = typeof next === "function" ? next(k, member) : at.scope;
153
+ if (into !== undefined)
154
+ stack.push({
155
+ value: member,
156
+ step: { parent: at.step, key: k },
157
+ scope: into,
158
+ key: isArray ? at.key : name,
159
+ parent: v,
160
+ repeat: false,
161
+ });
162
+ }
163
+ }
164
+ }
165
+ /** Hints for tool results, from the tool calls that produced them, by tool call id. */
166
+ function toolHints(value) {
167
+ const hints = new Map();
168
+ walk(value, null, ({ value: v }) => {
169
+ if (!v || typeof v !== "object" || Array.isArray(v))
170
+ return;
171
+ const o = v;
172
+ // Anthropic: {type: "tool_use", id, input: {...}}. OpenAI: {id, type: "function", function: {arguments: "<json>"}}.
173
+ // OpenAI Responses: {type: "function_call", call_id, arguments}; {type: "local_shell_call", call_id, action: {command: [...]}}.
174
+ let input = o.input ?? o.action ?? o.arguments;
175
+ const fn = o.function && typeof o.function === "object" ? o.function.arguments : undefined;
176
+ if (fn !== undefined && fn !== null)
177
+ input = fn;
178
+ if (typeof input === "string") {
179
+ try {
180
+ input = JSON.parse(input);
181
+ }
182
+ catch { }
183
+ }
184
+ const id = (typeof o.call_id === "string" ? o.call_id : undefined) ?? (typeof o.id === "string" ? o.id : undefined);
185
+ if (!id || !input || typeof input !== "object")
186
+ return;
187
+ const i = input;
188
+ const asText = (x) => typeof x === "string" ? x : Array.isArray(x) && x.every((y) => typeof y === "string") ? shellLine(x) : undefined;
189
+ const command = TOOL_COMMAND_KEYS.map((k) => asText(i[k])).find((x) => x !== undefined);
190
+ // (A call that writes a file, its content in its input, returns a status line, not the file: no path hint.)
191
+ const writes = Object.keys(i).some((k) => CONTENT_KEYS.has(k));
192
+ const path = writes
193
+ ? undefined
194
+ : TOOL_PATH_KEYS.map((k) => i[k]).find((x) => typeof x === "string");
195
+ if (command || path)
196
+ hints.set(id, { command, path });
197
+ });
198
+ return hints;
199
+ }
200
+ /** A base64 data URI of binary media. Other strings that start with `data:` (SSE lines, text data URIs) are scanned. */
201
+ const MEDIA_URI = /^data:(?:image|audio|video|font)\/[\w.+-]+(?:;[\w.+-]+=[^;,]*)*;base64,[\sA-Za-z0-9+/=_-]*$|^data:application\/(?:pdf|octet-stream|zip)(?:;[\w.+-]+=[^;,]*)*;base64,[\sA-Za-z0-9+/=_-]*$/;
202
+ /** Media types that are text: `text/*`, JSON, XML (SVG included), YAML, TOML and other languages, with a `+json`, `+xml` or `+yaml` suffix. */
203
+ const TEXT_MEDIA_TYPE = /^(?:text\/[\w.+-]+|application\/(?:json|xml|ya?ml|x-ya?ml|toml|javascript|x-sh|sql|x-ndjson|x-www-form-urlencoded)|[\w.-]+\/[\w.-]+\+(?:json|xml|ya?ml))$/i;
204
+ /** A base64 data URI: its media type (group 1), its parameters and `;base64,` (group 2), its payload (group 3). */
205
+ const BASE64_URI = /^data:([\w.+-]+\/[\w.+-]+)((?:;[\w.+-]+=[^;,]*)*;base64,)([\sA-Za-z0-9+/=_-]*)$/;
206
+ /** How many bytes are turned into characters at a time when encoding (a spread of more arguments overflows the stack). */
207
+ const ENCODE_CHUNK = 8192;
208
+ /** The text base64 `payload` encodes, or undefined if it isn't base64 of UTF-8 text. */
209
+ function decodeBase64Text(payload) {
210
+ const binary = base64Binary(payload.replace(/\s/g, ""));
211
+ if (binary === undefined)
212
+ return undefined;
213
+ try {
214
+ return new TextDecoder("utf-8", { fatal: true }).decode(Uint8Array.from(binary, (c) => c.charCodeAt(0)));
215
+ }
216
+ catch {
217
+ return undefined;
218
+ }
219
+ }
220
+ /** `text` as base64, in the alphabet and padding `like` (the payload it replaces) was written in. */
221
+ function encodeBase64Text(text, like) {
222
+ const bytes = new TextEncoder().encode(text);
223
+ let binary = "";
224
+ for (let i = 0; i < bytes.length; i += ENCODE_CHUNK)
225
+ binary += String.fromCharCode(...bytes.subarray(i, i + ENCODE_CHUNK));
226
+ let b64 = btoa(binary);
227
+ if (/[-_]/.test(like))
228
+ b64 = b64.replace(/\+/g, "-").replace(/\//g, "_");
229
+ return /=\s*$/.test(like) ? b64 : b64.replace(/=+$/, "");
230
+ }
231
+ /**
232
+ * The text a base64 string holds, when its media type says text: `data` under a `media_type` of its object
233
+ * (`{type: "base64", media_type: "text/plain", data}`), or the payload of a text data URI (`data:image/svg+xml;base64,…`).
234
+ * `encode` writes the redacted text back the way it was written. Undefined for binary media.
235
+ */
236
+ function base64Text(o, key, x) {
237
+ const uri = BASE64_URI.exec(x);
238
+ const textual = uri
239
+ ? TEXT_MEDIA_TYPE.test(uri[1])
240
+ : key === "data" && typeof o.media_type === "string" && TEXT_MEDIA_TYPE.test(o.media_type.split(";")[0].trim());
241
+ const payload = uri ? uri[3] : x;
242
+ const text = textual ? decodeBase64Text(payload) : undefined;
243
+ return text === undefined
244
+ ? undefined
245
+ : { text, encode: (redacted) => (uri ? `data:${uri[1]}${uri[2]}` : "") + encodeBase64Text(redacted, payload) };
246
+ }
247
+ /** A document block's file name, from its title or file name: the path hint its content is judged with. */
248
+ function documentName(o) {
249
+ return [o.title, o.filename, o.file_name].find((x) => typeof x === "string" && x.length > 0 && x.length <= 4096 && !x.includes("\n"));
250
+ }
251
+ /** Keys whose values are a tool's own input: everything inside is scanned, whatever its key. */
252
+ const TOOL_INPUT_KEYS = new Set(["input", "arguments", "parameters", "args", "action"]);
253
+ /**
254
+ * The strings of a value worth scanning, each with the hint its text is judged with: a tool result inherits its
255
+ * call's; a tool input that writes a file has its content judged as that file. Envelope fields (ids, roles,
256
+ * signatures), base64 media and, with `keepThinking`, thinking blocks are left out.
257
+ */
258
+ function collect(value, hints, keepThinking) {
259
+ const leaves = [];
260
+ walk(value, { hint: {}, free: false }, ({ value: v, step, scope, key, parent, repeat }) => {
261
+ // (A number under a credential's name is one too: `"db_password": 98127734`, `"pin": 4821`. Only a safe integer
262
+ // prints as its digits; a larger one, JSON.parse has already rounded.)
263
+ if (typeof v === "number" && key && Number.isSafeInteger(v) && isCredentialName(key)) {
264
+ leaves.push({
265
+ get path() {
266
+ return pathOf(step);
267
+ },
268
+ text: String(v),
269
+ hint: scope.hint,
270
+ key,
271
+ parent,
272
+ });
273
+ return;
274
+ }
275
+ if (typeof v === "string") {
276
+ const { decoded } = scope;
277
+ // (Under four characters is no secret anywhere: looksSecret's floor, so no name makes one.)
278
+ if ((decoded ? decoded.text.length : v.length) < 4)
279
+ return;
280
+ if (decoded) {
281
+ leaves.push({
282
+ get path() {
283
+ return pathOf(step);
284
+ },
285
+ text: decoded.text,
286
+ hint: scope.hint,
287
+ encode: decoded.encode,
288
+ });
289
+ return;
290
+ }
291
+ let hint = scope.hint;
292
+ // A patch inside a tool's input that writes a secret file is read as that file.
293
+ if (scope.free && !hint.path && /^(?:\*\*\* (?:Add|Update) File: |\+\+\+ )/m.test(v)) {
294
+ const file = [...v.matchAll(PATCH_FILE)].map((m) => m[1]).find((f) => isSecretFile(f));
295
+ if (file)
296
+ hint = { path: file };
297
+ }
298
+ leaves.push({
299
+ get path() {
300
+ return pathOf(step);
301
+ },
302
+ text: v,
303
+ hint,
304
+ key,
305
+ parent,
306
+ });
307
+ return;
308
+ }
309
+ if (!v || typeof v !== "object" || repeat || Array.isArray(v))
310
+ return;
311
+ const o = v;
312
+ if (keepThinking && (o.type === "thinking" || o.type === "redacted_thinking"))
313
+ return "skip";
314
+ // A tool result inherits the hint of its call; OpenAI tool messages carry `tool_call_id`.
315
+ const id = (o.tool_use_id ??
316
+ o.tool_call_id ??
317
+ (o.type === "function_call_output" || o.type === "local_shell_call_output" ? o.call_id : undefined));
318
+ const hint = typeof id === "string" && hints.has(id) ? hints.get(id) : scope.hint;
319
+ // Base64 media: `{type: "base64", media_type, data}`, `image_url: {url: "data:…"}`.
320
+ const media = o.type === "base64" ||
321
+ o.type === "image" ||
322
+ o.type === "image_url" ||
323
+ o.type === "input_image" ||
324
+ o.type === "document";
325
+ // A tool input that writes a file: its content is judged as that file (`.env` content as a secret file).
326
+ const writes = TOOL_PATH_KEYS.map((k) => o[k]).find((x) => typeof x === "string");
327
+ const envelope = !scope.free && isEnvelope(o);
328
+ // (An attached file's name: Anthropic's `document` block, OpenAI's `input_file` part and Chat Completions' `file`.)
329
+ const name = !hint.path && (o.type === "document" || o.type === "input_file" || "file_data" in o)
330
+ ? documentName(o)
331
+ : undefined;
332
+ return (field, x) => {
333
+ const k = field;
334
+ // A key is scanned like a string (under four characters is no secret), whatever its value is.
335
+ if (k.length >= 4)
336
+ leaves.push({
337
+ get path() {
338
+ return pathOf({ parent: step, key: k });
339
+ },
340
+ text: k,
341
+ hint: {},
342
+ isKey: true,
343
+ });
344
+ // Envelope fields (ids, roles, signatures) are skipped; inside a tool's input, or a payload, every string is data.
345
+ // (A media object's own `type` and `media_type` too: under a `.env` document's name they'd read as its values.)
346
+ if ((envelope && isStructure(k, x)) || (media && (k === "type" || k === "media_type")))
347
+ return undefined;
348
+ // (`edits` of a MultiEdit call: each edit's strings are judged as the file too. A Claude Code record's
349
+ // `toolUseResult` is a second copy of the result in its `message`, and has that result's call's hint.)
350
+ const childHint = name && (k === "source" || k === "file_data")
351
+ ? { ...hint, path: name }
352
+ : writes && CONTENT_KEYS.has(k)
353
+ ? { path: writes }
354
+ : k === "toolUseResult"
355
+ ? (recordHint(o, hints) ?? hint)
356
+ : hint;
357
+ // Base64 of text is decoded and scanned as text; other base64 payloads are skipped; URLs are scanned (a presigned URL
358
+ // carries a credential) unless they're data: URIs.
359
+ const decoded = typeof x === "string" && (media || BASE64_URI.test(x)) ? base64Text(o, k, x) : undefined;
360
+ if (decoded)
361
+ return { hint: childHint, free: scope.free, decoded };
362
+ if ((media && k === "data" && typeof x === "string" && BASE64_VALUE.test(x.replace(/\s/g, ""))) ||
363
+ (typeof x === "string" && MEDIA_URI.test(x)))
364
+ return undefined;
365
+ return { hint: childHint, free: scope.free || TOOL_INPUT_KEYS.has(k) };
366
+ };
367
+ });
368
+ return leaves;
369
+ }
370
+ /** The hint of the tool call whose result a Claude Code record's `message` holds (`content[].tool_use_id`). */
371
+ function recordHint(record, hints) {
372
+ const content = record.message?.content;
373
+ if (!Array.isArray(content))
374
+ return undefined;
375
+ for (const block of content) {
376
+ const id = block?.tool_use_id;
377
+ if (typeof id === "string" && hints.has(id))
378
+ return hints.get(id);
379
+ }
380
+ return undefined;
381
+ }
382
+ /** A deep copy of a JSON value, without recursion, so any depth works (`structuredClone` overflows the stack at a few thousand levels). */
383
+ function cloneJson(value) {
384
+ if (!value || typeof value !== "object")
385
+ return value;
386
+ // Each object copied once: shared references stay shared, and a cycle is copied as a cycle. The scope is the copy of the
387
+ // object a value is in.
388
+ const copies = new WeakMap();
389
+ let root;
390
+ walk(value, {}, ({ value: v, step, scope }) => {
391
+ let copy = v;
392
+ if (v && typeof v === "object") {
393
+ copy = copies.get(v);
394
+ if (!copy)
395
+ copies.set(v, (copy = Array.isArray(v) ? [] : {}));
396
+ }
397
+ // (A `__proto__` key from JSON.parse stays a key.)
398
+ if (step)
399
+ Object.defineProperty(scope, step.key, { value: copy, writable: true, enumerable: true, configurable: true });
400
+ else
401
+ root = copy;
402
+ return () => copy;
403
+ });
404
+ return root;
405
+ }
406
+ /** Sets a string inside a value already cloned for this purpose. */
407
+ function setAt(root, path, value) {
408
+ if (!path.length)
409
+ return value;
410
+ let node = root;
411
+ for (let i = 0; i < path.length - 1; i++)
412
+ node = node[path[i]];
413
+ node[path[path.length - 1]] = value;
414
+ return root;
415
+ }
416
+ /** Whether a leaf's whole text is a secret by the key it sits under (`{"api_key": "…"}`), as the decision stage judges it there. */
417
+ function isNamedKeySecret(leaf, keep) {
418
+ return (!!leaf.key &&
419
+ isCredentialName(leaf.key) &&
420
+ isNamedSecret(leaf.key, JSON.stringify(leaf.text), true) &&
421
+ keep({ start: 0, end: leaf.text.length, kind: "secret", rule: "named:key", confidence: 0.95 }));
422
+ }
423
+ /**
424
+ * Whether a value is plain JSON data already, so that JSON.stringify would send exactly its strings, keys and
425
+ * numbers: only strings, finite numbers, booleans, null, arrays and plain objects (no `toJSON`), each object once.
426
+ */
427
+ function isPlainJson(root) {
428
+ let plain = true;
429
+ walk(root, null, ({ value: v, repeat }) => {
430
+ if (v === null ||
431
+ typeof v === "string" ||
432
+ typeof v === "boolean" ||
433
+ (typeof v === "number" && Number.isFinite(v)) ||
434
+ repeat)
435
+ return;
436
+ if (typeof v === "object") {
437
+ const proto = Object.getPrototypeOf(v);
438
+ if ((proto === (Array.isArray(v) ? Array.prototype : Object.prototype) || (proto === null && !Array.isArray(v))) &&
439
+ typeof v.toJSON !== "function")
440
+ return;
441
+ }
442
+ plain = false;
443
+ return "stop";
444
+ });
445
+ return plain;
446
+ }
447
+ /**
448
+ * The value as it will be sent: itself if it is plain JSON data, otherwise what JSON.stringify makes of it, parsed
449
+ * (`toJSON` called, a `Map` or a class instance as its enumerable members, a `Date` as its string, `NaN` as null, a
450
+ * function or `undefined` dropped). A BigInt throws, as it does when sent; so does a cycle in such a value. A value
451
+ * with no JSON form (`undefined`, a function) is returned as it is.
452
+ */
453
+ function jsonShape(value) {
454
+ if (isPlainJson(value))
455
+ return value;
456
+ const text = JSON.stringify(value);
457
+ return text === undefined ? value : JSON.parse(text);
458
+ }
459
+ /** `root` with every key in `renames` renamed in place, each object's keys in their order. */
460
+ function renameKeys(root, renames) {
461
+ walk(root, null, ({ value: v }) => {
462
+ if (!v || typeof v !== "object" || Array.isArray(v) || !Object.keys(v).some((k) => renames.has(k)))
463
+ return;
464
+ const entries = Object.entries(v);
465
+ for (const [k] of entries)
466
+ delete v[k];
467
+ for (const [k, x] of entries) {
468
+ let name = renames.get(k) ?? k;
469
+ // (Two keys hidden to the same text keep both.)
470
+ while (Object.hasOwn(v, name))
471
+ name += "'";
472
+ Object.defineProperty(v, name, { value: x, writable: true, enumerable: true, configurable: true });
473
+ }
474
+ });
475
+ }
476
+ /** A copy of `value` with every secret in every string and key replaced, and the findings by JSON path. */
477
+ export function redactValue(redactor, value, options = {}) {
478
+ // Tool arguments sent as a JSON string (OpenAI) are scanned as the object they encode, and encoded
479
+ // back afterwards; one with nothing redacted inside keeps its original string.
480
+ const encoded = [];
481
+ const work = decodeArguments(jsonShape(value), encoded);
482
+ value = work;
483
+ const hints = toolHints(value);
484
+ const leaves = collect(value, hints, options.thinking === "keep");
485
+ // A leaf is detected and judged where it sits: in its object, serialized, under its key, so every stage sees what it
486
+ // would see in the same JSON as text (a `{name: "DB_PASSWORD", value: …}` pair, a Firebase config's siblings, a
487
+ // JWK's `kty`, `anon_key`, an `…Id`). A leaf with a hint of its own (a tool's output) is its own text.
488
+ const serialized = new WeakMap();
489
+ const inParents = new Map();
490
+ const found = (leaf) => {
491
+ const alone = redactor.detect(leaf.text, { hint: leaf.hint, guessing: options.guessing });
492
+ const ctx = leaf.hint.command === undefined && leaf.hint.path === undefined ? leafContext(leaf, serialized) : undefined;
493
+ if (!ctx)
494
+ return alone;
495
+ let inParent = inParents.get(ctx.text);
496
+ if (!inParent)
497
+ inParents.set(ctx.text, (inParent = redactor.detect(ctx.text, { guessing: options.guessing })));
498
+ const end = ctx.at + leaf.text.length;
499
+ const mine = inParent
500
+ .filter((f) => f.start >= ctx.at && f.end <= end)
501
+ .map((f) => ({ ...f, start: f.start - ctx.at, end: f.end - ctx.at }));
502
+ return mine.length ? merge([...alone, ...mine]) : alone;
503
+ };
504
+ const judged = (leaf) => {
505
+ const ctx = leafContext(leaf, serialized);
506
+ // (The key names only a finding that is the whole leaf; inside a longer text, the name is the text's own.)
507
+ return (f) => {
508
+ const name = f.name ?? (f.start === 0 && f.end === leaf.text.length ? leaf.key : undefined);
509
+ const g = name ? { ...f, name } : f;
510
+ return ctx
511
+ ? redactor.judge(ctx.text, { ...g, start: f.start + ctx.at, end: f.end + ctx.at })
512
+ : redactor.judge(leaf.text, g);
513
+ };
514
+ };
515
+ // Pass 1: what's confidently secret anywhere: learned as known values, or, with `learn: false`, looked for again in
516
+ // this value's other leaves only.
517
+ // (Learned all at once after the pass: adding values rebuilds the short-value matcher, once per batch.)
518
+ const learned = [];
519
+ for (const leaf of leaves) {
520
+ const keep = judged(leaf);
521
+ if (isNamedKeySecret(leaf, keep) && !isNeverSecretShape(leaf.text))
522
+ learned.push([leaf.text, leaf.key]);
523
+ for (const f of found(leaf).filter(keep)) {
524
+ const secret = secretValue(leaf.text, f);
525
+ if (!isLearnable(f, secret))
526
+ continue;
527
+ learned.push([secret, f.kind]);
528
+ const password = loginPassword(f, secret);
529
+ if (password)
530
+ learned.push([password, f.kind]);
531
+ }
532
+ }
533
+ const remember = options.learn !== false;
534
+ if (remember)
535
+ for (const [value, label] of learned)
536
+ redactor.addKnown(value, label);
537
+ const seen = remember ? undefined : new Map(learned.map(([value, kind]) => [value, { kind, value }]));
538
+ // Pass 2: redact, into one deep copy of the value.
539
+ let out = leaves.length ? cloneJson(value) : value;
540
+ const findings = [];
541
+ // (Object keys hidden: the key's text, and what it is replaced by.)
542
+ const renames = new Map();
543
+ for (const leaf of leaves) {
544
+ const keep = judged(leaf);
545
+ if (isNamedKeySecret(leaf, keep)) {
546
+ findings.push({
547
+ start: 0,
548
+ end: leaf.text.length,
549
+ kind: "secret",
550
+ rule: "transcript:named-key",
551
+ confidence: 0.95,
552
+ path: leaf.path,
553
+ value: leaf.text,
554
+ });
555
+ out = setAt(out, leaf.path, redactor.placeholderFor({ start: 0, end: leaf.text.length, kind: leaf.key, rule: "transcript:named-key", confidence: 0.95 }, leaf.text));
556
+ continue;
557
+ }
558
+ const here = found(leaf).filter(keep);
559
+ const r = redactor.apply(leaf.text, seen?.size ? merge([...here, ...occurrences(leaf.text, seen, here)]) : here);
560
+ if (!r.findings.length)
561
+ continue;
562
+ for (const f of r.findings)
563
+ findings.push({ ...f, path: leaf.path, ...(leaf.isKey ? { inKey: true } : {}) });
564
+ if (leaf.isKey)
565
+ renames.set(leaf.text, r.text);
566
+ else
567
+ out = setAt(out, leaf.path, leaf.encode ? leaf.encode(r.text) : r.text);
568
+ }
569
+ for (const { path, original, node } of encoded.reverse()) {
570
+ const inside = findings.filter((f) => path.every((p, i) => f.path[i] === p));
571
+ if (!inside.length) {
572
+ out = setAt(out, path, original);
573
+ continue;
574
+ }
575
+ // (A renamed key has no place in the original's text: that string is written again from the redacted object.)
576
+ const renamed = inside.some((f) => f.inKey);
577
+ if (renamed)
578
+ renameKeys(getAt(out, path), renames);
579
+ out = setAt(out, path, renamed ? JSON.stringify(getAt(out, path)) : spliced(original, node, getAt(out, path)));
580
+ }
581
+ if (renames.size)
582
+ renameKeys(out, renames);
583
+ return { value: out, findings };
584
+ }
585
+ /**
586
+ * The leaf's object serialized, or its list of strings (an argv: `["docker", "login", "-p", "…"]`), and where the leaf's
587
+ * text starts in it; undefined when it can't be placed exactly.
588
+ */
589
+ function leafContext(leaf, serialized) {
590
+ const parent = leaf.parent;
591
+ if (!parent || JSON.stringify(leaf.text) !== `"${leaf.text}"`)
592
+ return undefined;
593
+ if (Array.isArray(parent)) {
594
+ const index = leaf.path.at(-1);
595
+ if (typeof index !== "number" || parent.length > 200 || !parent.every((x) => typeof x === "string"))
596
+ return undefined;
597
+ if (!serialized.has(parent))
598
+ serialized.set(parent, JSON.stringify(parent));
599
+ const text = serialized.get(parent);
600
+ const at = 1 + parent.slice(0, index).reduce((n, x) => n + JSON.stringify(x).length + 1, 0) + 1;
601
+ return text.length <= 20_000 && text.slice(at, at + leaf.text.length) === leaf.text ? { text, at } : undefined;
602
+ }
603
+ if (leaf.key === undefined)
604
+ return undefined;
605
+ // (Once per object: a big object's leaves share it. Past 20,000 characters, or with a cycle, no context.)
606
+ if (!serialized.has(parent)) {
607
+ let s;
608
+ try {
609
+ // (Its own members only: nested objects and lists as `{}` and `[]`, so a deep value costs each level once.)
610
+ s =
611
+ Object.keys(parent).length <= 200
612
+ ? JSON.stringify(Object.fromEntries(Object.entries(parent).map(([k, v]) => [
613
+ k,
614
+ v !== null && typeof v === "object" ? (Array.isArray(v) ? [] : {}) : v,
615
+ ])))
616
+ : undefined;
617
+ }
618
+ catch {
619
+ s = undefined;
620
+ }
621
+ serialized.set(parent, s !== undefined && s.length <= 20_000 ? s : undefined);
622
+ }
623
+ const text = serialized.get(parent);
624
+ if (text === undefined)
625
+ return undefined;
626
+ const needle = `${JSON.stringify(leaf.key)}:"${leaf.text}"`;
627
+ const i = text.indexOf(needle);
628
+ return i < 0 ? undefined : { text, at: i + needle.length - 1 - leaf.text.length };
629
+ }
630
+ /**
631
+ * The `arguments` or `output` JSON string `original` with the scalars that redaction changed (`redacted` is the value it parsed
632
+ * to, redacted) written into it in place: the rest of the string stays as sent, a 64-bit ID's digits, number
633
+ * spellings, spacing and escapes included. (decodeArguments only decodes what parseJson reads whole.)
634
+ */
635
+ function spliced(original, node, redacted) {
636
+ const edits = [];
637
+ const walk = (node, value) => {
638
+ if (node.kind === "object")
639
+ for (const [k, x] of node.entries)
640
+ walk(x, value[k]);
641
+ else if (node.kind === "array")
642
+ node.items.forEach((x, i) => walk(x, value[i]));
643
+ // A string's span is its content; a number's, the number: the replacement is quoted either way.
644
+ else if (node.value !== value && typeof value === "string")
645
+ edits.push(node.kind === "string"
646
+ ? [node.start - 1, node.end + 1, JSON.stringify(value)]
647
+ : [node.start, node.end, JSON.stringify(value)]);
648
+ };
649
+ walk(node, redacted);
650
+ const pieces = [];
651
+ let at = 0;
652
+ for (const [start, end, text] of edits.sort((a, b) => a[0] - b[0])) {
653
+ pieces.push(original.slice(at, start), text);
654
+ at = end;
655
+ }
656
+ pieces.push(original.slice(at));
657
+ return pieces.join("");
658
+ }
659
+ /** Whether every object in a parsed JSON value has its keys once, so the value JSON.parse gives holds all of it. */
660
+ function uniqueKeys(node) {
661
+ if (node.kind === "array")
662
+ return node.items.every(uniqueKeys);
663
+ if (node.kind !== "object")
664
+ return true;
665
+ return (new Set(node.entries.map(([k]) => k)).size === node.entries.length && node.entries.every(([, x]) => uniqueKeys(x)));
666
+ }
667
+ /**
668
+ * A copy of `value` with every tool call's `arguments` and tool result's `output` sent as a JSON string parsed, and
669
+ * where each was and its parse; `value` itself if none. A result's members then carry its call's hint (Codex's
670
+ * `{"output": "<what cat .env printed>", …}`). Only a string parseJson reads whole and JSON.parse gives every part of
671
+ * (no key twice, no raw control character) is decoded; another is scanned as the text it is.
672
+ */
673
+ function decodeArguments(value, encoded) {
674
+ const found = [];
675
+ walk(value, null, ({ value: v, step }) => {
676
+ // (A tool call's `arguments`; a tool result's `output` that is an object, Codex's `{"output": …, "metadata": …}`.)
677
+ if (typeof v !== "string" ||
678
+ !(step?.key === "arguments" || (step?.key === "output" && /^\s*\{/.test(v))) ||
679
+ !/^\s*[{[]/.test(v))
680
+ return;
681
+ const parsed = parseJson(v, 0);
682
+ if (!parsed || v.slice(parsed.end).trim() || !uniqueKeys(parsed.node))
683
+ return;
684
+ // (An `output` only as Codex wraps one; a command's own JSON output is its text, read whole under its hint.)
685
+ if (step.key === "output" &&
686
+ !(parsed.node.kind === "object" &&
687
+ parsed.node.entries.some(([k, x]) => /^(?:output|stdout|stderr)$/.test(k) && x.kind === "string")))
688
+ return;
689
+ // (parseJson reads what JSON.parse refuses: a raw tab or control character inside a string.)
690
+ let json;
691
+ try {
692
+ json = JSON.parse(v);
693
+ }
694
+ catch {
695
+ return;
696
+ }
697
+ found.push({ path: pathOf(step), original: v, node: parsed.node, parsed: json });
698
+ });
699
+ if (!found.length)
700
+ return value;
701
+ let copy = cloneJson(value);
702
+ for (const { parsed, ...f } of found) {
703
+ copy = setAt(copy, f.path, parsed);
704
+ encoded.push(f);
705
+ }
706
+ return copy;
707
+ }
708
+ function getAt(root, path) {
709
+ let node = root;
710
+ for (const p of path)
711
+ node = node[p];
712
+ return node;
713
+ }