@vellumai/assistant 0.3.16 → 0.3.18

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 (90) hide show
  1. package/ARCHITECTURE.md +70 -13
  2. package/README.md +6 -0
  3. package/docs/architecture/http-token-refresh.md +23 -1
  4. package/package.json +1 -1
  5. package/src/__tests__/access-request-decision.test.ts +4 -7
  6. package/src/__tests__/channel-guardian.test.ts +3 -1
  7. package/src/__tests__/checker.test.ts +79 -48
  8. package/src/__tests__/config-watcher.test.ts +11 -13
  9. package/src/__tests__/conversation-pairing.test.ts +103 -3
  10. package/src/__tests__/guardian-action-conversation-turn.test.ts +1 -1
  11. package/src/__tests__/guardian-action-followup-executor.test.ts +1 -1
  12. package/src/__tests__/guardian-action-late-reply.test.ts +131 -0
  13. package/src/__tests__/guardian-action-store.test.ts +182 -0
  14. package/src/__tests__/guardian-dispatch.test.ts +120 -0
  15. package/src/__tests__/ipc-snapshot.test.ts +21 -0
  16. package/src/__tests__/non-member-access-request.test.ts +1 -2
  17. package/src/__tests__/notification-broadcaster.test.ts +115 -4
  18. package/src/__tests__/notification-decision-strategy.test.ts +2 -1
  19. package/src/__tests__/notification-deep-link.test.ts +44 -1
  20. package/src/__tests__/notification-guardian-path.test.ts +157 -0
  21. package/src/__tests__/notification-thread-candidate-validation.test.ts +215 -0
  22. package/src/__tests__/slack-channel-config.test.ts +3 -3
  23. package/src/__tests__/trust-store.test.ts +21 -21
  24. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +5 -7
  25. package/src/__tests__/trusted-contact-multichannel.test.ts +2 -6
  26. package/src/__tests__/trusted-contact-verification.test.ts +9 -9
  27. package/src/__tests__/update-bulletin-state.test.ts +1 -1
  28. package/src/__tests__/update-bulletin.test.ts +66 -3
  29. package/src/__tests__/update-template-contract.test.ts +6 -11
  30. package/src/__tests__/voice-session-bridge.test.ts +109 -9
  31. package/src/calls/call-controller.ts +129 -8
  32. package/src/calls/guardian-action-sweep.ts +1 -1
  33. package/src/calls/guardian-dispatch.ts +8 -0
  34. package/src/calls/voice-session-bridge.ts +4 -2
  35. package/src/cli/core-commands.ts +41 -1
  36. package/src/config/templates/UPDATES.md +5 -6
  37. package/src/config/update-bulletin-format.ts +2 -0
  38. package/src/config/update-bulletin-state.ts +1 -1
  39. package/src/config/update-bulletin-template-path.ts +6 -0
  40. package/src/config/update-bulletin.ts +21 -6
  41. package/src/daemon/config-watcher.ts +3 -2
  42. package/src/daemon/daemon-control.ts +64 -10
  43. package/src/daemon/handlers/config-slack-channel.ts +1 -1
  44. package/src/daemon/handlers/identity.ts +45 -25
  45. package/src/daemon/handlers/sessions.ts +1 -1
  46. package/src/daemon/ipc-contract/sessions.ts +1 -1
  47. package/src/daemon/ipc-contract/workspace.ts +12 -1
  48. package/src/daemon/ipc-contract-inventory.json +1 -0
  49. package/src/daemon/lifecycle.ts +8 -0
  50. package/src/daemon/server.ts +25 -3
  51. package/src/daemon/session-process.ts +438 -184
  52. package/src/daemon/tls-certs.ts +17 -12
  53. package/src/daemon/tool-side-effects.ts +1 -1
  54. package/src/memory/channel-delivery-store.ts +18 -20
  55. package/src/memory/channel-guardian-store.ts +39 -42
  56. package/src/memory/conversation-crud.ts +2 -2
  57. package/src/memory/conversation-queries.ts +2 -2
  58. package/src/memory/conversation-store.ts +24 -25
  59. package/src/memory/db-init.ts +9 -1
  60. package/src/memory/fts-reconciler.ts +41 -26
  61. package/src/memory/guardian-action-store.ts +57 -7
  62. package/src/memory/guardian-verification.ts +1 -0
  63. package/src/memory/jobs-worker.ts +2 -2
  64. package/src/memory/migrations/032-guardian-delivery-conversation-index.ts +15 -0
  65. package/src/memory/migrations/032-notification-delivery-thread-decision.ts +20 -0
  66. package/src/memory/migrations/index.ts +4 -2
  67. package/src/memory/schema-migration.ts +1 -0
  68. package/src/memory/schema.ts +6 -1
  69. package/src/memory/search/semantic.ts +3 -3
  70. package/src/notifications/README.md +158 -17
  71. package/src/notifications/broadcaster.ts +68 -50
  72. package/src/notifications/conversation-pairing.ts +96 -18
  73. package/src/notifications/decision-engine.ts +6 -3
  74. package/src/notifications/deliveries-store.ts +12 -0
  75. package/src/notifications/emit-signal.ts +1 -0
  76. package/src/notifications/thread-candidates.ts +60 -25
  77. package/src/notifications/types.ts +2 -1
  78. package/src/permissions/checker.ts +1 -16
  79. package/src/permissions/defaults.ts +14 -4
  80. package/src/runtime/guardian-action-followup-executor.ts +1 -1
  81. package/src/runtime/http-server.ts +11 -11
  82. package/src/runtime/routes/access-request-decision.ts +1 -1
  83. package/src/runtime/routes/debug-routes.ts +4 -4
  84. package/src/runtime/routes/guardian-approval-interception.ts +4 -4
  85. package/src/runtime/routes/inbound-message-handler.ts +6 -6
  86. package/src/runtime/routes/integration-routes.ts +2 -2
  87. package/src/tools/permission-checker.ts +1 -2
  88. package/src/tools/secret-detection-handler.ts +1 -1
  89. package/src/tools/system/voice-config.ts +1 -1
  90. package/src/version.ts +29 -2
