@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.
Files changed (42) hide show
  1. package/ARCHITECTURE.md +4 -0
  2. package/docs/architecture/security.md +80 -0
  3. package/package.json +1 -1
  4. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +4 -0
  5. package/src/__tests__/call-controller.test.ts +170 -0
  6. package/src/__tests__/checker.test.ts +60 -0
  7. package/src/__tests__/guardian-action-grant-mint-consume.test.ts +511 -0
  8. package/src/__tests__/guardian-dispatch.test.ts +61 -1
  9. package/src/__tests__/guardian-grant-minting.test.ts +543 -0
  10. package/src/__tests__/ipc-snapshot.test.ts +1 -0
  11. package/src/__tests__/remote-skill-policy.test.ts +215 -0
  12. package/src/__tests__/scoped-approval-grants.test.ts +521 -0
  13. package/src/__tests__/scoped-grant-security-matrix.test.ts +443 -0
  14. package/src/__tests__/trust-store.test.ts +2 -0
  15. package/src/__tests__/voice-scoped-grant-consumer.test.ts +571 -0
  16. package/src/calls/call-controller.ts +27 -6
  17. package/src/calls/call-domain.ts +12 -0
  18. package/src/calls/guardian-dispatch.ts +8 -0
  19. package/src/calls/relay-server.ts +13 -0
  20. package/src/calls/voice-session-bridge.ts +42 -3
  21. package/src/config/bundled-skills/notifications/SKILL.md +18 -0
  22. package/src/config/schema.ts +6 -0
  23. package/src/config/skills-schema.ts +27 -0
  24. package/src/daemon/handlers/config-channels.ts +18 -0
  25. package/src/daemon/handlers/skills.ts +45 -2
  26. package/src/daemon/ipc-contract/skills.ts +1 -0
  27. package/src/daemon/session-process.ts +12 -0
  28. package/src/memory/db-init.ts +9 -1
  29. package/src/memory/embedding-local.ts +16 -7
  30. package/src/memory/guardian-action-store.ts +8 -0
  31. package/src/memory/guardian-verification.ts +1 -1
  32. package/src/memory/migrations/033-scoped-approval-grants.ts +51 -0
  33. package/src/memory/migrations/034-guardian-action-tool-metadata.ts +12 -0
  34. package/src/memory/migrations/index.ts +2 -0
  35. package/src/memory/schema.ts +30 -0
  36. package/src/memory/scoped-approval-grants.ts +509 -0
  37. package/src/permissions/checker.ts +27 -0
  38. package/src/runtime/guardian-action-grant-minter.ts +97 -0
  39. package/src/runtime/routes/guardian-approval-interception.ts +116 -0
  40. package/src/runtime/routes/inbound-message-handler.ts +94 -27
  41. package/src/security/tool-approval-digest.ts +67 -0
  42. package/src/skills/remote-skill-policy.ts +131 -0
