@vellumai/assistant 0.3.26 → 0.3.28

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 (82) hide show
  1. package/ARCHITECTURE.md +48 -1
  2. package/Dockerfile +2 -2
  3. package/package.json +1 -1
  4. package/scripts/ipc/generate-swift.ts +6 -2
  5. package/src/__tests__/agent-loop.test.ts +119 -0
  6. package/src/__tests__/bundled-asset.test.ts +107 -0
  7. package/src/__tests__/canonical-guardian-store.test.ts +636 -0
  8. package/src/__tests__/channel-approval-routes.test.ts +174 -1
  9. package/src/__tests__/emit-signal-routing-intent.test.ts +43 -1
  10. package/src/__tests__/guardian-actions-endpoint.test.ts +205 -345
  11. package/src/__tests__/guardian-decision-primitive-canonical.test.ts +599 -0
  12. package/src/__tests__/guardian-dispatch.test.ts +19 -19
  13. package/src/__tests__/guardian-routing-invariants.test.ts +954 -0
  14. package/src/__tests__/mcp-cli.test.ts +77 -0
  15. package/src/__tests__/non-member-access-request.test.ts +31 -29
  16. package/src/__tests__/notification-decision-fallback.test.ts +61 -3
  17. package/src/__tests__/notification-decision-strategy.test.ts +17 -0
  18. package/src/__tests__/notification-guardian-path.test.ts +13 -15
  19. package/src/__tests__/onboarding-template-contract.test.ts +116 -21
  20. package/src/__tests__/secret-scanner-executor.test.ts +59 -0
  21. package/src/__tests__/secret-scanner.test.ts +8 -0
  22. package/src/__tests__/sensitive-output-placeholders.test.ts +208 -0
  23. package/src/__tests__/session-runtime-assembly.test.ts +76 -47
  24. package/src/__tests__/tool-grant-request-escalation.test.ts +497 -0
  25. package/src/agent/loop.ts +46 -3
  26. package/src/approvals/guardian-decision-primitive.ts +285 -0
  27. package/src/approvals/guardian-request-resolvers.ts +539 -0
  28. package/src/calls/guardian-dispatch.ts +46 -40
  29. package/src/calls/relay-server.ts +147 -2
  30. package/src/calls/types.ts +1 -1
  31. package/src/config/system-prompt.ts +2 -1
  32. package/src/config/templates/BOOTSTRAP.md +47 -31
  33. package/src/config/templates/USER.md +5 -0
  34. package/src/config/update-bulletin-template-path.ts +4 -1
  35. package/src/config/vellum-skills/trusted-contacts/SKILL.md +22 -17
  36. package/src/daemon/handlers/guardian-actions.ts +45 -66
  37. package/src/daemon/ipc-contract/guardian-actions.ts +7 -0
  38. package/src/daemon/lifecycle.ts +3 -16
  39. package/src/daemon/server.ts +18 -0
  40. package/src/daemon/session-agent-loop-handlers.ts +5 -4
  41. package/src/daemon/session-agent-loop.ts +32 -5
  42. package/src/daemon/session-process.ts +68 -307
  43. package/src/daemon/session-runtime-assembly.ts +112 -24
  44. package/src/daemon/session-tool-setup.ts +1 -0
  45. package/src/daemon/session.ts +1 -0
  46. package/src/home-base/prebuilt/seed.ts +2 -1
  47. package/src/hooks/templates.ts +2 -1
  48. package/src/memory/canonical-guardian-store.ts +524 -0
  49. package/src/memory/channel-guardian-store.ts +1 -0
  50. package/src/memory/db-init.ts +16 -0
  51. package/src/memory/guardian-action-store.ts +7 -60
  52. package/src/memory/guardian-approvals.ts +9 -4
  53. package/src/memory/migrations/036-normalize-phone-identities.ts +289 -0
  54. package/src/memory/migrations/118-reminder-routing-intent.ts +3 -3
  55. package/src/memory/migrations/121-canonical-guardian-requests.ts +59 -0
  56. package/src/memory/migrations/122-canonical-guardian-requester-chat-id.ts +15 -0
  57. package/src/memory/migrations/123-canonical-guardian-deliveries-destination-index.ts +15 -0
  58. package/src/memory/migrations/index.ts +4 -0
  59. package/src/memory/migrations/registry.ts +5 -0
  60. package/src/memory/schema-migration.ts +1 -0
  61. package/src/memory/schema.ts +52 -0
  62. package/src/notifications/copy-composer.ts +16 -4
  63. package/src/notifications/decision-engine.ts +57 -0
  64. package/src/permissions/defaults.ts +2 -0
  65. package/src/runtime/access-request-helper.ts +137 -0
  66. package/src/runtime/actor-trust-resolver.ts +225 -0
  67. package/src/runtime/channel-guardian-service.ts +12 -4
  68. package/src/runtime/guardian-context-resolver.ts +32 -7
  69. package/src/runtime/guardian-decision-types.ts +6 -0
  70. package/src/runtime/guardian-reply-router.ts +687 -0
  71. package/src/runtime/http-server.ts +8 -0
  72. package/src/runtime/routes/canonical-guardian-expiry-sweep.ts +116 -0
  73. package/src/runtime/routes/conversation-routes.ts +18 -0
  74. package/src/runtime/routes/guardian-action-routes.ts +100 -109
  75. package/src/runtime/routes/inbound-message-handler.ts +170 -525
  76. package/src/runtime/tool-grant-request-helper.ts +195 -0
  77. package/src/tools/executor.ts +13 -1
  78. package/src/tools/sensitive-output-placeholders.ts +203 -0
  79. package/src/tools/tool-approval-handler.ts +44 -1
  80. package/src/tools/types.ts +11 -0
  81. package/src/util/bundled-asset.ts +31 -0
  82. package/src/util/canonicalize-identity.ts +52 -0
