From 391207bbae6d86aef30a035c60b8163d47f9b071 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 8 Apr 2026 13:26:49 +0200 Subject: [PATCH 1/5] fix(ai-code-mode): warn when tool parameters look like secrets Code Mode executes LLM-generated code in a sandbox. If a tool's input schema includes parameters like apiKey, token, or password, the LLM-generated code can access those values and potentially exfiltrate them via tool calls. Add warnIfBindingsExposeSecrets() that scans tool input schemas for secret-like parameter names and emits console.warn during development. --- .../ai-code-mode/src/create-code-mode-tool.ts | 3 + .../ai-code-mode/src/validate-bindings.ts | 36 ++++++ .../tests/validate-bindings.test.ts | 112 ++++++++++++++++++ 3 files changed, 151 insertions(+) create mode 100644 packages/typescript/ai-code-mode/src/validate-bindings.ts create mode 100644 packages/typescript/ai-code-mode/tests/validate-bindings.test.ts diff --git a/packages/typescript/ai-code-mode/src/create-code-mode-tool.ts b/packages/typescript/ai-code-mode/src/create-code-mode-tool.ts index 52180d6a0..f20959f7a 100644 --- a/packages/typescript/ai-code-mode/src/create-code-mode-tool.ts +++ b/packages/typescript/ai-code-mode/src/create-code-mode-tool.ts @@ -5,6 +5,7 @@ import { toolsToBindings, } from './bindings/tool-to-binding' import { stripTypeScript } from './strip-typescript' +import { warnIfBindingsExposeSecrets } from './validate-bindings' import type { ServerTool, ToolExecutionContext } from '@tanstack/ai' import type { CodeModeTool, @@ -103,6 +104,8 @@ export function createCodeModeTool( // Transform tools to bindings with external_ prefix (static bindings) const staticBindings = toolsToBindings(tools, 'external_') + warnIfBindingsExposeSecrets(Object.values(staticBindings)) + // Create the tool definition const definition = toolDefinition({ name: 'execute_typescript' as const, diff --git a/packages/typescript/ai-code-mode/src/validate-bindings.ts b/packages/typescript/ai-code-mode/src/validate-bindings.ts new file mode 100644 index 000000000..fb05d507e --- /dev/null +++ b/packages/typescript/ai-code-mode/src/validate-bindings.ts @@ -0,0 +1,36 @@ +/** + * Patterns that indicate a parameter might carry a secret value. + * Case-insensitive matching against JSON Schema property names. + */ +const SECRET_PATTERNS = + /^(api[_-]?key|secret|token|password|credential|auth[_-]?token|access[_-]?key|private[_-]?key)$/i + +interface ToolLike { + name: string + inputSchema?: { type?: string; properties?: Record } +} + +/** + * Scan tool input schemas for parameter names that look like secrets. + * Emits console.warn for each match so developers notice during development. + * + * This is a best-effort heuristic, not a security boundary. + */ +export function warnIfBindingsExposeSecrets(tools: Array): void { + for (const tool of tools) { + const properties = tool.inputSchema?.properties + if (!properties) continue + + for (const paramName of Object.keys(properties)) { + if (SECRET_PATTERNS.test(paramName)) { + console.warn( + `[TanStack AI Code Mode] Tool "${tool.name}" has parameter "${paramName}" ` + + `that looks like a secret. Code Mode executes LLM-generated code — any ` + + `value passed through this parameter is accessible to generated code and ` + + `could be exfiltrated. Keep secrets in your server-side tool implementation ` + + `instead of passing them as tool parameters.`, + ) + } + } + } +} diff --git a/packages/typescript/ai-code-mode/tests/validate-bindings.test.ts b/packages/typescript/ai-code-mode/tests/validate-bindings.test.ts new file mode 100644 index 000000000..b139b81a8 --- /dev/null +++ b/packages/typescript/ai-code-mode/tests/validate-bindings.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it, vi } from 'vitest' +import { warnIfBindingsExposeSecrets } from '../src/validate-bindings' + +describe('warnIfBindingsExposeSecrets', () => { + it('should warn when input schema has secret-like parameter names', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + warnIfBindingsExposeSecrets([ + { + name: 'callApi', + description: 'Call an API', + inputSchema: { + type: 'object', + properties: { + url: { type: 'string' }, + apiKey: { type: 'string' }, + }, + }, + }, + ]) + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('apiKey'), + ) + warnSpy.mockRestore() + }) + + it('should not warn for safe parameter names', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + warnIfBindingsExposeSecrets([ + { + name: 'search', + description: 'Search items', + inputSchema: { + type: 'object', + properties: { + query: { type: 'string' }, + limit: { type: 'number' }, + }, + }, + }, + ]) + + expect(warnSpy).not.toHaveBeenCalled() + warnSpy.mockRestore() + }) + + it('should detect multiple secret patterns', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + warnIfBindingsExposeSecrets([ + { + name: 'auth', + description: 'Auth tool', + inputSchema: { + type: 'object', + properties: { + token: { type: 'string' }, + password: { type: 'string' }, + username: { type: 'string' }, + }, + }, + }, + ]) + + const calls = warnSpy.mock.calls.map((c) => c[0]) + expect(calls.some((c: string) => c.includes('token'))).toBe(true) + expect(calls.some((c: string) => c.includes('password'))).toBe(true) + // username should NOT trigger a warning + expect(calls.some((c: string) => c.includes('username'))).toBe(false) + warnSpy.mockRestore() + }) + + it('should handle tools with no inputSchema', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + warnIfBindingsExposeSecrets([ + { + name: 'simple', + description: 'Simple tool', + }, + ]) + + expect(warnSpy).not.toHaveBeenCalled() + warnSpy.mockRestore() + }) + + it('should detect api_key and api-key variations', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + warnIfBindingsExposeSecrets([ + { + name: 'tool1', + inputSchema: { + type: 'object', + properties: { api_key: { type: 'string' } }, + }, + }, + { + name: 'tool2', + inputSchema: { + type: 'object', + properties: { 'api-key': { type: 'string' } }, + }, + }, + ]) + + expect(warnSpy).toHaveBeenCalledTimes(2) + warnSpy.mockRestore() + }) +}) From 8bd10a8c40a3be20a50d1d41a18d97bc7a42a2d4 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 11:38:43 +0000 Subject: [PATCH 2/5] ci: apply automated fixes --- .../typescript/ai-code-mode/tests/validate-bindings.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/typescript/ai-code-mode/tests/validate-bindings.test.ts b/packages/typescript/ai-code-mode/tests/validate-bindings.test.ts index b139b81a8..aec4ba6e5 100644 --- a/packages/typescript/ai-code-mode/tests/validate-bindings.test.ts +++ b/packages/typescript/ai-code-mode/tests/validate-bindings.test.ts @@ -19,9 +19,7 @@ describe('warnIfBindingsExposeSecrets', () => { }, ]) - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('apiKey'), - ) + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('apiKey')) warnSpy.mockRestore() }) From 7a12c6d528f914fe5bbff0259e44242512e0da30 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 8 Apr 2026 13:39:35 +0200 Subject: [PATCH 3/5] changeset: code-mode secret warning --- .changeset/fix-code-mode-secret-warning.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/fix-code-mode-secret-warning.md diff --git a/.changeset/fix-code-mode-secret-warning.md b/.changeset/fix-code-mode-secret-warning.md new file mode 100644 index 000000000..08dbdd397 --- /dev/null +++ b/.changeset/fix-code-mode-secret-warning.md @@ -0,0 +1,7 @@ +--- +'@tanstack/ai-code-mode': patch +--- + +fix(ai-code-mode): warn when tool parameters look like secrets + +Add `warnIfBindingsExposeSecrets()` that scans tool input schemas for secret-like parameter names (`apiKey`, `token`, `password`, etc.) and emits `console.warn` during development. Code Mode executes LLM-generated code — any secrets passed through tool parameters are accessible to generated code. From 6287faa316ba332c735a615028a3312b57ac6554 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Fri, 24 Apr 2026 11:45:49 +0200 Subject: [PATCH 4/5] fix(ai-code-mode): harden secret-parameter detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address CR findings on the secret-parameter warning heuristic: - Recurse into nested object properties, array items, anyOf/oneOf/allOf branches, additionalProperties, and `$ref` targets — previously only top-level properties were scanned, so `{ auth: { token: string } }` slipped through. - Replace the narrow anchored regex with a two-stage matcher (camelCase/snake/kebab word tokenization + compound-substring check) so common names now hit: `accessToken`, `bearerToken`, `refreshToken`, `sessionToken`, `clientSecret`, `x-api-key`, `openaiApiKey`, `passcode`, `pwd`, `jwt`, `Authorization`. Safe names stay safe: `tokenizer`, `tokens`, `foreignKey`, `sortKey`, `email`, `username`. - Add `onSecretParameter` config option with `'warn' | 'throw' | 'ignore' | fn` variants so consumers can route matches (throw in CI, ignore in trusted environments, log to an observability pipeline). - Dedupe per `(toolName, paramPath)` across a single code-mode instance to stop the same binding warning on every execute call. - Scan dynamic `getSkillBindings()` output too, with the same dedup cache; skill bindings are in the same exfiltration threat model. Tests: 56 cases covering every pattern/safe-name pair, nested + array + union + $ref + additionalProperties + cycle safety, and each handler variant + dedup behavior. --- .../ai-code-mode/src/create-code-mode-tool.ts | 21 +- packages/typescript/ai-code-mode/src/index.ts | 6 + packages/typescript/ai-code-mode/src/types.ts | 15 + .../ai-code-mode/src/validate-bindings.ts | 201 +++++++++- .../tests/validate-bindings.test.ts | 370 +++++++++++++++++- 5 files changed, 572 insertions(+), 41 deletions(-) diff --git a/packages/typescript/ai-code-mode/src/create-code-mode-tool.ts b/packages/typescript/ai-code-mode/src/create-code-mode-tool.ts index f20959f7a..fac2bf66c 100644 --- a/packages/typescript/ai-code-mode/src/create-code-mode-tool.ts +++ b/packages/typescript/ai-code-mode/src/create-code-mode-tool.ts @@ -94,6 +94,7 @@ export function createCodeModeTool( timeout = 30000, memoryLimit = 128, getSkillBindings, + onSecretParameter, } = config // Validate tools @@ -104,7 +105,14 @@ export function createCodeModeTool( // Transform tools to bindings with external_ prefix (static bindings) const staticBindings = toolsToBindings(tools, 'external_') - warnIfBindingsExposeSecrets(Object.values(staticBindings)) + // Shared across static + dynamic (skill) binding scans so a given + // (toolName, paramPath) pair surfaces at most once per code-mode instance. + const secretDedupCache = new Set() + + warnIfBindingsExposeSecrets(Object.values(staticBindings), { + handler: onSecretParameter, + dedupCache: secretDedupCache, + }) // Create the tool definition const definition = toolDefinition({ @@ -163,6 +171,17 @@ export function createCodeModeTool( // Step 2: Get dynamic skill bindings if available const skillBindings = getSkillBindings ? await getSkillBindings() : {} + // Scan dynamic bindings too — their schemas are equally in-scope for + // the same exfiltration threat. Dedup cache prevents repeat warnings + // when the same binding reappears across executions. + const skillBindingValues = Object.values(skillBindings) + if (skillBindingValues.length > 0) { + warnIfBindingsExposeSecrets(skillBindingValues, { + handler: onSecretParameter, + dedupCache: secretDedupCache, + }) + } + // Step 3: Merge static and dynamic bindings, then wrap with event awareness const allBindings = { ...staticBindings, ...skillBindings } const eventAwareBindings = createEventAwareBindings( diff --git a/packages/typescript/ai-code-mode/src/index.ts b/packages/typescript/ai-code-mode/src/index.ts index c0794a89f..0af4d197a 100644 --- a/packages/typescript/ai-code-mode/src/index.ts +++ b/packages/typescript/ai-code-mode/src/index.ts @@ -53,3 +53,9 @@ export type { // Re-exported from @tanstack/ai ToolExecutionContext, } from './types' + +// Secret-parameter warning configuration +export type { + SecretParameterHandler, + SecretParameterInfo, +} from './validate-bindings' diff --git a/packages/typescript/ai-code-mode/src/types.ts b/packages/typescript/ai-code-mode/src/types.ts index 194d1ec42..1e96a1ced 100644 --- a/packages/typescript/ai-code-mode/src/types.ts +++ b/packages/typescript/ai-code-mode/src/types.ts @@ -3,6 +3,7 @@ import type { ToolDefinition, ToolExecutionContext, } from '@tanstack/ai' +import type { SecretParameterHandler } from './validate-bindings' // ============================================================================ // Isolate Driver Interfaces @@ -193,6 +194,20 @@ export interface CodeModeToolConfig { * ``` */ getSkillBindings?: () => Promise> + + /** + * How to surface tool parameters whose names look like secrets. + * Defaults to `'warn'` (logs via `console.warn`). + * + * - `'warn'`: log a warning for each match. + * - `'throw'`: throw an Error on the first match — useful in tests/CI. + * - `'ignore'`: suppress the check entirely. + * - `(info) => void`: receive each match and decide how to react. + * + * Matches are deduplicated per `(toolName, paramPath)` across the lifetime + * of a single `createCodeModeTool` instance. + */ + onSecretParameter?: SecretParameterHandler } /** diff --git a/packages/typescript/ai-code-mode/src/validate-bindings.ts b/packages/typescript/ai-code-mode/src/validate-bindings.ts index fb05d507e..067b2f16f 100644 --- a/packages/typescript/ai-code-mode/src/validate-bindings.ts +++ b/packages/typescript/ai-code-mode/src/validate-bindings.ts @@ -1,35 +1,196 @@ /** - * Patterns that indicate a parameter might carry a secret value. - * Case-insensitive matching against JSON Schema property names. + * Single words that on their own signal "this is a credential". + * Matched after splitting a parameter name into camelCase/snake/kebab words. + * So `accessToken` → `['access', 'token']` → matches `token`, + * but `tokenizer` → `['tokenizer']` → does NOT match `token`. */ -const SECRET_PATTERNS = - /^(api[_-]?key|secret|token|password|credential|auth[_-]?token|access[_-]?key|private[_-]?key)$/i +const DANGEROUS_WORDS = new Set([ + 'password', + 'passwd', + 'pwd', + 'passcode', + 'secret', + 'token', + 'credential', + 'credentials', + 'authorization', + 'jwt', + 'bearer', +]) + +/** + * Compound patterns matched as substrings of the normalized (lowercased, + * separator-stripped) parameter name. Catches forms like `openai_api_key`, + * `x-api-key`, and `webhookSecret`. + */ +const COMPOUND_PATTERNS = [ + 'apikey', + 'accesskey', + 'authkey', + 'privatekey', + 'clientsecret', + 'webhooksecret', +] as const + +export interface SecretParameterInfo { + toolName: string + paramName: string + paramPath: Array +} + +export type SecretParameterHandler = + | 'warn' + | 'throw' + | 'ignore' + | ((info: SecretParameterInfo) => void) interface ToolLike { name: string - inputSchema?: { type?: string; properties?: Record } + inputSchema?: Record +} + +interface JsonSchemaLike { + type?: string + properties?: Record + items?: JsonSchemaLike | Array + anyOf?: Array + oneOf?: Array + allOf?: Array + additionalProperties?: boolean | JsonSchemaLike + $ref?: string + $defs?: Record + definitions?: Record +} + +function splitIntoWords(name: string): Array { + return name + .replace(/[_\-\s]+/g, ' ') + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .toLowerCase() + .split(/\s+/) + .filter(Boolean) +} + +function looksLikeSecret(name: string): boolean { + const words = splitIntoWords(name) + if (words.some((w) => DANGEROUS_WORDS.has(w))) return true + const normalized = name.replace(/[_\-\s]/g, '').toLowerCase() + return COMPOUND_PATTERNS.some((p) => normalized.includes(p)) +} + +function resolveRef( + ref: string, + root: JsonSchemaLike, +): JsonSchemaLike | undefined { + const match = ref.match(/^#\/(\$defs|definitions)\/(.+)$/) + if (!match) return undefined + const bucket = match[1] as '$defs' | 'definitions' + return root[bucket]?.[match[2]!] +} + +function findSecretParams( + schema: JsonSchemaLike | undefined, + root: JsonSchemaLike, + seen: Set, + path: Array, + found: Array<{ path: Array; name: string }>, +): void { + if (!schema || typeof schema !== 'object' || seen.has(schema)) return + seen.add(schema) + + if (schema.properties && typeof schema.properties === 'object') { + for (const [paramName, sub] of Object.entries(schema.properties)) { + if (looksLikeSecret(paramName)) { + found.push({ path: [...path, paramName], name: paramName }) + } + findSecretParams(sub, root, seen, [...path, paramName], found) + } + } + + if (Array.isArray(schema.items)) { + schema.items.forEach((s, i) => + findSecretParams(s, root, seen, [...path, `[${i}]`], found), + ) + } else if (schema.items && typeof schema.items === 'object') { + findSecretParams(schema.items, root, seen, [...path, '[]'], found) + } + + if ( + schema.additionalProperties && + typeof schema.additionalProperties === 'object' + ) { + findSecretParams(schema.additionalProperties, root, seen, path, found) + } + + for (const key of ['anyOf', 'oneOf', 'allOf'] as const) { + const arr = schema[key] + if (Array.isArray(arr)) { + arr.forEach((s) => findSecretParams(s, root, seen, path, found)) + } + } + + if (typeof schema.$ref === 'string') { + const target = resolveRef(schema.$ref, root) + if (target) findSecretParams(target, root, seen, path, found) + } +} + +function buildMessage(toolName: string, paramPath: Array): string { + return ( + `[TanStack AI Code Mode] Tool "${toolName}" has parameter "${paramPath.join('.')}" ` + + `that looks like a secret. Code Mode executes LLM-generated code — any ` + + `value passed through this parameter is accessible to generated code and ` + + `could be exfiltrated. Keep secrets in your server-side tool implementation ` + + `instead of passing them as tool parameters.` + ) } /** * Scan tool input schemas for parameter names that look like secrets. - * Emits console.warn for each match so developers notice during development. + * Emits a warning (or invokes the configured handler) for each match. + * + * Recurses into nested object properties, array items, union branches + * (anyOf/oneOf/allOf), additionalProperties, and `$ref` targets that + * resolve within the same schema's `$defs`/`definitions`. * - * This is a best-effort heuristic, not a security boundary. + * Best-effort heuristic, not a security boundary. */ -export function warnIfBindingsExposeSecrets(tools: Array): void { +export function warnIfBindingsExposeSecrets( + tools: Array, + options: { + handler?: SecretParameterHandler + dedupCache?: Set + } = {}, +): void { + const { handler = 'warn', dedupCache } = options + if (handler === 'ignore') return + for (const tool of tools) { - const properties = tool.inputSchema?.properties - if (!properties) continue - - for (const paramName of Object.keys(properties)) { - if (SECRET_PATTERNS.test(paramName)) { - console.warn( - `[TanStack AI Code Mode] Tool "${tool.name}" has parameter "${paramName}" ` + - `that looks like a secret. Code Mode executes LLM-generated code — any ` + - `value passed through this parameter is accessible to generated code and ` + - `could be exfiltrated. Keep secrets in your server-side tool implementation ` + - `instead of passing them as tool parameters.`, - ) + const schema = tool.inputSchema as JsonSchemaLike | undefined + if (!schema) continue + + const found: Array<{ path: Array; name: string }> = [] + findSecretParams(schema, schema, new Set(), [], found) + + for (const entry of found) { + const dedupKey = `${tool.name}::${entry.path.join('.')}` + if (dedupCache) { + if (dedupCache.has(dedupKey)) continue + dedupCache.add(dedupKey) + } + + const info: SecretParameterInfo = { + toolName: tool.name, + paramName: entry.name, + paramPath: entry.path, + } + + if (typeof handler === 'function') { + handler(info) + } else if (handler === 'throw') { + throw new Error(buildMessage(tool.name, entry.path)) + } else { + console.warn(buildMessage(tool.name, entry.path)) } } } diff --git a/packages/typescript/ai-code-mode/tests/validate-bindings.test.ts b/packages/typescript/ai-code-mode/tests/validate-bindings.test.ts index aec4ba6e5..d07790aa1 100644 --- a/packages/typescript/ai-code-mode/tests/validate-bindings.test.ts +++ b/packages/typescript/ai-code-mode/tests/validate-bindings.test.ts @@ -1,14 +1,14 @@ import { describe, expect, it, vi } from 'vitest' import { warnIfBindingsExposeSecrets } from '../src/validate-bindings' +import type { SecretParameterInfo } from '../src/validate-bindings' -describe('warnIfBindingsExposeSecrets', () => { - it('should warn when input schema has secret-like parameter names', () => { +describe('warnIfBindingsExposeSecrets — top-level detection', () => { + it('warns when input schema has a secret-like parameter name', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) warnIfBindingsExposeSecrets([ { name: 'callApi', - description: 'Call an API', inputSchema: { type: 'object', properties: { @@ -23,13 +23,12 @@ describe('warnIfBindingsExposeSecrets', () => { warnSpy.mockRestore() }) - it('should not warn for safe parameter names', () => { + it('does not warn for safe parameter names', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) warnIfBindingsExposeSecrets([ { name: 'search', - description: 'Search items', inputSchema: { type: 'object', properties: { @@ -44,13 +43,12 @@ describe('warnIfBindingsExposeSecrets', () => { warnSpy.mockRestore() }) - it('should detect multiple secret patterns', () => { + it('detects multiple secret patterns in a single tool', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) warnIfBindingsExposeSecrets([ { name: 'auth', - description: 'Auth tool', inputSchema: { type: 'object', properties: { @@ -62,48 +60,380 @@ describe('warnIfBindingsExposeSecrets', () => { }, ]) - const calls = warnSpy.mock.calls.map((c) => c[0]) - expect(calls.some((c: string) => c.includes('token'))).toBe(true) - expect(calls.some((c: string) => c.includes('password'))).toBe(true) - // username should NOT trigger a warning - expect(calls.some((c: string) => c.includes('username'))).toBe(false) + const calls = warnSpy.mock.calls.map((c) => String(c[0])) + expect(calls.some((c) => c.includes('token'))).toBe(true) + expect(calls.some((c) => c.includes('password'))).toBe(true) + expect(calls.some((c) => c.includes('username'))).toBe(false) warnSpy.mockRestore() }) - it('should handle tools with no inputSchema', () => { + it('handles tools with no inputSchema', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + warnIfBindingsExposeSecrets([{ name: 'simple' }]) + + expect(warnSpy).not.toHaveBeenCalled() + warnSpy.mockRestore() + }) +}) + +describe('warnIfBindingsExposeSecrets — name patterns', () => { + const secretNames = [ + 'apiKey', + 'api_key', + 'api-key', + 'APIKey', + 'accessToken', + 'access_token', + 'bearerToken', + 'refreshToken', + 'sessionToken', + 'clientSecret', + 'client_secret', + 'webhookSecret', + 'authorization', + 'Authorization', + 'password', + 'Password', + 'passcode', + 'pwd', + 'jwt', + 'privateKey', + 'private_key', + 'openaiApiKey', + 'openai_api_key', + 'githubToken', + 'x-api-key', + ] + + it.each(secretNames)('warns on parameter named "%s"', (paramName) => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) warnIfBindingsExposeSecrets([ { - name: 'simple', - description: 'Simple tool', + name: 'tool', + inputSchema: { + type: 'object', + properties: { [paramName]: { type: 'string' } }, + }, + }, + ]) + + expect(warnSpy).toHaveBeenCalled() + warnSpy.mockRestore() + }) + + const safeNames = [ + 'username', + 'email', + 'query', + 'limit', + 'offset', + 'url', + 'path', + 'id', + 'userId', + 'foreignKey', + 'sortKey', + 'partitionKey', + 'tokenizer', + 'tokenization', + 'tokens', + 'keyboardLayout', + ] + + it.each(safeNames)('does NOT warn on safe parameter "%s"', (paramName) => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + warnIfBindingsExposeSecrets([ + { + name: 'tool', + inputSchema: { + type: 'object', + properties: { [paramName]: { type: 'string' } }, + }, }, ]) expect(warnSpy).not.toHaveBeenCalled() warnSpy.mockRestore() }) +}) + +describe('warnIfBindingsExposeSecrets — nested schema recursion', () => { + it('detects secrets nested inside an object property', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + warnIfBindingsExposeSecrets([ + { + name: 'nested', + inputSchema: { + type: 'object', + properties: { + auth: { + type: 'object', + properties: { + token: { type: 'string' }, + }, + }, + }, + }, + }, + ]) + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('auth.token')) + warnSpy.mockRestore() + }) + + it('detects secrets inside array items', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + warnIfBindingsExposeSecrets([ + { + name: 'arr', + inputSchema: { + type: 'object', + properties: { + headers: { + type: 'array', + items: { + type: 'object', + properties: { + apiKey: { type: 'string' }, + }, + }, + }, + }, + }, + }, + ]) + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('apiKey')) + warnSpy.mockRestore() + }) + + it('detects secrets inside anyOf/oneOf/allOf branches', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + warnIfBindingsExposeSecrets([ + { + name: 'unioned', + inputSchema: { + type: 'object', + properties: { + credentials: { + anyOf: [ + { + type: 'object', + properties: { password: { type: 'string' } }, + }, + { + type: 'object', + properties: { jwt: { type: 'string' } }, + }, + ], + }, + }, + }, + }, + ]) + + const calls = warnSpy.mock.calls.map((c) => String(c[0])) + expect(calls.some((c) => c.includes('password'))).toBe(true) + expect(calls.some((c) => c.includes('jwt'))).toBe(true) + warnSpy.mockRestore() + }) - it('should detect api_key and api-key variations', () => { + it('resolves and scans $ref targets in $defs', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) warnIfBindingsExposeSecrets([ { - name: 'tool1', + name: 'refs', inputSchema: { + $defs: { + Creds: { + type: 'object', + properties: { + accessToken: { type: 'string' }, + }, + }, + }, type: 'object', - properties: { api_key: { type: 'string' } }, + properties: { + creds: { $ref: '#/$defs/Creds' }, + }, }, }, + ]) + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('accessToken'), + ) + warnSpy.mockRestore() + }) + + it('scans additionalProperties schemas', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + warnIfBindingsExposeSecrets([ { - name: 'tool2', + name: 'dict', inputSchema: { type: 'object', - properties: { 'api-key': { type: 'string' } }, + additionalProperties: { + type: 'object', + properties: { + secret: { type: 'string' }, + }, + }, }, }, ]) + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('secret')) + warnSpy.mockRestore() + }) + + it('does not loop forever on schemas that self-reference via cycles', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const cyclic: Record = { type: 'object', properties: {} } + ;(cyclic.properties as Record).self = cyclic + ;(cyclic.properties as Record).secret = { type: 'string' } + + warnIfBindingsExposeSecrets([ + { name: 'cycle', inputSchema: cyclic as Record }, + ]) + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('secret')) + warnSpy.mockRestore() + }) +}) + +describe('warnIfBindingsExposeSecrets — handler variants', () => { + it('handler: "ignore" suppresses warnings', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + warnIfBindingsExposeSecrets( + [ + { + name: 't', + inputSchema: { + type: 'object', + properties: { apiKey: { type: 'string' } }, + }, + }, + ], + { handler: 'ignore' }, + ) + + expect(warnSpy).not.toHaveBeenCalled() + warnSpy.mockRestore() + }) + + it('handler: "throw" throws on the first match', () => { + expect(() => + warnIfBindingsExposeSecrets( + [ + { + name: 't', + inputSchema: { + type: 'object', + properties: { apiKey: { type: 'string' } }, + }, + }, + ], + { handler: 'throw' }, + ), + ).toThrow(/apiKey/) + }) + + it('handler: function receives each match', () => { + const matches: Array = [] + + warnIfBindingsExposeSecrets( + [ + { + name: 'callApi', + inputSchema: { + type: 'object', + properties: { + apiKey: { type: 'string' }, + nested: { + type: 'object', + properties: { token: { type: 'string' } }, + }, + }, + }, + }, + ], + { handler: (info) => matches.push(info) }, + ) + + expect(matches).toHaveLength(2) + expect(matches[0]).toMatchObject({ + toolName: 'callApi', + paramName: 'apiKey', + paramPath: ['apiKey'], + }) + expect(matches[1]).toMatchObject({ + toolName: 'callApi', + paramName: 'token', + paramPath: ['nested', 'token'], + }) + }) +}) + +describe('warnIfBindingsExposeSecrets — dedup', () => { + it('does not surface the same (tool, paramPath) twice across calls when a shared cache is passed', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const cache = new Set() + + const tool = { + name: 'api', + inputSchema: { + type: 'object', + properties: { apiKey: { type: 'string' } }, + }, + } + + warnIfBindingsExposeSecrets([tool], { dedupCache: cache }) + warnIfBindingsExposeSecrets([tool], { dedupCache: cache }) + + expect(warnSpy).toHaveBeenCalledTimes(1) + warnSpy.mockRestore() + }) + + it('still surfaces a different paramPath on the second call', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const cache = new Set() + + warnIfBindingsExposeSecrets( + [ + { + name: 'api', + inputSchema: { + type: 'object', + properties: { apiKey: { type: 'string' } }, + }, + }, + ], + { dedupCache: cache }, + ) + warnIfBindingsExposeSecrets( + [ + { + name: 'api', + inputSchema: { + type: 'object', + properties: { secret: { type: 'string' } }, + }, + }, + ], + { dedupCache: cache }, + ) + expect(warnSpy).toHaveBeenCalledTimes(2) warnSpy.mockRestore() }) From 2dea51e9a897a7f4e2f0873a5f12378641e0a224 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 09:57:55 +0000 Subject: [PATCH 5/5] ci: apply automated fixes --- .../typescript/ai-code-mode/tests/validate-bindings.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/typescript/ai-code-mode/tests/validate-bindings.test.ts b/packages/typescript/ai-code-mode/tests/validate-bindings.test.ts index d07790aa1..86eea0d81 100644 --- a/packages/typescript/ai-code-mode/tests/validate-bindings.test.ts +++ b/packages/typescript/ai-code-mode/tests/validate-bindings.test.ts @@ -267,9 +267,7 @@ describe('warnIfBindingsExposeSecrets — nested schema recursion', () => { }, ]) - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('accessToken'), - ) + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('accessToken')) warnSpy.mockRestore() })