@vellumai/assistant 0.3.27 → 0.4.0

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 (247) hide show
  1. package/ARCHITECTURE.md +81 -4
  2. package/Dockerfile +2 -2
  3. package/bun.lock +4 -1
  4. package/docs/trusted-contact-access.md +9 -2
  5. package/package.json +6 -3
  6. package/scripts/ipc/generate-swift.ts +9 -5
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +80 -0
  8. package/src/__tests__/agent-loop-thinking.test.ts +1 -1
  9. package/src/__tests__/agent-loop.test.ts +119 -0
  10. package/src/__tests__/approval-routes-http.test.ts +13 -5
  11. package/src/__tests__/asset-materialize-tool.test.ts +2 -0
  12. package/src/__tests__/asset-search-tool.test.ts +2 -0
  13. package/src/__tests__/assistant-events-sse-hardening.test.ts +4 -2
  14. package/src/__tests__/attachments-store.test.ts +2 -0
  15. package/src/__tests__/browser-skill-endstate.test.ts +3 -3
  16. package/src/__tests__/bundled-asset.test.ts +107 -0
  17. package/src/__tests__/call-controller.test.ts +30 -29
  18. package/src/__tests__/call-routes-http.test.ts +34 -32
  19. package/src/__tests__/call-start-guardian-guard.test.ts +2 -0
  20. package/src/__tests__/canonical-guardian-store.test.ts +636 -0
  21. package/src/__tests__/channel-approval-routes.test.ts +174 -1
  22. package/src/__tests__/channel-invite-transport.test.ts +6 -6
  23. package/src/__tests__/channel-reply-delivery.test.ts +19 -0
  24. package/src/__tests__/channel-retry-sweep.test.ts +130 -0
  25. package/src/__tests__/clarification-resolver.test.ts +2 -0
  26. package/src/__tests__/claude-code-skill-regression.test.ts +2 -0
  27. package/src/__tests__/claude-code-tool-profiles.test.ts +2 -0
  28. package/src/__tests__/commit-message-enrichment-service.test.ts +9 -1
  29. package/src/__tests__/computer-use-session-lifecycle.test.ts +2 -0
  30. package/src/__tests__/computer-use-session-working-dir.test.ts +1 -0
  31. package/src/__tests__/computer-use-skill-lifecycle-cleanup.test.ts +2 -0
  32. package/src/__tests__/config-schema.test.ts +5 -5
  33. package/src/__tests__/config-watcher.test.ts +3 -1
  34. package/src/__tests__/connection-policy.test.ts +14 -5
  35. package/src/__tests__/contacts-tools.test.ts +3 -1
  36. package/src/__tests__/contradiction-checker.test.ts +2 -0
  37. package/src/__tests__/conversation-pairing.test.ts +10 -0
  38. package/src/__tests__/conversation-routes.test.ts +1 -1
  39. package/src/__tests__/credential-security-invariants.test.ts +16 -6
  40. package/src/__tests__/credential-vault-unit.test.ts +2 -2
  41. package/src/__tests__/credential-vault.test.ts +5 -4
  42. package/src/__tests__/daemon-lifecycle.test.ts +9 -0
  43. package/src/__tests__/daemon-server-session-init.test.ts +27 -0
  44. package/src/__tests__/elevenlabs-config.test.ts +2 -0
  45. package/src/__tests__/emit-signal-routing-intent.test.ts +43 -1
  46. package/src/__tests__/encrypted-store.test.ts +10 -5
  47. package/src/__tests__/followup-tools.test.ts +3 -1
  48. package/src/__tests__/gateway-only-enforcement.test.ts +21 -21
  49. package/src/__tests__/gmail-integration.test.ts +0 -1
  50. package/src/__tests__/guardian-actions-endpoint.test.ts +205 -345
  51. package/src/__tests__/guardian-control-plane-policy.test.ts +19 -19
  52. package/src/__tests__/guardian-decision-primitive-canonical.test.ts +599 -0
  53. package/src/__tests__/guardian-dispatch.test.ts +21 -19
  54. package/src/__tests__/guardian-grant-minting.test.ts +68 -1
  55. package/src/__tests__/guardian-outbound-http.test.ts +12 -9
  56. package/src/__tests__/guardian-routing-invariants.test.ts +1092 -0
  57. package/src/__tests__/handle-user-message-secret-resume.test.ts +1 -0
  58. package/src/__tests__/handlers-slack-config.test.ts +3 -1
  59. package/src/__tests__/handlers-telegram-config.test.ts +3 -1
  60. package/src/__tests__/handlers-twilio-config.test.ts +3 -1
  61. package/src/__tests__/handlers-twitter-config.test.ts +3 -1
  62. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +318 -0
  63. package/src/__tests__/heartbeat-service.test.ts +20 -0
  64. package/src/__tests__/inbound-invite-redemption.test.ts +33 -0
  65. package/src/__tests__/ingress-reconcile.test.ts +3 -1
  66. package/src/__tests__/ingress-routes-http.test.ts +231 -4
  67. package/src/__tests__/intent-routing.test.ts +2 -0
  68. package/src/__tests__/ipc-snapshot.test.ts +13 -0
  69. package/src/__tests__/mcp-cli.test.ts +77 -0
  70. package/src/__tests__/media-generate-image.test.ts +21 -0
  71. package/src/__tests__/media-reuse-story.e2e.test.ts +2 -0
  72. package/src/__tests__/memory-regressions.test.ts +20 -20
  73. package/src/__tests__/non-member-access-request.test.ts +212 -36
  74. package/src/__tests__/notification-decision-fallback.test.ts +63 -3
  75. package/src/__tests__/notification-decision-strategy.test.ts +78 -0
  76. package/src/__tests__/notification-guardian-path.test.ts +15 -15
  77. package/src/__tests__/oauth-connect-handler.test.ts +3 -1
  78. package/src/__tests__/oauth2-gateway-transport.test.ts +2 -0
  79. package/src/__tests__/onboarding-starter-tasks.test.ts +4 -4
  80. package/src/__tests__/onboarding-template-contract.test.ts +116 -21
  81. package/src/__tests__/pairing-routes.test.ts +171 -0
  82. package/src/__tests__/playbook-execution.test.ts +3 -1
  83. package/src/__tests__/playbook-tools.test.ts +3 -1
  84. package/src/__tests__/provider-error-scenarios.test.ts +59 -8
  85. package/src/__tests__/proxy-approval-callback.test.ts +2 -0
  86. package/src/__tests__/recording-handler.test.ts +11 -0
  87. package/src/__tests__/recording-intent-handler.test.ts +15 -0
  88. package/src/__tests__/recording-state-machine.test.ts +13 -2
  89. package/src/__tests__/registry.test.ts +7 -3
  90. package/src/__tests__/relay-server.test.ts +148 -28
  91. package/src/__tests__/runtime-attachment-metadata.test.ts +4 -2
  92. package/src/__tests__/runtime-events-sse-parity.test.ts +21 -0
  93. package/src/__tests__/runtime-events-sse.test.ts +4 -2
  94. package/src/__tests__/sandbox-diagnostics.test.ts +2 -0
  95. package/src/__tests__/schedule-tools.test.ts +3 -1
  96. package/src/__tests__/secret-scanner-executor.test.ts +59 -0
  97. package/src/__tests__/secret-scanner.test.ts +8 -0
  98. package/src/__tests__/send-endpoint-busy.test.ts +4 -0
  99. package/src/__tests__/sensitive-output-placeholders.test.ts +208 -0
  100. package/src/__tests__/session-abort-tool-results.test.ts +23 -0
  101. package/src/__tests__/session-agent-loop.test.ts +16 -0
  102. package/src/__tests__/session-conflict-gate.test.ts +21 -0
  103. package/src/__tests__/session-load-history-repair.test.ts +27 -17
  104. package/src/__tests__/session-pre-run-repair.test.ts +23 -0
  105. package/src/__tests__/session-profile-injection.test.ts +21 -0
  106. package/src/__tests__/session-provider-retry-repair.test.ts +20 -0
  107. package/src/__tests__/session-queue.test.ts +23 -0
  108. package/src/__tests__/session-runtime-assembly.test.ts +126 -59
  109. package/src/__tests__/session-skill-tools.test.ts +27 -5
  110. package/src/__tests__/session-slash-known.test.ts +23 -0
  111. package/src/__tests__/session-slash-queue.test.ts +23 -0
  112. package/src/__tests__/session-slash-unknown.test.ts +23 -0
  113. package/src/__tests__/session-workspace-cache-state.test.ts +7 -0
  114. package/src/__tests__/session-workspace-injection.test.ts +21 -0
  115. package/src/__tests__/session-workspace-tool-tracking.test.ts +21 -0
  116. package/src/__tests__/shell-credential-ref.test.ts +2 -0
  117. package/src/__tests__/skill-feature-flags-integration.test.ts +6 -6
  118. package/src/__tests__/skill-load-feature-flag.test.ts +5 -4
  119. package/src/__tests__/skill-projection-feature-flag.test.ts +22 -0
  120. package/src/__tests__/skills.test.ts +8 -4
  121. package/src/__tests__/slack-channel-config.test.ts +3 -1
  122. package/src/__tests__/subagent-tools.test.ts +19 -0
  123. package/src/__tests__/swarm-recursion.test.ts +2 -0
  124. package/src/__tests__/swarm-session-integration.test.ts +2 -0
  125. package/src/__tests__/swarm-tool.test.ts +2 -0
  126. package/src/__tests__/system-prompt.test.ts +3 -1
  127. package/src/__tests__/task-compiler.test.ts +3 -1
  128. package/src/__tests__/task-management-tools.test.ts +3 -1
  129. package/src/__tests__/task-tools.test.ts +3 -1
  130. package/src/__tests__/terminal-sandbox.test.ts +13 -12
  131. package/src/__tests__/terminal-tools.test.ts +2 -0
  132. package/src/__tests__/tool-approval-handler.test.ts +15 -15
  133. package/src/__tests__/tool-execution-abort-cleanup.test.ts +2 -0
  134. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +2 -0
  135. package/src/__tests__/tool-grant-request-escalation.test.ts +497 -0
  136. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +48 -0
  137. package/src/__tests__/trusted-contact-multichannel.test.ts +22 -19
  138. package/src/__tests__/trusted-contact-verification.test.ts +91 -0
  139. package/src/__tests__/twilio-routes-elevenlabs.test.ts +2 -0
  140. package/src/__tests__/twitter-auth-handler.test.ts +3 -1
  141. package/src/__tests__/twitter-cli-routing.test.ts +3 -1
  142. package/src/__tests__/view-image-tool.test.ts +3 -1
  143. package/src/__tests__/voice-invite-redemption.test.ts +329 -0
  144. package/src/__tests__/voice-scoped-grant-consumer.test.ts +7 -5
  145. package/src/__tests__/voice-session-bridge.test.ts +10 -10
  146. package/src/__tests__/work-item-output.test.ts +3 -1
  147. package/src/__tests__/workspace-lifecycle.test.ts +13 -2
  148. package/src/agent/loop.ts +46 -3
  149. package/src/approvals/guardian-decision-primitive.ts +285 -0
  150. package/src/approvals/guardian-request-resolvers.ts +539 -0
  151. package/src/calls/call-controller.ts +26 -23
  152. package/src/calls/guardian-action-sweep.ts +10 -2
  153. package/src/calls/guardian-dispatch.ts +46 -40
  154. package/src/calls/relay-server.ts +358 -24
  155. package/src/calls/types.ts +1 -1
  156. package/src/calls/voice-session-bridge.ts +3 -3
  157. package/src/cli.ts +12 -0
  158. package/src/config/agent-schema.ts +14 -3
  159. package/src/config/calls-schema.ts +6 -6
  160. package/src/config/core-schema.ts +3 -3
  161. package/src/config/feature-flag-registry.json +8 -0
  162. package/src/config/mcp-schema.ts +1 -1
  163. package/src/config/memory-schema.ts +27 -19
  164. package/src/config/schema.ts +21 -21
  165. package/src/config/skills-schema.ts +7 -7
  166. package/src/config/system-prompt.ts +2 -1
  167. package/src/config/templates/BOOTSTRAP.md +47 -31
  168. package/src/config/templates/USER.md +5 -0
  169. package/src/config/update-bulletin-template-path.ts +4 -1
  170. package/src/config/vellum-skills/trusted-contacts/SKILL.md +149 -21
  171. package/src/daemon/handlers/config-inbox.ts +4 -4
  172. package/src/daemon/handlers/guardian-actions.ts +45 -66
  173. package/src/daemon/handlers/sessions.ts +148 -4
  174. package/src/daemon/ipc-contract/guardian-actions.ts +7 -0
  175. package/src/daemon/ipc-contract/messages.ts +16 -0
  176. package/src/daemon/ipc-contract-inventory.json +1 -0
  177. package/src/daemon/lifecycle.ts +22 -16
  178. package/src/daemon/pairing-store.ts +86 -3
  179. package/src/daemon/server.ts +18 -0
  180. package/src/daemon/session-agent-loop-handlers.ts +5 -4
  181. package/src/daemon/session-agent-loop.ts +33 -6
  182. package/src/daemon/session-lifecycle.ts +25 -17
  183. package/src/daemon/session-memory.ts +2 -2
  184. package/src/daemon/session-process.ts +68 -326
  185. package/src/daemon/session-runtime-assembly.ts +119 -25
  186. package/src/daemon/session-tool-setup.ts +3 -2
  187. package/src/daemon/session.ts +4 -3
  188. package/src/home-base/prebuilt/seed.ts +2 -1
  189. package/src/hooks/templates.ts +2 -1
  190. package/src/memory/canonical-guardian-store.ts +586 -0
  191. package/src/memory/channel-guardian-store.ts +2 -0
  192. package/src/memory/conversation-crud.ts +7 -7
  193. package/src/memory/db-init.ts +20 -0
  194. package/src/memory/embedding-local.ts +257 -39
  195. package/src/memory/embedding-runtime-manager.ts +471 -0
  196. package/src/memory/guardian-action-store.ts +7 -60
  197. package/src/memory/guardian-approvals.ts +9 -4
  198. package/src/memory/guardian-bindings.ts +25 -1
  199. package/src/memory/indexer.ts +3 -3
  200. package/src/memory/ingress-invite-store.ts +45 -0
  201. package/src/memory/job-handlers/backfill.ts +16 -9
  202. package/src/memory/migrations/036-normalize-phone-identities.ts +289 -0
  203. package/src/memory/migrations/037-voice-invite-columns.ts +16 -0
  204. package/src/memory/migrations/118-reminder-routing-intent.ts +3 -3
  205. package/src/memory/migrations/121-canonical-guardian-requests.ts +59 -0
  206. package/src/memory/migrations/122-canonical-guardian-requester-chat-id.ts +15 -0
  207. package/src/memory/migrations/123-canonical-guardian-deliveries-destination-index.ts +15 -0
  208. package/src/memory/migrations/index.ts +5 -0
  209. package/src/memory/migrations/registry.ts +5 -0
  210. package/src/memory/qdrant-client.ts +31 -22
  211. package/src/memory/schema-migration.ts +1 -0
  212. package/src/memory/schema.ts +56 -0
  213. package/src/notifications/copy-composer.ts +31 -4
  214. package/src/notifications/decision-engine.ts +57 -0
  215. package/src/permissions/defaults.ts +2 -0
  216. package/src/runtime/access-request-helper.ts +173 -0
  217. package/src/runtime/actor-trust-resolver.ts +221 -0
  218. package/src/runtime/channel-guardian-service.ts +12 -4
  219. package/src/runtime/channel-invite-transports/voice.ts +58 -0
  220. package/src/runtime/channel-retry-sweep.ts +18 -6
  221. package/src/runtime/guardian-context-resolver.ts +38 -71
  222. package/src/runtime/guardian-decision-types.ts +6 -0
  223. package/src/runtime/guardian-reply-router.ts +717 -0
  224. package/src/runtime/http-server.ts +8 -0
  225. package/src/runtime/ingress-service.ts +80 -3
  226. package/src/runtime/invite-redemption-service.ts +141 -2
  227. package/src/runtime/routes/canonical-guardian-expiry-sweep.ts +116 -0
  228. package/src/runtime/routes/channel-route-shared.ts +1 -1
  229. package/src/runtime/routes/channel-routes.ts +1 -1
  230. package/src/runtime/routes/conversation-routes.ts +20 -2
  231. package/src/runtime/routes/guardian-action-routes.ts +100 -109
  232. package/src/runtime/routes/guardian-approval-interception.ts +17 -6
  233. package/src/runtime/routes/inbound-message-handler.ts +205 -529
  234. package/src/runtime/routes/ingress-routes.ts +52 -4
  235. package/src/runtime/routes/pairing-routes.ts +3 -0
  236. package/src/runtime/tool-grant-request-helper.ts +195 -0
  237. package/src/tools/executor.ts +13 -1
  238. package/src/tools/guardian-control-plane-policy.ts +2 -2
  239. package/src/tools/sensitive-output-placeholders.ts +203 -0
  240. package/src/tools/tool-approval-handler.ts +53 -10
  241. package/src/tools/types.ts +13 -2
  242. package/src/util/bundled-asset.ts +31 -0
  243. package/src/util/canonicalize-identity.ts +52 -0
  244. package/src/util/logger.ts +20 -8
  245. package/src/util/platform.ts +10 -0
  246. package/src/util/voice-code.ts +29 -0
  247. package/src/daemon/guardian-invite-intent.ts +0 -124
