@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
package/ARCHITECTURE.md CHANGED
@@ -22,6 +22,10 @@ This document owns assistant-runtime architecture details. The repo-level archit
22
22
  - Voice calls mirror the same prompt contract: `CallController` receives guardian context on setup and refreshes it immediately after successful voice challenge verification, so the first post-verification turn is grounded as `actor_role: guardian`.
23
23
  - Voice-specific behavior (DTMF/speech verification flow, relay state machine) remains voice-local; only actor-role resolution is shared.
24
24
 
25
+ ### Channel-Agnostic Scoped Approval Grants
26
+
27
+ Scoped approval grants allow a guardian's approval decision on one channel (e.g., Telegram) to authorize a tool execution on a different channel (e.g., voice). Two scope modes exist: `request_id` (bound to a specific pending request) and `tool_signature` (bound to `toolName` + canonical `inputDigest`). Grants are one-time-use, exact-match, fail-closed, and TTL-bound. Full architecture details (lifecycle flow, security invariants, key files) live in [`docs/architecture/security.md`](docs/architecture/security.md#channel-agnostic-scoped-approval-grants).
28
+
25
29
  ### Outbound Guardian Verification (HTTP Endpoints)
26
30
 
27
31
  Guardian verification can be initiated through the runtime HTTP API as an alternative to the legacy IPC-only flow. This enables chat-first verification where the assistant guides the user through guardian setup via normal conversation.
@@ -315,3 +315,83 @@ The `allowOneTimeSend` config gate (default: `false`) enables a secondary "Send
315
315
 
316
316
  ---
317
317
 
