@gethmy/mcp 3.6.0 → 3.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/cli.js +300 -2
- package/dist/index.js +300 -2
- package/dist/lib/api-client.js +139 -2
- package/dist/lib/config.js +1 -1
- package/dist/lib/oauth-refresh.js +1 -1
- package/dist/run-hook-cli.js +317 -8
- package/package.json +4 -3
- package/src/api-client.ts +82 -1
- package/src/config.ts +20 -3
- package/src/run-hook.ts +1 -1
- package/src/server.ts +27 -0
- package/src/run-redaction.ts +0 -483
package/src/run-redaction.ts
DELETED
|
@@ -1,483 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* What a tool call may say on a shared board (card #874).
|
|
3
|
-
*
|
|
4
|
-
* A `PostToolUse` hook sees the raw argument and the raw result of every tool
|
|
5
|
-
* call an MCP session makes, and this module decides how much of that reaches
|
|
6
|
-
* `agent_run_events`. The board is shared: a card's timeline is readable by
|
|
7
|
-
* every member of the workspace, and by anyone a card is shared with. So the
|
|
8
|
-
* question is not "can we send this" but "would we paste it into a team chat".
|
|
9
|
-
*
|
|
10
|
-
* ## Three rules, in the order they fire
|
|
11
|
-
*
|
|
12
|
-
* 1. **Withhold by path.** A tool whose input names a credential file gets its
|
|
13
|
-
* input AND its output dropped, replaced by a reason. The row survives — the
|
|
14
|
-
* timeline still shows that a `Read` happened — but the bytes never leave the
|
|
15
|
-
* machine. This is the only rule that withholds rather than edits, because a
|
|
16
|
-
* `.env` has no safe prefix to truncate to: the first line is the secret.
|
|
17
|
-
* 2. **Redact by pattern.** Anything that survives rule 1 is swept for secret
|
|
18
|
-
* SHAPES — a token, a private key block, a URL with a password in it, a
|
|
19
|
-
* `FOO_SECRET=` assignment. This catches the case rule 1 cannot see: a
|
|
20
|
-
* secret that was never in a file, like `curl -H "Authorization: Bearer …"`.
|
|
21
|
-
* 3. **Truncate.** What is left is capped, so one `Read` of a 2 MB file cannot
|
|
22
|
-
* blow the 16 KB server-side payload limit
|
|
23
|
-
* (`MAX_RUN_EVENT_PAYLOAD_BYTES`, `_shared/run-event-validation.ts`).
|
|
24
|
-
*
|
|
25
|
-
* ## Why rule 1's list mirrors `credentialDirectories()`
|
|
26
|
-
*
|
|
27
|
-
* `packages/harmony-harness/src/run-containment.ts` already answered "which
|
|
28
|
-
* directories hold a credential" for the sandbox's `denyRead` list. The same
|
|
29
|
-
* answer applies here for a different reason — that list fences a contained
|
|
30
|
-
* run OUT of those files, this one keeps their contents OFF the board — so the
|
|
31
|
-
* two are kept deliberately parallel.
|
|
32
|
-
*
|
|
33
|
-
* "Keep them parallel" was a sentence, and a sentence did not hold: the first
|
|
34
|
-
* version of this file omitted `~/.claude`, the directory holding Claude Code's
|
|
35
|
-
* own OAuth token, which this repo has already seen a run be talked into
|
|
36
|
-
* reading. So the parallel is now mechanical — `HARNESS_CREDENTIAL_LEAVES`
|
|
37
|
-
* below transcribes the harness list, and a test walks it and asserts every
|
|
38
|
-
* entry is withheld. **When the harness list grows, grow that constant**; the
|
|
39
|
-
* test then tells you whether the rules already cover the new entry.
|
|
40
|
-
*
|
|
41
|
-
* Everything here is pure and synchronous. The hook that calls it runs on the
|
|
42
|
-
* critical path of every single tool call, so it may not do I/O, and it is
|
|
43
|
-
* table-tested rather than reconstructed from a live run.
|
|
44
|
-
*/
|
|
45
|
-
|
|
46
|
-
/** Cap on the serialized tool input. */
|
|
47
|
-
export const MAX_INPUT_CHARS = 2_000;
|
|
48
|
-
/**
|
|
49
|
-
* Cap on tool output. Matches `MAX_OUTPUT_LEN` in the daemon's
|
|
50
|
-
* `cli-agent-runner.ts`, so an MCP session's rows truncate exactly where a
|
|
51
|
-
* daemon run's rows do and the two read the same on one timeline.
|
|
52
|
-
*/
|
|
53
|
-
export const MAX_OUTPUT_CHARS = 4_000;
|
|
54
|
-
/** Cap on any single string leaf inside a structured input. */
|
|
55
|
-
export const MAX_INPUT_STRING_CHARS = 600;
|
|
56
|
-
|
|
57
|
-
/** What replaces a redacted span. Distinctive on purpose — it is greppable. */
|
|
58
|
-
export const REDACTION_MARK = "«redacted»";
|
|
59
|
-
|
|
60
|
-
/** Reason codes, so a withheld row says WHY rather than just going blank. */
|
|
61
|
-
export type WithholdReason = "sensitive-path";
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Path segments that are credential stores. A path containing any of these as a
|
|
65
|
-
* whole segment is sensitive regardless of the file name inside it.
|
|
66
|
-
*
|
|
67
|
-
* Kept in step with `credentialDirectories()` in the harness — see the module
|
|
68
|
-
* doc. `.hmy` is here for the same reason it is first there: it holds this
|
|
69
|
-
* product's own API key. `.harmony-mcp` is its pre-#1082 name and stays listed
|
|
70
|
-
* FOREVER — the rename moved the daemon's writes, not the operator's old
|
|
71
|
-
* directory, which keeps that key on disk until they delete it by hand.
|
|
72
|
-
*
|
|
73
|
-
* One segment covers `~/.hmy/agent` and every future sibling: the match below
|
|
74
|
-
* walks segments, so `.hmy` needs no `CONFIG_SCOPED_SEGMENTS` special case.
|
|
75
|
-
* Both names are dot-prefixed and product-specific, so neither can collide with
|
|
76
|
-
* a source directory the way the bare `gh` / `op` entries could.
|
|
77
|
-
*/
|
|
78
|
-
const SENSITIVE_SEGMENTS: readonly string[] = [
|
|
79
|
-
".ssh",
|
|
80
|
-
".gnupg",
|
|
81
|
-
".aws",
|
|
82
|
-
".codex",
|
|
83
|
-
".gemini",
|
|
84
|
-
".docker",
|
|
85
|
-
".kube",
|
|
86
|
-
".hmy",
|
|
87
|
-
".harmony-mcp",
|
|
88
|
-
".password-store",
|
|
89
|
-
// `~/.claude` holds `.credentials.json`, Claude Code's own OAuth token, and
|
|
90
|
-
// this repo has already watched a run be talked into reading it and pasting
|
|
91
|
-
// the contents into a source comment (`confine-to-repo.ts`, `ci-repair.ts`,
|
|
92
|
-
// `ci-patch.ts` all record that measurement). The harness denies the whole
|
|
93
|
-
// directory for that reason; this list omitted it, so a `Read` of the token
|
|
94
|
-
// file would have reached the board in full.
|
|
95
|
-
//
|
|
96
|
-
// Matched UNCONDITIONALLY, not only under `$HOME`, and that is deliberate.
|
|
97
|
-
// A repo's own `.claude/settings.local.json` is gitignored precisely because
|
|
98
|
-
// it is personal, and the key it most often carries is `env` — tokens. So
|
|
99
|
-
// "project layer, therefore safe" is false, and anchoring on the home
|
|
100
|
-
// directory would have to be right about which of the two a path is. The
|
|
101
|
-
// cost of being unconditional is a blank row for a `Read` of a skill or a
|
|
102
|
-
// settings file; the cost of being wrong the other way is an OAuth token on
|
|
103
|
-
// a shared board. Same asymmetry `.env.example` is decided on below.
|
|
104
|
-
".claude",
|
|
105
|
-
"gh",
|
|
106
|
-
"gcloud",
|
|
107
|
-
"op",
|
|
108
|
-
"anthropic",
|
|
109
|
-
];
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* The leaf names of `credentialDirectories()`, hand-transcribed.
|
|
113
|
-
*
|
|
114
|
-
* This is the mirror the module doc's "when one grows, grow the other" asks
|
|
115
|
-
* for, made mechanical: `run-redaction.test.ts` walks this list and asserts
|
|
116
|
-
* every entry is withheld, so an entry added to the harness and forgotten here
|
|
117
|
-
* fails a test instead of shipping. It was a comment before, and the comment
|
|
118
|
-
* did not stop `.claude` from going missing.
|
|
119
|
-
*
|
|
120
|
-
* Transcribed rather than imported because `@gethmy/harness` is not a
|
|
121
|
-
* dependency of the published `@gethmy/mcp` package and must not become one for
|
|
122
|
-
* a test — the same reason `agent-run-event-kinds_test.ts` hand-transcribes its
|
|
123
|
-
* list.
|
|
124
|
-
*/
|
|
125
|
-
export const HARNESS_CREDENTIAL_LEAVES: readonly {
|
|
126
|
-
/** Path relative to the home directory, exactly as the harness spells it. */
|
|
127
|
-
readonly path: string;
|
|
128
|
-
/**
|
|
129
|
-
* Directory or file. The harness list mixes the two — its own deny rules need
|
|
130
|
-
* `/**` for one and not the other — and the distinction matters here as well:
|
|
131
|
-
* for a directory the test must prove a file INSIDE it is withheld, which is
|
|
132
|
-
* the shape an exfiltration actually takes.
|
|
133
|
-
*/
|
|
134
|
-
readonly kind: "dir" | "file";
|
|
135
|
-
}[] = [
|
|
136
|
-
// Three entries for one credential directory (#1082). `~/.harmony-mcp` was
|
|
137
|
-
// renamed to `~/.hmy/agent`, and this list does NOT follow `getConfigDir()` —
|
|
138
|
-
// it is hand-transcribed, so a rename here is a decision rather than a
|
|
139
|
-
// consequence. The decision is to ADD, never to swap: an operator's
|
|
140
|
-
// `~/.harmony-mcp` keeps its `config.json` (the Harmony API key) and its run
|
|
141
|
-
// logs until they delete it by hand, so dropping the old name would put the
|
|
142
|
-
// key back on the board.
|
|
143
|
-
//
|
|
144
|
-
// `.hmy` covers the whole tree, which is what `isSensitivePath` wants: it
|
|
145
|
-
// matches on path SEGMENTS, so a single segment needs no `CONFIG_SCOPED_
|
|
146
|
-
// SEGMENTS` special case the way `.config/gh` does. `.hmy/agent` is listed
|
|
147
|
-
// beside it so the mirror test walks the exact path the harness names.
|
|
148
|
-
{ path: ".hmy", kind: "dir" }, // getHmyRootDir()
|
|
149
|
-
{ path: ".hmy/agent", kind: "dir" }, // getConfigDir()
|
|
150
|
-
{ path: ".harmony-mcp", kind: "dir" }, // getLegacyConfigDir()
|
|
151
|
-
{ path: ".claude", kind: "dir" },
|
|
152
|
-
{ path: ".claude.json", kind: "file" },
|
|
153
|
-
{ path: ".ssh", kind: "dir" },
|
|
154
|
-
{ path: ".gnupg", kind: "dir" },
|
|
155
|
-
{ path: ".aws", kind: "dir" },
|
|
156
|
-
{ path: ".codex", kind: "dir" },
|
|
157
|
-
{ path: ".gemini", kind: "dir" },
|
|
158
|
-
{ path: ".config/gh", kind: "dir" },
|
|
159
|
-
{ path: ".config/gcloud", kind: "dir" },
|
|
160
|
-
{ path: ".config/anthropic", kind: "dir" },
|
|
161
|
-
{ path: ".config/op", kind: "dir" },
|
|
162
|
-
{ path: ".docker", kind: "dir" },
|
|
163
|
-
{ path: ".kube", kind: "dir" },
|
|
164
|
-
{ path: ".netrc", kind: "file" },
|
|
165
|
-
{ path: ".npmrc", kind: "file" },
|
|
166
|
-
{ path: ".git-credentials", kind: "file" },
|
|
167
|
-
];
|
|
168
|
-
|
|
169
|
-
/**
|
|
170
|
-
* The `gh` / `gcloud` / `op` / `anthropic` entries above are single common words
|
|
171
|
-
* and would otherwise match `src/gh/…`. They count only directly under a
|
|
172
|
-
* `.config` directory, which is where the harness names them.
|
|
173
|
-
*/
|
|
174
|
-
const CONFIG_SCOPED_SEGMENTS: ReadonlySet<string> = new Set([
|
|
175
|
-
"gh",
|
|
176
|
-
"gcloud",
|
|
177
|
-
"op",
|
|
178
|
-
"anthropic",
|
|
179
|
-
]);
|
|
180
|
-
|
|
181
|
-
/**
|
|
182
|
-
* File names that are a credential whatever directory they sit in.
|
|
183
|
-
*
|
|
184
|
-
* The dot-prefixed spellings sit beside their bare ones on purpose. A basename
|
|
185
|
-
* set is an exact match, so `credentials.json` does not cover
|
|
186
|
-
* `.credentials.json` — and `.credentials.json` is the one that holds Claude
|
|
187
|
-
* Code's OAuth token. The directory rule above already withholds it; this is
|
|
188
|
-
* the second, independent catch, because a token file is worth two.
|
|
189
|
-
*/
|
|
190
|
-
const SENSITIVE_BASENAMES: ReadonlySet<string> = new Set([
|
|
191
|
-
".netrc",
|
|
192
|
-
"_netrc",
|
|
193
|
-
".npmrc",
|
|
194
|
-
".pgpass",
|
|
195
|
-
".git-credentials",
|
|
196
|
-
".htpasswd",
|
|
197
|
-
".claude.json",
|
|
198
|
-
"credentials",
|
|
199
|
-
".credentials",
|
|
200
|
-
"credentials.json",
|
|
201
|
-
".credentials.json",
|
|
202
|
-
"credentials.yml",
|
|
203
|
-
"credentials.yaml",
|
|
204
|
-
// Codex, and the shape several other runtimes reuse for a token cache.
|
|
205
|
-
"auth.json",
|
|
206
|
-
".auth.json",
|
|
207
|
-
"secrets",
|
|
208
|
-
"secrets.json",
|
|
209
|
-
"secrets.yaml",
|
|
210
|
-
"secrets.yml",
|
|
211
|
-
"id_rsa",
|
|
212
|
-
"id_dsa",
|
|
213
|
-
"id_ecdsa",
|
|
214
|
-
"id_ed25519",
|
|
215
|
-
"known_hosts",
|
|
216
|
-
]);
|
|
217
|
-
|
|
218
|
-
/** Extensions that are a key or a keystore. */
|
|
219
|
-
const SENSITIVE_EXTENSIONS: readonly string[] = [
|
|
220
|
-
".pem",
|
|
221
|
-
".key",
|
|
222
|
-
".p12",
|
|
223
|
-
".pfx",
|
|
224
|
-
".keystore",
|
|
225
|
-
".jks",
|
|
226
|
-
".asc",
|
|
227
|
-
".gpg",
|
|
228
|
-
];
|
|
229
|
-
|
|
230
|
-
/**
|
|
231
|
-
* Is this path a credential?
|
|
232
|
-
*
|
|
233
|
-
* Deliberately conservative in two places. `.env.example` is withheld along
|
|
234
|
-
* with `.env`, because telling them apart means trusting a naming convention
|
|
235
|
-
* that nothing enforces, and the cost of being wrong is asymmetric: a withheld
|
|
236
|
-
* example file is a missing timeline row, a leaked `.env` is an incident.
|
|
237
|
-
* Likewise `known_hosts` — not a secret, but it enumerates the machines an
|
|
238
|
-
* operator reaches, which is not board material either.
|
|
239
|
-
*/
|
|
240
|
-
export function isSensitivePath(rawPath: string): boolean {
|
|
241
|
-
if (typeof rawPath !== "string" || rawPath.length === 0) return false;
|
|
242
|
-
const path = rawPath.trim().toLowerCase();
|
|
243
|
-
// Normalize both separators so a Windows-shaped path is judged the same.
|
|
244
|
-
const segments = path.split(/[\\/]+/).filter((s) => s.length > 0);
|
|
245
|
-
if (segments.length === 0) return false;
|
|
246
|
-
|
|
247
|
-
for (let i = 0; i < segments.length; i++) {
|
|
248
|
-
const segment = segments[i] as string;
|
|
249
|
-
if (!SENSITIVE_SEGMENTS.includes(segment)) continue;
|
|
250
|
-
if (CONFIG_SCOPED_SEGMENTS.has(segment)) {
|
|
251
|
-
// Only when it sits directly under `.config`, per the harness list.
|
|
252
|
-
if (i > 0 && segments[i - 1] === ".config") return true;
|
|
253
|
-
continue;
|
|
254
|
-
}
|
|
255
|
-
return true;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
const basename = segments[segments.length - 1] as string;
|
|
259
|
-
if (SENSITIVE_BASENAMES.has(basename)) return true;
|
|
260
|
-
// `.env`, `.env.local`, `.env.production` — and `.env.example`, on purpose.
|
|
261
|
-
if (basename === ".env" || basename.startsWith(".env.")) return true;
|
|
262
|
-
// `foo.env` reads as an environment file too.
|
|
263
|
-
if (basename.endsWith(".env")) return true;
|
|
264
|
-
if (SENSITIVE_EXTENSIONS.some((ext) => basename.endsWith(ext))) return true;
|
|
265
|
-
// `serviceAccount.json`, `service-account-key.json`, …
|
|
266
|
-
if (/service[-_]?account.*\.json$/.test(basename)) return true;
|
|
267
|
-
|
|
268
|
-
return false;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
/**
|
|
272
|
-
* Every string in `input` that looks like a filesystem path and is sensitive.
|
|
273
|
-
*
|
|
274
|
-
* Walks the whole structure rather than reading a known key, because the key
|
|
275
|
-
* differs per tool (`file_path` on Read/Edit, `path` on Glob, `notebook_path`
|
|
276
|
-
* on NotebookEdit) and a tool this code has never heard of is exactly the one
|
|
277
|
-
* that would slip through a per-tool lookup.
|
|
278
|
-
*/
|
|
279
|
-
export function sensitivePathsIn(input: unknown, depth = 0): string[] {
|
|
280
|
-
if (depth > 6) return [];
|
|
281
|
-
if (typeof input === "string") {
|
|
282
|
-
return isSensitivePath(input) ? [input] : [];
|
|
283
|
-
}
|
|
284
|
-
if (Array.isArray(input)) {
|
|
285
|
-
return input.flatMap((item) => sensitivePathsIn(item, depth + 1));
|
|
286
|
-
}
|
|
287
|
-
if (input !== null && typeof input === "object") {
|
|
288
|
-
return Object.values(input as Record<string, unknown>).flatMap((value) =>
|
|
289
|
-
sensitivePathsIn(value, depth + 1),
|
|
290
|
-
);
|
|
291
|
-
}
|
|
292
|
-
return [];
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
/**
|
|
296
|
-
* Secret SHAPES, swept over any text that survives the path rule.
|
|
297
|
-
*
|
|
298
|
-
* Each entry replaces the whole match, or — where a capture group is present —
|
|
299
|
-
* keeps the group and replaces the rest, so `GITHUB_TOKEN=…` stays legible as
|
|
300
|
-
* `GITHUB_TOKEN=«redacted»`. Knowing WHICH secret was passed is often the point
|
|
301
|
-
* of the timeline row; knowing its value never is.
|
|
302
|
-
*/
|
|
303
|
-
const SECRET_PATTERNS: readonly { pattern: RegExp; replace: string }[] = [
|
|
304
|
-
// A PEM block, first — it spans lines and would otherwise be truncated into
|
|
305
|
-
// a still-usable prefix by rule 3.
|
|
306
|
-
{
|
|
307
|
-
pattern: /-----BEGIN[^-]*PRIVATE KEY-----[\s\S]*?-----END[^-]*-----/g,
|
|
308
|
-
replace: REDACTION_MARK,
|
|
309
|
-
},
|
|
310
|
-
// Harmony's own credentials. `hmy_at_` is the OAuth shape, `hmy_` the
|
|
311
|
-
// integration key; the longer alternative is written first so it wins.
|
|
312
|
-
{ pattern: /\bhmy_at_[A-Za-z0-9_-]{8,}/g, replace: REDACTION_MARK },
|
|
313
|
-
{ pattern: /\bhmy_[A-Za-z0-9_-]{8,}/g, replace: REDACTION_MARK },
|
|
314
|
-
// Anthropic / OpenAI.
|
|
315
|
-
{ pattern: /\bsk-(?:ant-)?[A-Za-z0-9_-]{16,}/g, replace: REDACTION_MARK },
|
|
316
|
-
// GitHub: classic PAT prefixes and the fine-grained shape.
|
|
317
|
-
{ pattern: /\bgh[pousr]_[A-Za-z0-9]{16,}/g, replace: REDACTION_MARK },
|
|
318
|
-
{ pattern: /\bgithub_pat_[A-Za-z0-9_]{20,}/g, replace: REDACTION_MARK },
|
|
319
|
-
// Slack.
|
|
320
|
-
{ pattern: /\bxox[abprs]-[A-Za-z0-9-]{10,}/g, replace: REDACTION_MARK },
|
|
321
|
-
// AWS access key id, Google API key.
|
|
322
|
-
{ pattern: /\bAKIA[0-9A-Z]{16}\b/g, replace: REDACTION_MARK },
|
|
323
|
-
{ pattern: /\bAIza[0-9A-Za-z_-]{20,}/g, replace: REDACTION_MARK },
|
|
324
|
-
// A JWT — three base64url segments. Catches Supabase anon/service keys.
|
|
325
|
-
{
|
|
326
|
-
pattern: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g,
|
|
327
|
-
replace: REDACTION_MARK,
|
|
328
|
-
},
|
|
329
|
-
// `Authorization: Bearer <token>` and friends.
|
|
330
|
-
{
|
|
331
|
-
pattern: /\b(Bearer|Basic|Token)\s+[A-Za-z0-9._~+/=-]{12,}/gi,
|
|
332
|
-
replace: `$1 ${REDACTION_MARK}`,
|
|
333
|
-
},
|
|
334
|
-
// A URL with userinfo: https://user:password@host
|
|
335
|
-
{
|
|
336
|
-
pattern: /(\w+:\/\/)[^/\s:@]+:[^/\s@]+@/g,
|
|
337
|
-
replace: `$1${REDACTION_MARK}@`,
|
|
338
|
-
},
|
|
339
|
-
// An assignment whose NAME says it is a secret. Keeps the name.
|
|
340
|
-
//
|
|
341
|
-
// The two `[A-Za-z0-9_]` runs are bounded rather than `*`. Unbounded, they
|
|
342
|
-
// backtrack quadratically over a long alphanumeric blob — a 500 KB `Write`
|
|
343
|
-
// payload took 147 seconds and blew a 5-second test timeout. A real
|
|
344
|
-
// environment variable name is nowhere near 40 characters, so the bound costs
|
|
345
|
-
// nothing and turns O(n²) into O(n).
|
|
346
|
-
{
|
|
347
|
-
pattern:
|
|
348
|
-
/\b([A-Za-z0-9_]{0,40}(?:TOKEN|SECRET|PASSWORD|PASSWD|APIKEY|API_KEY|ACCESS_KEY|PRIVATE_KEY|CREDENTIAL|AUTH)[A-Za-z0-9_]{0,40})\s*[=:]\s*(?:"[^"]*"|'[^']*'|`[^`]*`|[^\s,;)}\]]+)/gi,
|
|
349
|
-
replace: `$1=${REDACTION_MARK}`,
|
|
350
|
-
},
|
|
351
|
-
// A command-line flag whose NAME says it is a secret.
|
|
352
|
-
{
|
|
353
|
-
pattern:
|
|
354
|
-
/(--?(?:password|passwd|token|api-?key|secret|auth)(?:=|\s+))(?:"[^"]*"|'[^']*'|[^\s]+)/gi,
|
|
355
|
-
replace: `$1${REDACTION_MARK}`,
|
|
356
|
-
},
|
|
357
|
-
];
|
|
358
|
-
|
|
359
|
-
/**
|
|
360
|
-
* Sweep `text` for secret shapes.
|
|
361
|
-
*
|
|
362
|
-
* Order matters and is fixed by `SECRET_PATTERNS`: the PEM block runs first so
|
|
363
|
-
* a key body is gone before any narrower pattern chews on its base64.
|
|
364
|
-
*/
|
|
365
|
-
export function redactSecrets(text: string): string {
|
|
366
|
-
if (typeof text !== "string" || text.length === 0) return text;
|
|
367
|
-
let out = text;
|
|
368
|
-
for (const { pattern, replace } of SECRET_PATTERNS) {
|
|
369
|
-
// Each regex is `g`-flagged and shared, so reset before reuse.
|
|
370
|
-
pattern.lastIndex = 0;
|
|
371
|
-
out = out.replace(pattern, replace);
|
|
372
|
-
}
|
|
373
|
-
return out;
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
/** Cut `text` to `max`, marking the cut so a reader knows it happened. */
|
|
377
|
-
export function truncate(
|
|
378
|
-
text: string,
|
|
379
|
-
max: number,
|
|
380
|
-
originalLength?: number,
|
|
381
|
-
): string {
|
|
382
|
-
const total = originalLength ?? text.length;
|
|
383
|
-
if (total <= max) return text;
|
|
384
|
-
return `${text.slice(0, max)}… [+${total - max} chars]`;
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
/**
|
|
388
|
-
* Redact, then cut to `max`.
|
|
389
|
-
*
|
|
390
|
-
* The order is deliberate and so is the pre-cap. Redacting the WHOLE of a
|
|
391
|
-
* multi-megabyte tool result before throwing 99% of it away is wasted work on
|
|
392
|
-
* the critical path of every tool call, so the sweep sees at most a small
|
|
393
|
-
* multiple of what can survive. Cutting first and redacting after would be
|
|
394
|
-
* cheaper still and is wrong: it would leave a secret that straddles the cut
|
|
395
|
-
* as a usable prefix. Anything between `max` and the pre-cap IS redacted and
|
|
396
|
-
* then discarded; anything past the pre-cap is discarded without ever being
|
|
397
|
-
* emitted, so nothing unexamined can reach the board.
|
|
398
|
-
*/
|
|
399
|
-
function redactThenTruncate(text: string, max: number): string {
|
|
400
|
-
const preCap = max * 4 + 64;
|
|
401
|
-
const scanned = text.length > preCap ? text.slice(0, preCap) : text;
|
|
402
|
-
return truncate(redactSecrets(scanned), max, text.length);
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
/**
|
|
406
|
-
* Redact and cap every string leaf of a structured value.
|
|
407
|
-
*
|
|
408
|
-
* Structure is preserved rather than flattened to a string, because the
|
|
409
|
-
* timeline's `ToolRow` renders an object input as a key/value list and a string
|
|
410
|
-
* as one blob — keeping the shape keeps the row readable.
|
|
411
|
-
*/
|
|
412
|
-
function redactStructure(value: unknown, depth = 0): unknown {
|
|
413
|
-
if (depth > 6) return REDACTION_MARK;
|
|
414
|
-
if (typeof value === "string") {
|
|
415
|
-
return redactThenTruncate(value, MAX_INPUT_STRING_CHARS);
|
|
416
|
-
}
|
|
417
|
-
if (Array.isArray(value)) {
|
|
418
|
-
// A long array is a payload risk of its own; cap the element count too.
|
|
419
|
-
return value.slice(0, 20).map((item) => redactStructure(item, depth + 1));
|
|
420
|
-
}
|
|
421
|
-
if (value !== null && typeof value === "object") {
|
|
422
|
-
const out: Record<string, unknown> = {};
|
|
423
|
-
for (const [key, item] of Object.entries(
|
|
424
|
-
value as Record<string, unknown>,
|
|
425
|
-
)) {
|
|
426
|
-
out[key] = redactStructure(item, depth + 1);
|
|
427
|
-
}
|
|
428
|
-
return out;
|
|
429
|
-
}
|
|
430
|
-
return value;
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
export interface RedactedToolCall {
|
|
434
|
-
/** What may be sent as `payload.input`, or `undefined` when withheld. */
|
|
435
|
-
input?: unknown;
|
|
436
|
-
/** What may be sent as `payload.output`, or `undefined` when withheld. */
|
|
437
|
-
output?: string;
|
|
438
|
-
/** Set when rule 1 fired; surfaced on the event so the row explains itself. */
|
|
439
|
-
withheld?: WithholdReason;
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
/**
|
|
443
|
-
* Apply all three rules to one tool call.
|
|
444
|
-
*
|
|
445
|
-
* Returns the pair that may be published. A withheld call keeps neither half:
|
|
446
|
-
* a `Read` of a `.env` withholds the output for the obvious reason, and the
|
|
447
|
-
* input for a less obvious one — the PATH of a credential file is itself worth
|
|
448
|
-
* withholding, since it tells a reader exactly where to go looking.
|
|
449
|
-
*/
|
|
450
|
-
export function redactToolCall(args: {
|
|
451
|
-
input?: unknown;
|
|
452
|
-
output?: string;
|
|
453
|
-
}): RedactedToolCall {
|
|
454
|
-
const sensitive = sensitivePathsIn(args.input);
|
|
455
|
-
if (sensitive.length > 0) {
|
|
456
|
-
return { withheld: "sensitive-path" };
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
const result: RedactedToolCall = {};
|
|
460
|
-
|
|
461
|
-
if (args.input !== undefined) {
|
|
462
|
-
let input = redactStructure(args.input);
|
|
463
|
-
// A structure can still be huge in aggregate even with every leaf capped.
|
|
464
|
-
// Fall back to a truncated serialization rather than shipping it.
|
|
465
|
-
let serialized: string;
|
|
466
|
-
try {
|
|
467
|
-
serialized = JSON.stringify(input) ?? "";
|
|
468
|
-
} catch {
|
|
469
|
-
serialized = "";
|
|
470
|
-
input = REDACTION_MARK;
|
|
471
|
-
}
|
|
472
|
-
if (serialized.length > MAX_INPUT_CHARS) {
|
|
473
|
-
input = truncate(serialized, MAX_INPUT_CHARS);
|
|
474
|
-
}
|
|
475
|
-
result.input = input;
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
if (typeof args.output === "string" && args.output.length > 0) {
|
|
479
|
-
result.output = redactThenTruncate(args.output, MAX_OUTPUT_CHARS);
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
return result;
|
|
483
|
-
}
|