Skip to content

Commit b9e4bdd

Browse files
committed
fix(tools): resolve helper-built self hops
1 parent b80417c commit b9e4bdd

2 files changed

Lines changed: 282 additions & 16 deletions

File tree

scripts/check-tool-request-boundary.test.ts

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,37 @@ describe('tool self-hop audit', () => {
336336
expect(audit.violations[0]?.reason).toBe('same-origin-tool-request')
337337
})
338338

339+
it('rejects a helper-returned path concatenated with the Sim origin', () => {
340+
const audit = auditToolSelfHops(`
341+
import { getBaseUrl } from '@/lib/core/utils/urls'
342+
function buildPath() {
343+
return '/api/tools/test'
344+
}
345+
const tool = {
346+
id: 'test_tool',
347+
request: { url: () => getBaseUrl() + buildPath(), method: 'POST' },
348+
}
349+
`)
350+
351+
expect(audit.violations[0]?.reason).toBe('same-origin-tool-request')
352+
})
353+
354+
it('rejects a locally-bound helper path concatenated with the Sim origin', () => {
355+
const audit = auditToolSelfHops(`
356+
import { getBaseUrl } from '@/lib/core/utils/urls'
357+
function buildPath() {
358+
const path = '/api/tools/test'
359+
return path
360+
}
361+
const tool = {
362+
id: 'test_tool',
363+
request: { url: () => getBaseUrl() + buildPath(), method: 'POST' },
364+
}
365+
`)
366+
367+
expect(audit.violations[0]?.reason).toBe('same-origin-tool-request')
368+
})
369+
339370
it('rejects a same-origin path interpolated with the Sim origin', () => {
340371
const audit = auditToolSelfHops(`
341372
import { getBaseUrl } from '@/lib/core/utils/urls'
@@ -348,6 +379,81 @@ describe('tool self-hop audit', () => {
348379
expect(audit.violations[0]?.reason).toBe('same-origin-tool-request')
349380
})
350381

382+
it('rejects a helper-returned path interpolated with the Sim origin', () => {
383+
const audit = auditToolSelfHops(`
384+
import { getBaseUrl } from '@/lib/core/utils/urls'
385+
function buildPath() {
386+
return '/api/tools/test'
387+
}
388+
const tool = {
389+
id: 'test_tool',
390+
request: { url: () => \`\${getBaseUrl()}\${buildPath()}\`, method: 'POST' },
391+
}
392+
`)
393+
394+
expect(audit.violations[0]?.reason).toBe('same-origin-tool-request')
395+
})
396+
397+
it('rejects a helper-returned path resolved against the Sim origin', () => {
398+
const audit = auditToolSelfHops(`
399+
import { getBaseUrl } from '@/lib/core/utils/urls'
400+
function buildPath() {
401+
return '/api/tools/test'
402+
}
403+
const tool = {
404+
id: 'test_tool',
405+
request: { url: () => new URL(buildPath(), getBaseUrl()).toString(), method: 'POST' },
406+
}
407+
`)
408+
409+
expect(audit.violations[0]?.reason).toBe('same-origin-tool-request')
410+
})
411+
412+
it('rejects an internal path resolved against a path-normalized Sim origin', () => {
413+
const audit = auditToolSelfHops(`
414+
import { getBaseUrl } from '@/lib/core/utils/urls'
415+
const baseUrl = getBaseUrl() + '/tool-proxy/'
416+
const tool = {
417+
id: 'test_tool',
418+
request: { url: () => new URL('/api/tools/test', baseUrl).toString(), method: 'POST' },
419+
}
420+
`)
421+
422+
expect(audit.violations[0]?.reason).toBe('same-origin-tool-request')
423+
})
424+
425+
it('rejects an internal path resolved against a template-normalized Sim origin', () => {
426+
const audit = auditToolSelfHops(`
427+
import { getBaseUrl } from '@/lib/core/utils/urls'
428+
const baseUrl = \`\${getBaseUrl()}/tool-proxy/\`
429+
const tool = {
430+
id: 'test_tool',
431+
request: { url: () => new URL('/api/tools/test', baseUrl).toString(), method: 'POST' },
432+
}
433+
`)
434+
435+
expect(audit.violations[0]?.reason).toBe('same-origin-tool-request')
436+
})
437+
438+
it('rejects an internal path resolved against a helper-normalized Sim origin', () => {
439+
const audit = auditToolSelfHops(`
440+
import { getBaseUrl } from '@/lib/core/utils/urls'
441+
function getNormalizedOrigin() {
442+
const origin = \`\${getBaseUrl()}/tool-proxy/\`
443+
return origin
444+
}
445+
const tool = {
446+
id: 'test_tool',
447+
request: {
448+
url: () => new URL('/api/tools/test', getNormalizedOrigin()).toString(),
449+
method: 'POST',
450+
},
451+
}
452+
`)
453+
454+
expect(audit.violations[0]?.reason).toBe('same-origin-tool-request')
455+
})
456+
351457
it('rejects a one-argument URL built from the Sim origin', () => {
352458
const audit = auditToolSelfHops(`
353459
import { getBaseUrl } from '@/lib/core/utils/urls'
@@ -500,6 +606,57 @@ describe('tool self-hop audit', () => {
500606
expect(audit.violations).toEqual([])
501607
})
502608

609+
it('allows a helper-returned API-shaped path resolved against an external origin', () => {
610+
const audit = auditToolSelfHops(`
611+
function buildPath() {
612+
return '/api/messages'
613+
}
614+
const tool = {
615+
id: 'test_tool',
616+
request: {
617+
url: () => new URL(buildPath(), 'https://provider.example.com').toString(),
618+
method: 'POST',
619+
},
620+
}
621+
`)
622+
623+
expect(audit.violations).toEqual([])
624+
})
625+
626+
it('does not treat hostname mutation as Sim-origin normalization', () => {
627+
const audit = auditToolSelfHops(`
628+
import { getBaseUrl } from '@/lib/core/utils/urls'
629+
const providerOrigin = getBaseUrl() + '.provider.example.com'
630+
const tool = {
631+
id: 'test_tool',
632+
request: {
633+
url: () => new URL('/api/messages', providerOrigin).toString(),
634+
method: 'POST',
635+
},
636+
}
637+
`)
638+
639+
expect(audit.violations).toEqual([])
640+
})
641+
642+
it('does not treat a dynamic hostname suffix as Sim-origin normalization', () => {
643+
const audit = auditToolSelfHops(`
644+
import { getBaseUrl } from '@/lib/core/utils/urls'
645+
const tool = {
646+
id: 'test_tool',
647+
request: {
648+
url: (params) => new URL(
649+
'/api/messages',
650+
\`\${getBaseUrl()}\${params.providerDomain}\`
651+
).toString(),
652+
method: 'POST',
653+
},
654+
}
655+
`)
656+
657+
expect(audit.violations).toEqual([])
658+
})
659+
503660
it('allows an API-shaped path interpolated with an external provider origin', () => {
504661
const audit = auditRequest(
505662
`url: (params) => \`${PARAMS_HOST_TEMPLATE}/api/messages\`, method: 'POST'`

scripts/check-tool-request-boundary.ts

Lines changed: 125 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,23 @@ function resolveScopedArgument(
230230
return { expression: current, resolver }
231231
}
232232

233+
function collectFunctionLocalBindings(fn: SyntaxNode, locals: Map<string, SyntaxNode>): void {
234+
const visit = (node: SyntaxNode) => {
235+
if (node !== fn && FUNCTION_NODE_TYPES.has(node.type)) return
236+
if (
237+
node.type === 'VariableDeclarator' &&
238+
isSyntaxNode(node.id) &&
239+
node.id.type === 'Identifier' &&
240+
typeof node.id.name === 'string' &&
241+
isSyntaxNode(node.init)
242+
) {
243+
locals.set(node.id.name, node.init)
244+
}
245+
for (const child of getChildNodes(node)) visit(child)
246+
}
247+
visit(fn)
248+
}
249+
233250
function isInternalPathExpression(
234251
expression: SyntaxNode,
235252
resolver: SelfHopResolver,
@@ -251,6 +268,31 @@ function isInternalPathExpression(
251268
return isInternalPathExpression(binding.expression, binding.resolver, nextSeen)
252269
}
253270

271+
if (current.type === 'CallExpression' || current.type === 'OptionalCallExpression') {
272+
if (!isSyntaxNode(current.callee)) return false
273+
const callee = unwrapExpression(current.callee)
274+
if (callee.type === 'Identifier' && typeof callee.name === 'string') {
275+
const key = `${resolver.file}:path-call:${callee.name}`
276+
if (seen.has(key)) return false
277+
const binding = resolveScopedIdentifier(callee.name, resolver)
278+
if (binding && FUNCTION_NODE_TYPES.has(unwrapExpression(binding.expression).type)) {
279+
const nextSeen = new Set(seen)
280+
nextSeen.add(key)
281+
const argumentsList = Array.isArray(current.arguments)
282+
? current.arguments
283+
.filter(isSyntaxNode)
284+
.map((argument) => resolveScopedArgument(argument, resolver))
285+
: []
286+
return functionReturnsInternalPath(
287+
binding.expression,
288+
binding.resolver,
289+
argumentsList,
290+
nextSeen
291+
)
292+
}
293+
}
294+
}
295+
254296
if (current.type === 'ConditionalExpression') {
255297
return (
256298
(isSyntaxNode(current.consequent) &&
@@ -275,6 +317,59 @@ function isInternalPathExpression(
275317
return false
276318
}
277319

320+
function functionReturnsInternalPath(
321+
fn: SyntaxNode,
322+
resolver: SelfHopResolver,
323+
argumentsList: readonly ScopedExpression[],
324+
seen: ReadonlySet<string>
325+
): boolean {
326+
const current = unwrapExpression(fn)
327+
if (!FUNCTION_NODE_TYPES.has(current.type)) return false
328+
const locals = new Map(resolver.locals)
329+
const scopedLocals = new Map(resolver.scopedLocals)
330+
const parameters = Array.isArray(current.params) ? current.params : []
331+
for (const [index, parameter] of parameters.entries()) {
332+
if (
333+
isSyntaxNode(parameter) &&
334+
parameter.type === 'Identifier' &&
335+
typeof parameter.name === 'string' &&
336+
argumentsList[index]
337+
) {
338+
const argument = argumentsList[index]
339+
scopedLocals.set(parameter.name, argument)
340+
if (argument.resolver === resolver) locals.set(parameter.name, argument.expression)
341+
}
342+
}
343+
collectFunctionLocalBindings(current, locals)
344+
const localResolver = { ...resolver, locals, scopedLocals }
345+
if (current.type === 'ArrowFunctionExpression' && isSyntaxNode(current.body)) {
346+
const body = unwrapExpression(current.body)
347+
if (body.type !== 'BlockStatement') {
348+
return isInternalPathExpression(body, localResolver, new Set(seen))
349+
}
350+
}
351+
let found = false
352+
const visit = (node: SyntaxNode) => {
353+
if (found || (node !== current && FUNCTION_NODE_TYPES.has(node.type))) return
354+
if (
355+
node.type === 'ReturnStatement' &&
356+
isSyntaxNode(node.argument) &&
357+
isInternalPathExpression(node.argument, localResolver, new Set(seen))
358+
) {
359+
found = true
360+
return
361+
}
362+
for (const child of getChildNodes(node)) visit(child)
363+
}
364+
visit(current)
365+
return found
366+
}
367+
368+
function isOriginPreservingStaticSuffix(expression: SyntaxNode): boolean {
369+
const suffix = getStaticString(expression)
370+
return suffix !== undefined && (suffix === '' || /^[/?#]/.test(suffix))
371+
}
372+
278373
function isSimOriginExpression(
279374
expression: SyntaxNode,
280375
resolver: SelfHopResolver,
@@ -321,6 +416,34 @@ function isSimOriginExpression(
321416
}
322417
}
323418
}
419+
if (
420+
current.type === 'BinaryExpression' &&
421+
current.operator === '+' &&
422+
isSyntaxNode(current.left) &&
423+
isSyntaxNode(current.right)
424+
) {
425+
return (
426+
isSimOriginExpression(current.left, resolver, new Set(seen)) &&
427+
isOriginPreservingStaticSuffix(current.right)
428+
)
429+
}
430+
if (
431+
current.type === 'TemplateLiteral' &&
432+
Array.isArray(current.expressions) &&
433+
Array.isArray(current.quasis) &&
434+
current.expressions.length > 0 &&
435+
current.quasis.length === current.expressions.length + 1 &&
436+
current.expressions.every(isSyntaxNode) &&
437+
current.quasis.every(isSyntaxNode) &&
438+
getTemplateQuasiValue(current.quasis[0]) === '' &&
439+
isSimOriginExpression(current.expressions[0], resolver, new Set(seen))
440+
) {
441+
const suffix = getTemplateQuasiValue(current.quasis[1])
442+
return (
443+
suffix !== undefined &&
444+
(suffix === '' ? current.expressions.length === 1 : /^[/?#]/.test(suffix))
445+
)
446+
}
324447
if (current.type === 'ConditionalExpression') {
325448
return (
326449
(isSyntaxNode(current.consequent) &&
@@ -362,6 +485,7 @@ function functionReturnsSimOrigin(
362485
if (argument.resolver === resolver) locals.set(parameter.name, argument.expression)
363486
}
364487
}
488+
collectFunctionLocalBindings(current, locals)
365489
const localResolver = { ...resolver, locals, scopedLocals }
366490
if (current.type === 'ArrowFunctionExpression' && isSyntaxNode(current.body)) {
367491
const body = unwrapExpression(current.body)
@@ -744,22 +868,7 @@ function functionContainsInternalRoute(
744868
}
745869
const localResolver: SelfHopResolver = { ...resolver, locals, scopedLocals }
746870

747-
const collectLocals = (node: SyntaxNode) => {
748-
if (node !== current && FUNCTION_NODE_TYPES.has(node.type)) {
749-
return
750-
}
751-
if (
752-
node.type === 'VariableDeclarator' &&
753-
isSyntaxNode(node.id) &&
754-
node.id.type === 'Identifier' &&
755-
typeof node.id.name === 'string' &&
756-
isSyntaxNode(node.init)
757-
) {
758-
locals.set(node.id.name, node.init)
759-
}
760-
for (const child of getChildNodes(node)) collectLocals(child)
761-
}
762-
collectLocals(current)
871+
collectFunctionLocalBindings(current, locals)
763872

764873
if (current.type === 'ArrowFunctionExpression' && isSyntaxNode(current.body)) {
765874
const body = unwrapExpression(current.body)

0 commit comments

Comments
 (0)