@vellumai/assistant 0.3.16 → 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 (114) hide show
  1. package/ARCHITECTURE.md +74 -13
  2. package/README.md +6 -0
  3. package/docs/architecture/http-token-refresh.md +23 -1
  4. package/docs/architecture/security.md +80 -0
  5. package/package.json +1 -1
  6. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +4 -0
  7. package/src/__tests__/access-request-decision.test.ts +4 -7
  8. package/src/__tests__/call-controller.test.ts +170 -0
  9. package/src/__tests__/channel-guardian.test.ts +3 -1
  10. package/src/__tests__/checker.test.ts +139 -48
  11. package/src/__tests__/config-watcher.test.ts +11 -13
  12. package/src/__tests__/conversation-pairing.test.ts +103 -3
  13. package/src/__tests__/guardian-action-conversation-turn.test.ts +1 -1
  14. package/src/__tests__/guardian-action-followup-executor.test.ts +1 -1
  15. package/src/__tests__/guardian-action-grant-mint-consume.test.ts +511 -0
  16. package/src/__tests__/guardian-action-late-reply.test.ts +131 -0
  17. package/src/__tests__/guardian-action-store.test.ts +182 -0
  18. package/src/__tests__/guardian-dispatch.test.ts +180 -0
  19. package/src/__tests__/guardian-grant-minting.test.ts +543 -0
  20. package/src/__tests__/ipc-snapshot.test.ts +22 -0
  21. package/src/__tests__/non-member-access-request.test.ts +1 -2
  22. package/src/__tests__/notification-broadcaster.test.ts +115 -4
  23. package/src/__tests__/notification-decision-strategy.test.ts +2 -1
  24. package/src/__tests__/notification-deep-link.test.ts +44 -1
  25. package/src/__tests__/notification-guardian-path.test.ts +157 -0
  26. package/src/__tests__/notification-thread-candidate-validation.test.ts +215 -0
  27. package/src/__tests__/remote-skill-policy.test.ts +215 -0
  28. package/src/__tests__/scoped-approval-grants.test.ts +521 -0
  29. package/src/__tests__/scoped-grant-security-matrix.test.ts +443 -0
  30. package/src/__tests__/slack-channel-config.test.ts +3 -3
  31. package/src/__tests__/trust-store.test.ts +23 -21
  32. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +5 -7
  33. package/src/__tests__/trusted-contact-multichannel.test.ts +2 -6
  34. package/src/__tests__/trusted-contact-verification.test.ts +9 -9
  35. package/src/__tests__/update-bulletin-state.test.ts +1 -1
  36. package/src/__tests__/update-bulletin.test.ts +66 -3
  37. package/src/__tests__/update-template-contract.test.ts +6 -11
  38. package/src/__tests__/voice-scoped-grant-consumer.test.ts +571 -0
  39. package/src/__tests__/voice-session-bridge.test.ts +109 -9
  40. package/src/calls/call-controller.ts +150 -8
  41. package/src/calls/call-domain.ts +12 -0
  42. package/src/calls/guardian-action-sweep.ts +1 -1
  43. package/src/calls/guardian-dispatch.ts +16 -0
  44. package/src/calls/relay-server.ts +13 -0
  45. package/src/calls/voice-session-bridge.ts +46 -5
  46. package/src/cli/core-commands.ts +41 -1
  47. package/src/config/bundled-skills/notifications/SKILL.md +18 -0
  48. package/src/config/schema.ts +6 -0
  49. package/src/config/skills-schema.ts +27 -0
  50. package/src/config/templates/UPDATES.md +5 -6
  51. package/src/config/update-bulletin-format.ts +2 -0
  52. package/src/config/update-bulletin-state.ts +1 -1
  53. package/src/config/update-bulletin-template-path.ts +6 -0
  54. package/src/config/update-bulletin.ts +21 -6
  55. package/src/daemon/config-watcher.ts +3 -2
  56. package/src/daemon/daemon-control.ts +64 -10
  57. package/src/daemon/handlers/config-channels.ts +18 -0
  58. package/src/daemon/handlers/config-slack-channel.ts +1 -1
  59. package/src/daemon/handlers/identity.ts +45 -25
  60. package/src/daemon/handlers/sessions.ts +1 -1
  61. package/src/daemon/handlers/skills.ts +45 -2
  62. package/src/daemon/ipc-contract/sessions.ts +1 -1
  63. package/src/daemon/ipc-contract/skills.ts +1 -0
  64. package/src/daemon/ipc-contract/workspace.ts +12 -1
  65. package/src/daemon/ipc-contract-inventory.json +1 -0
  66. package/src/daemon/lifecycle.ts +8 -0
  67. package/src/daemon/server.ts +25 -3
  68. package/src/daemon/session-process.ts +450 -184
  69. package/src/daemon/tls-certs.ts +17 -12
  70. package/src/daemon/tool-side-effects.ts +1 -1
  71. package/src/memory/channel-delivery-store.ts +18 -20
  72. package/src/memory/channel-guardian-store.ts +39 -42
  73. package/src/memory/conversation-crud.ts +2 -2
  74. package/src/memory/conversation-queries.ts +2 -2
  75. package/src/memory/conversation-store.ts +24 -25
  76. package/src/memory/db-init.ts +17 -1
  77. package/src/memory/embedding-local.ts +16 -7
  78. package/src/memory/fts-reconciler.ts +41 -26
  79. package/src/memory/guardian-action-store.ts +65 -7
  80. package/src/memory/guardian-verification.ts +1 -0
  81. package/src/memory/jobs-worker.ts +2 -2
  82. package/src/memory/migrations/032-guardian-delivery-conversation-index.ts +15 -0
  83. package/src/memory/migrations/032-notification-delivery-thread-decision.ts +20 -0
  84. package/src/memory/migrations/033-scoped-approval-grants.ts +51 -0
  85. package/src/memory/migrations/034-guardian-action-tool-metadata.ts +12 -0
  86. package/src/memory/migrations/index.ts +6 -2
  87. package/src/memory/schema-migration.ts +1 -0
  88. package/src/memory/schema.ts +36 -1
  89. package/src/memory/scoped-approval-grants.ts +509 -0
  90. package/src/memory/search/semantic.ts +3 -3
  91. package/src/notifications/README.md +158 -17
  92. package/src/notifications/broadcaster.ts +68 -50
  93. package/src/notifications/conversation-pairing.ts +96 -18
  94. package/src/notifications/decision-engine.ts +6 -3
  95. package/src/notifications/deliveries-store.ts +12 -0
  96. package/src/notifications/emit-signal.ts +1 -0
  97. package/src/notifications/thread-candidates.ts +60 -25
  98. package/src/notifications/types.ts +2 -1
  99. package/src/permissions/checker.ts +28 -16
  100. package/src/permissions/defaults.ts +14 -4
  101. package/src/runtime/guardian-action-followup-executor.ts +1 -1
  102. package/src/runtime/guardian-action-grant-minter.ts +97 -0
  103. package/src/runtime/http-server.ts +11 -11
  104. package/src/runtime/routes/access-request-decision.ts +1 -1
  105. package/src/runtime/routes/debug-routes.ts +4 -4
  106. package/src/runtime/routes/guardian-approval-interception.ts +120 -4
  107. package/src/runtime/routes/inbound-message-handler.ts +100 -33
  108. package/src/runtime/routes/integration-routes.ts +2 -2
  109. package/src/security/tool-approval-digest.ts +67 -0
  110. package/src/skills/remote-skill-policy.ts +131 -0
  111. package/src/tools/permission-checker.ts +1 -2
  112. package/src/tools/secret-detection-handler.ts +1 -1
  113. package/src/tools/system/voice-config.ts +1 -1
  114. package/src/version.ts +29 -2
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.
@@ -218,7 +222,7 @@ The app token is validated by format only — it must start with `xapp-`.
218
222
 
