Skip to content

Commit 1a287c0

Browse files
committed
fix(ai-code-mode): harden secret-parameter detection
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.
1 parent 11220f3 commit 1a287c0

4 files changed

Lines changed: 566 additions & 41 deletions

File tree

packages/typescript/ai-code-mode/src/create-code-mode-tool.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ export function createCodeModeTool(
9494
timeout = 30000,
9595
memoryLimit = 128,
9696
getSkillBindings,
97+
onSecretParameter,
9798
} = config
9899

99100
// Validate tools
@@ -104,7 +105,14 @@ export function createCodeModeTool(
104105
// Transform tools to bindings with external_ prefix (static bindings)
105106
const staticBindings = toolsToBindings(tools, 'external_')
106107

107-
warnIfBindingsExposeSecrets(Object.values(staticBindings))
108+
// Shared across static + dynamic (skill) binding scans so a given
109+
// (toolName, paramPath) pair surfaces at most once per code-mode instance.
110+
const secretDedupCache = new Set<string>()
111+
112+
warnIfBindingsExposeSecrets(Object.values(staticBindings), {
113+
handler: onSecretParameter,
114+
dedupCache: secretDedupCache,
115+
})
108116

109117
// Create the tool definition
110118
const definition = toolDefinition({
@@ -163,6 +171,17 @@ export function createCodeModeTool(
163171
// Step 2: Get dynamic skill bindings if available
164172
const skillBindings = getSkillBindings ? await getSkillBindings() : {}
165173

174+
// Scan dynamic bindings too — their schemas are equally in-scope for
175+
// the same exfiltration threat. Dedup cache prevents repeat warnings
176+
// when the same binding reappears across executions.
177+
const skillBindingValues = Object.values(skillBindings)
178+
if (skillBindingValues.length > 0) {
179+
warnIfBindingsExposeSecrets(skillBindingValues, {
180+
handler: onSecretParameter,
181+
dedupCache: secretDedupCache,
182+
})
183+
}
184+
166185
// Step 3: Merge static and dynamic bindings, then wrap with event awareness
167186
const allBindings = { ...staticBindings, ...skillBindings }
168187
const eventAwareBindings = createEventAwareBindings(

packages/typescript/ai-code-mode/src/types.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type {
33
ToolDefinition,
44
ToolExecutionContext,
55
} from '@tanstack/ai'
6+
import type { SecretParameterHandler } from './validate-bindings'
67

78
// ============================================================================
89
// Isolate Driver Interfaces
@@ -193,6 +194,20 @@ export interface CodeModeToolConfig {
193194
* ```
194195
*/
195196
getSkillBindings?: () => Promise<Record<string, ToolBinding>>
197+
198+
/**
199+
* How to surface tool parameters whose names look like secrets.
200+
* Defaults to `'warn'` (logs via `console.warn`).
201+
*
202+
* - `'warn'`: log a warning for each match.
203+
* - `'throw'`: throw an Error on the first match — useful in tests/CI.
204+
* - `'ignore'`: suppress the check entirely.
205+
* - `(info) => void`: receive each match and decide how to react.
206+
*
207+
* Matches are deduplicated per `(toolName, paramPath)` across the lifetime
208+
* of a single `createCodeModeTool` instance.
209+
*/
210+
onSecretParameter?: SecretParameterHandler
196211
}
197212

198213
/**

packages/typescript/ai-code-mode/src/validate-bindings.ts

Lines changed: 181 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,196 @@
11
/**
2-
* Patterns that indicate a parameter might carry a secret value.
3-
* Case-insensitive matching against JSON Schema property names.
2+
* Single words that on their own signal "this is a credential".
3+
* Matched after splitting a parameter name into camelCase/snake/kebab words.
4+
* So `accessToken` → `['access', 'token']` → matches `token`,
5+
* but `tokenizer` → `['tokenizer']` → does NOT match `token`.
46
*/
5-
const SECRET_PATTERNS =
6-
/^(api[_-]?key|secret|token|password|credential|auth[_-]?token|access[_-]?key|private[_-]?key)$/i
7+
const DANGEROUS_WORDS = new Set<string>([
8+
'password',
9+
'passwd',
10+
'pwd',
11+
'passcode',
12+
'secret',
13+
'token',
14+
'credential',
15+
'credentials',
16+
'authorization',
17+
'jwt',
18+
'bearer',
19+
])
20+
21+
/**
22+
* Compound patterns matched as substrings of the normalized (lowercased,
23+
* separator-stripped) parameter name. Catches forms like `openai_api_key`,
24+
* `x-api-key`, and `webhookSecret`.
25+
*/
26+
const COMPOUND_PATTERNS = [
27+
'apikey',
28+
'accesskey',
29+
'authkey',
30+
'privatekey',
31+
'clientsecret',
32+
'webhooksecret',
33+
] as const
34+
35+
export interface SecretParameterInfo {
36+
toolName: string
37+
paramName: string
38+
paramPath: Array<string>
39+
}
40+
41+
export type SecretParameterHandler =
42+
| 'warn'
43+
| 'throw'
44+
| 'ignore'
45+
| ((info: SecretParameterInfo) => void)
746

847
interface ToolLike {
948
name: string
10-
inputSchema?: { type?: string; properties?: Record<string, unknown> }
49+
inputSchema?: Record<string, unknown>
50+
}
51+
52+
interface JsonSchemaLike {
53+
type?: string
54+
properties?: Record<string, JsonSchemaLike>
55+
items?: JsonSchemaLike | Array<JsonSchemaLike>
56+
anyOf?: Array<JsonSchemaLike>
57+
oneOf?: Array<JsonSchemaLike>
58+
allOf?: Array<JsonSchemaLike>
59+
additionalProperties?: boolean | JsonSchemaLike
60+
$ref?: string
61+
$defs?: Record<string, JsonSchemaLike>
62+
definitions?: Record<string, JsonSchemaLike>
63+
}
64+
65+
function splitIntoWords(name: string): Array<string> {
66+
return name
67+
.replace(/[_\-\s]+/g, ' ')
68+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
69+
.toLowerCase()
70+
.split(/\s+/)
71+
.filter(Boolean)
72+
}
73+
74+
function looksLikeSecret(name: string): boolean {
75+
const words = splitIntoWords(name)
76+
if (words.some((w) => DANGEROUS_WORDS.has(w))) return true
77+
const normalized = name.replace(/[_\-\s]/g, '').toLowerCase()
78+
return COMPOUND_PATTERNS.some((p) => normalized.includes(p))
79+
}
80+
81+
function resolveRef(
82+
ref: string,
83+
root: JsonSchemaLike,
84+
): JsonSchemaLike | undefined {
85+
const match = ref.match(/^#\/(\$defs|definitions)\/(.+)$/)
86+
if (!match) return undefined
87+
const bucket = match[1] as '$defs' | 'definitions'
88+
return root[bucket]?.[match[2]!]
89+
}
90+
91+
function findSecretParams(
92+
schema: JsonSchemaLike | undefined,
93+
root: JsonSchemaLike,
94+
seen: Set<object>,
95+
path: Array<string>,
96+
found: Array<{ path: Array<string>; name: string }>,
97+
): void {
98+
if (!schema || typeof schema !== 'object' || seen.has(schema)) return
99+
seen.add(schema)
100+
101+
if (schema.properties && typeof schema.properties === 'object') {
102+
for (const [paramName, sub] of Object.entries(schema.properties)) {
103+
if (looksLikeSecret(paramName)) {
104+
found.push({ path: [...path, paramName], name: paramName })
105+
}
106+
findSecretParams(sub, root, seen, [...path, paramName], found)
107+
}
108+
}
109+
110+
if (Array.isArray(schema.items)) {
111+
schema.items.forEach((s, i) =>
112+
findSecretParams(s, root, seen, [...path, `[${i}]`], found),
113+
)
114+
} else if (schema.items && typeof schema.items === 'object') {
115+
findSecretParams(schema.items, root, seen, [...path, '[]'], found)
116+
}
117+
118+
if (
119+
schema.additionalProperties &&
120+
typeof schema.additionalProperties === 'object'
121+
) {
122+
findSecretParams(schema.additionalProperties, root, seen, path, found)
123+
}
124+
125+
for (const key of ['anyOf', 'oneOf', 'allOf'] as const) {
126+
const arr = schema[key]
127+
if (Array.isArray(arr)) {
128+
arr.forEach((s) => findSecretParams(s, root, seen, path, found))
129+
}
130+
}
131+
132+
if (typeof schema.$ref === 'string') {
133+
const target = resolveRef(schema.$ref, root)
134+
if (target) findSecretParams(target, root, seen, path, found)
135+
}
136+
}
137+
138+
function buildMessage(toolName: string, paramPath: Array<string>): string {
139+
return (
140+
`[TanStack AI Code Mode] Tool "${toolName}" has parameter "${paramPath.join('.')}" ` +
141+
`that looks like a secret. Code Mode executes LLM-generated code — any ` +
142+
`value passed through this parameter is accessible to generated code and ` +
143+
`could be exfiltrated. Keep secrets in your server-side tool implementation ` +
144+
`instead of passing them as tool parameters.`
145+
)
11146
}
12147

13148
/**
14149
* Scan tool input schemas for parameter names that look like secrets.
15-
* Emits console.warn for each match so developers notice during development.
150+
* Emits a warning (or invokes the configured handler) for each match.
151+
*
152+
* Recurses into nested object properties, array items, union branches
153+
* (anyOf/oneOf/allOf), additionalProperties, and `$ref` targets that
154+
* resolve within the same schema's `$defs`/`definitions`.
16155
*
17-
* This is a best-effort heuristic, not a security boundary.
156+
* Best-effort heuristic, not a security boundary.
18157
*/
19-
export function warnIfBindingsExposeSecrets(tools: Array<ToolLike>): void {
158+
export function warnIfBindingsExposeSecrets(
159+
tools: Array<ToolLike>,
160+
options: {
161+
handler?: SecretParameterHandler
162+
dedupCache?: Set<string>
163+
} = {},
164+
): void {
165+
const { handler = 'warn', dedupCache } = options
166+
if (handler === 'ignore') return
167+
20168
for (const tool of tools) {
21-
const properties = tool.inputSchema?.properties
22-
if (!properties) continue
23-
24-
for (const paramName of Object.keys(properties)) {
25-
if (SECRET_PATTERNS.test(paramName)) {
26-
console.warn(
27-
`[TanStack AI Code Mode] Tool "${tool.name}" has parameter "${paramName}" ` +
28-
`that looks like a secret. Code Mode executes LLM-generated code — any ` +
29-
`value passed through this parameter is accessible to generated code and ` +
30-
`could be exfiltrated. Keep secrets in your server-side tool implementation ` +
31-
`instead of passing them as tool parameters.`,
32-
)
169+
const schema = tool.inputSchema as JsonSchemaLike | undefined
170+
if (!schema) continue
171+
172+
const found: Array<{ path: Array<string>; name: string }> = []
173+
findSecretParams(schema, schema, new Set(), [], found)
174+
175+
for (const entry of found) {
176+
const dedupKey = `${tool.name}::${entry.path.join('.')}`
177+
if (dedupCache) {
178+
if (dedupCache.has(dedupKey)) continue
179+
dedupCache.add(dedupKey)
180+
}
181+
182+
const info: SecretParameterInfo = {
183+
toolName: tool.name,
184+
paramName: entry.name,
185+
paramPath: entry.path,
186+
}
187+
188+
if (typeof handler === 'function') {
189+
handler(info)
190+
} else if (handler === 'throw') {
191+
throw new Error(buildMessage(tool.name, entry.path))
192+
} else {
193+
console.warn(buildMessage(tool.name, entry.path))
33194
}
34195
}
35196
}

0 commit comments

Comments
 (0)