Skip to content

Commit 78426f9

Browse files
icecrasher321claude
andcommitted
fix(tools): address the cubic review round on direct tool execution
Two were real defects rather than polish: The usage ledger de-duplicates on `eventKey`, and the derived key hashes actor, workspace, source and description — identical for every call to the same tool. `onConflictDoNothing` therefore billed the first hosted-key call and silently dropped every one after it. A workflow run has an `executionId` to separate its rows; a direct call has nothing, so it now mints one. Undeclared input keys reached the executor. `impersonateUserEmail` is read straight out of params and forwarded to credential-token resolution as an impersonation request, and no tool declares it. The two ad-hoc denylists are replaced by a declared-parameter allowlist — the actual boundary, and the one `GET /api/v2/tools/{toolId}` already publishes. Also: renaming a canonical id orphans `data.canonicalModes`, and the backfill recovers it only when one side holds a value. `setBlockCanonicalMode` writes the mode without clearing its sibling, so a workflow holding both would have silently switched from the typed reference to the uploaded file. `migrateCanonicalModeIds` carries the key across, ahead of the backfill. Smaller: the reachability audit now mirrors execution exactly, exempting `credentialType` only under `authoritativeParams` and refusing to treat conditional hosting as a guarantee; the boundary guard splits on both path separators so it still excludes the execute route on Windows; the route stops documenting the 409 and 423 it cannot produce; and `sim tools execute` prints the output it promised in the human formats. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent fcbccd2 commit 78426f9

11 files changed

Lines changed: 295 additions & 68 deletions

File tree

