@app-vitals/agent-lib 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,51 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { callerLabel } from "./request-context.ts";
3
+
4
+ describe("callerLabel — defined caller", () => {
5
+ test("formats an admin caller (scope '*')", () => {
6
+ expect(callerLabel({ name: "bodhi", scope: "*" })).toBe("bodhi (*)");
7
+ });
8
+
9
+ test("formats a caller scoped to a clientId", () => {
10
+ expect(callerLabel({ name: "sully", scope: "client-xyz" })).toBe(
11
+ "sully (client-xyz)",
12
+ );
13
+ });
14
+
15
+ test("formats a caller scoped to an agentId", () => {
16
+ expect(callerLabel({ name: "agent-123", scope: "agent-456" })).toBe(
17
+ "agent-123 (agent-456)",
18
+ );
19
+ });
20
+
21
+ test("is stable across repeated calls with the same input", () => {
22
+ const caller = { name: "bodhi", scope: "*" };
23
+ expect(callerLabel(caller)).toBe(callerLabel(caller));
24
+ });
25
+ });
26
+
27
+ describe("callerLabel — undefined caller", () => {
28
+ test("returns a distinct, readable label for undefined", () => {
29
+ const label = callerLabel(undefined);
30
+ expect(label).toBe("anonymous");
31
+ });
32
+
33
+ test("returns the same label when called with no argument", () => {
34
+ expect(callerLabel()).toBe("anonymous");
35
+ });
36
+
37
+ test("undefined label never collides with a defined caller's label", () => {
38
+ const anonymousLabel = callerLabel(undefined);
39
+ expect(callerLabel({ name: "anonymous", scope: "*" })).not.toBe(
40
+ anonymousLabel,
41
+ );
42
+ });
43
+ });
44
+
45
+ describe("callerLabel — scope kind", () => {
46
+ test("appends the scope kind when given", () => {
47
+ expect(
48
+ callerLabel({ name: "svc", scope: "platform.read" }, "platform-read"),
49
+ ).toBe("svc (platform.read) [platform-read]");
50
+ });
51
+ });
@@ -0,0 +1,41 @@
1
+ /**
2
+ * lib/secret-env-vars.ts
3
+ * Canonical list of well-known secret-shaped env var names used by the
4
+ * shipwright-agent framework. Shared by Sentry log/event scrubbing
5
+ * (lib/sentry.ts) and by admin's agent env storage (marks these keys as
6
+ * `secret` so they're masked in GET /agents/:id/envs responses).
7
+ */
8
+
9
+ /** Secret-shaped env var names, redacted/masked wherever their value is currently set. */
10
+ export const SECRET_ENV_VARS = [
11
+ "ANTHROPIC_API_KEY",
12
+ "CLAUDE_CODE_OAUTH_TOKEN",
13
+ "GH_APP_PRIVATE_KEY",
14
+ "GH_APP_CLIENT_SECRET",
15
+ "GH_TOKEN",
16
+ "SLACK_BOT_TOKEN",
17
+ "SLACK_APP_TOKEN",
18
+ "SLACK_SIGNING_SECRET",
19
+ "SLACK_CLIENT_SECRET",
20
+ "SLACK_ADMIN_TOKEN",
21
+ "SHIPWRIGHT_AGENT_API_KEY",
22
+ "SHIPWRIGHT_TASK_STORE_TOKEN",
23
+ "SHIPWRIGHT_TASK_STORE_ADMIN_TOKEN",
24
+ "SHIPWRIGHT_CHAT_SERVICE_TOKEN",
25
+ "SHIPWRIGHT_CHAT_SERVICE_ADMIN_TOKEN",
26
+ "SHIPWRIGHT_ADMIN_API_KEYS",
27
+ "SHIPWRIGHT_SESSION_SECRET",
28
+ "SHIPWRIGHT_ENCRYPTION_KEY",
29
+ // VAPID private key for Web Push (RFC 8292). The PUBLIC key is NOT secret —
30
+ // it's sent to the browser — so only the private key is listed here.
31
+ "SHIPWRIGHT_ADMIN_VAPID_PRIVATE_KEY",
32
+ // Shared token the chat service presents to admin's push-notify webhook so
33
+ // an unauthenticated caller can't trigger notifications.
34
+ "SHIPWRIGHT_ADMIN_PUSH_WEBHOOK_TOKEN",
35
+ // Bearer token the chat service presents to admin's push-notify webhook
36
+ // when firing outbound reply notifications. Same shared-secret value as
37
+ // SHIPWRIGHT_ADMIN_PUSH_WEBHOOK_TOKEN in deployment, but a distinct env
38
+ // var name (chat-side vs. admin-side).
39
+ "SHIPWRIGHT_CHAT_PUSH_WEBHOOK_TOKEN",
40
+ "GOOGLE_CLIENT_SECRET",
41
+ ] as const;
@@ -0,0 +1,62 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { SECRET_ENV_VARS } from "./secret-env-vars.ts";
3
+
4
+ describe("SECRET_ENV_VARS", () => {
5
+ test("is a non-empty array", () => {
6
+ expect(Array.isArray(SECRET_ENV_VARS)).toBe(true);
7
+ expect(SECRET_ENV_VARS.length).toBeGreaterThan(0);
8
+ });
9
+
10
+ test("contains expected well-known secret env var names", () => {
11
+ const expectedEntries = [
12
+ "ANTHROPIC_API_KEY",
13
+ "CLAUDE_CODE_OAUTH_TOKEN",
14
+ "GH_APP_PRIVATE_KEY",
15
+ "GH_APP_CLIENT_SECRET",
16
+ "GH_TOKEN",
17
+ "SLACK_BOT_TOKEN",
18
+ "SLACK_APP_TOKEN",
19
+ "SLACK_SIGNING_SECRET",
20
+ "SLACK_CLIENT_SECRET",
21
+ "SLACK_ADMIN_TOKEN",
22
+ "SHIPWRIGHT_AGENT_API_KEY",
23
+ "SHIPWRIGHT_TASK_STORE_TOKEN",
24
+ "SHIPWRIGHT_TASK_STORE_ADMIN_TOKEN",
25
+ "SHIPWRIGHT_CHAT_SERVICE_TOKEN",
26
+ "SHIPWRIGHT_CHAT_SERVICE_ADMIN_TOKEN",
27
+ "SHIPWRIGHT_ADMIN_API_KEYS",
28
+ "SHIPWRIGHT_SESSION_SECRET",
29
+ "SHIPWRIGHT_ENCRYPTION_KEY",
30
+ "SHIPWRIGHT_ADMIN_VAPID_PRIVATE_KEY",
31
+ "SHIPWRIGHT_ADMIN_PUSH_WEBHOOK_TOKEN",
32
+ "SHIPWRIGHT_CHAT_PUSH_WEBHOOK_TOKEN",
33
+ "GOOGLE_CLIENT_SECRET",
34
+ ];
35
+
36
+ for (const entry of expectedEntries) {
37
+ expect(SECRET_ENV_VARS as readonly string[]).toContain(entry);
38
+ }
39
+ });
40
+
41
+ test("has exactly the expected number of entries (catches accidental additions/removals)", () => {
42
+ expect(SECRET_ENV_VARS.length).toBe(22);
43
+ });
44
+
45
+ test("every entry is a non-empty string", () => {
46
+ for (const entry of SECRET_ENV_VARS) {
47
+ expect(typeof entry).toBe("string");
48
+ expect(entry.length).toBeGreaterThan(0);
49
+ }
50
+ });
51
+
52
+ test("every entry looks like an env var name (uppercase snake case)", () => {
53
+ for (const entry of SECRET_ENV_VARS) {
54
+ expect(entry).toMatch(/^[A-Z][A-Z0-9_]*$/);
55
+ }
56
+ });
57
+
58
+ test("has no duplicate entries", () => {
59
+ const uniqueEntries = new Set(SECRET_ENV_VARS);
60
+ expect(uniqueEntries.size).toBe(SECRET_ENV_VARS.length);
61
+ });
62
+ });
package/sentry.ts ADDED
@@ -0,0 +1,201 @@
1
+ import * as Sentry from "@sentry/bun";
2
+ import { consoleLoggingIntegration } from "@sentry/bun";
3
+ import type { BunOptions, ErrorEvent, EventHint } from "@sentry/bun";
4
+ import { SECRET_ENV_VARS } from "./secret-env-vars.ts";
5
+
6
+ /**
7
+ * `@sentry/bun` doesn't re-export the `Log` type directly (it lives in `@sentry/core`, a
8
+ * transitive dependency), so it's derived here from `beforeSendLog`'s parameter type instead of
9
+ * adding a direct dependency on `@sentry/core`.
10
+ */
11
+ type SentryLog = NonNullable<BunOptions["beforeSendLog"]> extends (
12
+ log: infer L,
13
+ ) => unknown
14
+ ? L
15
+ : never;
16
+
17
+ interface InitSentryOptions {
18
+ service: string;
19
+ agentId?: string;
20
+ }
21
+
22
+ /** Narrowed to the one method initSentry calls, so tests can inject a fake without mock.module(). */
23
+ interface SentryClient {
24
+ init: (options: Record<string, unknown>) => void;
25
+ }
26
+
27
+ /**
28
+ * Narrowed to the one method a scope fork needs to expose — setting a tag on
29
+ * the forked (not shared/global) scope passed into `withScope`'s callback.
30
+ * The real `Sentry.Scope` from `@sentry/bun` satisfies this shape via its
31
+ * `setTag` method; it has many more methods, but callers of `withScope` below
32
+ * only ever need this one.
33
+ */
34
+ export interface ScopeTagSetter {
35
+ setTag: (key: string, value: string) => unknown;
36
+ /** Optional so tag-only fakes keep type-checking; the real `Scope` has it. */
37
+ setContext?: (
38
+ name: string,
39
+ context: Record<string, unknown> | null,
40
+ ) => unknown;
41
+ }
42
+
43
+ /**
44
+ * Narrowed to the methods callers need to report unhandled errors and
45
+ * expected-but-notable events, so tests can inject a fake without
46
+ * mock.module(). Mirrors the SentryClient pattern — the real `Sentry` from
47
+ * `@sentry/bun` satisfies this shape via its `captureException`/
48
+ * `captureMessage`/`withScope` exports. `captureMessage` and `withScope` are
49
+ * optional so existing fakes that only implement `captureException` keep
50
+ * type-checking.
51
+ *
52
+ * `withScope` mirrors `@sentry/bun`'s `Sentry.withScope` signature: it forks
53
+ * the current scope for the duration of the async `callback`, so tags set on
54
+ * the forked scope (via `ScopeTagSetter.setTag`) are visible to any Sentry
55
+ * event (Issue or Log) captured while `callback` is in flight, without
56
+ * mutating any other concurrent or sequential call's scope. Real `Sentry`
57
+ * satisfies this via its AsyncLocalStorage-based async context strategy —
58
+ * scope propagation survives the awaited callback, including nested awaits.
59
+ */
60
+ export interface ErrorCapturingClient {
61
+ captureException: (err: unknown) => void;
62
+ captureMessage?: (message: string) => void;
63
+ withScope?: <T>(
64
+ callback: (scope: ScopeTagSetter) => Promise<T>,
65
+ ) => Promise<T>;
66
+ }
67
+
68
+ /** Max depth walked when scrubbing, so a pathological/deeply-nested object can't hang scrubbing. */
69
+ const MAX_SCRUB_DEPTH = 20;
70
+
71
+ /** Recomputed per call (not cached at module load) so tests can mutate env freely between assertions. */
72
+ function liveSecretValues(): string[] {
73
+ return SECRET_ENV_VARS.map((key) => process.env[key]).filter(
74
+ (value): value is string => !!value,
75
+ );
76
+ }
77
+
78
+ function redactSecrets<T>(
79
+ value: T,
80
+ secrets: string[],
81
+ seen: WeakSet<object>,
82
+ depth: number,
83
+ ): T {
84
+ if (secrets.length === 0) return value;
85
+
86
+ if (typeof value === "string") {
87
+ let result: string = value;
88
+ for (const secret of secrets) {
89
+ if (result.includes(secret)) {
90
+ result = result.split(secret).join("[Filtered]");
91
+ }
92
+ }
93
+ return result as unknown as T;
94
+ }
95
+
96
+ if (value === null || typeof value !== "object" || depth >= MAX_SCRUB_DEPTH) {
97
+ return value;
98
+ }
99
+
100
+ if (seen.has(value as object)) {
101
+ return value;
102
+ }
103
+ seen.add(value as object);
104
+
105
+ if (Array.isArray(value)) {
106
+ return value.map((item) =>
107
+ redactSecrets(item, secrets, seen, depth + 1),
108
+ ) as unknown as T;
109
+ }
110
+
111
+ const result: Record<string, unknown> = {};
112
+ for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
113
+ result[key] = redactSecrets(val, secrets, seen, depth + 1);
114
+ }
115
+ return result as unknown as T;
116
+ }
117
+
118
+ function stripSensitiveHeaders(event: ErrorEvent): ErrorEvent {
119
+ const headers = event.request?.headers;
120
+ if (!headers) return event;
121
+
122
+ const scrubbedHeaders = { ...headers };
123
+ for (const key of Object.keys(scrubbedHeaders)) {
124
+ if (
125
+ key.toLowerCase() === "authorization" ||
126
+ key.toLowerCase() === "cookie"
127
+ ) {
128
+ scrubbedHeaders[key] = "[Filtered]";
129
+ }
130
+ }
131
+
132
+ return {
133
+ ...event,
134
+ request: { ...event.request, headers: scrubbedHeaders },
135
+ };
136
+ }
137
+
138
+ export function scrubEvent(
139
+ event: ErrorEvent,
140
+ _hint: EventHint,
141
+ ): ErrorEvent | null {
142
+ const withHeadersStripped = stripSensitiveHeaders(event);
143
+ return redactSecrets(
144
+ withHeadersStripped,
145
+ liveSecretValues(),
146
+ new WeakSet(),
147
+ 0,
148
+ );
149
+ }
150
+
151
+ export function scrubLog(log: SentryLog): SentryLog {
152
+ return redactSecrets(log, liveSecretValues(), new WeakSet(), 0);
153
+ }
154
+
155
+ /**
156
+ * Builds the init options `initSentry` passes to `sentryClient.init` — shared
157
+ * with callers (like `@sentry/hono`'s `sentry()` middleware) that must
158
+ * perform their own `Sentry.init` call, so every init site gets the same
159
+ * enableLogs/environment/scrub-hook config instead of each duplicating it.
160
+ * Returns `undefined` when SENTRY_DSN is unset (nothing to init), or when
161
+ * NODE_ENV is "test" — Bun auto-sets this for `bun test`, and without this
162
+ * guard any SENTRY_DSN present in the environment would leak intentional
163
+ * error-path test assertions to production Sentry as real events.
164
+ */
165
+ export function buildSentryInitOptions(
166
+ opts: InitSentryOptions,
167
+ ): Record<string, unknown> | undefined {
168
+ if (process.env.NODE_ENV === "test") return undefined;
169
+
170
+ const dsn = process.env.SENTRY_DSN;
171
+ if (!dsn) return undefined;
172
+
173
+ const tags: Record<string, string> = { service: opts.service };
174
+ if (opts.agentId) {
175
+ tags.agent_id = opts.agentId;
176
+ }
177
+
178
+ return {
179
+ dsn,
180
+ enableLogs: true,
181
+ integrations: [
182
+ consoleLoggingIntegration({ levels: ["log", "warn", "error"] }),
183
+ ],
184
+ environment:
185
+ process.env.SENTRY_ENVIRONMENT ?? process.env.NODE_ENV ?? "production",
186
+ initialScope: { tags },
187
+ beforeSend: scrubEvent,
188
+ beforeSendLog: scrubLog,
189
+ };
190
+ }
191
+
192
+ /** No-ops (zero telemetry, no init call) when SENTRY_DSN is unset. */
193
+ export function initSentry(
194
+ opts: InitSentryOptions,
195
+ sentryClient: SentryClient = Sentry,
196
+ ): void {
197
+ const options = buildSentryInitOptions(opts);
198
+ if (!options) return;
199
+
200
+ sentryClient.init(options);
201
+ }
@@ -0,0 +1,294 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { SECRET_ENV_VARS } from "./secret-env-vars.ts";
3
+ import { initSentry, scrubEvent, scrubLog } from "./sentry.ts";
4
+
5
+ function createFakeSentryClient() {
6
+ const calls: unknown[] = [];
7
+ return {
8
+ init: (options: unknown) => {
9
+ calls.push(options);
10
+ },
11
+ calls,
12
+ };
13
+ }
14
+
15
+ // Snapshot the env vars this suite touches so we can restore them exactly,
16
+ // keeping this suite isolated from sibling suites sharing the Bun test process.
17
+ const ENV_KEYS_UNDER_TEST = [
18
+ ...SECRET_ENV_VARS,
19
+ "SENTRY_DSN",
20
+ "SENTRY_ENVIRONMENT",
21
+ "NODE_ENV",
22
+ ];
23
+ const originalEnv: Record<string, string | undefined> = {};
24
+
25
+ beforeEach(() => {
26
+ for (const key of ENV_KEYS_UNDER_TEST) {
27
+ originalEnv[key] = process.env[key];
28
+ delete process.env[key];
29
+ }
30
+ });
31
+
32
+ afterEach(() => {
33
+ for (const key of ENV_KEYS_UNDER_TEST) {
34
+ const original = originalEnv[key];
35
+ if (original === undefined) {
36
+ delete process.env[key];
37
+ } else {
38
+ process.env[key] = original;
39
+ }
40
+ }
41
+ });
42
+
43
+ describe("initSentry — SENTRY_DSN unset", () => {
44
+ test("never calls sentryClient.init", () => {
45
+ delete process.env.SENTRY_DSN;
46
+ const fakeClient = createFakeSentryClient();
47
+
48
+ initSentry({ service: "metrics" }, fakeClient);
49
+
50
+ expect(fakeClient.calls.length).toBe(0);
51
+ });
52
+ });
53
+
54
+ describe("initSentry — NODE_ENV=test", () => {
55
+ test("never calls sentryClient.init even with SENTRY_DSN set", () => {
56
+ process.env.NODE_ENV = "test";
57
+ process.env.SENTRY_DSN = "https://example@o0.ingest.sentry.io/0";
58
+ const fakeClient = createFakeSentryClient();
59
+
60
+ initSentry({ service: "metrics" }, fakeClient);
61
+
62
+ expect(fakeClient.calls.length).toBe(0);
63
+ });
64
+ });
65
+
66
+ describe("initSentry — SENTRY_DSN set", () => {
67
+ test("calls sentryClient.init with enableLogs, service tag, and scrub hooks wired", () => {
68
+ process.env.SENTRY_DSN = "https://example@o0.ingest.sentry.io/0";
69
+ const fakeClient = createFakeSentryClient();
70
+
71
+ initSentry({ service: "metrics" }, fakeClient);
72
+
73
+ expect(fakeClient.calls.length).toBe(1);
74
+ const options = fakeClient.calls[0] as {
75
+ dsn: string;
76
+ enableLogs: boolean;
77
+ initialScope: { tags: { service: string } };
78
+ beforeSend: unknown;
79
+ beforeSendLog: unknown;
80
+ environment: string;
81
+ };
82
+
83
+ expect(options.dsn).toBe("https://example@o0.ingest.sentry.io/0");
84
+ expect(options.enableLogs).toBe(true);
85
+ expect(options.initialScope.tags.service).toBe("metrics");
86
+ expect(options.beforeSend).toBe(scrubEvent);
87
+ expect(options.beforeSendLog).toBe(scrubLog);
88
+ });
89
+
90
+ test("environment falls back to SENTRY_ENVIRONMENT, then NODE_ENV, then production", () => {
91
+ process.env.SENTRY_DSN = "https://example@o0.ingest.sentry.io/0";
92
+
93
+ process.env.SENTRY_ENVIRONMENT = "staging";
94
+ process.env.NODE_ENV = "production";
95
+ let fakeClient = createFakeSentryClient();
96
+ initSentry({ service: "agent" }, fakeClient);
97
+ expect((fakeClient.calls[0] as { environment: string }).environment).toBe(
98
+ "staging",
99
+ );
100
+
101
+ delete process.env.SENTRY_ENVIRONMENT;
102
+ process.env.NODE_ENV = "development";
103
+ fakeClient = createFakeSentryClient();
104
+ initSentry({ service: "agent" }, fakeClient);
105
+ expect((fakeClient.calls[0] as { environment: string }).environment).toBe(
106
+ "development",
107
+ );
108
+
109
+ delete process.env.SENTRY_ENVIRONMENT;
110
+ delete process.env.NODE_ENV;
111
+ fakeClient = createFakeSentryClient();
112
+ initSentry({ service: "agent" }, fakeClient);
113
+ expect((fakeClient.calls[0] as { environment: string }).environment).toBe(
114
+ "production",
115
+ );
116
+ });
117
+
118
+ test("integrations include a console logging integration configured for log/warn/error", () => {
119
+ process.env.SENTRY_DSN = "https://example@o0.ingest.sentry.io/0";
120
+ const fakeClient = createFakeSentryClient();
121
+
122
+ initSentry({ service: "task-store" }, fakeClient);
123
+
124
+ const options = fakeClient.calls[0] as { integrations: unknown[] };
125
+ expect(Array.isArray(options.integrations)).toBe(true);
126
+ expect(options.integrations.length).toBeGreaterThan(0);
127
+ });
128
+
129
+ test("agent_id tag is included in initialScope.tags when agentId is passed", () => {
130
+ process.env.SENTRY_DSN = "https://example@o0.ingest.sentry.io/0";
131
+ const fakeClient = createFakeSentryClient();
132
+
133
+ initSentry({ service: "agent", agentId: "test-agent-123" }, fakeClient);
134
+
135
+ expect(fakeClient.calls.length).toBe(1);
136
+ const options = fakeClient.calls[0] as {
137
+ initialScope: { tags: Record<string, string> };
138
+ };
139
+ expect(options.initialScope.tags.agent_id).toBe("test-agent-123");
140
+ });
141
+
142
+ test("agent_id tag is absent when agentId is not passed", () => {
143
+ process.env.SENTRY_DSN = "https://example@o0.ingest.sentry.io/0";
144
+ const fakeClient = createFakeSentryClient();
145
+
146
+ initSentry({ service: "metrics" }, fakeClient);
147
+
148
+ expect(fakeClient.calls.length).toBe(1);
149
+ const options = fakeClient.calls[0] as {
150
+ initialScope: { tags: Record<string, unknown> };
151
+ };
152
+ expect(options.initialScope.tags).not.toHaveProperty("agent_id");
153
+ });
154
+ });
155
+
156
+ describe("scrubEvent — header stripping", () => {
157
+ test("strips Authorization and Cookie request headers unconditionally, even with no secrets set", () => {
158
+ const event = {
159
+ request: {
160
+ headers: {
161
+ Authorization: "Bearer abc123",
162
+ Cookie: "session=xyz",
163
+ "User-Agent": "test-agent",
164
+ },
165
+ },
166
+ };
167
+
168
+ const scrubbed = scrubEvent(event as never, {} as never);
169
+
170
+ expect(scrubbed?.request?.headers?.Authorization).toBe("[Filtered]");
171
+ expect(scrubbed?.request?.headers?.Cookie).toBe("[Filtered]");
172
+ expect(scrubbed?.request?.headers?.["User-Agent"]).toBe("test-agent");
173
+ });
174
+ });
175
+
176
+ describe("scrubEvent — secret redaction", () => {
177
+ test("redacts a nested error message matching a currently-set secret env var", () => {
178
+ process.env.ANTHROPIC_API_KEY = "sk-ant-super-secret-value";
179
+ const event = {
180
+ exception: {
181
+ values: [
182
+ {
183
+ type: "Error",
184
+ value: "sk-ant-super-secret-value",
185
+ },
186
+ ],
187
+ },
188
+ };
189
+
190
+ const scrubbed = scrubEvent(event as never, {} as never) as {
191
+ exception: { values: Array<{ value: string }> };
192
+ };
193
+
194
+ expect(scrubbed.exception.values[0].value).toBe("[Filtered]");
195
+ });
196
+
197
+ test("redacts a secret embedded within a longer string, not just an exact match", () => {
198
+ process.env.GH_TOKEN = "ghp_secretvalue";
199
+ const event = {
200
+ exception: {
201
+ values: [
202
+ {
203
+ type: "Error",
204
+ value: "Failed to authenticate: Bearer ghp_secretvalue",
205
+ },
206
+ ],
207
+ },
208
+ };
209
+
210
+ const scrubbed = scrubEvent(event as never, {} as never) as {
211
+ exception: { values: Array<{ value: string }> };
212
+ };
213
+
214
+ expect(scrubbed.exception.values[0].value).toBe(
215
+ "Failed to authenticate: Bearer [Filtered]",
216
+ );
217
+ });
218
+
219
+ test("does not redact anything when no secrets are set (no false positives)", () => {
220
+ const event = {
221
+ message: "a perfectly normal message",
222
+ extra: { note: "nothing sensitive here", count: 42 },
223
+ };
224
+
225
+ const scrubbed = scrubEvent(
226
+ JSON.parse(JSON.stringify(event)) as never,
227
+ {} as never,
228
+ );
229
+
230
+ expect(scrubbed).toEqual(event as never);
231
+ });
232
+
233
+ test("does not touch non-string values", () => {
234
+ process.env.ANTHROPIC_API_KEY = "sk-ant-super-secret-value";
235
+ const event = {
236
+ extra: { count: 42, enabled: true, nothing: null },
237
+ };
238
+
239
+ const scrubbed = scrubEvent(event as never, {} as never) as unknown as {
240
+ extra: { count: number; enabled: boolean; nothing: null };
241
+ };
242
+
243
+ expect(scrubbed.extra.count).toBe(42);
244
+ expect(scrubbed.extra.enabled).toBe(true);
245
+ expect(scrubbed.extra.nothing).toBeNull();
246
+ });
247
+ });
248
+
249
+ describe("scrubLog — secret redaction", () => {
250
+ test("redacts log attributes matching a currently-set secret env var", () => {
251
+ process.env.SHIPWRIGHT_SESSION_SECRET = "super-session-secret-value";
252
+ const log = {
253
+ level: "error" as const,
254
+ message: "session created",
255
+ attributes: {
256
+ sessionSecret: "super-session-secret-value",
257
+ userId: "user-123",
258
+ },
259
+ };
260
+
261
+ const scrubbed = scrubLog(structuredClone(log));
262
+
263
+ expect(scrubbed.attributes?.sessionSecret).toBe("[Filtered]");
264
+ expect(scrubbed.attributes?.userId).toBe("user-123");
265
+ });
266
+
267
+ test("does not redact anything when no secrets are set (no false positives)", () => {
268
+ const log = {
269
+ level: "info" as const,
270
+ message: "normal log line",
271
+ attributes: { foo: "bar", count: 1 },
272
+ };
273
+
274
+ const scrubbed = scrubLog(structuredClone(log));
275
+
276
+ expect(scrubbed).toEqual(log);
277
+ });
278
+
279
+ test("handles circular references without infinite looping", () => {
280
+ process.env.GH_TOKEN = "ghp_secretvalue";
281
+ const attributes: Record<string, unknown> = { token: "ghp_secretvalue" };
282
+ attributes.self = attributes;
283
+ const log = {
284
+ level: "warn" as const,
285
+ message: "circular test",
286
+ attributes,
287
+ };
288
+
289
+ // Should complete without throwing / hanging.
290
+ const scrubbed = scrubLog(log);
291
+
292
+ expect(scrubbed.attributes?.token).toBe("[Filtered]");
293
+ });
294
+ });