Skip to content

Commit 1bdb56a

Browse files
jonathanMLDevzhao
andauthored
Redact credentials at tool-error construction and in source_errors (#234)
* redact credentials at tool-error construction and in source_errors * addressed ai review * addressed timon's review * addressed ai review: Use an explicit undefined check --------- Co-authored-by: zhao <jornathanm910923@gmail.com>
1 parent 91c7868 commit 1bdb56a

12 files changed

Lines changed: 180 additions & 41 deletions

docs/SECURITY.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
`src/logger.ts` implements `redactApiKey` and recursive redaction for structured log data:
1212

1313
- UUID-shaped tokens (`xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`) → `***`
14+
The UUID mask is intentional: legacy (pre-`pcsk_`) Pinecone API keys are UUID-formatted, so this pattern is a credential mask, not just an incidental document-ID match. Tool-error `message`/`suggestion` therefore use the full `redactApiKey` (including UUID masking), not a document-ID-preserving variant.
1415
- Modern Pinecone keys (`pcsk_…`) → `***`
1516
- Substrings after `apiKey` / `api_key` / similar patterns → masked
1617
- `Authorization: Bearer …` tokens → masked
@@ -19,9 +20,14 @@ Logs go to **stderr**; use `PINECONE_READ_ONLY_MCP_LOG_FORMAT=json` for pipeline
1920

2021
## MCP response redaction
2122

22-
Tool responses returned to MCP clients (and LLM consumers) are sanitized in `src/core/server/tool-response.ts` via `redactSensitiveFields()` before JSON serialization. Only known sensitive keys are masked (`message`, `suggestion`, `degradation_reason`); document metadata UUIDs and other non-sensitive fields are preserved.
23+
Tool responses are sanitized at construction and at the serialization boundary:
2324

24-
This covers tool error payloads, hybrid degradation reasons, and SDK error text surfaced in DEBUG log mode — not only stderr logs.
25+
- `src/core/server/tool-error.ts`: `pineconeToolError` and `timeoutToolError` apply `redactApiKey` to `message` before the `ToolError` object is built; `timeoutToolError` redacts caller-supplied `suggestion` only (the default suggestion is a static string with no secrets).
26+
- `src/logger.ts`: `redactErrorMessage` redacts credential-shaped substrings from error values; `redactSensitiveFields()` masks known sensitive keys (`message`, `suggestion`, `degradation_reason`) and every string value nested directly under `source_errors` (keyed by source name, not a sensitive field name).
27+
- `src/core/server/source-registry.ts`: multi-source `source_errors` rejection messages are redacted via `redactErrorMessage` when the per-source error map is built.
28+
- `src/core/server/tool-response.ts`: `jsonResponse` / `jsonErrorResponse` call `redactSensitiveFields()` before JSON serialization (boundary layer).
29+
30+
Document metadata UUIDs and other non-sensitive fields are preserved throughout MCP response construction, aggregation, and serialization.
2531

2632
## Private config content (descriptions and schemas)
2733

src/core/pinecone/rerank.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
*/
44

55
import type { Pinecone } from '@pinecone-database/pinecone';
6-
import { error as logError, redactApiKey } from '../../logger.js';
6+
import { error as logError, redactErrorMessage } from '../../logger.js';
77
import { DEFAULT_REQUEST_TIMEOUT_MS } from '../config.js';
88
import { runWithPolicy } from '../server/retry.js';
99
import type { MergedHit, SearchResult } from '../../types.js';
@@ -66,7 +66,7 @@ export async function rerankResults(
6666
return { results: reranked, degraded: false };
6767
} catch (error) {
6868
logError('Error reranking results', error);
69-
const msg = redactApiKey(error instanceof Error ? error.message : String(error));
69+
const msg = redactErrorMessage(error);
7070
return {
7171
results: results.slice(0, topN).map((result) => ({
7272
id: result._id || '',

src/core/server/redaction.test.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import {
1515
makeNamespaceCacheEntry,
1616
makeSearchResult,
1717
parseToolJson,
18+
PCSK_KEY,
19+
UUID_KEY,
1820
} from './tools/test-helpers.js';
1921

2022
vi.mock('./client-context.js', () => ({
@@ -33,9 +35,6 @@ vi.mock('./suggestion-flow.js', async (importOriginal) => {
3335
};
3436
});
3537

36-
const PCSK_KEY = 'pcsk_abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
37-
const UUID_KEY = '12345678-1234-1234-1234-123456789abc';
38-
3938
const flowOk = {
4039
ok: true as const,
4140
flow: {
@@ -78,6 +77,19 @@ describe('redactSensitiveFields', () => {
7877
expect(out.message).not.toContain(PCSK_KEY);
7978
expect(out.results[0].metadata.document_id).toBe(UUID_KEY);
8079
});
80+
81+
it('redacts string values nested under source_errors regardless of key name', () => {
82+
const input = {
83+
source_errors: {
84+
api_key_2: `unreachable: ${PCSK_KEY}`,
85+
api_key_3: 'benign message with no token',
86+
},
87+
};
88+
const out = redactSensitiveFields(input) as typeof input;
89+
expect(out.source_errors.api_key_2).not.toContain(PCSK_KEY);
90+
expect(out.source_errors.api_key_2).toContain('***');
91+
expect(out.source_errors.api_key_3).toBe('benign message with no token');
92+
});
8193
});
8294

8395
describe('MCP response redaction', () => {
@@ -99,6 +111,8 @@ describe('MCP response redaction', () => {
99111
const err = pineconeToolError(`PINECONE_ERROR with ${PCSK_KEY}`, {
100112
suggestion: `retry with ${PCSK_KEY}`,
101113
});
114+
expect(err.message).not.toContain(PCSK_KEY);
115+
expect(err.suggestion).not.toContain(PCSK_KEY);
102116
const text = jsonErrorResponse(err).content[0]!.text;
103117
expect(text).not.toContain(PCSK_KEY);
104118
expect(text).toContain('***');
@@ -107,6 +121,8 @@ describe('MCP response redaction', () => {
107121
it('redacts classifyToolCatchError output in DEBUG mode', () => {
108122
setLogLevel('DEBUG');
109123
const err = classifyToolCatchError(new Error(`SDK auth failed ${PCSK_KEY}`), 'fallback');
124+
expect(err.message).not.toContain(PCSK_KEY);
125+
expect(err.message).toContain('***');
110126
const text = jsonErrorResponse(err).content[0]!.text;
111127
expect(text).not.toContain(PCSK_KEY);
112128
});

src/core/server/retry.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
* waiter still rejects immediately on timeout.
88
*/
99

10-
import { warn, redactApiKey } from '../../logger.js';
10+
import { warn, redactErrorMessage } from '../../logger.js';
1111

1212
/** Matches {@link withTimeout} rejection message prefix; used by tool-error and callers. */
1313
export const APP_TIMEOUT_PATTERN = /^Timeout after \d+ms while waiting for /i;
@@ -140,7 +140,7 @@ export function runWithPolicy<T>(
140140
backoffMs: options.backoffMs,
141141
shouldRetry: transientShouldRetry,
142142
onRetry: (attempt, error) => {
143-
const msg = redactApiKey(error instanceof Error ? error.message : String(error));
143+
const msg = redactErrorMessage(error);
144144
warn(`Retrying ${options.label} (attempt ${attempt})`, msg);
145145
},
146146
});

src/core/server/source-registry.test.ts

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, expect, it, vi } from 'vitest';
22
import { buildSourceRegistry } from './source-registry.js';
33
import type { SourceDefinition } from './source-config.js';
4+
import { PCSK_KEY, makeFailingSourceClient } from './tools/test-helpers.js';
45

56
const sources: SourceDefinition[] = [
67
{ name: 'api_key_1', apiKey: 'k1', indexName: 'idx-a', sparseIndexName: 'idx-a-sparse' },
@@ -67,14 +68,14 @@ describe('SourceRegistry', () => {
6768

6869
it('returns partial namespaces and source_errors when one source fails', async () => {
6970
const client1 = mockClient('api_key_1');
70-
const client2 = {
71-
listNamespacesWithMetadata: vi.fn().mockRejectedValue(new Error('api_key_2 unreachable')),
72-
checkIndexes: vi.fn().mockResolvedValue({ ok: true, errors: [] }),
73-
getSparseIndexName: () => 'api_key_2-sparse',
74-
};
7571
const clients = new Map([
7672
['api_key_1', client1 as never],
77-
['api_key_2', client2 as never],
73+
[
74+
'api_key_2',
75+
makeFailingSourceClient('api_key_2 unreachable', {
76+
sparseIndexName: 'api_key_2-sparse',
77+
}) as never,
78+
],
7879
]);
7980
const registry = buildSourceRegistry({
8081
sources,
@@ -91,6 +92,30 @@ describe('SourceRegistry', () => {
9192
expect(result.cache_hit).toBe(false);
9293
});
9394

95+
it('redacts credential-shaped tokens in source_errors at construction time', async () => {
96+
const client1 = mockClient('api_key_1');
97+
const clients = new Map([
98+
['api_key_1', client1 as never],
99+
[
100+
'api_key_2',
101+
makeFailingSourceClient(`api_key_2 unreachable: auth failed ${PCSK_KEY}`, {
102+
sparseIndexName: 'api_key_2-sparse',
103+
}) as never,
104+
],
105+
]);
106+
const registry = buildSourceRegistry({
107+
sources,
108+
defaultSource: 'api_key_1',
109+
cacheTtlMs: 60_000,
110+
defaultTopK: 10,
111+
requestTimeoutMs: 15_000,
112+
clients,
113+
});
114+
const result = await registry.getAllNamespacesWithCache();
115+
expect(result.source_errors?.['api_key_2']).not.toContain(PCSK_KEY);
116+
expect(result.source_errors?.['api_key_2']).toContain('***');
117+
});
118+
94119
it('aggregates warnings across sources in getAllNamespacesWithCache', async () => {
95120
const client1 = {
96121
listNamespacesWithMetadata: vi.fn().mockResolvedValue({

src/core/server/source-registry.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
*/
44

55
import { PineconeClient } from '../pinecone-client.js';
6+
import { redactErrorMessage } from '../../logger.js';
67
import type { NamespaceInfo } from './server-context.js';
78
import { fetchNamespacesWithDeclaredConfig, type NamespacesCacheEntry } from './namespace-cache.js';
89
import type { SourceDefinition } from './source-config.js';
@@ -151,9 +152,7 @@ export class SourceRegistry {
151152
maxExpires = Math.max(maxExpires, outcome.value.result.expires_at);
152153
} else {
153154
cache_hit = false;
154-
const message =
155-
outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason);
156-
source_errors[name] = message;
155+
source_errors[name] = redactErrorMessage(outcome.reason);
157156
}
158157
}
159158
const expires_at = maxExpires > 0 ? maxExpires : Date.now() + this.cacheTtlMs;

src/core/server/tool-error.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { describe, expect, it } from 'vitest';
22
import {
33
classifyToolCatchError,
4+
DEFAULT_TIMEOUT_SUGGESTION,
45
flowGateToolError,
56
lifecycleToolError,
7+
timeoutToolError,
68
toolErrorSchema,
79
validationToolError,
810
} from './tool-error.js';
@@ -56,4 +58,16 @@ describe('ToolError schema and builders', () => {
5658
expect(err.suggestion).toMatch(/retry|timeout/i);
5759
toolErrorSchema.parse(err);
5860
});
61+
62+
it('TIMEOUT: default suggestion is unredacted literal', () => {
63+
const err = timeoutToolError('Timeout after 100ms');
64+
expect(err.suggestion).toBe(DEFAULT_TIMEOUT_SUGGESTION);
65+
toolErrorSchema.parse(err);
66+
});
67+
68+
it('TIMEOUT: explicit empty suggestion is preserved, not replaced by default', () => {
69+
const err = timeoutToolError('Timeout after 100ms', { suggestion: '' });
70+
expect(err.suggestion).toBe('');
71+
toolErrorSchema.parse(err);
72+
});
5973
});

src/core/server/tool-error.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
*/
55

66
import { z } from 'zod';
7-
import { getLogLevel, error as logError, info } from '../../logger.js';
7+
import { getLogLevel, redactApiKey, error as logError, info } from '../../logger.js';
88
import { isAppTimeoutError } from './retry.js';
99

1010
/** User-facing error message: detailed in DEBUG, generic otherwise. */
@@ -60,7 +60,7 @@ const timeoutToolErrorSchema = z.object({
6060
code: z.literal('TIMEOUT'),
6161
message: z.string(),
6262
recoverable: z.literal(true),
63-
suggestion: z.string().optional(),
63+
suggestion: z.string(),
6464
});
6565

6666
const lifecycleToolErrorSchema = z.object({
@@ -80,7 +80,8 @@ export const toolErrorSchema = z.discriminatedUnion('code', [
8080

8181
export type ToolError = z.infer<typeof toolErrorSchema>;
8282

83-
const DEFAULT_TIMEOUT_SUGGESTION = 'Retry the request, or increase --request-timeout-ms.';
83+
/** Default TIMEOUT suggestion when the caller does not supply one. */
84+
export const DEFAULT_TIMEOUT_SUGGESTION = 'Retry the request, or increase --request-timeout-ms.';
8485

8586
export function flowGateToolError(namespace: string, message: string): ToolError {
8687
return {
@@ -111,18 +112,21 @@ export function pineconeToolError(
111112
): ToolError {
112113
return {
113114
code: 'PINECONE_ERROR',
114-
message,
115+
message: redactApiKey(message),
115116
recoverable: options?.recoverable ?? false,
116-
...(options?.suggestion ? { suggestion: options.suggestion } : {}),
117+
...(options?.suggestion ? { suggestion: redactApiKey(options.suggestion) } : {}),
117118
};
118119
}
119120

120121
export function timeoutToolError(message: string, options?: { suggestion?: string }): ToolError {
121122
return {
122123
code: 'TIMEOUT',
123-
message,
124+
message: redactApiKey(message),
124125
recoverable: true,
125-
suggestion: options?.suggestion ?? DEFAULT_TIMEOUT_SUGGESTION,
126+
suggestion:
127+
options?.suggestion !== undefined
128+
? redactApiKey(options.suggestion)
129+
: DEFAULT_TIMEOUT_SUGGESTION,
126130
};
127131
}
128132

src/core/server/tools/list-namespaces-tool.context.test.ts

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
import { describe, expect, it, vi } from 'vitest';
2+
import { setLogLevel } from '../../../logger.js';
23
import { registerListNamespacesTool } from './list-namespaces-tool.js';
34
import { listNamespacesResponseSchema } from '../response-schemas.js';
45
import {
56
createMockServer,
67
createMultiSourceTestContext,
78
createTestServerContext,
89
expectMatchesResponseSchema,
9-
makeMockPineconeClient,
10+
makePartialFailureMultiSourceClients,
1011
mockNamespacesWithMetadataResult,
1112
parseToolJson,
13+
PCSK_KEY,
1214
} from './test-helpers.js';
1315

1416
describe('list_namespaces tool handler (ServerContext instance path)', () => {
@@ -73,19 +75,7 @@ describe('list_namespaces tool handler (ServerContext instance path)', () => {
7375

7476
describe('list_namespaces tool handler (multi-source)', () => {
7577
it('tags namespaces with source and propagates source_errors on partial failure', async () => {
76-
const client1 = makeMockPineconeClient(['wg21']);
77-
const client2 = {
78-
listNamespacesWithMetadata: vi.fn().mockRejectedValue(new Error('api_key_2 unreachable')),
79-
query: vi.fn(),
80-
count: vi.fn(),
81-
keywordSearch: vi.fn(),
82-
checkIndexes: vi.fn().mockResolvedValue({ ok: true, errors: [] }),
83-
getSparseIndexName: () => 'sparse',
84-
};
85-
const clients = new Map([
86-
['api_key_1', client1],
87-
['api_key_2', client2],
88-
]);
78+
const clients = makePartialFailureMultiSourceClients('api_key_2 unreachable');
8979
const { ctx } = createMultiSourceTestContext({ clients });
9080

9181
const server = createMockServer();
@@ -99,6 +89,31 @@ describe('list_namespaces tool handler (multi-source)', () => {
9989
expect(body['cache_hit']).toBe(false);
10090
});
10191

92+
it('redacts a credential-shaped token embedded in a source_errors rejection message at DEBUG log level', async () => {
93+
setLogLevel('DEBUG');
94+
try {
95+
const clients = makePartialFailureMultiSourceClients(
96+
`api_key_2 unreachable: auth failed ${PCSK_KEY}`
97+
);
98+
const { ctx } = createMultiSourceTestContext({ clients });
99+
100+
const server = createMockServer();
101+
registerListNamespacesTool(server as never, ctx);
102+
const raw = await server.getHandler('list_namespaces')!({});
103+
const text = raw.content[0]!.text;
104+
expect(text).not.toContain(PCSK_KEY);
105+
expect(text).toContain('***');
106+
107+
const body = parseToolJson(raw);
108+
expectMatchesResponseSchema(listNamespacesResponseSchema, body);
109+
expect(body['source_errors']).toMatchObject({
110+
api_key_2: expect.not.stringContaining(PCSK_KEY),
111+
});
112+
} finally {
113+
setLogLevel('INFO');
114+
}
115+
});
116+
102117
it('surfaces schema_source and config_warnings when declarations mismatch live data', async () => {
103118
const client1 = {
104119
listNamespacesWithMetadata: vi.fn().mockResolvedValue({

src/core/server/tools/test-helpers.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ import {
1515
import type { ToolError, ToolErrorCode } from '../tool-error.js';
1616
import { toolErrorSchema } from '../tool-error.js';
1717

18+
/** Credential-shaped test token for redaction assertions. Not a real key. */
19+
export const PCSK_KEY = 'pcsk_abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
20+
21+
/** Non-sensitive UUID for regression tests (must not be masked). */
22+
export const UUID_KEY = '12345678-1234-1234-1234-123456789abc';
23+
1824
/** Handler invoked by MCP tool registration (params shape varies by tool). */
1925
export type ToolHandler = (params: Record<string, unknown>) => Promise<unknown>;
2026

@@ -216,6 +222,35 @@ export function makeMockPineconeClient(
216222
};
217223
}
218224

225+
/** Mock client that rejects namespace listing with the given error message. */
226+
export function makeFailingSourceClient(
227+
rejectionMessage: string,
228+
options?: { sparseIndexName?: string }
229+
) {
230+
return {
231+
listNamespacesWithMetadata: vi.fn().mockRejectedValue(new Error(rejectionMessage)),
232+
query: vi.fn(),
233+
count: vi.fn(),
234+
keywordSearch: vi.fn(),
235+
checkIndexes: vi.fn().mockResolvedValue({ ok: true, errors: [] }),
236+
getSparseIndexName: () => options?.sparseIndexName ?? 'sparse',
237+
};
238+
}
239+
240+
/** Two-source client map: api_key_1 succeeds; api_key_2 fails with rejectionMessage. */
241+
export function makePartialFailureMultiSourceClients(
242+
rejectionMessage: string,
243+
options?: { successNamespaces?: string[] }
244+
) {
245+
return new Map([
246+
['api_key_1', makeMockPineconeClient(options?.successNamespaces ?? ['wg21'])],
247+
[
248+
'api_key_2',
249+
makeFailingSourceClient(rejectionMessage, { sparseIndexName: 'api_key_2-sparse' }),
250+
],
251+
]);
252+
}
253+
219254
export type MultiSourceTestContext = {
220255
ctx: CoreServerContext;
221256
clients: Map<string, ReturnType<typeof makeMockPineconeClient>>;

0 commit comments

Comments
 (0)