Skip to content

Commit 6a0ce48

Browse files
icecrasher321claude
andcommitted
fix(copilot): bar the headless client-tool fallback below write
Client-routed tools carry no catalog requiredPermission because the browser runs them through the workflow APIs, which authorize the caller's own session. The headless fallback in executeTool has no session and runs under the request's principal instead, with nothing standing in for that check. So the read cap from the previous commit did not reach run_workflow, run_workflow_until_block, run_block or run_from_block: all four are route 'client' with no requiredPermission, unlike create_workflow and edit_workflow. An unattributed inbox sender could therefore still run an existing workflow, which executes with enforceCredentialAccess under the workspace owner and resolves the owner's workspace and personal secrets. Derive the requirement at the gate instead: a client-routed tool taking the headless fallback requires write. Interactive callers never reach this branch, so the browser path is unaffected. The catalog itself is generated from the copilot contracts repo and cannot carry this rule, which only applies to the fallback. Also corrects the inboxToolPermission doc, which claimed run_workflow gates on requiredPermission 'write'. It does not; it is gated here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 17c049a commit 6a0ce48

3 files changed

Lines changed: 61 additions & 11 deletions

File tree

apps/sim/lib/copilot/tool-executor/executor.test.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,14 +242,48 @@ describe('copilot tool executor fallback', () => {
242242
const runWorkflowHandler = vi.fn().mockResolvedValue({ success: true, output: { ran: true } })
243243
registerHandler('run_workflow', runWorkflowHandler)
244244

245-
const context = { userId: 'user-1', workflowId: 'workflow-1', workspaceId: 'ws-1' }
245+
const context = {
246+
userId: 'user-1',
247+
workflowId: 'workflow-1',
248+
workspaceId: 'ws-1',
249+
userPermission: 'write',
250+
}
246251
const result = await executeTool('run_workflow', { workflow_input: {} }, context)
247252

248253
expect(runWorkflowHandler).toHaveBeenCalledWith({ workflow_input: {} }, context)
249254
expect(executeAppTool).not.toHaveBeenCalled()
250255
expect(result).toEqual({ success: true, output: { ran: true } })
251256
})
252257

258+
/**
259+
* `run_workflow` carries no catalog permission — the browser path authorizes it through the
260+
* workflow APIs against the caller's own session. The headless fallback has no session, so
261+
* without a bar of its own a deliberately capped run (an unattributed inbox message) would
262+
* still execute workflows under the principal it was capped away from.
263+
*/
264+
it.each([['read'], [undefined]] as const)(
265+
'refuses the headless client fallback for a %s permission',
266+
async (userPermission) => {
267+
isKnownTool.mockReturnValue(true)
268+
isSimExecuted.mockReturnValue(false)
269+
isClientExecuted.mockReturnValue(true)
270+
271+
const runWorkflowHandler = vi.fn().mockResolvedValue({ success: true })
272+
registerHandler('run_workflow', runWorkflowHandler)
273+
274+
const result = await executeTool(
275+
'run_workflow',
276+
{ workflow_input: {} },
277+
{ userId: 'user-1', workflowId: 'workflow-1', workspaceId: 'ws-1', userPermission }
278+
)
279+
280+
expect(result.success).toBe(false)
281+
expect(result.error).toContain('requires write access')
282+
expect(runWorkflowHandler).not.toHaveBeenCalled()
283+
expect(executeAppTool).not.toHaveBeenCalled()
284+
}
285+
)
286+
253287
it('falls back to app tool executor for client-routed tools with no registered handler', async () => {
254288
isKnownTool.mockReturnValue(true)
255289
isSimExecuted.mockReturnValue(false)

apps/sim/lib/copilot/tool-executor/executor.ts

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,26 @@ export async function executeTool(
3838
params: Record<string, unknown>,
3939
context: ToolExecutionContext
4040
): Promise<ToolExecutionResult> {
41-
const requiredPermission = getToolEntry(toolId)?.requiredPermission
41+
// Client-routed tools (e.g. run_workflow) are normally executed in the browser and never
42+
// reach this point in interactive mode. In headless mode (Mothership block, no browser) there
43+
// is no client to delegate to, so fall back to the registered server-side handler when one
44+
// exists — otherwise the call would route to executeAppTool and throw "Tool not found".
45+
const usesHeadlessClientFallback = isClientExecuted(toolId) && hasHandler(toolId)
46+
47+
/**
48+
* Client-routed tools carry no catalog `requiredPermission` because the browser runs them
49+
* through the workflow APIs, which authorize the caller's own session. The headless fallback
50+
* has no session to authorize against and runs under the request's principal instead, so it
51+
* has to supply a bar of its own.
52+
*
53+
* Without one, a run whose permission was deliberately capped still reaches `run_workflow`,
54+
* and `runWorkflowFromCopilot` executes with `enforceCredentialAccess` — resolving the
55+
* principal's workspace and personal secrets. That is the hole an unattributed inbox message
56+
* leaves open: `resolveInboxExecutionActor` refuses it a secret actor, but the run still
57+
* carries the workspace owner as principal.
58+
*/
59+
const requiredPermission =
60+
getToolEntry(toolId)?.requiredPermission ?? (usesHeadlessClientFallback ? 'write' : undefined)
4261
if (
4362
requiredPermission &&
4463
!permissionSatisfies(
@@ -54,13 +73,8 @@ export async function executeTool(
5473

5574
const normalizedParams = normalizeToolParams(toolId, params, context)
5675

57-
// Client-routed tools (e.g. run_workflow) are normally executed in the browser and never
58-
// reach this point in interactive mode. In headless mode (Mothership block, no browser) there
59-
// is no client to delegate to, so fall back to the registered server-side handler when one
60-
// exists — otherwise the call would route to executeAppTool and throw "Tool not found".
6176
const canUseRegisteredHandler =
62-
isKnownTool(toolId) &&
63-
(isSimExecuted(toolId) || (isClientExecuted(toolId) && hasHandler(toolId)))
77+
isKnownTool(toolId) && (isSimExecuted(toolId) || usesHeadlessClientFallback)
6478
if (!canUseRegisteredHandler) {
6579
const appParams = buildAppToolParams(normalizedParams, context)
6680
const options = {

apps/sim/lib/mothership/inbox/executor.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -370,9 +370,11 @@ interface InboxExecutionActor {
370370
* An unattributed message resolves to the workspace owner so the run has a real
371371
* user for billing and workspace reads, and the owner is typically an admin. Left
372372
* alone, that hands an allowlisted external correspondent the owner's write
373-
* authority: `create_workflow`, `edit_workflow` and `run_workflow` all gate on
374-
* `requiredPermission: 'write'`, and a workflow built and run through them executes
375-
* with `enforceCredentialAccess`, resolving the owner's workspace *and personal*
373+
* authority: `create_workflow` and `edit_workflow` gate on
374+
* `requiredPermission: 'write'`, and `run_workflow` is gated by the headless
375+
* client-fallback bar in `executeTool` — it carries no catalog permission of its
376+
* own. A workflow built or run through any of them executes with
377+
* `enforceCredentialAccess`, resolving the owner's workspace *and personal*
376378
* secrets. That is the same reach `secretActorUserId: null` already refuses for a
377379
* direct mount, so refusing it here keeps one answer rather than two.
378380
*

0 commit comments

Comments
 (0)