219
223
  **Connection status:**
220
224
 
221
- The `GET` endpoint reports `connected: true` only when both `hasBotToken` and `hasAppToken` are true. If only one token is stored, a `warning` field describes which token is missing.
225
+ Both `GET` and `POST` endpoints report `connected: true` only when both `hasBotToken` and `hasAppToken` are true. The `POST` endpoint additionally returns a `warning` field when only one token is stored, describing which token is missing.
222
226
 
223
227
  **Key source files:**
224
228
 
@@ -276,6 +280,33 @@ External users who are not the guardian can gain access to the assistant through
276
280
  | `src/memory/channel-guardian-store.ts` | Approval request and verification challenge persistence |
277
281
  | `src/config/vellum-skills/trusted-contacts/SKILL.md` | Skill teaching the assistant to manage contacts via HTTP API |
278
282
 
283
+ ### Update Bulletin System
284
+
285
+ Release-driven update notification system that surfaces release notes to the assistant via the system prompt.
286
+
287
+ **Data flow:**
288
+ 1. **Bundled template** (`src/config/templates/UPDATES.md`) — source of release notes, maintained per-release in the repo.
289
+ 2. **Startup sync** (`syncUpdateBulletinOnStartup()` in `src/config/update-bulletin.ts`) — materializes the bundled template into the workspace `UPDATES.md` on daemon boot. Uses atomic write (temp + rename) for crash safety.
290
+ 3. **System prompt injection** — `buildSystemPrompt()` reads workspace `UPDATES.md` and injects it as a `## Recent Updates` section with judgment-based handling instructions.
291
+ 4. **Completion by deletion** — the assistant deletes `UPDATES.md` when it has actioned all updates. Next startup detects the deletion and marks those releases as completed in checkpoint state.
292
+ 5. **Cross-release merge** — if pending updates from a prior release exist when a new release lands, both release blocks coexist in the same file.
293
+
294
+ **Checkpoint keys** (in `memory_checkpoints` table):
295
+ - `updates:active_releases` — JSON array of version strings currently active.
296
+ - `updates:completed_releases` — JSON array of version strings already completed.
297
+
298
+ **Key source files:**
299
+
300
+ | File | Purpose |
301
+ |------|---------|
302
+ | `src/config/templates/UPDATES.md` | Bundled release-note template |
303
+ | `src/config/update-bulletin.ts` | Startup sync logic (materialize, delete-complete, merge) |
304
+ | `src/config/update-bulletin-format.ts` | Release block formatter/parser helpers |
305
+ | `src/config/update-bulletin-state.ts` | Checkpoint state helpers for active/completed releases |
306
+ | `src/config/system-prompt.ts` | Prompt injection of updates section |
307
+ | `src/daemon/config-watcher.ts` | File watcher — evicts sessions on UPDATES.md changes |
308
+ | `src/permissions/defaults.ts` | Auto-allow rules for file_read/write/edit + rm UPDATES.md |
309
+
279
310
  ---
