Skip to content

Commit 1316299

Browse files
committed
fix(agent): gate realtime tool permission writes
1 parent 22c859e commit 1316299

18 files changed

Lines changed: 593 additions & 37 deletions

File tree

apps/realtime/src/database/operations.test.ts

Lines changed: 93 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
/** @vitest-environment node */
2-
import { OPERATION_TARGETS, SUBBLOCK_OPERATIONS } from '@sim/realtime-protocol/constants'
2+
import {
3+
BLOCK_OPERATIONS,
4+
BLOCKS_OPERATIONS,
5+
OPERATION_TARGETS,
6+
SUBBLOCK_OPERATIONS,
7+
WORKFLOW_OPERATIONS,
8+
} from '@sim/realtime-protocol/constants'
39
import { beforeEach, describe, expect, it, vi } from 'vitest'
410

511
const { mockTransaction, mockSelectWhere, mockSet } = vi.hoisted(() => ({
@@ -38,13 +44,28 @@ vi.mock('drizzle-orm', () => ({
3844
}))
3945
vi.mock('drizzle-orm/postgres-js', () => ({ drizzle: () => ({ transaction: mockTransaction }) }))
4046
vi.mock('postgres', () => ({ default: vi.fn() }))
41-
vi.mock('@/env', () => ({ env: { DATABASE_URL: 'postgres://localhost/test' } }))
47+
vi.mock('@/env', () => ({
48+
env: { DATABASE_URL: 'postgres://localhost/test' },
49+
getBaseUrl: () => 'http://localhost:3000',
50+
}))
4251

52+
import { mergeSubBlockValues } from '@sim/workflow-persistence/subblocks'
53+
import { afterEach } from 'vitest'
4354
import { persistWorkflowOperation } from '@/database/operations'
4455

56+
const mockFetch = vi.fn()
57+
beforeEach(() => {
58+
vi.stubGlobal('fetch', mockFetch)
59+
mockFetch.mockReset()
60+
mockFetch.mockImplementation(async () => Response.json({ agentToolPermissionModeEnabled: true }))
61+
})
62+
afterEach(() => vi.unstubAllGlobals())
63+
4564
const transaction = {
4665
select: () => ({ from: () => ({ where: mockSelectWhere }) }),
4766
update: () => ({ set: mockSet }),
67+
delete: vi.fn(),
68+
insert: vi.fn(),
4869
}
4970

5071
describe('search replacement persistence', () => {
@@ -70,6 +91,7 @@ describe('search replacement persistence', () => {
7091
mockSelectWhere.mockResolvedValue([
7192
{
7293
id: 'agent-1',
94+
type: 'agent',
7395
locked: false,
7496
data: {},
7597
subBlocks: { tools: { id: 'tools', type: 'tool-input', value: stored } },
@@ -118,3 +140,72 @@ describe('search replacement persistence', () => {
118140
expect(mockSet).toHaveBeenCalledTimes(1)
119141
})
120142
})
143+
144+
describe('variable permissions at every realtime transaction write boundary', () => {
145+
const block = {
146+
id: 'agent-1',
147+
type: 'agent',
148+
name: 'Agent',
149+
position: { x: 0, y: 0 },
150+
locked: false,
151+
subBlocks: {
152+
tools: { id: 'tools', type: 'tool-input', value: [{ usageControlExpression: 'force' }] },
153+
},
154+
data: {},
155+
}
156+
157+
beforeEach(() => {
158+
vi.clearAllMocks()
159+
mockTransaction.mockImplementation(
160+
async (callback: (tx: typeof transaction) => Promise<void>) => callback(transaction)
161+
)
162+
mockSet.mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) })
163+
mockSelectWhere.mockImplementation(() =>
164+
Object.assign(Promise.resolve([block]), { limit: async () => [block] })
165+
)
166+
mockFetch.mockImplementation(async () =>
167+
Response.json({ agentToolPermissionModeEnabled: false })
168+
)
169+
})
170+
171+
it.each([
172+
{
173+
operation: WORKFLOW_OPERATIONS.REPLACE_STATE,
174+
target: OPERATION_TARGETS.WORKFLOW,
175+
payload: { state: { blocks: { 'agent-1': block } } },
176+
},
177+
{
178+
operation: BLOCK_OPERATIONS.UPDATE_CANONICAL_MODE,
179+
target: OPERATION_TARGETS.BLOCK,
180+
payload: { id: 'agent-1', canonicalId: '0:agentToolUsageControl', canonicalMode: 'advanced' },
181+
},
182+
{
183+
operation: BLOCK_OPERATIONS.REPLACE_CANONICAL_MODES,
184+
target: OPERATION_TARGETS.BLOCK,
185+
payload: {
186+
id: 'agent-1',
187+
data: { canonicalModes: { '0:agentToolUsageControl': 'advanced' } },
188+
},
189+
},
190+
{
191+
operation: BLOCKS_OPERATIONS.BATCH_ADD_BLOCKS,
192+
target: OPERATION_TARGETS.BLOCKS,
193+
payload: { blocks: [block] },
194+
},
195+
{
196+
operation: SUBBLOCK_OPERATIONS.BATCH_UPDATE,
197+
target: OPERATION_TARGETS.SUBBLOCK,
198+
payload: {
199+
updates: [{ blockId: 'agent-1', subblockId: 'tools', value: block.subBlocks.tools.value }],
200+
},
201+
},
202+
])('refuses $operation before changing any block rows', async (operation) => {
203+
vi.mocked(mergeSubBlockValues).mockReturnValue(block.subBlocks)
204+
await expect(
205+
persistWorkflowOperation('workflow-1', { ...operation, timestamp: Date.now() })
206+
).rejects.toThrow('disabled')
207+
expect(mockSet).toHaveBeenCalledTimes(1)
208+
expect(transaction.delete).not.toHaveBeenCalled()
209+
expect(transaction.insert).not.toHaveBeenCalled()
210+
})
211+
})

apps/realtime/src/database/operations.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
import { and, eq, inArray, isNull, or, sql } from 'drizzle-orm'
4141
import { drizzle } from 'drizzle-orm/postgres-js'
4242
import postgres from 'postgres'
43+
import { assertAgentToolPermissionModeEnabled } from '@/database/workflow-authoring'
4344
import { env } from '@/env'
4445

4546
const logger = createLogger('SocketDatabase')
@@ -781,7 +782,11 @@ async function handleBlockOperationTx(
781782
}
782783

783784
const existingBlock = await tx
784-
.select({ data: workflowBlocks.data })
785+
.select({
786+
type: workflowBlocks.type,
787+
subBlocks: workflowBlocks.subBlocks,
788+
data: workflowBlocks.data,
789+
})
785790
.from(workflowBlocks)
786791
.where(and(eq(workflowBlocks.id, payload.id), eq(workflowBlocks.workflowId, workflowId)))
787792
.limit(1)
@@ -793,6 +798,15 @@ async function handleBlockOperationTx(
793798
[payload.canonicalId]: payload.canonicalMode,
794799
}
795800

801+
if (existingBlock[0]) {
802+
await assertAgentToolPermissionModeEnabled([
803+
{
804+
...existingBlock[0],
805+
data: { ...currentData, canonicalModes },
806+
},
807+
])
808+
}
809+
796810
const updateResult = await tx
797811
.update(workflowBlocks)
798812
.set({
@@ -821,13 +835,26 @@ async function handleBlockOperationTx(
821835
}
822836

823837
const existingBlock = await tx
824-
.select({ data: workflowBlocks.data })
838+
.select({
839+
type: workflowBlocks.type,
840+
subBlocks: workflowBlocks.subBlocks,
841+
data: workflowBlocks.data,
842+
})
825843
.from(workflowBlocks)
826844
.where(and(eq(workflowBlocks.id, payload.id), eq(workflowBlocks.workflowId, workflowId)))
827845
.limit(1)
828846

829847
const currentData = (existingBlock?.[0]?.data as Record<string, unknown>) || {}
830848

849+
if (existingBlock[0]) {
850+
await assertAgentToolPermissionModeEnabled([
851+
{
852+
...existingBlock[0],
853+
data: { ...currentData, canonicalModes: payload.data.canonicalModes },
854+
},
855+
])
856+
}
857+
831858
const updateResult = await tx
832859
.update(workflowBlocks)
833860
.set({
@@ -975,6 +1002,8 @@ async function handleBlocksOperationTx(
9751002
}
9761003
})
9771004

1005+
await assertAgentToolPermissionModeEnabled(blockValues)
1006+
9781007
await tx
9791008
.insert(workflowBlocks)
9801009
.values(blockValues)
@@ -2006,6 +2035,7 @@ async function handleSubblockOperationTx(
20062035
const allBlocks = await tx
20072036
.select({
20082037
id: workflowBlocks.id,
2038+
type: workflowBlocks.type,
20092039
subBlocks: workflowBlocks.subBlocks,
20102040
locked: workflowBlocks.locked,
20112041
data: workflowBlocks.data,
@@ -2045,6 +2075,10 @@ async function handleSubblockOperationTx(
20452075
? { ...currentSubBlock, value }
20462076
: { id: subblockId, type: 'unknown', value }
20472077

2078+
if (subblockId === 'tools') {
2079+
await assertAgentToolPermissionModeEnabled([{ ...block, subBlocks }])
2080+
}
2081+
20482082
await tx
20492083
.update(workflowBlocks)
20502084
.set({
@@ -2156,6 +2190,7 @@ async function handleWorkflowOperationTx(
21562190
}
21572191

21582192
const { blocks, edges, loops, parallels } = payload.state
2193+
await assertAgentToolPermissionModeEnabled(Object.values(blocks || {}))
21592194

21602195
logger.info(`Replacing workflow state for ${workflowId}`, {
21612196
blockCount: Object.keys(blocks || {}).length,
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/** @vitest-environment node */
2+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3+
import { assertAgentToolPermissionModeEnabled } from '@/database/workflow-authoring'
4+
5+
const mockFetch = vi.fn()
6+
const variableBlock = {
7+
type: 'agent',
8+
subBlocks: { tools: { value: [{ usageControlExpression: 'none' }] } },
9+
}
10+
11+
describe('realtime workflow authoring policy', () => {
12+
beforeEach(() => {
13+
vi.stubGlobal('fetch', mockFetch)
14+
mockFetch.mockReset()
15+
})
16+
afterEach(() => vi.unstubAllGlobals())
17+
18+
it('does not depend on the app for fixed, empty, or non-agent edits', async () => {
19+
await assertAgentToolPermissionModeEnabled([
20+
{ type: 'agent', subBlocks: { tools: { value: [{ usageControl: 'force' }] } } },
21+
{ type: 'agent' },
22+
{ ...variableBlock, type: 'function' },
23+
])
24+
expect(mockFetch).not.toHaveBeenCalled()
25+
})
26+
27+
it('accepts enabled authoring and checks the current policy again on the next write', async () => {
28+
mockFetch
29+
.mockResolvedValueOnce(Response.json({ agentToolPermissionModeEnabled: true }))
30+
.mockResolvedValueOnce(Response.json({ agentToolPermissionModeEnabled: false }))
31+
await expect(assertAgentToolPermissionModeEnabled([variableBlock])).resolves.toBeUndefined()
32+
await expect(assertAgentToolPermissionModeEnabled([variableBlock])).rejects.toThrow('disabled')
33+
expect(mockFetch).toHaveBeenCalledTimes(2)
34+
expect(mockFetch).toHaveBeenCalledWith(
35+
expect.stringContaining('/api/internal/workflow-authoring-policy'),
36+
expect.objectContaining({
37+
cache: 'no-store',
38+
redirect: 'error',
39+
signal: expect.any(AbortSignal),
40+
})
41+
)
42+
})
43+
44+
it.each([
45+
variableBlock,
46+
{
47+
type: 'agent',
48+
subBlocks: { tools: { value: [{}] } },
49+
data: { canonicalModes: { '0:agentToolUsageControl': 'advanced' } },
50+
},
51+
{
52+
type: 'agent',
53+
subBlocks: { tools: { value: [{ usageControlExpression: '' }] } },
54+
data: { canonicalModes: { '0:agentToolUsageControl': 'basic' } },
55+
},
56+
])('rejects active, missing, and dormant expressions when disabled: %j', async (block) => {
57+
mockFetch.mockResolvedValue(Response.json({ agentToolPermissionModeEnabled: false }))
58+
await expect(assertAgentToolPermissionModeEnabled([block])).rejects.toThrow('disabled')
59+
})
60+
61+
it.each([{}, { agentToolPermissionModeEnabled: 'true' }, null])(
62+
'rejects malformed policy %j',
63+
async (body) => {
64+
mockFetch.mockResolvedValue(Response.json(body))
65+
await expect(assertAgentToolPermissionModeEnabled([variableBlock])).rejects.toThrow()
66+
}
67+
)
68+
69+
it.each([401, 403, 500, 503])('rejects HTTP %i without persisting', async (status) => {
70+
mockFetch.mockResolvedValue(new Response('', { status }))
71+
await expect(assertAgentToolPermissionModeEnabled([variableBlock])).rejects.toThrow(
72+
'Unable to verify'
73+
)
74+
})
75+
76+
it('propagates a dropped connection and accepts a later healthy request', async () => {
77+
mockFetch
78+
.mockRejectedValueOnce(new TypeError('connection reset'))
79+
.mockResolvedValueOnce(Response.json({ agentToolPermissionModeEnabled: true }))
80+
await expect(assertAgentToolPermissionModeEnabled([variableBlock])).rejects.toThrow(
81+
'connection reset'
82+
)
83+
await expect(assertAgentToolPermissionModeEnabled([variableBlock])).resolves.toBeUndefined()
84+
})
85+
86+
it('rejects a truncated body', async () => {
87+
mockFetch.mockResolvedValue(new Response('{"agentToolPermissionModeEnabled":'))
88+
await expect(assertAgentToolPermissionModeEnabled([variableBlock])).rejects.toThrow()
89+
})
90+
})
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { workflowAuthoringPolicySchema } from '@sim/realtime-protocol/workflow-authoring'
2+
import {
3+
type AgentToolPermissionBlock,
4+
hasVariableAgentToolPermissions,
5+
} from '@sim/workflow-types/agent-tool-permissions'
6+
import { env, getBaseUrl } from '@/env'
7+
8+
export class AgentToolPermissionModeDisabledError extends Error {
9+
constructor() {
10+
super('Variable agent tool permission modes are disabled')
11+
this.name = 'AgentToolPermissionModeDisabledError'
12+
}
13+
}
14+
15+
/**
16+
* The app owns flag evaluation. Only variable-bearing writes cross this service boundary;
17+
* ordinary workflow edits remain local. A failed lookup aborts the pending write.
18+
*/
19+
export async function assertAgentToolPermissionModeEnabled(
20+
blocks: Iterable<AgentToolPermissionBlock>
21+
): Promise<void> {
22+
if (!hasVariableAgentToolPermissions(blocks)) return
23+
24+
const response = await fetch(`${getBaseUrl()}/api/internal/workflow-authoring-policy`, {
25+
headers: { 'x-api-key': env.INTERNAL_API_SECRET },
26+
signal: AbortSignal.timeout(5_000),
27+
cache: 'no-store',
28+
redirect: 'error',
29+
})
30+
if (!response.ok) throw new Error('Unable to verify workflow authoring policy')
31+
const policy = workflowAuthoringPolicySchema.parse(await response.json())
32+
if (!policy.agentToolPermissionModeEnabled) throw new AgentToolPermissionModeDisabledError()
33+
}

apps/realtime/src/handlers/operations.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { getErrorMessage } from '@sim/utils/errors'
1515
import { generateId } from '@sim/utils/id'
1616
import { ZodError } from 'zod'
1717
import { persistWorkflowOperation } from '@/database/operations'
18+
import { AgentToolPermissionModeDisabledError } from '@/database/workflow-authoring'
1819
import type { AuthenticatedSocket } from '@/middleware/auth'
1920
import { checkWorkflowOperationPermission } from '@/middleware/permissions'
2021
import { type IRoomManager, type UserSession, workflowRoom as wf } from '@/rooms'
@@ -605,7 +606,9 @@ export function setupOperationsHandlers(socket: AuthenticatedSocket, roomManager
605606
socket.emit('operation-failed', {
606607
operationId,
607608
error: errorMessage,
608-
retryable: !(error instanceof ZodError),
609+
retryable:
610+
!(error instanceof ZodError) &&
611+
!(error instanceof AgentToolPermissionModeDisabledError),
609612
})
610613
}
611614

0 commit comments

Comments
 (0)