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
5 changes: 3 additions & 2 deletions apps/sim/app/api/copilot/tool-permission/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
TOOL_PERMISSION_DECISION,
type ToolPermissionDecision,
} from '@/lib/mothership/persistence/tool-permission'
import { toolApprovalKey } from '@/lib/mothership/persistence/tool-permission/approval-key'
import {
addAutoAllowedTool,
addChatAutoAllowedTool,
Expand Down Expand Up @@ -113,15 +114,15 @@ async function applyDecision(
toolName: claimed.toolName,
})
} else if (decision === TOOL_PERMISSION_DECISION.always_allow) {
await addAutoAllowedTool(userId, claimed.toolName).catch((err) => {
await addAutoAllowedTool(userId, toolApprovalKey(claimed.toolName)).catch((err) => {
logger.error('Failed to persist always-allow preference', {
toolCallId,
toolName: claimed.toolName,
error: getErrorMessage(err),
})
})
} else if (decision === TOOL_PERMISSION_DECISION.allow_chat && run.chatId) {
await addChatAutoAllowedTool(run.chatId, claimed.toolName).catch((err) => {
await addChatAutoAllowedTool(run.chatId, toolApprovalKey(claimed.toolName)).catch((err) => {
logger.error('Failed to persist chat-scoped allow preference', {
toolCallId,
chatId: run.chatId,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client'

import { useCallback, useEffect, useState } from 'react'
import { isBrowserToolName } from '@sim/browser-protocol'
import {
ChevronDown,
Chip,
Expand Down Expand Up @@ -102,6 +103,8 @@ export function ToolPermissionCard({
// Only the first outstanding card offers the bulk action, so N cards do not
// each render their own "Allow all".
const showBulkActions = outstandingIds.length > 1 && outstandingIds[0] === toolCallId
// The server remembers one answer for every browser action, so say so.
const isBrowserTool = isBrowserToolName(toolName)

const submit = useCallback(
async (decision: Decision, ids: string[]) => {
Expand Down Expand Up @@ -198,10 +201,10 @@ export function ToolPermissionCard({
</DropdownMenuTrigger>
<DropdownMenuContent align='start'>
<DropdownMenuItem onSelect={() => void submit('allow_chat', [toolCallId])}>
For this chat
{isBrowserTool ? 'Browser actions in this chat' : 'For this chat'}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => void submit('always_allow', [toolCallId])}>
For every chat
{isBrowserTool ? 'Browser actions in every chat' : 'For every chat'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { isBrowserToolName } from '@sim/browser-protocol'

/**
* What a "don't ask again" answer is remembered under. Browser tools share one
* entry, so allowing the browser to act covers every action instead of asking
* again for each kind of click. The shared entry is not a tool id, so it can
* never collide with one.
*/
export function toolApprovalKey(toolName: string): string {
return isBrowserToolName(toolName) ? 'browser_*' : toolName
}
16 changes: 16 additions & 0 deletions apps/sim/lib/mothership/request/tools/permission.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,22 @@ describe('runGatedToolExecution', () => {
expect(context.toolPermissions.autoAllowed.has('terminal')).toBe(true)
})

it('lets one allow-for-this-chat cover every browser action, not just the one asked about', async () => {
const context = makeContext()
const toolCall = makeToolCall()
toolCall.name = 'browser_click'
toolCall.params = { elementId: 4 }
waitForToolPermissionDecision.mockResolvedValue({
toolCallId: 'call-1',
decision: 'allow_chat',
})

await gate(context, toolCall, () => Promise.resolve({ status: 'success' }), [])

expect(toolCallNeedsApproval('browser_type', context, {}, false, { text: 'hi' })).toBe(false)
expect(toolCallNeedsApproval('terminal', context, {}, false, { operation: 'run' })).toBe(true)
})

it('accepts the normal chat-level decision for code with a secret reference', async () => {
const context = makeContext()
const toolCall = makeToolCall()
Expand Down
5 changes: 3 additions & 2 deletions apps/sim/lib/mothership/request/tools/permission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
decisionSuppressesFuturePrompts,
waitForToolPermissionDecision,
} from '@/lib/mothership/persistence/tool-permission'
import { toolApprovalKey } from '@/lib/mothership/persistence/tool-permission/approval-key'
import { withCopilotSpan } from '@/lib/mothership/request/otel'
import { markToolResultSeen } from '@/lib/mothership/request/sse-utils'
import { setTerminalToolCallState } from '@/lib/mothership/request/tool-call-state'
Expand Down Expand Up @@ -117,7 +118,7 @@ export function toolCallNeedsApproval(
*/
if (!context.toolPermissions.autoAllowPermitted) return true

return !context.toolPermissions.autoAllowed.has(toolName)
return !context.toolPermissions.autoAllowed.has(toolApprovalKey(toolName))
}

function skipOutput(toolName: string) {
Expand Down Expand Up @@ -332,7 +333,7 @@ export function runGatedToolExecution(
* endpoint reads the capability on its own request and refuses the
* durable write.
*/
context.toolPermissions.autoAllowed.add(toolName)
context.toolPermissions.autoAllowed.add(toolApprovalKey(toolName))
}

if (!decisionAllowsExecution(decision.decision)) {
Expand Down
12 changes: 11 additions & 1 deletion apps/sim/lib/mothership/tool-executor/router.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { BROWSER_TOOL_OBSERVES_ONLY, isBrowserToolName } from '@sim/browser-protocol'
import { isCopilotToolPermissionsEnabled } from '@/lib/core/config/env-flags'
import { TOOL_CATALOG, type ToolCatalogEntry } from '@/lib/mothership/generated/tool-catalog-v1'

Expand All @@ -21,8 +22,17 @@ export function isKnownTool(toolId: string): boolean {
return isToolInCatalog(toolId)
}

/** Declared in the mothership tool catalog; Go carries the flag but never enforces it. */
/**
* Whether a call must wait for the user's approval.
*
* Declared in the mothership tool catalog, which Go carries but never enforces,
* except for browser tools: Sim owns their split between acting on a page and
* only observing it. One that acts does so as the user, in a browser signed in
* to their accounts, so it is gated like a terminal command. Reading the split
* from `BROWSER_TOOL_OBSERVES_ONLY` gates a browser tool added later by default.
*/
export function toolRequiresApproval(toolId: string): boolean {
if (isBrowserToolName(toolId)) return !BROWSER_TOOL_OBSERVES_ONLY[toolId]
return getToolEntry(toolId)?.requiresApproval === true
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* durable Stop write, and results arrive through the confirm route.
*/
import { authMock, authMockFns } from '@sim/testing/mocks/auth.mock'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'

const { redisUrl, inheritedEnv } = await vi.hoisted(async () => {
const { readTestRedisUrl } = await import('@sim/db/testing/test-infrastructure')
Expand Down Expand Up @@ -36,6 +36,7 @@ import {
closeStreamToolAdmission,
requestRunStop,
} from '@/lib/mothership/async-runs/repository'
import { getAutoAllowedTools } from '@/lib/mothership/persistence/tool-permission/auto-allow'
import { prePersistClientExecutableToolCall } from '@/lib/mothership/request/handlers'
import { waitForClientToolCompletion } from '@/lib/mothership/request/tools/client'
import { TraceCollector } from '@/lib/mothership/request/trace'
Expand Down Expand Up @@ -72,7 +73,7 @@ const reportsResult = (toolCallId: string, status: 'success' | 'error' = 'succes
data: { ok: true },
})

const userAnswers = (toolCallId: string, decision: 'allow' | 'skip') =>
const userAnswers = (toolCallId: string, decision: 'allow' | 'allow_chat' | 'skip') =>
post(toolPermissionPOST, '/api/copilot/tool-permission', {
decisions: [{ toolCallId, decision }],
})
Expand Down Expand Up @@ -160,7 +161,11 @@ describe.runIf(Boolean(redisUrl))('desktop tool calls the server no longer admit
streamComplete: false,
wasAborted: false,
errors: [],
toolPermissions: { enabled: true, autoAllowed: new Set(), autoAllowPermitted: true },
toolPermissions: {
enabled: true,
autoAllowed: await getAutoAllowedTools(userId, chatId),
autoAllowPermitted: true,
},
}
await prePersistClientExecutableToolCall(
{
Expand Down Expand Up @@ -224,6 +229,11 @@ describe.runIf(Boolean(redisUrl))('desktop tool calls the server no longer admit
})
})

/** Turns read the chat's stored allow list, so one test's "don't ask again" must not leak. */
afterEach(async () => {
await db.update(copilotChats).set({ autoAllowedTools: [] }).where(eq(copilotChats.id, chatId))
})

afterAll(async () => {
await db.delete(copilotChats).where(eq(copilotChats.id, chatId))
await deleteWorkspaceFixture(db, eq(workspace.id, workspaceId))
Expand Down Expand Up @@ -262,6 +272,37 @@ describe.runIf(Boolean(redisUrl))('desktop tool calls the server no longer admit
expect(await storedCall(toolCallId)).toMatchObject({ status: 'pending', claimedBy: null })
})

it('refuses to act in the signed-in browser until the user approves it', async () => {
const { runId } = await startRun()
const toolCallId = await agentCalls(runId, 'browser_click', { elementId: 4 })

expect((await desktopClaims(toolCallId)).status).toBe(403)
expect(await storedCall(toolCallId)).toMatchObject({ status: 'pending', claimedBy: null })

expect((await userAnswers(toolCallId, 'allow')).status).toBe(200)
expect((await desktopClaims(toolCallId)).status).toBe(200)
})

it('remembers allowing the browser for this chat across every browser action, and only those', async () => {
const { runId } = await startRun()
const clickId = await agentCalls(runId, 'browser_click', { elementId: 4 })
expect((await userAnswers(clickId, 'allow_chat')).status).toBe(200)

const nextTurn = await startRun()
const typeId = await agentCalls(nextTurn.runId, 'browser_type', { elementId: 4, text: 'hi' })
expect((await desktopClaims(typeId)).status).toBe(200)

const commandId = await agentCalls(nextTurn.runId, 'terminal', {
operation: 'run',
args: { command: 'ls' },
})
const commandClaim = await desktopClaims(commandId)
expect(commandClaim.status).toBe(403)
expect(await commandClaim.json()).toMatchObject({
error: 'The user has not approved this tool call',
})
})

it('refuses a call the user declined before its gate was recorded on the row', async () => {
const { runId } = await startRun()
const toolCallId = generateId()
Expand Down Expand Up @@ -371,7 +412,9 @@ describe.runIf(Boolean(redisUrl))('desktop tool calls the server no longer admit

it('acknowledges a desktop result that lands after Stop without reviving the call', async () => {
const { runId, streamId } = await startRun()
const toolCallId = await desktopIsRunning(runId, 'browser_click')
const toolCallId = await agentCalls(runId, 'browser_click', { elementId: 4 })
expect((await userAnswers(toolCallId, 'allow')).status).toBe(200)
expect((await desktopClaims(toolCallId)).status).toBe(200)
await requestRunStop({ userId, workspaceId, streamId, chatId })
expect(await storedCall(toolCallId)).toMatchObject({
status: 'cancelled',
Expand Down
Loading