280
311
 
281
312
 
@@ -1543,9 +1574,10 @@ Keep-alive heartbeats (every 30 s by default):
1543
1574
  The notification module (`assistant/src/notifications/`) uses a signal-based architecture where producers emit free-form events and an LLM-backed decision engine determines whether, where, and how to notify the user. See `assistant/src/notifications/README.md` for the full developer guide.
1544
1575
 
1545
1576
  ```
1546
- Producer → NotificationSignal → Decision Engine (LLM) → Deterministic Checks → Broadcaster → Conversation Pairing → Adapters → Delivery
1547
- ↑ ↓
1548
- Preference Summary notification_thread_created IPC
1577
+ Producer → NotificationSignal → Candidate Generation → Decision Engine (LLM) → Deterministic Checks → Broadcaster → Conversation Pairing → Adapters → Delivery
1578
+ ↑ ↓
1579
+ Preference Summary notification_thread_created IPC
1580
+ Thread Candidates (creation-only — not emitted on reuse)
1549
1581
  ```
1550
1582
 
1551
1583
  ### Channel Policy Registry
@@ -1560,13 +1592,18 @@ Producer → NotificationSignal → Decision Engine (LLM) → Deterministic Chec
1560
1592
 
1561
1593
  Helper functions: `getDeliverableChannels()`, `getChannelPolicy()`, `isNotificationDeliverable()`, `getConversationStrategy()`.
1562
1594
 
1563
- ### Conversation Pairing
1595
+ ### Conversation Pairing and Thread Routing
1564
1596
 
1565
- Every notification delivery materializes a conversation + seed message **before** the adapter sends it (`conversation-pairing.ts`). This ensures:
1597
+ Every notification delivery materializes a conversation + seed message **before** the adapter sends it (`conversation-pairing.ts`). The pairing function now accepts a `threadAction` from the decision engine:
1598
+
1599
+ - **`reuse_existing`**: Looks up the target conversation. If valid (exists with `source: 'notification'`), the seed message is appended to the existing thread. If invalid, falls back to creating a new conversation with `threadDecisionFallbackUsed: true`.
1600
+ - **`start_new` (default)**: Creates a fresh conversation per delivery.
1601
+
1602
+ This ensures:
1566
1603
 
