@gethmy/mcp 3.3.0 → 3.4.1

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.
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Turning one `PostToolUse` hook payload into two run events (#874).
3
+ *
4
+ * The harness fires this hook after every tool call and hands it the tool name,
5
+ * the input it was called with and the result it produced. That is the whole
6
+ * log an MCP session never had — the MCP server sees only its own tools, so
7
+ * Read, Edit, Bash and Grep are invisible to it and no amount of server-side
8
+ * work can recover them.
9
+ *
10
+ * ## Why a pair, from a POST hook
11
+ *
12
+ * The timeline correlates `tool_started` and `tool_ended` by `toolUseId` into
13
+ * ONE row (`deriveTimeline`, `src/lib/agentTimelineModel.ts`), and that row is
14
+ * what renders input and output behind an expander. `PostToolUse` fires once,
15
+ * after the call, holding both halves — so it emits both halves, with the same
16
+ * correlation id and `createdAt` timestamps one millisecond apart so they sort
17
+ * in the right order under the server-assigned `seq`.
18
+ *
19
+ * A `PreToolUse` hook would give a truer "started" timestamp. It is not worth
20
+ * it: it would double the per-tool-call hook cost, on the critical path of
21
+ * every `Read`, to move a bar that renders as a single row either way.
22
+ *
23
+ * ## Harmony's own MCP tools are included, deliberately
24
+ *
25
+ * A `/hmy` session calls `mcp__harmony__*` tools constantly, and it is tempting
26
+ * to filter them out as noise. They stay, for one reason: a DAEMON run's
27
+ * timeline already shows its MCP calls, because the daemon's stream parser does
28
+ * not distinguish them either. Filtering here would make the two runtimes'
29
+ * timelines disagree about what a tool call is, and the point of this card is
30
+ * that they should read the same.
31
+ *
32
+ * This module is pure. The process that calls it does the I/O.
33
+ */
34
+
35
+ import { redactToolCall } from "./run-redaction.js";
36
+
37
+ /**
38
+ * The subset of the harness's `PostToolUse` stdin payload this reads.
39
+ *
40
+ * Everything is optional because the shape is the harness's to change, and a
41
+ * hook that throws on an unfamiliar field would break the user's tool call —
42
+ * the one thing it must never do. An unusable payload yields no events.
43
+ */
44
+ export interface PostToolUsePayload {
45
+ session_id?: string;
46
+ transcript_path?: string;
47
+ cwd?: string;
48
+ hook_event_name?: string;
49
+ tool_name?: string;
50
+ tool_input?: unknown;
51
+ tool_response?: unknown;
52
+ tool_use_id?: string;
53
+ }
54
+
55
+ /** The event shape written to the spool. Matches `AgentRunEventDraft`. */
56
+ export interface SpooledRunEvent {
57
+ kind: "tool_started" | "tool_ended";
58
+ source: "agent";
59
+ payload: Record<string, unknown>;
60
+ createdAt: string;
61
+ }
62
+
63
+ /**
64
+ * Pull readable text out of whatever the harness put in `tool_response`.
65
+ *
66
+ * The field is not one shape. A `Bash` result is an object with `stdout` and
67
+ * `stderr`; a `Read` is an object with `file`; an MCP tool result is an array
68
+ * of content blocks; some tools return a bare string. Rather than switch on the
69
+ * tool name — which would go stale the moment a tool is added — this reads the
70
+ * shapes, in the order that produces the most useful text.
71
+ */
72
+ export function extractOutputText(response: unknown, depth = 0): string {
73
+ if (depth > 4) return "";
74
+ if (response === null || response === undefined) return "";
75
+ if (typeof response === "string") return response;
76
+ if (typeof response === "number" || typeof response === "boolean") {
77
+ return String(response);
78
+ }
79
+ if (Array.isArray(response)) {
80
+ return response
81
+ .map((item) => extractOutputText(item, depth + 1))
82
+ .filter((part) => part.length > 0)
83
+ .join("\n");
84
+ }
85
+ if (typeof response === "object") {
86
+ const record = response as Record<string, unknown>;
87
+ // Content-block shape: { type: "text", text: "…" }
88
+ if (typeof record.text === "string") return record.text;
89
+ const parts: string[] = [];
90
+ for (const key of ["stdout", "stderr", "output", "content", "result"]) {
91
+ const value = record[key];
92
+ if (value === undefined || value === null) continue;
93
+ const text = extractOutputText(value, depth + 1);
94
+ if (text.length > 0) parts.push(text);
95
+ }
96
+ if (parts.length > 0) return parts.join("\n");
97
+ // Nothing recognizable — serialize, so the row is not silently empty.
98
+ try {
99
+ const serialized = JSON.stringify(record) ?? "";
100
+ // A tool that returned nothing should render as NO output, not as a
101
+ // literal `{}` — an empty expander is noise a reader has to open to
102
+ // discover is empty.
103
+ return serialized === "{}" || serialized === "[]" ? "" : serialized;
104
+ } catch {
105
+ return "";
106
+ }
107
+ }
108
+ return "";
109
+ }
110
+
111
+ /** Did the tool fail? Read from the flags the harness sets, never inferred. */
112
+ export function extractIsError(response: unknown): boolean {
113
+ if (response === null || typeof response !== "object") return false;
114
+ const record = response as Record<string, unknown>;
115
+ if (record.is_error === true || record.isError === true) return true;
116
+ if (record.interrupted === true) return true;
117
+ if (typeof record.error === "string" && record.error.length > 0) return true;
118
+ return false;
119
+ }
120
+
121
+ /**
122
+ * A correlation id for the pair.
123
+ *
124
+ * The harness supplies one on most payloads; when it does not, a synthesized id
125
+ * still correlates correctly because both halves of THIS pair are built from
126
+ * the same call. It is prefixed so a reader can tell a synthesized id from a
127
+ * real one when a row looks wrong.
128
+ */
129
+ function correlationId(
130
+ payload: PostToolUsePayload,
131
+ nonce: () => string,
132
+ ): string {
133
+ const supplied = payload.tool_use_id;
134
+ if (typeof supplied === "string" && supplied.length > 0) return supplied;
135
+ return `hook-${nonce()}`;
136
+ }
137
+
138
+ /**
139
+ * Build the `tool_started` / `tool_ended` pair for one tool call.
140
+ *
141
+ * Returns an empty array when there is nothing worth writing — no tool name, or
142
+ * a payload from a hook event this does not handle. An empty array is the
143
+ * caller's cue to do nothing at all, not to write an empty batch.
144
+ */
145
+ export function buildHookEvents(
146
+ payload: PostToolUsePayload,
147
+ options?: { now?: number; nonce?: () => string },
148
+ ): SpooledRunEvent[] {
149
+ const toolName =
150
+ typeof payload?.tool_name === "string" ? payload.tool_name.trim() : "";
151
+ if (!toolName) return [];
152
+ if (
153
+ typeof payload.hook_event_name === "string" &&
154
+ payload.hook_event_name !== "PostToolUse"
155
+ ) {
156
+ return [];
157
+ }
158
+
159
+ const now = options?.now ?? Date.now();
160
+ const nonce =
161
+ options?.nonce ?? (() => Math.random().toString(36).slice(2, 12));
162
+ const toolUseId = correlationId(payload, nonce);
163
+
164
+ const rawOutput = extractOutputText(payload.tool_response);
165
+ const isError = extractIsError(payload.tool_response);
166
+ const redacted = redactToolCall({
167
+ input: payload.tool_input,
168
+ output: rawOutput,
169
+ });
170
+
171
+ const startPayload: Record<string, unknown> = { toolName, toolUseId };
172
+ const endPayload: Record<string, unknown> = { toolName, toolUseId };
173
+
174
+ if (redacted.withheld) {
175
+ // The row survives so the timeline still shows the call happened; only the
176
+ // bytes are gone. `withheld` is a plain payload field — the schema is open,
177
+ // and an unknown field is stored and ignored rather than rejected.
178
+ startPayload.withheld = redacted.withheld;
179
+ endPayload.withheld = redacted.withheld;
180
+ endPayload.output = `[withheld: ${redacted.withheld}]`;
181
+ } else {
182
+ if (redacted.input !== undefined) startPayload.input = redacted.input;
183
+ if (redacted.output !== undefined) endPayload.output = redacted.output;
184
+ }
185
+ if (isError) endPayload.isError = true;
186
+
187
+ return [
188
+ {
189
+ kind: "tool_started",
190
+ source: "agent",
191
+ payload: startPayload,
192
+ // One millisecond apart so the pair keeps its order when the server
193
+ // assigns `seq` from insert order within a batch.
194
+ createdAt: new Date(now).toISOString(),
195
+ },
196
+ {
197
+ kind: "tool_ended",
198
+ source: "agent",
199
+ payload: endPayload,
200
+ createdAt: new Date(now + 1).toISOString(),
201
+ },
202
+ ];
203
+ }
@@ -0,0 +1,461 @@
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. `.harmony-mcp` is here for the same reason it is first there: it holds
69
+ * this product's own API key.
70
+ */
71
+ const SENSITIVE_SEGMENTS: readonly string[] = [
72
+ ".ssh",
73
+ ".gnupg",
74
+ ".aws",
75
+ ".codex",
76
+ ".gemini",
77
+ ".docker",
78
+ ".kube",
79
+ ".harmony-mcp",
80
+ ".password-store",
81
+ // `~/.claude` holds `.credentials.json`, Claude Code's own OAuth token, and
82
+ // this repo has already watched a run be talked into reading it and pasting
83
+ // the contents into a source comment (`confine-to-repo.ts`, `ci-repair.ts`,
84
+ // `ci-patch.ts` all record that measurement). The harness denies the whole
85
+ // directory for that reason; this list omitted it, so a `Read` of the token
86
+ // file would have reached the board in full.
87
+ //
88
+ // Matched UNCONDITIONALLY, not only under `$HOME`, and that is deliberate.
89
+ // A repo's own `.claude/settings.local.json` is gitignored precisely because
90
+ // it is personal, and the key it most often carries is `env` — tokens. So
91
+ // "project layer, therefore safe" is false, and anchoring on the home
92
+ // directory would have to be right about which of the two a path is. The
93
+ // cost of being unconditional is a blank row for a `Read` of a skill or a
94
+ // settings file; the cost of being wrong the other way is an OAuth token on
95
+ // a shared board. Same asymmetry `.env.example` is decided on below.
96
+ ".claude",
97
+ "gh",
98
+ "gcloud",
99
+ "op",
100
+ "anthropic",
101
+ ];
102
+
103
+ /**
104
+ * The leaf names of `credentialDirectories()`, hand-transcribed.
105
+ *
106
+ * This is the mirror the module doc's "when one grows, grow the other" asks
107
+ * for, made mechanical: `run-redaction.test.ts` walks this list and asserts
108
+ * every entry is withheld, so an entry added to the harness and forgotten here
109
+ * fails a test instead of shipping. It was a comment before, and the comment
110
+ * did not stop `.claude` from going missing.
111
+ *
112
+ * Transcribed rather than imported because `@gethmy/harness` is not a
113
+ * dependency of the published `@gethmy/mcp` package and must not become one for
114
+ * a test — the same reason `agent-run-event-kinds_test.ts` hand-transcribes its
115
+ * list.
116
+ */
117
+ export const HARNESS_CREDENTIAL_LEAVES: readonly {
118
+ /** Path relative to the home directory, exactly as the harness spells it. */
119
+ readonly path: string;
120
+ /**
121
+ * Directory or file. The harness list mixes the two — its own deny rules need
122
+ * `/**` for one and not the other — and the distinction matters here as well:
123
+ * for a directory the test must prove a file INSIDE it is withheld, which is
124
+ * the shape an exfiltration actually takes.
125
+ */
126
+ readonly kind: "dir" | "file";
127
+ }[] = [
128
+ { path: ".harmony-mcp", kind: "dir" }, // getConfigDir()
129
+ { path: ".claude", kind: "dir" },
130
+ { path: ".claude.json", kind: "file" },
131
+ { path: ".ssh", kind: "dir" },
132
+ { path: ".gnupg", kind: "dir" },
133
+ { path: ".aws", kind: "dir" },
134
+ { path: ".codex", kind: "dir" },
135
+ { path: ".gemini", kind: "dir" },
136
+ { path: ".config/gh", kind: "dir" },
137
+ { path: ".config/gcloud", kind: "dir" },
138
+ { path: ".config/anthropic", kind: "dir" },
139
+ { path: ".config/op", kind: "dir" },
140
+ { path: ".docker", kind: "dir" },
141
+ { path: ".kube", kind: "dir" },
142
+ { path: ".netrc", kind: "file" },
143
+ { path: ".npmrc", kind: "file" },
144
+ { path: ".git-credentials", kind: "file" },
145
+ ];
146
+
147
+ /**
148
+ * The `gh` / `gcloud` / `op` / `anthropic` entries above are single common words
149
+ * and would otherwise match `src/gh/…`. They count only directly under a
150
+ * `.config` directory, which is where the harness names them.
151
+ */
152
+ const CONFIG_SCOPED_SEGMENTS: ReadonlySet<string> = new Set([
153
+ "gh",
154
+ "gcloud",
155
+ "op",
156
+ "anthropic",
157
+ ]);
158
+
159
+ /**
160
+ * File names that are a credential whatever directory they sit in.
161
+ *
162
+ * The dot-prefixed spellings sit beside their bare ones on purpose. A basename
163
+ * set is an exact match, so `credentials.json` does not cover
164
+ * `.credentials.json` — and `.credentials.json` is the one that holds Claude
165
+ * Code's OAuth token. The directory rule above already withholds it; this is
166
+ * the second, independent catch, because a token file is worth two.
167
+ */
168
+ const SENSITIVE_BASENAMES: ReadonlySet<string> = new Set([
169
+ ".netrc",
170
+ "_netrc",
171
+ ".npmrc",
172
+ ".pgpass",
173
+ ".git-credentials",
174
+ ".htpasswd",
175
+ ".claude.json",
176
+ "credentials",
177
+ ".credentials",
178
+ "credentials.json",
179
+ ".credentials.json",
180
+ "credentials.yml",
181
+ "credentials.yaml",
182
+ // Codex, and the shape several other runtimes reuse for a token cache.
183
+ "auth.json",
184
+ ".auth.json",
185
+ "secrets",
186
+ "secrets.json",
187
+ "secrets.yaml",
188
+ "secrets.yml",
189
+ "id_rsa",
190
+ "id_dsa",
191
+ "id_ecdsa",
192
+ "id_ed25519",
193
+ "known_hosts",
194
+ ]);
195
+
196
+ /** Extensions that are a key or a keystore. */
197
+ const SENSITIVE_EXTENSIONS: readonly string[] = [
198
+ ".pem",
199
+ ".key",
200
+ ".p12",
201
+ ".pfx",
202
+ ".keystore",
203
+ ".jks",
204
+ ".asc",
205
+ ".gpg",
206
+ ];
207
+
208
+ /**
209
+ * Is this path a credential?
210
+ *
211
+ * Deliberately conservative in two places. `.env.example` is withheld along
212
+ * with `.env`, because telling them apart means trusting a naming convention
213
+ * that nothing enforces, and the cost of being wrong is asymmetric: a withheld
214
+ * example file is a missing timeline row, a leaked `.env` is an incident.
215
+ * Likewise `known_hosts` — not a secret, but it enumerates the machines an
216
+ * operator reaches, which is not board material either.
217
+ */
218
+ export function isSensitivePath(rawPath: string): boolean {
219
+ if (typeof rawPath !== "string" || rawPath.length === 0) return false;
220
+ const path = rawPath.trim().toLowerCase();
221
+ // Normalize both separators so a Windows-shaped path is judged the same.
222
+ const segments = path.split(/[\\/]+/).filter((s) => s.length > 0);
223
+ if (segments.length === 0) return false;
224
+
225
+ for (let i = 0; i < segments.length; i++) {
226
+ const segment = segments[i] as string;
227
+ if (!SENSITIVE_SEGMENTS.includes(segment)) continue;
228
+ if (CONFIG_SCOPED_SEGMENTS.has(segment)) {
229
+ // Only when it sits directly under `.config`, per the harness list.
230
+ if (i > 0 && segments[i - 1] === ".config") return true;
231
+ continue;
232
+ }
233
+ return true;
234
+ }
235
+
236
+ const basename = segments[segments.length - 1] as string;
237
+ if (SENSITIVE_BASENAMES.has(basename)) return true;
238
+ // `.env`, `.env.local`, `.env.production` — and `.env.example`, on purpose.
239
+ if (basename === ".env" || basename.startsWith(".env.")) return true;
240
+ // `foo.env` reads as an environment file too.
241
+ if (basename.endsWith(".env")) return true;
242
+ if (SENSITIVE_EXTENSIONS.some((ext) => basename.endsWith(ext))) return true;
243
+ // `serviceAccount.json`, `service-account-key.json`, …
244
+ if (/service[-_]?account.*\.json$/.test(basename)) return true;
245
+
246
+ return false;
247
+ }
248
+
249
+ /**
250
+ * Every string in `input` that looks like a filesystem path and is sensitive.
251
+ *
252
+ * Walks the whole structure rather than reading a known key, because the key
253
+ * differs per tool (`file_path` on Read/Edit, `path` on Glob, `notebook_path`
254
+ * on NotebookEdit) and a tool this code has never heard of is exactly the one
255
+ * that would slip through a per-tool lookup.
256
+ */
257
+ export function sensitivePathsIn(input: unknown, depth = 0): string[] {
258
+ if (depth > 6) return [];
259
+ if (typeof input === "string") {
260
+ return isSensitivePath(input) ? [input] : [];
261
+ }
262
+ if (Array.isArray(input)) {
263
+ return input.flatMap((item) => sensitivePathsIn(item, depth + 1));
264
+ }
265
+ if (input !== null && typeof input === "object") {
266
+ return Object.values(input as Record<string, unknown>).flatMap((value) =>
267
+ sensitivePathsIn(value, depth + 1),
268
+ );
269
+ }
270
+ return [];
271
+ }
272
+
273
+ /**
274
+ * Secret SHAPES, swept over any text that survives the path rule.
275
+ *
276
+ * Each entry replaces the whole match, or — where a capture group is present —
277
+ * keeps the group and replaces the rest, so `GITHUB_TOKEN=…` stays legible as
278
+ * `GITHUB_TOKEN=«redacted»`. Knowing WHICH secret was passed is often the point
279
+ * of the timeline row; knowing its value never is.
280
+ */
281
+ const SECRET_PATTERNS: readonly { pattern: RegExp; replace: string }[] = [
282
+ // A PEM block, first — it spans lines and would otherwise be truncated into
283
+ // a still-usable prefix by rule 3.
284
+ {
285
+ pattern: /-----BEGIN[^-]*PRIVATE KEY-----[\s\S]*?-----END[^-]*-----/g,
286
+ replace: REDACTION_MARK,
287
+ },
288
+ // Harmony's own credentials. `hmy_at_` is the OAuth shape, `hmy_` the
289
+ // integration key; the longer alternative is written first so it wins.
290
+ { pattern: /\bhmy_at_[A-Za-z0-9_-]{8,}/g, replace: REDACTION_MARK },
291
+ { pattern: /\bhmy_[A-Za-z0-9_-]{8,}/g, replace: REDACTION_MARK },
292
+ // Anthropic / OpenAI.
293
+ { pattern: /\bsk-(?:ant-)?[A-Za-z0-9_-]{16,}/g, replace: REDACTION_MARK },
294
+ // GitHub: classic PAT prefixes and the fine-grained shape.
295
+ { pattern: /\bgh[pousr]_[A-Za-z0-9]{16,}/g, replace: REDACTION_MARK },
296
+ { pattern: /\bgithub_pat_[A-Za-z0-9_]{20,}/g, replace: REDACTION_MARK },
297
+ // Slack.
298
+ { pattern: /\bxox[abprs]-[A-Za-z0-9-]{10,}/g, replace: REDACTION_MARK },
299
+ // AWS access key id, Google API key.
300
+ { pattern: /\bAKIA[0-9A-Z]{16}\b/g, replace: REDACTION_MARK },
301
+ { pattern: /\bAIza[0-9A-Za-z_-]{20,}/g, replace: REDACTION_MARK },
302
+ // A JWT — three base64url segments. Catches Supabase anon/service keys.
303
+ {
304
+ pattern: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g,
305
+ replace: REDACTION_MARK,
306
+ },
307
+ // `Authorization: Bearer <token>` and friends.
308
+ {
309
+ pattern: /\b(Bearer|Basic|Token)\s+[A-Za-z0-9._~+/=-]{12,}/gi,
310
+ replace: `$1 ${REDACTION_MARK}`,
311
+ },
312
+ // A URL with userinfo: https://user:password@host
313
+ {
314
+ pattern: /(\w+:\/\/)[^/\s:@]+:[^/\s@]+@/g,
315
+ replace: `$1${REDACTION_MARK}@`,
316
+ },
317
+ // An assignment whose NAME says it is a secret. Keeps the name.
318
+ //
319
+ // The two `[A-Za-z0-9_]` runs are bounded rather than `*`. Unbounded, they
320
+ // backtrack quadratically over a long alphanumeric blob — a 500 KB `Write`
321
+ // payload took 147 seconds and blew a 5-second test timeout. A real
322
+ // environment variable name is nowhere near 40 characters, so the bound costs
323
+ // nothing and turns O(n²) into O(n).
324
+ {
325
+ pattern:
326
+ /\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,
327
+ replace: `$1=${REDACTION_MARK}`,
328
+ },
329
+ // A command-line flag whose NAME says it is a secret.
330
+ {
331
+ pattern:
332
+ /(--?(?:password|passwd|token|api-?key|secret|auth)(?:=|\s+))(?:"[^"]*"|'[^']*'|[^\s]+)/gi,
333
+ replace: `$1${REDACTION_MARK}`,
334
+ },
335
+ ];
336
+
337
+ /**
338
+ * Sweep `text` for secret shapes.
339
+ *
340
+ * Order matters and is fixed by `SECRET_PATTERNS`: the PEM block runs first so
341
+ * a key body is gone before any narrower pattern chews on its base64.
342
+ */
343
+ export function redactSecrets(text: string): string {
344
+ if (typeof text !== "string" || text.length === 0) return text;
345
+ let out = text;
346
+ for (const { pattern, replace } of SECRET_PATTERNS) {
347
+ // Each regex is `g`-flagged and shared, so reset before reuse.
348
+ pattern.lastIndex = 0;
349
+ out = out.replace(pattern, replace);
350
+ }
351
+ return out;
352
+ }
353
+
354
+ /** Cut `text` to `max`, marking the cut so a reader knows it happened. */
355
+ export function truncate(
356
+ text: string,
357
+ max: number,
358
+ originalLength?: number,
359
+ ): string {
360
+ const total = originalLength ?? text.length;
361
+ if (total <= max) return text;
362
+ return `${text.slice(0, max)}… [+${total - max} chars]`;
363
+ }
364
+
365
+ /**
366
+ * Redact, then cut to `max`.
367
+ *
368
+ * The order is deliberate and so is the pre-cap. Redacting the WHOLE of a
369
+ * multi-megabyte tool result before throwing 99% of it away is wasted work on
370
+ * the critical path of every tool call, so the sweep sees at most a small
371
+ * multiple of what can survive. Cutting first and redacting after would be
372
+ * cheaper still and is wrong: it would leave a secret that straddles the cut
373
+ * as a usable prefix. Anything between `max` and the pre-cap IS redacted and
374
+ * then discarded; anything past the pre-cap is discarded without ever being
375
+ * emitted, so nothing unexamined can reach the board.
376
+ */
377
+ function redactThenTruncate(text: string, max: number): string {
378
+ const preCap = max * 4 + 64;
379
+ const scanned = text.length > preCap ? text.slice(0, preCap) : text;
380
+ return truncate(redactSecrets(scanned), max, text.length);
381
+ }
382
+
383
+ /**
384
+ * Redact and cap every string leaf of a structured value.
385
+ *
386
+ * Structure is preserved rather than flattened to a string, because the
387
+ * timeline's `ToolRow` renders an object input as a key/value list and a string
388
+ * as one blob — keeping the shape keeps the row readable.
389
+ */
390
+ function redactStructure(value: unknown, depth = 0): unknown {
391
+ if (depth > 6) return REDACTION_MARK;
392
+ if (typeof value === "string") {
393
+ return redactThenTruncate(value, MAX_INPUT_STRING_CHARS);
394
+ }
395
+ if (Array.isArray(value)) {
396
+ // A long array is a payload risk of its own; cap the element count too.
397
+ return value.slice(0, 20).map((item) => redactStructure(item, depth + 1));
398
+ }
399
+ if (value !== null && typeof value === "object") {
400
+ const out: Record<string, unknown> = {};
401
+ for (const [key, item] of Object.entries(
402
+ value as Record<string, unknown>,
403
+ )) {
404
+ out[key] = redactStructure(item, depth + 1);
405
+ }
406
+ return out;
407
+ }
408
+ return value;
409
+ }
410
+
411
+ export interface RedactedToolCall {
412
+ /** What may be sent as `payload.input`, or `undefined` when withheld. */
413
+ input?: unknown;
414
+ /** What may be sent as `payload.output`, or `undefined` when withheld. */
415
+ output?: string;
416
+ /** Set when rule 1 fired; surfaced on the event so the row explains itself. */
417
+ withheld?: WithholdReason;
418
+ }
419
+
420
+ /**
421
+ * Apply all three rules to one tool call.
422
+ *
423
+ * Returns the pair that may be published. A withheld call keeps neither half:
424
+ * a `Read` of a `.env` withholds the output for the obvious reason, and the
425
+ * input for a less obvious one — the PATH of a credential file is itself worth
426
+ * withholding, since it tells a reader exactly where to go looking.
427
+ */
428
+ export function redactToolCall(args: {
429
+ input?: unknown;
430
+ output?: string;
431
+ }): RedactedToolCall {
432
+ const sensitive = sensitivePathsIn(args.input);
433
+ if (sensitive.length > 0) {
434
+ return { withheld: "sensitive-path" };
435
+ }
436
+
437
+ const result: RedactedToolCall = {};
438
+
439
+ if (args.input !== undefined) {
440
+ let input = redactStructure(args.input);
441
+ // A structure can still be huge in aggregate even with every leaf capped.
442
+ // Fall back to a truncated serialization rather than shipping it.
443
+ let serialized: string;
444
+ try {
445
+ serialized = JSON.stringify(input) ?? "";
446
+ } catch {
447
+ serialized = "";
448
+ input = REDACTION_MARK;
449
+ }
450
+ if (serialized.length > MAX_INPUT_CHARS) {
451
+ input = truncate(serialized, MAX_INPUT_CHARS);
452
+ }
453
+ result.input = input;
454
+ }
455
+
456
+ if (typeof args.output === "string" && args.output.length > 0) {
457
+ result.output = redactThenTruncate(args.output, MAX_OUTPUT_CHARS);
458
+ }
459
+
460
+ return result;
461
+ }