@@ -0,0 +1,599 @@
1
+ import { mkdtempSync, rmSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+
5
+ import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test';
6
+
7
+ const testDir = mkdtempSync(join(tmpdir(), 'canonical-decision-test-'));
8
+
9
+ mock.module('../util/platform.js', () => ({
10
+ getDataDir: () => testDir,
11
+ isMacOS: () => process.platform === 'darwin',
12
+ isLinux: () => process.platform === 'linux',
13
+ isWindows: () => process.platform === 'win32',
14
+ getSocketPath: () => join(testDir, 'test.sock'),
15
+ getPidPath: () => join(testDir, 'test.pid'),
16
+ getDbPath: () => join(testDir, 'test.db'),
17
+ getLogPath: () => join(testDir, 'test.log'),
18
+ ensureDataDir: () => {},
19
+ migrateToDataLayout: () => {},
20
+ migrateToWorkspaceLayout: () => {},
21
+ }));
22
+
23
+ mock.module('../util/logger.js', () => ({
24
+ getLogger: () =>
25
+ new Proxy({} as Record<string, unknown>, {
26
+ get: () => () => {},
27
+ }),
28
+ isDebug: () => false,
29
+ truncateForLog: (value: string) => value,
30
+ }));
31
+
32
+ import {
33
+ applyCanonicalGuardianDecision,
34
+ mintCanonicalRequestGrant,
35
+ } from '../approvals/guardian-decision-primitive.js';
36
+ import type { ActorContext } from '../approvals/guardian-request-resolvers.js';
37
+ import { getRegisteredKinds,getResolver } from '../approvals/guardian-request-resolvers.js';
38
+ import {
39
+ createCanonicalGuardianRequest,
40
+ getCanonicalGuardianRequest,
41
+ } from '../memory/canonical-guardian-store.js';
42
+ import { getDb, initializeDb, resetDb } from '../memory/db.js';
43
+ import { scopedApprovalGrants } from '../memory/schema.js';
44
+
45
+ initializeDb();
46
+
47
+ function resetTables(): void {
48
+ const db = getDb();
49
+ db.run('DELETE FROM scoped_approval_grants');
50
+ db.run('DELETE FROM canonical_guardian_deliveries');
51
+ db.run('DELETE FROM canonical_guardian_requests');
52
+ }
53
+
54
+ afterAll(() => {
55
+ resetDb();
56
+ try {
57
+ rmSync(testDir, { recursive: true });
58
+ } catch {
59
+ // best-effort cleanup
60
+ }
61
+ });
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Helpers
65
+ // ---------------------------------------------------------------------------
66
+
67
+ function guardianActor(overrides: Partial<ActorContext> = {}): ActorContext {
68
+ return {
69
+ externalUserId: 'guardian-1',
70
+ channel: 'telegram',
71
+ isTrusted: false,
72
+ ...overrides,
73
+ };
74
+ }
75
+
76
+ function trustedActor(overrides: Partial<ActorContext> = {}): ActorContext {
77
+ return {
78
+ externalUserId: undefined,
79
+ channel: 'desktop',
80
+ isTrusted: true,
81
+ ...overrides,
82
+ };
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Resolver registry tests
87
+ // ---------------------------------------------------------------------------
88
+
89
+ describe('guardian-request-resolvers / registry', () => {
90
+ test('built-in resolvers are registered', () => {
91
+ const kinds = getRegisteredKinds();
92
+ expect(kinds).toContain('tool_approval');
93
+ expect(kinds).toContain('pending_question');
94
+ });
95
+
96
+ test('getResolver returns undefined for unknown kind', () => {
97
+ expect(getResolver('nonexistent_kind')).toBeUndefined();
98
+ });
99
+
100
+ test('getResolver returns resolver for known kind', () => {
101
+ const resolver = getResolver('tool_approval');
102
+ expect(resolver).toBeDefined();
103
+ expect(resolver!.kind).toBe('tool_approval');
104
+ });
105
+ });
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // applyCanonicalGuardianDecision tests
109
+ // ---------------------------------------------------------------------------
110
+
111
+ describe('applyCanonicalGuardianDecision', () => {
112
+ beforeEach(() => resetTables());
113
+
114
+ // ── Successful approval ─────────────────────────────────────────────
115
+
116
+ test('approves a pending tool_approval request', async () => {
117
+ const req = createCanonicalGuardianRequest({
118
+ kind: 'tool_approval',
119
+ sourceType: 'channel',
120
+ sourceChannel: 'telegram',
121
+ conversationId: 'conv-1',
122
+ guardianExternalUserId: 'guardian-1',
123
+ toolName: 'shell',
124
+ inputDigest: 'sha256:abc',
125
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
126
+ });
127
+
128
+ const result = await applyCanonicalGuardianDecision({
129
+ requestId: req.id,
130
+ action: 'approve_once',
131
+ actorContext: guardianActor(),
132
+ });
133
+
134
+ expect(result.applied).toBe(true);
135
+ if (!result.applied) return;
136
+ expect(result.requestId).toBe(req.id);
137
+ // Grant is not minted because the tool_approval resolver fails (no pending
138
+ // interaction registered in the test environment). The decision primitive
139
+ // correctly skips grant minting when the resolver reports a failure.
140
+ expect(result.grantMinted).toBe(false);
141
+ expect(result.resolverFailed).toBe(true);
142
+
143
+ // Verify canonical request state
144
+ const resolved = getCanonicalGuardianRequest(req.id);
145
+ expect(resolved!.status).toBe('approved');
146
+ expect(resolved!.decidedByExternalUserId).toBe('guardian-1');
147
+ });
148
+
149
+ test('denies a pending tool_approval request', async () => {
150
+ const req = createCanonicalGuardianRequest({
151
+ kind: 'tool_approval',
152
+ sourceType: 'channel',
153
+ sourceChannel: 'telegram',
154
+ conversationId: 'conv-1',
155
+ guardianExternalUserId: 'guardian-1',
156
+ toolName: 'shell',
157
+ inputDigest: 'sha256:abc',
158
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
159
+ });
160
+
161
+ const result = await applyCanonicalGuardianDecision({
162
+ requestId: req.id,
163
+ action: 'reject',
164
+ actorContext: guardianActor(),
165
+ });
166
+
167
+ expect(result.applied).toBe(true);
168
+ if (!result.applied) return;
169
+ expect(result.grantMinted).toBe(false);
170
+
171
+ const resolved = getCanonicalGuardianRequest(req.id);
172
+ expect(resolved!.status).toBe('denied');
173
+ });
174
+
175
+ test('approves a pending_question request with answer text', async () => {
176
+ const req = createCanonicalGuardianRequest({
177
+ kind: 'pending_question',
178
+ sourceType: 'voice',
179
+ sourceChannel: 'twilio',
180
+ guardianExternalUserId: 'guardian-1',
181
+ callSessionId: 'call-1',
182
+ pendingQuestionId: 'pq-1',
183
+ questionText: 'What is the gate code?',
184
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
185
+ });
186
+
187
+ const result = await applyCanonicalGuardianDecision({
188
+ requestId: req.id,
189
+ action: 'approve_once',
190
+ actorContext: guardianActor(),
191
+ userText: '1234',
192
+ });
193
+
194
+ expect(result.applied).toBe(true);
195
+ if (!result.applied) return;
196
+
197
+ const resolved = getCanonicalGuardianRequest(req.id);
198
+ expect(resolved!.status).toBe('approved');
199
+ expect(resolved!.answerText).toBe('1234');
200
+ });
201
+
202
+ // ── Identity mismatch ──────────────────────────────────────────────
203
+
204
+ test('rejects decision when actor does not match guardian', async () => {
205
+ const req = createCanonicalGuardianRequest({
206
+ kind: 'tool_approval',
207
+ sourceType: 'channel',
208
+ conversationId: 'conv-1',
209
+ guardianExternalUserId: 'guardian-1',
210
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
211
+ });
212
+
213
+ const result = await applyCanonicalGuardianDecision({
214
+ requestId: req.id,
215
+ action: 'approve_once',
216
+ actorContext: guardianActor({ externalUserId: 'imposter-99' }),
217
+ });
218
+
219
+ expect(result.applied).toBe(false);
220
+ if (result.applied) return;
221
+ expect(result.reason).toBe('identity_mismatch');
222
+
223
+ // Request remains pending
224
+ const unchanged = getCanonicalGuardianRequest(req.id);
225
+ expect(unchanged!.status).toBe('pending');
226
+ });
227
+
228
+ test('trusted actor bypasses identity check', async () => {
229
+ const req = createCanonicalGuardianRequest({
230
+ kind: 'tool_approval',
231
+ sourceType: 'desktop',
232
+ conversationId: 'conv-1',
233
+ guardianExternalUserId: 'guardian-1',
234
+ toolName: 'shell',
235
+ inputDigest: 'sha256:abc',
236
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
237
+ });
238
+
239
+ const result = await applyCanonicalGuardianDecision({
240
+ requestId: req.id,
241
+ action: 'approve_once',
242
+ actorContext: trustedActor(),
243
+ });
244
+
245
+ expect(result.applied).toBe(true);
246
+ // No grant minted because trusted actor has no externalUserId
247
+ if (!result.applied) return;
248
+ expect(result.grantMinted).toBe(false);
249
+ });
250
+
251
+ test('allows decision when request has no guardian binding', async () => {
252
+ const req = createCanonicalGuardianRequest({
253
+ kind: 'tool_approval',
254
+ sourceType: 'channel',
255
+ conversationId: 'conv-1',
256
+ // No guardianExternalUserId — open request
257
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
258
+ });
259
+
260
+ const result = await applyCanonicalGuardianDecision({
261
+ requestId: req.id,
262
+ action: 'approve_once',
263
+ actorContext: guardianActor({ externalUserId: 'anyone' }),
264
+ });
265
+
266
+ expect(result.applied).toBe(true);
267
+ });
268
+
269
+ // ── Stale / already-resolved (race condition) ──────────────────────
270
+
271
+ test('second concurrent decision fails (first-writer-wins)', async () => {
272
+ const req = createCanonicalGuardianRequest({
273
+ kind: 'tool_approval',
274
+ sourceType: 'channel',
275
+ conversationId: 'conv-1',
276
+ guardianExternalUserId: 'guardian-1',
277
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
278
+ });
279
+
280
+ // First decision succeeds
281
+ const first = await applyCanonicalGuardianDecision({
282
+ requestId: req.id,
283
+ action: 'approve_once',
284
+ actorContext: guardianActor(),
285
+ });
286
+ expect(first.applied).toBe(true);
287
+
288
+ // Second decision fails — request is no longer pending
289
+ const second = await applyCanonicalGuardianDecision({
290
+ requestId: req.id,
291
+ action: 'reject',
292
+ actorContext: guardianActor(),
293
+ });
294
+ expect(second.applied).toBe(false);
295
+ if (second.applied) return;
296
+ expect(second.reason).toBe('already_resolved');
297
+
298
+ // First decision stuck
299
+ const final = getCanonicalGuardianRequest(req.id);
300
+ expect(final!.status).toBe('approved');
301
+ });
302
+
303
+ // ── Not found ──────────────────────────────────────────────────────
304
+
305
+ test('returns not_found for nonexistent request', async () => {
306
+ const result = await applyCanonicalGuardianDecision({
307
+ requestId: 'nonexistent-id',
308
+ action: 'approve_once',
309
+ actorContext: guardianActor(),
310
+ });
311
+
312
+ expect(result.applied).toBe(false);
313
+ if (result.applied) return;
314
+ expect(result.reason).toBe('not_found');
315
+ });
316
+
317
+ // ── Invalid action ─────────────────────────────────────────────────
318
+
319
+ test('rejects invalid action', async () => {
320
+ const req = createCanonicalGuardianRequest({
321
+ kind: 'tool_approval',
322
+ sourceType: 'channel',
323
+ conversationId: 'conv-1',
324
+ guardianExternalUserId: 'guardian-1',
325
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
326
+ });
327
+
328
+ const result = await applyCanonicalGuardianDecision({
329
+ requestId: req.id,
330
+ action: 'bogus_action' as any,
331
+ actorContext: guardianActor(),
332
+ });
333
+
334
+ expect(result.applied).toBe(false);
335
+ if (result.applied) return;
336
+ expect(result.reason).toBe('invalid_action');
337
+
338
+ // Request remains pending
339
+ const unchanged = getCanonicalGuardianRequest(req.id);
340
+ expect(unchanged!.status).toBe('pending');
341
+ });
342
+
343
+ // ── approve_always downgrade ───────────────────────────────────────
344
+
345
+ test('downgrades approve_always to approve_once', async () => {
346
+ const req = createCanonicalGuardianRequest({
347
+ kind: 'tool_approval',
348
+ sourceType: 'channel',
349
+ sourceChannel: 'telegram',
350
+ conversationId: 'conv-1',
351
+ guardianExternalUserId: 'guardian-1',
352
+ toolName: 'shell',
353
+ inputDigest: 'sha256:abc',
354
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
355
+ });
356
+
357
+ const result = await applyCanonicalGuardianDecision({
358
+ requestId: req.id,
359
+ action: 'approve_always',
360
+ actorContext: guardianActor(),
361
+ });
362
+
363
+ // Should succeed — approve_always was silently downgraded to approve_once
364
+ expect(result.applied).toBe(true);
365
+ if (!result.applied) return;
366
+
367
+ // The canonical request should be approved (not "always approved")
368
+ const resolved = getCanonicalGuardianRequest(req.id);
369
+ expect(resolved!.status).toBe('approved');
370
+
371
+ // Grant is not minted because the tool_approval resolver fails (no pending
372
+ // interaction registered in the test environment). The decision primitive
373
+ // correctly skips grant minting when the resolver reports a failure.
374
+ expect(result.grantMinted).toBe(false);
375
+ expect(result.resolverFailed).toBe(true);
376
+ });
377
+
378
+ // ── Expired request ────────────────────────────────────────────────
379
+
380
+ test('rejects decision on expired request', async () => {
381
+ const req = createCanonicalGuardianRequest({
382
+ kind: 'tool_approval',
383
+ sourceType: 'channel',
384
+ conversationId: 'conv-1',
385
+ guardianExternalUserId: 'guardian-1',
386
+ expiresAt: new Date(Date.now() - 10_000).toISOString(), // already expired
387
+ });
388
+
389
+ const result = await applyCanonicalGuardianDecision({
390
+ requestId: req.id,
391
+ action: 'approve_once',
392
+ actorContext: guardianActor(),
393
+ });
394
+
395
+ expect(result.applied).toBe(false);
396
+ if (result.applied) return;
397
+ expect(result.reason).toBe('expired');
398
+ });
399
+
400
+ test('allows decision on request with no expiresAt', async () => {
401
+ const req = createCanonicalGuardianRequest({
402
+ kind: 'tool_approval',
403
+ sourceType: 'channel',
404
+ conversationId: 'conv-1',
405
+ guardianExternalUserId: 'guardian-1',
406
+ // No expiresAt
407
+ });
408
+
409
+ const result = await applyCanonicalGuardianDecision({
410
+ requestId: req.id,
411
+ action: 'approve_once',
412
+ actorContext: guardianActor(),
413
+ });
414
+
415
+ expect(result.applied).toBe(true);
416
+ });
417
+
418
+ // ── Resolver dispatch ──────────────────────────────────────────────
419
+
420
+ test('dispatches to tool_approval resolver', async () => {
421
+ const req = createCanonicalGuardianRequest({
422
+ kind: 'tool_approval',
423
+ sourceType: 'channel',
424
+ sourceChannel: 'telegram',
425
+ conversationId: 'conv-1',
426
+ guardianExternalUserId: 'guardian-1',
427
+ toolName: 'file_read',
428
+ inputDigest: 'sha256:def',
429
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
430
+ });
431
+
432
+ const result = await applyCanonicalGuardianDecision({
433
+ requestId: req.id,
434
+ action: 'approve_once',
435
+ actorContext: guardianActor(),
436
+ });
437
+
438
+ expect(result.applied).toBe(true);
439
+ });
440
+
441
+ test('dispatches to pending_question resolver', async () => {
442
+ const req = createCanonicalGuardianRequest({
443
+ kind: 'pending_question',
444
+ sourceType: 'voice',
445
+ sourceChannel: 'twilio',
446
+ guardianExternalUserId: 'guardian-1',
447
+ callSessionId: 'call-99',
448
+ pendingQuestionId: 'pq-99',
449
+ questionText: 'What is the password?',
450
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
451
+ });
452
+
453
+ const result = await applyCanonicalGuardianDecision({
454
+ requestId: req.id,
455
+ action: 'approve_once',
456
+ actorContext: guardianActor(),
457
+ userText: 'secret123',
458
+ });
459
+
460
+ expect(result.applied).toBe(true);
461
+ const resolved = getCanonicalGuardianRequest(req.id);
462
+ expect(resolved!.answerText).toBe('secret123');
463
+ });
464
+
465
+ test('succeeds even with no resolver for unknown kind', async () => {
466
+ const req = createCanonicalGuardianRequest({
467
+ kind: 'unknown_kind',
468
+ sourceType: 'channel',
469
+ conversationId: 'conv-1',
470
+ guardianExternalUserId: 'guardian-1',
471
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
472
+ });
473
+
474
+ // Should still succeed — CAS resolution happens regardless of resolver
475
+ const result = await applyCanonicalGuardianDecision({
476
+ requestId: req.id,
477
+ action: 'approve_once',
478
+ actorContext: guardianActor(),
479
+ });
480
+
481
+ expect(result.applied).toBe(true);
482
+ const resolved = getCanonicalGuardianRequest(req.id);
483
+ expect(resolved!.status).toBe('approved');
484
+ });
485
+
486
+ test('trusted desktop actor still mints scoped grant for approved canonical request', async () => {
487
+ const req = createCanonicalGuardianRequest({
488
+ kind: 'unknown_kind',
489
+ sourceType: 'voice',
490
+ sourceChannel: 'voice',
491
+ conversationId: 'conv-voice-1',
492
+ callSessionId: 'call-voice-1',
493
+ toolName: 'host_bash',
494
+ inputDigest: 'sha256:voice-digest-1',
495
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
496
+ });
497
+
498
+ const result = await applyCanonicalGuardianDecision({
499
+ requestId: req.id,
500
+ action: 'approve_once',
501
+ actorContext: trustedActor(),
502
+ });
503
+
504
+ expect(result.applied).toBe(true);
505
+ if (!result.applied) return;
506
+ expect(result.grantMinted).toBe(true);
507
+
508
+ const db = getDb();
509
+ const grants = db.select().from(scopedApprovalGrants).all();
510
+ expect(grants.length).toBe(1);
511
+ expect(grants[0].toolName).toBe('host_bash');
512
+ expect(grants[0].conversationId).toBe('conv-voice-1');
513
+ expect(grants[0].callSessionId).toBe('call-voice-1');
514
+ expect(grants[0].guardianExternalUserId).toBeNull();
515
+ });
516
+ });
517
+
518
+ // ---------------------------------------------------------------------------
519
+ // mintCanonicalRequestGrant tests
520
+ // ---------------------------------------------------------------------------
521
+
522
+ describe('mintCanonicalRequestGrant', () => {
523
+ beforeEach(() => resetTables());
524
+
525
+ test('mints grant for request with tool metadata', () => {
526
+ const req = createCanonicalGuardianRequest({
527
+ kind: 'tool_approval',
528
+ sourceType: 'channel',
529
+ sourceChannel: 'telegram',
530
+ conversationId: 'conv-1',
531
+ toolName: 'shell',
532
+ inputDigest: 'sha256:abc',
533
+ });
534
+
535
+ const result = mintCanonicalRequestGrant({
536
+ request: req,
537
+ actorChannel: 'telegram',
538
+ guardianExternalUserId: 'guardian-1',
539
+ });
540
+
541
+ expect(result.minted).toBe(true);
542
+ });
543
+
544
+ test('mints grant when guardianExternalUserId is omitted', () => {
545
+ const req = createCanonicalGuardianRequest({
546
+ kind: 'tool_approval',
547
+ sourceType: 'channel',
548
+ sourceChannel: 'telegram',
549
+ conversationId: 'conv-2',
550
+ toolName: 'shell',
551
+ inputDigest: 'sha256:xyz',
552
+ });
553
+
554
+ const result = mintCanonicalRequestGrant({
555
+ request: req,
556
+ actorChannel: 'vellum',
557
+ });
558
+
559
+ expect(result.minted).toBe(true);
560
+
561
+ const db = getDb();
562
+ const grants = db.select().from(scopedApprovalGrants).all();
563
+ expect(grants.length).toBe(1);
564
+ expect(grants[0].guardianExternalUserId).toBeNull();
565
+ });
566
+
567
+ test('skips grant for request without tool metadata', () => {
568
+ const req = createCanonicalGuardianRequest({
569
+ kind: 'pending_question',
570
+ sourceType: 'voice',
571
+ // No toolName or inputDigest
572
+ });
573
+
574
+ const result = mintCanonicalRequestGrant({
575
+ request: req,
576
+ actorChannel: 'telegram',
577
+ guardianExternalUserId: 'guardian-1',
578
+ });
579
+
580
+ expect(result.minted).toBe(false);
581
+ });
582
+
583
+ test('skips grant when toolName present but inputDigest missing', () => {
584
+ const req = createCanonicalGuardianRequest({
585
+ kind: 'tool_approval',
586
+ sourceType: 'channel',
587
+ toolName: 'shell',
588
+ // No inputDigest
589
+ });
590
+
591
+ const result = mintCanonicalRequestGrant({
592
+ request: req,
593
+ actorChannel: 'telegram',
594
+ guardianExternalUserId: 'guardian-1',
595
+ });
596
+
597
+ expect(result.minted).toBe(false);
598
+ });
599
+ });