1567
1604
  1. Every delivery has an auditable conversation trail in the conversations table
1568
1605
  2. The macOS/iOS client can deep-link directly into the notification thread
1569
- 3. Delivery audit rows in `notification_deliveries` carry `conversation_id`, `message_id`, and `conversation_strategy` columns
1606
+ 3. Delivery audit rows in `notification_deliveries` carry `conversation_id`, `message_id`, `conversation_strategy`, `thread_action`, `thread_target_conversation_id`, and `thread_decision_fallback_used` columns
1570
1607
 
1571
1608
  The pairing function (`pairDeliveryWithConversation`) is resilient — errors are caught and logged without breaking the delivery pipeline.
1572
1609
 
@@ -1577,19 +1614,42 @@ The notification pipeline uses a single conversation materialization path across
1577
1614
  1. **Canonical pipeline** (`emitNotificationSignal` → decision engine → broadcaster → conversation pairing → adapters): The broadcaster pairs each delivery with a conversation, then dispatches a `notification_intent` IPC event via the Vellum adapter. The IPC payload includes `deepLinkMetadata` (e.g. `{ conversationId }`) so the macOS/iOS client can deep-link to the relevant context when the user taps the notification.
1578
1615
  2. **Guardian bookkeeping** (`dispatchGuardianQuestion`): Guardian dispatch creates `guardian_action_request` / `guardian_action_delivery` audit rows derived from pipeline delivery results and the per-dispatch `onThreadCreated` callback — there is no separate thread-creation path.
1579
1616
 
1580
- ### Thread Surfacing via `notification_thread_created` IPC
1617
+ ### Thread Surfacing via `notification_thread_created` IPC (Creation-Only)
1618
+
1619
+ The `notification_thread_created` IPC event is emitted **only when a brand-new conversation is created** by the broadcaster. Reusing an existing thread does not trigger this event — the macOS/iOS client already knows about the conversation from the original creation. This is enforced in `broadcaster.ts` by gating on `pairing.createdNewConversation === true`.
1581
1620
 
1582
- When a vellum notification thread is paired with a conversation (strategy `start_new_conversation`), the broadcaster emits a `notification_thread_created` IPC event **immediately** (before waiting for slower channel deliveries like Telegram). This pushes the thread to the macOS/iOS client so it can display the notification thread in the sidebar and deep-link to it.
1621
+ When a new vellum notification thread is created (strategy `start_new_conversation`), the broadcaster emits the IPC event **immediately** (before waiting for slower channel deliveries like Telegram). This pushes the thread to the macOS/iOS client so it can display the notification thread in the sidebar and deep-link to it.
1583
1622
 
1584
1623
  ### IPC Thread-Created Events
1585
1624
 
1586
1625
  Two IPC push events surface new threads in the macOS/iOS client sidebar:
1587
1626
 
1588
- - **`notification_thread_created`** — Emitted by `broadcaster.ts` when a notification delivery creates a vellum conversation (strategy `start_new_conversation`). Payload: `{ conversationId, title, sourceEventName }`.
1627
+ - **`notification_thread_created`** — Emitted by `broadcaster.ts` when a notification delivery **creates** a new vellum conversation (strategy `start_new_conversation`, `createdNewConversation: true`). **Not** emitted when a thread is reused. Payload: `{ conversationId, title, sourceEventName }`.
1589
1628
  - **`task_run_thread_created`** — Emitted by `work-item-runner.ts` when a task run creates a conversation. Payload: `{ conversationId, workItemId, title }`.
1590
1629
 
1591
1630
  All events follow the same pattern: the daemon creates a server-side conversation, persists an initial message, and broadcasts the IPC event so the macOS `ThreadManager` can create a visible thread in the sidebar.
1592
1631
 