@@ -0,0 +1,543 @@
1
+ /**
2
+ * Tests for M3: scoped grant minting on guardian tool-approval decisions.
3
+ *
4
+ * When a guardian approves a tool-approval request (one with toolName + input),
5
+ * the approval interception flow should mint a `tool_signature` scoped grant.
6
+ * Non-tool-approval requests and rejections must NOT mint grants.
7
+ */
8
+
9
+ import { mkdtempSync, rmSync } from 'node:fs';
10
+ import { tmpdir } from 'node:os';
11
+ import { join } from 'node:path';
12
+
13
+ import { afterAll, beforeEach, describe, expect, mock, spyOn, test } from 'bun:test';
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Test isolation: in-memory SQLite via temp directory
17
+ // ---------------------------------------------------------------------------
18
+
19
+ const testDir = mkdtempSync(join(tmpdir(), 'guardian-grant-minting-test-'));
20
+
21
+ mock.module('../util/platform.js', () => ({
22
+ getRootDir: () => testDir,
23
+ getDataDir: () => testDir,
24
+ isMacOS: () => process.platform === 'darwin',
25
+ isLinux: () => process.platform === 'linux',
26
+ isWindows: () => process.platform === 'win32',
27
+ getSocketPath: () => join(testDir, 'test.sock'),
28
+ getPidPath: () => join(testDir, 'test.pid'),
29
+ getDbPath: () => join(testDir, 'test.db'),
30
+ getLogPath: () => join(testDir, 'test.log'),
31
+ ensureDataDir: () => {},
32
+ }));
33
+
34
+ mock.module('../util/logger.js', () => ({
35
+ getLogger: () => new Proxy({} as Record<string, unknown>, {
36
+ get: () => () => {},
37
+ }),
38
+ }));
39
+
40
+ import type { Session } from '../daemon/session.js';
41
+ import {
42
+ createApprovalRequest,
43
+ createBinding,
44
+ getAllPendingApprovalsByGuardianChat,
45
+ type GuardianApprovalRequest,
46
+ } from '../memory/channel-guardian-store.js';
47
+ import { initializeDb, resetDb } from '../memory/db.js';
48
+ import * as scopedGrantStore from '../memory/scoped-approval-grants.js';
49
+ import { computeToolApprovalDigest } from '../security/tool-approval-digest.js';
50
+ import * as approvalMessageComposer from '../runtime/approval-message-composer.js';
51
+ import * as gatewayClient from '../runtime/gateway-client.js';
52
+ import * as pendingInteractions from '../runtime/pending-interactions.js';
53
+ import {
54
+ handleApprovalInterception,
55
+ GRANT_TTL_MS,
56
+ } from '../runtime/routes/guardian-approval-interception.js';
57
+ import type { GuardianContext } from '../runtime/routes/channel-route-shared.js';
58
+
59
+ initializeDb();
60
+
61
+ afterAll(() => {
62
+ resetDb();
63
+ try { rmSync(testDir, { recursive: true }); } catch { /* best effort */ }
64
+ });
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Helpers
68
+ // ---------------------------------------------------------------------------
69
+
70
+ const ASSISTANT_ID = 'self';
71
+ const GUARDIAN_USER = 'guardian-user-1';
72
+ const GUARDIAN_CHAT = 'guardian-chat-1';
73
+ const REQUESTER_USER = 'requester-user-1';
74
+ const REQUESTER_CHAT = 'requester-chat-1';
75
+ const CONVERSATION_ID = 'conv-1';
76
+ const TOOL_NAME = 'execute_shell';
77
+ const TOOL_INPUT = { command: 'rm -rf /tmp/test' };
78
+
79
+ function resetTables(): void {
80
+ try {
81
+ const { getDb } = require('../memory/db.js');
82
+ const db = getDb();
83
+ db.run('DELETE FROM channel_guardian_approval_requests');
84
+ db.run('DELETE FROM scoped_approval_grants');
85
+ } catch { /* tables may not exist yet */ }
86
+ pendingInteractions.clear();
87
+ }
88
+
89
+ function createTestGuardianApproval(
90
+ requestId: string,
91
+ overrides: Partial<Parameters<typeof createApprovalRequest>[0]> = {},
92
+ ): GuardianApprovalRequest {
93
+ return createApprovalRequest({
94
+ runId: `run-${requestId}`,
95
+ requestId,
96
+ conversationId: CONVERSATION_ID,
97
+ assistantId: ASSISTANT_ID,
98
+ channel: 'telegram',
99
+ requesterExternalUserId: REQUESTER_USER,
100
+ requesterChatId: REQUESTER_CHAT,
101
+ guardianExternalUserId: GUARDIAN_USER,
102
+ guardianChatId: GUARDIAN_CHAT,
103
+ toolName: TOOL_NAME,
104
+ expiresAt: Date.now() + 300_000,
105
+ ...overrides,
106
+ });
107
+ }
108
+
109
+ function registerPendingInteraction(
110
+ requestId: string,
111
+ conversationId: string,
112
+ toolName: string,
113
+ input: Record<string, unknown> = TOOL_INPUT,
114
+ ): ReturnType<typeof mock> {
115
+ const handleConfirmationResponse = mock(() => {});
116
+ const mockSession = {
117
+ handleConfirmationResponse,
118
+ } as unknown as Session;
119
+
120
+ pendingInteractions.register(requestId, {
121
+ session: mockSession,
122
+ conversationId,
123
+ kind: 'confirmation',
124
+ confirmationDetails: {
125
+ toolName,
126
+ input,
127
+ riskLevel: 'high',
128
+ allowlistOptions: [
129
+ { label: 'test', description: 'test', pattern: 'test' },
130
+ ],
131
+ scopeOptions: [
132
+ { label: 'everywhere', scope: 'everywhere' },
133
+ ],
134
+ },
135
+ });
136
+
137
+ return handleConfirmationResponse;
138
+ }
139
+
140
+ function makeGuardianContext(): GuardianContext {
141
+ return {
142
+ actorRole: 'guardian',
143
+ denialReason: undefined,
144
+ };
145
+ }
146
+
147
+ function makeNonGuardianContext(): GuardianContext {
148
+ return {
149
+ actorRole: 'non-guardian',
150
+ denialReason: undefined,
151
+ };
152
+ }
153
+
154
+ function countGrants(): number {
155
+ try {
156
+ const { getDb } = require('../memory/db.js');
157
+ const db = getDb();
158
+ const row = db.$client.prepare('SELECT count(*) as cnt FROM scoped_approval_grants').get() as { cnt: number };
159
+ return row.cnt;
160
+ } catch {
161
+ return 0;
162
+ }
163
+ }
164
+
165
+ function getLatestGrant(): Record<string, unknown> | null {
166
+ try {
167
+ const { getDb } = require('../memory/db.js');
168
+ const db = getDb();
169
+ const row = db.$client.prepare('SELECT * FROM scoped_approval_grants ORDER BY created_at DESC LIMIT 1').get();
170
+ return (row as Record<string, unknown>) ?? null;
171
+ } catch {
172
+ return null;
173
+ }
174
+ }
175
+
176
+ // ═══════════════════════════════════════════════════════════════════════════
177
+ // Tests
178
+ // ═══════════════════════════════════════════════════════════════════════════
179
+
180
+ describe('guardian grant minting on tool-approval decisions', () => {
181
+ let deliverSpy: ReturnType<typeof spyOn>;
182
+ let composeSpy: ReturnType<typeof spyOn>;
183
+
184
+ beforeEach(() => {
185
+ resetTables();
186
+ deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
187
+ composeSpy = spyOn(approvalMessageComposer, 'composeApprovalMessageGenerative')
188
+ .mockResolvedValue('test message');
189
+ });
190
+
191
+ // ── 1. approve_once via callback mints a grant ──
192
+
193
+ test('approve_once via callback for tool-approval request mints a scoped grant', async () => {
194
+ const requestId = 'req-grant-cb-1';
195
+ createTestGuardianApproval(requestId);
196
+ registerPendingInteraction(requestId, CONVERSATION_ID, TOOL_NAME, TOOL_INPUT);
197
+
198
+ const result = await handleApprovalInterception({
199
+ conversationId: 'guardian-conv-1',
200
+ callbackData: `apr:${requestId}:approve_once`,
201
+ content: '',
202
+ externalChatId: GUARDIAN_CHAT,
203
+ sourceChannel: 'telegram',
204
+ senderExternalUserId: GUARDIAN_USER,
205
+ replyCallbackUrl: 'https://gateway.test/deliver',
206
+ guardianCtx: makeGuardianContext(),
207
+ assistantId: ASSISTANT_ID,
208
+ });
209
+
210
+ expect(result.handled).toBe(true);
211
+ expect(result.type).toBe('guardian_decision_applied');
212
+
213
+ // Verify a grant was minted
214
+ expect(countGrants()).toBe(1);
215
+
216
+ const grant = getLatestGrant();
217
+ expect(grant).not.toBeNull();
218
+ expect(grant!.scope_mode).toBe('tool_signature');
219
+ expect(grant!.tool_name).toBe(TOOL_NAME);
220
+ expect(grant!.status).toBe('active');
221
+ expect(grant!.request_channel).toBe('telegram');
222
+ expect(grant!.decision_channel).toBe('telegram');
223
+ expect(grant!.guardian_external_user_id).toBe(GUARDIAN_USER);
224
+ expect(grant!.requester_external_user_id).toBe(REQUESTER_USER);
225
+ expect(grant!.conversation_id).toBe(CONVERSATION_ID);
226
+ expect(grant!.execution_channel).toBeNull();
227
+ expect(grant!.call_session_id).toBeNull();
228
+
229
+ // Verify the input digest matches what computeToolApprovalDigest produces
230
+ const expectedDigest = computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT);
231
+ expect(grant!.input_digest).toBe(expectedDigest);
232
+
233
+ deliverSpy.mockRestore();
234
+ composeSpy.mockRestore();
235
+ });
236
+
237
+ // ── 2. approve_once for non-tool-approval does NOT mint a grant ──
238
+
239
+ test('approve_once for informational request (no toolName) does NOT mint a grant', async () => {
240
+ const requestId = 'req-no-grant-1';
241
+ // Informational requests have no meaningful tool name — the empty string
242
+ // signals that this is not a tool-approval request.
243
+ createTestGuardianApproval(requestId, { toolName: '' });
244
+ registerPendingInteraction(requestId, CONVERSATION_ID, '', {});
245
+
246
+ const result = await handleApprovalInterception({
247
+ conversationId: 'guardian-conv-2',
248
+ callbackData: `apr:${requestId}:approve_once`,
249
+ content: '',
250
+ externalChatId: GUARDIAN_CHAT,
251
+ sourceChannel: 'telegram',
252
+ senderExternalUserId: GUARDIAN_USER,
253
+ replyCallbackUrl: 'https://gateway.test/deliver',
254
+ guardianCtx: makeGuardianContext(),
255
+ assistantId: ASSISTANT_ID,
256
+ });
257
+
258
+ expect(result.handled).toBe(true);
259
+ expect(result.type).toBe('guardian_decision_applied');
260
+
261
+ // No grant should have been minted
262
+ expect(countGrants()).toBe(0);
263
+
264
+ deliverSpy.mockRestore();
265
+ composeSpy.mockRestore();
266
+ });
267
+
268
+ // ── 2b. approve_once for zero-argument tool call DOES mint a grant ──
269
+
270
+ test('approve_once for zero-argument tool call mints a scoped grant', async () => {
271
+ const requestId = 'req-grant-zero-arg';
272
+ const zeroArgTool = 'get_system_status';
273
+ createTestGuardianApproval(requestId, { toolName: zeroArgTool });
274
+ // Register with empty input object to simulate a zero-argument tool call
275
+ registerPendingInteraction(requestId, CONVERSATION_ID, zeroArgTool, {});
276
+
277
+ const result = await handleApprovalInterception({
278
+ conversationId: 'guardian-conv-2b',
279
+ callbackData: `apr:${requestId}:approve_once`,
280
+ content: '',
281
+ externalChatId: GUARDIAN_CHAT,
282
+ sourceChannel: 'telegram',
283
+ senderExternalUserId: GUARDIAN_USER,
284
+ replyCallbackUrl: 'https://gateway.test/deliver',
285
+ guardianCtx: makeGuardianContext(),
286
+ assistantId: ASSISTANT_ID,
287
+ });
288
+
289
+ expect(result.handled).toBe(true);
290
+ expect(result.type).toBe('guardian_decision_applied');
291
+
292
+ // A grant MUST be minted even though input is {}
293
+ expect(countGrants()).toBe(1);
294
+
295
+ const grant = getLatestGrant();
296
+ expect(grant).not.toBeNull();
297
+ expect(grant!.scope_mode).toBe('tool_signature');
298
+ expect(grant!.tool_name).toBe(zeroArgTool);
299
+ expect(grant!.status).toBe('active');
300
+
301
+ // Verify the input digest matches what computeToolApprovalDigest produces for empty input
302
+ const expectedDigest = computeToolApprovalDigest(zeroArgTool, {});
303
+ expect(grant!.input_digest).toBe(expectedDigest);
304
+
305
+ deliverSpy.mockRestore();
306
+ composeSpy.mockRestore();
307
+ });
308
+
309
+ // ── 3. reject does NOT mint a grant ──
310
+
311
+ test('reject decision does NOT mint a scoped grant', async () => {
312
+ const requestId = 'req-no-grant-rej';
313
+ createTestGuardianApproval(requestId);
314
+ registerPendingInteraction(requestId, CONVERSATION_ID, TOOL_NAME, TOOL_INPUT);
315
+
316
+ const result = await handleApprovalInterception({
317
+ conversationId: 'guardian-conv-3',
318
+ callbackData: `apr:${requestId}:reject`,
319
+ content: '',
320
+ externalChatId: GUARDIAN_CHAT,
321
+ sourceChannel: 'telegram',
322
+ senderExternalUserId: GUARDIAN_USER,
323
+ replyCallbackUrl: 'https://gateway.test/deliver',
324
+ guardianCtx: makeGuardianContext(),
325
+ assistantId: ASSISTANT_ID,
326
+ });
327
+
328
+ expect(result.handled).toBe(true);
329
+ expect(result.type).toBe('guardian_decision_applied');
330
+
331
+ // No grant should have been minted
332
+ expect(countGrants()).toBe(0);
333
+
334
+ deliverSpy.mockRestore();
335
+ composeSpy.mockRestore();
336
+ });
337
+
338
+ // ── 4. Identity mismatch remains fail-closed (no grant minted) ──
339
+
340
+ test('identity mismatch does NOT mint a grant and fails closed', async () => {
341
+ const requestId = 'req-mismatch-1';
342
+ createTestGuardianApproval(requestId);
343
+ registerPendingInteraction(requestId, CONVERSATION_ID, TOOL_NAME, TOOL_INPUT);
344
+
345
+ const result = await handleApprovalInterception({
346
+ conversationId: 'guardian-conv-4',
347
+ callbackData: `apr:${requestId}:approve_once`,
348
+ content: '',
349
+ externalChatId: GUARDIAN_CHAT,
350
+ sourceChannel: 'telegram',
351
+ senderExternalUserId: 'wrong-guardian-user',
352
+ replyCallbackUrl: 'https://gateway.test/deliver',
353
+ guardianCtx: makeGuardianContext(),
354
+ assistantId: ASSISTANT_ID,
355
+ });
356
+
357
+ expect(result.handled).toBe(true);
358
+ // Identity mismatch results in guardian_decision_applied (fail-closed, no actual decision applied)
359
+ expect(result.type).toBe('guardian_decision_applied');
360
+
361
+ // No grant should have been minted
362
+ expect(countGrants()).toBe(0);
363
+
364
+ deliverSpy.mockRestore();
365
+ composeSpy.mockRestore();
366
+ });
367
+
368
+ // ── 5. Stale/already-resolved request does NOT mint a grant ──
369
+
370
+ test('stale request (already resolved) does NOT mint a grant', async () => {
371
+ const requestId = 'req-stale-1';
372
+ // Create guardian approval but do NOT register a pending interaction
373
+ // This simulates the pending interaction being already resolved
374
+ createTestGuardianApproval(requestId);
375
+
376
+ const result = await handleApprovalInterception({
377
+ conversationId: 'guardian-conv-5',
378
+ callbackData: `apr:${requestId}:approve_once`,
379
+ content: '',
380
+ externalChatId: GUARDIAN_CHAT,
381
+ sourceChannel: 'telegram',
382
+ senderExternalUserId: GUARDIAN_USER,
383
+ replyCallbackUrl: 'https://gateway.test/deliver',
384
+ guardianCtx: makeGuardianContext(),
385
+ assistantId: ASSISTANT_ID,
386
+ });
387
+
388
+ expect(result.handled).toBe(true);
389
+ expect(result.type).toBe('stale_ignored');
390
+
391
+ // No grant should have been minted
392
+ expect(countGrants()).toBe(0);
393
+
394
+ deliverSpy.mockRestore();
395
+ composeSpy.mockRestore();
396
+ });
397
+
398
+ // ── 6. approve_once via conversation engine mints a grant ──
399
+
400
+ test('approve_once via conversation engine mints a scoped grant', async () => {
401
+ const requestId = 'req-grant-eng-1';
402
+ createTestGuardianApproval(requestId);
403
+ registerPendingInteraction(requestId, CONVERSATION_ID, TOOL_NAME, TOOL_INPUT);
404
+
405
+ const mockGenerator = async () => ({
406
+ disposition: 'approve_once' as const,
407
+ replyText: 'Approved!',
408
+ targetRequestId: requestId,
409
+ });
410
+
411
+ const result = await handleApprovalInterception({
412
+ conversationId: 'guardian-conv-6',
413
+ content: 'yes, approve it',
414
+ externalChatId: GUARDIAN_CHAT,
415
+ sourceChannel: 'telegram',
416
+ senderExternalUserId: GUARDIAN_USER,
417
+ replyCallbackUrl: 'https://gateway.test/deliver',
418
+ guardianCtx: makeGuardianContext(),
419
+ assistantId: ASSISTANT_ID,
420
+ approvalConversationGenerator: mockGenerator,
421
+ });
422
+
423
+ expect(result.handled).toBe(true);
424
+ expect(result.type).toBe('guardian_decision_applied');
425
+
426
+ // Verify a grant was minted
427
+ expect(countGrants()).toBe(1);
428
+
429
+ const grant = getLatestGrant();
430
+ expect(grant).not.toBeNull();
431
+ expect(grant!.scope_mode).toBe('tool_signature');
432
+ expect(grant!.tool_name).toBe(TOOL_NAME);
433
+ expect(grant!.status).toBe('active');
434
+
435
+ deliverSpy.mockRestore();
436
+ composeSpy.mockRestore();
437
+ });
438
+
439
+ // ── 7. reject via conversation engine does NOT mint a grant ──
440
+
441
+ test('reject via conversation engine does NOT mint a grant', async () => {
442
+ const requestId = 'req-no-grant-eng-rej';
443
+ createTestGuardianApproval(requestId);
444
+ registerPendingInteraction(requestId, CONVERSATION_ID, TOOL_NAME, TOOL_INPUT);
445
+
446
+ const mockGenerator = async () => ({
447
+ disposition: 'reject' as const,
448
+ replyText: 'Denied.',
449
+ targetRequestId: requestId,
450
+ });
451
+
452
+ const result = await handleApprovalInterception({
453
+ conversationId: 'guardian-conv-7',
454
+ content: 'no, deny it',
455
+ externalChatId: GUARDIAN_CHAT,
456
+ sourceChannel: 'telegram',
457
+ senderExternalUserId: GUARDIAN_USER,
458
+ replyCallbackUrl: 'https://gateway.test/deliver',
459
+ guardianCtx: makeGuardianContext(),
460
+ assistantId: ASSISTANT_ID,
461
+ approvalConversationGenerator: mockGenerator,
462
+ });
463
+
464
+ expect(result.handled).toBe(true);
465
+ expect(result.type).toBe('guardian_decision_applied');
466
+
467
+ // No grant should have been minted
468
+ expect(countGrants()).toBe(0);
469
+
470
+ deliverSpy.mockRestore();
471
+ composeSpy.mockRestore();
472
+ });
473
+
474
+ // ── 8. approve_once via legacy parser mints a grant ──
475
+
476
+ test('approve_once via legacy parser mints a scoped grant', async () => {
477
+ const requestId = 'req-grant-leg-1';
478
+ createTestGuardianApproval(requestId);
479
+ registerPendingInteraction(requestId, CONVERSATION_ID, TOOL_NAME, TOOL_INPUT);
480
+
481
+ // No approvalConversationGenerator => legacy parser path
482
+ const result = await handleApprovalInterception({
483
+ conversationId: 'guardian-conv-8',
484
+ content: 'yes',
485
+ externalChatId: GUARDIAN_CHAT,
486
+ sourceChannel: 'telegram',
487
+ senderExternalUserId: GUARDIAN_USER,
488
+ replyCallbackUrl: 'https://gateway.test/deliver',
489
+ guardianCtx: makeGuardianContext(),
490
+ assistantId: ASSISTANT_ID,
491
+ });
492
+
493
+ expect(result.handled).toBe(true);
494
+ expect(result.type).toBe('guardian_decision_applied');
495
+
496
+ // Verify a grant was minted
497
+ expect(countGrants()).toBe(1);
498
+
499
+ const grant = getLatestGrant();
500
+ expect(grant).not.toBeNull();
501
+ expect(grant!.scope_mode).toBe('tool_signature');
502
+ expect(grant!.tool_name).toBe(TOOL_NAME);
503
+
504
+ deliverSpy.mockRestore();
505
+ composeSpy.mockRestore();
506
+ });
507
+
508
+ // ── 9. Grant TTL is approximately 5 minutes ──
509
+
510
+ test('minted grant has approximately 5-minute TTL', async () => {
511
+ const requestId = 'req-grant-ttl-1';
512
+ createTestGuardianApproval(requestId);
513
+ registerPendingInteraction(requestId, CONVERSATION_ID, TOOL_NAME, TOOL_INPUT);
514
+
515
+ const beforeTime = Date.now();
516
+
517
+ const result = await handleApprovalInterception({
518
+ conversationId: 'guardian-conv-9',
519
+ callbackData: `apr:${requestId}:approve_once`,
520
+ content: '',
521
+ externalChatId: GUARDIAN_CHAT,
522
+ sourceChannel: 'telegram',
523
+ senderExternalUserId: GUARDIAN_USER,
524
+ replyCallbackUrl: 'https://gateway.test/deliver',
525
+ guardianCtx: makeGuardianContext(),
526
+ assistantId: ASSISTANT_ID,
527
+ });
528
+
529
+ expect(result.type).toBe('guardian_decision_applied');
530
+
531
+ const grant = getLatestGrant();
532
+ expect(grant).not.toBeNull();
533
+
534
+ const expiresAt = new Date(grant!.expires_at as string).getTime();
535
+ const expectedMin = beforeTime + GRANT_TTL_MS - 1000; // 1s tolerance
536
+ const expectedMax = beforeTime + GRANT_TTL_MS + 5000; // 5s tolerance
537
+ expect(expiresAt).toBeGreaterThanOrEqual(expectedMin);
538
+ expect(expiresAt).toBeLessThanOrEqual(expectedMax);
539
+
540
+ deliverSpy.mockRestore();
541
+ composeSpy.mockRestore();
542
+ });
543
+ });
@@ -1081,6 +1081,7 @@ const serverMessages: Record<ServerMessageType, ServerMessage> = {
1081
1081
  degraded: false,
1082
1082
  updateAvailable: false,
1083
1083
  userInvocable: true,
1084
+ provenance: { kind: 'first-party', provider: 'Vellum' },
1084
1085
  },
1085
1086
  ],
1086
1087
  },