@desplega.ai/agent-swarm 1.67.4 → 1.67.5

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/openapi.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Agent Swarm API",
5
- "version": "1.67.4",
5
+ "version": "1.67.5",
6
6
  "description": "Multi-agent orchestration API for Claude Code, Codex, and Gemini CLI. Enables task distribution, agent communication, and service discovery.\n\nMCP tools are documented separately in [MCP.md](./MCP.md)."
7
7
  },
8
8
  "servers": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@desplega.ai/agent-swarm",
3
- "version": "1.67.4",
3
+ "version": "1.67.5",
4
4
  "description": "Multi-agent orchestration for Claude Code, Codex, Gemini CLI, and other AI coding assistants",
5
5
  "license": "MIT",
6
6
  "author": "desplega.sh <contact@desplega.sh>",
package/src/be/db.ts CHANGED
@@ -57,6 +57,7 @@ import type {
57
57
  WorkflowVersion,
58
58
  } from "../types";
59
59
  import { deriveProviderFromKeyType } from "../utils/credentials";
60
+ import { scrubSecrets } from "../utils/secret-scrubber";
60
61
  import { decryptSecret, encryptSecret, getEncryptionKey, resolveEncryptionKey } from "./crypto";
61
62
  import { normalizeDate, normalizeDateRequired } from "./date-utils";
62
63
  import { runMigrations } from "./migrations/runner";
@@ -3408,7 +3409,11 @@ export function createSessionLogs(logs: {
3408
3409
  logs.sessionId,
3409
3410
  logs.iteration,
3410
3411
  logs.cli,
3411
- line,
3412
+ // Defense-in-depth: callers (runner.ts → POST /api/session-logs) send
3413
+ // content that is already scrubbed at the adapter emit site. We scrub
3414
+ // again here so any future write path that bypasses the adapter still
3415
+ // lands clean text in the persistent session_logs table.
3416
+ scrubSecrets(line),
3412
3417
  i,
3413
3418
  );
3414
3419
  }
package/src/http/core.ts CHANGED
@@ -14,6 +14,7 @@ import { initGitHub, resetGitHub } from "../github";
14
14
  import { initLinear, resetLinear } from "../linear";
15
15
  import { startSlackApp, stopSlackApp } from "../slack";
16
16
  import type { AgentStatus } from "../types";
17
+ import { refreshSecretScrubberCache } from "../utils/secret-scrubber";
17
18
  import { generateOpenApiSpec, SCALAR_HTML } from "./openapi";
18
19
  import { agentWithCapacity, parseQueryParams } from "./utils";
19
20
 
@@ -34,6 +35,11 @@ export function loadGlobalConfigsIntoEnv(override = false): string[] {
34
35
  updated.push(config.key);
35
36
  }
36
37
  }
38
+ // The scrubber caches process.env-derived secret values; invalidate so the
39
+ // next scrub picks up any new/rotated secrets we just injected.
40
+ if (updated.length > 0) {
41
+ refreshSecretScrubberCache();
42
+ }
37
43
  return updated;
38
44
  }
39
45
 
@@ -7,6 +7,7 @@ import {
7
7
  SessionErrorTracker,
8
8
  trackErrorFromJson,
9
9
  } from "../utils/error-tracker";
