Skip to content

Commit 2986e56

Browse files
committed
improvement(copilot): move the approval-lane predicate beside the tool router
Importing the dispatch gate module for a one-line predicate pulled the permission persistence layer in with it, whose module body opens a pub/sub channel — two Redis clients and a channel subscription — in every process that loads the in-band route. Move toolRequiresApprovalLane to tool-executor/router.ts, which imports only the catalog. The route already imported @/lib/copilot/tool-executor for ensureHandlersRegistered, so the guard now costs no new import edge at all. The dispatch gate keeps a pointer to it. Its flag-and-catalog behavior is covered in the router tests against the real flag and the real catalog; the route tests keep to what the route does with the answer.
1 parent 87e6419 commit 2986e56

7 files changed

Lines changed: 86 additions & 94 deletions

File tree

‎apps/sim/app/api/copilot/tools/execute/route.test.ts‎

Lines changed: 14 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,19 @@
11
/**
22
* @vitest-environment node
33
*/
4-
import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing/mocks/env-flags.mock'
5-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
65
import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
76

87
const {
98
mockCheckInternalApiKey,
109
mockPrepareEnvironmentContext,
1110
mockHandler,
12-
mockToolRequiresApproval,
11+
mockToolRequiresApprovalLane,
1312
} = vi.hoisted(() => ({
1413
mockCheckInternalApiKey: vi.fn(),
1514
mockPrepareEnvironmentContext: vi.fn(),
1615
mockHandler: vi.fn(),
17-
mockToolRequiresApproval: vi.fn().mockReturnValue(false),
16+
mockToolRequiresApprovalLane: vi.fn().mockReturnValue(false),
1817
}))
1918

