@vellumai/assistant 0.4.2 → 0.4.4

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 (221) hide show
  1. package/.env.example +3 -0
  2. package/ARCHITECTURE.md +124 -10
  3. package/README.md +43 -35
  4. package/docs/trusted-contact-access.md +20 -0
  5. package/package.json +1 -1
  6. package/scripts/ipc/generate-swift.ts +1 -0
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +58 -120
  8. package/src/__tests__/access-request-decision.test.ts +0 -1
  9. package/src/__tests__/actor-token-service.test.ts +1099 -0
  10. package/src/__tests__/agent-loop.test.ts +51 -0
  11. package/src/__tests__/approval-routes-http.test.ts +2 -0
  12. package/src/__tests__/assistant-events-sse-hardening.test.ts +7 -5
  13. package/src/__tests__/assistant-id-boundary-guard.test.ts +415 -0
  14. package/src/__tests__/call-controller.test.ts +49 -0
  15. package/src/__tests__/call-pointer-message-composer.test.ts +171 -0
  16. package/src/__tests__/call-pointer-messages.test.ts +93 -3
  17. package/src/__tests__/call-pointer-no-hardcoded-copy.guard.test.ts +42 -0
  18. package/src/__tests__/call-routes-http.test.ts +0 -25
  19. package/src/__tests__/callback-handoff-copy.test.ts +186 -0
  20. package/src/__tests__/channel-approval-routes.test.ts +133 -12
  21. package/src/__tests__/channel-guardian.test.ts +0 -86
  22. package/src/__tests__/channel-readiness-service.test.ts +10 -16
  23. package/src/__tests__/checker.test.ts +33 -12
  24. package/src/__tests__/config-schema.test.ts +6 -0
  25. package/src/__tests__/confirmation-request-guardian-bridge.test.ts +410 -0
  26. package/src/__tests__/conversation-routes-guardian-reply.test.ts +256 -0
  27. package/src/__tests__/conversation-routes.test.ts +12 -3
  28. package/src/__tests__/credential-security-invariants.test.ts +1 -1
  29. package/src/__tests__/daemon-server-session-init.test.ts +4 -0
  30. package/src/__tests__/deterministic-verification-control-plane.test.ts +0 -1
  31. package/src/__tests__/guardian-actions-endpoint.test.ts +39 -13
  32. package/src/__tests__/guardian-dispatch.test.ts +8 -0
  33. package/src/__tests__/guardian-outbound-http.test.ts +4 -5
  34. package/src/__tests__/guardian-question-mode.test.ts +200 -0
  35. package/src/__tests__/guardian-routing-invariants.test.ts +178 -0
  36. package/src/__tests__/guardian-routing-state.test.ts +525 -0
  37. package/src/__tests__/handle-user-message-secret-resume.test.ts +2 -0
  38. package/src/__tests__/handlers-telegram-config.test.ts +0 -83
  39. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +55 -0
  40. package/src/__tests__/headless-browser-navigate.test.ts +2 -0
  41. package/src/__tests__/inbound-invite-redemption.test.ts +0 -1
  42. package/src/__tests__/ingress-routes-http.test.ts +55 -0
  43. package/src/__tests__/ipc-snapshot.test.ts +18 -51
  44. package/src/__tests__/non-member-access-request.test.ts +159 -9
  45. package/src/__tests__/notification-decision-fallback.test.ts +129 -4
  46. package/src/__tests__/notification-decision-strategy.test.ts +106 -2
  47. package/src/__tests__/notification-guardian-path.test.ts +3 -0
  48. package/src/__tests__/recording-intent-handler.test.ts +1 -0
  49. package/src/__tests__/relay-server.test.ts +1475 -33
  50. package/src/__tests__/send-endpoint-busy.test.ts +5 -0
  51. package/src/__tests__/session-agent-loop.test.ts +1 -0
  52. package/src/__tests__/session-confirmation-signals.test.ts +523 -0
  53. package/src/__tests__/session-init.benchmark.test.ts +0 -2
  54. package/src/__tests__/session-runtime-assembly.test.ts +4 -1
  55. package/src/__tests__/session-surfaces-task-progress.test.ts +44 -1
  56. package/src/__tests__/session-tool-setup-app-refresh.test.ts +81 -2
  57. package/src/__tests__/session-tool-setup-memory-scope.test.ts +1 -1
  58. package/src/__tests__/session-tool-setup-side-effect-flag.test.ts +1 -1
  59. package/src/__tests__/tool-executor.test.ts +21 -2
  60. package/src/__tests__/tool-grant-request-escalation.test.ts +333 -27
  61. package/src/__tests__/trusted-contact-approval-notifier.test.ts +678 -0
  62. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +1064 -0
  63. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +11 -1
  64. package/src/__tests__/trusted-contact-multichannel.test.ts +0 -1
  65. package/src/__tests__/trusted-contact-verification.test.ts +0 -1
  66. package/src/__tests__/twilio-config.test.ts +2 -13
  67. package/src/__tests__/twilio-routes.test.ts +4 -3
  68. package/src/__tests__/update-bulletin.test.ts +0 -1
  69. package/src/agent/loop.ts +1 -1
  70. package/src/approvals/guardian-decision-primitive.ts +12 -3
  71. package/src/approvals/guardian-request-resolvers.ts +169 -11
  72. package/src/calls/call-constants.ts +29 -0
  73. package/src/calls/call-controller.ts +11 -3
  74. package/src/calls/call-domain.ts +33 -11
  75. package/src/calls/call-pointer-message-composer.ts +154 -0
  76. package/src/calls/call-pointer-messages.ts +106 -27
  77. package/src/calls/guardian-dispatch.ts +4 -2
  78. package/src/calls/relay-server.ts +921 -112
  79. package/src/calls/twilio-config.ts +4 -11
  80. package/src/calls/twilio-routes.ts +4 -6
  81. package/src/calls/types.ts +3 -1
  82. package/src/calls/voice-session-bridge.ts +4 -3
  83. package/src/cli/core-commands.ts +7 -4
  84. package/src/cli.ts +5 -4
  85. package/src/config/bundled-skills/agentmail/SKILL.md +4 -0
  86. package/src/config/bundled-skills/app-builder/SKILL.md +309 -10
  87. package/src/config/bundled-skills/app-builder/TOOLS.json +1 -1
  88. package/src/config/bundled-skills/email-setup/SKILL.md +1 -1
  89. package/src/config/bundled-skills/google-oauth-setup/SKILL.md +105 -81
  90. package/src/config/bundled-skills/messaging/SKILL.md +61 -12
  91. package/src/config/bundled-skills/messaging/TOOLS.json +58 -0
  92. package/src/config/bundled-skills/messaging/tools/gmail-sender-digest.ts +6 -1
  93. package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +35 -0
  94. package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +52 -0
  95. package/src/config/bundled-skills/phone-calls/SKILL.md +30 -39
  96. package/src/config/bundled-skills/twitter/SKILL.md +3 -3
  97. package/src/config/bundled-skills/vercel-token-setup/SKILL.md +215 -0
  98. package/src/config/calls-schema.ts +36 -0
  99. package/src/config/env.ts +22 -0
  100. package/src/config/feature-flag-registry.json +8 -8
  101. package/src/config/schema.ts +2 -2
  102. package/src/config/skills.ts +11 -0
  103. package/src/config/system-prompt.ts +11 -1
  104. package/src/config/templates/SOUL.md +2 -0
  105. package/src/config/vellum-skills/sms-setup/SKILL.md +71 -82
  106. package/src/config/vellum-skills/trusted-contacts/SKILL.md +8 -1
  107. package/src/config/vellum-skills/twilio-setup/SKILL.md +88 -73
  108. package/src/daemon/call-pointer-generators.ts +59 -0
  109. package/src/daemon/computer-use-session.ts +2 -5
  110. package/src/daemon/handlers/apps.ts +76 -20
  111. package/src/daemon/handlers/config-channels.ts +9 -61
  112. package/src/daemon/handlers/config-inbox.ts +11 -3
  113. package/src/daemon/handlers/config-ingress.ts +28 -3
  114. package/src/daemon/handlers/config-telegram.ts +12 -0
  115. package/src/daemon/handlers/config.ts +2 -6
  116. package/src/daemon/handlers/index.ts +2 -1
  117. package/src/daemon/handlers/pairing.ts +2 -0
  118. package/src/daemon/handlers/publish.ts +11 -46
  119. package/src/daemon/handlers/sessions.ts +59 -5
  120. package/src/daemon/handlers/shared.ts +17 -2
  121. package/src/daemon/ipc-contract/apps.ts +1 -0
  122. package/src/daemon/ipc-contract/inbox.ts +4 -0
  123. package/src/daemon/ipc-contract/integrations.ts +1 -97
  124. package/src/daemon/ipc-contract/messages.ts +47 -1
  125. package/src/daemon/ipc-contract/notifications.ts +11 -0
  126. package/src/daemon/ipc-contract-inventory.json +2 -4
  127. package/src/daemon/lifecycle.ts +17 -0
  128. package/src/daemon/server.ts +16 -2
  129. package/src/daemon/session-agent-loop-handlers.ts +20 -0
  130. package/src/daemon/session-agent-loop.ts +24 -12
  131. package/src/daemon/session-lifecycle.ts +1 -1
  132. package/src/daemon/session-process.ts +11 -1
  133. package/src/daemon/session-runtime-assembly.ts +6 -1
  134. package/src/daemon/session-surfaces.ts +32 -3
  135. package/src/daemon/session.ts +88 -1
  136. package/src/daemon/tool-side-effects.ts +22 -0
  137. package/src/home-base/prebuilt/brain-graph.html +1483 -0
  138. package/src/home-base/prebuilt/index.html +40 -0
  139. package/src/inbound/platform-callback-registration.ts +157 -0
  140. package/src/memory/canonical-guardian-store.ts +1 -1
  141. package/src/memory/conversation-crud.ts +2 -1
  142. package/src/memory/conversation-title-service.ts +16 -2
  143. package/src/memory/db-init.ts +8 -0
  144. package/src/memory/delivery-crud.ts +2 -1
  145. package/src/memory/guardian-action-store.ts +2 -1
  146. package/src/memory/guardian-approvals.ts +3 -2
  147. package/src/memory/ingress-invite-store.ts +12 -2
  148. package/src/memory/ingress-member-store.ts +4 -3
  149. package/src/memory/migrations/038-actor-token-records.ts +39 -0
  150. package/src/memory/migrations/124-voice-invite-display-metadata.ts +14 -0
  151. package/src/memory/migrations/index.ts +2 -0
  152. package/src/memory/schema.ts +26 -5
  153. package/src/messaging/provider-types.ts +24 -0
  154. package/src/messaging/provider.ts +7 -0
  155. package/src/messaging/providers/gmail/adapter.ts +127 -0
  156. package/src/messaging/providers/sms/adapter.ts +40 -37
  157. package/src/notifications/adapters/macos.ts +45 -2
  158. package/src/notifications/broadcaster.ts +16 -0
  159. package/src/notifications/copy-composer.ts +50 -2
  160. package/src/notifications/decision-engine.ts +22 -9
  161. package/src/notifications/destination-resolver.ts +16 -2
  162. package/src/notifications/emit-signal.ts +18 -9
  163. package/src/notifications/guardian-question-mode.ts +419 -0
  164. package/src/notifications/signal.ts +14 -3
  165. package/src/permissions/checker.ts +13 -1
  166. package/src/permissions/prompter.ts +14 -0
  167. package/src/providers/anthropic/client.ts +20 -0
  168. package/src/providers/provider-send-message.ts +15 -3
  169. package/src/runtime/access-request-helper.ts +82 -4
  170. package/src/runtime/actor-token-service.ts +234 -0
  171. package/src/runtime/actor-token-store.ts +236 -0
  172. package/src/runtime/actor-trust-resolver.ts +2 -2
  173. package/src/runtime/assistant-scope.ts +10 -0
  174. package/src/runtime/channel-approvals.ts +5 -3
  175. package/src/runtime/channel-readiness-service.ts +23 -64
  176. package/src/runtime/channel-readiness-types.ts +3 -4
  177. package/src/runtime/channel-retry-sweep.ts +4 -1
  178. package/src/runtime/confirmation-request-guardian-bridge.ts +197 -0
  179. package/src/runtime/guardian-action-followup-executor.ts +1 -1
  180. package/src/runtime/guardian-context-resolver.ts +82 -0
  181. package/src/runtime/guardian-outbound-actions.ts +5 -7
  182. package/src/runtime/guardian-reply-router.ts +67 -30
  183. package/src/runtime/guardian-vellum-migration.ts +57 -0
  184. package/src/runtime/http-server.ts +75 -31
  185. package/src/runtime/http-types.ts +13 -0
  186. package/src/runtime/ingress-service.ts +14 -0
  187. package/src/runtime/invite-redemption-service.ts +10 -1
  188. package/src/runtime/local-actor-identity.ts +76 -0
  189. package/src/runtime/middleware/actor-token.ts +271 -0
  190. package/src/runtime/middleware/twilio-validation.ts +2 -4
  191. package/src/runtime/routes/approval-routes.ts +82 -7
  192. package/src/runtime/routes/brain-graph-routes.ts +222 -0
  193. package/src/runtime/routes/call-routes.ts +2 -1
  194. package/src/runtime/routes/channel-readiness-routes.ts +71 -0
  195. package/src/runtime/routes/channel-route-shared.ts +3 -3
  196. package/src/runtime/routes/conversation-attention-routes.ts +2 -1
  197. package/src/runtime/routes/conversation-routes.ts +142 -53
  198. package/src/runtime/routes/events-routes.ts +22 -8
  199. package/src/runtime/routes/guardian-action-routes.ts +45 -3
  200. package/src/runtime/routes/guardian-approval-interception.ts +29 -0
  201. package/src/runtime/routes/guardian-bootstrap-routes.ts +145 -0
  202. package/src/runtime/routes/inbound-conversation.ts +4 -3
  203. package/src/runtime/routes/inbound-message-handler.ts +147 -5
  204. package/src/runtime/routes/ingress-routes.ts +2 -0
  205. package/src/runtime/routes/integration-routes.ts +7 -15
  206. package/src/runtime/routes/pairing-routes.ts +163 -0
  207. package/src/runtime/routes/twilio-routes.ts +934 -0
  208. package/src/runtime/tool-grant-request-helper.ts +3 -1
  209. package/src/security/oauth2.ts +27 -2
  210. package/src/security/token-manager.ts +46 -10
  211. package/src/tools/browser/browser-execution.ts +4 -3
  212. package/src/tools/browser/browser-handoff.ts +10 -18
  213. package/src/tools/browser/browser-manager.ts +80 -25
  214. package/src/tools/browser/browser-screencast.ts +35 -119
  215. package/src/tools/calls/call-start.ts +2 -1
  216. package/src/tools/permission-checker.ts +15 -4
  217. package/src/tools/terminal/parser.ts +12 -0
  218. package/src/tools/tool-approval-handler.ts +244 -19
  219. package/src/workspace/git-service.ts +19 -0
  220. package/src/__tests__/handlers-twilio-config.test.ts +0 -1928
  221. package/src/daemon/handlers/config-twilio.ts +0 -1082
