Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/fix-code-mode-secret-warning.md
Original file line number Diff line number Diff line change
@@ -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.
22 changes: 22 additions & 0 deletions packages/typescript/ai-code-mode/src/create-code-mode-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -93,6 +94,7 @@ export function createCodeModeTool(
timeout = 30000,
memoryLimit = 128,
getSkillBindings,
onSecretParameter,
} = config

// Validate tools
Expand All @@ -103,6 +105,15 @@ export function createCodeModeTool(
// Transform tools to bindings with external_ prefix (static bindings)
const staticBindings = toolsToBindings(tools, 'external_')

// 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<string>()

warnIfBindingsExposeSecrets(Object.values(staticBindings), {
handler: onSecretParameter,
dedupCache: secretDedupCache,
})

// Create the tool definition
const definition = toolDefinition({
name: 'execute_typescript' as const,
Expand Down Expand Up @@ -160,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(
Expand Down
6 changes: 6 additions & 0 deletions packages/typescript/ai-code-mode/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
15 changes: 15 additions & 0 deletions packages/typescript/ai-code-mode/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
ToolDefinition,
ToolExecutionContext,
} from '@tanstack/ai'
import type { SecretParameterHandler } from './validate-bindings'

// ============================================================================
// Isolate Driver Interfaces
Expand Down Expand Up @@ -193,6 +194,20 @@ export interface CodeModeToolConfig {
* ```
*/
getSkillBindings?: () => Promise<Record<string, ToolBinding>>

/**
* 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
}

/**
Expand Down
197 changes: 197 additions & 0 deletions packages/typescript/ai-code-mode/src/validate-bindings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
/**
* 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 DANGEROUS_WORDS = new Set<string>([
'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<string>
}

export type SecretParameterHandler =
| 'warn'
| 'throw'
| 'ignore'
| ((info: SecretParameterInfo) => void)

interface ToolLike {
name: string
inputSchema?: Record<string, unknown>
}

interface JsonSchemaLike {
type?: string
properties?: Record<string, JsonSchemaLike>
items?: JsonSchemaLike | Array<JsonSchemaLike>
anyOf?: Array<JsonSchemaLike>
oneOf?: Array<JsonSchemaLike>
allOf?: Array<JsonSchemaLike>
additionalProperties?: boolean | JsonSchemaLike
$ref?: string
$defs?: Record<string, JsonSchemaLike>
definitions?: Record<string, JsonSchemaLike>
}

function splitIntoWords(name: string): Array<string> {
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<object>,
path: Array<string>,
found: Array<{ path: Array<string>; 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>): 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 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`.
*
* Best-effort heuristic, not a security boundary.
*/
export function warnIfBindingsExposeSecrets(
tools: Array<ToolLike>,
options: {
handler?: SecretParameterHandler
dedupCache?: Set<string>
} = {},
): void {
const { handler = 'warn', dedupCache } = options
if (handler === 'ignore') return

for (const tool of tools) {
const schema = tool.inputSchema as JsonSchemaLike | undefined
if (!schema) continue

const found: Array<{ path: Array<string>; 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))
}
}
}
}
Loading
Loading