package/ARCHITECTURE.md CHANGED
@@ -218,7 +218,7 @@ The app token is validated by format only — it must start with `xapp-`.
218
218
 
219
219
  **Connection status:**
220
220
 
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.
221
+ 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
222
 
223
223
  **Key source files:**
224
224
 
@@ -276,6 +276,33 @@ External users who are not the guardian can gain access to the assistant through
276
276
  | `src/memory/channel-guardian-store.ts` | Approval request and verification challenge persistence |
277
277
  | `src/config/vellum-skills/trusted-contacts/SKILL.md` | Skill teaching the assistant to manage contacts via HTTP API |
278
278
 
279
+ ### Update Bulletin System
280
+
281
+ Release-driven update notification system that surfaces release notes to the assistant via the system prompt.
282
+
283
+ **Data flow:**
284
+ 1. **Bundled template** (`src/config/templates/UPDATES.md`) — source of release notes, maintained per-release in the repo.
285
+ 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.
286
+ 3. **System prompt injection** — `buildSystemPrompt()` reads workspace `UPDATES.md` and injects it as a `## Recent Updates` section with judgment-based handling instructions.
287
+ 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.
288
+ 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.
289
+
290
+ **Checkpoint keys** (in `memory_checkpoints` table):
291
+ - `updates:active_releases` — JSON array of version strings currently active.
292
+ - `updates:completed_releases` — JSON array of version strings already completed.
293
+
294
+ **Key source files:**
295
+
296
+ | File | Purpose |
297
+ |------|---------|
298
+ | `src/config/templates/UPDATES.md` | Bundled release-note template |
299
+ | `src/config/update-bulletin.ts` | Startup sync logic (materialize, delete-complete, merge) |
300
+ | `src/config/update-bulletin-format.ts` | Release block formatter/parser helpers |
301
+ | `src/config/update-bulletin-state.ts` | Checkpoint state helpers for active/completed releases |
302
+ | `src/config/system-prompt.ts` | Prompt injection of updates section |
303
+ | `src/daemon/config-watcher.ts` | File watcher — evicts sessions on UPDATES.md changes |
304
+ | `src/permissions/defaults.ts` | Auto-allow rules for file_read/write/edit + rm UPDATES.md |
305
+
279
306
  ---