318
+ ## Channel-Agnostic Scoped Approval Grants
319
+
320
+ Scoped approval grants are a channel-agnostic primitive that allows a guardian's approval decision on one channel (e.g., Telegram) to authorize a tool execution on a different channel (e.g., voice). Each grant authorizes exactly one tool execution and is consumed atomically.
321
+
322
+ ### Scope Modes
323
+
324
+ Two scope modes exist:
325
+
326
+ | Mode | Key fields | Use case |
327
+ |------|-----------|----------|
328
+ | `request_id` | `requestId` | Grant is bound to a specific pending confirmation request. Consumed by matching the request ID. |
329
+ | `tool_signature` | `toolName` + `inputDigest` | Grant is bound to a specific tool invocation identified by tool name and a canonical SHA-256 digest of the input. Consumed by matching both fields plus optional context constraints. |
330
+
331
+ ### Lifecycle Flow
332
+
333
+ ```mermaid
334
+ sequenceDiagram
335
+ participant Caller as Non-Guardian Caller (Voice)
336
+ participant Session as Session / Agent Loop
337
+ participant Bridge as Voice Session Bridge
338
+ participant Guardian as Guardian (Telegram)
339
+ participant Interception as Approval Interception
340
+ participant GrantStore as Scoped Grant Store (SQLite)
341
+
342
+ Caller->>Session: Tool invocation triggers confirmation_request
343
+ Session->>Bridge: confirmation_request event
344
+ Note over Bridge: Non-guardian voice call cannot prompt interactively
345
+
346
+ Bridge->>Session: ASK_GUARDIAN_APPROVAL marker in agent response
347
+ Session->>Guardian: "Approve [tool] with [args]?" (Telegram)
348
+
349
+ Guardian->>Interception: "yes" / approve_once callback
350
+ Interception->>Session: handleChannelDecision(approve_once)
351
+ Interception->>GrantStore: createScopedApprovalGrant(tool_signature)
352
+ Note over GrantStore: Grant minted with 5-min TTL
353
+
354
+ Note over Bridge: On next confirmation_request for same tool+input...
355
+ Bridge->>GrantStore: consumeScopedApprovalGrantByToolSignature()
356
+ GrantStore-->>Bridge: { ok: true, grant }
357
+ Bridge->>Session: handleConfirmationResponse(allow)
358
+ Note over GrantStore: Grant status: active -> consumed (CAS)
359
+ ```
360
+
361
+ ### Security Invariants
362
+
363
+ 1. **One-time use** -- Each grant can be consumed at most once. The consume operation uses compare-and-swap (CAS) on the `status` column (`active` -> `consumed`) so concurrent consumers race safely. At most one wins.
364
+
365
+ 2. **Exact-match** -- All non-null scope fields on the grant must match the consumption context exactly. The `inputDigest` is a SHA-256 of the canonical JSON serialization of `{ toolName, input }`, ensuring key-order-independent matching.
366
+
367
+ 3. **Fail-closed** -- When no matching active grant exists, consumption returns `{ ok: false }` and the voice bridge auto-denies. There is no fallback to "allow without a grant."
368
+
369
+ 4. **TTL-bound** -- Grants expire after a configurable TTL (default: 5 minutes). An expiry sweep transitions active past-TTL grants to `expired` status. Expired grants cannot be consumed.
370
+
371
+ 5. **Context-constrained** -- Optional scope fields (`executionChannel`, `conversationId`, `callSessionId`, `requesterExternalUserId`) narrow the grant's applicability. When set on the grant, they must match the consumer's context. When null on the grant, they act as wildcards.
372
+
373
+ 6. **Identity-bound** -- The guardian identity is verified at the approval interception level before a grant is minted. A sender whose `externalUserId` does not match the expected guardian cannot mint a grant.
374
+
375
+ 7. **Persistent storage** -- Grants are stored in the SQLite `scoped_approval_grants` table, which survives daemon restarts. This ensures fail-closed behavior across restarts: consumed grants remain consumed, and no implicit "reset to allowed" occurs.
376
+
377
+ ### Key Source Files
378
+
379
+ | File | Role |
380
+ |------|------|
381
+ | `assistant/src/memory/scoped-approval-grants.ts` | CRUD, atomic CAS consume, expiry sweep, context-based revocation |
382
+ | `assistant/src/memory/migrations/033-scoped-approval-grants.ts` | SQLite schema migration for the `scoped_approval_grants` table |
383
+ | `assistant/src/security/tool-approval-digest.ts` | Canonical JSON serialization + SHA-256 digest for tool signatures |
384
+ | `assistant/src/runtime/routes/guardian-approval-interception.ts` | Grant minting on guardian approve_once decisions (`tryMintToolApprovalGrant`) |
385
+ | `assistant/src/calls/voice-session-bridge.ts` | Voice consumer: checks and consumes grants before auto-denying |
386
+
387
+ ### Test Coverage
388
+
389
+ | Test file | Scenarios covered |
390
+ |-----------|-------------------|
391
+ | `assistant/src/__tests__/scoped-approval-grants.test.ts` | Store CRUD, request_id consume, tool_signature consume, expiry, revocation, digest stability |
392
+ | `assistant/src/__tests__/voice-scoped-grant-consumer.test.ts` | Voice bridge integration: grant-allowed, no-grant-denied, tool-mismatch, guardian-bypass, one-time-use, revocation on call end |
393
+ | `assistant/src/__tests__/guardian-grant-minting.test.ts` | Grant minting: callback/engine/legacy paths, informational-skip, reject-skip, identity-mismatch, stale-skip, TTL verification |
394
+ | `assistant/src/__tests__/scoped-grant-security-matrix.test.ts` | Security matrix: requester identity mismatch, concurrent CAS, persistence across restart, fail-closed default, cross-scope invariants |
395
+
396
+ ---
397
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.3.18",
3
+ "version": "0.3.19",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "vellum": "./src/index.ts"
@@ -1748,6 +1748,10 @@ exports[`IPC message snapshots ServerMessage types skills_list_response serializ
1748
1748
  "emoji": "🔧",
1749
1749
  "id": "my-skill",
1750
1750
  "name": "My Skill",
1751
+ "provenance": {
1752
+ "kind": "first-party",
1753
+ "provider": "Vellum",
1754
+ },
1751
1755
  "source": "bundled",
1752
1756
  "state": "enabled",
1753
1757
  "updateAvailable": false,
@@ -1195,4 +1195,174 @@ describe('call-controller', () => {
1195
1195
 
1196
1196
  controller.destroy();
1197
1197
  });
1198
+
1199
+ // ── Structured tool-approval ASK_GUARDIAN_APPROVAL ──────────────────
1200
+
1201
+ test('ASK_GUARDIAN_APPROVAL: persists toolName and inputDigest on guardian action request', async () => {
1202
+ const approvalPayload = JSON.stringify({
1203
+ question: 'Allow send_email to bob@example.com?',
1204
+ toolName: 'send_email',
1205
+ input: { to: 'bob@example.com', subject: 'Hello' },
1206
+ });
1207
+ mockStartVoiceTurn.mockImplementation(createMockVoiceTurn(
1208
+ [`Let me check with your guardian. [ASK_GUARDIAN_APPROVAL: ${approvalPayload}]`],
1209
+ ));
1210
+ const { session, relay, controller } = setupController('Send an email');
1211
+
1212
+ await controller.handleCallerUtterance('Send an email to Bob');
1213
+
1214
+ // Give the async dispatchGuardianQuestion a tick to create the request
1215
+ await new Promise((r) => setTimeout(r, 50));
1216
+
1217
+ // Verify controller entered waiting_on_user
1218
+ expect(controller.getState()).toBe('waiting_on_user');
1219
+
1220
+ // Verify a pending question was created with the correct text
1221
+ const question = getPendingQuestion(session.id);
1222
+ expect(question).not.toBeNull();
1223
+ expect(question!.questionText).toBe('Allow send_email to bob@example.com?');
1224
+
1225
+ // Verify the guardian action request has tool metadata
1226
+ const pendingRequest = getPendingRequestByCallSessionId(session.id);
1227
+ expect(pendingRequest).not.toBeNull();
1228
+ expect(pendingRequest!.toolName).toBe('send_email');
1229
+ expect(pendingRequest!.inputDigest).not.toBeNull();
1230
+ expect(pendingRequest!.inputDigest!.length).toBe(64); // SHA-256 hex = 64 chars
1231
+
1232
+ // The ASK_GUARDIAN_APPROVAL marker should NOT appear in the relay tokens
1233
+ const allText = relay.sentTokens.map((t) => t.token).join('');
1234
+ expect(allText).not.toContain('[ASK_GUARDIAN_APPROVAL:');
1235
+ expect(allText).not.toContain('send_email');
1236
+
1237
+ controller.destroy();
1238
+ });
1239
+
1240
+ test('ASK_GUARDIAN_APPROVAL: computes deterministic digest for same tool+input', async () => {
1241
+ const approvalPayload = JSON.stringify({
1242
+ question: 'Allow send_email?',
1243
+ toolName: 'send_email',
1244
+ input: { subject: 'Hello', to: 'bob@example.com' },
1245
+ });
1246
+ mockStartVoiceTurn.mockImplementation(createMockVoiceTurn(
1247
+ [`Checking. [ASK_GUARDIAN_APPROVAL: ${approvalPayload}]`],
1248
+ ));
1249
+ const { session, controller } = setupController('Send email');
1250
+
1251
+ await controller.handleCallerUtterance('Send it');
1252
+ await new Promise((r) => setTimeout(r, 50));
1253
+
1254
+ const request1 = getPendingRequestByCallSessionId(session.id);
1255
+ expect(request1).not.toBeNull();
1256
+
1257
+ // Compute expected digest independently using the same utility
1258
+ const { computeToolApprovalDigest } = await import('../security/tool-approval-digest.js');
1259
+ const expectedDigest = computeToolApprovalDigest('send_email', { subject: 'Hello', to: 'bob@example.com' });
1260
+ expect(request1!.inputDigest).toBe(expectedDigest);
1261
+
1262
+ controller.destroy();
1263
+ });
1264
+
1265
+ test('informational ASK_GUARDIAN: does NOT persist tool metadata (null toolName/inputDigest)', async () => {
1266
+ mockStartVoiceTurn.mockImplementation(createMockVoiceTurn(
1267
+ ['Let me check. [ASK_GUARDIAN: What date works best?]'],
1268
+ ));
1269
+ const { session, controller } = setupController('Book appointment');
1270
+
1271
+ await controller.handleCallerUtterance('I need to schedule something');
1272
+ await new Promise((r) => setTimeout(r, 50));
1273
+
1274
+ // Verify the guardian action request has NO tool metadata
1275
+ const pendingRequest = getPendingRequestByCallSessionId(session.id);
1276
+ expect(pendingRequest).not.toBeNull();
1277
+ expect(pendingRequest!.toolName).toBeNull();
1278
+ expect(pendingRequest!.inputDigest).toBeNull();
1279
+ expect(pendingRequest!.questionText).toBe('What date works best?');
1280
+
1281
+ controller.destroy();
1282
+ });
1283
+
1284
+ test('ASK_GUARDIAN_APPROVAL: strips marker from TTS output', async () => {
1285
+ const approvalPayload = JSON.stringify({
1286
+ question: 'Allow calendar_create?',
1287
+ toolName: 'calendar_create',
1288
+ input: { date: '2026-03-01', title: 'Meeting' },
1289
+ });
1290
+ mockStartVoiceTurn.mockImplementation(createMockVoiceTurn([
1291
+ 'Let me get approval for that. ',
1292
+ `[ASK_GUARDIAN_APPROVAL: ${approvalPayload}]`,
1293
+ ' Thank you.',
1294
+ ]));
1295
+ const { relay, controller } = setupController('Create event');
1296
+
1297
+ await controller.handleCallerUtterance('Create a meeting');
1298
+
1299
+ const allText = relay.sentTokens.map((t) => t.token).join('');
1300
+ expect(allText).toContain('Let me get approval');
1301
+ expect(allText).not.toContain('[ASK_GUARDIAN_APPROVAL:');
1302
+ expect(allText).not.toContain('calendar_create');
1303
+ expect(allText).not.toContain('inputDigest');
1304
+
1305
+ controller.destroy();
1306
+ });
1307
+
1308
+ test('ASK_GUARDIAN_APPROVAL: handles JSON payloads containing }] in string values', async () => {
1309
+ // The `}]` sequence inside a JSON string value previously caused the
1310
+ // non-greedy regex to terminate early, truncating the JSON and leaking
1311
+ // partial data into TTS output.
1312
+ const approvalPayload = JSON.stringify({
1313
+ question: 'Allow send_message?',
1314
+ toolName: 'send_message',
1315
+ input: { msg: 'test}]more', nested: { key: 'value with }] braces' } },
1316
+ });
1317
+ mockStartVoiceTurn.mockImplementation(createMockVoiceTurn(
1318
+ [`Let me check. [ASK_GUARDIAN_APPROVAL: ${approvalPayload}]`],
1319
+ ));
1320
+ const { session, relay, controller } = setupController('Send a message');
1321
+
1322
+ await controller.handleCallerUtterance('Send it');
1323
+ await new Promise((r) => setTimeout(r, 50));
1324
+
1325
+ // Verify controller entered waiting_on_user with the correct question
1326
+ expect(controller.getState()).toBe('waiting_on_user');
1327
+ const question = getPendingQuestion(session.id);
1328
+ expect(question).not.toBeNull();
1329
+ expect(question!.questionText).toBe('Allow send_message?');
1330
+
1331
+ // Verify tool metadata was parsed correctly
1332
+ const pendingRequest = getPendingRequestByCallSessionId(session.id);
1333
+ expect(pendingRequest).not.toBeNull();
1334
+ expect(pendingRequest!.toolName).toBe('send_message');
1335
+ expect(pendingRequest!.inputDigest).not.toBeNull();
1336
+
1337
+ // No partial JSON or marker text should leak into TTS output
1338
+ const allText = relay.sentTokens.map((t) => t.token).join('');
1339
+ expect(allText).not.toContain('[ASK_GUARDIAN_APPROVAL:');
1340
+ expect(allText).not.toContain('send_message');
1341
+ expect(allText).not.toContain('}]');
1342
+ expect(allText).not.toContain('test}]more');
1343
+ expect(allText).toContain('Let me check.');
1344
+
1345
+ controller.destroy();
1346
+ });
1347
+
1348
+ test('ASK_GUARDIAN_APPROVAL with malformed JSON: falls through to informational ASK_GUARDIAN', async () => {
1349
+ // Malformed JSON in the approval marker — should be ignored, and if there's
1350
+ // also an informational ASK_GUARDIAN marker, it should be used instead
1351
+ mockStartVoiceTurn.mockImplementation(createMockVoiceTurn(
1352
+ ['Checking. [ASK_GUARDIAN_APPROVAL: {invalid json}] [ASK_GUARDIAN: Fallback question?]'],
1353
+ ));
1354
+ const { session, controller } = setupController('Test fallback');
1355
+
1356
+ await controller.handleCallerUtterance('Do something');
1357
+ await new Promise((r) => setTimeout(r, 50));
1358
+
1359
+ const pendingRequest = getPendingRequestByCallSessionId(session.id);
1360
+ expect(pendingRequest).not.toBeNull();
1361
+ expect(pendingRequest!.questionText).toBe('Fallback question?');
1362
+ // Tool metadata should be null since the approval marker was malformed
1363
+ expect(pendingRequest!.toolName).toBeNull();
1364
+ expect(pendingRequest!.inputDigest).toBeNull();
1365
+
1366
+ controller.destroy();
1367
+ });
1198
1368
  });
@@ -354,6 +354,66 @@ describe('Permission Checker', () => {
354
354
  test('env injection is high risk', async () => {
355
355
  expect(await classifyRisk('bash', { command: 'LD_PRELOAD=evil.so cmd' })).toBe(RiskLevel.High);
356
356
  });
357
+
358
+ test('wrapped rm via env is high risk', async () => {
359
+ expect(await classifyRisk('bash', { command: 'env rm -rf /tmp/x' })).toBe(RiskLevel.High);
360
+ });
361
+
362
+ test('wrapped rm via time is high risk', async () => {
363
+ expect(await classifyRisk('bash', { command: 'time rm file.txt' })).toBe(RiskLevel.High);
364
+ });
365
+
366
+ test('wrapped kill via env is high risk', async () => {
367
+ expect(await classifyRisk('bash', { command: 'env kill -9 1234' })).toBe(RiskLevel.High);
368
+ });
369
+
370
+ test('wrapped sudo via env is high risk', async () => {
371
+ expect(await classifyRisk('bash', { command: 'env sudo apt-get install foo' })).toBe(RiskLevel.High);
372
+ });
373
+
374
+ test('wrapped reboot via nice is high risk', async () => {
375
+ expect(await classifyRisk('bash', { command: 'nice reboot' })).toBe(RiskLevel.High);
376
+ });
377
+
378
+ test('wrapped pkill via nohup is high risk', async () => {
379
+ expect(await classifyRisk('bash', { command: 'nohup pkill node' })).toBe(RiskLevel.High);
380
+ });
381
+
382
+ test('command -v is low risk (read-only lookup)', async () => {
383
+ expect(await classifyRisk('bash', { command: 'command -v rm' })).toBe(RiskLevel.Low);
384
+ });
385
+
386
+ test('command -V is low risk (read-only lookup)', async () => {
387
+ expect(await classifyRisk('bash', { command: 'command -V sudo' })).toBe(RiskLevel.Low);
388
+ });
389
+
390
+ test('command without -v/-V flag escalates wrapped program', async () => {
391
+ expect(await classifyRisk('bash', { command: 'command rm file.txt' })).toBe(RiskLevel.High);
392
+ });
393
+
394
+ test('rm BOOTSTRAP.md (bare safe file) is medium risk', async () => {
395
+ expect(await classifyRisk('bash', { command: 'rm BOOTSTRAP.md' })).toBe(RiskLevel.Medium);
396
+ });
397
+
398
+ test('rm UPDATES.md (bare safe file) is medium risk', async () => {
399
+ expect(await classifyRisk('bash', { command: 'rm UPDATES.md' })).toBe(RiskLevel.Medium);
400
+ });
401
+
402
+ test('rm -rf BOOTSTRAP.md is still high risk (flags present)', async () => {
403
+ expect(await classifyRisk('bash', { command: 'rm -rf BOOTSTRAP.md' })).toBe(RiskLevel.High);
404
+ });
405
+
406
+ test('rm /path/to/BOOTSTRAP.md is still high risk (path separator)', async () => {
407
+ expect(await classifyRisk('bash', { command: 'rm /path/to/BOOTSTRAP.md' })).toBe(RiskLevel.High);
408
+ });
409
+
410
+ test('rm BOOTSTRAP.md other.txt is still high risk (multiple targets)', async () => {
411
+ expect(await classifyRisk('bash', { command: 'rm BOOTSTRAP.md other.txt' })).toBe(RiskLevel.High);
412
+ });
413
+
414
+ test('rm somefile.md is still high risk (not a known safe file)', async () => {
415
+ expect(await classifyRisk('bash', { command: 'rm somefile.md' })).toBe(RiskLevel.High);
416
+ });
357
417
  });
358
418
 
359
419
  // unknown tool