Skip to content

Commit 2f5d201

Browse files
icecrasher321claude
andcommitted
fix(tools): reject a direct call missing a required user-only input
`validateRequiredParametersAfterMerge` checks `user-or-llm` parameters alone, because on the workflow path a `user-only` parameter was already validated during serialization against the block field that holds it. The v2 execute path has no serialization step, so nothing had checked them: omitting `zendesk_get_ticket`'s `subdomain` reached Zendesk as `undefined` and came back a provider authentication failure — the same undiagnosable shape this branch set out to remove. The check exempts a parameter Sim supplies itself, mirroring `injectHostedKeyIfNeeded`'s three tests in the same order so the two cannot disagree about whether a value is coming. `firecrawl_scrape` stays callable with no `apiKey` where keys are hosted, and requires one where they are not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent bd119a6 commit 2f5d201

2 files changed

Lines changed: 164 additions & 11 deletions

File tree

apps/sim/lib/tool-execution/application/execute-tool.test.ts

Lines changed: 112 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
* @vitest-environment node
33
*/
44
import type { PersonalApiKeyPrincipal, WorkspaceApiKeyPrincipal } from '@sim/auth/principal'
5-
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing/mocks'
6+
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
67

78
const mocks = vi.hoisted(() => ({
89
loadWorkspace: vi.fn(),
@@ -69,8 +70,8 @@ vi.mock('@/blocks/registry', () => ({
6970
getBlockMeta: vi.fn(() => ({ tags: [] })),
7071
}))
7172

72-
vi.mock('@/tools/metadata', () => ({
73-
getToolMetadata: (toolId: string) =>
73+
vi.mock('@/tools/utils', () => ({
74+
getTool: (toolId: string) =>
7475
Object.hasOwn(TOOL_METADATA, toolId) ? TOOL_METADATA[toolId] : undefined,
7576
}))
7677

@@ -98,10 +99,27 @@ const TOOL_METADATA: Record<string, Record<string, unknown>> = {
9899
slack_message: {
99100
id: 'slack_message',
100101
name: 'Slack Send Message',
101-
params: { text: { type: 'string', required: true } },
102+
params: { text: { type: 'string', required: true, visibility: 'user-or-llm' } },
102103
oauth: { required: true, provider: 'slack' },
103104
},
104-
firecrawl_scrape: { id: 'firecrawl_scrape', name: 'Firecrawl Scrape', params: {} },
105+
firecrawl_scrape: {
106+
id: 'firecrawl_scrape',
107+
name: 'Firecrawl Scrape',
108+
params: {
109+
url: { type: 'string', required: true, visibility: 'user-or-llm' },
110+
apiKey: { type: 'string', required: true, visibility: 'user-only' },
111+
},
112+
hosting: { apiKeyParam: 'apiKey' },
113+
},
114+
zendesk_get_ticket: {
115+
id: 'zendesk_get_ticket',
116+
name: 'Zendesk Get Ticket',
117+
params: {
118+
subdomain: { type: 'string', required: true, visibility: 'user-only' },
119+
apiToken: { type: 'string', required: true, visibility: 'user-only' },
120+
ticketId: { type: 'string', required: true, visibility: 'user-or-llm' },
121+
},
122+
},
105123
preview_call: { id: 'preview_call', name: 'Preview Call', params: {} },
106124
confluence_read_v2: { id: 'confluence_read_v2', name: 'Confluence Read', params: {} },
107125
}
@@ -148,6 +166,7 @@ const previewBlock = block({
148166
preview: true,
149167
tools: { access: ['preview_call'] },
150168
})
169+
const zendeskBlock = block({ type: 'zendesk', tools: { access: ['zendesk_get_ticket'] } })
151170
const confluenceBlock = block({
152171
type: 'confluence_v2',
153172
tools: { access: ['confluence_read_v2'] },
@@ -156,20 +175,35 @@ const confluenceBlock = block({
156175
function run(input: Partial<Parameters<typeof executeToolForCaller.execute>[0]['input']> = {}) {
157176
return executeToolForCaller.execute({
158177
principal,
159-
input: { workspaceId: WORKSPACE_ID, toolId: 'firecrawl_scrape', input: {}, ...input },
178+
input: {
179+
workspaceId: WORKSPACE_ID,
180+
toolId: 'firecrawl_scrape',
181+
input: { url: 'https://example.com' },
182+
...input,
183+
},
160184
})
161185
}
162186

163187
describe('executeToolForCaller', () => {
188+
afterAll(resetEnvFlagsMock)
189+
164190
beforeEach(() => {
165191
vi.clearAllMocks()
192+
// Hosted-key injection only happens where Sim hosts keys.
193+
setEnvFlags({ isHosted: true })
166194
mocks.loadWorkspace.mockResolvedValue(workspaceContext)
167195
mocks.resolvePermission.mockResolvedValue('write')
168196
mocks.allowedIntegrationTypes.mockResolvedValue(null)
169197
mocks.getBlockVisibility.mockResolvedValue({ revealed: new Set(), disabled: new Set() })
170198
mocks.listCustomBlocks.mockResolvedValue([])
171199
mocks.isDeploymentAvailable.mockReturnValue(true)
172-
mocks.getAllBlocks.mockReturnValue([slackBlock, firecrawlBlock, previewBlock, confluenceBlock])
200+
mocks.getAllBlocks.mockReturnValue([
201+
slackBlock,
202+
firecrawlBlock,
203+
previewBlock,
204+
confluenceBlock,
205+
zendeskBlock,
206+
])
173207
mocks.executeRegistryTool.mockResolvedValue({ success: true, output: { markdown: '# Hi' } })
174208
mocks.resolveBillingAttribution.mockResolvedValue({ workspaceId: WORKSPACE_ID })
175209
})
@@ -247,6 +281,77 @@ describe('executeToolForCaller', () => {
247281
})
248282
})
249283

284+
/**
285+
* The workflow path validates `user-only` parameters during serialization.
286+
* This path has no serialization step, so without an explicit check a missing
287+
* credential reached the provider as `undefined`.
288+
*/
289+
it('refuses a missing required user-only input, naming every one of them', async () => {
290+
await expect(
291+
run({ toolId: 'zendesk_get_ticket', input: { ticketId: '42' } })
292+
).rejects.toMatchObject({
293+
code: 'validation',
294+
message: expect.stringContaining('input.subdomain'),
295+
})
296+
expect(mocks.executeRegistryTool).not.toHaveBeenCalled()
297+
})
298+
299+
it('names the missing inputs together rather than one per round trip', async () => {
300+
await expect(
301+
run({ toolId: 'zendesk_get_ticket', input: { ticketId: '42' } })
302+
).rejects.toMatchObject({ message: expect.stringContaining('input.apiToken') })
303+
})
304+
305+
it('treats a blank string as missing, the way the merge validator does', async () => {
306+
await expect(
307+
run({ toolId: 'zendesk_get_ticket', input: { ticketId: '4', subdomain: '', apiToken: 't' } })
308+
).rejects.toMatchObject({ code: 'validation' })
309+
})
310+
311+
it('runs once every required user-only input is supplied', async () => {
312+
await expect(
313+
run({
314+
toolId: 'zendesk_get_ticket',
315+
input: { ticketId: '42', subdomain: 'acme', apiToken: 'tok' },
316+
})
317+
).resolves.toMatchObject({ status: 'succeeded' })
318+
})
319+
320+
/**
321+
* `firecrawl_scrape` declares `apiKey` required and `user-only`, and Sim
322+
* supplies it. Rejecting the omission would make every hosted-key tool
323+
* uncallable without a key the caller does not need to have.
324+
*/
325+
it('does not require a key the deployment hosts', async () => {
326+
await expect(run({ input: { url: 'https://example.com' } })).resolves.toMatchObject({
327+
status: 'succeeded',
328+
})
329+
})
330+
331+
/**
332+
* Self-hosted supplies no hosted keys — `injectHostedKeyIfNeeded` short-circuits
333+
* on `isHosted` — so the exemption must lift with it, or the caller is told a
334+
* key is optional and the provider disagrees.
335+
*/
336+
it('does require that key on a deployment that hosts none', async () => {
337+
setEnvFlags({ isHosted: false })
338+
339+
await expect(run({ input: { url: 'https://example.com' } })).rejects.toMatchObject({
340+
code: 'validation',
341+
message: expect.stringContaining('input.apiKey'),
342+
})
343+
})
344+
345+
it('accepts a {{VAR}} reference as a present value, leaving resolution to the executor', async () => {
346+
await run({
347+
toolId: 'zendesk_get_ticket',
348+
input: { ticketId: '4', subdomain: 'acme', apiToken: '{{ZENDESK_TOKEN}}' },
349+
})
350+
351+
const [, params] = mocks.executeRegistryTool.mock.calls[0]
352+
expect(params.apiToken).toBe('{{ZENDESK_TOKEN}}')
353+
})
354+
250355
it('requires a credential for an OAuth tool before it dispatches', async () => {
251356
await expect(run({ toolId: 'slack_message', input: { text: 'hi' } })).rejects.toMatchObject({
252357
code: 'validation',

apps/sim/lib/tool-execution/application/execute-tool.ts

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,13 @@ import {
1313
} from '@/lib/catalog/application/tool-scope'
1414
import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application'
1515
import { ForbiddenOperationError } from '@/lib/core/application/forbidden'
16+
import { isHosted } from '@/lib/core/config/env-flags'
1617
import { OrchestrationError } from '@/lib/core/orchestration/types'
1718
import { principalUserId } from '@/lib/integrations/principal-scope.server'
1819
import { toolExecutionOperations } from '@/lib/tool-execution/application/operations'
1920
import { executeTool as executeRegistryTool } from '@/tools'
20-
import { getToolMetadata } from '@/tools/metadata'
21+
import type { ExecutableToolConfig } from '@/tools/types'
22+
import { getTool } from '@/tools/utils'
2123

2224
const logger = createLogger('ExecuteToolUseCase')
2325

@@ -79,6 +81,50 @@ function assertNoInlineCredential(args: Record<string, unknown>): void {
7981
}
8082
}
8183

84+
/**
85+
* Refuses a call missing a required parameter only its caller can supply.
86+
*
87+
* `validateRequiredParametersAfterMerge` deliberately checks `user-or-llm`
88+
* alone, because on the workflow path a `user-only` parameter was already
89+
* validated during serialization against the block field that holds it. This
90+
* path has no serialization step, so nothing had checked them — a caller
91+
* omitting `zendesk_get_ticket`'s `subdomain` reached Zendesk as `undefined`
92+
* and came back a provider authentication failure, which is the same
93+
* undiagnosable shape this PR set out to remove.
94+
*
95+
* A parameter Sim itself supplies is not missing: `hosting` fills its
96+
* `apiKeyParam` on a key-hosting deployment, so `firecrawl_scrape` must stay
97+
* callable with no `apiKey`. The condition mirrors `injectHostedKeyIfNeeded`
98+
* exactly — same three tests, same order — so the two cannot disagree about
99+
* whether a value is coming.
100+
*/
101+
function assertRequiredCallerInputsPresent(
102+
tool: ExecutableToolConfig,
103+
toolId: string,
104+
params: Record<string, unknown>
105+
): void {
106+
const hostedKeyParam =
107+
isHosted && tool.hosting && (!tool.hosting.enabled || tool.hosting.enabled(params))
108+
? tool.hosting.apiKeyParam
109+
: undefined
110+
111+
const missing = Object.entries(tool.params ?? {})
112+
.filter(([name, declaration]) => {
113+
if (!declaration?.required || declaration.visibility !== 'user-only') return false
114+
if (name === hostedKeyParam) return false
115+
const value = params[name]
116+
return value === undefined || value === null || value === ''
117+
})
118+
.map(([name]) => name)
119+
120+
if (missing.length > 0) {
121+
throw new OrchestrationError(
122+
'validation',
123+
`${toolId} requires ${missing.map((name) => `input.${name}`).join(', ')}`
124+
)
125+
}
126+
}
127+
82128
/**
83129
* Runs one code-defined tool for an authenticated caller.
84130
*
@@ -136,13 +182,15 @@ export const executeToolForCaller = defineAuthorizedWorkspaceUseCase({
136182
)
137183
}
138184

139-
const metadata = getToolMetadata(toolId)
140-
if (metadata?.oauth?.required && !input.credentialId) {
185+
const tool = getTool(toolId)
186+
if (!tool) throw new OrchestrationError('not_found', 'Tool not found')
187+
if (tool.oauth?.required && !input.credentialId) {
141188
throw new OrchestrationError(
142189
'validation',
143-
`credentialId is required: ${toolId} authenticates with a ${metadata.oauth.provider} credential`
190+
`credentialId is required: ${toolId} authenticates with a ${tool.oauth.provider} credential`
144191
)
145192
}
193+
assertRequiredCallerInputsPresent(tool, toolId, input.input)
146194

147195
const userId = principalUserId(principal)
148196
if (!userId) {

0 commit comments

Comments
 (0)