10
+ import { scrubSecrets } from "../utils/secret-scrubber";
10
11
  import type {
11
12
  CostData,
12
13
  ProviderAdapter,
@@ -278,7 +279,9 @@ class ClaudeSession implements ProviderSession {
278
279
  for await (const chunk of stdout) {
279
280
  stdoutChunks++;
280
281
  const text = new TextDecoder().decode(chunk);
281
- logFileHandle.write(text);
282
+ // Scrub before every log-egress point: file write, listener emit, and
283
+ // downstream pretty-print / session-logs push (all consume event.content).
284
+ logFileHandle.write(scrubSecrets(text));
282
285
 
283
286
  const combined = partialLine + text;
284
287
  const parts = combined.split("\n");
@@ -288,7 +291,7 @@ class ClaudeSession implements ProviderSession {
288
291
  const trimmed = line.trim();
289
292
  if (!trimmed) continue;
290
293
 
291
- this.emit({ type: "raw_log", content: trimmed });
294
+ this.emit({ type: "raw_log", content: scrubSecrets(trimmed) });
292
295
  this.processJsonLine(trimmed, (cost) => {
293
296
  lastCost = cost;
294
297
  });
@@ -297,7 +300,7 @@ class ClaudeSession implements ProviderSession {
297
300
 
298
301
  // Handle remaining partial line
299
302
  if (partialLine.trim()) {
300
- this.emit({ type: "raw_log", content: partialLine.trim() });
303
+ this.emit({ type: "raw_log", content: scrubSecrets(partialLine.trim()) });
301
304
  this.processJsonLine(partialLine.trim(), (cost) => {
302
305
  lastCost = cost;
303
306
  });
@@ -314,10 +317,11 @@ class ClaudeSession implements ProviderSession {
314
317
  const text = new TextDecoder().decode(chunk);
315
318
  stderrOutput += text;
316
319
  parseStderrForErrors(text, this.errorTracker);
320
+ const scrubbedText = scrubSecrets(text);
317
321
  logFileHandle.write(
318
- `${JSON.stringify({ type: "stderr", content: text, timestamp: new Date().toISOString() })}\n`,
322
+ `${JSON.stringify({ type: "stderr", content: scrubbedText, timestamp: new Date().toISOString() })}\n`,
319
323
  );
320
- this.emit({ type: "raw_stderr", content: text });
324
+ this.emit({ type: "raw_stderr", content: scrubbedText });
321
325
  }
322
326
  })();
323
327
 
@@ -337,7 +341,7 @@ class ClaudeSession implements ProviderSession {
337
341
 
338
342
  if (exitCode !== 0 && stderrOutput) {
339
343
  console.error(
340
- `\x1b[31m[${this.config.role}] Full stderr for task ${this.config.taskId.slice(0, 8)}:\x1b[0m\n${stderrOutput}`,
344
+ `\x1b[31m[${this.config.role}] Full stderr for task ${this.config.taskId.slice(0, 8)}:\x1b[0m\n${scrubSecrets(stderrOutput)}`,
341
345
  );
342
346
  }
343
347
 
@@ -64,6 +64,7 @@ import {
64
64
  type Usage,
65
65
  type WebSearchItem,
66
66
  } from "@openai/codex-sdk";
67
+ import { scrubSecrets } from "../utils/secret-scrubber";
67
68
  import { type CodexAgentsMdHandle, writeCodexAgentsMd } from "./codex-agents-md";
68
69
  import {
69
70
  CODEX_DEFAULT_MODEL,
@@ -344,9 +345,17 @@ class CodexSession implements ProviderSession {
344
345
  }
345
346
 
346
347
  private emit(event: ProviderEvent): void {
348
+ // Scrub secret values from raw_log / raw_stderr content before any egress
349
+ // (log file write, listener dispatch, downstream session-logs push). Keeps
350
+ // secrets out of /workspace/logs/*.jsonl, the session_logs SQLite table,
351
+ // and container stdout (pretty-print consumes event.content).
352
+ const scrubbed: ProviderEvent =
353
+ event.type === "raw_log" || event.type === "raw_stderr"
354
+ ? { ...event, content: scrubSecrets(event.content) }
355
+ : event;
347
356
  try {
348
357
  this.logFileHandle.write(
349
- `${JSON.stringify({ ...event, timestamp: new Date().toISOString() })}\n`,
358
+ `${JSON.stringify({ ...scrubbed, timestamp: new Date().toISOString() })}\n`,
350
359
  );
351
360
  } catch {
352
361
  // Log writer failure must not break the event stream.
@@ -354,13 +363,13 @@ class CodexSession implements ProviderSession {
354
363
  if (this.listeners.length > 0) {
355
364
  for (const listener of this.listeners) {
356
365
  try {
357
- listener(event);
366
+ listener(scrubbed);
358
367
  } catch {
359
368
  // Swallow listener errors — a bad listener must not kill the session.
360
369
  }
361
370
  }
362
371
  } else {
363
- this.eventQueue.push(event);
372
+ this.eventQueue.push(scrubbed);
364
373
  }
365
374
  }
366
375
 
@@ -22,6 +22,7 @@ import {
22
22
  SessionManager,
23
23
  } from "@mariozechner/pi-coding-agent";
24
24
  import { type TSchema, Type } from "@sinclair/typebox";
25
+ import { scrubSecrets } from "../utils/secret-scrubber";
25
26
  import { createSwarmHooksExtension } from "./pi-mono-extension";
26
27
  import { McpHttpClient } from "./pi-mono-mcp-client";
27
28
  import type {
@@ -164,17 +165,24 @@ class PiMonoSession implements ProviderSession {
164
165
  }
165
166
 
166
167
  private emit(event: ProviderEvent): void {
168
+ // Scrub secrets from raw_log / raw_stderr content before egress (log file
169
+ // write, listener dispatch, downstream session-logs push + pretty-print).
170
+ const scrubbed: ProviderEvent =
171
+ event.type === "raw_log" || event.type === "raw_stderr"
172
+ ? { ...event, content: scrubSecrets(event.content) }
173
+ : event;
174
+
167
175
  // Log all events
168
176
  this.logFileHandle.write(
169
- `${JSON.stringify({ ...event, timestamp: new Date().toISOString() })}\n`,
177
+ `${JSON.stringify({ ...scrubbed, timestamp: new Date().toISOString() })}\n`,
170
178
  );
171
179
 
172
180
  if (this.listeners.length > 0) {
173
181
  for (const listener of this.listeners) {
174
- listener(event);
182
+ listener(scrubbed);
175
183
  }
176
184
  } else {
177
- this.eventQueue.push(event);
185
+ this.eventQueue.push(scrubbed);
178
186
  }
179
187
  }
180
188
 
@@ -0,0 +1,249 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { refreshSecretScrubberCache, scrubSecrets } from "../utils/secret-scrubber";
3
+
4
+ // Snapshot/restore process.env between tests so env-derived cache entries
5
+ // don't leak across cases.
6
+ let savedEnv: Record<string, string | undefined>;
7
+
8
+ beforeEach(() => {
9
+ savedEnv = { ...process.env };
10
+ refreshSecretScrubberCache();
11
+ });
12
+
13
+ afterEach(() => {
14
+ for (const k of Object.keys(process.env)) {
15
+ if (!(k in savedEnv)) delete process.env[k];
16
+ }
17
+ for (const [k, v] of Object.entries(savedEnv)) {
18
+ if (v === undefined) delete process.env[k];
19
+ else process.env[k] = v;
20
+ }
21
+ refreshSecretScrubberCache();
22
+ });
23
+
24
+ describe("scrubSecrets — edge cases", () => {
25
+ test("empty string passes through", () => {
26
+ expect(scrubSecrets("")).toBe("");
27
+ });
28
+
29
+ test("null returns empty string", () => {
30
+ expect(scrubSecrets(null)).toBe("");
31
+ });
32
+
33
+ test("undefined returns empty string", () => {
34
+ expect(scrubSecrets(undefined)).toBe("");
35
+ });
36
+
37
+ test("plain text with no secrets passes through untouched", () => {
38
+ const s = "hello world, this is a regular log line with no secrets";
39
+ expect(scrubSecrets(s)).toBe(s);
40
+ });
41
+ });
42
+
43
+ describe("scrubSecrets — env-based replacement", () => {
44
+ test("redacts exact GITHUB_TOKEN value from env", () => {
45
+ process.env.GITHUB_TOKEN = "ghp_abcdefghijklmnopqrstuvwxyz0123456789";
46
+ refreshSecretScrubberCache();
47
+ const out = scrubSecrets("Authorization: Bearer ghp_abcdefghijklmnopqrstuvwxyz0123456789 end");
48
+ expect(out).toBe("Authorization: Bearer [REDACTED:GITHUB_TOKEN] end");
49
+ });
50
+
51
+ test("redacts any key with _API_KEY suffix", () => {
52
+ process.env.FOO_SERVICE_API_KEY = "supersecretFooApiKey_longerthan12chars";
53
+ refreshSecretScrubberCache();
54
+ const out = scrubSecrets("key=supersecretFooApiKey_longerthan12chars tail");
55
+ expect(out).toBe("key=[REDACTED:FOO_SERVICE_API_KEY] tail");
56
+ });
57
+
58
+ test("redacts any key with _TOKEN suffix", () => {
59
+ process.env.WEIRD_SERVICE_TOKEN = "weirdserviceTOKENvalue_1234567890";
60
+ refreshSecretScrubberCache();
61
+ const out = scrubSecrets("t=weirdserviceTOKENvalue_1234567890");
62
+ expect(out).toBe("t=[REDACTED:WEIRD_SERVICE_TOKEN]");
63
+ });
64
+
65
+ test("redacts any key with _SECRET suffix", () => {
66
+ process.env.MY_OAUTH_CLIENT_SECRET = "oauthsecret_verylong_1234567890abcdef";
67
+ refreshSecretScrubberCache();
68
+ const out = scrubSecrets("secret=oauthsecret_verylong_1234567890abcdef");
69
+ expect(out).toBe("secret=[REDACTED:MY_OAUTH_CLIENT_SECRET]");
70
+ });
71
+
72
+ test("does not redact safe keys like MCP_BASE_URL even though they could otherwise match suffix heuristics", () => {
73
+ // MCP_BASE_URL is on the exception allowlist — must not get scrubbed.
74
+ process.env.MCP_BASE_URL = "https://api.swarm.example.com:3013";
75
+ refreshSecretScrubberCache();
76
+ const out = scrubSecrets("connecting to https://api.swarm.example.com:3013");
77
+ expect(out).toBe("connecting to https://api.swarm.example.com:3013");
78
+ });
79
+
80
+ test("does not redact values shorter than the minimum length (defense against false positives)", () => {
81
+ process.env.SHORT_TOKEN = "abc12"; // 5 chars, below threshold
82
+ refreshSecretScrubberCache();
83
+ const out = scrubSecrets("contains abc12 somewhere");
84
+ expect(out).toBe("contains abc12 somewhere");
85
+ });
86
+
87
+ test("does not redact non-sensitive env vars", () => {
88
+ process.env.NODE_ENV = "production";
89
+ refreshSecretScrubberCache();
90
+ const out = scrubSecrets("env is production currently");
91
+ expect(out).toBe("env is production currently");
92
+ });
93
+
94
+ test("handles comma-separated pool values (scrubs both the full pool and each component)", () => {
95
+ process.env.POOL_TOKEN =
96
+ "ghp_poolfirst1234567890abcdefABCDEF1234567890,ghp_poolsecond1234567890abcdef1234567890AB";
97
+ refreshSecretScrubberCache();
98
+ const out = scrubSecrets("using ghp_poolfirst1234567890abcdefABCDEF1234567890");
99
+ expect(out).not.toContain("ghp_poolfirst1234567890abcdefABCDEF1234567890");
100
+ expect(out).toContain("[REDACTED:");
101
+ });
102
+
103
+ test("multi-secret line: both redacted", () => {
104
+ process.env.GITHUB_TOKEN = "ghp_abcdefghijklmnopqrstuvwxyz0123456789";
105
+ process.env.OPENAI_API_KEY = "sk-proj-abcd1234567890EFGHefgh1234567890";
106
+ refreshSecretScrubberCache();
107
+ const input =
108
+ "gh=ghp_abcdefghijklmnopqrstuvwxyz0123456789 and openai=sk-proj-abcd1234567890EFGHefgh1234567890";
109
+ const out = scrubSecrets(input);
110
+ expect(out).toContain("[REDACTED:GITHUB_TOKEN]");
111
+ expect(out).toContain("[REDACTED:OPENAI_API_KEY]");
112
+ expect(out).not.toContain("ghp_abcdefghijklmnopqrstuvwxyz");
113
+ expect(out).not.toContain("sk-proj-abcd1234567890");
114
+ });
115
+
116
+ test("cache rebuilds after refresh when new secret is added", () => {
117
+ const out1 = scrubSecrets("no secret yet here_abcdefghij");
118
+ expect(out1).toBe("no secret yet here_abcdefghij");
119
+
120
+ process.env.NEW_SERVICE_API_KEY = "here_abcdefghij_andmore1234567890";
121
+ refreshSecretScrubberCache();
122
+ const out2 = scrubSecrets("value=here_abcdefghij_andmore1234567890");
123
+ expect(out2).toBe("value=[REDACTED:NEW_SERVICE_API_KEY]");
124
+ });
125
+ });
126
+
127
+ describe("scrubSecrets — regex patterns", () => {
128
+ test("redacts github_pat_ fine-grained PATs", () => {
129
+ const out = scrubSecrets("PAT: github_pat_11B4WKYAA0Qe95fajGmt3o_ABCDEF1234567890abcdef");
130
+ expect(out).toContain("[REDACTED:github_pat]");
131
+ expect(out).not.toContain("github_pat_11B4WKYAA");
132
+ });
133
+
134
+ test("redacts ghp_ classic tokens", () => {
135
+ const out = scrubSecrets("PAT: ghp_1234567890abcdefABCDEF1234567890ABCD end");
136
+ expect(out).toContain("[REDACTED:github_token]");
137
+ expect(out).not.toContain("ghp_1234567890abcdef");
138
+ });
139
+
140
+ test("redacts gho_ OAuth tokens", () => {
141
+ const out = scrubSecrets("OAuth: gho_abcdef1234567890ABCDEF1234567890abcd");
142
+ expect(out).toContain("[REDACTED:github_token]");
143
+ });
144
+
145
+ test("redacts ghs_ installation tokens", () => {
146
+ const out = scrubSecrets("Installation: ghs_abcdef1234567890ABCDEF1234567890abcd");
147
+ expect(out).toContain("[REDACTED:github_token]");
148
+ });
149
+
150
+ test("redacts glpat- GitLab PATs", () => {
151
+ const out = scrubSecrets("GL: glpat-abcdef1234567890ABCDEFgh");
152
+ expect(out).toContain("[REDACTED:gitlab_pat]");
153
+ expect(out).not.toContain("glpat-abcdef");
154
+ });
155
+
156
+ test("redacts sk-ant- Anthropic keys", () => {
157
+ const out = scrubSecrets("Anthropic: sk-ant-api03-abc123def456ghi789jkl012mno345");
158
+ expect(out).toContain("[REDACTED:anthropic_key]");
159
+ expect(out).not.toContain("sk-ant-api03");
160
+ });
161
+
162
+ test("redacts sk-proj- OpenAI project keys (preferred over legacy sk-)", () => {
163
+ const out = scrubSecrets("OpenAI: sk-proj-abcdefghijklmnopqrstuvwxyz012345");
164
+ expect(out).toContain("[REDACTED:openai_proj_key]");
165
+ expect(out).not.toContain("sk-proj-abcdefghijkl");
166
+ });
167
+
168
+ test("redacts legacy sk- keys (catch-all)", () => {
169
+ const out = scrubSecrets("Legacy: sk-abcdefghijklmnopqrstuvwxyz0123");
170
+ expect(out).toContain("[REDACTED:sk_key]");
171
+ });
172
+
173
+ test("redacts Slack xoxb tokens", () => {
174
+ const out = scrubSecrets("slack=xoxb-1234567890-0987654321-abcdefghij");
175
+ expect(out).toContain("[REDACTED:slack_token]");
176
+ expect(out).not.toContain("xoxb-1234567890");
177
+ });
178
+
179
+ test("redacts AWS access key IDs", () => {
180
+ const out = scrubSecrets("AWS: AKIAIOSFODNN7EXAMPLE in config");
181
+ expect(out).toContain("[REDACTED:aws_access_key]");
182
+ expect(out).not.toContain("AKIAIOSFODNN7EXAMPLE");
183
+ });
184
+
185
+ test("redacts Google AIza API keys", () => {
186
+ // Google API key shape: `AIza` + exactly 35 word chars.
187
+ const out = scrubSecrets("gapi: AIzaSyABCDEFGHIJKLMNOPQRSTUVWXYZ0123456 tail");
188
+ expect(out).toContain("[REDACTED:google_api_key]");
189
+ expect(out).not.toContain("AIzaSyABCDEFGHI");
190
+ });
191
+
192
+ test("redacts JWT-shaped tokens", () => {
193
+ const jwt =
194
+ "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
195
+ const out = scrubSecrets(`auth: ${jwt}`);
196
+ expect(out).toContain("[REDACTED:jwt]");
197
+ expect(out).not.toContain("eyJhbGciOiJIUzI1NiJ9");
198
+ });
199
+
200
+ test("regex patterns catch tokens even when env is empty", () => {
201
+ // Fresh env — no secrets registered — regex should still catch well-known shapes.
202
+ const out = scrubSecrets("token=ghp_1234567890abcdefABCDEF1234567890ABCD");
203
+ expect(out).toContain("[REDACTED:github_token]");
204
+ });
205
+ });
206
+
207
+ describe("scrubSecrets — does not over-scrub", () => {
208
+ test("the word 'token' in prose is not redacted", () => {
209
+ const s = "Please provide your access token in the Authorization header.";
210
+ expect(scrubSecrets(s)).toBe(s);
211
+ });
212
+
213
+ test("short strings are never redacted by regex", () => {
214
+ const out = scrubSecrets("gh pr ghp_short or ghp_ghp_ or ghp_abc");
215
+ // "ghp_abc" is only 7 chars — below the 20-char threshold.
216
+ expect(out).toBe("gh pr ghp_short or ghp_ghp_ or ghp_abc");
217
+ });
218
+
219
+ test("arbitrary base64 strings that don't match any credential shape are preserved", () => {
220
+ const b64 = "SGVsbG8gV29ybGQhIFRoaXMgaXMgbm90IGEgc2VjcmV0Lg==";
221
+ const out = scrubSecrets(`data: ${b64}`);
222
+ expect(out).toContain(b64);
223
+ });
224
+
225
+ test("idempotent — scrubbing an already-scrubbed string is a no-op", () => {
226
+ process.env.GITHUB_TOKEN = "ghp_abcdefghijklmnopqrstuvwxyz0123456789";
227
+ refreshSecretScrubberCache();
228
+ const once = scrubSecrets("x=ghp_abcdefghijklmnopqrstuvwxyz0123456789");
229
+ const twice = scrubSecrets(once);
230
+ expect(twice).toBe(once);
231
+ expect(twice).toContain("[REDACTED:GITHUB_TOKEN]");
232
+ });
233
+
234
+ test("the `[REDACTED:...]` markers themselves don't get scrubbed", () => {
235
+ // Important: the marker strings include `_` so we need to ensure the regex
236
+ // patterns don't chew through `[REDACTED:github_pat]` etc.
237
+ const out = scrubSecrets("result=[REDACTED:github_token] OK");
238
+ expect(out).toBe("result=[REDACTED:github_token] OK");
239
+ });
240
+
241
+ test("preserves placeholder-style fake tokens in docs without env registration", () => {
242
+ // "ghp_YOUR_TOKEN_HERE" matches the regex because it has 17 chars after
243
+ // ghp_ which is > 20 total — but we'd rather scrub than leak, so accept
244
+ // this as expected (no test assertion that it's preserved).
245
+ // Instead, assert a shorter placeholder is NOT scrubbed.
246
+ const out = scrubSecrets("example: ghp_TOKEN and glpat-xyz (both too short)");
247
+ expect(out).toBe("example: ghp_TOKEN and glpat-xyz (both too short)");
248
+ });
249
+ });
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Runtime secret scrubber for log/stdout/stderr emission.
3
+ *
4
+ * Exported `scrubSecrets(text)` replaces known sensitive values with
5
+ * `[REDACTED:<name>]` placeholders. Used at every text-egress point (adapter
6
+ * log files, session-log uploads, pretty-printed stdout, stderr dumps) so
7
+ * credentials set via `swarm_config` or container env never leak into
8
+ * /workspace/logs/*.jsonl, the `session_logs` SQLite table, or container
9
+ * stdout shipped to log aggregators.
10
+ *
11
+ * Two sources are combined:
12
+ * 1. `process.env` values of known-sensitive keys (either exact names or
13
+ * suffix-matched like *_API_KEY, *_TOKEN, *_SECRET). These are the
14
+ * concrete strings the worker actually holds.
15
+ * 2. Structural regex patterns for well-known token shapes (GitHub PATs,
16
+ * OpenAI keys, Slack tokens, JWTs, …). Covers cases where a secret
17
+ * arrived via a tool result without ever being in our env.
18
+ *
19
+ * This module is deliberately worker/API neutral — it reads only from
20
+ * `process.env` so it can be imported from both sides without violating the
21
+ * API↔worker DB boundary (scripts/check-db-boundary.sh).
22
+ */
23
+
24
+ /** Env-var names that are always considered secrets, even without suffix hints. */
25
+ const SENSITIVE_KEY_EXACT = new Set<string>([
26
+ "API_KEY",
27
+ "SECRETS_ENCRYPTION_KEY",
28
+ "GITHUB_TOKEN",
29
+ "GITLAB_TOKEN",
30
+ "CLAUDE_CODE_OAUTH_TOKEN",
31
+ "ANTHROPIC_API_KEY",
32
+ "OPENAI_API_KEY",
33
+ "OPENROUTER_API_KEY",
34
+ "SLACK_BOT_TOKEN",
35
+ "SLACK_SIGNING_SECRET",
36
+ "SLACK_CLIENT_SECRET",
37
+ "SLACK_USER_TOKEN",
38
+ "SLACK_APP_TOKEN",
39
+ "SENTRY_AUTH_TOKEN",
40
+ "VERCEL_TOKEN",
41
+ "RESEND_API_KEY",
42
+ "AGENTMAIL_API_KEY",
43
+ "AGENT_FS_API_KEY",
44
+ "BUSINESS_USE_API_KEY",
45
+ "QA_USE_API_KEY",
46
+ "DOCS_API_KEY",
47
+ "DOKPLOY_API_KEY",
48
+ "DEVTO_API_KEY",
49
+ "ELEVENLABS_API_KEY",
50
+ "ENGINY_API_KEY",
51
+ "OPENFORT_API_KEY",
52
+ "OPENFORT_TEST_SECRET_KEY",
53
+ "OPENFORT_TEST_WALLET_PRIVATE_KEY",
54
+ "OPENFORT_WALLET_SECRET",
55
+ "TURSO_API_TOKEN",
56
+ "TURSO_DB_TOKEN",
57
+ "TURSO_X_POSTS_DB_TOKEN",
58
+ "BROWSER_USE_API_KEY",
59
+ "PLAUSIBLE_API_KEY",
60
+ "IMGFLIP_PASSWORD",
61
+ "GSC_SERVICE_ACCOUNT_BASE64",
62
+ "LINEAR_API_KEY",
63
+ "LINEAR_OAUTH_CLIENT_SECRET",
64
+ ]);
65
+
66
+ /** Suffixes that mark an env-var value as sensitive by convention. */
67
+ const SENSITIVE_KEY_SUFFIXES = ["_API_KEY", "_TOKEN", "_SECRET", "_PASSWORD", "_PRIVATE_KEY"];
68
+
69
+ /** Keys that match the sensitive suffix heuristic but are actually safe URLs/configs. */
70
+ const NON_SECRET_EXCEPTIONS = new Set<string>([
71
+ "MCP_BASE_URL",
72
+ "APP_URL",
73
+ "API_URL",
74
+ "TEMPLATE_REGISTRY_URL",
75
+ ]);
76
+
77
+ /**
78
+ * Minimum length for an env-var value to be considered scrub-worthy.
79
+ * Short values (< 12 chars) cause false-positive replacements across
80
+ * legitimate log content (e.g. a 6-char password would collide with a user
81
+ * name). For short secrets we rely on the regex pass only.
82
+ */
83
+ const MIN_VALUE_LENGTH = 12;
84
+
85
+ /**
86
+ * Structural regex patterns for common credential shapes. Applied AFTER the
87
+ * env-value substitution pass so env-sourced replacements keep their
88
+ * human-readable `[REDACTED:<KEY_NAME>]` labels instead of the generic
89
+ * pattern name.
90
+ *
91
+ * Order matters when one pattern is a prefix of another (e.g. `sk-ant-` must
92
+ * match before the more general `sk-`).
93
+ */
94
+ const TOKEN_REGEXES: ReadonlyArray<{ name: string; re: RegExp }> = [
95
+ // GitHub fine-grained PATs
96
+ { name: "github_pat", re: /github_pat_[A-Za-z0-9_]{20,}/g },
97
+ // GitHub classic/OAuth tokens (ghp_, gho_, ghu_, ghs_, ghr_)
98
+ { name: "github_token", re: /\bgh[pousr]_[A-Za-z0-9]{20,}\b/g },
99
+ // GitLab personal access tokens
100
+ { name: "gitlab_pat", re: /\bglpat-[A-Za-z0-9_-]{20,}\b/g },
101
+ // Anthropic API keys (must match before the generic sk- rule below)
102
+ { name: "anthropic_key", re: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/g },
103
+ // OpenAI project keys
104
+ { name: "openai_proj_key", re: /\bsk-proj-[A-Za-z0-9_-]{20,}\b/g },
105
+ // OpenRouter keys
106
+ { name: "openrouter_key", re: /\bsk-or-(?:v1-)?[A-Za-z0-9_-]{20,}\b/g },
107
+ // Generic sk- legacy OpenAI keys (must come AFTER the ant/proj/or variants)
108
+ { name: "sk_key", re: /\bsk-[A-Za-z0-9]{20,}\b/g },
109
+ // Slack tokens
110
+ { name: "slack_token", re: /\bxox[baprseo]-[A-Za-z0-9-]{10,}\b/g },
111
+ // AWS access key IDs
112
+ { name: "aws_access_key", re: /\bAKIA[0-9A-Z]{16}\b/g },
113
+ // Google API keys
114
+ { name: "google_api_key", re: /\bAIza[A-Za-z0-9_-]{35}\b/g },
115
+ // JWTs (3 dot-separated base64url segments)
116
+ {
117
+ name: "jwt",
118
+ re: /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g,
119
+ },
120
+ ];
121
+
122
+ interface EnvValueEntry {
123
+ value: string;
124
+ name: string;
125
+ }
126
+
127
+ interface ScrubCache {
128
+ entries: EnvValueEntry[];
129
+ snapshotKey: string;
130
+ }
131
+
132
+ let cache: ScrubCache | null = null;
133
+
134
+ /** Fingerprint current env so we can invalidate cache cheaply when it changes. */
135
+ function snapshotEnv(): string {
136
+ const parts: string[] = [];
137
+ for (const key of Object.keys(process.env).sort()) {
138
+ if (!isSensitiveKey(key)) continue;
139
+ const v = process.env[key];
140
+ if (!v) continue;
141
+ parts.push(`${key}=${v.length}`);
142
+ }
143
+ return parts.join("|");
144
+ }
145
+
146
+ function isSensitiveKey(key: string): boolean {
147
+ if (NON_SECRET_EXCEPTIONS.has(key)) return false;
148
+ if (SENSITIVE_KEY_EXACT.has(key)) return true;
149
+ for (const suffix of SENSITIVE_KEY_SUFFIXES) {
150
+ if (key.endsWith(suffix)) return true;
151
+ }
152
+ return false;
153
+ }
154
+
155
+ function buildCache(): ScrubCache {
156
+ const entries: EnvValueEntry[] = [];
157
+ const seen = new Set<string>();
158
+
159
+ for (const [key, rawValue] of Object.entries(process.env)) {
160
+ if (!rawValue) continue;
161
+ if (!isSensitiveKey(key)) continue;
162
+
163
+ // Credential pools: a single env var may hold a comma-separated list of
164
+ // tokens that the runner rotates through. Scrub each component too.
165
+ const candidates = rawValue.includes(",")
166
+ ? [rawValue, ...rawValue.split(",").map((s) => s.trim())]
167
+ : [rawValue];
168
+
169
+ for (const candidate of candidates) {
170
+ if (!candidate) continue;
171
+ if (candidate.length < MIN_VALUE_LENGTH) continue;
172
+ if (seen.has(candidate)) continue;
173
+ seen.add(candidate);
174
+ entries.push({ value: candidate, name: key });
175
+ }
176
+ }
177
+
178
+ // Replace longer values before shorter ones so prefix-overlapping secrets
179
+ // don't mangle each other (rare but possible with pool values).
180
+ entries.sort((a, b) => b.value.length - a.value.length);
181
+
182
+ return { entries, snapshotKey: snapshotEnv() };
183
+ }
184
+
185
+ function getCache(): ScrubCache {
186
+ const current = snapshotEnv();
187
+ if (!cache || cache.snapshotKey !== current) {
188
+ cache = buildCache();
189
+ }
190
+ return cache;
191
+ }
192
+
193
+ /**
194
+ * Replace known secret values in `text` with `[REDACTED:<name>]` markers.
195
+ * Null/undefined inputs return an empty string. Empty strings pass through.
196
+ */
197
+ export function scrubSecrets(text: string | null | undefined): string {
198
+ if (text == null) return "";
199
+ if (text.length === 0) return text;
200
+
201
+ let out = text;
202
+
203
+ // Pass 1: exact-match env values (preserves the env-var name in the marker
204
+ // for debugging).
205
+ const { entries } = getCache();
206
+ for (const { value, name } of entries) {
207
+ if (out.includes(value)) {
208
+ // split/join is O(n) and faster than building a RegExp for every value.
209
+ out = out.split(value).join(`[REDACTED:${name}]`);
210
+ }
211
+ }
212
+
213
+ // Pass 2: structural patterns (catches secrets we never saw in env, e.g.
214
+ // a token pasted into a tool_result by the operator or fetched from a
215
+ // third-party API during a task).
216
+ for (const { name, re } of TOKEN_REGEXES) {
217
+ out = out.replace(re, `[REDACTED:${name}]`);
218
+ }
219
+
220
+ return out;
221
+ }
222
+
223
+ /**
224
+ * Force the env-value cache to rebuild on the next scrub call. Callers should
225
+ * invoke this whenever the swarm_config is reloaded (`/internal/reload-config`
226
+ * on the API, credential-selection on the worker) so new secrets get covered
227
+ * immediately.
228
+ */
229
+ export function refreshSecretScrubberCache(): void {
230
+ cache = null;
231
+ }