@@ -15,17 +15,29 @@
15
15
 
16
16
  import type { ChannelId } from '../channels/types.js';
17
17
  import {
18
+ createCanonicalGuardianDelivery,
18
19
  createCanonicalGuardianRequest,
19
20
  listCanonicalGuardianRequests,
21
+ updateCanonicalGuardianDelivery,
20
22
  } from '../memory/canonical-guardian-store.js';
21
23
  import { listActiveBindingsByAssistant } from '../memory/channel-guardian-store.js';
24
+ import type { MemberStatus } from '../memory/ingress-member-store.js';
22
25
  import { emitNotificationSignal } from '../notifications/emit-signal.js';
26
+ import type { NotificationDeliveryResult } from '../notifications/types.js';
23
27
  import { getLogger } from '../util/logger.js';
24
28
  import { getGuardianBinding } from './channel-guardian-service.js';
25
29
  import { GUARDIAN_APPROVAL_TTL_MS } from './routes/channel-route-shared.js';
26
30
 
27
31
  const log = getLogger('access-request-helper');
28
32
 
33
+ function applyDeliveryStatus(deliveryId: string, result: NotificationDeliveryResult): void {
34
+ if (result.status === 'sent') {
35
+ updateCanonicalGuardianDelivery(deliveryId, { status: 'sent' });
36
+ return;
37
+ }
38
+ updateCanonicalGuardianDelivery(deliveryId, { status: 'failed' });
39
+ }
40
+
29
41
  // ---------------------------------------------------------------------------
