Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 52 additions & 1 deletion apps/sim/app/api/copilot/tools/execute/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,16 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'

const { mockCheckInternalApiKey, mockPrepareEnvironmentContext, mockHandler } = vi.hoisted(() => ({
const {
mockCheckInternalApiKey,
mockPrepareEnvironmentContext,
mockHandler,
mockToolRequiresApprovalLane,
} = vi.hoisted(() => ({
mockCheckInternalApiKey: vi.fn(),
mockPrepareEnvironmentContext: vi.fn(),
mockHandler: vi.fn(),
mockToolRequiresApprovalLane: vi.fn().mockReturnValue(false),
}))

vi.mock('@/lib/copilot/request/http', () => ({
Expand All @@ -20,6 +26,7 @@ vi.mock('@/lib/copilot/environment-context', () => ({

vi.mock('@/lib/copilot/tool-executor', () => ({
ensureHandlersRegistered: vi.fn(),
toolRequiresApprovalLane: mockToolRequiresApprovalLane,
}))

vi.mock('@/lib/copilot/tool-executor/executor', () => ({
Expand Down Expand Up @@ -67,6 +74,7 @@ describe('POST /api/copilot/tools/execute (in-band)', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCheckInternalApiKey.mockReturnValue({ success: true })
mockToolRequiresApprovalLane.mockReturnValue(false)
// A fresh, complete registry per test: the module-level turn cache is keyed
// by messageId, so each test uses a distinct messageId to avoid cross-test
// cache hits.
Expand Down Expand Up @@ -161,6 +169,49 @@ describe('POST /api/copilot/tools/execute (in-band)', () => {
})
})

/**
* Whether a tool needs an approval-capable lane is decided by
* `toolRequiresApprovalLane` (covered against the real flag and catalog in
* the tool-executor router tests). What matters here is what the route does
* with that answer.
*/
describe('approval-gated tools', () => {
/**
* This lane cannot hold an approval prompt: the dispatch handler owns the gate and
* declines to dispatch in-band calls, so a gated tool arriving here has no waiter behind
* it. Refuse before running anything rather than execute on consent nobody gave.
*/
it('refuses a tool that needs an approval-capable lane, without executing it', async () => {
mockToolRequiresApprovalLane.mockReturnValue(true)
mockHandler.mockResolvedValue({ success: true, output: { ran: true } })

const res = await POST(
makeRequest({
...BASE_BODY,
toolName: 'run_function',
params: { code: 'return 1' },
messageId: 'msg-gated',
}) as never
)
const body = await res.json()

expect(mockHandler).not.toHaveBeenCalled()
expect(body.success).toBe(false)
expect(body.output).toEqual({ resultWithheld: true, effect: 'not_attempted' })
expect(body.error).toContain('requires user approval')
expect(body.error).toContain('checkpoint lane')
})

it('still runs a tool that does not need an approval-capable lane', async () => {
mockHandler.mockResolvedValue({ success: true, output: { content: 'hello' } })

const res = await POST(makeRequest({ ...BASE_BODY, messageId: 'msg-ungated' }) as never)

expect(mockHandler).toHaveBeenCalledTimes(1)
await expect(res.json()).resolves.toEqual({ success: true, output: { content: 'hello' } })
})
})

it('passes a failed generate_api_key call through with its error', async () => {
mockHandler.mockResolvedValue({ success: false, error: 'name is required' })
const res = await POST(
Expand Down
25 changes: 24 additions & 1 deletion apps/sim/app/api/copilot/tools/execute/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
} from '@/lib/copilot/request/tools/resolved-secret-result'
import { handleResourceSideEffects } from '@/lib/copilot/request/tools/resources'
import type { ToolCallResult } from '@/lib/copilot/request/types'
import { ensureHandlersRegistered } from '@/lib/copilot/tool-executor'
import { ensureHandlersRegistered, toolRequiresApprovalLane } from '@/lib/copilot/tool-executor'
import { executeTool } from '@/lib/copilot/tool-executor/executor'
import { TOOL_EFFECT_PHASE } from '@/lib/copilot/tool-executor/types'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
Expand Down Expand Up @@ -126,6 +126,29 @@ export const POST = withRouteHandler((request: NextRequest) =>
[TraceAttr.UserId]: userId,
})

/**
* Cheap admission, before any work: this lane cannot hold an approval prompt. The
* dispatch handler gates `requiresApproval` tools against a streaming context and a
* decision row, then deliberately declines to dispatch anything the mothership marks
* in-band — so a gated tool arriving here has no waiter behind it and would run on
* consent nobody gave. Refuse instead, and let the mothership take the checkpoint lane
* where the gate lives. Inert while copilot tool permissions are disabled, which keeps
* enabling the flag from silently leaving background lanes ungated.
*/
if (toolRequiresApprovalLane(toolName)) {
Comment thread
waleedlatif1 marked this conversation as resolved.
logger.warn('Refusing an approval-gated tool on the in-band lane', {
toolName,
toolCallId,
userId,
})
rootSpan.setAttributes({ [TraceAttr.ToolOutcome]: MothershipStreamV1ToolOutcome.error })
return NextResponse.json({
success: false,
error: `${toolName} was not run: it requires user approval, and this lane cannot hold an approval prompt. Dispatch it on the checkpoint lane instead.`,
output: { resultWithheld: true, effect: TOOL_EFFECT_PHASE.notAttempted },
})
}

let toolRegistry: ResolvedSecretTraceRegistry
let turnRegistry: ResolvedSecretTraceRegistry
try {
Expand Down
5 changes: 5 additions & 0 deletions apps/sim/lib/copilot/request/tools/permission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ export const TOOL_AWAITING_APPROVAL_STATUS = MothershipStreamV1ToolStatus.awaiti
*
* Headless one-shot executions are never gated: nobody is there to answer, and
* blocking them would hang the run until the orchestration timeout.
*
* This is the dispatch lane's answer, and it needs a streaming context. A lane
* that has none — the in-band route — asks `toolRequiresApprovalLane` instead,
* which lives beside the tool router so a caller needing only the predicate does
* not pull this module's permission pub/sub in with it.
*/
export function toolCallNeedsApproval(
toolName: string,
Expand Down
7 changes: 6 additions & 1 deletion apps/sim/lib/copilot/tool-executor/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
export { executeTool } from './executor'
export { ensureHandlersRegistered } from './register-handlers'
export { getToolEntry, isSimExecuted, toolRequiresApproval } from './router'
export {
getToolEntry,
isSimExecuted,
toolRequiresApproval,
toolRequiresApprovalLane,
} from './router'
35 changes: 34 additions & 1 deletion apps/sim/lib/copilot/tool-executor/router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
* @vitest-environment node
*/

import { describe, expect, it, vi } from 'vitest'
import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing/mocks/env-flags.mock'
import { afterEach, describe, expect, it, vi } from 'vitest'

/**
* The handler map is a wiring table from tool id to implementation. Only its
Expand Down Expand Up @@ -54,6 +55,7 @@ import {
getToolEntry,
isSimExecuted,
toolRequiresApproval,
toolRequiresApprovalLane,
} from '@/lib/copilot/tool-executor/router'
import { executeCancelWorkflowRun } from '@/lib/copilot/tools/handlers/workflow/mutations'

Expand All @@ -75,3 +77,34 @@ describe('workflow-run cancellation tool routing', () => {
expect(buildHandlerMap().cancel_workflow_run).toBe(executeCancelWorkflowRun)
})
})

describe('toolRequiresApprovalLane', () => {
afterEach(resetEnvFlagsMock)

/**
* Asked by lanes that cannot hold a prompt, so it answers from the catalog and the
* feature flag alone: there is no streaming context to consult, and the stored
* auto-allow list is deliberately not read (an auto-allowed tool is admitted on the
* checkpoint lane without prompting anyone).
*/
it('is false while copilot tool permissions are off, whatever the catalog says', () => {
expect(toolRequiresApproval('run_function')).toBe(true)
expect(toolRequiresApprovalLane('run_function')).toBe(false)
})

it('is true for a catalog-gated tool once the feature is on', () => {
setEnvFlags({ isCopilotToolPermissionsEnabled: true })
expect(toolRequiresApprovalLane('run_function')).toBe(true)
})

it('is false for a tool the catalog does not gate, feature on', () => {
setEnvFlags({ isCopilotToolPermissionsEnabled: true })
expect(toolRequiresApproval('read')).toBe(false)
expect(toolRequiresApprovalLane('read')).toBe(false)
})

it('is false for a tool that is not in the catalog at all', () => {
setEnvFlags({ isCopilotToolPermissionsEnabled: true })
expect(toolRequiresApprovalLane('not_a_real_tool')).toBe(false)
})
})
25 changes: 25 additions & 0 deletions apps/sim/lib/copilot/tool-executor/router.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { TOOL_CATALOG, type ToolCatalogEntry } from '@/lib/copilot/generated/tool-catalog-v1'
import { isCopilotToolPermissionsEnabled } from '@/lib/core/config/env-flags'

export function isToolInCatalog(toolId: string): boolean {
return toolId in TOOL_CATALOG
Expand All @@ -24,3 +25,27 @@ export function isKnownTool(toolId: string): boolean {
export function toolRequiresApproval(toolId: string): boolean {
return getToolEntry(toolId)?.requiresApproval === true
}

/**
* Whether a tool may only run on a lane that is able to hold an approval prompt.
*
* `toolCallNeedsApproval` answers for the dispatch lane, where a streaming
* context exists to gate against. The in-band route has neither a context nor a
* waiter — the mothership executes those calls itself — so it asks this instead,
* before running anything, and refuses rather than blocks: a background lane
* must never hang on a prompt with no row behind it.
*
* Lives here rather than beside the dispatch gate so that asking the question
* costs only the catalog. The gate module reaches the permission persistence
* layer, which opens a pub/sub channel when it loads.
*
* Deliberately blind to the stored auto-allow list. Consulting it here would add
* a database read to every in-band call to reach the same place by a longer
* route: an auto-allowed tool sent to the checkpoint lane is admitted there
* without prompting anyone. Refusing unconditionally keeps this fail-closed and
* leaves the one implementation of "has the user allowed this" on the lane that
* already owns it.
*/
export function toolRequiresApprovalLane(toolId: string): boolean {
return isCopilotToolPermissionsEnabled && toolRequiresApproval(toolId)
}
8 changes: 8 additions & 0 deletions apps/sim/lib/core/config/env-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,14 @@ export const isStatusNoticePreviewEnabled = isTruthy(getEnv('NEXT_PUBLIC_STATUS_
* used tools, so it is an opt-in change in how the product feels, not just a
* safety toggle. With it off nothing is stamped, gated, or persisted, and an
* approval stamp arriving from Go is cleared on the way to the client.
*
* The gate is a property of the lane, not only of the tool. Sim can hold a call
* for a decision on the dispatch lane, where a streaming context and a decision
* row exist. It cannot on the in-band route (`POST /api/copilot/tools/execute`),
* which the mothership drives for background lanes, so that route refuses a
* `requiresApproval` tool outright rather than running it ungated — see
* `toolRequiresApprovalLane`. Any new execution lane has to answer the same
* question before this flag is turned on.
*/
export const isCopilotToolPermissionsEnabled = isTruthy(env.COPILOT_TOOL_PERMISSIONS_ENABLED)

Expand Down
Loading