@@ -874,6 +874,58 @@ export const guardianActionDeliveries = sqliteTable('guardian_action_deliveries'
874
874
  index('idx_guardian_action_deliveries_dest_conversation').on(table.destinationConversationId),
875
875
  ]);
876
876
 
877
+ // ── Canonical Guardian Requests (unified cross-source guardian domain) ─
878
+
879
+ export const canonicalGuardianRequests = sqliteTable('canonical_guardian_requests', {
880
+ id: text('id').primaryKey(),
881
+ kind: text('kind').notNull(),
882
+ sourceType: text('source_type').notNull(),
883
+ sourceChannel: text('source_channel'),
884
+ conversationId: text('conversation_id'),
885
+ requesterExternalUserId: text('requester_external_user_id'),
886
+ requesterChatId: text('requester_chat_id'),
887
+ guardianExternalUserId: text('guardian_external_user_id'),
888
+ callSessionId: text('call_session_id'),
889
+ pendingQuestionId: text('pending_question_id'),
890
+ questionText: text('question_text'),
891
+ requestCode: text('request_code'),
892
+ toolName: text('tool_name'),
893
+ inputDigest: text('input_digest'),
894
+ status: text('status').notNull().default('pending'),
895
+ answerText: text('answer_text'),
896
+ decidedByExternalUserId: text('decided_by_external_user_id'),
897
+ followupState: text('followup_state'),
898
+ expiresAt: text('expires_at'),
899
+ createdAt: text('created_at').notNull(),
900
+ updatedAt: text('updated_at').notNull(),
901
+ }, (table) => [
902
+ index('idx_canonical_guardian_requests_status').on(table.status),
903
+ index('idx_canonical_guardian_requests_guardian').on(table.guardianExternalUserId, table.status),
904
+ index('idx_canonical_guardian_requests_conversation').on(table.conversationId, table.status),
905
+ index('idx_canonical_guardian_requests_source').on(table.sourceType, table.status),
906
+ index('idx_canonical_guardian_requests_kind').on(table.kind, table.status),
907
+ index('idx_canonical_guardian_requests_request_code').on(table.requestCode),
908
+ ]);
909
+
910
+ // ── Canonical Guardian Deliveries (per-channel delivery tracking) ─────
911
+
912
+ export const canonicalGuardianDeliveries = sqliteTable('canonical_guardian_deliveries', {
913
+ id: text('id').primaryKey(),
914
+ requestId: text('request_id')
915
+ .notNull()
916
+ .references(() => canonicalGuardianRequests.id, { onDelete: 'cascade' }),
917
+ destinationChannel: text('destination_channel').notNull(),
918
+ destinationConversationId: text('destination_conversation_id'),
919
+ destinationChatId: text('destination_chat_id'),
920
+ destinationMessageId: text('destination_message_id'),
921
+ status: text('status').notNull().default('pending'),
922
+ createdAt: text('created_at').notNull(),
923
+ updatedAt: text('updated_at').notNull(),
924
+ }, (table) => [
925
+ index('idx_canonical_guardian_deliveries_request_id').on(table.requestId),
926
+ index('idx_canonical_guardian_deliveries_status').on(table.status),
927
+ ]);
928
+
877
929
  // ── Assistant Inbox ──────────────────────────────────────────────────