280
307
 
281
308
 
@@ -1543,9 +1570,10 @@ Keep-alive heartbeats (every 30 s by default):
1543
1570
  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
1571
 
1545
1572
  ```
1546
- Producer → NotificationSignal → Decision Engine (LLM) → Deterministic Checks → Broadcaster → Conversation Pairing → Adapters → Delivery
1547
- ↑ ↓
1548
- Preference Summary notification_thread_created IPC
1573
+ Producer → NotificationSignal → Candidate Generation → Decision Engine (LLM) → Deterministic Checks → Broadcaster → Conversation Pairing → Adapters → Delivery
1574
+ ↑ ↓
1575
+ Preference Summary notification_thread_created IPC
1576
+ Thread Candidates (creation-only — not emitted on reuse)
1549
1577
  ```
1550
1578
 
1551
1579
  ### Channel Policy Registry
@@ -1560,13 +1588,18 @@ Producer → NotificationSignal → Decision Engine (LLM) → Deterministic Chec
1560
1588
 
1561
1589
  Helper functions: `getDeliverableChannels()`, `getChannelPolicy()`, `isNotificationDeliverable()`, `getConversationStrategy()`.
1562
1590
 
1563
- ### Conversation Pairing
1591
+ ### Conversation Pairing and Thread Routing
1592
+
1593
+ 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:
1564
1594
 
1565
- Every notification delivery materializes a conversation + seed message **before** the adapter sends it (`conversation-pairing.ts`). This ensures:
1595
+ - **`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`.
1596
+ - **`start_new` (default)**: Creates a fresh conversation per delivery.
1597
+
1598
+ This ensures:
1566
1599
 
1567
1600
  1. Every delivery has an auditable conversation trail in the conversations table
1568
1601
  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
1602
+ 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
1603
 
1571
1604
  The pairing function (`pairDeliveryWithConversation`) is resilient — errors are caught and logged without breaking the delivery pipeline.
1572
1605
 
@@ -1577,19 +1610,42 @@ The notification pipeline uses a single conversation materialization path across
1577
1610
  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
1611
  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
1612
 
1580
- ### Thread Surfacing via `notification_thread_created` IPC
1613
+ ### Thread Surfacing via `notification_thread_created` IPC (Creation-Only)
1614
+
1615
+ 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
1616
 
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.
1617
+ 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
1618
 
1584
1619
  ### IPC Thread-Created Events
1585
1620
 
1586
1621
  Two IPC push events surface new threads in the macOS/iOS client sidebar:
1587
1622
 
1588
- - **`notification_thread_created`** — Emitted by `broadcaster.ts` when a notification delivery creates a vellum conversation (strategy `start_new_conversation`). Payload: `{ conversationId, title, sourceEventName }`.
1623
+ - **`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
1624
  - **`task_run_thread_created`** — Emitted by `work-item-runner.ts` when a task run creates a conversation. Payload: `{ conversationId, workItemId, title }`.
1590
1625
 
1591
1626
  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
1627
 
1628
+ ### Thread Routing Decision Flow
1629
+
1630
+ The decision engine produces per-channel thread actions using a candidate-driven approach:
1631
+
1632
+ 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).
1633
+ 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.
1634
+ 3. **Strict validation** (`validateThreadActions`): Reuse targets must exist in the candidate set. Invalid targets are downgraded to `start_new`.
1635
+ 4. **Pairing execution**: `pairDeliveryWithConversation` executes the thread action — appending to an existing conversation on reuse, creating a new one otherwise.
1636
+ 5. **IPC gating**: `notification_thread_created` fires only on actual creation, not on reuse.
1637
+ 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`).
1638
+
1639
+ ### Guardian Multi-Request Disambiguation in Reused Threads
1640
+
1641
+ 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**:
1642
+
1643
+ - **Single pending delivery**: Matched automatically (single-match fast path).
1644
+ - **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.
1645
+ - **No match**: A disambiguation message is sent listing all active request codes.
1646
+
1647
+ 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).
1648
+
1593
1649
  ### Reminder Routing Metadata
