Skip to content

Commit 3868bf9

Browse files
authored
improvement(copilot): refuse approval-gated tools on the in-band lane (#8044)
* improvement(copilot): refuse approval-gated tools on the in-band lane Copilot's approval gate is scaffolding today: COPILOT_TOOL_PERMISSIONS_ENABLED is off by default, so nothing is gated on any lane. It is built only on the dispatch lane, which holds a call against a streaming context and a decision row and then declines to dispatch anything the mothership marks in-band. Those calls run via POST /api/copilot/tools/execute, which has no context and no waiter, so turning the flag on would gate the foreground and leave background lanes ungated — a gate that looks enforced but is not. Add toolRequiresApprovalLane next to toolCallNeedsApproval so the covered tool set is defined once, and refuse a gated tool at the in-band route before it runs. Refuse rather than block: a background lane must never hang on a prompt with no row behind it. The check deliberately ignores the stored auto-allow list — an auto-allowed tool sent to the checkpoint lane is admitted there without prompting anyone, so reading it here would only add a database read to reach the same place. Inert while the flag is off, which is the state this ships in; a test pins that. Also record on the flag itself that the gate is a property of the lane, since that is what the next person reads before enabling it. * 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 4e72b2b commit 3868bf9

7 files changed

Lines changed: 154 additions & 4 deletions

File tree

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

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,16 @@
44
import { beforeEach, describe, expect, it, vi } from 'vitest'
55
import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
66

7-
const { mockCheckInternalApiKey, mockPrepareEnvironmentContext, mockHandler } = vi.hoisted(() => ({
7+
const {
8+
mockCheckInternalApiKey,
9+
mockPrepareEnvironmentContext,
10+
mockHandler,
11+
mockToolRequiresApprovalLane,
12+
} = vi.hoisted(() => ({
813
mockCheckInternalApiKey: vi.fn(),
914
mockPrepareEnvironmentContext: vi.fn(),
1015
mockHandler: vi.fn(),
16+
mockToolRequiresApprovalLane: vi.fn().mockReturnValue(false),
1117
}))
1218

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

2127
vi.mock('@/lib/copilot/tool-executor', () => ({
2228
ensureHandlersRegistered: vi.fn(),
29+
toolRequiresApprovalLane: mockToolRequiresApprovalLane,
2330
}))
2431

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

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+
*/
178+
describe('approval-gated tools', () => {
179+
/**
180+
* This lane cannot hold an approval prompt: the dispatch handler owns the gate and
181+
* declines to dispatch in-band calls, so a gated tool arriving here has no waiter behind
182+
* it. Refuse before running anything rather than execute on consent nobody gave.
183+
*/
184+
it('refuses a tool that needs an approval-capable lane, without executing it', async () => {
185+
mockToolRequiresApprovalLane.mockReturnValue(true)
186+
mockHandler.mockResolvedValue({ success: true, output: { ran: true } })
187+
188+
const res = await POST(
189+
makeRequest({
190+
...BASE_BODY,
191+
toolName: 'run_function',
192+
params: { code: 'return 1' },
193+
messageId: 'msg-gated',
194+
}) as never
195+
)
196+
const body = await res.json()
197+
198+
expect(mockHandler).not.toHaveBeenCalled()
199+
expect(body.success).toBe(false)
200+
expect(body.output).toEqual({ resultWithheld: true, effect: 'not_attempted' })
201+
expect(body.error).toContain('requires user approval')
202+
expect(body.error).toContain('checkpoint lane')
203+
})
204+
205+
it('still runs a tool that does not need an approval-capable lane', async () => {
206+
mockHandler.mockResolvedValue({ success: true, output: { content: 'hello' } })
207+
208+
const res = await POST(makeRequest({ ...BASE_BODY, messageId: 'msg-ungated' }) as never)
209+
210+
expect(mockHandler).toHaveBeenCalledTimes(1)
211+
await expect(res.json()).resolves.toEqual({ success: true, output: { content: 'hello' } })
212+
})
213+
})
214+
164215
it('passes a failed generate_api_key call through with its error', async () => {
165216
mockHandler.mockResolvedValue({ success: false, error: 'name is required' })
166217
const res = await POST(

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

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
} from '@/lib/copilot/request/tools/resolved-secret-result'
1818
import { handleResourceSideEffects } from '@/lib/copilot/request/tools/resources'
1919
import type { ToolCallResult } from '@/lib/copilot/request/types'
20-
import { ensureHandlersRegistered } from '@/lib/copilot/tool-executor'
20+
import { ensureHandlersRegistered, toolRequiresApprovalLane } from '@/lib/copilot/tool-executor'
2121
import { executeTool } from '@/lib/copilot/tool-executor/executor'
2222
import { TOOL_EFFECT_PHASE } from '@/lib/copilot/tool-executor/types'
2323
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
@@ -126,6 +126,29 @@ export const POST = withRouteHandler((request: NextRequest) =>
126126
[TraceAttr.UserId]: userId,
127127
})
128128

129+
/**
130+
* Cheap admission, before any work: this lane cannot hold an approval prompt. The
131+
* dispatch handler gates `requiresApproval` tools against a streaming context and a
132+
* decision row, then deliberately declines to dispatch anything the mothership marks
133+
* in-band — so a gated tool arriving here has no waiter behind it and would run on
134+
* consent nobody gave. Refuse instead, and let the mothership take the checkpoint lane
135+
* where the gate lives. Inert while copilot tool permissions are disabled, which keeps
136+
* enabling the flag from silently leaving background lanes ungated.
137+
*/
138+
if (toolRequiresApprovalLane(toolName)) {
139+
logger.warn('Refusing an approval-gated tool on the in-band lane', {
140+
toolName,
141+
toolCallId,
142+
userId,
143+
})
144+
rootSpan.setAttributes({ [TraceAttr.ToolOutcome]: MothershipStreamV1ToolOutcome.error })
145+
return NextResponse.json({
146+
success: false,
147+
error: `${toolName} was not run: it requires user approval, and this lane cannot hold an approval prompt. Dispatch it on the checkpoint lane instead.`,
148+
output: { resultWithheld: true, effect: TOOL_EFFECT_PHASE.notAttempted },
149+
})
150+
}
151+
129152
let toolRegistry: ResolvedSecretTraceRegistry
130153
let turnRegistry: ResolvedSecretTraceRegistry
131154
try {

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ export const TOOL_AWAITING_APPROVAL_STATUS = MothershipStreamV1ToolStatus.awaiti
5656
*
5757
* Headless one-shot executions are never gated: nobody is there to answer, and
5858
* 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.
5964
*/
6065
export function toolCallNeedsApproval(
6166
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+
}

‎apps/sim/lib/core/config/env-flags.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,14 @@ export const isStatusNoticePreviewEnabled = isTruthy(getEnv('NEXT_PUBLIC_STATUS_
9292
* used tools, so it is an opt-in change in how the product feels, not just a
9393
* safety toggle. With it off nothing is stamped, gated, or persisted, and an
9494
* approval stamp arriving from Go is cleared on the way to the client.
95+
*
96+
* The gate is a property of the lane, not only of the tool. Sim can hold a call
97+
* for a decision on the dispatch lane, where a streaming context and a decision
98+
* row exist. It cannot on the in-band route (`POST /api/copilot/tools/execute`),
99+
* which the mothership drives for background lanes, so that route refuses a
100+
* `requiresApproval` tool outright rather than running it ungated — see
101+
* `toolRequiresApprovalLane`. Any new execution lane has to answer the same
102+
* question before this flag is turned on.
95103
*/
96104
export const isCopilotToolPermissionsEnabled = isTruthy(env.COPILOT_TOOL_PERMISSIONS_ENABLED)
97105

0 commit comments

Comments
 (0)