30
42
  // Types
31
43
  // ---------------------------------------------------------------------------
@@ -37,6 +49,7 @@ export interface AccessRequestParams {
37
49
  senderExternalUserId?: string;
38
50
  senderName?: string;
39
51
  senderUsername?: string;
52
+ previousMemberStatus?: MemberStatus;
40
53
  }
41
54
 
42
55
  export type AccessRequestResult =
@@ -71,6 +84,7 @@ export function notifyGuardianOfAccessRequest(
71
84
  senderExternalUserId,
72
85
  senderName,
73
86
  senderUsername,
87
+ previousMemberStatus,
74
88
  } = params;
75
89
 
76
90
  if (!senderExternalUserId) {
@@ -105,14 +119,22 @@ export function notifyGuardianOfAccessRequest(
105
119
  }
106
120
  }
107
121
 
122
+ // The conversationId is assistant-scoped so the dedupe query below only
123
+ // matches requests for the same assistant. Without this, a pending request
124
+ // from assistant A could be returned for assistant B, allowing the caller
125
+ // to piggyback on A's guardian approval.
126
+ const conversationId = `access-req-${canonicalAssistantId}-${sourceChannel}-${senderExternalUserId}`;
127
+
108
128
  // Deduplicate: skip creation if there is already a pending canonical request