apps/docs/openapi-v2-resources.json

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4104,18 +4104,12 @@
41044104
"404": {
41054105
"$ref": "#/components/responses/NotFound"
41064106
},
4107-
"409": {
4108-
"$ref": "#/components/responses/Conflict"
4109-
},
41104107
"413": {
41114108
"$ref": "#/components/responses/PayloadTooLarge"
41124109
},
41134110
"415": {
41144111
"$ref": "#/components/responses/UnsupportedMediaType"
41154112
},
4116-
"423": {
4117-
"$ref": "#/components/responses/Locked"
4118-
},
41194113
"429": {
41204114
"$ref": "#/components/responses/RateLimited"
41214115
},
@@ -4383,22 +4377,6 @@
43834377
}
43844378
}
43854379
},
4386-
"Locked": {
4387-
"description": "The resource is locked and cannot be modified.",
4388-
"content": {
4389-
"application/json": {
4390-
"schema": {
4391-
"$ref": "#/components/schemas/V2Error"
4392-
},
4393-
"example": {
4394-
"error": {
4395-
"code": "LOCKED",
4396-
"message": "Workflow is locked"
4397-
}
4398-
}
4399-
}
4400-
}
4401-
},
44024380
"RateLimited": {
44034381
"description": "The caller exceeded the request rate limit.",
44044382
"headers": {

apps/sim/lib/api/contracts/v2/openapi/resources.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@ import {
3838
RATE_LIMIT_HEADERS,
3939
RESOURCE_CONFLICT_ERRORS,
4040
RESOURCE_ERRORS,
41-
RESOURCE_MUTATION_ERRORS,
4241
V2_API_KEY_SECURITY,
4342
V2_API_KEY_SECURITY_SCHEMES,
4443
V2_COMMON_HEADERS,
@@ -1828,7 +1827,7 @@ const declaredRoutes = [
18281827
operationId: 'executeTool',
18291828
summary: 'Run Tool',
18301829
description: `Run one built-in tool and return what it produced. Supply \`input\` using the parameter ids \`GET /api/v2/tools/{toolId}\` publishes; Sim resolves the credential named by \`credentialId\`, injects a hosted API key for the tools it supplies one for, and substitutes environment-variable references, so the request carries arguments rather than secrets. A parameter the tool marks \`user-only\` also accepts \`{{VAR_NAME}}\` as its whole value, resolved server-side against the workspace environment; every other value is sent verbatim, so a literal secret passes through untouched. A tool that runs and refuses is a \`200\` carrying \`status: "failed"\` and the reason — the error envelope is reserved for failures of this API, not of the third party. A tool the workspace's visible blocks do not expose answers \`404\` identically to one that does not exist; one whose integration the workspace does not permit answers \`403\` with \`error.details.code\` \`INTEGRATION_NOT_ALLOWED\`. Hosted-key spend this call incurs is billed to the workspace. ${WORKSPACE_API_KEY_DENIED}`,
1831-
errors: RESOURCE_MUTATION_ERRORS,
1830+
errors: RESOURCE_ERRORS,
18321831
success: { description: 'The outcome of the tool call.' },
18331832
}),
18341833
{

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

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,7 @@ describe('executeToolForCaller', () => {
283283
})
284284

285285
it('resolves an unversioned name to the newest visible version', async () => {
286-
await expect(run({ toolId: 'confluence_read' })).resolves.toMatchObject({
286+
await expect(run({ toolId: 'confluence_read', input: {} })).resolves.toMatchObject({
287287
toolId: 'confluence_read_v2',
288288
})
289289
})
@@ -396,26 +396,76 @@ describe('executeToolForCaller', () => {
396396
})
397397

398398
it('refuses a reserved argument rather than dropping it', async () => {
399-
await expect(run({ input: { _context: { userId: 'someone-else' } } })).rejects.toMatchObject({
399+
await expect(
400+
run({ input: { url: 'https://a.co', _context: { userId: 'someone-else' } } })
401+
).rejects.toMatchObject({
400402
code: 'validation',
401403
message: expect.stringContaining('_context'),
402404
})
403405
expect(mocks.executeRegistryTool).not.toHaveBeenCalled()
404406
})
405407

406408
it('refuses a hosted-key flag smuggled in as an argument', async () => {
407-
await expect(run({ input: { __usingHostedKey: true } })).rejects.toMatchObject({
409+
await expect(
410+
run({ input: { url: 'https://a.co', __usingHostedKey: true } })
411+
).rejects.toMatchObject({
412+
code: 'validation',
413+
})
414+
})
415+
416+
/**
417+
* The executor reads this straight out of params and forwards it to
418+
* credential-token resolution as an impersonation request. No tool declares
419+
* it, which is why the check is a declared-parameter allowlist rather than a
420+
* list of names someone remembered.
421+
*/
422+
it('refuses an undeclared impersonation field', async () => {
423+
await expect(
424+
run({ input: { url: 'https://a.co', impersonateUserEmail: 'someone@example.com' } })
425+
).rejects.toMatchObject({
408426
code: 'validation',
427+
message: expect.stringContaining('impersonateUserEmail'),
428+
})
429+
expect(mocks.executeRegistryTool).not.toHaveBeenCalled()
430+
})
431+
432+
it('refuses any other undeclared input, naming it', async () => {
433+
await expect(run({ input: { url: 'https://a.co', nope: 1 } })).rejects.toMatchObject({
434+
code: 'validation',
435+
message: expect.stringContaining('input.nope'),
409436
})
410437
})
411438

412439
it('refuses a credential named inline instead of at the top level', async () => {
413-
await expect(run({ input: { credential: 'cred-1' } })).rejects.toMatchObject({
440+
await expect(
441+
run({ input: { url: 'https://a.co', credential: 'cred-1' } })
442+
).rejects.toMatchObject({
414443
code: 'validation',
415444
message: expect.stringContaining('credentialId'),
416445
})
417446
})
418447

448+
/**
449+
* The ledger de-duplicates on `eventKey`, and the derived key is a hash of
450+
* actor, workspace, source and description — identical for every call to the
451+
* same tool. Without a per-call id, `onConflictDoNothing` billed the first
452+
* hosted-key call and silently dropped every one after it.
453+
*/
454+
it('gives each call its own ledger event so repeat calls all bill', async () => {
455+
mocks.executeRegistryTool.mockResolvedValue({
456+
success: true,
457+
output: { cost: { total: 0.004 } },
458+
})
459+
460+
await run()
461+
await run()
462+
463+
const keys = mocks.recordUsage.mock.calls.map((call) => call[0].entries[0].eventKey)
464+
expect(keys).toHaveLength(2)
465+
expect(keys[0]).toBeTruthy()
466+
expect(keys[0]).not.toBe(keys[1])
467+
})
468+
419469
it('refuses a workspace API key: the call runs under a person or not at all', async () => {
420470
await expect(
421471
executeToolForCaller.execute({

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

Lines changed: 47 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createLogger } from '@sim/logger'
22
import { getErrorMessage } from '@sim/utils/errors'
3+
import { generateId } from '@sim/utils/id'
34
import { resolveBillingAttribution, toBillingContext } from '@/lib/billing/core/billing-attribution'
45
import { recordUsage } from '@/lib/billing/core/usage-log'
56
import {
@@ -41,44 +42,46 @@ export interface ExecuteToolResult {
4142
}
4243

4344
/**
44-
* Keys the caller may not set, because the execution path assigns them.
45+
* Refuses an input key the tool does not declare.
4546
*
46-
* `_context` carries the acting identity and `enforceCredentialAccess`, and the
47-
* `__`-prefixed fields are the reserved transient channel `stripInternalFields`
48-
* documents — `__usingHostedKey` among them, which decides whether a tool bills
49-
* its call as hosted spend. No tool in the registry declares a parameter
50-
* starting with `_`, so one rule covers both and cannot collide with a real
51-
* argument. Refused rather than silently dropped: a caller who believed it set
52-
* something must not be told the call succeeded as sent.
53-
*/
54-
function assertNoReservedArguments(args: Record<string, unknown>): void {
55-
const reserved = Object.keys(args).find((key) => key.startsWith('_'))
56-
if (reserved) {
57-
throw new OrchestrationError(
58-
'validation',
59-
`input.${reserved} is reserved and cannot be supplied; Sim sets it from the authenticated caller`
60-
)
61-
}
62-
}
63-
64-
/**
65-
* Credential fields are named once, at the top level.
47+
* Strict rather than a denylist, because the denylist was already wrong twice
48+
* over. `_context` carries the acting identity and `enforceCredentialAccess`;
49+
* the `__`-prefixed fields are the reserved transient channel, `__usingHostedKey`
50+
* among them, which decides whether a call bills as hosted spend; and
51+
* `impersonateUserEmail` is read straight out of params by the executor and
52+
* forwarded to credential-token resolution as an impersonation request. Naming
53+
* those three is guesswork about a surface that keeps growing — a declared
54+
* parameter list is the actual boundary, and it is what
55+
* `GET /api/v2/tools/{toolId}` already publishes.
6656
*
67-
* The registry accepts three spellings of the same selection — `credential`,
68-
* `credentialId`, `oauthCredential` — which is fine inside the executor, where
69-
* one caller writes one of them, and wrong on a public contract, where three
70-
* spellings with undefined precedence is a shape no client can reason about.
57+
* Also collapses the credential spellings. The executor accepts `credential`,
58+
* `credentialId` and `oauthCredential` interchangeably, which is fine where one
59+
* caller writes one of them and wrong on a public contract: three spellings with
60+
* undefined precedence is a shape no client can reason about. The credential is
61+
* named once, at the top level.
7162
*/
72-
const CREDENTIAL_ARGUMENT_ALIASES = ['credential', 'credentialId', 'oauthCredential'] as const
63+
function assertNoUndeclaredInputs(
64+
tool: ExecutableToolConfig,
65+
toolId: string,
66+
args: Record<string, unknown>
67+
): void {
68+
const undeclared = Object.keys(args).filter((key) => !Object.hasOwn(tool.params ?? {}, key))
69+
if (undeclared.length === 0) return
7370

74-
function assertNoInlineCredential(args: Record<string, unknown>): void {
75-
const alias = CREDENTIAL_ARGUMENT_ALIASES.find((key) => key in args)
76-
if (alias) {
71+
const credentialAlias = undeclared.find((key) =>
72+
['credential', 'credentialId', 'oauthCredential'].includes(key)
73+
)
74+
if (credentialAlias) {
7775
throw new OrchestrationError(
7876
'validation',
79-
`input.${alias} is not accepted; pass the credential as the top-level credentialId field`
77+
`input.${credentialAlias} is not accepted; pass the credential as the top-level credentialId field`
8078
)
8179
}
80+
81+
throw new OrchestrationError(
82+
'validation',
83+
`${toolId} does not accept ${undeclared.map((key) => `input.${key}`).join(', ')}`
84+
)
8285
}
8386

8487
/**
@@ -171,9 +174,6 @@ export const executeToolForCaller = defineAuthorizedWorkspaceUseCase({
171174
loadCatalogWorkspaceContext(input.workspaceId),
172175
authorizationOptions: {},
173176
execute: async ({ principal, input, context }): Promise<ExecuteToolResult> => {
174-
assertNoReservedArguments(input.input)
175-
assertNoInlineCredential(input.input)
176-
177177
const gate = await resolveCatalogGate(principal, context)
178178

179179
/**
@@ -205,6 +205,7 @@ export const executeToolForCaller = defineAuthorizedWorkspaceUseCase({
205205
`credentialId is required: ${toolId} authenticates with a ${tool.oauth.provider} credential`
206206
)
207207
}
208+
assertNoUndeclaredInputs(tool, toolId, input.input)
208209
assertRequiredCallerInputsPresent(tool, toolId, input.input)
209210

210211
const userId = principalUserId(principal)
@@ -234,6 +235,16 @@ export const executeToolForCaller = defineAuthorizedWorkspaceUseCase({
234235
},
235236
}
236237

238+
/**
239+
* The ledger de-duplicates on `eventKey`, and the derived key is a hash of
240+
* actor, workspace, source and description — identical for every call to the
241+
* same tool. Without a per-call id `onConflictDoNothing` silently billed the
242+
* first hosted-key call and nothing after it. A workflow run has an
243+
* `executionId` to distinguish its rows; a direct call has nothing, so it
244+
* mints one.
245+
*/
246+
const callId = generateId()
247+
237248
const result = await executeRegistryTool(toolId, params, {
238249
signal: AbortSignal.timeout((input.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS) * 1000),
239250
operationContext: {
@@ -251,6 +262,7 @@ export const executeToolForCaller = defineAuthorizedWorkspaceUseCase({
251262
})
252263

253264
await meterHostedKeySpend({
265+
callId,
254266
toolId,
255267
userId,
256268
workspaceId: context.workspaceId,
@@ -282,6 +294,7 @@ export const executeToolForCaller = defineAuthorizedWorkspaceUseCase({
282294
* `applyHostedKeyCostToResult` makes one layer down.
283295
*/
284296
async function meterHostedKeySpend(args: {
297+
callId: string
285298
toolId: string
286299
userId: string
287300
workspaceId: string
@@ -304,6 +317,7 @@ async function meterHostedKeySpend(args: {
304317
source: 'api-tool',
305318
description: `Tool call: ${args.toolId}`,
306319
cost,
320+
eventKey: args.callId,
307321
},
308322
],
309323
})

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import { defineWorkspaceOperation } from '@/lib/core/application'
2828
* kinds ahead of a surface that uses them.
2929
*/
3030
export const toolExecutionOperations = {
31-
// permission-group-exempt: no static capability names running one built-in tool — the per-tool denial is the deniedTools key, applied inside @/tools against the resolved id, and the per-integration denial is the parameterized allowedIntegrations key, which the funnel cannot apply because it never sees which integration a tool id reaches. That decision is enforced from the use case by the owning-block-type check in executeToolForCaller, ahead of dispatch.
31+
// permission-group-exempt: declares capability: 'none' because no static capability names running one built-in tool — the per-tool denial is the deniedTools key, applied inside @/tools against the resolved id, and the per-integration denial is the parameterized allowedIntegrations key, which the funnel cannot apply because it never sees which integration a tool id reaches. That decision is enforced from the use case by the owning-block-type check in executeToolForCaller, ahead of dispatch.
3232
execute: defineWorkspaceOperation({
3333
id: 'tools.execute',
3434
minimumRole: 'write',

apps/sim/lib/workflows/migrations/subblock-migrations.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import * as blocksBarrel from '@/blocks'
1111
import { getBlock as getRealBlock } from '@/blocks/registry'
1212
import {
1313
backfillCanonicalModes,
14+
migrateCanonicalModeIds,
1415
migrateSubblockIds,
1516
SUBBLOCK_ID_MIGRATIONS,
1617
} from './subblock-migrations'
@@ -745,6 +746,73 @@ describe('migrateSubblockIds', () => {
745746
})
746747
})
747748

749+
describe('migrateCanonicalModeIds', () => {
750+
function mistralBlock(data: Record<string, unknown>, subBlocks: Record<string, unknown>) {
751+
return makeBlock({ type: 'mistral_parse_v3', data, subBlocks } as never)
752+
}
753+
754+
it('carries the selection across the document -> file rename', () => {
755+
const { blocks, migrated } = migrateCanonicalModeIds({
756+
b1: mistralBlock({ canonicalModes: { document: 'advanced' } }, {}),
757+
})
758+
759+
expect(migrated).toBe(true)
760+
const modes = blocks.b1.data?.canonicalModes as Record<string, string>
761+
expect(modes).toEqual({ file: 'advanced' })
762+
})
763+
764+
/**
765+
* The case the backfill alone cannot recover. `setBlockCanonicalMode` writes
766+
* the mode without clearing the sibling, so a workflow that uploaded a file,
767+
* switched to advanced, then typed a reference holds both values — and
768+
* `resolveCanonicalMode` prefers basic whenever the basic side is populated.
769+
* Without the rename the run would silently switch to the uploaded file.
770+
*/
771+
it('preserves advanced when both sides hold a value, which the backfill would not', () => {
772+
const both = {
773+
fileUpload: { id: 'fileUpload', type: 'file-upload', value: { name: 'a.pdf' } },
774+
fileReference: { id: 'fileReference', type: 'short-input', value: '<block.file>' },
775+
}
776+
777+
const { blocks } = migrateCanonicalModeIds({
778+
b1: mistralBlock({ canonicalModes: { document: 'advanced' } }, both),
779+
})
780+
expect((blocks.b1.data?.canonicalModes as Record<string, string>).file).toBe('advanced')
781+
782+
// Same input through the backfill alone resolves to basic — the regression
783+
// this migration exists to prevent.
784+
const { blocks: backfilled } = backfillCanonicalModes({
785+
b1: mistralBlock({ canonicalModes: {} }, both),
786+
})
787+
expect((backfilled.b1.data?.canonicalModes as Record<string, string>).file).toBe('basic')
788+
})
789+
790+
it('leaves a block that already stores the current id alone', () => {
791+
const { blocks, migrated } = migrateCanonicalModeIds({
792+
b1: mistralBlock({ canonicalModes: { file: 'basic' } }, {}),
793+
})
794+
795+
expect(migrated).toBe(false)
796+
expect(blocks.b1.data?.canonicalModes).toEqual({ file: 'basic' })
797+
})
798+
799+
it('prefers a value already written under the current id over the legacy one', () => {
800+
const { blocks } = migrateCanonicalModeIds({
801+
b1: mistralBlock({ canonicalModes: { document: 'advanced', file: 'basic' } }, {}),
802+
})
803+
804+
expect(blocks.b1.data?.canonicalModes).toEqual({ file: 'basic' })
805+
})
806+
807+
it('does not touch a block type with no canonical rename', () => {
808+
const { migrated } = migrateCanonicalModeIds({
809+
b1: makeBlock({ type: 'knowledge', data: { canonicalModes: { document: 'advanced' } } }),
810+
})
811+
812+
expect(migrated).toBe(false)
813+
})
814+
})
815+
748816
describe('backfillCanonicalModes', () => {
749817
it('should add missing canonicalModes entry for knowledge block with basic value', () => {
750818
const input: Record<string, BlockState> = {

0 commit comments

Comments
 (0)