@desplega.ai/agent-swarm 1.67.3 → 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 +1 -1
- package/package.json +1 -1
- package/src/be/db.ts +6 -1
- package/src/http/core.ts +6 -0
- package/src/providers/claude-adapter.ts +10 -6
- package/src/providers/codex-adapter.ts +12 -3
- package/src/providers/pi-mono-adapter.ts +11 -3
- package/src/slack/handlers.ts +17 -2
- package/src/slack/router.ts +20 -2
- package/src/tests/secret-scrubber.test.ts +249 -0
- package/src/tests/slack-bot-filter.test.ts +156 -0
- package/src/utils/secret-scrubber.ts +231 -0
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.
|
|
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
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
|
-
|
|
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
|
-
|
|
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:
|
|
322
|
+
`${JSON.stringify({ type: "stderr", content: scrubbedText, timestamp: new Date().toISOString() })}\n`,
|
|
319
323
|
);
|
|
320
|
-
this.emit({ type: "raw_stderr", content:
|
|
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({ ...
|
|
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(
|
|
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(
|
|
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({ ...
|
|
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(
|
|
182
|
+
listener(scrubbed);
|
|
175
183
|
}
|
|
176
184
|
} else {
|
|
177
|
-
this.eventQueue.push(
|
|
185
|
+
this.eventQueue.push(scrubbed);
|
|
178
186
|
}
|
|
179
187
|
}
|
|
180
188
|
|
package/src/slack/handlers.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { resolveTemplate } from "../prompts/resolver";
|
|
|
13
13
|
import { workflowEventBus } from "../workflows/event-bus";
|
|
14
14
|
import { buildTreeBlocks, type TreeNode } from "./blocks";
|
|
15
15
|
import type { SlackFile } from "./files";
|
|
16
|
-
import { extractTaskFromMessage, routeMessage } from "./router";
|
|
16
|
+
import { extractTaskFromMessage, hasOtherUserMention, routeMessage } from "./router";
|
|
17
17
|
// Side-effect import: registers all Slack event templates in the in-memory registry
|
|
18
18
|
import "./templates";
|
|
19
19
|
import { bufferThreadMessage, getBufferMessageCount, instantFlush } from "./thread-buffer";
|
|
@@ -162,6 +162,12 @@ interface MessageEvent {
|
|
|
162
162
|
* - `bot_id` present (newer Slack API, may lack subtype)
|
|
163
163
|
* - `user` matches the bot's own user ID (catches edge cases where
|
|
164
164
|
* messages posted with `username` override lack `bot_id`)
|
|
165
|
+
*
|
|
166
|
+
* Note: intentionally does NOT filter on `app_id`/`bot_profile`/`username` —
|
|
167
|
+
* those signals also appear on human messages sent via Slack apps that proxy
|
|
168
|
+
* a user (e.g. Claude.ai's Slack integration sends with `app_id` + `bot_profile`
|
|
169
|
+
* set, but the poster is still a real human). Filtering those drops legitimate
|
|
170
|
+
* human @mentions of the swarm.
|
|
165
171
|
*/
|
|
166
172
|
export function isBotMessage(
|
|
167
173
|
event: { subtype?: string; bot_id?: string; user?: string },
|
|
@@ -439,8 +445,17 @@ export function registerMessageHandler(app: App): void {
|
|
|
439
445
|
}
|
|
440
446
|
}
|
|
441
447
|
|
|
442
|
-
// ADDITIVE_SLACK: Buffer non-mention thread messages
|
|
448
|
+
// ADDITIVE_SLACK: Buffer non-mention thread messages.
|
|
449
|
+
// Skip if the message @-mentions someone other than our bot (e.g. "@Devin wdyt?"):
|
|
450
|
+
// that message is directed at a different bot/user and must not be fed to
|
|
451
|
+
// the swarm as an implicit follow-up.
|
|
443
452
|
if (additiveSlack && !botMentioned && msg.thread_ts && !requireMentionForThreadFollowup) {
|
|
453
|
+
if (hasOtherUserMention(effectiveText, botUserId)) {
|
|
454
|
+
console.log(
|
|
455
|
+
`[Slack] Skipping ADDITIVE buffer in ${msg.channel}/${msg.thread_ts}: message mentions another user`,
|
|
456
|
+
);
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
444
459
|
// Check if this thread has any swarm activity (existing tasks)
|
|
445
460
|
const hasSwarmActivity = getAgentWorkingOnThread(msg.channel, msg.thread_ts) !== null;
|
|
446
461
|
|
package/src/slack/router.ts
CHANGED
|
@@ -6,6 +6,15 @@ export interface ThreadContext {
|
|
|
6
6
|
threadTs: string;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Returns true if the text contains a `<@U...>` mention of anyone other than our bot.
|
|
11
|
+
* Exported for testing.
|
|
12
|
+
*/
|
|
13
|
+
export function hasOtherUserMention(text: string, botUserId: string): boolean {
|
|
14
|
+
const mentions = text.match(/<@([A-Z0-9]+)>/g) ?? [];
|
|
15
|
+
return mentions.some((m) => m !== `<@${botUserId}>`);
|
|
16
|
+
}
|
|
17
|
+
|
|
9
18
|
/**
|
|
10
19
|
* Routes a Slack message to the appropriate agent(s) based on mentions.
|
|
11
20
|
*
|
|
@@ -16,7 +25,7 @@ export interface ThreadContext {
|
|
|
16
25
|
*/
|
|
17
26
|
export function routeMessage(
|
|
18
27
|
text: string,
|
|
19
|
-
|
|
28
|
+
botUserId: string,
|
|
20
29
|
botMentioned: boolean,
|
|
21
30
|
threadContext?: ThreadContext,
|
|
22
31
|
): AgentMatch[] {
|
|
@@ -46,8 +55,17 @@ export function routeMessage(
|
|
|
46
55
|
}
|
|
47
56
|
}
|
|
48
57
|
|
|
49
|
-
// Thread follow-up — route to agent already working in this thread
|
|
58
|
+
// Thread follow-up — route to agent already working in this thread.
|
|
59
|
+
// Skip if the message @-mentions someone other than our bot (e.g. "@Devin wdyt?")
|
|
60
|
+
// and does not mention our bot: that message is directed at a different bot/user,
|
|
61
|
+
// not a follow-up intended for the swarm.
|
|
50
62
|
if (matches.length === 0 && threadContext && (!requireMentionForThreadFollowup || botMentioned)) {
|
|
63
|
+
if (!botMentioned && hasOtherUserMention(text, botUserId)) {
|
|
64
|
+
console.log(
|
|
65
|
+
`[Slack] Skipping thread follow-up in ${threadContext.channelId}/${threadContext.threadTs}: message mentions another user`,
|
|
66
|
+
);
|
|
67
|
+
return matches;
|
|
68
|
+
}
|
|
51
69
|
const workingAgent = getAgentWorkingOnThread(threadContext.channelId, threadContext.threadTs);
|
|
52
70
|
if (workingAgent && workingAgent.status !== "offline") {
|
|
53
71
|
console.log(
|
|
@@ -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,156 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { unlinkSync } from "node:fs";
|
|
3
|
+
import { closeDb, createAgent, createTaskExtended, initDb } from "../be/db";
|
|
4
|
+
import { isBotMessage } from "../slack/handlers";
|
|
5
|
+
import { hasOtherUserMention, routeMessage } from "../slack/router";
|
|
6
|
+
import type { Agent } from "../types";
|
|
7
|
+
|
|
8
|
+
const TEST_DB_PATH = "./test-slack-bot-filter.sqlite";
|
|
9
|
+
|
|
10
|
+
let leadAgent: Agent;
|
|
11
|
+
let workerAgent: Agent;
|
|
12
|
+
|
|
13
|
+
beforeAll(() => {
|
|
14
|
+
initDb(TEST_DB_PATH);
|
|
15
|
+
leadAgent = createAgent({
|
|
16
|
+
name: "filter-lead",
|
|
17
|
+
isLead: true,
|
|
18
|
+
status: "idle",
|
|
19
|
+
capabilities: [],
|
|
20
|
+
});
|
|
21
|
+
workerAgent = createAgent({
|
|
22
|
+
name: "filter-worker",
|
|
23
|
+
isLead: false,
|
|
24
|
+
status: "idle",
|
|
25
|
+
capabilities: [],
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterAll(() => {
|
|
30
|
+
closeDb();
|
|
31
|
+
try {
|
|
32
|
+
unlinkSync(TEST_DB_PATH);
|
|
33
|
+
unlinkSync(`${TEST_DB_PATH}-wal`);
|
|
34
|
+
unlinkSync(`${TEST_DB_PATH}-shm`);
|
|
35
|
+
} catch {
|
|
36
|
+
// ignore
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("isBotMessage", () => {
|
|
41
|
+
const BOT_ID = "UBOT123";
|
|
42
|
+
|
|
43
|
+
test("plain human message → false", () => {
|
|
44
|
+
expect(isBotMessage({ user: "UHUMAN" }, BOT_ID)).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("subtype bot_message → true", () => {
|
|
48
|
+
expect(isBotMessage({ subtype: "bot_message", user: "UHUMAN" }, BOT_ID)).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("bot_id present → true", () => {
|
|
52
|
+
expect(isBotMessage({ bot_id: "B001", user: "UHUMAN" }, BOT_ID)).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("own bot user ID (self-posted) → true", () => {
|
|
56
|
+
expect(isBotMessage({ user: BOT_ID }, BOT_ID)).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("empty event with no bot signals → false", () => {
|
|
60
|
+
expect(isBotMessage({ user: "UHUMAN" }, BOT_ID)).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("hasOtherUserMention", () => {
|
|
65
|
+
const BOT_ID = "UBOT123";
|
|
66
|
+
|
|
67
|
+
test("no mentions → false", () => {
|
|
68
|
+
expect(hasOtherUserMention("hello everyone", BOT_ID)).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("only our bot mentioned → false", () => {
|
|
72
|
+
expect(hasOtherUserMention(`hey <@${BOT_ID}> pls`, BOT_ID)).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("different user mentioned → true", () => {
|
|
76
|
+
expect(hasOtherUserMention("hey <@UDEVIN01> wdyt", BOT_ID)).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("our bot AND another user mentioned → true", () => {
|
|
80
|
+
expect(hasOtherUserMention(`<@${BOT_ID}> and <@UDEVIN01> hi`, BOT_ID)).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("routeMessage — thread follow-up skips messages aimed at other users", () => {
|
|
85
|
+
const BOT_ID = "UBOT123";
|
|
86
|
+
|
|
87
|
+
test("plain follow-up (no mentions) still routes to active worker", () => {
|
|
88
|
+
const channelId = "C_BF_100";
|
|
89
|
+
const threadTs = "1100.0001";
|
|
90
|
+
createTaskExtended("original", {
|
|
91
|
+
agentId: workerAgent.id,
|
|
92
|
+
source: "slack",
|
|
93
|
+
slackChannelId: channelId,
|
|
94
|
+
slackThreadTs: threadTs,
|
|
95
|
+
slackUserId: "U_HUMAN",
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const matches = routeMessage("and also the weather", BOT_ID, false, {
|
|
99
|
+
channelId,
|
|
100
|
+
threadTs,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
expect(matches).toHaveLength(1);
|
|
104
|
+
expect(matches[0].agent.id).toBe(workerAgent.id);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("follow-up mentioning another bot (Devin) does NOT route", () => {
|
|
108
|
+
const channelId = "C_BF_200";
|
|
109
|
+
const threadTs = "1200.0001";
|
|
110
|
+
createTaskExtended("original", {
|
|
111
|
+
agentId: workerAgent.id,
|
|
112
|
+
source: "slack",
|
|
113
|
+
slackChannelId: channelId,
|
|
114
|
+
slackThreadTs: threadTs,
|
|
115
|
+
slackUserId: "U_HUMAN",
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const matches = routeMessage("<@UDEVIN01> wdyt?", BOT_ID, false, {
|
|
119
|
+
channelId,
|
|
120
|
+
threadTs,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
expect(matches).toHaveLength(0);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("follow-up mentioning BOTH our bot and another bot routes to swarm", () => {
|
|
127
|
+
const channelId = "C_BF_300";
|
|
128
|
+
const threadTs = "1300.0001";
|
|
129
|
+
createTaskExtended("original", {
|
|
130
|
+
agentId: workerAgent.id,
|
|
131
|
+
source: "slack",
|
|
132
|
+
slackChannelId: channelId,
|
|
133
|
+
slackThreadTs: threadTs,
|
|
134
|
+
slackUserId: "U_HUMAN",
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const matches = routeMessage(`<@${BOT_ID}> and <@UDEVIN01> please coordinate`, BOT_ID, true, {
|
|
138
|
+
channelId,
|
|
139
|
+
threadTs,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
expect(matches).toHaveLength(1);
|
|
143
|
+
expect(matches[0].agent.id).toBe(workerAgent.id);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("no thread activity + only another bot mentioned → no match", () => {
|
|
147
|
+
const matches = routeMessage("<@UDEVIN01> hi", BOT_ID, false, {
|
|
148
|
+
channelId: "C_BF_400",
|
|
149
|
+
threadTs: "1400.0001",
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
expect(matches).toHaveLength(0);
|
|
153
|
+
// Silence unused lead warning — lead exists but should not be routed to
|
|
154
|
+
expect(leadAgent.id).toBeDefined();
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -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
|
+
}
|