2019
vi.mock('@/lib/copilot/request/http', () => ({
@@ -27,7 +26,7 @@ vi.mock('@/lib/copilot/environment-context', () => ({
2726

2827
vi.mock('@/lib/copilot/tool-executor', () => ({
2928
ensureHandlersRegistered: vi.fn(),
30-
toolRequiresApproval: mockToolRequiresApproval,
29+
toolRequiresApprovalLane: mockToolRequiresApprovalLane,
3130
}))
3231

3332
vi.mock('@/lib/copilot/tool-executor/executor', () => ({
@@ -75,7 +74,7 @@ describe('POST /api/copilot/tools/execute (in-band)', () => {
7574
beforeEach(() => {
7675
vi.clearAllMocks()
7776
mockCheckInternalApiKey.mockReturnValue({ success: true })
78-
mockToolRequiresApproval.mockReturnValue(false)
77+
mockToolRequiresApprovalLane.mockReturnValue(false)
7978
// A fresh, complete registry per test: the module-level turn cache is keyed
8079
// by messageId, so each test uses a distinct messageId to avoid cross-test
8180
// cache hits.
@@ -170,17 +169,20 @@ describe('POST /api/copilot/tools/execute (in-band)', () => {
170169
})
171170
})
172171

172+
/**
173+
* Whether a tool needs an approval-capable lane is decided by
174+
* `toolRequiresApprovalLane` (covered against the real flag and catalog in
175+
* the tool-executor router tests). What matters here is what the route does
176+
* with that answer.
177+
*/
173178
describe('approval-gated tools', () => {
174-
afterEach(resetEnvFlagsMock)
175-
176179
/**
177180
* This lane cannot hold an approval prompt: the dispatch handler owns the gate and
178181
* declines to dispatch in-band calls, so a gated tool arriving here has no waiter behind
179182
* it. Refuse before running anything rather than execute on consent nobody gave.
180183
*/
181-
it('refuses an approval-gated tool without executing it when permissions are enabled', async () => {
182-
setEnvFlags({ isCopilotToolPermissionsEnabled: true })
183-
mockToolRequiresApproval.mockReturnValue(true)
184+
it('refuses a tool that needs an approval-capable lane, without executing it', async () => {
185+
mockToolRequiresApprovalLane.mockReturnValue(true)
184186
mockHandler.mockResolvedValue({ success: true, output: { ran: true } })
185187

186188
const res = await POST(
@@ -200,36 +202,14 @@ describe('POST /api/copilot/tools/execute (in-band)', () => {
200202
expect(body.error).toContain('checkpoint lane')
201203
})
202204

203-
it('still runs a tool the catalog does not gate when permissions are enabled', async () => {
204-
setEnvFlags({ isCopilotToolPermissionsEnabled: true })
205+
it('still runs a tool that does not need an approval-capable lane', async () => {
205206
mockHandler.mockResolvedValue({ success: true, output: { content: 'hello' } })
206207

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

209210
expect(mockHandler).toHaveBeenCalledTimes(1)
210211
await expect(res.json()).resolves.toEqual({ success: true, output: { content: 'hello' } })
211212
})
212-
213-
/**
214-
* The guard is inert while the feature is off, which is the state this ships in — enabling
215-
* the flag is what makes it bite, so it cannot change in-band behavior today.
216-
*/
217-
it('runs an approval-gated tool unchanged while permissions are disabled', async () => {
218-
mockToolRequiresApproval.mockReturnValue(true)
219-
mockHandler.mockResolvedValue({ success: true, output: { ran: true } })
220-
221-
const res = await POST(
222-
makeRequest({
223-
...BASE_BODY,
224-
toolName: 'run_function',
225-
params: { code: 'return 1' },
226-
messageId: 'msg-gated-flag-off',
227-
}) as never
228-
)
229-
230-
expect(mockHandler).toHaveBeenCalledTimes(1)
231-
await expect(res.json()).resolves.toEqual({ success: true, output: { ran: true } })
232-
})
233213
})
234214

235215
it('passes a failed generate_api_key call through with its error', async () => {

‎apps/sim/app/api/copilot/tools/execute/route.ts‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,14 @@ import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
1010
import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1'
1111
import { checkInternalApiKey } from '@/lib/copilot/request/http'
1212
import { withIncomingGoSpan } from '@/lib/copilot/request/otel'
13-
import { toolRequiresApprovalLane } from '@/lib/copilot/request/tools/permission'
1413
import {
1514
describeWithholdingCause,
1615
inspectToolResultForCopilot,
1716
projectToolErrorMessageForCopilot,
1817
} from '@/lib/copilot/request/tools/resolved-secret-result'
1918
import { handleResourceSideEffects } from '@/lib/copilot/request/tools/resources'
2019
import type { ToolCallResult } from '@/lib/copilot/request/types'
21-
import { ensureHandlersRegistered } from '@/lib/copilot/tool-executor'
20+
import { ensureHandlersRegistered, toolRequiresApprovalLane } from '@/lib/copilot/tool-executor'
2221
import { executeTool } from '@/lib/copilot/tool-executor/executor'
2322
import { TOOL_EFFECT_PHASE } from '@/lib/copilot/tool-executor/types'
2423
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'

‎apps/sim/lib/copilot/request/tools/permission.test.ts‎

Lines changed: 1 addition & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@
22
* @vitest-environment node
33
*/
44

5-
import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing/mocks/env-flags.mock'
6-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
76
import { TraceCollector } from '@/lib/copilot/request/trace'
87

98
const { toolRequiresApproval, waitForToolPermissionDecision } = vi.hoisted(() => ({
@@ -29,7 +28,6 @@ import { createStreamingContext } from '@/lib/copilot/request/context/request-co
2928
import {
3029
runGatedToolExecution,
3130
toolCallNeedsApproval,
32-
toolRequiresApprovalLane,
3331
} from '@/lib/copilot/request/tools/permission'
3432
import type { StreamEvent, ToolCallState } from '@/lib/copilot/request/types'
3533

@@ -75,38 +73,6 @@ function gate(
7573
)
7674
}
7775

78-
describe('toolRequiresApprovalLane', () => {
79-
// vi.clearAllMocks() clears calls but not implementations, so a mockReturnValue
80-
// set here would otherwise outlive this block and silently flip the suites below.
81-
afterEach(() => {
82-
resetEnvFlagsMock()
83-
toolRequiresApproval.mockReturnValue(true)
84-
})
85-
86-
/**
87-
* Asked by lanes that cannot hold a prompt, so it answers from the catalog and the
88-
* feature flag alone — there is no streaming context to consult, and the stored
89-
* auto-allow list is deliberately not read (an auto-allowed tool is admitted on the
90-
* checkpoint lane without prompting anyone).
91-
*/
92-
it('is false while the feature is off, whatever the catalog says', () => {
93-
toolRequiresApproval.mockReturnValue(true)
94-
expect(toolRequiresApprovalLane('run_function')).toBe(false)
95-
})
96-
97-
it('is true for a catalog-gated tool once the feature is on', () => {
98-
setEnvFlags({ isCopilotToolPermissionsEnabled: true })
99-
toolRequiresApproval.mockReturnValue(true)
100-
expect(toolRequiresApprovalLane('run_function')).toBe(true)
101-
})
102-
103-
it('is false for an ungated tool once the feature is on', () => {
104-
setEnvFlags({ isCopilotToolPermissionsEnabled: true })
105-
toolRequiresApproval.mockReturnValue(false)
106-
expect(toolRequiresApprovalLane('read')).toBe(false)
107-
})
108-
})
109-
11076
describe('toolCallNeedsApproval', () => {
11177
const runCall = { operation: 'run', args: { command: 'ls' } }
11278

‎apps/sim/lib/copilot/request/tools/permission.ts‎

Lines changed: 5 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ import type {
2727
ToolCallState,
2828
} from '@/lib/copilot/request/types'
2929
import { getToolEntry, toolRequiresApproval } from '@/lib/copilot/tool-executor'
30-
import { isCopilotToolPermissionsEnabled } from '@/lib/core/config/env-flags'
3130

3231
const logger = createLogger('CopilotToolPermissionGate')
3332

@@ -52,31 +51,16 @@ const PERMISSION_WAIT_TIMEOUT_MS = ORCHESTRATION_TIMEOUT_MS
5251

5352
export const TOOL_AWAITING_APPROVAL_STATUS = MothershipStreamV1ToolStatus.awaiting_approval
5453

55-
/**
56-
* Whether a tool may only run on a lane that is able to hold an approval prompt.
57-
*
58-
* `toolCallNeedsApproval` answers for the dispatch lane, where a streaming
59-
* context exists to gate against. The in-band route has neither a context nor a
60-
* waiter — the mothership executes those calls itself — so it asks this instead,
61-
* before running anything, and refuses rather than blocks: a background lane
62-
* must never hang on a prompt with no row behind it.
63-
*
64-
* Deliberately blind to the stored auto-allow list. Consulting it here would add
65-
* a database read to every in-band call to reach the same place by a longer
66-
* route: an auto-allowed tool sent to the checkpoint lane is admitted there
67-
* without prompting anyone. Refusing unconditionally keeps this fail-closed and
68-
* leaves the one implementation of "has the user allowed this" on the lane that
69-
* already owns it.
70-
*/
71-
export function toolRequiresApprovalLane(toolName: string): boolean {
72-
return isCopilotToolPermissionsEnabled && toolRequiresApproval(toolName)
73-
}
74-
7554
/**
7655
* Whether this call must be held for an explicit user decision.
7756
*
7857
* Headless one-shot executions are never gated: nobody is there to answer, and
7958
* blocking them would hang the run until the orchestration timeout.
59+
*
60+
* This is the dispatch lane's answer, and it needs a streaming context. A lane
61+
* that has none — the in-band route — asks `toolRequiresApprovalLane` instead,
62+
* which lives beside the tool router so a caller needing only the predicate does
63+
* not pull this module's permission pub/sub in with it.
8064
*/
8165
export function toolCallNeedsApproval(
8266
toolName: string,
Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
11
export { executeTool } from './executor'
22
export { ensureHandlersRegistered } from './register-handlers'
3-
export { getToolEntry, isSimExecuted, toolRequiresApproval } from './router'
3+
export {
4+
getToolEntry,
5+
isSimExecuted,
6+
toolRequiresApproval,
7+
toolRequiresApprovalLane,
8+
} from './router'

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

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
* @vitest-environment node
33
*/
44

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

78
/**
89
* The handler map is a wiring table from tool id to implementation. Only its
@@ -54,6 +55,7 @@ import {
5455
getToolEntry,
5556
isSimExecuted,
5657
toolRequiresApproval,
58+
toolRequiresApprovalLane,
5759
} from '@/lib/copilot/tool-executor/router'
5860
import { executeCancelWorkflowRun } from '@/lib/copilot/tools/handlers/workflow/mutations'
5961

@@ -75,3 +77,34 @@ describe('workflow-run cancellation tool routing', () => {
7577
expect(buildHandlerMap().cancel_workflow_run).toBe(executeCancelWorkflowRun)
7678
})
7779
})
80+
81+
describe('toolRequiresApprovalLane', () => {
82+
afterEach(resetEnvFlagsMock)
83+
84+
/**
85+
* Asked by lanes that cannot hold a prompt, so it answers from the catalog and the
86+
* feature flag alone: there is no streaming context to consult, and the stored
87+
* auto-allow list is deliberately not read (an auto-allowed tool is admitted on the
88+
* checkpoint lane without prompting anyone).
89+
*/
90+
it('is false while copilot tool permissions are off, whatever the catalog says', () => {
91+
expect(toolRequiresApproval('run_function')).toBe(true)
92+
expect(toolRequiresApprovalLane('run_function')).toBe(false)
93+
})
94+
95+
it('is true for a catalog-gated tool once the feature is on', () => {
96+
setEnvFlags({ isCopilotToolPermissionsEnabled: true })
97+
expect(toolRequiresApprovalLane('run_function')).toBe(true)
98+
})
99+
100+
it('is false for a tool the catalog does not gate, feature on', () => {
101+
setEnvFlags({ isCopilotToolPermissionsEnabled: true })
102+
expect(toolRequiresApproval('read')).toBe(false)
103+
expect(toolRequiresApprovalLane('read')).toBe(false)
104+
})
105+
106+
it('is false for a tool that is not in the catalog at all', () => {
107+
setEnvFlags({ isCopilotToolPermissionsEnabled: true })
108+
expect(toolRequiresApprovalLane('not_a_real_tool')).toBe(false)
109+
})
110+
})

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { TOOL_CATALOG, type ToolCatalogEntry } from '@/lib/copilot/generated/tool-catalog-v1'
2+
import { isCopilotToolPermissionsEnabled } from '@/lib/core/config/env-flags'
23

34
export function isToolInCatalog(toolId: string): boolean {
45
return toolId in TOOL_CATALOG
@@ -24,3 +25,27 @@ export function isKnownTool(toolId: string): boolean {
2425
export function toolRequiresApproval(toolId: string): boolean {
2526
return getToolEntry(toolId)?.requiresApproval === true
2627
}
28+
29+
/**
30+
* Whether a tool may only run on a lane that is able to hold an approval prompt.
31+
*
32+
* `toolCallNeedsApproval` answers for the dispatch lane, where a streaming
33+
* context exists to gate against. The in-band route has neither a context nor a
34+
* waiter — the mothership executes those calls itself — so it asks this instead,
35+
* before running anything, and refuses rather than blocks: a background lane
36+
* must never hang on a prompt with no row behind it.
37+
*
38+
* Lives here rather than beside the dispatch gate so that asking the question
39+
* costs only the catalog. The gate module reaches the permission persistence
40+
* layer, which opens a pub/sub channel when it loads.
41+
*
42+
* Deliberately blind to the stored auto-allow list. Consulting it here would add
43+
* a database read to every in-band call to reach the same place by a longer
44+
* route: an auto-allowed tool sent to the checkpoint lane is admitted there
45+
* without prompting anyone. Refusing unconditionally keeps this fail-closed and
46+
* leaves the one implementation of "has the user allowed this" on the lane that
47+
* already owns it.
48+
*/
49+
export function toolRequiresApprovalLane(toolId: string): boolean {
50+
return isCopilotToolPermissionsEnabled && toolRequiresApproval(toolId)
51+
}

0 commit comments

Comments
 (0)