109
- // for the same requester on this channel. Still return notified: true with
110
- // the existing request ID so callers know the guardian was already notified.
129
+ // for the same requester on this channel *and* assistant. Still return
130
+ // notified: true with the existing request ID so callers know the guardian
131
+ // was already notified.
111
132
  const existingCanonical = listCanonicalGuardianRequests({
112
133
  status: 'pending',
113
134
  requesterExternalUserId: senderExternalUserId,
114
135
  sourceChannel,
115
136
  kind: 'access_request',
137
+ conversationId,
116
138
  });
117
139
  if (existingCanonical.length > 0) {
118
140
  log.debug(
@@ -130,7 +152,7 @@ export function notifyGuardianOfAccessRequest(
130
152
  kind: 'access_request',
131
153
  sourceType: 'channel',
132
154
  sourceChannel,
133
- conversationId: `access-req-${sourceChannel}-${senderExternalUserId}`,
155
+ conversationId,
134
156
  requesterExternalUserId: senderExternalUserId,
135
157
  requesterChatId: externalChatId,
136
158
  guardianExternalUserId: guardianExternalUserId ?? undefined,
@@ -139,6 +161,7 @@ export function notifyGuardianOfAccessRequest(
139
161
  expiresAt: new Date(Date.now() + GUARDIAN_APPROVAL_TTL_MS).toISOString(),
140
162
  });
141
163
 
164
+ let vellumDeliveryId: string | null = null;
142
165
  void emitNotificationSignal({
143
166
  sourceEventName: 'ingress.access_request',
144
167
  sourceChannel,
@@ -160,9 +183,64 @@ export function notifyGuardianOfAccessRequest(
160
183
  senderUsername: senderUsername ?? null,
161
184
  senderIdentifier,
162
185
  guardianBindingChannel,
186
+ previousMemberStatus: previousMemberStatus ?? null,
163
187
  },
164
188
  dedupeKey: `access-request:${canonicalRequest.id}`,
165
- });
189
+ onThreadCreated: (info) => {
190
+ if (info.sourceEventName !== 'ingress.access_request' || vellumDeliveryId) return;
191
+ const delivery = createCanonicalGuardianDelivery({
192
+ requestId: canonicalRequest.id,
193
+ destinationChannel: 'vellum',
194
+ destinationConversationId: info.conversationId,
195
+ });
196
+ vellumDeliveryId = delivery.id;
197
+ },
198
+ })
199
+ .then((signalResult) => {
200
+ for (const result of signalResult.deliveryResults) {
201
+ if (result.channel === 'vellum') {
202
+ if (!vellumDeliveryId) {
203
+ const delivery = createCanonicalGuardianDelivery({
204
+ requestId: canonicalRequest.id,
205
+ destinationChannel: 'vellum',
206
+ destinationConversationId: result.conversationId,
207
+ });
208
+ vellumDeliveryId = delivery.id;
209
+ }
210
+ applyDeliveryStatus(vellumDeliveryId, result);
211
+ continue;
212
+ }
213
+
214
+ if (result.channel !== 'telegram' && result.channel !== 'sms') {
215
+ continue;
216
+ }
217
+
218
+ const delivery = createCanonicalGuardianDelivery({
219
+ requestId: canonicalRequest.id,
220
+ destinationChannel: result.channel,
221
+ destinationChatId: result.destination.length > 0 ? result.destination : undefined,
222
+ });
223
+ applyDeliveryStatus(delivery.id, result);
224
+ }
225
+
226
+ if (!vellumDeliveryId) {
227
+ const fallback = createCanonicalGuardianDelivery({
228
+ requestId: canonicalRequest.id,
229
+ destinationChannel: 'vellum',
230
+ });
231
+ updateCanonicalGuardianDelivery(fallback.id, { status: 'failed' });
232
+ log.warn(
233
+ { requestId: canonicalRequest.id, reason: signalResult.reason },
234
+ 'Notification pipeline did not produce a vellum delivery result for access request',
235
+ );
236
+ }
237
+ })
238
+ .catch((err) => {
239
+ log.error(
240
+ { err, requestId: canonicalRequest.id, sourceChannel, senderExternalUserId },
241
+ 'Failed to persist access request delivery rows from notification pipeline',
242
+ );
243
+ });
166
244
 