1594
1650
 
1595
1651
  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 +1668,9 @@ Connected channels are resolved at signal emission time: vellum is always includ
1612
1668
  | `assistant/src/notifications/emit-signal.ts` | Single entry point for all producers; orchestrates the full pipeline |
1613
1669
  | `assistant/src/notifications/decision-engine.ts` | LLM-based routing decisions with deterministic fallback |
1614
1670
  | `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 |
1671
+ | `assistant/src/notifications/broadcaster.ts` | Dispatches decisions to channel adapters; emits `notification_thread_created` IPC (creation-only) |
1672
+ | `assistant/src/notifications/conversation-pairing.ts` | Materializes conversation + message per delivery; executes thread reuse decisions |
1673
+ | `assistant/src/notifications/thread-candidates.ts` | Builds per-channel candidate set of recent conversations for the decision engine |
1617
1674
  | `assistant/src/notifications/adapters/macos.ts` | Vellum adapter — broadcasts `notification_intent` via IPC with deep-link metadata |
1618
1675
  | `assistant/src/notifications/adapters/telegram.ts` | Telegram adapter — POSTs to gateway `/deliver/telegram` |
1619
1676
  | `assistant/src/notifications/adapters/sms.ts` | SMS adapter — POSTs to gateway `/deliver/sms` via Twilio Messages API |
@@ -1624,7 +1681,7 @@ Connected channels are resolved at signal emission time: vellum is always includ
1624
1681
  | `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
1682
  | `assistant/src/calls/guardian-dispatch.ts` | Guardian question dispatch that reuses canonical notification pairing and records guardian delivery bookkeeping from pipeline results |
1626
1683
 
1627
- **Audit trail (SQLite):** `notification_events` → `notification_decisions` → `notification_deliveries` (with `conversation_id`, `message_id`, `conversation_strategy`)
1684
+ **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
1685
 
1629
1686
  **Configuration:** `notifications.decisionModelIntent` in `config.json`.
