Skip to content

Commit bf7487b

Browse files
fix(copilot): make tool write gates fail closed
The three handler-map management tools (manage_custom_tool, manage_mcp_tool, manage_skill) gated writes with `context.userPermission && perm !== 'write' && perm !== 'admin'`. userPermission is optional on the execution context, so an absent value skipped the check entirely and the write proceeded unguarded — while the server-tool router's equivalent gate is fail-closed. Both paths now share copilotToolCanWrite, built on the canonical permissionSatisfies (null/undefined never satisfies). manage_custom_tool additionally resolved its target as `params.workspaceId || context.workspaceId`, so a model-supplied workspace id won while the permission check was resolved for the context workspace — and upsertCustomTools performs no authz of its own. It now uses the server-set context only, matching its two siblings. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CiHhAk2R1NryaS3R8n2yFz
1 parent e98715d commit bf7487b

6 files changed

Lines changed: 91 additions & 28 deletions

File tree

apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.ts

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
22
import { createLogger } from '@sim/logger'
33
import { getErrorMessage, toError } from '@sim/utils/errors'
44
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
5+
import { copilotToolCanWrite, copilotWriteDeniedMessage } from '@/lib/copilot/tools/permissions'
56
import { captureServerEvent } from '@/lib/posthog/server'
67
import {
78
deleteCustomTool,
@@ -30,7 +31,6 @@ interface ManageCustomToolParams {
3031
schema?: ManageCustomToolSchema
3132
code?: string
3233
title?: string
33-
workspaceId?: string
3434
}
3535

3636
export async function executeManageCustomTool(
@@ -39,22 +39,25 @@ export async function executeManageCustomTool(
3939
): Promise<ToolCallResult> {
4040
const params = rawParams as ManageCustomToolParams
4141
const operation = String(params.operation || '').toLowerCase() as ManageCustomToolOperation
42-
const workspaceId = params.workspaceId || context.workspaceId
42+
/**
43+
* Server-set context only. A model-supplied `params.workspaceId` used to win
44+
* here, while the permission gate above is resolved for the CONTEXT
45+
* workspace — so a caller could name another workspace and have it
46+
* authorized against their own. `upsertCustomTools` does no authz of its own
47+
* (it only scopes queries by the id it is handed), so nothing downstream
48+
* caught it. Matches manage_mcp_tool and manage_skill.
49+
*/
50+
const workspaceId = context.workspaceId
4351

4452
if (!operation) {
4553
return { success: false, error: "Missing required 'operation' argument" }
4654
}
4755

4856
const writeOps: string[] = ['add', 'edit', 'delete']
49-
if (
50-
writeOps.includes(operation) &&
51-
context.userPermission &&
52-
context.userPermission !== 'write' &&
53-
context.userPermission !== 'admin'
54-
) {
57+
if (writeOps.includes(operation) && !copilotToolCanWrite(context.userPermission)) {
5558
return {
5659
success: false,
57-
error: `Permission denied: '${operation}' on manage_custom_tool requires write access. You have '${context.userPermission}' permission.`,
60+
error: copilotWriteDeniedMessage('manage_custom_tool', operation, context.userPermission),
5861
}
5962
}
6063

apps/sim/lib/copilot/tools/handlers/management/manage-mcp-tool.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
44
import { getErrorMessage, toError } from '@sim/utils/errors'
55
import { and, eq, isNull } from 'drizzle-orm'
66
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
7+
import { copilotToolCanWrite, copilotWriteDeniedMessage } from '@/lib/copilot/tools/permissions'
78
import {
89
performCreateMcpServer,
910
performDeleteMcpServer,
@@ -46,15 +47,10 @@ export async function executeManageMcpTool(
4647
}
4748

4849
const writeOps: string[] = ['add', 'edit', 'delete']
49-
if (
50-
writeOps.includes(operation) &&
51-
context.userPermission &&
52-
context.userPermission !== 'write' &&
53-
context.userPermission !== 'admin'
54-
) {
50+
if (writeOps.includes(operation) && !copilotToolCanWrite(context.userPermission)) {
5551
return {
5652
success: false,
57-
error: `Permission denied: '${operation}' on manage_mcp_tool requires write access. You have '${context.userPermission}' permission.`,
53+
error: copilotWriteDeniedMessage('manage_mcp_tool', operation, context.userPermission),
5854
}
5955
}
6056

apps/sim/lib/copilot/tools/handlers/management/manage-skill.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
22
import { createLogger } from '@sim/logger'
33
import { getErrorMessage, toError } from '@sim/utils/errors'
44
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
5+
import { copilotToolCanWrite, copilotWriteDeniedMessage } from '@/lib/copilot/tools/permissions'
56
import { captureServerEvent } from '@/lib/posthog/server'
67
import { getSkillActorContext } from '@/lib/skills/access'
78
import { isBuiltinSkillId } from '@/lib/workflows/skills/builtin-skills'
@@ -37,15 +38,10 @@ export async function executeManageSkill(
3738

3839
// Workspace write gates only creation; edits and deletes are gated per skill
3940
// below (skill editor — explicit editor row or derived workspace admin).
40-
if (
41-
operation === 'add' &&
42-
context.userPermission &&
43-
context.userPermission !== 'write' &&
44-
context.userPermission !== 'admin'
45-
) {
41+
if (operation === 'add' && !copilotToolCanWrite(context.userPermission)) {
4642
return {
4743
success: false,
48-
error: `Permission denied: 'add' on manage_skill requires write access. You have '${context.userPermission}' permission.`,
44+
error: copilotWriteDeniedMessage('manage_skill', operation, context.userPermission),
4945
}
5046
}
5147

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { copilotToolCanWrite, copilotWriteDeniedMessage } from '@/lib/copilot/tools/permissions'
6+
7+
describe('copilotToolCanWrite', () => {
8+
it('fails closed when the permission is absent', () => {
9+
// The regression this exists for: the previous `perm && perm !== 'write' &&
10+
// perm !== 'admin'` ladders skipped the check entirely for these values,
11+
// letting an ungated write through. userPermission is optional on the
12+
// execution context, so every one of these is reachable.
13+
expect(copilotToolCanWrite(undefined)).toBe(false)
14+
expect(copilotToolCanWrite(null)).toBe(false)
15+
expect(copilotToolCanWrite('')).toBe(false)
16+
})
17+
18+
it('denies read-only and unrecognized permissions', () => {
19+
expect(copilotToolCanWrite('read')).toBe(false)
20+
expect(copilotToolCanWrite('nonsense')).toBe(false)
21+
})
22+
23+
it('allows write and admin', () => {
24+
expect(copilotToolCanWrite('write')).toBe(true)
25+
expect(copilotToolCanWrite('admin')).toBe(true)
26+
})
27+
})
28+
29+
describe('copilotWriteDeniedMessage', () => {
30+
it('names the operation and the caller’s actual permission', () => {
31+
expect(copilotWriteDeniedMessage('manage_custom_tool', 'delete', 'read')).toBe(
32+
"Permission denied: 'delete' on manage_custom_tool requires write access. You have 'read' permission."
33+
)
34+
})
35+
36+
it('reports "none" rather than an empty string when permission is absent', () => {
37+
expect(copilotWriteDeniedMessage('manage_skill', 'add', undefined)).toContain("You have 'none'")
38+
})
39+
40+
it('omits the operation label when there is no operation', () => {
41+
expect(copilotWriteDeniedMessage('knowledge_base', undefined, 'read')).toBe(
42+
"Permission denied: knowledge_base requires write access. You have 'read' permission."
43+
)
44+
})
45+
})
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { type PermissionType, permissionSatisfies } from '@sim/platform-authz/workspace'
2+
3+
/**
4+
* Whether a copilot tool call may perform a write, given the workspace
5+
* permission resolved for the request.
6+
*
7+
* **Fails closed.** `ExecutionContext.userPermission` is optional, so an absent
8+
* value must deny — the hand-written `perm && perm !== 'write' && perm !==
9+
* 'admin'` ladders this replaces skipped the check entirely when the field was
10+
* undefined, letting a write through unguarded. Shared by the server-tool
11+
* router and the handler-map tools so the two copilot execution paths cannot
12+
* disagree about what "write access" means.
13+
*/
14+
export function copilotToolCanWrite(userPermission: string | null | undefined): boolean {
15+
return permissionSatisfies((userPermission ?? null) as PermissionType | null, 'write')
16+
}
17+
18+
/** Renders the denial message shared by both copilot execution paths. */
19+
export function copilotWriteDeniedMessage(
20+
toolName: string,
21+
operation: string | undefined,
22+
userPermission: string | null | undefined
23+
): string {
24+
const actionLabel = operation ? `'${operation}' on ` : ''
25+
return `Permission denied: ${actionLabel}${toolName} requires write access. You have '${userPermission || 'none'}' permission.`
26+
}

apps/sim/lib/copilot/tools/server/router.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
UserTable,
1717
WorkspaceFile,
1818
} from '@/lib/copilot/generated/tool-catalog-v1'
19+
import { copilotToolCanWrite } from '@/lib/copilot/tools/permissions'
1920
import {
2021
assertServerToolNotAborted,
2122
type BaseServerTool,
@@ -139,10 +140,6 @@ const WRITE_ACTIONS: Record<string, string[]> = {
139140
[enrichmentRunServerTool.name]: ['*'],
140141
}
141142

142-
function isWritePermission(userPermission: string): boolean {
143-
return userPermission === 'write' || userPermission === 'admin'
144-
}
145-
146143
function isWriteAction(toolName: string, action: string | undefined): boolean {
147144
const writeActions = WRITE_ACTIONS[toolName]
148145
if (!writeActions) return false
@@ -211,7 +208,7 @@ export async function routeExecution(
211208
if (WRITE_ACTIONS[toolName]) {
212209
const p = payload as Record<string, unknown>
213210
const action = (p?.operation ?? p?.action) as string | undefined
214-
if (isWriteAction(toolName, action) && !isWritePermission(context?.userPermission ?? '')) {
211+
if (isWriteAction(toolName, action) && !copilotToolCanWrite(context?.userPermission)) {
215212
const actionLabel = action ? `'${action}' on ` : ''
216213
throw new Error(
217214
`Permission denied: ${actionLabel}${toolName} requires write access. You have '${context?.userPermission ?? 'none'}' permission.`

0 commit comments

Comments
 (0)