@vellumai/assistant 0.3.18 → 0.3.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +4 -0
- package/docs/architecture/security.md +80 -0
- package/package.json +1 -1
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +4 -0
- package/src/__tests__/call-controller.test.ts +170 -0
- package/src/__tests__/checker.test.ts +60 -0
- package/src/__tests__/guardian-action-grant-mint-consume.test.ts +511 -0
- package/src/__tests__/guardian-dispatch.test.ts +61 -1
- package/src/__tests__/guardian-grant-minting.test.ts +543 -0
- package/src/__tests__/ipc-snapshot.test.ts +1 -0
- package/src/__tests__/remote-skill-policy.test.ts +215 -0
- package/src/__tests__/scoped-approval-grants.test.ts +521 -0
- package/src/__tests__/scoped-grant-security-matrix.test.ts +443 -0
- package/src/__tests__/trust-store.test.ts +2 -0
- package/src/__tests__/voice-scoped-grant-consumer.test.ts +571 -0
- package/src/calls/call-controller.ts +27 -6
- package/src/calls/call-domain.ts +12 -0
- package/src/calls/guardian-dispatch.ts +8 -0
- package/src/calls/relay-server.ts +13 -0
- package/src/calls/voice-session-bridge.ts +42 -3
- package/src/config/bundled-skills/notifications/SKILL.md +18 -0
- package/src/config/schema.ts +6 -0
- package/src/config/skills-schema.ts +27 -0
- package/src/daemon/handlers/config-channels.ts +18 -0
- package/src/daemon/handlers/skills.ts +45 -2
- package/src/daemon/ipc-contract/skills.ts +1 -0
- package/src/daemon/session-process.ts +12 -0
- package/src/memory/db-init.ts +9 -1
- package/src/memory/embedding-local.ts +16 -7
- package/src/memory/guardian-action-store.ts +8 -0
- package/src/memory/guardian-verification.ts +1 -1
- package/src/memory/migrations/033-scoped-approval-grants.ts +51 -0
- package/src/memory/migrations/034-guardian-action-tool-metadata.ts +12 -0
- package/src/memory/migrations/index.ts +2 -0
- package/src/memory/schema.ts +30 -0
- package/src/memory/scoped-approval-grants.ts +509 -0
- package/src/permissions/checker.ts +27 -0
- package/src/runtime/guardian-action-grant-minter.ts +97 -0
- package/src/runtime/routes/guardian-approval-interception.ts +116 -0
- package/src/runtime/routes/inbound-message-handler.ts +94 -27
- package/src/security/tool-approval-digest.ts +67 -0
- package/src/skills/remote-skill-policy.ts +131 -0
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for M4: voice consumer checks scoped grants before auto-denying
|
|
3
|
+
* non-guardian confirmation requests.
|
|
4
|
+
*
|
|
5
|
+
* Verifies:
|
|
6
|
+
* 1. A matching grant allows a non-guardian voice confirmation (exactly once).
|
|
7
|
+
* 2. No grant or mismatched grant still auto-denies.
|
|
8
|
+
* 3. Guardian auto-allow path remains unchanged.
|
|
9
|
+
* 4. Grants are revoked on call end (controller.destroy).
|
|
10
|
+
* 5. Second identical invocation after consume is denied (one-time use).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
14
|
+
import { tmpdir } from 'node:os';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
|
|
17
|
+
import { afterAll, beforeEach, describe, expect, type Mock, mock, test } from 'bun:test';
|
|
18
|
+
|
|
19
|
+
const testDir = mkdtempSync(join(tmpdir(), 'voice-scoped-grant-consumer-test-'));
|
|
20
|
+
|
|
21
|
+
// ── Platform + logger mocks (must come before any source imports) ────
|
|
22
|
+
|
|
23
|
+
mock.module('../util/platform.js', () => ({
|
|
24
|
+
getRootDir: () => testDir,
|
|
25
|
+
getDataDir: () => testDir,
|
|
26
|
+
isMacOS: () => process.platform === 'darwin',
|
|
27
|
+
isLinux: () => process.platform === 'linux',
|
|
28
|
+
isWindows: () => process.platform === 'win32',
|
|
29
|
+
getSocketPath: () => join(testDir, 'test.sock'),
|
|
30
|
+
getPidPath: () => join(testDir, 'test.pid'),
|
|
31
|
+
getDbPath: () => join(testDir, 'test.db'),
|
|
32
|
+
getLogPath: () => join(testDir, 'test.log'),
|
|
33
|
+
ensureDataDir: () => {},
|
|
34
|
+
readHttpToken: () => null,
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
mock.module('../util/logger.js', () => ({
|
|
38
|
+
getLogger: () =>
|
|
39
|
+
new Proxy({} as Record<string, unknown>, {
|
|
40
|
+
get: () => () => {},
|
|
41
|
+
}),
|
|
42
|
+
isDebug: () => false,
|
|
43
|
+
truncateForLog: (value: string) => value,
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
// ── Config mock ─────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
mock.module('../config/loader.js', () => ({
|
|
49
|
+
getConfig: () => ({
|
|
50
|
+
provider: 'anthropic',
|
|
51
|
+
providerOrder: ['anthropic'],
|
|
52
|
+
apiKeys: { anthropic: 'test-key' },
|
|
53
|
+
calls: {
|
|
54
|
+
enabled: true,
|
|
55
|
+
provider: 'twilio',
|
|
56
|
+
maxDurationSeconds: 12 * 60,
|
|
57
|
+
userConsultTimeoutSeconds: 90,
|
|
58
|
+
userConsultationTimeoutSeconds: 90,
|
|
59
|
+
silenceTimeoutSeconds: 30,
|
|
60
|
+
disclosure: { enabled: false, text: '' },
|
|
61
|
+
safety: { denyCategories: [] },
|
|
62
|
+
model: undefined,
|
|
63
|
+
},
|
|
64
|
+
memory: { enabled: false },
|
|
65
|
+
}),
|
|
66
|
+
}));
|
|
67
|
+
|
|
68
|
+
// ── Secret ingress mock ────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
mock.module('../security/secret-ingress.js', () => ({
|
|
71
|
+
checkIngressForSecrets: () => ({ blocked: false }),
|
|
72
|
+
}));
|
|
73
|
+
|
|
74
|
+
// ── Assistant event hub mock ───────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
mock.module('../runtime/assistant-event-hub.js', () => ({
|
|
77
|
+
assistantEventHub: {
|
|
78
|
+
publish: async () => {},
|
|
79
|
+
},
|
|
80
|
+
}));
|
|
81
|
+
|
|
82
|
+
mock.module('../runtime/assistant-event.js', () => ({
|
|
83
|
+
buildAssistantEvent: () => ({}),
|
|
84
|
+
}));
|
|
85
|
+
|
|
86
|
+
// ── Session runtime assembly mock ──────────────────────────────────
|
|
87
|
+
|
|
88
|
+
mock.module('../daemon/session-runtime-assembly.js', () => ({
|
|
89
|
+
resolveChannelCapabilities: () => ({
|
|
90
|
+
supportsRichText: false,
|
|
91
|
+
supportsDynamicUi: false,
|
|
92
|
+
supportsVoiceInput: true,
|
|
93
|
+
}),
|
|
94
|
+
}));
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
// ── Import source modules after all mocks are registered ────────────
|
|
98
|
+
|
|
99
|
+
import { and, eq } from 'drizzle-orm';
|
|
100
|
+
|
|
101
|
+
import {
|
|
102
|
+
createScopedApprovalGrant,
|
|
103
|
+
type CreateScopedApprovalGrantParams,
|
|
104
|
+
revokeScopedApprovalGrantsForContext,
|
|
105
|
+
} from '../memory/scoped-approval-grants.js';
|
|
106
|
+
import { getDb, initializeDb, resetDb } from '../memory/db.js';
|
|
107
|
+
import { conversations, scopedApprovalGrants } from '../memory/schema.js';
|
|
108
|
+
import { computeToolApprovalDigest } from '../security/tool-approval-digest.js';
|
|
109
|
+
import type { ServerMessage } from '../daemon/ipc-protocol.js';
|
|
110
|
+
import { setVoiceBridgeDeps, startVoiceTurn } from '../calls/voice-session-bridge.js';
|
|
111
|
+
import type { GuardianRuntimeContext } from '../daemon/session-runtime-assembly.js';
|
|
112
|
+
|
|
113
|
+
initializeDb();
|
|
114
|
+
|
|
115
|
+
afterAll(() => {
|
|
116
|
+
resetDb();
|
|
117
|
+
try { rmSync(testDir, { recursive: true }); } catch { /* best effort */ }
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// Mock session that triggers a confirmation_request on processMessage
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
const TOOL_NAME = 'execute_shell';
|
|
125
|
+
const TOOL_INPUT = { command: 'rm -rf /tmp/test' };
|
|
126
|
+
const ASSISTANT_ID = 'self';
|
|
127
|
+
const CONVERSATION_ID = 'conv-voice-grant-test';
|
|
128
|
+
const CALL_SESSION_ID = 'call-session-voice-grant-test';
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Create a mock session that, when runAgentLoop is called, emits a
|
|
132
|
+
* confirmation_request through the updateClient callback before completing.
|
|
133
|
+
*/
|
|
134
|
+
function createMockSession(opts?: {
|
|
135
|
+
confirmationRequestId?: string;
|
|
136
|
+
toolName?: string;
|
|
137
|
+
toolInput?: Record<string, unknown>;
|
|
138
|
+
}) {
|
|
139
|
+
const requestId = opts?.confirmationRequestId ?? `req-${crypto.randomUUID()}`;
|
|
140
|
+
const toolName = opts?.toolName ?? TOOL_NAME;
|
|
141
|
+
const toolInput = opts?.toolInput ?? TOOL_INPUT;
|
|
142
|
+
|
|
143
|
+
let clientCallback: ((msg: ServerMessage) => void) | null = null;
|
|
144
|
+
let confirmationDecision: { requestId: string; decision: string; reason?: string } | null = null;
|
|
145
|
+
|
|
146
|
+
const session = {
|
|
147
|
+
isProcessing: () => false,
|
|
148
|
+
memoryPolicy: {},
|
|
149
|
+
setAssistantId: () => {},
|
|
150
|
+
setGuardianContext: () => {},
|
|
151
|
+
setCommandIntent: () => {},
|
|
152
|
+
setTurnChannelContext: () => {},
|
|
153
|
+
setChannelCapabilities: () => {},
|
|
154
|
+
setVoiceCallControlPrompt: () => {},
|
|
155
|
+
currentRequestId: requestId,
|
|
156
|
+
abort: () => {},
|
|
157
|
+
persistUserMessage: async () => 'msg-1',
|
|
158
|
+
updateClient: (cb: (msg: ServerMessage) => void, _reset?: boolean) => {
|
|
159
|
+
clientCallback = cb;
|
|
160
|
+
},
|
|
161
|
+
handleConfirmationResponse: (
|
|
162
|
+
reqId: string,
|
|
163
|
+
decision: string,
|
|
164
|
+
_pattern?: string,
|
|
165
|
+
_scope?: string,
|
|
166
|
+
reason?: string,
|
|
167
|
+
) => {
|
|
168
|
+
confirmationDecision = { requestId: reqId, decision, reason };
|
|
169
|
+
},
|
|
170
|
+
handleSecretResponse: () => {},
|
|
171
|
+
runAgentLoop: async (
|
|
172
|
+
_content: string,
|
|
173
|
+
_messageId: string,
|
|
174
|
+
broadcastFn: (msg: ServerMessage) => void,
|
|
175
|
+
) => {
|
|
176
|
+
// Emit a confirmation_request through the client callback
|
|
177
|
+
if (clientCallback) {
|
|
178
|
+
clientCallback({
|
|
179
|
+
type: 'confirmation_request',
|
|
180
|
+
requestId,
|
|
181
|
+
toolName,
|
|
182
|
+
input: toolInput,
|
|
183
|
+
riskLevel: 'medium',
|
|
184
|
+
allowlistOptions: [],
|
|
185
|
+
scopeOptions: [],
|
|
186
|
+
} as ServerMessage);
|
|
187
|
+
}
|
|
188
|
+
// Then complete the turn
|
|
189
|
+
broadcastFn({ type: 'message_complete' } as ServerMessage);
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
session,
|
|
195
|
+
requestId,
|
|
196
|
+
getConfirmationDecision: () => confirmationDecision,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
// Setup: inject mock deps into voice-session-bridge
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
function setupBridgeDeps(sessionFactory: () => ReturnType<typeof createMockSession>['session']) {
|
|
205
|
+
let currentSession: ReturnType<typeof createMockSession>['session'] | null = null;
|
|
206
|
+
setVoiceBridgeDeps({
|
|
207
|
+
getOrCreateSession: async () => {
|
|
208
|
+
currentSession = sessionFactory();
|
|
209
|
+
return currentSession as any;
|
|
210
|
+
},
|
|
211
|
+
resolveAttachments: () => [],
|
|
212
|
+
deriveDefaultStrictSideEffects: () => true,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
// Helpers
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
function clearTables(): void {
|
|
221
|
+
const db = getDb();
|
|
222
|
+
try { db.run('DELETE FROM scoped_approval_grants'); } catch { /* table may not exist */ }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function grantParams(overrides: Partial<CreateScopedApprovalGrantParams> = {}): CreateScopedApprovalGrantParams {
|
|
226
|
+
const futureExpiry = new Date(Date.now() + 60_000).toISOString();
|
|
227
|
+
return {
|
|
228
|
+
assistantId: ASSISTANT_ID,
|
|
229
|
+
scopeMode: 'tool_signature',
|
|
230
|
+
toolName: TOOL_NAME,
|
|
231
|
+
inputDigest: computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT),
|
|
232
|
+
requestChannel: 'voice',
|
|
233
|
+
decisionChannel: 'telegram',
|
|
234
|
+
executionChannel: 'voice',
|
|
235
|
+
conversationId: CONVERSATION_ID,
|
|
236
|
+
callSessionId: CALL_SESSION_ID,
|
|
237
|
+
expiresAt: futureExpiry,
|
|
238
|
+
...overrides,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ===========================================================================
|
|
243
|
+
// Tests
|
|
244
|
+
// ===========================================================================
|
|
245
|
+
|
|
246
|
+
describe('voice scoped grant consumer', () => {
|
|
247
|
+
beforeEach(() => {
|
|
248
|
+
clearTables();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test('non-guardian with matching grant: consumed and allowed', async () => {
|
|
252
|
+
// Create a matching grant
|
|
253
|
+
createScopedApprovalGrant(grantParams());
|
|
254
|
+
|
|
255
|
+
const mockData = createMockSession();
|
|
256
|
+
setupBridgeDeps(() => mockData.session);
|
|
257
|
+
|
|
258
|
+
const guardianContext: GuardianRuntimeContext = {
|
|
259
|
+
sourceChannel: 'voice',
|
|
260
|
+
actorRole: 'non-guardian',
|
|
261
|
+
requesterExternalUserId: 'caller-123',
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const handle = await startVoiceTurn({
|
|
265
|
+
conversationId: CONVERSATION_ID,
|
|
266
|
+
callSessionId: CALL_SESSION_ID,
|
|
267
|
+
content: 'test utterance',
|
|
268
|
+
assistantId: ASSISTANT_ID,
|
|
269
|
+
guardianContext,
|
|
270
|
+
isInbound: true,
|
|
271
|
+
onTextDelta: () => {},
|
|
272
|
+
onComplete: () => {},
|
|
273
|
+
onError: () => {},
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
// Wait for the async agent loop to finish
|
|
277
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
278
|
+
|
|
279
|
+
const decision = mockData.getConfirmationDecision();
|
|
280
|
+
expect(decision).not.toBeNull();
|
|
281
|
+
expect(decision!.decision).toBe('allow');
|
|
282
|
+
expect(decision!.reason).toContain('scoped grant');
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test('non-guardian without grant: auto-denied', async () => {
|
|
286
|
+
// No grant created
|
|
287
|
+
|
|
288
|
+
const mockData = createMockSession();
|
|
289
|
+
setupBridgeDeps(() => mockData.session);
|
|
290
|
+
|
|
291
|
+
const guardianContext: GuardianRuntimeContext = {
|
|
292
|
+
sourceChannel: 'voice',
|
|
293
|
+
actorRole: 'non-guardian',
|
|
294
|
+
requesterExternalUserId: 'caller-123',
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
const handle = await startVoiceTurn({
|
|
298
|
+
conversationId: CONVERSATION_ID,
|
|
299
|
+
callSessionId: CALL_SESSION_ID,
|
|
300
|
+
content: 'test utterance',
|
|
301
|
+
assistantId: ASSISTANT_ID,
|
|
302
|
+
guardianContext,
|
|
303
|
+
isInbound: true,
|
|
304
|
+
onTextDelta: () => {},
|
|
305
|
+
onComplete: () => {},
|
|
306
|
+
onError: () => {},
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
310
|
+
|
|
311
|
+
const decision = mockData.getConfirmationDecision();
|
|
312
|
+
expect(decision).not.toBeNull();
|
|
313
|
+
expect(decision!.decision).toBe('deny');
|
|
314
|
+
expect(decision!.reason).toContain('Permission denied');
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test('non-guardian with mismatched tool name: auto-denied', async () => {
|
|
318
|
+
// Create a grant for a different tool
|
|
319
|
+
createScopedApprovalGrant(grantParams({
|
|
320
|
+
toolName: 'read_file',
|
|
321
|
+
inputDigest: computeToolApprovalDigest('read_file', TOOL_INPUT),
|
|
322
|
+
}));
|
|
323
|
+
|
|
324
|
+
const mockData = createMockSession();
|
|
325
|
+
setupBridgeDeps(() => mockData.session);
|
|
326
|
+
|
|
327
|
+
const guardianContext: GuardianRuntimeContext = {
|
|
328
|
+
sourceChannel: 'voice',
|
|
329
|
+
actorRole: 'non-guardian',
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
await startVoiceTurn({
|
|
333
|
+
conversationId: CONVERSATION_ID,
|
|
334
|
+
callSessionId: CALL_SESSION_ID,
|
|
335
|
+
content: 'test utterance',
|
|
336
|
+
assistantId: ASSISTANT_ID,
|
|
337
|
+
guardianContext,
|
|
338
|
+
isInbound: true,
|
|
339
|
+
onTextDelta: () => {},
|
|
340
|
+
onComplete: () => {},
|
|
341
|
+
onError: () => {},
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
345
|
+
|
|
346
|
+
const decision = mockData.getConfirmationDecision();
|
|
347
|
+
expect(decision).not.toBeNull();
|
|
348
|
+
expect(decision!.decision).toBe('deny');
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test('guardian caller: auto-allowed regardless of grants', async () => {
|
|
352
|
+
// No grant needed — guardian should auto-allow
|
|
353
|
+
|
|
354
|
+
const mockData = createMockSession();
|
|
355
|
+
setupBridgeDeps(() => mockData.session);
|
|
356
|
+
|
|
357
|
+
const guardianContext: GuardianRuntimeContext = {
|
|
358
|
+
sourceChannel: 'voice',
|
|
359
|
+
actorRole: 'guardian',
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
await startVoiceTurn({
|
|
363
|
+
conversationId: CONVERSATION_ID,
|
|
364
|
+
callSessionId: CALL_SESSION_ID,
|
|
365
|
+
content: 'test utterance',
|
|
366
|
+
assistantId: ASSISTANT_ID,
|
|
367
|
+
guardianContext,
|
|
368
|
+
isInbound: true,
|
|
369
|
+
onTextDelta: () => {},
|
|
370
|
+
onComplete: () => {},
|
|
371
|
+
onError: () => {},
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
375
|
+
|
|
376
|
+
const decision = mockData.getConfirmationDecision();
|
|
377
|
+
expect(decision).not.toBeNull();
|
|
378
|
+
expect(decision!.decision).toBe('allow');
|
|
379
|
+
expect(decision!.reason).toContain('guardian voice call');
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test('one-time use: second identical invocation after consume is denied', async () => {
|
|
383
|
+
// Create a single grant
|
|
384
|
+
createScopedApprovalGrant(grantParams());
|
|
385
|
+
|
|
386
|
+
// First invocation — should consume the grant and allow
|
|
387
|
+
const mockData1 = createMockSession({ confirmationRequestId: 'req-first' });
|
|
388
|
+
setupBridgeDeps(() => mockData1.session);
|
|
389
|
+
|
|
390
|
+
const guardianContext: GuardianRuntimeContext = {
|
|
391
|
+
sourceChannel: 'voice',
|
|
392
|
+
actorRole: 'non-guardian',
|
|
393
|
+
requesterExternalUserId: 'caller-123',
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
await startVoiceTurn({
|
|
397
|
+
conversationId: CONVERSATION_ID,
|
|
398
|
+
callSessionId: CALL_SESSION_ID,
|
|
399
|
+
content: 'first utterance',
|
|
400
|
+
assistantId: ASSISTANT_ID,
|
|
401
|
+
guardianContext,
|
|
402
|
+
isInbound: true,
|
|
403
|
+
onTextDelta: () => {},
|
|
404
|
+
onComplete: () => {},
|
|
405
|
+
onError: () => {},
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
409
|
+
|
|
410
|
+
const decision1 = mockData1.getConfirmationDecision();
|
|
411
|
+
expect(decision1).not.toBeNull();
|
|
412
|
+
expect(decision1!.decision).toBe('allow');
|
|
413
|
+
|
|
414
|
+
// Second invocation — grant already consumed, should deny
|
|
415
|
+
const mockData2 = createMockSession({ confirmationRequestId: 'req-second' });
|
|
416
|
+
setupBridgeDeps(() => mockData2.session);
|
|
417
|
+
|
|
418
|
+
await startVoiceTurn({
|
|
419
|
+
conversationId: CONVERSATION_ID,
|
|
420
|
+
callSessionId: CALL_SESSION_ID,
|
|
421
|
+
content: 'second utterance',
|
|
422
|
+
assistantId: ASSISTANT_ID,
|
|
423
|
+
guardianContext,
|
|
424
|
+
isInbound: true,
|
|
425
|
+
onTextDelta: () => {},
|
|
426
|
+
onComplete: () => {},
|
|
427
|
+
onError: () => {},
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
431
|
+
|
|
432
|
+
const decision2 = mockData2.getConfirmationDecision();
|
|
433
|
+
expect(decision2).not.toBeNull();
|
|
434
|
+
expect(decision2!.decision).toBe('deny');
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
test('grants revoked when revokeScopedApprovalGrantsForContext is called with callSessionId', () => {
|
|
438
|
+
const db = getDb();
|
|
439
|
+
const testCallSessionId = 'call-session-revoke-test';
|
|
440
|
+
|
|
441
|
+
// Create two grants: one for our call session, one for another
|
|
442
|
+
createScopedApprovalGrant(grantParams({ callSessionId: testCallSessionId }));
|
|
443
|
+
createScopedApprovalGrant(grantParams({ callSessionId: 'other-call-session' }));
|
|
444
|
+
|
|
445
|
+
// Verify both grants are active
|
|
446
|
+
const allActive = db.select()
|
|
447
|
+
.from(scopedApprovalGrants)
|
|
448
|
+
.where(eq(scopedApprovalGrants.status, 'active'))
|
|
449
|
+
.all();
|
|
450
|
+
expect(allActive.length).toBe(2);
|
|
451
|
+
|
|
452
|
+
// Revoke grants for the specific call session (simulates call end)
|
|
453
|
+
const revokedCount = revokeScopedApprovalGrantsForContext({ callSessionId: testCallSessionId });
|
|
454
|
+
expect(revokedCount).toBe(1);
|
|
455
|
+
|
|
456
|
+
// Only the target call session's grant should be revoked
|
|
457
|
+
const activeAfter = db.select()
|
|
458
|
+
.from(scopedApprovalGrants)
|
|
459
|
+
.where(and(
|
|
460
|
+
eq(scopedApprovalGrants.callSessionId, testCallSessionId),
|
|
461
|
+
eq(scopedApprovalGrants.status, 'active'),
|
|
462
|
+
))
|
|
463
|
+
.all();
|
|
464
|
+
expect(activeAfter.length).toBe(0);
|
|
465
|
+
|
|
466
|
+
const revokedAfter = db.select()
|
|
467
|
+
.from(scopedApprovalGrants)
|
|
468
|
+
.where(and(
|
|
469
|
+
eq(scopedApprovalGrants.callSessionId, testCallSessionId),
|
|
470
|
+
eq(scopedApprovalGrants.status, 'revoked'),
|
|
471
|
+
))
|
|
472
|
+
.all();
|
|
473
|
+
expect(revokedAfter.length).toBe(1);
|
|
474
|
+
|
|
475
|
+
// The other call session's grant should still be active
|
|
476
|
+
const otherActive = db.select()
|
|
477
|
+
.from(scopedApprovalGrants)
|
|
478
|
+
.where(and(
|
|
479
|
+
eq(scopedApprovalGrants.callSessionId, 'other-call-session'),
|
|
480
|
+
eq(scopedApprovalGrants.status, 'active'),
|
|
481
|
+
))
|
|
482
|
+
.all();
|
|
483
|
+
expect(otherActive.length).toBe(1);
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
test('grants with null callSessionId are revoked by conversationId', () => {
|
|
487
|
+
const db = getDb();
|
|
488
|
+
const testConversationId = 'conv-revoke-by-conversation';
|
|
489
|
+
|
|
490
|
+
// Simulate the guardian-approval-interception minting path which sets
|
|
491
|
+
// callSessionId: null but always sets conversationId
|
|
492
|
+
createScopedApprovalGrant(grantParams({
|
|
493
|
+
callSessionId: null,
|
|
494
|
+
conversationId: testConversationId,
|
|
495
|
+
}));
|
|
496
|
+
createScopedApprovalGrant(grantParams({
|
|
497
|
+
callSessionId: null,
|
|
498
|
+
conversationId: 'other-conversation',
|
|
499
|
+
}));
|
|
500
|
+
|
|
501
|
+
// Verify both grants are active
|
|
502
|
+
const allActive = db.select()
|
|
503
|
+
.from(scopedApprovalGrants)
|
|
504
|
+
.where(eq(scopedApprovalGrants.status, 'active'))
|
|
505
|
+
.all();
|
|
506
|
+
expect(allActive.length).toBe(2);
|
|
507
|
+
|
|
508
|
+
// callSessionId-based revocation should miss grants with null callSessionId
|
|
509
|
+
// because the filter matches on the column value, not NULL
|
|
510
|
+
const revokedByCallSession = revokeScopedApprovalGrantsForContext({ callSessionId: CALL_SESSION_ID });
|
|
511
|
+
expect(revokedByCallSession).toBe(0);
|
|
512
|
+
|
|
513
|
+
// conversationId-based revocation catches the grant
|
|
514
|
+
const revokedByConversation = revokeScopedApprovalGrantsForContext({ conversationId: testConversationId });
|
|
515
|
+
expect(revokedByConversation).toBe(1);
|
|
516
|
+
|
|
517
|
+
// The target conversation's grant should be revoked
|
|
518
|
+
const revokedAfter = db.select()
|
|
519
|
+
.from(scopedApprovalGrants)
|
|
520
|
+
.where(and(
|
|
521
|
+
eq(scopedApprovalGrants.conversationId, testConversationId),
|
|
522
|
+
eq(scopedApprovalGrants.status, 'revoked'),
|
|
523
|
+
))
|
|
524
|
+
.all();
|
|
525
|
+
expect(revokedAfter.length).toBe(1);
|
|
526
|
+
|
|
527
|
+
// The other conversation's grant should still be active
|
|
528
|
+
const otherActive = db.select()
|
|
529
|
+
.from(scopedApprovalGrants)
|
|
530
|
+
.where(and(
|
|
531
|
+
eq(scopedApprovalGrants.conversationId, 'other-conversation'),
|
|
532
|
+
eq(scopedApprovalGrants.status, 'active'),
|
|
533
|
+
))
|
|
534
|
+
.all();
|
|
535
|
+
expect(otherActive.length).toBe(1);
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
test('non-guardian with grant for different assistantId: auto-denied', async () => {
|
|
539
|
+
// Create a grant scoped to a different assistant
|
|
540
|
+
createScopedApprovalGrant(grantParams({
|
|
541
|
+
assistantId: 'other-assistant',
|
|
542
|
+
}));
|
|
543
|
+
|
|
544
|
+
const mockData = createMockSession();
|
|
545
|
+
setupBridgeDeps(() => mockData.session);
|
|
546
|
+
|
|
547
|
+
const guardianContext: GuardianRuntimeContext = {
|
|
548
|
+
sourceChannel: 'voice',
|
|
549
|
+
actorRole: 'non-guardian',
|
|
550
|
+
requesterExternalUserId: 'caller-123',
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
await startVoiceTurn({
|
|
554
|
+
conversationId: CONVERSATION_ID,
|
|
555
|
+
callSessionId: CALL_SESSION_ID,
|
|
556
|
+
content: 'test utterance',
|
|
557
|
+
assistantId: ASSISTANT_ID,
|
|
558
|
+
guardianContext,
|
|
559
|
+
isInbound: true,
|
|
560
|
+
onTextDelta: () => {},
|
|
561
|
+
onComplete: () => {},
|
|
562
|
+
onError: () => {},
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
566
|
+
|
|
567
|
+
const decision = mockData.getConfirmationDecision();
|
|
568
|
+
expect(decision).not.toBeNull();
|
|
569
|
+
expect(decision!.decision).toBe('deny');
|
|
570
|
+
});
|
|
571
|
+
});
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
getPendingRequestByCallSessionId,
|
|
17
17
|
markTimedOutWithReason,
|
|
18
18
|
} from '../memory/guardian-action-store.js';
|
|
19
|
+
import { revokeScopedApprovalGrantsForContext } from '../memory/scoped-approval-grants.js';
|
|
19
20
|
import { getLogger } from '../util/logger.js';
|
|
20
21
|
import { readHttpToken } from '../util/platform.js';
|
|
21
22
|
import { getMaxCallDurationMs, getUserConsultationTimeoutMs, SILENCE_TIMEOUT_MS } from './call-constants.js';
|
|
@@ -29,6 +30,7 @@ import {
|
|
|
29
30
|
recordCallEvent,
|
|
30
31
|
updateCallSession,
|
|
31
32
|
} from './call-store.js';
|
|
33
|
+
import { computeToolApprovalDigest } from '../security/tool-approval-digest.js';
|
|
32
34
|
import { sendGuardianExpiryNotices } from './guardian-action-sweep.js';
|
|
33
35
|
import { dispatchGuardianQuestion } from './guardian-dispatch.js';
|
|
34
36
|
import type { RelayConnection } from './relay-server.js';
|
|
@@ -436,6 +438,21 @@ export class CallController {
|
|
|
436
438
|
this.abortCurrentTurn();
|
|
437
439
|
this.currentTurnPromise = null;
|
|
438
440
|
unregisterCallController(this.callSessionId);
|
|
441
|
+
|
|
442
|
+
// Revoke any scoped approval grants bound to this call session.
|
|
443
|
+
// Revoke by both callSessionId and conversationId because the
|
|
444
|
+
// guardian-approval-interception minting path sets callSessionId: null
|
|
445
|
+
// but always sets conversationId.
|
|
446
|
+
try {
|
|
447
|
+
let revoked = revokeScopedApprovalGrantsForContext({ callSessionId: this.callSessionId });
|
|
448
|
+
revoked += revokeScopedApprovalGrantsForContext({ conversationId: this.conversationId });
|
|
449
|
+
if (revoked > 0) {
|
|
450
|
+
log.info({ callSessionId: this.callSessionId, conversationId: this.conversationId, revokedCount: revoked }, 'Revoked scoped grants on call end');
|
|
451
|
+
}
|
|
452
|
+
} catch (err) {
|
|
453
|
+
log.warn({ err, callSessionId: this.callSessionId }, 'Failed to revoke scoped grants on call end');
|
|
454
|
+
}
|
|
455
|
+
|
|
439
456
|
log.info({ callSessionId: this.callSessionId }, 'CallController destroyed');
|
|
440
457
|
}
|
|
441
458
|
|
|
@@ -574,6 +591,7 @@ export class CallController {
|
|
|
574
591
|
// Start the voice turn through the session bridge
|
|
575
592
|
startVoiceTurn({
|
|
576
593
|
conversationId: this.conversationId,
|
|
594
|
+
callSessionId: this.callSessionId,
|
|
577
595
|
content,
|
|
578
596
|
assistantId: this.assistantId,
|
|
579
597
|
guardianContext: this.guardianContext ?? undefined,
|
|
@@ -635,23 +653,24 @@ export class CallController {
|
|
|
635
653
|
// `}]` inside JSON string values does not truncate the payload or
|
|
636
654
|
// leak partial JSON into TTS output.
|
|
637
655
|
const approvalMatch = extractBalancedJson(responseText);
|
|
638
|
-
let
|
|
656
|
+
let toolApprovalMeta: { question: string; toolName: string; inputDigest: string } | null = null;
|
|
639
657
|
if (approvalMatch) {
|
|
640
658
|
try {
|
|
641
|
-
const parsed = JSON.parse(approvalMatch.json) as { question?: string };
|
|
642
|
-
if (parsed.question) {
|
|
643
|
-
|
|
659
|
+
const parsed = JSON.parse(approvalMatch.json) as { question?: string; toolName?: string; input?: Record<string, unknown> };
|
|
660
|
+
if (parsed.question && parsed.toolName && parsed.input) {
|
|
661
|
+
const digest = computeToolApprovalDigest(parsed.toolName, parsed.input);
|
|
662
|
+
toolApprovalMeta = { question: parsed.question, toolName: parsed.toolName, inputDigest: digest };
|
|
644
663
|
}
|
|
645
664
|
} catch {
|
|
646
665
|
log.warn({ callSessionId: this.callSessionId }, 'Failed to parse ASK_GUARDIAN_APPROVAL JSON payload');
|
|
647
666
|
}
|
|
648
667
|
}
|
|
649
668
|
|
|
650
|
-
const askMatch =
|
|
669
|
+
const askMatch = toolApprovalMeta
|
|
651
670
|
? null // structured approval takes precedence
|
|
652
671
|
: responseText.match(ASK_GUARDIAN_CAPTURE_REGEX);
|
|
653
672
|
|
|
654
|
-
const questionText =
|
|
673
|
+
const questionText = toolApprovalMeta?.question ?? (askMatch ? askMatch[1] : null);
|
|
655
674
|
|
|
656
675
|
if (questionText) {
|
|
657
676
|
if (this.isCallerGuardian()) {
|
|
@@ -690,6 +709,8 @@ export class CallController {
|
|
|
690
709
|
conversationId: session.conversationId,
|
|
691
710
|
assistantId: this.assistantId,
|
|
692
711
|
pendingQuestion,
|
|
712
|
+
toolName: toolApprovalMeta?.toolName,
|
|
713
|
+
inputDigest: toolApprovalMeta?.inputDigest,
|
|
693
714
|
});
|
|
694
715
|
}
|
|
695
716
|
|
package/src/calls/call-domain.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { getTwilioStatusCallbackUrl,getTwilioVoiceWebhookUrl } from '../inbound/
|
|
|
13
13
|
import { getOrCreateConversation } from '../memory/conversation-key-store.js';
|
|
14
14
|
import { queueGenerateConversationTitle } from '../memory/conversation-title-service.js';
|
|
15
15
|
import { upsertBinding } from '../memory/external-conversation-store.js';
|
|
16
|
+
import { revokeScopedApprovalGrantsForContext } from '../memory/scoped-approval-grants.js';
|
|
16
17
|
import { isGuardian } from '../runtime/channel-guardian-service.js';
|
|
17
18
|
import { getSecureKey } from '../security/secure-keys.js';
|
|
18
19
|
import { getLogger } from '../util/logger.js';
|
|
@@ -489,6 +490,17 @@ export async function cancelCall(input: CancelCallInput): Promise<{ ok: true; se
|
|
|
489
490
|
// Expire any pending questions so they don't linger
|
|
490
491
|
expirePendingQuestions(callSessionId);
|
|
491
492
|
|
|
493
|
+
// Revoke any scoped approval grants bound to this call session.
|
|
494
|
+
// Revoke by both callSessionId and conversationId because the
|
|
495
|
+
// guardian-approval-interception minting path sets callSessionId: null
|
|
496
|
+
// but always sets conversationId.
|
|
497
|
+
try {
|
|
498
|
+
revokeScopedApprovalGrantsForContext({ callSessionId });
|
|
499
|
+
revokeScopedApprovalGrantsForContext({ conversationId: session.conversationId });
|
|
500
|
+
} catch (err) {
|
|
501
|
+
log.warn({ err, callSessionId }, 'Failed to revoke scoped grants on call cancel');
|
|
502
|
+
}
|
|
503
|
+
|
|
492
504
|
// Re-check final status: a concurrent transition (e.g. Twilio callback) may have
|
|
493
505
|
// moved the session to a terminal state before our update, causing it to be skipped.
|
|
494
506
|
const updated = getCallSession(callSessionId);
|