1630
1687
 
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.3.16",
3
+ "version": "0.3.18",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "vellum": "./src/index.ts"
@@ -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(
@@ -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
 
@@ -272,8 +272,8 @@ describe('Permission Checker', () => {
272
272
  expect(await classifyRisk('bash', { command: 'some_custom_tool' })).toBe(RiskLevel.Medium);
273
273
  });
274
274
 
275
- test('rm (without -r) is medium risk', async () => {
276
- expect(await classifyRisk('bash', { command: 'rm file.txt' })).toBe(RiskLevel.Medium);
275
+ test('rm (without -r) is high risk', async () => {
276
+ expect(await classifyRisk('bash', { command: 'rm file.txt' })).toBe(RiskLevel.High);
277
277
  });
278
278
 
279
279
  test('chmod is medium risk', async () => {
@@ -374,7 +374,7 @@ describe('Permission Checker', () => {
374
374
  expect(high.matchedRule?.id).toBe('default:allow-bash-global');
375
375
 
376
376
  // Medium risk
377
- const med = await check('bash', { command: 'rm file.txt' }, '/tmp');
377
+ const med = await check('bash', { command: 'curl https://example.com' }, '/tmp');
378
378
  expect(med.decision).toBe('allow');
379
379
  expect(med.matchedRule?.id).toBe('default:allow-bash-global');
380
380
 
@@ -391,7 +391,7 @@ describe('Permission Checker', () => {
391
391
  const high = await check('bash', { command: 'sudo rm -rf /' }, '/tmp');
392
392
  expect(high.decision).toBe('prompt');
393
393
 
394
- const med = await check('bash', { command: 'rm file.txt' }, '/tmp');
394
+ const med = await check('bash', { command: 'curl https://example.com' }, '/tmp');
395
395
  expect(med.decision).toBe('prompt');
396
396
 
397
397
  // Low risk still auto-allows via the normal risk-based fallback
@@ -409,17 +409,31 @@ describe('Permission Checker', () => {
409
409
  expect(result.decision).toBe('prompt');
410
410
  });
411
411
 
412
- test('host_bash medium risk with no matching rule → prompt', async () => {
412
+ test('host_bash rm is always high risk → prompt', async () => {
413
413
  const result = await check('host_bash', { command: 'rm file.txt' }, '/tmp');
414
414
  expect(result.decision).toBe('prompt');
415
+ expect(result.reason).toContain('High risk');
416
+ });
417
+
418
+ test('plain rm (without -rf) is high risk and prompts despite default allow rule', async () => {
419
+ // Validates that ALL rm commands are escalated to High risk, not just rm -rf.
420
+ // The default allow rule for host_bash auto-approves Low/Medium risk but
421
+ // High risk always prompts.
422
+ const result = await check('host_bash', { command: 'rm single-file.txt' }, '/tmp');
423
+ expect(result.decision).toBe('prompt');
424
+ expect(result.reason).toContain('High risk');
425
+
426
+ // Also verify rm -rf still prompts
427
+ const rfResult = await check('host_bash', { command: 'rm -rf /tmp/dir' }, '/tmp');
428
+ expect(rfResult.decision).toBe('prompt');
429
+ expect(rfResult.reason).toContain('High risk');
415
430
  });
416
431
 
417
- test('medium risk with matching trust rule → allow', async () => {
432
+ test('rm is high risk even with matching trust rule → prompt', async () => {
418
433
  addRule('bash', 'rm *', '/tmp');
419
434
  const result = await check('bash', { command: 'rm file.txt' }, '/tmp');
420
- expect(result.decision).toBe('allow');
421
- expect(result.reason).toContain('Matched trust rule');
422
- expect(result.matchedRule).toBeDefined();
435
+ expect(result.decision).toBe('prompt');
436
+ expect(result.reason).toContain('High risk');
423
437
  });
424
438
 
425
439
  test('file_read → auto-allow', async () => {
@@ -489,11 +503,11 @@ describe('Permission Checker', () => {
489
503
  expect(result.matchedRule?.id).toBe('default:ask-host_file_edit-global');
490
504
  });
491
505
 
492
- test('host_bash prompts by default via host ask rule', async () => {
506
+ test('host_bash auto-allows low risk via default allow rule', async () => {
493
507
  const result = await check('host_bash', { command: 'ls' }, '/tmp');
494
- expect(result.decision).toBe('prompt');
495
- expect(result.reason).toContain('ask rule');
496
- expect(result.matchedRule?.id).toBe('default:ask-host_bash-global');
508
+ expect(result.decision).toBe('allow');
509
+ expect(result.reason).toContain('Matched trust rule');
510
+ expect(result.matchedRule?.id).toBe('default:allow-host_bash-global');
497
511
  });
498
512
 
499
513
  test('scaffold_managed_skill prompts by default via managed skill ask rule', async () => {
@@ -597,7 +611,7 @@ describe('Permission Checker', () => {
597
611
  });
598
612
 
599
613
  // Deny rule tests
600
- test('deny rule blocks medium-risk command', async () => {
614
+ test('deny rule blocks high-risk command', async () => {
601
615
  addRule('bash', 'rm *', '/tmp', 'deny');
602
616
  const result = await check('bash', { command: 'rm file.txt' }, '/tmp');
603
617
  expect(result.decision).toBe('deny');
@@ -764,16 +778,16 @@ describe('Permission Checker', () => {
764
778
 
765
779
  // Priority-based rule resolution
766
780
  test('higher-priority allow rule overrides lower-priority deny rule', async () => {
767
- addRule('bash', 'rm *', '/tmp', 'deny', 0);
768
- addRule('bash', 'rm *', '/tmp', 'allow', 100);
769
- const result = await check('bash', { command: 'rm file.txt' }, '/tmp');
781
+ addRule('bash', 'chmod *', '/tmp', 'deny', 0);
782
+ addRule('bash', 'chmod *', '/tmp', 'allow', 100);
783
+ const result = await check('bash', { command: 'chmod 644 file.txt' }, '/tmp');
770
784
  expect(result.decision).toBe('allow');
771
785
  });
772
786
 
773
787
  test('higher-priority deny rule overrides lower-priority allow rule', async () => {
774
- addRule('bash', 'rm *', '/tmp', 'allow', 0);
775
- addRule('bash', 'rm *', '/tmp', 'deny', 100);
776
- const result = await check('bash', { command: 'rm file.txt' }, '/tmp');
788
+ addRule('bash', 'chmod *', '/tmp', 'allow', 0);
789
+ addRule('bash', 'chmod *', '/tmp', 'deny', 100);
790
+ const result = await check('bash', { command: 'chmod 644 file.txt' }, '/tmp');
777
791
  expect(result.decision).toBe('deny');
778
792
  });
779
793
 
@@ -1465,13 +1479,14 @@ describe('Permission Checker', () => {
1465
1479
  expect(result.matchedRule?.id).toBe('default:allow-bash-global');
1466
1480
  });
1467
1481
 
1468
- test('host_bash with no user rule returns prompt in strict mode', async () => {
1482
+ test('host_bash auto-allows low risk in strict mode (default allow rule is a matching rule)', async () => {
1469
1483
  testConfig.permissions.mode = 'strict';
1470
1484
  const result = await check('host_bash', { command: 'ls' }, '/tmp');
1471
- expect(result.decision).toBe('prompt');
1485
+ expect(result.decision).toBe('allow');
1486
+ expect(result.matchedRule?.id).toBe('default:allow-host_bash-global');
1472
1487
  });
1473
1488
 
1474
- test('medium-risk host_bash with no matching rule returns prompt in strict mode', async () => {
1489
+ test('high-risk host_bash (rm) with no matching rule returns prompt in strict mode', async () => {
1475
1490
  testConfig.permissions.mode = 'strict';
1476
1491
  const result = await check('host_bash', { command: 'rm file.txt' }, '/tmp');
1477
1492
  expect(result.decision).toBe('prompt');
@@ -1568,8 +1583,8 @@ describe('Permission Checker', () => {
1568
1583
  });
1569
1584
 
1570
1585
  test('medium-risk tool with allow rule is NOT affected by allowHighRisk', async () => {
1571
- addRule('bash', 'rm *', '/tmp', 'allow', 100);
1572
- const result = await check('bash', { command: 'rm file.txt' }, '/tmp');
1586
+ addRule('bash', 'chmod *', '/tmp', 'allow', 100);
1587
+ const result = await check('bash', { command: 'chmod 644 file.txt' }, '/tmp');
1573
1588
  expect(result.decision).toBe('allow');
1574
1589
  expect(result.reason).toContain('Matched trust rule');
1575
1590
  // No mention of high-risk in the reason
@@ -1639,8 +1654,8 @@ describe('Permission Checker', () => {
1639
1654
 
1640
1655
  test('strict mode: medium-risk with matching allow rule auto-allows', async () => {
1641
1656
  testConfig.permissions.mode = 'strict';
1642
- addRule('bash', 'rm *', '/tmp', 'allow');
1643
- const result = await check('bash', { command: 'rm file.txt' }, '/tmp');
1657
+ addRule('bash', 'chmod *', '/tmp', 'allow');
1658
+ const result = await check('bash', { command: 'chmod 644 file.txt' }, '/tmp');
1644
1659
  expect(result.decision).toBe('allow');
1645
1660
  expect(result.reason).toContain('Matched trust rule');
1646
1661
  });
@@ -2416,10 +2431,11 @@ describe('Permission Checker', () => {
2416
2431
  expect(result.matchedRule?.id).toBe('default:allow-bash-global');
2417
2432
  });
2418
2433
 
2419
- test('low-risk host_bash with no user rule prompts in strict mode', async () => {
2434
+ test('low-risk host_bash auto-allows in strict mode (default allow rule is a matching rule)', async () => {
2420
2435
  testConfig.permissions.mode = 'strict';
2421
2436
  const result = await check('host_bash', { command: 'echo hello' }, '/tmp');
2422
- expect(result.decision).toBe('prompt');
2437
+ expect(result.decision).toBe('allow');
2438
+ expect(result.matchedRule?.id).toBe('default:allow-host_bash-global');
2423
2439
  });
2424
2440
 
2425
2441
  test('low-risk file_read with no rule prompts in strict mode', async () => {
@@ -2481,10 +2497,10 @@ describe('Permission Checker', () => {
2481
2497
  // target-scoped. ───────────────────────────────────────────────
2482
2498
 
2483
2499
  describe('Invariant 4: host execution approvals are explicit and target-scoped', () => {
2484
- test('host_bash prompts by default (no implicit allow)', async () => {
2500
+ test('host_bash auto-allows low risk via default allow rule', async () => {
2485
2501
  const result = await check('host_bash', { command: 'ls' }, '/tmp');
2486
- expect(result.decision).toBe('prompt');
2487
- expect(result.matchedRule?.id).toBe('default:ask-host_bash-global');
2502
+ expect(result.decision).toBe('allow');
2503
+ expect(result.matchedRule?.id).toBe('default:allow-host_bash-global');
2488
2504
  });
2489
2505
 
2490
2506
  test('host_file_read prompts by default (no implicit allow)', async () => {
@@ -2531,11 +2547,11 @@ describe('Permission Checker', () => {
2531
2547
  expect(matchResult.matchedRule?.id).toBe('inv4-target-scoped');
2532
2548
 
2533
2549
  // Different target — the target-scoped rule should NOT match;
2534
- // falls back to the default host_bash ask rule (prompt)
2550
+ // falls back to the default host_bash allow rule (auto-allows medium risk)
2535
2551
  const noMatchResult = await check('host_bash', { command: 'run script.js' }, '/tmp', {
2536
2552
  executionTarget: '/usr/local/bin/bun',
2537
2553
  });
2538
- expect(noMatchResult.decision).toBe('prompt');
2554
+ expect(noMatchResult.decision).toBe('allow');
2539
2555
  expect(noMatchResult.matchedRule?.id).not.toBe('inv4-target-scoped');
2540
2556
  });
2541
2557
  });
@@ -2605,7 +2621,7 @@ describe('Permission Checker', () => {
2605
2621
  test('wildcard allow rule matches any command in legacy mode', async () => {
2606
2622
  testConfig.permissions.mode = 'legacy';
2607
2623
  addRule('bash', '*', 'everywhere');
2608
- const result = await check('bash', { command: 'rm file.txt' }, '/tmp');
2624
+ const result = await check('bash', { command: 'chmod 644 file.txt' }, '/tmp');
2609
2625
  expect(result.decision).toBe('allow');
2610
2626
  expect(result.matchedRule).toBeDefined();
2611
2627
  });
@@ -2613,7 +2629,7 @@ describe('Permission Checker', () => {
2613
2629
  test('wildcard allow rule matches any command in strict mode', async () => {
2614
2630
  testConfig.permissions.mode = 'strict';
2615
2631
  addRule('bash', '*', 'everywhere');
2616
- const result = await check('bash', { command: 'rm file.txt' }, '/tmp');
2632
+ const result = await check('bash', { command: 'chmod 644 file.txt' }, '/tmp');
2617
2633
  expect(result.decision).toBe('allow');
2618
2634
  expect(result.matchedRule).toBeDefined();
2619
2635
  });
@@ -2724,12 +2740,27 @@ describe('Permission Checker', () => {
2724
2740
  );
2725
2741
 
2726
2742
  test('getDefaultRuleTemplates has no extra rules when extraDirs is empty', () => {
2727
- // Default testConfig has no skills property → getConfig returns default
2728
- // with extraDirs: []
2729
2743
  const templates = getDefaultRuleTemplates();
2730
2744
  const extraRules = templates.filter((t) => t.id.includes('extra-'));
2731
2745
  expect(extraRules.length).toBe(0);
2732
2746
  });
2747
+
2748
+ test('getDefaultRuleTemplates tolerates partial config mocks', () => {
2749
+ const originalSkills = testConfig.skills;
2750
+ const originalSandbox = testConfig.sandbox;
2751
+ try {
2752
+ testConfig.skills = {} as any;
2753
+ testConfig.sandbox = {} as any;
2754
+
2755
+ const templates = getDefaultRuleTemplates();
2756
+ expect(Array.isArray(templates)).toBe(true);
2757
+ expect(templates.some((t) => t.id.includes('extra-'))).toBe(false);
2758
+ expect(templates.some((t) => t.id === 'default:allow-bash-global')).toBe(true);
2759
+ } finally {
2760
+ testConfig.skills = originalSkills;
2761
+ testConfig.sandbox = originalSandbox;
2762
+ }
2763
+ });
2733
2764
  });
2734
2765
 
2735
2766
  // ── backslash normalization gated to Windows (PR 3558 follow-up) ──
@@ -2952,8 +2983,8 @@ describe('bash network_mode=proxied force prompt', () => {
2952
2983
  });
2953
2984
 
2954
2985
  test('non-proxied bash with trust rule follows normal flow', async () => {
2955
- addRule('bash', 'rm *', '/tmp');
2956
- const result = await check('bash', { command: 'rm file.txt' }, '/tmp');
2986
+ addRule('bash', 'chmod *', '/tmp');
2987
+ const result = await check('bash', { command: 'chmod 644 file.txt' }, '/tmp');
2957
2988
  expect(result.decision).toBe('allow');
2958
2989
  expect(result.reason).not.toContain('Proxied network mode');
2959
2990
  });
@@ -3245,10 +3276,10 @@ describe('workspace mode — auto-allow workspace-scoped operations', () => {
3245
3276
  expect(result.reason).toContain('ask rule');
3246
3277
  });
3247
3278
 
3248
- test('host_bash → prompt (default ask rule matches)', async () => {
3279
+ test('host_bash → allow (default allow rule matches)', async () => {
3249
3280
  const result = await check('host_bash', { command: 'ls' }, workspaceDir);
3250
- expect(result.decision).toBe('prompt');
3251
- expect(result.reason).toContain('ask rule');
3281
+ expect(result.decision).toBe('allow');
3282
+ expect(result.reason).toContain('Matched trust rule');
3252
3283
  });
3253
3284
 
3254
3285
  // ── explicit rules still take precedence in workspace mode ──
@@ -3428,20 +3459,20 @@ describe('integration regressions (PR 11)', () => {
3428
3459
  });
3429
3460
 
3430
3461
  test('raw legacy rule still works alongside new action key system', async () => {
3431
- // Use medium-risk commands (rm) so they aren't auto-allowed by low-risk classification.
3462
+ // Use medium-risk commands (chmod) so they aren't auto-allowed by low-risk classification.
3432
3463
  // Disable sandbox so the catch-all "**" rule doesn't interfere.
3433
3464
  testConfig.sandbox.enabled = false;
3434
3465
  try { rmSync(join(checkerTestDir, 'protected', 'trust.json')); } catch { /* may not exist */ }
3435
3466
  clearCache();
3436
3467
  try {
3437
- addRule('bash', 'rm file.txt', 'everywhere');
3468
+ addRule('bash', 'chmod 644 file.txt', 'everywhere');
3438
3469
 
3439
3470
  // Exact match still works
3440
- const r1 = await check('bash', { command: 'rm file.txt' }, '/tmp');
3471
+ const r1 = await check('bash', { command: 'chmod 644 file.txt' }, '/tmp');
3441
3472
  expect(r1.decision).toBe('allow');
3442
3473
 
3443
- // Different rm argument should not match this exact raw rule
3444
- const r2 = await check('bash', { command: 'rm other.txt' }, '/tmp');
3474
+ // Different chmod argument should not match this exact raw rule
3475
+ const r2 = await check('bash', { command: 'chmod 755 other.txt' }, '/tmp');
3445
3476
  expect(r2.decision).not.toBe('allow');
3446
3477
  } finally {
3447
3478
  testConfig.sandbox.enabled = true;