878
930
 
879
931
  export const assistantIngressInvites = sqliteTable('assistant_ingress_invites', {
@@ -890,6 +942,10 @@ export const assistantIngressInvites = sqliteTable('assistant_ingress_invites',
890
942
  redeemedByExternalUserId: text('redeemed_by_external_user_id'),
891
943
  redeemedByExternalChatId: text('redeemed_by_external_chat_id'),
892
944
  redeemedAt: integer('redeemed_at'),
945
+ // Voice invite fields (nullable — non-voice invites leave these NULL)
946
+ expectedExternalUserId: text('expected_external_user_id'),
947
+ voiceCodeHash: text('voice_code_hash'),
948
+ voiceCodeDigits: integer('voice_code_digits'),
893
949
  createdAt: integer('created_at').notNull(),
894
950
  updatedAt: integer('updated_at').notNull(),
895
951
  });
@@ -37,10 +37,37 @@ const TEMPLATES: Record<string, CopyTemplate> = {
37
37
  body: `${str(payload.name, 'A schedule')} has finished running`,
38
38
  }),
39
39
 
40
- 'guardian.question': (payload) => ({
41
- title: 'Guardian Question',
42
- body: str(payload.questionText, 'A guardian question needs your attention'),
43
- }),
40
+ 'guardian.question': (payload) => {
41
+ const question = str(payload.questionText, 'A guardian question needs your attention');
42
+ const requestCode = nonEmpty(typeof payload.requestCode === 'string' ? payload.requestCode : undefined);
43
+ if (!requestCode) {
44
+ return {
45
+ title: 'Guardian Question',
46
+ body: question,
47
+ };
48
+ }
49
+
50
+ const normalizedCode = requestCode.toUpperCase();
51
+ return {
52
+ title: 'Guardian Question',
53
+ body: `${question}\n\nReference code: ${normalizedCode}. Reply "${normalizedCode} approve" or "${normalizedCode} reject".`,
54
+ };
55
+ },
56
+
57
+ 'ingress.access_request': (payload) => {
58
+ const requester = str(payload.senderIdentifier, 'Someone');
59
+ const requestCode = nonEmpty(typeof payload.requestCode === 'string' ? payload.requestCode : undefined);
60
+ const lines: string[] = [`${requester} is requesting access to the assistant.`];
61
+ if (requestCode) {
62
+ const code = requestCode.toUpperCase();
63
+ lines.push(`Reply "${code} approve" to grant access or "${code} reject" to deny.`);
64
+ }
65
+ lines.push('Reply "open invite flow" to start Trusted Contacts invite flow.');
66
+ return {
67
+ title: 'Access Request',
68
+ body: lines.join('\n'),
69
+ };
70
+ },
44
71
 