1632
+ ### Thread Routing Decision Flow
1633
+
1634
+ The decision engine produces per-channel thread actions using a candidate-driven approach:
1635
+
1636
+ 1. **Candidate generation** (`thread-candidates.ts`): Queries recent notification-sourced conversations (24-hour window, up to 5 per channel) and enriches them with guardian context (pending request counts).
1637
+ 2. **LLM decision**: The candidate set is serialized into the system prompt. The LLM chooses `start_new` or `reuse_existing` (with a candidate `conversationId`) per channel.
1638
+ 3. **Strict validation** (`validateThreadActions`): Reuse targets must exist in the candidate set. Invalid targets are downgraded to `start_new`.
1639
+ 4. **Pairing execution**: `pairDeliveryWithConversation` executes the thread action — appending to an existing conversation on reuse, creating a new one otherwise.
1640
+ 5. **IPC gating**: `notification_thread_created` fires only on actual creation, not on reuse.
1641
+ 6. **Audit trail**: Thread actions are persisted in both `notification_decisions.validation_results` and `notification_deliveries` columns (`thread_action`, `thread_target_conversation_id`, `thread_decision_fallback_used`).
1642
+
1643
+ ### Guardian Multi-Request Disambiguation in Reused Threads
1644
+
1645
+ When the decision engine routes multiple guardian questions to the same conversation (via `reuse_existing`), those questions share a single thread. The guardian disambiguates which question they are answering using **request-code prefixes**:
1646
+
1647
+ - **Single pending delivery**: Matched automatically (single-match fast path).
1648
+ - **Multiple pending deliveries**: The guardian must prefix their reply with the 6-char hex request code (e.g. `A1B2C3 yes, allow it`). Case-insensitive matching.
1649
+ - **No match**: A disambiguation message is sent listing all active request codes.
1650
+
1651
+ This invariant is enforced identically on mac/vellum (`session-process.ts`), Telegram, and SMS (`inbound-message-handler.ts`). All disambiguation messages are generated through the guardian action message composer (LLM with deterministic fallback).
1652
+
1593
1653
  ### Reminder Routing Metadata
1594
1654
 
1595
1655
  Reminders carry optional `routingIntent` (`single_channel` | `multi_channel` | `all_channels`) and free-form `routingHints` metadata. When a reminder fires, this metadata flows through the notification signal into a post-decision enforcement step (`enforceRoutingIntent()` in `decision-engine.ts`) that overrides the LLM's channel selection to match the requested coverage. This enables single-reminder fanout: one reminder can produce multi-channel delivery without duplicate reminders. See `assistant/docs/architecture/scheduling.md` for the full trigger-time data flow.
@@ -1612,8 +1672,9 @@ Connected channels are resolved at signal emission time: vellum is always includ
1612
1672
  | `assistant/src/notifications/emit-signal.ts` | Single entry point for all producers; orchestrates the full pipeline |
1613
1673
  | `assistant/src/notifications/decision-engine.ts` | LLM-based routing decisions with deterministic fallback |
1614
1674
  | `assistant/src/notifications/deterministic-checks.ts` | Hard invariant checks (dedupe, source-active suppression, channel availability) |
1615
- | `assistant/src/notifications/broadcaster.ts` | Dispatches decisions to channel adapters; emits `notification_thread_created` IPC |
1616
- | `assistant/src/notifications/conversation-pairing.ts` | Materializes conversation + message per delivery based on channel strategy |
1675
+ | `assistant/src/notifications/broadcaster.ts` | Dispatches decisions to channel adapters; emits `notification_thread_created` IPC (creation-only) |
1676
+ | `assistant/src/notifications/conversation-pairing.ts` | Materializes conversation + message per delivery; executes thread reuse decisions |
1677
+ | `assistant/src/notifications/thread-candidates.ts` | Builds per-channel candidate set of recent conversations for the decision engine |
1617
1678
  | `assistant/src/notifications/adapters/macos.ts` | Vellum adapter — broadcasts `notification_intent` via IPC with deep-link metadata |
1618
1679
  | `assistant/src/notifications/adapters/telegram.ts` | Telegram adapter — POSTs to gateway `/deliver/telegram` |
1619
1680
  | `assistant/src/notifications/adapters/sms.ts` | SMS adapter — POSTs to gateway `/deliver/sms` via Twilio Messages API |
@@ -1624,7 +1685,7 @@ Connected channels are resolved at signal emission time: vellum is always includ
1624
1685
  | `assistant/src/config/bundled-skills/messaging/tools/send-notification.ts` | Explicit producer tool for user-requested notifications; emits signals into the same routing pipeline |
1625
1686
  | `assistant/src/calls/guardian-dispatch.ts` | Guardian question dispatch that reuses canonical notification pairing and records guardian delivery bookkeeping from pipeline results |
1626
1687
 
