@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,443 @@
1
+ /**
2
+ * Security test matrix for channel-agnostic scoped approval grants.
3
+ *
4
+ * This file covers scenarios NOT already tested in:
5
+ * - scoped-approval-grants.test.ts (CRUD, digest, basic consume semantics)
6
+ * - voice-scoped-grant-consumer.test.ts (voice bridge integration)
7
+ * - guardian-grant-minting.test.ts (grant minting on approval decisions)
8
+ *
9
+ * Additional scenarios tested here:
10
+ * 6. Requester identity mismatch denied
11
+ * 8. Concurrent consume attempts: only one succeeds
12
+ * 12. Restart behavior remains fail-closed — grants stored in persistent DB
13
+ *
14
+ * Cross-reference:
15
+ * 1. Voice happy path — voice-scoped-grant-consumer.test.ts
16
+ * 2. Replay denied — scoped-approval-grants.test.ts + voice-scoped-grant-consumer.test.ts
17
+ * 3. Tool mismatch denied — scoped-approval-grants.test.ts + voice-scoped-grant-consumer.test.ts
18
+ * 4. Input mismatch denied — scoped-approval-grants.test.ts
19
+ * 5. Execution-channel mismatch denied — scoped-approval-grants.test.ts
20
+ * 7. Expired grant denied — scoped-approval-grants.test.ts
21
+ * 9. Stale decision cannot mint extra grant — guardian-grant-minting.test.ts
22
+ * 10. Informational ASK_GUARDIAN cannot mint grant — guardian-grant-minting.test.ts
23
+ * 11. Guardian identity mismatch cannot mint grant — guardian-grant-minting.test.ts
24
+ */
25
+
26
+ import { mkdtempSync, rmSync } from 'node:fs';
27
+ import { tmpdir } from 'node:os';
28
+ import { join } from 'node:path';
29
+
30
+ import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test';
31
+
32
+ const testDir = mkdtempSync(join(tmpdir(), 'scoped-grant-security-matrix-'));
33
+
34
+ mock.module('../util/platform.js', () => ({
35
+ getDataDir: () => testDir,
36
+ isMacOS: () => process.platform === 'darwin',
37
+ isLinux: () => process.platform === 'linux',
38
+ isWindows: () => process.platform === 'win32',
39
+ getSocketPath: () => join(testDir, 'test.sock'),
40
+ getPidPath: () => join(testDir, 'test.pid'),
41
+ getDbPath: () => join(testDir, 'test.db'),
42
+ getLogPath: () => join(testDir, 'test.log'),
43
+ ensureDataDir: () => {},
44
+ migrateToDataLayout: () => {},
45
+ migrateToWorkspaceLayout: () => {},
46
+ }));
47
+
48
+ mock.module('../util/logger.js', () => ({
49
+ getLogger: () =>
50
+ new Proxy({} as Record<string, unknown>, {
51
+ get: () => () => {},
52
+ }),
53
+ isDebug: () => false,
54
+ truncateForLog: (value: string) => value,
55
+ }));
56
+
57
+ import {
58
+ type CreateScopedApprovalGrantParams,
59
+ consumeScopedApprovalGrantByToolSignature,
60
+ createScopedApprovalGrant,
61
+ } from '../memory/scoped-approval-grants.js';
62
+ import { getDb, initializeDb, resetDb } from '../memory/db.js';
63
+ import { scopedApprovalGrants } from '../memory/schema.js';
64
+ import { computeToolApprovalDigest } from '../security/tool-approval-digest.js';
65
+
66
+ initializeDb();
67
+
68
+ function clearTables(): void {
69
+ const db = getDb();
70
+ db.delete(scopedApprovalGrants).run();
71
+ }
72
+
73
+ afterAll(() => {
74
+ resetDb();
75
+ try {
76
+ rmSync(testDir, { recursive: true });
77
+ } catch {
78
+ /* best effort */
79
+ }
80
+ });
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Helper to build grant params with sensible defaults
84
+ // ---------------------------------------------------------------------------
85
+
86
+ function grantParams(overrides: Partial<CreateScopedApprovalGrantParams> = {}): CreateScopedApprovalGrantParams {
87
+ const futureExpiry = new Date(Date.now() + 60_000).toISOString();
88
+ return {
89
+ assistantId: 'self',
90
+ scopeMode: 'tool_signature',
91
+ toolName: 'bash',
92
+ inputDigest: computeToolApprovalDigest('bash', { cmd: 'ls' }),
93
+ requestChannel: 'telegram',
94
+ decisionChannel: 'telegram',
95
+ expiresAt: futureExpiry,
96
+ ...overrides,
97
+ };
98
+ }
99
+
100
+ // ===========================================================================
101
+ // 6. Requester identity mismatch denied
102
+ // ===========================================================================
103
+
104
+ describe('security matrix: requester identity mismatch', () => {
105
+ beforeEach(() => clearTables());
106
+
107
+ test('grant scoped to a specific requester cannot be consumed by a different requester', () => {
108
+ const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
109
+ createScopedApprovalGrant(
110
+ grantParams({
111
+ toolName: 'bash',
112
+ inputDigest: digest,
113
+ requesterExternalUserId: 'user-alice',
114
+ }),
115
+ );
116
+
117
+ // Attempt to consume as a different user
118
+ const wrongUser = consumeScopedApprovalGrantByToolSignature({
119
+ toolName: 'bash',
120
+ inputDigest: digest,
121
+ consumingRequestId: 'c1',
122
+ requesterExternalUserId: 'user-bob',
123
+ });
124
+ expect(wrongUser.ok).toBe(false);
125
+
126
+ // Correct user succeeds
127
+ const correctUser = consumeScopedApprovalGrantByToolSignature({
128
+ toolName: 'bash',
129
+ inputDigest: digest,
130
+ consumingRequestId: 'c2',
131
+ requesterExternalUserId: 'user-alice',
132
+ });
133
+ expect(correctUser.ok).toBe(true);
134
+ });
135
+
136
+ test('grant with null requesterExternalUserId allows any requester (wildcard)', () => {
137
+ const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
138
+ createScopedApprovalGrant(
139
+ grantParams({
140
+ toolName: 'bash',
141
+ inputDigest: digest,
142
+ requesterExternalUserId: null,
143
+ }),
144
+ );
145
+
146
+ // Any user can consume when requester is null (wildcard)
147
+ const result = consumeScopedApprovalGrantByToolSignature({
148
+ toolName: 'bash',
149
+ inputDigest: digest,
150
+ consumingRequestId: 'c1',
151
+ requesterExternalUserId: 'user-anyone',
152
+ });
153
+ expect(result.ok).toBe(true);
154
+ });
155
+
156
+ test('consume without providing requester only matches grants with null requester', () => {
157
+ const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
158
+
159
+ // Grant scoped to a specific requester
160
+ createScopedApprovalGrant(
161
+ grantParams({
162
+ toolName: 'bash',
163
+ inputDigest: digest,
164
+ requesterExternalUserId: 'user-alice',
165
+ }),
166
+ );
167
+
168
+ // Consume without specifying requester — should NOT match a requester-scoped grant
169
+ const result = consumeScopedApprovalGrantByToolSignature({
170
+ toolName: 'bash',
171
+ inputDigest: digest,
172
+ consumingRequestId: 'c1',
173
+ // No requesterExternalUserId provided
174
+ });
175
+ expect(result.ok).toBe(false);
176
+ });
177
+ });
178
+
179
+ // ===========================================================================
180
+ // 8. Concurrent consume attempts: only one succeeds
181
+ // ===========================================================================
182
+
183
+ describe('security matrix: concurrent consume (CAS)', () => {
184
+ beforeEach(() => clearTables());
185
+
186
+ test('only one of multiple concurrent consumers succeeds for the same grant', () => {
187
+ const digest = computeToolApprovalDigest('bash', { cmd: 'rm -rf /' });
188
+ createScopedApprovalGrant(
189
+ grantParams({
190
+ toolName: 'bash',
191
+ inputDigest: digest,
192
+ }),
193
+ );
194
+
195
+ // Simulate concurrent consumers racing to consume the same grant.
196
+ // Since SQLite is synchronous in Bun, we simulate by issuing
197
+ // back-to-back consume calls — the CAS mechanism ensures only the
198
+ // first succeeds.
199
+ const results: boolean[] = [];
200
+ for (let i = 0; i < 5; i++) {
201
+ const result = consumeScopedApprovalGrantByToolSignature({
202
+ toolName: 'bash',
203
+ inputDigest: digest,
204
+ consumingRequestId: `concurrent-consumer-${i}`,
205
+ });
206
+ results.push(result.ok);
207
+ }
208
+
209
+ // Exactly one should succeed
210
+ const successes = results.filter(Boolean);
211
+ expect(successes.length).toBe(1);
212
+
213
+ // The first consumer should win
214
+ expect(results[0]).toBe(true);
215
+ });
216
+
217
+ test('with multiple matching grants, each consumer gets at most one grant', () => {
218
+ const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
219
+
220
+ // Create 3 grants for the same tool signature
221
+ for (let i = 0; i < 3; i++) {
222
+ createScopedApprovalGrant(
223
+ grantParams({
224
+ toolName: 'bash',
225
+ inputDigest: digest,
226
+ }),
227
+ );
228
+ }
229
+
230
+ // 5 consumers compete for 3 grants
231
+ const results: boolean[] = [];
232
+ for (let i = 0; i < 5; i++) {
233
+ const result = consumeScopedApprovalGrantByToolSignature({
234
+ toolName: 'bash',
235
+ inputDigest: digest,
236
+ consumingRequestId: `consumer-${i}`,
237
+ });
238
+ results.push(result.ok);
239
+ }
240
+
241
+ // Exactly 3 should succeed (one per grant)
242
+ const successes = results.filter(Boolean);
243
+ expect(successes.length).toBe(3);
244
+
245
+ // The last 2 should fail
246
+ expect(results[3]).toBe(false);
247
+ expect(results[4]).toBe(false);
248
+ });
249
+ });
250
+
251
+ // ===========================================================================
252
+ // 12. Restart behavior remains fail-closed — grants stored in persistent DB
253
+ // ===========================================================================
254
+
255
+ describe('security matrix: persistence and fail-closed behavior', () => {
256
+ beforeEach(() => clearTables());
257
+
258
+ test('grants survive DB re-initialization (simulating daemon restart)', () => {
259
+ const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
260
+
261
+ // Create a grant
262
+ const grant = createScopedApprovalGrant(
263
+ grantParams({
264
+ toolName: 'bash',
265
+ inputDigest: digest,
266
+ }),
267
+ );
268
+ expect(grant.status).toBe('active');
269
+
270
+ // Re-initialize the DB (simulates daemon restart — the SQLite file persists)
271
+ initializeDb();
272
+
273
+ // The grant should still be consumable after restart
274
+ const result = consumeScopedApprovalGrantByToolSignature({
275
+ toolName: 'bash',
276
+ inputDigest: digest,
277
+ consumingRequestId: 'post-restart-consumer',
278
+ });
279
+ expect(result.ok).toBe(true);
280
+ expect(result.grant!.id).toBe(grant.id);
281
+ });
282
+
283
+ test('consumed grants remain consumed after DB re-initialization', () => {
284
+ const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
285
+
286
+ createScopedApprovalGrant(
287
+ grantParams({
288
+ toolName: 'bash',
289
+ inputDigest: digest,
290
+ }),
291
+ );
292
+
293
+ // Consume the grant
294
+ const first = consumeScopedApprovalGrantByToolSignature({
295
+ toolName: 'bash',
296
+ inputDigest: digest,
297
+ consumingRequestId: 'pre-restart-consumer',
298
+ });
299
+ expect(first.ok).toBe(true);
300
+
301
+ // Re-initialize the DB (simulates daemon restart)
302
+ initializeDb();
303
+
304
+ // The consumed grant must NOT be consumable again after restart
305
+ const second = consumeScopedApprovalGrantByToolSignature({
306
+ toolName: 'bash',
307
+ inputDigest: digest,
308
+ consumingRequestId: 'post-restart-consumer',
309
+ });
310
+ expect(second.ok).toBe(false);
311
+ });
312
+
313
+ test('no grants means fail-closed (deny by default)', () => {
314
+ // Empty grant table — no grants at all
315
+ const digest = computeToolApprovalDigest('bash', { cmd: 'dangerous-command' });
316
+
317
+ const result = consumeScopedApprovalGrantByToolSignature({
318
+ toolName: 'bash',
319
+ inputDigest: digest,
320
+ consumingRequestId: 'consumer-1',
321
+ });
322
+
323
+ // Must fail closed — no grant = no permission
324
+ expect(result.ok).toBe(false);
325
+ expect(result.grant).toBeNull();
326
+ });
327
+ });
328
+
329
+ // ===========================================================================
330
+ // Combined cross-scope invariants
331
+ // ===========================================================================
332
+
333
+ describe('security matrix: cross-scope invariants', () => {
334
+ beforeEach(() => clearTables());
335
+
336
+ test('grant for one assistant cannot be consumed by another assistant', () => {
337
+ const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
338
+ createScopedApprovalGrant(
339
+ grantParams({
340
+ toolName: 'bash',
341
+ inputDigest: digest,
342
+ assistantId: 'assistant-alpha',
343
+ }),
344
+ );
345
+
346
+ // Attempt consumption from a different assistant
347
+ const wrongAssistant = consumeScopedApprovalGrantByToolSignature({
348
+ toolName: 'bash',
349
+ inputDigest: digest,
350
+ consumingRequestId: 'c1',
351
+ assistantId: 'assistant-beta',
352
+ });
353
+ expect(wrongAssistant.ok).toBe(false);
354
+
355
+ // Correct assistant succeeds
356
+ const correctAssistant = consumeScopedApprovalGrantByToolSignature({
357
+ toolName: 'bash',
358
+ inputDigest: digest,
359
+ consumingRequestId: 'c2',
360
+ assistantId: 'assistant-alpha',
361
+ });
362
+ expect(correctAssistant.ok).toBe(true);
363
+ });
364
+
365
+ test('all scope fields must match simultaneously for consumption', () => {
366
+ const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
367
+
368
+ // Create a maximally-scoped grant
369
+ createScopedApprovalGrant(
370
+ grantParams({
371
+ toolName: 'bash',
372
+ inputDigest: digest,
373
+ assistantId: 'self',
374
+ executionChannel: 'voice',
375
+ conversationId: 'conv-123',
376
+ callSessionId: 'call-456',
377
+ requesterExternalUserId: 'user-alice',
378
+ }),
379
+ );
380
+
381
+ // Each field mismatch should independently cause failure:
382
+
383
+ // Wrong execution channel
384
+ expect(consumeScopedApprovalGrantByToolSignature({
385
+ toolName: 'bash',
386
+ inputDigest: digest,
387
+ consumingRequestId: 'c-chan',
388
+ assistantId: 'self',
389
+ executionChannel: 'sms',
390
+ conversationId: 'conv-123',
391
+ callSessionId: 'call-456',
392
+ requesterExternalUserId: 'user-alice',
393
+ }).ok).toBe(false);
394
+
395
+ // Wrong conversation
396
+ expect(consumeScopedApprovalGrantByToolSignature({
397
+ toolName: 'bash',
398
+ inputDigest: digest,
399
+ consumingRequestId: 'c-conv',
400
+ assistantId: 'self',
401
+ executionChannel: 'voice',
402
+ conversationId: 'conv-999',
403
+ callSessionId: 'call-456',
404
+ requesterExternalUserId: 'user-alice',
405
+ }).ok).toBe(false);
406
+
407
+ // Wrong call session
408
+ expect(consumeScopedApprovalGrantByToolSignature({
409
+ toolName: 'bash',
410
+ inputDigest: digest,
411
+ consumingRequestId: 'c-call',
412
+ assistantId: 'self',
413
+ executionChannel: 'voice',
414
+ conversationId: 'conv-123',
415
+ callSessionId: 'call-999',
416
+ requesterExternalUserId: 'user-alice',
417
+ }).ok).toBe(false);
418
+
419
+ // Wrong requester
420
+ expect(consumeScopedApprovalGrantByToolSignature({
421
+ toolName: 'bash',
422
+ inputDigest: digest,
423
+ consumingRequestId: 'c-user',
424
+ assistantId: 'self',
425
+ executionChannel: 'voice',
426
+ conversationId: 'conv-123',
427
+ callSessionId: 'call-456',
428
+ requesterExternalUserId: 'user-bob',
429
+ }).ok).toBe(false);
430
+
431
+ // All fields match — succeeds
432
+ expect(consumeScopedApprovalGrantByToolSignature({
433
+ toolName: 'bash',
434
+ inputDigest: digest,
435
+ consumingRequestId: 'c-all',
436
+ assistantId: 'self',
437
+ executionChannel: 'voice',
438
+ conversationId: 'conv-123',
439
+ callSessionId: 'call-456',
440
+ requesterExternalUserId: 'user-alice',
441
+ }).ok).toBe(true);
442
+ });
443
+ });
@@ -838,6 +838,7 @@ describe('Trust Store', () => {
838
838
  expect(match).not.toBeNull();
839
839
  expect(match!.id).toBe('default:allow-bash-rm-bootstrap');
840
840
  expect(match!.decision).toBe('allow');
841
+ expect(match!.allowHighRisk).toBe(true);
841
842
  // Outside workspace, the bootstrap rule doesn't match — the global
842
843
  // default:allow-bash-global rule matches instead (not the bootstrap rule).
843
844
  const other = findHighestPriorityRule('bash', ['rm BOOTSTRAP.md'], '/tmp/other-project');
@@ -852,6 +853,7 @@ describe('Trust Store', () => {
852
853
  expect(match).not.toBeNull();
853
854
  expect(match!.id).toBe('default:allow-bash-rm-updates');
854
855
  expect(match!.decision).toBe('allow');
856
+ expect(match!.allowHighRisk).toBe(true);
855
857
  // Outside workspace, should NOT match the updates rule
856
858
  const other = findHighestPriorityRule('bash', ['rm UPDATES.md'], '/tmp/other-project');
857
859
  expect(other).not.toBeNull();