167
245
  log.info(
168
246
  { sourceChannel, senderExternalUserId, senderIdentifier, guardianBindingChannel },
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Actor-token mint/verify service.
3
+ *
4
+ * Mints HMAC-signed actor tokens bound to (assistantId, guardianPrincipalId,
5
+ * deviceId, platform). Only the SHA-256 hash of the token is persisted —
6
+ * the raw plaintext is returned to the caller once and never stored.
7
+ *
8
+ * Token format: base64url(JSON claims) + '.' + base64url(HMAC-SHA256 signature)
9
+ */
10
+
11
+ import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
12
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
13
+ import { dirname, join } from 'node:path';
14
+
15
+ import { getLogger } from '../util/logger.js';
16
+ import { getRootDir } from '../util/platform.js';
17
+
18
+ const log = getLogger('actor-token-service');
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Types
22
+ // ---------------------------------------------------------------------------
23
+
24
+ export interface ActorTokenClaims {
25
+ /** The assistant this token is scoped to. */
26
+ assistantId: string;
27
+ /** Platform: 'macos' | 'ios' */
28
+ platform: string;
29
+ /** Opaque device identifier (hashed for storage). */
30
+ deviceId: string;
31
+ /** The guardian principal this token is bound to. */
32
+ guardianPrincipalId: string;
33
+ /** Token issuance timestamp (epoch ms). */
34
+ iat: number;
35
+ /** Token expiration timestamp (epoch ms). Null means non-expiring. */
36
+ exp: number | null;
37
+ /** Random jti (JWT ID) for uniqueness. */
38
+ jti: string;
39
+ }
40
+
41
+ export interface MintResult {
42
+ /** The raw actor token string — returned once, never persisted. */
43
+ token: string;
44
+ /** SHA-256 hex hash of the token (for storage/lookup). */
45
+ tokenHash: string;
46
+ /** The decoded claims. */
47
+ claims: ActorTokenClaims;
48
+ }
49
+
50
+ export type VerifyResult =
51
+ | { ok: true; claims: ActorTokenClaims }
52
+ | { ok: false; reason: string };
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Signing key management
56
+ // ---------------------------------------------------------------------------
57
+
58
+ let signingKey: Buffer | null = null;
59
+
60
+ /**
61
+ * Path to the persisted signing key file.
62
+ * Stored in the protected directory alongside other sensitive material.
63
+ */
64
+ function getSigningKeyPath(): string {
65
+ return join(getRootDir(), 'protected', 'actor-token-signing-key');
66
+ }
67
+
68
+ /**
69
+ * Load a signing key from disk or generate and persist a new one.
70
+ * Uses the same atomic-write + chmod 0o600 pattern as approved-devices-store.ts.
71
+ */
72
+ export function loadOrCreateSigningKey(): Buffer {
73
+ const keyPath = getSigningKeyPath();
74
+
75
+ // Try to load existing key
76
+ if (existsSync(keyPath)) {
77
+ try {
78
+ const raw = readFileSync(keyPath);
79
+ if (raw.length === 32) {
80
+ log.info('Actor-token signing key loaded from disk');
81
+ return raw;
82
+ }
83
+ log.warn('Signing key file has unexpected length, regenerating');
84
+ } catch (err) {
85
+ log.warn({ err }, 'Failed to read signing key file, regenerating');
86
+ }
87
+ }
88
+
89
+ // Generate and persist a new key
90
+ const newKey = randomBytes(32);
91
+ const dir = dirname(keyPath);
92
+ if (!existsSync(dir)) {
93
+ mkdirSync(dir, { recursive: true });
94
+ }
95
+ const tmpPath = keyPath + '.tmp.' + process.pid;
96
+ writeFileSync(tmpPath, newKey, { mode: 0o600 });
97
+ renameSync(tmpPath, keyPath);
98
+ chmodSync(keyPath, 0o600);
99
+
100
+ log.info('Actor-token signing key generated and persisted');
101
+ return newKey;
102
+ }
103
+
104
+ /**
105
+ * Initialize (or reinitialize) the signing key. Called at daemon startup
106
+ * with a key loaded from disk via loadOrCreateSigningKey(), or by tests
107
+ * with a deterministic key.
108
+ */
109
+ export function initSigningKey(key: Buffer): void {
110
+ signingKey = key;
111
+ }
112
+
113
+ function getSigningKey(): Buffer {
114
+ if (!signingKey) {
115
+ throw new Error('Actor-token signing key not initialized — call initSigningKey() during startup');
116
+ }
117
+ return signingKey;
118
+ }
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Base64url helpers
122
+ // ---------------------------------------------------------------------------
123
+
124
+ function base64urlEncode(data: Buffer | string): string {
125
+ const buf = typeof data === 'string' ? Buffer.from(data, 'utf-8') : data;
126
+ return buf.toString('base64url');
127
+ }
128
+
129
+ function base64urlDecode(str: string): Buffer {
130
+ return Buffer.from(str, 'base64url');
131
+ }
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // Hashing
135
+ // ---------------------------------------------------------------------------
136
+
137
+ /** SHA-256 hex digest of a raw token string. */
138
+ export function hashToken(token: string): string {
139
+ return createHash('sha256').update(token).digest('hex');
140
+ }
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // Mint
144
+ // ---------------------------------------------------------------------------
145
+
146
+ /** Default TTL for actor tokens: 90 days in milliseconds. */
147
+ const DEFAULT_TOKEN_TTL_MS = 90 * 24 * 60 * 60 * 1000;
148
+
149
+ /**
150
+ * Mint a new actor token.
151
+ *
152
+ * @param params Token claims (assistantId, platform, deviceId, guardianPrincipalId).
153
+ * @param ttlMs Optional TTL in milliseconds. Defaults to 90 days.
154
+ * Pass `null` explicitly for a non-expiring token.
155
+ * @returns The raw token, its hash, and the embedded claims.
156
+ */
157
+ export function mintActorToken(params: {
158
+ assistantId: string;
159
+ platform: string;
160
+ deviceId: string;
161
+ guardianPrincipalId: string;
162
+ ttlMs?: number | null;
163
+ }): MintResult {
164
+ const now = Date.now();
165
+ const effectiveTtl = params.ttlMs === undefined ? DEFAULT_TOKEN_TTL_MS : params.ttlMs;
166
+ const claims: ActorTokenClaims = {
167
+ assistantId: params.assistantId,
168
+ platform: params.platform,
169
+ deviceId: params.deviceId,
170
+ guardianPrincipalId: params.guardianPrincipalId,
171
+ iat: now,
172
+ exp: effectiveTtl != null ? now + effectiveTtl : null,
173
+ jti: randomBytes(16).toString('hex'),
174
+ };
175
+
176
+ const payload = base64urlEncode(JSON.stringify(claims));
177
+ const sig = createHmac('sha256', getSigningKey())
178
+ .update(payload)
179
+ .digest();
180
+ const token = payload + '.' + base64urlEncode(sig);
181
+ const tokenHash = hashToken(token);
182
+
183
+ return { token, tokenHash, claims };
184
+ }
185
+
186
+ // ---------------------------------------------------------------------------
187
+ // Verify
188
+ // ---------------------------------------------------------------------------
189
+
190
+ /**
191
+ * Verify an actor token's structural integrity and signature.
192
+ *
193
+ * Does NOT check revocation — callers must additionally verify the
194
+ * token hash exists in the actor-token store with status='active'.
195
+ */
196
+ export function verifyActorToken(token: string): VerifyResult {
197
+ const dotIndex = token.indexOf('.');
198
+ if (dotIndex < 0) {
199
+ return { ok: false, reason: 'malformed_token' };
200
+ }
201
+
202
+ const payload = token.slice(0, dotIndex);
203
+ const sigPart = token.slice(dotIndex + 1);
204
+
205
+ // Recompute HMAC
206
+ const expectedSig = createHmac('sha256', getSigningKey())
207
+ .update(payload)
208
+ .digest();
209
+ const actualSig = base64urlDecode(sigPart);
210
+
211
+ if (expectedSig.length !== actualSig.length) {
212
+ return { ok: false, reason: 'invalid_signature' };
213
+ }
214
+
215
+ // Constant-time comparison
216
+ if (!timingSafeEqual(expectedSig, actualSig)) {
217
+ return { ok: false, reason: 'invalid_signature' };
218
+ }
219
+
220
+ let claims: ActorTokenClaims;
221
+ try {
222
+ const decoded = base64urlDecode(payload).toString('utf-8');
223
+ claims = JSON.parse(decoded) as ActorTokenClaims;
224
+ } catch {
225
+ return { ok: false, reason: 'malformed_claims' };
226
+ }
227
+
228
+ // Expiration check
229
+ if (claims.exp != null && Date.now() > claims.exp) {
230
+ return { ok: false, reason: 'token_expired' };
231
+ }
232
+
233
+ return { ok: true, claims };
234
+ }
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Hash-only actor token persistence.
3
+ *
4
+ * Stores only the SHA-256 hash of each actor token alongside metadata
5
+ * (assistantId, guardianPrincipalId, deviceId hash, platform, status).
6
+ * The raw token plaintext is never stored.
7
+ *
8
+ * Uses the assistant SQLite database via drizzle-orm.
9
+ */
10
+
11
+ import { and, eq } from 'drizzle-orm';
12
+ import { v4 as uuid } from 'uuid';
13
+
14
+ import { getDb } from '../memory/db.js';
15
+ import { actorTokenRecords } from '../memory/schema.js';
16
+ import { getLogger } from '../util/logger.js';
17
+
18
+ const log = getLogger('actor-token-store');
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Types
22
+ // ---------------------------------------------------------------------------
23
+
24
+ export type ActorTokenStatus = 'active' | 'revoked';
25
+
26
+ export interface ActorTokenRecord {
27
+ id: string;
28
+ tokenHash: string;
29
+ assistantId: string;
30
+ guardianPrincipalId: string;
31
+ hashedDeviceId: string;
32
+ platform: string;
33
+ status: ActorTokenStatus;
34
+ issuedAt: number;
35
+ expiresAt: number | null;
36
+ createdAt: number;
37
+ updatedAt: number;
38
+ }
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Operations
42
+ // ---------------------------------------------------------------------------
43
+
44
+ /**
45
+ * Store a new actor token record (hash-only).
46
+ */
47
+ export function createActorTokenRecord(params: {
48
+ tokenHash: string;
49
+ assistantId: string;
50
+ guardianPrincipalId: string;
51
+ hashedDeviceId: string;
52
+ platform: string;
53
+ issuedAt: number;
54
+ expiresAt?: number | null;
55
+ }): ActorTokenRecord {
56
+ const db = getDb();
57
+ const now = Date.now();
58
+ const id = uuid();
59
+
60
+ const row = {
61
+ id,
62
+ tokenHash: params.tokenHash,
63
+ assistantId: params.assistantId,
64
+ guardianPrincipalId: params.guardianPrincipalId,
65
+ hashedDeviceId: params.hashedDeviceId,
66
+ platform: params.platform,
67
+ status: 'active' as const,
68
+ issuedAt: params.issuedAt,
69
+ expiresAt: params.expiresAt ?? null,
70
+ createdAt: now,
71
+ updatedAt: now,
72
+ };
73
+
74
+ db.insert(actorTokenRecords).values(row).run();
75
+ log.info({ id, assistantId: params.assistantId, platform: params.platform }, 'Actor token record created');
76
+
77
+ return row;
78
+ }
79
+
80
+ /**
81
+ * Look up an active actor token record by its hash.
82
+ */
83
+ export function findActiveByTokenHash(tokenHash: string): ActorTokenRecord | null {
84
+ const db = getDb();
85
+ const row = db
86
+ .select()
87
+ .from(actorTokenRecords)
88
+ .where(
89
+ and(
90
+ eq(actorTokenRecords.tokenHash, tokenHash),
91
+ eq(actorTokenRecords.status, 'active'),
92
+ ),
93
+ )
94
+ .get();
95
+
96
+ return row ? rowToRecord(row) : null;
97
+ }
98
+
99
+ /**
100
+ * Find an active token for a specific (assistantId, guardianPrincipalId, deviceId).
101
+ * Used for idempotent bootstrap — if an active token already exists for this
102
+ * device binding, we can revoke-and-remint or return the existing record.
103
+ */
104
+ export function findActiveByDeviceBinding(
105
+ assistantId: string,
106
+ guardianPrincipalId: string,
107
+ hashedDeviceId: string,
108
+ ): ActorTokenRecord | null {
109
+ const db = getDb();
110
+ const row = db
111
+ .select()
112
+ .from(actorTokenRecords)
113
+ .where(
114
+ and(
115
+ eq(actorTokenRecords.assistantId, assistantId),
116
+ eq(actorTokenRecords.guardianPrincipalId, guardianPrincipalId),
117
+ eq(actorTokenRecords.hashedDeviceId, hashedDeviceId),
118
+ eq(actorTokenRecords.status, 'active'),
119
+ ),
120
+ )
121
+ .get();
122
+
123
+ return row ? rowToRecord(row) : null;
124
+ }
125
+
126
+ /**
127
+ * Revoke all active tokens for a given device binding.
128
+ * Called before minting a new token to ensure one-active-per-device.
129
+ */
130
+ export function revokeByDeviceBinding(
131
+ assistantId: string,
132
+ guardianPrincipalId: string,
133
+ hashedDeviceId: string,
134
+ ): number {
135
+ const db = getDb();
136
+ const now = Date.now();
137
+
138
+ const condition = and(
139
+ eq(actorTokenRecords.assistantId, assistantId),
140
+ eq(actorTokenRecords.guardianPrincipalId, guardianPrincipalId),
141
+ eq(actorTokenRecords.hashedDeviceId, hashedDeviceId),
142
+ eq(actorTokenRecords.status, 'active'),
143
+ );
144
+
145
+ // Count matching rows before the update since drizzle's bun-sqlite
146
+ // .run() does not expose the underlying changes count in its types.
147
+ const matching = db
148
+ .select({ id: actorTokenRecords.id })
149
+ .from(actorTokenRecords)
150
+ .where(condition)
151
+ .all();
152
+
153
+ if (matching.length === 0) return 0;
154
+
155
+ db.update(actorTokenRecords)
156
+ .set({ status: 'revoked', updatedAt: now })
157
+ .where(condition)
158
+ .run();
159
+
160
+ return matching.length;
161
+ }
162
+
163
+ /**
164
+ * Find all active actor token records for a given (assistantId, guardianPrincipalId).
165
+ * Used for multi-device guardian fanout — returns all bound devices (macOS, iOS, etc.)
166
+ * so notification targeting can reach every device for the same guardian identity.
167
+ */
168
+ export function findActiveByGuardianPrincipalId(
169
+ assistantId: string,
170
+ guardianPrincipalId: string,
171
+ ): ActorTokenRecord[] {
172
+ const db = getDb();
173
+ const rows = db
174
+ .select()
175
+ .from(actorTokenRecords)
176
+ .where(
177
+ and(
178
+ eq(actorTokenRecords.assistantId, assistantId),
179
+ eq(actorTokenRecords.guardianPrincipalId, guardianPrincipalId),
180
+ eq(actorTokenRecords.status, 'active'),
181
+ ),
182
+ )
183
+ .all();
184
+
185
+ return rows.map(rowToRecord);
186
+ }
187
+
188
+ /**
189
+ * Revoke a single token by its hash.
190
+ */
191
+ export function revokeByTokenHash(tokenHash: string): boolean {
192
+ const db = getDb();
193
+ const now = Date.now();
194
+
195
+ const condition = and(
196
+ eq(actorTokenRecords.tokenHash, tokenHash),
197
+ eq(actorTokenRecords.status, 'active'),
198
+ );
199
+
200
+ // Check existence before update since drizzle's bun-sqlite .run()
201
+ // does not expose the underlying changes count in its types.
202
+ const existing = db
203
+ .select({ id: actorTokenRecords.id })
204
+ .from(actorTokenRecords)
205
+ .where(condition)
206
+ .get();
207
+
208
+ if (!existing) return false;
209
+
210
+ db.update(actorTokenRecords)
211
+ .set({ status: 'revoked', updatedAt: now })
212
+ .where(condition)
213
+ .run();
214
+
215
+ return true;
216
+ }
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // Helpers
220
+ // ---------------------------------------------------------------------------
221
+
222
+ function rowToRecord(row: typeof actorTokenRecords.$inferSelect): ActorTokenRecord {
223
+ return {
224
+ id: row.id,
225
+ tokenHash: row.tokenHash,
226
+ assistantId: row.assistantId,
227
+ guardianPrincipalId: row.guardianPrincipalId,
228
+ hashedDeviceId: row.hashedDeviceId,
229
+ platform: row.platform,
230
+ status: row.status as ActorTokenStatus,
231
+ issuedAt: row.issuedAt,
232
+ expiresAt: row.expiresAt,
233
+ createdAt: row.createdAt,
234
+ updatedAt: row.updatedAt,
235
+ };
236
+ }
@@ -17,7 +17,7 @@ import type { GuardianRuntimeContext } from '../daemon/session-runtime-assembly.
17
17
  import type { IngressMember } from '../memory/ingress-member-store.js';
18
18
  import { findMember } from '../memory/ingress-member-store.js';
19
19
  import { canonicalizeInboundIdentity } from '../util/canonicalize-identity.js';
20
- import { normalizeAssistantId } from '../util/platform.js';
20
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from './assistant-scope.js';
21
21
  import { getGuardianBinding } from './channel-guardian-service.js';
22
22
 
23
23
  // ---------------------------------------------------------------------------
@@ -76,7 +76,7 @@ export interface ResolveActorTrustInput {
76
76
  * 5. Classify: guardian > trusted_contact (active member) > unknown.
77
77
  */
78
78
  export function resolveActorTrust(input: ResolveActorTrustInput): ActorTrustContext {
79
- const assistantId = normalizeAssistantId(input.assistantId);
79
+ const assistantId = DAEMON_INTERNAL_ASSISTANT_ID;
80
80
 
81
81
  const rawUserId = typeof input.senderExternalUserId === 'string' && input.senderExternalUserId.trim().length > 0
82
82
  ? input.senderExternalUserId.trim()
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Canonical internal scope ID for all daemon-side assistant-scoped storage.
3
+ *
4
+ * The daemon uses a single fixed identity (`'self'`) for its own assistant
5
+ * scope. Public/external assistant IDs are an edge concern owned by the
6
+ * gateway and platform layers (hatch, invite links, etc.). Daemon code
7
+ * should never derive scoping decisions from externally-provided assistant
8
+ * IDs — use this constant instead.
9
+ */
10
+ export const DAEMON_INTERNAL_ASSISTANT_ID = 'self' as const;
@@ -151,11 +151,13 @@ export function handleChannelDecision(
151
151
  if (
152
152
  details &&
153
153
  details.persistentDecisionsAllowed !== false &&
154
- details.allowlistOptions?.length &&
155
- details.scopeOptions?.length
154
+ details.allowlistOptions?.length
156
155
  ) {
157
156
  const pattern = details.allowlistOptions[0].pattern;
158
- const scope = details.scopeOptions[0].scope;
157
+ // Non-scoped tools (web_fetch, network_request, etc.) have empty
158
+ // scopeOptions — default to 'everywhere' so approve_always still
159
+ // persists a trust rule instead of silently degrading to one-time.
160
+ const scope = details.scopeOptions?.length ? details.scopeOptions[0].scope : 'everywhere';
159
161
  // Only persist executionTarget for skill-origin tools — core tools don't
160
162
  // set it in their PolicyContext, so a persisted value would prevent the
161
163
  // rule from ever matching on subsequent permission checks.