Skip to content

Commit 8348592

Browse files
fix(copilot): close fail-open write gates in agent tools (#6132)
* 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 * fix(copilot): gate materialize_file writes and enforce the deploy mutation lock materialize_file's save/extract/import all create workspace resources, but the handler-map path has no central permission check, so a read-only member could create files and workflows through the agent. Gate on write access after param validation. assertWorkflowMutable moves into performFullDeploy / performFullUndeploy / performActivateVersion, where performRevertToVersion already had it. The check previously lived only in the deploy routes, so the copilot deploy tools — which call the orchestration functions directly — could deploy, undeploy, and activate versions of a locked workflow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(copilot): enforce secret-admin on env writes and gate KB indexing on usage Two more paths where the agent is a weaker route to the same write than the UI. upsertWorkspaceEnvVars was a weakened copy of the environment route's write: no per-key secret-admin check, no advisory lock, no audit row. Its only caller is the set_environment_variables copilot tool, which gates on workspace 'write' alone — so any write-level member could have the agent overwrite a workspace secret they do not administer, with nothing in the audit log and a lost-update race against the route's locked transaction. The gate now lives in the function so every caller inherits it, reusing getWorkspaceEnvKeyAdminAccess rather than restating the route's logic. knowledge_base add_file computed a billing attribution and then indexed without calling checkAttributedUsageLimits, which every upload route applies before accepting indexing work — an over-quota workspace could index without limit through the agent. The same file's query operation already gated correctly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(copilot): return lock denials and stop bootstrapping a legacy secret's ACL Two defects in the previous commits, both found by review. assertWorkflowMutable throws, and the three orchestration entry points awaited it outside any try/catch, so a locked workflow escaped as an exception instead of the { success: false } result the callers consume — performChatDeploy and the copilot deploy tools would have surfaced a generic 500 rather than a lock denial. performRevertToVersion already converted it; the three now do too, through a shared helper. upsertWorkspaceEnvVars derived newKeys for createWorkspaceEnvCredentials from the credential rows rather than the stored variables. A secret written before credential rows existed has no ACL, so overwriting it looked like adding a new key: it minted a credential and made the caller that secret's admin. The environment route derives newKeys from the locked jsonb read for exactly this reason, and now so does this. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * style(env): collapse the merged test import onto one line Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PCXbU36FwJBmtJKaH83BUm * fix(env): give the workspace env denial its own write-access message WorkspaceEnvAccessError reported "must be an admin of these secrets" for both denials, so a caller lacking workspace write to ADD a key was told to get secret-admin on a key that does not exist yet. Carry the reason and mirror the route's two messages. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PCXbU36FwJBmtJKaH83BUm --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 30820fc commit 8348592

14 files changed

Lines changed: 527 additions & 54 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

apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ const {
2828
mockResolveStorageBillingContext: vi.fn(),
2929
}))
3030

31+
vi.mock('@/lib/copilot/tools/handlers/access', () => ({
32+
ensureWorkspaceAccess: vi.fn(),
33+
}))
34+
3135
vi.mock('@/lib/copilot/tools/handlers/upload-file-reader', () => ({
3236
findMothershipUploadRowByChatAndName: mockFindUpload,
3337
}))
@@ -128,6 +132,36 @@ const mothershipRow = {
128132
updatedAt: new Date('2026-01-01'),
129133
}
130134

