@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,521 @@
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(), 'scoped-grants-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
+ type CreateScopedApprovalGrantParams,
34
+ consumeScopedApprovalGrantByRequestId,
35
+ consumeScopedApprovalGrantByToolSignature,
36
+ createScopedApprovalGrant,
37
+ expireScopedApprovalGrants,
38
+ revokeScopedApprovalGrantsForContext,
39
+ } from '../memory/scoped-approval-grants.js';
40
+ import { getDb, initializeDb, resetDb } from '../memory/db.js';
41
+ import { scopedApprovalGrants } from '../memory/schema.js';
42
+ import {
43
+ canonicalJsonSerialize,
44
+ computeToolApprovalDigest,
45
+ } from '../security/tool-approval-digest.js';
46
+
47
+ initializeDb();
48
+
49
+ function clearTables(): void {
50
+ const db = getDb();
51
+ db.delete(scopedApprovalGrants).run();
52
+ }
53
+
54
+ afterAll(() => {
55
+ resetDb();
56
+ try {
57
+ rmSync(testDir, { recursive: true });
58
+ } catch {
59
+ /* best effort */
60
+ }
61
+ });
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Helper to build grant params with sensible defaults
65
+ // ---------------------------------------------------------------------------
66
+
67
+ function grantParams(overrides: Partial<CreateScopedApprovalGrantParams> = {}): CreateScopedApprovalGrantParams {
68
+ const futureExpiry = new Date(Date.now() + 60_000).toISOString();
69
+ return {
70
+ assistantId: 'self',
71
+ scopeMode: 'request_id',
72
+ requestChannel: 'telegram',
73
+ decisionChannel: 'telegram',
74
+ expiresAt: futureExpiry,
75
+ ...overrides,
76
+ };
77
+ }
78
+
79
+ // ===========================================================================
80
+ // SCOPE MODE: request_id
81
+ // ===========================================================================
82
+
83
+ describe('scoped-approval-grants / request_id scope', () => {
84
+ beforeEach(() => clearTables());
85
+
86
+ test('create and consume by request_id succeeds', () => {
87
+ const grant = createScopedApprovalGrant(
88
+ grantParams({ scopeMode: 'request_id', requestId: 'req-1' }),
89
+ );
90
+ expect(grant.status).toBe('active');
91
+ expect(grant.requestId).toBe('req-1');
92
+
93
+ const result = consumeScopedApprovalGrantByRequestId('req-1', 'consumer-1', 'self');
94
+ expect(result.ok).toBe(true);
95
+ expect(result.grant).not.toBeNull();
96
+ expect(result.grant!.status).toBe('consumed');
97
+ expect(result.grant!.consumedByRequestId).toBe('consumer-1');
98
+ });
99
+
100
+ test('second consume of same grant fails (one-time use)', () => {
101
+ createScopedApprovalGrant(
102
+ grantParams({ scopeMode: 'request_id', requestId: 'req-2' }),
103
+ );
104
+
105
+ const first = consumeScopedApprovalGrantByRequestId('req-2', 'consumer-a', 'self');
106
+ expect(first.ok).toBe(true);
107
+
108
+ const second = consumeScopedApprovalGrantByRequestId('req-2', 'consumer-b', 'self');
109
+ expect(second.ok).toBe(false);
110
+ expect(second.grant).toBeNull();
111
+ });
112
+
113
+ test('consume fails when no matching grant exists', () => {
114
+ const result = consumeScopedApprovalGrantByRequestId('nonexistent', 'consumer-x', 'self');
115
+ expect(result.ok).toBe(false);
116
+ });
117
+
118
+ test('expired grant cannot be consumed', () => {
119
+ const pastExpiry = new Date(Date.now() - 1_000).toISOString();
120
+ createScopedApprovalGrant(
121
+ grantParams({ scopeMode: 'request_id', requestId: 'req-expired', expiresAt: pastExpiry }),
122
+ );
123
+
124
+ const result = consumeScopedApprovalGrantByRequestId('req-expired', 'consumer-1', 'self');
125
+ expect(result.ok).toBe(false);
126
+ });
127
+ });
128
+
129
+ // ===========================================================================
130
+ // SCOPE MODE: tool_signature
131
+ // ===========================================================================
132
+
133
+ describe('scoped-approval-grants / tool_signature scope', () => {
134
+ beforeEach(() => clearTables());
135
+
136
+ test('create and consume by tool signature succeeds', () => {
137
+ const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
138
+ const grant = createScopedApprovalGrant(
139
+ grantParams({
140
+ scopeMode: 'tool_signature',
141
+ toolName: 'bash',
142
+ inputDigest: digest,
143
+ }),
144
+ );
145
+ expect(grant.status).toBe('active');
146
+ expect(grant.toolName).toBe('bash');
147
+
148
+ const result = consumeScopedApprovalGrantByToolSignature({
149
+ toolName: 'bash',
150
+ inputDigest: digest,
151
+ consumingRequestId: 'consumer-1',
152
+ });
153
+ expect(result.ok).toBe(true);
154
+ expect(result.grant!.status).toBe('consumed');
155
+ });
156
+
157
+ test('second consume of tool_signature grant fails', () => {
158
+ const digest = computeToolApprovalDigest('bash', { cmd: 'rm -rf' });
159
+ createScopedApprovalGrant(
160
+ grantParams({
161
+ scopeMode: 'tool_signature',
162
+ toolName: 'bash',
163
+ inputDigest: digest,
164
+ }),
165
+ );
166
+
167
+ const first = consumeScopedApprovalGrantByToolSignature({
168
+ toolName: 'bash',
169
+ inputDigest: digest,
170
+ consumingRequestId: 'c1',
171
+ });
172
+ expect(first.ok).toBe(true);
173
+
174
+ const second = consumeScopedApprovalGrantByToolSignature({
175
+ toolName: 'bash',
176
+ inputDigest: digest,
177
+ consumingRequestId: 'c2',
178
+ });
179
+ expect(second.ok).toBe(false);
180
+ });
181
+
182
+ test('mismatched input digest fails consume', () => {
183
+ const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
184
+ createScopedApprovalGrant(
185
+ grantParams({
186
+ scopeMode: 'tool_signature',
187
+ toolName: 'bash',
188
+ inputDigest: digest,
189
+ }),
190
+ );
191
+
192
+ const wrongDigest = computeToolApprovalDigest('bash', { cmd: 'pwd' });
193
+ const result = consumeScopedApprovalGrantByToolSignature({
194
+ toolName: 'bash',
195
+ inputDigest: wrongDigest,
196
+ consumingRequestId: 'c1',
197
+ });
198
+ expect(result.ok).toBe(false);
199
+ });
200
+
201
+ test('mismatched tool name fails consume', () => {
202
+ const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
203
+ createScopedApprovalGrant(
204
+ grantParams({
205
+ scopeMode: 'tool_signature',
206
+ toolName: 'bash',
207
+ inputDigest: digest,
208
+ }),
209
+ );
210
+
211
+ const result = consumeScopedApprovalGrantByToolSignature({
212
+ toolName: 'python',
213
+ inputDigest: digest,
214
+ consumingRequestId: 'c1',
215
+ });
216
+ expect(result.ok).toBe(false);
217
+ });
218
+
219
+ test('context constraint: executionChannel must match non-null grant field', () => {
220
+ const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
221
+ createScopedApprovalGrant(
222
+ grantParams({
223
+ scopeMode: 'tool_signature',
224
+ toolName: 'bash',
225
+ inputDigest: digest,
226
+ executionChannel: 'telegram',
227
+ }),
228
+ );
229
+
230
+ // Wrong channel
231
+ const wrong = consumeScopedApprovalGrantByToolSignature({
232
+ toolName: 'bash',
233
+ inputDigest: digest,
234
+ consumingRequestId: 'c1',
235
+ executionChannel: 'sms',
236
+ });
237
+ expect(wrong.ok).toBe(false);
238
+
239
+ // Correct channel
240
+ const correct = consumeScopedApprovalGrantByToolSignature({
241
+ toolName: 'bash',
242
+ inputDigest: digest,
243
+ consumingRequestId: 'c2',
244
+ executionChannel: 'telegram',
245
+ });
246
+ expect(correct.ok).toBe(true);
247
+ });
248
+
249
+ test('null executionChannel on grant means any channel matches', () => {
250
+ const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
251
+ createScopedApprovalGrant(
252
+ grantParams({
253
+ scopeMode: 'tool_signature',
254
+ toolName: 'bash',
255
+ inputDigest: digest,
256
+ executionChannel: null,
257
+ }),
258
+ );
259
+
260
+ const result = consumeScopedApprovalGrantByToolSignature({
261
+ toolName: 'bash',
262
+ inputDigest: digest,
263
+ consumingRequestId: 'c1',
264
+ executionChannel: 'sms',
265
+ });
266
+ expect(result.ok).toBe(true);
267
+ });
268
+
269
+ test('context constraint: conversationId must match non-null grant field', () => {
270
+ const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
271
+ createScopedApprovalGrant(
272
+ grantParams({
273
+ scopeMode: 'tool_signature',
274
+ toolName: 'bash',
275
+ inputDigest: digest,
276
+ conversationId: 'conv-123',
277
+ }),
278
+ );
279
+
280
+ // Mismatched
281
+ const wrong = consumeScopedApprovalGrantByToolSignature({
282
+ toolName: 'bash',
283
+ inputDigest: digest,
284
+ consumingRequestId: 'c1',
285
+ conversationId: 'conv-999',
286
+ });
287
+ expect(wrong.ok).toBe(false);
288
+
289
+ // Matched
290
+ const correct = consumeScopedApprovalGrantByToolSignature({
291
+ toolName: 'bash',
292
+ inputDigest: digest,
293
+ consumingRequestId: 'c2',
294
+ conversationId: 'conv-123',
295
+ });
296
+ expect(correct.ok).toBe(true);
297
+ });
298
+
299
+ test('expired tool_signature grant cannot be consumed', () => {
300
+ const pastExpiry = new Date(Date.now() - 1_000).toISOString();
301
+ const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
302
+ createScopedApprovalGrant(
303
+ grantParams({
304
+ scopeMode: 'tool_signature',
305
+ toolName: 'bash',
306
+ inputDigest: digest,
307
+ expiresAt: pastExpiry,
308
+ }),
309
+ );
310
+
311
+ const result = consumeScopedApprovalGrantByToolSignature({
312
+ toolName: 'bash',
313
+ inputDigest: digest,
314
+ consumingRequestId: 'c1',
315
+ });
316
+ expect(result.ok).toBe(false);
317
+ });
318
+
319
+ test('consume by tool signature only consumes one grant when multiple match', () => {
320
+ const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
321
+
322
+ // Create a wildcard grant (no executionChannel) and a channel-specific grant.
323
+ // Both match when executionChannel='telegram', but only one should be consumed.
324
+ const wildcardGrant = createScopedApprovalGrant(
325
+ grantParams({
326
+ scopeMode: 'tool_signature',
327
+ toolName: 'bash',
328
+ inputDigest: digest,
329
+ executionChannel: null,
330
+ }),
331
+ );
332
+ const specificGrant = createScopedApprovalGrant(
333
+ grantParams({
334
+ scopeMode: 'tool_signature',
335
+ toolName: 'bash',
336
+ inputDigest: digest,
337
+ executionChannel: 'telegram',
338
+ }),
339
+ );
340
+
341
+ const result = consumeScopedApprovalGrantByToolSignature({
342
+ toolName: 'bash',
343
+ inputDigest: digest,
344
+ consumingRequestId: 'c1',
345
+ executionChannel: 'telegram',
346
+ });
347
+ expect(result.ok).toBe(true);
348
+ // The most specific grant (channel-specific) should be consumed first
349
+ expect(result.grant!.id).toBe(specificGrant.id);
350
+
351
+ // The wildcard grant should still be active and consumable
352
+ const second = consumeScopedApprovalGrantByToolSignature({
353
+ toolName: 'bash',
354
+ inputDigest: digest,
355
+ consumingRequestId: 'c2',
356
+ executionChannel: 'sms',
357
+ });
358
+ expect(second.ok).toBe(true);
359
+ expect(second.grant!.id).toBe(wildcardGrant.id);
360
+ });
361
+ });
362
+
363
+ // ===========================================================================
364
+ // Expiry semantics
365
+ // ===========================================================================
366
+
367
+ describe('scoped-approval-grants / expiry', () => {
368
+ beforeEach(() => clearTables());
369
+
370
+ test('expireScopedApprovalGrants transitions active past-TTL grants to expired', () => {
371
+ const pastExpiry = new Date(Date.now() - 1_000).toISOString();
372
+ createScopedApprovalGrant(
373
+ grantParams({ scopeMode: 'request_id', requestId: 'req-e1', expiresAt: pastExpiry }),
374
+ );
375
+ createScopedApprovalGrant(
376
+ grantParams({ scopeMode: 'request_id', requestId: 'req-e2', expiresAt: pastExpiry }),
377
+ );
378
+ // Still active (future expiry)
379
+ createScopedApprovalGrant(
380
+ grantParams({ scopeMode: 'request_id', requestId: 'req-alive' }),
381
+ );
382
+
383
+ const count = expireScopedApprovalGrants();
384
+ expect(count).toBe(2);
385
+
386
+ // Verify the alive grant is still active
387
+ const alive = consumeScopedApprovalGrantByRequestId('req-alive', 'c1', 'self');
388
+ expect(alive.ok).toBe(true);
389
+ });
390
+
391
+ test('already-consumed grants are not affected by expiry sweep', () => {
392
+ const pastExpiry = new Date(Date.now() - 1_000).toISOString();
393
+ createScopedApprovalGrant(
394
+ grantParams({ scopeMode: 'request_id', requestId: 'req-consumed', expiresAt: new Date(Date.now() + 60_000).toISOString() }),
395
+ );
396
+ consumeScopedApprovalGrantByRequestId('req-consumed', 'c1', 'self');
397
+
398
+ // Force the expiry time to the past for the consumed grant (simulating time passing)
399
+ // The sweep should not touch consumed grants
400
+ const count = expireScopedApprovalGrants();
401
+ expect(count).toBe(0);
402
+ });
403
+ });
404
+
405
+ // ===========================================================================
406
+ // Revoke semantics
407
+ // ===========================================================================
408
+
409
+ describe('scoped-approval-grants / revoke', () => {
410
+ beforeEach(() => clearTables());
411
+
412
+ test('revokeScopedApprovalGrantsForContext revokes active grants matching context', () => {
413
+ createScopedApprovalGrant(
414
+ grantParams({ scopeMode: 'request_id', requestId: 'req-r1', callSessionId: 'call-1' }),
415
+ );
416
+ createScopedApprovalGrant(
417
+ grantParams({ scopeMode: 'request_id', requestId: 'req-r2', callSessionId: 'call-1' }),
418
+ );
419
+ createScopedApprovalGrant(
420
+ grantParams({ scopeMode: 'request_id', requestId: 'req-r3', callSessionId: 'call-2' }),
421
+ );
422
+
423
+ const count = revokeScopedApprovalGrantsForContext({ callSessionId: 'call-1' });
424
+ expect(count).toBe(2);
425
+
426
+ // Revoked grant cannot be consumed
427
+ const revoked = consumeScopedApprovalGrantByRequestId('req-r1', 'c1', 'self');
428
+ expect(revoked.ok).toBe(false);
429
+
430
+ // Unaffected grant is still consumable
431
+ const alive = consumeScopedApprovalGrantByRequestId('req-r3', 'c1', 'self');
432
+ expect(alive.ok).toBe(true);
433
+ });
434
+
435
+ test('revoked grants cannot be consumed', () => {
436
+ createScopedApprovalGrant(
437
+ grantParams({ scopeMode: 'request_id', requestId: 'req-revoke', conversationId: 'conv-1' }),
438
+ );
439
+
440
+ revokeScopedApprovalGrantsForContext({ conversationId: 'conv-1' });
441
+
442
+ const result = consumeScopedApprovalGrantByRequestId('req-revoke', 'c1', 'self');
443
+ expect(result.ok).toBe(false);
444
+ });
445
+
446
+ test('revokeScopedApprovalGrantsForContext throws when no context filters are provided', () => {
447
+ // Create a grant to ensure the guard is not based on empty results
448
+ createScopedApprovalGrant(
449
+ grantParams({ scopeMode: 'request_id', requestId: 'req-guard', callSessionId: 'call-guard' }),
450
+ );
451
+
452
+ // Empty object: all fields undefined
453
+ expect(() => revokeScopedApprovalGrantsForContext({})).toThrow(
454
+ 'revokeScopedApprovalGrantsForContext requires at least one context filter',
455
+ );
456
+
457
+ // The grant should still be active (not revoked)
458
+ const result = consumeScopedApprovalGrantByRequestId('req-guard', 'c1', 'self');
459
+ expect(result.ok).toBe(true);
460
+ });
461
+ });
462
+
463
+ // ===========================================================================
464
+ // tool-approval-digest: canonical serialization + hash
465
+ // ===========================================================================
466
+
467
+ describe('tool-approval-digest', () => {
468
+ test('canonicalJsonSerialize sorts keys recursively', () => {
469
+ const obj = { z: 1, a: { c: 3, b: 2 } };
470
+ const serialized = canonicalJsonSerialize(obj);
471
+ expect(serialized).toBe('{"a":{"b":2,"c":3},"z":1}');
472
+ });
473
+
474
+ test('canonicalJsonSerialize handles arrays (order preserved)', () => {
475
+ const obj = { items: [3, 1, 2], name: 'test' };
476
+ const serialized = canonicalJsonSerialize(obj);
477
+ expect(serialized).toBe('{"items":[3,1,2],"name":"test"}');
478
+ });
479
+
480
+ test('canonicalJsonSerialize handles null values', () => {
481
+ const obj = { a: null, b: 'hello' };
482
+ const serialized = canonicalJsonSerialize(obj);
483
+ expect(serialized).toBe('{"a":null,"b":"hello"}');
484
+ });
485
+
486
+ test('canonicalJsonSerialize handles nested arrays of objects', () => {
487
+ const obj = { list: [{ z: 1, a: 2 }, { y: 3, b: 4 }] };
488
+ const serialized = canonicalJsonSerialize(obj);
489
+ expect(serialized).toBe('{"list":[{"a":2,"z":1},{"b":4,"y":3}]}');
490
+ });
491
+
492
+ test('computeToolApprovalDigest is deterministic', () => {
493
+ const d1 = computeToolApprovalDigest('bash', { cmd: 'ls -la', cwd: '/tmp' });
494
+ const d2 = computeToolApprovalDigest('bash', { cwd: '/tmp', cmd: 'ls -la' });
495
+ expect(d1).toBe(d2);
496
+ });
497
+
498
+ test('computeToolApprovalDigest differs for different inputs', () => {
499
+ const d1 = computeToolApprovalDigest('bash', { cmd: 'ls' });
500
+ const d2 = computeToolApprovalDigest('bash', { cmd: 'pwd' });
501
+ expect(d1).not.toBe(d2);
502
+ });
503
+
504
+ test('computeToolApprovalDigest differs for different tool names', () => {
505
+ const d1 = computeToolApprovalDigest('bash', { cmd: 'ls' });
506
+ const d2 = computeToolApprovalDigest('python', { cmd: 'ls' });
507
+ expect(d1).not.toBe(d2);
508
+ });
509
+
510
+ test('computeToolApprovalDigest is stable across key orderings (deeply nested)', () => {
511
+ const d1 = computeToolApprovalDigest('tool', {
512
+ config: { nested: { z: 1, a: 2 }, top: true },
513
+ name: 'test',
514
+ });
515
+ const d2 = computeToolApprovalDigest('tool', {
516
+ name: 'test',
517
+ config: { top: true, nested: { a: 2, z: 1 } },
518
+ });
519
+ expect(d1).toBe(d2);
520
+ });
521
+ });