1627
- **Audit trail (SQLite):** `notification_events` → `notification_decisions` → `notification_deliveries` (with `conversation_id`, `message_id`, `conversation_strategy`)
1688
+ **Audit trail (SQLite):** `notification_events` → `notification_decisions` (with `threadActions` in validation results) → `notification_deliveries` (with `conversation_id`, `message_id`, `conversation_strategy`, `thread_action`, `thread_target_conversation_id`, `thread_decision_fallback_used`)
1628
1689
 
1629
1690
  **Configuration:** `notifications.decisionModelIntent` in `config.json`.
1630
1691
 
package/README.md CHANGED
@@ -50,6 +50,12 @@ cp .env.example .env
50
50
  | `RUNTIME_GATEWAY_ORIGIN_SECRET` | No | — | Dedicated secret for the `X-Gateway-Origin` proof header on `/channels/inbound`. When not set, falls back to the bearer token. Both gateway and runtime must share the same value. |
51
51
  | `VELLUM_DAEMON_SOCKET` | No | `~/.vellum/vellum.sock` | Override the daemon socket path |
52
52
 
53
+ ## Update Bulletin
54
+
55
+ When a release includes relevant updates, the daemon materializes release notes from the bundled `src/config/templates/UPDATES.md` into `~/.vellum/workspace/UPDATES.md` on startup. The assistant uses judgment to surface updates to the user when relevant, and deletes the file when done.
56
+
57
+ **For release maintainers:** Update `assistant/src/config/templates/UPDATES.md` with release notes before each relevant release. Leave the template empty (or comment-only) for releases with no user/assistant-facing changes.
58
+
53
59
  ## Usage
54
60
 
55
61
  ### Start the daemon
@@ -4,7 +4,7 @@ Design for how the daemon notifies clients of bearer token rotation and how clie
4
4
 
5
5
  ## Current State
6
6
 
7
- The daemon's HTTP bearer token is generated at startup and persisted to `~/.vellum/http-token` (mode 0600). Clients read this file at connection time:
7
+ The daemon's HTTP bearer token is resolved at startup and persisted to `~/.vellum/http-token` (mode 0600). The startup token resolution order is: (1) the `RUNTIME_PROXY_BEARER_TOKEN` env var if set, (2) the existing token read from `~/.vellum/http-token` if the file is readable and non-empty, (3) a newly generated random token as a last resort. Clients read this file at connection time:
8
8
 
9
9
  - **macOS (local)**: Reads `~/.vellum/http-token` from disk via `resolveHttpTokenPath()` / `readHttpToken()`. Has direct filesystem access to the token file.
10
10
  - **iOS (remote)**: Receives the bearer token during the QR-code pairing flow. The token is stored in the iOS Keychain and used for all subsequent HTTP/SSE requests.
@@ -177,6 +177,28 @@ private rotateToken(revoke: boolean): string {
177
177
  }