45
72
  'ingress.escalation': (payload) => ({
46
73
  title: 'Escalation',
@@ -406,6 +406,61 @@ export function validateThreadActions(
406
406
  return result;
407
407
  }
408
408
 
409
+ function ensureGuardianRequestCodeInCopy(
410
+ copy: RenderedChannelCopy,
411
+ requestCode: string,
412
+ ): RenderedChannelCopy {
413
+ const instruction = `Reference code: ${requestCode}. Reply "${requestCode} approve" or "${requestCode} reject".`;
414
+ const hasParserCompatibleInstructions = (text: string | undefined): boolean => {
415
+ if (typeof text !== 'string') return false;
416
+ const upper = text.toUpperCase();
417
+ return upper.includes(`${requestCode} APPROVE`) && upper.includes(`${requestCode} REJECT`);
418
+ };
419
+
420
+ const ensureText = (text: string | undefined): string => {
421
+ const base = typeof text === 'string' ? text.trim() : '';
422
+ if (hasParserCompatibleInstructions(base)) return base;
423
+ return base.length > 0 ? `${base}\n\n${instruction}` : instruction;
424
+ };
425
+
426
+ return {
427
+ ...copy,
428
+ body: ensureText(copy.body),
429
+ deliveryText: copy.deliveryText ? ensureText(copy.deliveryText) : copy.deliveryText,
430
+ threadSeedMessage: copy.threadSeedMessage ? ensureText(copy.threadSeedMessage) : copy.threadSeedMessage,
431
+ };
432
+ }
433
+
434
+ /**
435
+ * Guardian questions that share a conversation require explicit request-code
436
+ * targeting. Enforce request-code instructions in rendered copy so guardians
437
+ * can always disambiguate replies even when model copy omits them.
438
+ */
439
+ function enforceGuardianRequestCode(
440
+ decision: NotificationDecision,
441
+ signal: NotificationSignal,
442
+ ): NotificationDecision {
443
+ if (signal.sourceEventName !== 'guardian.question') return decision;
444
+ const rawCode = signal.contextPayload.requestCode;
445
+ if (typeof rawCode !== 'string' || rawCode.trim().length === 0) return decision;
446
+
447
+ const requestCode = rawCode.trim().toUpperCase();
448
+ const nextCopy: Partial<Record<NotificationChannel, RenderedChannelCopy>> = {
449
+ ...decision.renderedCopy,
450
+ };
451
+
452
+ for (const channel of Object.keys(nextCopy) as NotificationChannel[]) {
453
+ const copy = nextCopy[channel];
454
+ if (!copy) continue;
455
+ nextCopy[channel] = ensureGuardianRequestCodeInCopy(copy, requestCode);
456
+ }
457
+
458
+ return {
459
+ ...decision,
460
+ renderedCopy: nextCopy,
461
+ };
462
+ }
463
+
409
464
  // ── Core evaluation function ───────────────────────────────────────────
410
465
 
411
466
  export async function evaluateSignal(
@@ -444,6 +499,7 @@ export async function evaluateSignal(
444
499
  if (!provider) {
445
500
  log.warn('Configured provider unavailable for notification decision, using fallback');
446
501
  let decision = buildFallbackDecision(signal, availableChannels);
502
+ decision = enforceGuardianRequestCode(decision, signal);
447
503
  decision = enforceConversationAffinity(decision, signal.conversationAffinityHint);
448
504
  decision.persistedDecisionId = persistDecision(signal, decision);
449
505
  return decision;
@@ -458,6 +514,7 @@ export async function evaluateSignal(
458
514
  decision = buildFallbackDecision(signal, availableChannels);
459
515
  }
460
516
 
517
+ decision = enforceGuardianRequestCode(decision, signal);
461
518
  decision = enforceConversationAffinity(decision, signal.conversationAffinityHint);
462
519
  decision.persistedDecisionId = persistDecision(signal, decision);
463
520
 
@@ -132,6 +132,7 @@ export function getDefaultRuleTemplates(): DefaultRuleTemplate[] {
132
132
  scope: workspaceDir,
133
133
  decision: 'allow',
134
134
  priority: 100,
135
+ allowHighRisk: true,
135
136
  };
136
137
 
137
138
  const updatesDeleteRule: DefaultRuleTemplate = {
@@ -141,6 +142,7 @@ export function getDefaultRuleTemplates(): DefaultRuleTemplate[] {
141
142
  scope: workspaceDir,
142
143
  decision: 'allow',
143
144
  priority: 100,
145
+ allowHighRisk: true,
144
146
  };
145
147
 
146
148
  // Skill source directories — writing or editing skill source files should
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Shared access-request creation and notification helper.
3
+ *
4
+ * Encapsulates the "create/dedupe canonical access request + emit notification"
5
+ * logic so both text-channel and voice-channel ingress paths use identical
6
+ * guardian notification flows.
7
+ *
8
+ * Access requests are a special case: they always create a canonical request
9
+ * and emit a notification signal, even when no same-channel guardian binding
10
+ * exists. Guardian binding resolution uses a fallback strategy:
11
+ * 1. Source-channel active binding first.
12
+ * 2. Any active binding for the assistant (deterministic, most-recently-verified).
13
+ * 3. No guardian identity (trusted/vellum-only resolution path).
14
+ */
15
+
16
+ import type { ChannelId } from '../channels/types.js';
17
+ import {
18
+ createCanonicalGuardianRequest,
19
+ listCanonicalGuardianRequests,
20
+ } from '../memory/canonical-guardian-store.js';
21
+ import { listActiveBindingsByAssistant } from '../memory/channel-guardian-store.js';
22
+ import { emitNotificationSignal } from '../notifications/emit-signal.js';
23
+ import { getLogger } from '../util/logger.js';
24
+ import { getGuardianBinding } from './channel-guardian-service.js';
25
+ import { GUARDIAN_APPROVAL_TTL_MS } from './routes/channel-route-shared.js';
26
+
27
+ const log = getLogger('access-request-helper');
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Types
31
+ // ---------------------------------------------------------------------------
32
+
33
+ export interface AccessRequestParams {
34
+ canonicalAssistantId: string;
35
+ sourceChannel: ChannelId;
36
+ externalChatId: string;
37
+ senderExternalUserId?: string;
38
+ senderName?: string;
39
+ senderUsername?: string;
40
+ }
41
+
42
+ export type AccessRequestResult =
43
+ | { notified: true; created: boolean; requestId: string }
44
+ | { notified: false; reason: 'no_sender_id' };
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Helper
48
+ // ---------------------------------------------------------------------------
49
+
50
+ /**
51
+ * Create/dedupe a canonical access request and emit a notification signal
52
+ * so the guardian can approve or deny the unknown sender.
53
+ *
54
+ * Returns a result indicating whether the guardian was notified and whether
55
+ * a new request was created or an existing one was deduped.
56
+ *
57
+ * Guardian binding resolution: source-channel first, then any active binding
58
+ * for the assistant, then null (notification pipeline handles delivery via
59
+ * trusted/vellum channels when no binding exists).
60
+ *
61
+ * This is intentionally synchronous with respect to the canonical store writes
62
+ * and fire-and-forget for the notification signal emission.
63
+ */
64
+ export function notifyGuardianOfAccessRequest(
65
+ params: AccessRequestParams,
66
+ ): AccessRequestResult {
67
+ const {
68
+ canonicalAssistantId,
69
+ sourceChannel,
70
+ externalChatId,
71
+ senderExternalUserId,
72
+ senderName,
73
+ senderUsername,
74
+ } = params;
75
+
76
+ if (!senderExternalUserId) {
77
+ return { notified: false, reason: 'no_sender_id' };
78
+ }
79
+
80
+ // Resolve guardian binding with fallback strategy:
81
+ // 1. Source-channel active binding
82
+ // 2. Any active binding for the assistant (deterministic order)
83
+ // 3. null (no guardian identity — notification pipeline uses trusted channels)
84
+ const sourceBinding = getGuardianBinding(canonicalAssistantId, sourceChannel);
85
+ let guardianExternalUserId: string | null = null;
86
+ let guardianBindingChannel: string | null = null;
87
+
88
+ if (sourceBinding) {
89
+ guardianExternalUserId = sourceBinding.guardianExternalUserId;
90
+ guardianBindingChannel = sourceBinding.channel;
91
+ } else {
92
+ const allBindings = listActiveBindingsByAssistant(canonicalAssistantId);
93
+ if (allBindings.length > 0) {
94
+ guardianExternalUserId = allBindings[0].guardianExternalUserId;
95
+ guardianBindingChannel = allBindings[0].channel;
96
+ log.debug(
97
+ { sourceChannel, fallbackChannel: guardianBindingChannel, canonicalAssistantId },
98
+ 'Using cross-channel guardian binding fallback for access request',
99
+ );
100
+ } else {
101
+ log.debug(
102
+ { sourceChannel, canonicalAssistantId },
103
+ 'No guardian binding for access request — proceeding without guardian identity',
104
+ );
105
+ }
106
+ }
107
+
108
+ // 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.
111
+ const existingCanonical = listCanonicalGuardianRequests({
112
+ status: 'pending',
113
+ requesterExternalUserId: senderExternalUserId,
114
+ sourceChannel,
115
+ kind: 'access_request',
116
+ });
117
+ if (existingCanonical.length > 0) {
118
+ log.debug(
119
+ { sourceChannel, senderExternalUserId, existingId: existingCanonical[0].id },
120
+ 'Skipping duplicate access request notification',
121
+ );
122
+ return { notified: true, created: false, requestId: existingCanonical[0].id };
123
+ }
124
+
125
+ const senderIdentifier = senderName || senderUsername || senderExternalUserId;
126
+ const requestId = `access-req-${canonicalAssistantId}-${sourceChannel}-${senderExternalUserId}-${Date.now()}`;
127
+
128
+ const canonicalRequest = createCanonicalGuardianRequest({
129
+ id: requestId,
130
+ kind: 'access_request',
131
+ sourceType: 'channel',
132
+ sourceChannel,
133
+ conversationId: `access-req-${sourceChannel}-${senderExternalUserId}`,
134
+ requesterExternalUserId: senderExternalUserId,
135
+ requesterChatId: externalChatId,
136
+ guardianExternalUserId: guardianExternalUserId ?? undefined,
137
+ toolName: 'ingress_access_request',
138
+ questionText: `${senderIdentifier} is requesting access to the assistant`,
139
+ expiresAt: new Date(Date.now() + GUARDIAN_APPROVAL_TTL_MS).toISOString(),
140
+ });
141
+
142
+ void emitNotificationSignal({
143
+ sourceEventName: 'ingress.access_request',
144
+ sourceChannel,
145
+ sourceSessionId: `access-req-${sourceChannel}-${senderExternalUserId}`,
146
+ assistantId: canonicalAssistantId,
147
+ attentionHints: {
148
+ requiresAction: true,
149
+ urgency: 'high',
150
+ isAsyncBackground: false,
151
+ visibleInSourceNow: false,
152
+ },
153
+ contextPayload: {
154
+ requestId,
155
+ requestCode: canonicalRequest.requestCode,
156
+ sourceChannel,
157
+ externalChatId,
158
+ senderExternalUserId,
159
+ senderName: senderName ?? null,
160
+ senderUsername: senderUsername ?? null,
161
+ senderIdentifier,
162
+ guardianBindingChannel,
163
+ },
164
+ dedupeKey: `access-request:${canonicalRequest.id}`,
165
+ });
166
+
167
+ log.info(
168
+ { sourceChannel, senderExternalUserId, senderIdentifier, guardianBindingChannel },
169
+ 'Guardian notified of access request',
170
+ );
171
+
172
+ return { notified: true, created: true, requestId: canonicalRequest.id };
173
+ }
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Unified inbound actor trust resolver.
3
+ *
4
+ * Produces a single trust-resolved actor context from raw inbound identity
5
+ * fields. Normalizes sender identity via channel-agnostic canonicalization,
6
+ * then resolves trust classification by checking guardian bindings and
7
+ * ingress member records.
8
+ *
9
+ * Trust classifications:
10
+ * - `guardian`: sender matches the active guardian binding for this channel.
11
+ * - `trusted_contact`: sender is an active ingress member (not the guardian).
12
+ * - `unknown`: sender has no member record or no identity could be established.
13
+ */
14
+
15
+ import type { ChannelId } from '../channels/types.js';
16
+ import type { GuardianRuntimeContext } from '../daemon/session-runtime-assembly.js';
17
+ import type { IngressMember } from '../memory/ingress-member-store.js';
18
+ import { findMember } from '../memory/ingress-member-store.js';
19
+ import { canonicalizeInboundIdentity } from '../util/canonicalize-identity.js';
20
+ import { normalizeAssistantId } from '../util/platform.js';
21
+ import { getGuardianBinding } from './channel-guardian-service.js';
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Types
25
+ // ---------------------------------------------------------------------------
26
+
27
+ export type TrustClass = 'guardian' | 'trusted_contact' | 'unknown';
28
+ export type DenialReason = 'no_binding' | 'no_identity';
29
+
30
+ export interface ActorTrustContext {
31
+ /** Canonical (normalized) sender identity. Null when identity could not be established. */
32
+ canonicalSenderId: string | null;
33
+ /** Guardian binding match, if any, for this (assistantId, channel). */
34
+ guardianBindingMatch: {
35
+ guardianExternalUserId: string;
36
+ guardianDeliveryChatId: string | null;
37
+ } | null;
38
+ /** Ingress member record, if any, for this sender. */
39
+ memberRecord: IngressMember | null;
40
+ /** Trust classification. */
41
+ trustClass: TrustClass;
42
+ /** Assistant-facing metadata for downstream consumption. */
43
+ actorMetadata: {
44
+ identifier: string | undefined;
45
+ displayName: string | undefined;
46
+ senderDisplayName: string | undefined;
47
+ memberDisplayName: string | undefined;
48
+ username: string | undefined;
49
+ channel: ChannelId;
50
+ trustStatus: TrustClass;
51
+ };
52
+ /** Legacy denial reason for backward-compatible unverified_channel paths. */
53
+ denialReason?: DenialReason;
54
+ }
55
+
56
+ export interface ResolveActorTrustInput {
57
+ assistantId: string;
58
+ sourceChannel: ChannelId;
59
+ externalChatId: string;
60
+ senderExternalUserId?: string;
61
+ senderUsername?: string;
62
+ senderDisplayName?: string;
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Resolver
67
+ // ---------------------------------------------------------------------------
68
+
69
+ /**
70
+ * Resolve the inbound actor's trust context from raw identity fields.
71
+ *
72
+ * 1. Canonicalize the sender identity (E.164 for phone channels, trimmed ID otherwise).
73
+ * 2. Look up the guardian binding for (assistantId, channel).
74
+ * 3. Compare canonical sender identity to the guardian binding.
75
+ * 4. Look up the ingress member record using the canonical identity.
76
+ * 5. Classify: guardian > trusted_contact (active member) > unknown.
77
+ */
78
+ export function resolveActorTrust(input: ResolveActorTrustInput): ActorTrustContext {
79
+ const assistantId = normalizeAssistantId(input.assistantId);
80
+
81
+ const rawUserId = typeof input.senderExternalUserId === 'string' && input.senderExternalUserId.trim().length > 0
82
+ ? input.senderExternalUserId.trim()
83
+ : undefined;
84
+
85
+ const senderUsername = typeof input.senderUsername === 'string' && input.senderUsername.trim().length > 0
86
+ ? input.senderUsername.trim()
87
+ : undefined;
88
+
89
+ const senderDisplayName = typeof input.senderDisplayName === 'string' && input.senderDisplayName.trim().length > 0
90
+ ? input.senderDisplayName.trim()
91
+ : undefined;
92
+
93
+ // Canonical identity: normalize phone-like channels to E.164.
94
+ const canonicalSenderId = rawUserId
95
+ ? canonicalizeInboundIdentity(input.sourceChannel, rawUserId)
96
+ : null;
97
+
98
+ const identifier = senderUsername ? `@${senderUsername}` : canonicalSenderId ?? undefined;
99
+
100
+ // No identity at all => unknown
101
+ if (!canonicalSenderId) {
102
+ return {
103
+ canonicalSenderId: null,
104
+ guardianBindingMatch: null,
105
+ memberRecord: null,
106
+ trustClass: 'unknown',
107
+ actorMetadata: {
108
+ identifier,
109
+ displayName: senderDisplayName,
110
+ senderDisplayName,
111
+ memberDisplayName: undefined,
112
+ username: senderUsername,
113
+ channel: input.sourceChannel,
114
+ trustStatus: 'unknown',
115
+ },
116
+ denialReason: 'no_identity',
117
+ };
118
+ }
119
+
120
+ // Guardian binding lookup
121
+ const binding = getGuardianBinding(assistantId, input.sourceChannel);
122
+ const guardianBindingMatch = binding
123
+ ? { guardianExternalUserId: binding.guardianExternalUserId, guardianDeliveryChatId: binding.guardianDeliveryChatId }
124
+ : null;
125
+
126
+ // Check if sender IS the guardian. Compare canonical sender against the
127
+ // binding's guardian identity (also canonicalize for phone channels to
128
+ // handle formatting variance in the stored binding).
129
+ let isGuardian = false;
130
+ if (binding) {
131
+ const canonicalGuardianId = canonicalizeInboundIdentity(input.sourceChannel, binding.guardianExternalUserId);
132
+ isGuardian = canonicalGuardianId === canonicalSenderId;
133
+ }
134
+
135
+ // Ingress member lookup using canonical identity.
136
+ const memberRecord = findMember({
137
+ assistantId,
138
+ sourceChannel: input.sourceChannel,
139
+ externalUserId: canonicalSenderId,
140
+ externalChatId: input.externalChatId,
141
+ });
142
+
143
+ // In group chats, findMember may match on externalChatId and return a
144
+ // record for a different user. Only use member metadata when the record's
145
+ // externalUserId matches the current sender to avoid misidentification.
146
+ // Canonicalize the stored member ID to handle formatting variance (e.g.
147
+ // phone numbers stored without E.164 normalization).
148
+ const memberMatchesSender = memberRecord?.externalUserId
149
+ ? canonicalizeInboundIdentity(input.sourceChannel, memberRecord.externalUserId) === canonicalSenderId
150
+ : false;
151
+
152
+ const memberUsername = memberMatchesSender && typeof memberRecord?.username === 'string' && memberRecord.username.trim().length > 0
153
+ ? memberRecord.username.trim()
154
+ : undefined;
155
+ const memberDisplayName = memberMatchesSender && typeof memberRecord?.displayName === 'string' && memberRecord.displayName.trim().length > 0
156
+ ? memberRecord.displayName.trim()
157
+ : undefined;
158
+ // Prefer member profile metadata over transient sender metadata so guardian-
159
+ // curated contact details are canonical for assistant-facing identity —
160
+ // but only when the member record actually belongs to the current sender.
161
+ const resolvedUsername = memberUsername ?? senderUsername;
162
+ const resolvedDisplayName = memberDisplayName ?? senderDisplayName;
163
+ const resolvedIdentifier = resolvedUsername ? `@${resolvedUsername}` : canonicalSenderId ?? undefined;
164
+
165
+ // Trust classification
166
+ let trustClass: TrustClass;
167
+ if (isGuardian) {
168
+ trustClass = 'guardian';
169
+ } else if (memberMatchesSender && memberRecord && memberRecord.status === 'active') {
170
+ trustClass = 'trusted_contact';
171
+ } else {
172
+ trustClass = 'unknown';
173
+ }
174
+
175
+ // Denial reason for legacy compatibility
176
+ let denialReason: DenialReason | undefined;
177
+ if (!isGuardian && !binding) {
178
+ denialReason = 'no_binding';
179
+ }
180
+
181
+ return {
182
+ canonicalSenderId,
183
+ guardianBindingMatch,
184
+ memberRecord,
185
+ trustClass,
186
+ actorMetadata: {
187
+ identifier: resolvedIdentifier,
188
+ displayName: resolvedDisplayName,
189
+ senderDisplayName,
190
+ memberDisplayName,
191
+ username: resolvedUsername,
192
+ channel: input.sourceChannel,
193
+ trustStatus: trustClass,
194
+ },
195
+ denialReason,
196
+ };
197
+ }
198
+
199
+ /**
200
+ * Convert an ActorTrustContext into the runtime trust context shape used by
201
+ * sessions/tooling.
202
+ */
203
+ export function toGuardianRuntimeContextFromTrust(
204
+ ctx: ActorTrustContext,
205
+ externalChatId: string,
206
+ ): GuardianRuntimeContext {
207
+ return {
208
+ sourceChannel: ctx.actorMetadata.channel,
209
+ trustClass: ctx.trustClass,
210
+ guardianChatId: ctx.guardianBindingMatch?.guardianDeliveryChatId ??
211
+ (ctx.trustClass === 'guardian' ? externalChatId : undefined),
212
+ guardianExternalUserId: ctx.guardianBindingMatch?.guardianExternalUserId,
213
+ requesterIdentifier: ctx.actorMetadata.identifier,
214
+ requesterDisplayName: ctx.actorMetadata.displayName,
215
+ requesterSenderDisplayName: ctx.actorMetadata.senderDisplayName,
216
+ requesterMemberDisplayName: ctx.actorMetadata.memberDisplayName,
217
+ requesterExternalUserId: ctx.canonicalSenderId ?? undefined,
218
+ requesterChatId: externalChatId,
219
+ denialReason: ctx.denialReason,
220
+ };
221
+ }
@@ -231,11 +231,19 @@ export function validateAndConsumeChallenge(
231
231
  }
232
232
  }
233
233
 
234
- // For Telegram: verify actorChatId matches expectedChatId
235
- // AND/OR actorExternalUserId matches expectedExternalUserId
234
+ // For chat-based channels (Telegram, Slack, etc.): when both
235
+ // expectedExternalUserId and expectedChatId are set, require the
236
+ // externalUserId match — chatId alone is insufficient because chat IDs
237
+ // can be shared (e.g. Slack channel IDs, Telegram group chat IDs) and
238
+ // would let any participant in the same chat satisfy identity binding.
239
+ // Fall back to chatId-only match only when expectedExternalUserId is
240
+ // not available (legacy sessions or channels without user-level identity).
236
241
  if (challenge.expectedChatId != null) {
237
- if (actorChatId === challenge.expectedChatId ||
238
- actorExternalUserId === challenge.expectedExternalUserId) {
242
+ if (challenge.expectedExternalUserId != null) {
243
+ if (actorExternalUserId === challenge.expectedExternalUserId) {
244
+ identityMatch = true;
245
+ }
246
+ } else if (actorChatId === challenge.expectedChatId) {
239
247
  identityMatch = true;
240
248
  }
241
249
  }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Voice channel invite transport adapter.
3
+ *
4
+ * Voice invites are identity-bound: the invitee must call from a specific
5
+ * phone number and enter a numeric code. Unlike Telegram invites, there is
6
+ * no shareable deep link — the guardian relays the code and calling
7
+ * instructions verbally or via another channel.
8
+ *
9
+ * The transport builds human-readable instruction text and provides a
10
+ * no-op token extractor since voice invite redemption uses the dedicated
11
+ * voice-code path rather than generic token extraction.
12
+ */
13
+
14
+ import type { ChannelId } from '../../channels/types.js';
15
+ import {
16
+ type ChannelInviteTransport,
17
+ type InviteSharePayload,
18
+ registerTransport,
19
+ } from '../channel-invite-transport.js';
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Transport implementation
23
+ // ---------------------------------------------------------------------------
24
+
25
+ export const voiceInviteTransport: ChannelInviteTransport = {
26
+ channel: 'voice' as ChannelId,
27
+
28
+ buildShareableInvite(_params: {
29
+ rawToken: string;
30
+ sourceChannel: ChannelId;
31
+ }): InviteSharePayload {
32
+ // Voice invites do not produce a clickable URL. The "url" field contains
33
+ // a placeholder — callers should use displayText for presentation.
34
+ return {
35
+ url: '',
36
+ displayText: [
37
+ 'Voice invite created.',
38
+ 'The invitee must call the assistant\'s phone number from the authorized number and enter their invite code when prompted.',
39
+ ].join(' '),
40
+ };
41
+ },
42
+
43
+ extractInboundToken(_params: {
44
+ commandIntent?: Record<string, unknown>;
45
+ content: string;
46
+ sourceMetadata?: Record<string, unknown>;
47
+ }): string | undefined {
48
+ // Voice invite redemption bypasses generic token extraction — it uses
49
+ // the identity-bound voice-code flow in invite-redemption-service.ts.
50
+ return undefined;
51
+ },
52
+ };
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Auto-register on import
56
+ // ---------------------------------------------------------------------------
57
+
58
+ registerTransport(voiceInviteTransport);