135+
describe('executeMaterializeFile - workspace write gate', () => {
136+
beforeEach(() => {
137+
vi.clearAllMocks()
138+
resetDbChainMock()
139+
})
140+
141+
it.each(['save', 'import', 'extract'])(
142+
'refuses %s without workspace write access and touches no upload',
143+
async (operation) => {
144+
const { ensureWorkspaceAccess } = await import('@/lib/copilot/tools/handlers/access')
145+
vi.mocked(ensureWorkspaceAccess).mockRejectedValueOnce(
146+
new Error('Write access required for this workspace')
147+
)
148+
149+
const result = await executeMaterializeFile({ fileNames: ['a.json'], operation }, context)
150+
151+
expect(result.success).toBe(false)
152+
expect(result.error).toContain('Write access required')
153+
expect(mockFindUpload).not.toHaveBeenCalled()
154+
}
155+
)
156+
157+
it('requires write, not merely read, access', async () => {
158+
const { ensureWorkspaceAccess } = await import('@/lib/copilot/tools/handlers/access')
159+
await executeMaterializeFile({ fileNames: ['a.json'], operation: 'save' }, context)
160+
161+
expect(ensureWorkspaceAccess).toHaveBeenCalledWith(context.workspaceId, context.userId, 'write')
162+
})
163+
})
164+
131165
describe('executeMaterializeFile - unsupported operation', () => {
132166
beforeEach(() => {
133167
vi.clearAllMocks()

apps/sim/lib/copilot/tools/handlers/materialize-file.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
resolveStorageBillingContext,
1313
} from '@/lib/billing/storage'
1414
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
15+
import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access'
1516
import { findMothershipUploadRowByChatAndName } from '@/lib/copilot/tools/handlers/upload-file-reader'
1617
import { canonicalWorkspaceFilePath, encodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils'
1718
import { getServePathPrefix } from '@/lib/uploads'
@@ -504,6 +505,15 @@ export async function executeMaterializeFile(
504505
error: `Unsupported materialize_file operation "${operation}". Use "save", "import", or "extract". For CSV/TSV/JSON → use the table subagent; for documents → use the knowledge subagent.`,
505506
}
506507
}
508+
509+
// Every operation writes: save/extract create files, import creates a workflow.
510+
// The handler-map path has no central permission gate.
511+
try {
512+
await ensureWorkspaceAccess(context.workspaceId, context.userId, 'write')
513+
} catch (error) {
514+
return { success: false, error: getErrorMessage(error, 'Workspace write access required') }
515+
}
516+
507517
const succeeded: string[] = []
508518
const failed: Array<{ fileName: string; error: string }> = []
509519
const resources: NonNullable<ToolCallResult['resources']> = []
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
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+
expect(copilotToolCanWrite(undefined)).toBe(false)
10+
expect(copilotToolCanWrite(null)).toBe(false)
11+
expect(copilotToolCanWrite('')).toBe(false)
12+
})
13+
14+
it('denies read-only and unrecognized permissions', () => {
15+
expect(copilotToolCanWrite('read')).toBe(false)
16+
expect(copilotToolCanWrite('nonsense')).toBe(false)
17+
})
18+
19+
it('allows write and admin', () => {
20+
expect(copilotToolCanWrite('write')).toBe(true)
21+
expect(copilotToolCanWrite('admin')).toBe(true)
22+
})
23+
})
24+
25+
describe('copilotWriteDeniedMessage', () => {
26+
it('names the operation and the caller’s actual permission', () => {
27+
expect(copilotWriteDeniedMessage('manage_custom_tool', 'delete', 'read')).toBe(
28+
"Permission denied: 'delete' on manage_custom_tool requires write access. You have 'read' permission."
29+
)
30+
})
31+
32+
it('reports "none" rather than an empty string when permission is absent', () => {
33+
expect(copilotWriteDeniedMessage('manage_skill', 'add', undefined)).toContain("You have 'none'")
34+
})
35+
36+
it('omits the operation label when there is no operation', () => {
37+
expect(copilotWriteDeniedMessage('knowledge_base', undefined, 'read')).toBe(
38+
"Permission denied: knowledge_base requires write access. You have 'read' permission."
39+
)
40+
})
41+
})
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { type PermissionType, permissionSatisfies } from '@sim/platform-authz/workspace'
2+
3+
/**
4+
* Whether a copilot tool call may write. Fails closed: `userPermission` is
5+
* optional on the execution context, and absent must deny. Shared by the
6+
* server-tool router and the handler-map tools.
7+
*/
8+
export function copilotToolCanWrite(userPermission: string | null | undefined): boolean {
9+
return permissionSatisfies((userPermission ?? null) as PermissionType | null, 'write')
10+
}
11+
12+
/** Renders the denial message shared by both copilot execution paths. */
13+
export function copilotWriteDeniedMessage(
14+
toolName: string,
15+
operation: string | undefined,
16+
userPermission: string | null | undefined
17+
): string {
18+
const actionLabel = operation ? `'${operation}' on ` : ''
19+
return `Permission denied: ${actionLabel}${toolName} requires write access. You have '${userPermission || 'none'}' permission.`
20+
}

apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,11 @@ vi.mock('@/app/api/knowledge/utils', () => ({
8282
checkKnowledgeBaseWriteAccess: mockCheckKnowledgeBaseWriteAccess,
8383
}))
8484

85+
import { checkAttributedUsageLimits } from '@/lib/billing/core/billing-attribution'
8586
import { knowledgeBaseServerTool } from '@/lib/copilot/tools/server/knowledge/knowledge-base'
87+
import { createSingleDocument } from '@/lib/knowledge/documents/service'
88+
import { getKnowledgeBaseById } from '@/lib/knowledge/service'
89+
import { resolveWorkspaceFileReference } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
8690

8791
const BILLING_ATTRIBUTION = {
8892
actorUserId: 'external-admin',
@@ -173,3 +177,58 @@ describe('knowledge base connector Copilot operations', () => {
173177
}
174178
)
175179
})
180+
181+
describe('knowledge base add_file usage gate', () => {
182+
beforeEach(() => {
183+
vi.clearAllMocks()
184+
resetDbChainMock()
185+
mockCheckKnowledgeBaseWriteAccess.mockResolvedValue({
186+
hasAccess: true,
187+
knowledgeBase: { id: 'knowledge-base-1', workspaceId: 'workspace-paid', name: 'Paid KB' },
188+
})
189+
vi.mocked(getKnowledgeBaseById).mockResolvedValue({
190+
id: 'knowledge-base-1',
191+
workspaceId: 'workspace-paid',
192+
} as Awaited<ReturnType<typeof getKnowledgeBaseById>>)
193+
})
194+
195+
function addFile() {
196+
return knowledgeBaseServerTool.execute(
197+
{
198+
operation: 'add_file',
199+
args: { knowledgeBaseId: 'knowledge-base-1', filePaths: ['files/report.pdf'] },
200+
},
201+
{
202+
userId: 'external-admin',
203+
workspaceId: 'workspace-paid',
204+
billingAttribution: BILLING_ATTRIBUTION,
205+
}
206+
)
207+
}
208+
209+
it('refuses to index when the payer is over its usage limit', async () => {
210+
vi.mocked(checkAttributedUsageLimits).mockResolvedValue({
211+
isExceeded: true,
212+
message: 'Usage limit exceeded.',
213+
} as Awaited<ReturnType<typeof checkAttributedUsageLimits>>)
214+
215+
const result = await addFile()
216+
217+
expect(result.success).toBe(false)
218+
expect(result.message).toContain('Usage limit exceeded')
219+
// The gate must precede any indexing work, matching the upload routes.
220+
expect(resolveWorkspaceFileReference).not.toHaveBeenCalled()
221+
expect(createSingleDocument).not.toHaveBeenCalled()
222+
})
223+
224+
it('gates on the knowledge base workspace payer, not the caller', async () => {
225+
vi.mocked(checkAttributedUsageLimits).mockResolvedValue({
226+
isExceeded: false,
227+
} as Awaited<ReturnType<typeof checkAttributedUsageLimits>>)
228+
vi.mocked(resolveWorkspaceFileReference).mockResolvedValue(null)
229+
230+
await addFile()
231+
232+
expect(checkAttributedUsageLimits).toHaveBeenCalledWith(BILLING_ATTRIBUTION)
233+
})
234+
})

apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,17 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg
349349

350350
const kbWorkspaceId: string = targetKb.workspaceId
351351
const billingAttribution = requireKnowledgeBillingAttribution(context, kbWorkspaceId)
352+
353+
// Gate the payer before accepting indexing work, same as the upload routes.
354+
const usage = await checkAttributedUsageLimits(billingAttribution)
355+
if (usage.isExceeded) {
356+
return {
357+
success: false,
358+
message:
359+
usage.message || 'Usage limit exceeded. Please upgrade your plan to continue.',
360+
}
361+
}
362+
352363
const added: Array<{ documentId: string; filename: string }> = []
353364
const failedFiles: string[] = []
354365

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)