178
178
  ```
179
179
 
180
+ **Failure semantics — routine vs. revocation**:
181
+
182
+ The two rotation modes have deliberately different failure ordering to match their security requirements:
183
+
184
+ | | Routine (`revoke: false`) | Revocation (`revoke: true`) |
185
+ |---|---|---|
186
+ | **Order** | Persist to disk first, then update in-memory state | Update in-memory state first, then persist to disk |
187
+ | **Disk write failure** | Rotation aborts cleanly — in-memory auth state is untouched, clients keep working with the old token | Old token is already invalidated in memory; the API endpoint returns an error to the caller |
188
+ | **Rationale** | Availability: don't lock out clients if persistence fails | Security: a potentially compromised token must never remain valid, even briefly |
189
+
190
+ **Revocation disk-write failure in detail**: If `writeTokenToDisk` throws after the in-memory switch during revocation, the system enters a degraded state:
191
+
192
+ 1. **In-memory state**: `currentToken` holds the new (unpersisted) token. The old token is rejected. All old-token SSE connections have been terminated.
193
+ 2. **Disk state**: `~/.vellum/http-token` still contains the old (now-invalid) token.
194
+ 3. **API response**: The `POST /v1/auth/rotate-token` endpoint returns an error indicating the persistence failure. The response body includes the new token so the caller can manually persist or distribute it if needed.
195
+ 4. **Client impact by platform**:
196
+ - **macOS**: Re-reading the token file yields the stale old token, which is rejected (401). Recovery requires a daemon restart (which generates a fresh token and persists it) or a successful retry of the rotation API call.
197
+ - **iOS**: Already disconnected (old-token SSE terminated). Cannot recover until the daemon restarts or the rotation is retried successfully, at which point re-pairing is required.
198
+ - **Chrome extension**: Same as iOS — the pasted token is stale and rejected.
199
+ 5. **Daemon restart recovery**: A daemon restart does **not** automatically heal this state. At startup, the daemon first checks for the `RUNTIME_PROXY_BEARER_TOKEN` env var, then tries to read the existing token from `~/.vellum/http-token`, and only generates a new random token if both are unavailable (see `assistant/src/daemon/lifecycle.ts`, lines 110-124). In the degraded state described here — where the disk still holds the old (now-invalid) token — a restart would reload that stale token, making it the active bearer token again. To actually recover, the operator must either: (a) manually delete or overwrite `~/.vellum/http-token` before restarting the daemon, (b) set `RUNTIME_PROXY_BEARER_TOKEN` to a known-good value, or (c) successfully retry the `POST /v1/auth/rotate-token` endpoint while the daemon is still running with the new in-memory token.
200
+ 6. **Why this is acceptable**: Revocation is a security-critical operation triggered when the old token is suspected compromised. The invariant — "a compromised token must not remain valid" — takes precedence over client convenience. The degraded state requires manual intervention but disk write failures are rare in practice (permissions, disk full), and the API response includes the new token so the caller can retry or manually persist it.
201
+
180
202
  **SSE event emission** (routine rotation only): The `token_rotated` event is published to `assistantEventHub` as a `ServerMessage`, reaching all connected SSE subscribers across all conversations. This event is never emitted during revocation rotations.
181
203
 
182
204
  ### 5. iOS Client Implementation
@@ -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.16",
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,
@@ -55,20 +55,18 @@ mock.module('../runtime/gateway-client.js', () => ({
55
55
 
56
56
  import {
57
57
  createApprovalRequest,
58
- createBinding,
59
58
  getApprovalRequestById,
60
- findPendingAccessRequestForRequester,
61
59
  } from '../memory/channel-guardian-store.js';
60
+ import { getDb, initializeDb, resetDb } from '../memory/db.js';
62
61
  import {
63
62
  findActiveSession,
64
63
  } from '../runtime/channel-guardian-service.js';
65
- import { initializeDb, resetDb } from '../memory/db.js';
66
64
  import {
67
- handleAccessRequestDecision,
68
65
  deliverVerificationCodeToGuardian,
66
+ handleAccessRequestDecision,
69
67
  notifyRequesterOfApproval,
70
- notifyRequesterOfDenial,
71
68
  notifyRequesterOfDeliveryFailure,
69
+ notifyRequesterOfDenial,
72
70
  } from '../runtime/routes/access-request-decision.js';
73
71
 
74
72
  initializeDb();
@@ -85,7 +83,6 @@ afterAll(() => {
85
83
  const GUARDIAN_APPROVAL_TTL_MS = 5 * 60 * 1000;
86
84
 
87
85
  function resetState(): void {
88
- const { getDb } = require('../memory/db.js');
89
86
  const db = getDb();
90
87
  db.run('DELETE FROM channel_guardian_approval_requests');
91
88
  db.run('DELETE FROM channel_guardian_bindings');
@@ -215,7 +212,7 @@ describe('access request decision handler', () => {
215
212
  'guardian-user-789',
216
213
  );
217
214
  expect(result1.type).toBe('approved');
218
- const sessionId1 = result1.verificationSessionId;
215
+ const _sessionId1 = result1.verificationSessionId;
219
216
 
220
217
  // Approve again — should be idempotent (already resolved with same decision)
221
218
  const result2 = handleAccessRequestDecision(
@@ -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
  });
@@ -2743,7 +2743,9 @@ describe('outbound SMS verification', () => {
2743
2743
  // Guardian outbound sessions (no verificationPurpose override) create
2744
2744
  // guardian bindings on success
2745
2745
  expect(result.verificationType).toBe('guardian');
2746
- expect(result.bindingId).toBeDefined();
2746
+ if (result.verificationType === 'guardian') {
2747
+ expect(result.bindingId).toBeDefined();
2748
+ }
2747
2749
  }
2748
2750
  });
2749
2751