@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
package/src/agent/loop.ts CHANGED
@@ -4,6 +4,8 @@ import { truncateOversizedToolResults } from '../context/tool-result-truncation.
4
4
  import { getHookManager } from '../hooks/manager.js';
5
5
  import type { ContentBlock,Message, Provider, ToolDefinition } from '../providers/types.js';
6
6
  import type { ToolResultContent } from '../providers/types.js';
7
+ import type { SensitiveOutputBinding } from '../tools/sensitive-output-placeholders.js';
8
+ import { applyStreamingSubstitution, applySubstitutions } from '../tools/sensitive-output-placeholders.js';
7
9
  import { getLogger, isDebug, truncateForLog } from '../util/logger.js';
8
10
 
9
11
  const log = getLogger('agent-loop');
@@ -63,14 +65,14 @@ export class AgentLoop {
63
65
  private tools: ToolDefinition[];
64
66
  private resolveTools: ((history: Message[]) => ToolDefinition[]) | null;
65
67
  private resolveSystemPrompt: ((history: Message[]) => ResolvedSystemPrompt) | null;
66
- private toolExecutor: ((name: string, input: Record<string, unknown>, onOutput?: (chunk: string) => void) => Promise<{ content: string; isError: boolean; diff?: { filePath: string; oldContent: string; newContent: string; isNewFile: boolean }; status?: string; contentBlocks?: ContentBlock[] }>) | null;
68
+ private toolExecutor: ((name: string, input: Record<string, unknown>, onOutput?: (chunk: string) => void) => Promise<{ content: string; isError: boolean; diff?: { filePath: string; oldContent: string; newContent: string; isNewFile: boolean }; status?: string; contentBlocks?: ContentBlock[]; sensitiveBindings?: SensitiveOutputBinding[] }>) | null;
67
69
 
68
70
  constructor(
69
71
  provider: Provider,
70
72
  systemPrompt: string,
71
73
  config?: Partial<AgentLoopConfig>,
72
74
  tools?: ToolDefinition[],
73
- toolExecutor?: (name: string, input: Record<string, unknown>, onOutput?: (chunk: string) => void) => Promise<{ content: string; isError: boolean; diff?: { filePath: string; oldContent: string; newContent: string; isNewFile: boolean }; status?: string; contentBlocks?: ContentBlock[] }>,
75
+ toolExecutor?: (name: string, input: Record<string, unknown>, onOutput?: (chunk: string) => void) => Promise<{ content: string; isError: boolean; diff?: { filePath: string; oldContent: string; newContent: string; isNewFile: boolean }; status?: string; contentBlocks?: ContentBlock[]; sensitiveBindings?: SensitiveOutputBinding[] }>,
74
76
  resolveTools?: (history: Message[]) => ToolDefinition[],
75
77
  resolveSystemPrompt?: (history: Message[]) => ResolvedSystemPrompt,
76
78
  ) {
@@ -97,6 +99,12 @@ export class AgentLoop {
97
99
  const debug = isDebug();
98
100
  const rlog = requestId ? log.child({ requestId }) : log;
99
101
 
102
+ // Per-run substitution map for sensitive output placeholders.
103
+ // Bindings are accumulated from tool results; placeholders are
104
+ // resolved in streamed deltas and final assistant message text.
105
+ const substitutionMap = new Map<string, string>();
106
+ let streamingPending = '';
107
+
100
108
  while (true) {
101
109
  if (signal?.aborted) break;
102
110
 
@@ -188,7 +196,17 @@ export class AgentLoop {
188
196
  config: providerConfig,
189
197
  onEvent: (event) => {
190
198
  if (event.type === 'text_delta') {
191
- onEvent({ type: 'text_delta', text: event.text });
199
+ // Apply sensitive-output placeholder substitution (chunk-safe)
200
+ if (substitutionMap.size > 0) {
201
+ const combined = streamingPending + event.text;
202
+ const { emit, pending } = applyStreamingSubstitution(combined, substitutionMap);
203
+ streamingPending = pending;
204
+ if (emit.length > 0) {
205
+ onEvent({ type: 'text_delta', text: emit });
206
+ }
207
+ } else {
208
+ onEvent({ type: 'text_delta', text: event.text });
209
+ }
192
210
  } else if (event.type === 'thinking_delta') {
193
211
  onEvent({ type: 'thinking_delta', thinking: event.thinking });
194
212
  } else if (event.type === 'input_json_delta') {
@@ -238,6 +256,20 @@ export class AgentLoop {
238
256
  durationMs: providerDurationMs,
239
257
  });
240
258
 
259
+ // Flush any buffered streaming text from the substitution pipeline
260
+ if (streamingPending.length > 0) {
261
+ const flushed = applySubstitutions(streamingPending, substitutionMap);
262
+ if (flushed.length > 0) {
263
+ onEvent({ type: 'text_delta', text: flushed });
264
+ }
265
+ streamingPending = '';
266
+ }
267
+
268
+ // Build the assistant message with placeholder-only text.
269
+ // Both provider history and persisted conversation store must retain
270
+ // placeholders so the model never sees real sensitive values — neither
271
+ // on subsequent loop turns nor on session reload from the database.
272
+ // Substitution to real values happens only in streamed text_delta events.
241
273
  const assistantMessage: Message = {
242
274
  role: 'assistant',
243
275
  content: response.content,
@@ -391,6 +423,17 @@ export class AgentLoop {
391
423
  toolResults = await toolExecutionPromise;
392
424
  }
393
425
 
426
+ // Merge sensitive output bindings from tool results into the
427
+ // per-run substitution map. Bindings carry placeholder->value pairs
428
+ // that are resolved in streamed text deltas and final message text.
429
+ for (const { result } of toolResults) {
430
+ if (result.sensitiveBindings) {
431
+ for (const binding of result.sensitiveBindings) {
432
+ substitutionMap.set(binding.placeholder, binding.value);
433
+ }
434
+ }
435
+ }
436
+
394
437
  // Collect result blocks preserving tool_use order (Promise.all maintains order)
395
438
  const rawResultBlocks: ContentBlock[] = toolResults.map(({ toolUse, result }) => ({
396
439
  type: 'tool_result' as const,
@@ -12,6 +12,11 @@
12
12
  * 5. Guardian approval record update
13
13
  * 6. Scoped grant minting on approve
14
14
  *
15
+ * The canonical path (`applyCanonicalGuardianDecision`) adds:
16
+ * 7. Canonical request lookup and status validation
17
+ * 8. CAS resolution via `resolveCanonicalGuardianRequest`
18
+ * 9. Kind-specific resolver dispatch via the resolver registry
19
+ *
15
20
  * Security invariants enforced here:
16
21
  * - Decision application is identity-bound to expected guardian identity
17
22
  * - Decisions are first-response-wins (CAS-like stale protection)
@@ -20,11 +25,18 @@
20
25
  */
21
26
 
22
27
  import type { ChannelId } from '../channels/types.js';
28
+ import {
29
+ type CanonicalGuardianRequest,
30
+ type CanonicalRequestStatus,
31
+ getCanonicalGuardianRequest,
32
+ resolveCanonicalGuardianRequest,
33
+ } from '../memory/canonical-guardian-store.js';
23
34
  import {
24
35
  type GuardianApprovalRequest,
25
36
  updateApprovalDecision,
26
37
  } from '../memory/channel-guardian-store.js';
27
38
  import type {
39
+ ApprovalAction,
28
40
  ApprovalDecisionResult,
29
41
  } from '../runtime/channel-approval-types.js';
30
42
  import {
@@ -36,6 +48,11 @@ import type { ApplyGuardianDecisionResult } from '../runtime/guardian-decision-t
36
48
  import { computeToolApprovalDigest } from '../security/tool-approval-digest.js';
37
49
  import { getLogger } from '../util/logger.js';
38
50
  import { mintGrantFromDecision } from './approval-primitive.js';
51
+ import {
52
+ type ActorContext,
53
+ type ChannelDeliveryContext,
54
+ getResolver,
55
+ } from './guardian-request-resolvers.js';
39
56
 
40
57
  const log = getLogger('guardian-decision-primitive');
41
58
 
@@ -189,3 +206,271 @@ export function applyGuardianDecision(params: ApplyGuardianDecisionParams): Appl
189
206
  requestId: result.requestId,
190
207
  };
191
208
  }
209
+
210
+ // ---------------------------------------------------------------------------
211
+ // Consolidated canonical grant minting
212
+ // ---------------------------------------------------------------------------
213
+
214
+ /**
215
+ * Mint a scoped approval grant from a canonical guardian request.
216
+ *
217
+ * Works for all request kinds that carry tool metadata (toolName + inputDigest).
218
+ * Requests without tool metadata are silently skipped — grant minting only
219
+ * applies to tool-approval flows.
220
+ *
221
+ * Fails silently on error — grant minting is best-effort and must never
222
+ * block the approval flow.
223
+ */
224
+ export function mintCanonicalRequestGrant(params: {
225
+ request: CanonicalGuardianRequest;
226
+ actorChannel: string;
227
+ guardianExternalUserId?: string;
228
+ }): { minted: boolean } {
229
+ const { request, actorChannel, guardianExternalUserId } = params;
230
+
231
+ if (!request.toolName || !request.inputDigest) {
232
+ return { minted: false };
233
+ }
234
+
235
+ const result = mintGrantFromDecision({
236
+ assistantId: 'self',
237
+ scopeMode: 'tool_signature',
238
+ toolName: request.toolName,
239
+ inputDigest: request.inputDigest,
240
+ requestChannel: request.sourceChannel ?? 'unknown',
241
+ decisionChannel: actorChannel,
242
+ executionChannel: null,
243
+ conversationId: request.conversationId ?? null,
244
+ callSessionId: request.callSessionId ?? null,
245
+ guardianExternalUserId: guardianExternalUserId ?? null,
246
+ requesterExternalUserId: request.requesterExternalUserId ?? null,
247
+ expiresAt: new Date(Date.now() + GRANT_TTL_MS).toISOString(),
248
+ });
249
+
250
+ if (result.ok) {
251
+ log.info(
252
+ {
253
+ event: 'canonical_grant_minted',
254
+ requestId: request.id,
255
+ toolName: request.toolName,
256
+ conversationId: request.conversationId,
257
+ },
258
+ 'Minted scoped approval grant for canonical guardian request',
259
+ );
260
+ return { minted: true };
261
+ }
262
+
263
+ log.error(
264
+ {
265
+ event: 'canonical_grant_mint_failed',
266
+ reason: result.reason,
267
+ requestId: request.id,
268
+ toolName: request.toolName,
269
+ },
270
+ 'Failed to mint scoped approval grant for canonical request (non-fatal)',
271
+ );
272
+ return { minted: false };
273
+ }
274
+
275
+ // ---------------------------------------------------------------------------
276
+ // Canonical guardian decision primitive
277
+ // ---------------------------------------------------------------------------
278
+
279
+ /** Valid actions for canonical guardian decisions. */
280
+ const VALID_CANONICAL_ACTIONS: ReadonlySet<ApprovalAction> = new Set([
281
+ 'approve_once',
282
+ 'approve_always',
283
+ 'reject',
284
+ ]);
285
+
286
+ export interface ApplyCanonicalGuardianDecisionParams {
287
+ /** The canonical request ID to resolve. */
288
+ requestId: string;
289
+ /** The decision action. */
290
+ action: ApprovalAction;
291
+ /** Actor context for the entity making the decision. */
292
+ actorContext: ActorContext;
293
+ /** Optional user-supplied text (e.g. answer text for pending questions). */
294
+ userText?: string;
295
+ /** Optional channel delivery context — present when the decision arrived via a channel message. */
296
+ channelDeliveryContext?: ChannelDeliveryContext;
297
+ }
298
+
299
+ export type CanonicalDecisionResult =
300
+ | { applied: true; requestId: string; grantMinted: boolean; resolverFailed?: boolean; resolverFailureReason?: string }
301
+ | { applied: false; reason: 'not_found' | 'already_resolved' | 'identity_mismatch' | 'invalid_action' | 'expired'; detail?: string };
302
+
303
+ /**
304
+ * Apply a guardian decision through the canonical request primitive.
305
+ *
306
+ * This is the future single write path for all guardian decisions. It
307
+ * operates on the canonical_guardian_requests table and dispatches to
308
+ * kind-specific resolvers via the resolver registry.
309
+ *
310
+ * Steps:
311
+ * 1. Look up the canonical request by ID
312
+ * 2. Validate: exists, pending status, identity match, valid action
313
+ * 3. Downgrade approve_always to approve_once (guardian-on-behalf invariant)
314
+ * 4. CAS resolve the canonical request atomically
315
+ * 5. Dispatch to kind-specific resolver
316
+ * 6. Mint grant if applicable
317
+ */
318
+ export async function applyCanonicalGuardianDecision(
319
+ params: ApplyCanonicalGuardianDecisionParams,
320
+ ): Promise<CanonicalDecisionResult> {
321
+ const { requestId, action, actorContext, userText, channelDeliveryContext } = params;
322
+
323
+ // 1. Look up the canonical request
324
+ const request = getCanonicalGuardianRequest(requestId);
325
+ if (!request) {
326
+ log.warn(
327
+ { event: 'canonical_decision_not_found', requestId },
328
+ 'Canonical request not found',
329
+ );
330
+ return { applied: false, reason: 'not_found' };
331
+ }
332
+
333
+ // 2a. Validate status is pending
334
+ if (request.status !== 'pending') {
335
+ log.info(
336
+ { event: 'canonical_decision_already_resolved', requestId, currentStatus: request.status },
337
+ 'Canonical request already resolved',
338
+ );
339
+ return { applied: false, reason: 'already_resolved' };
340
+ }
341
+
342
+ // 2b. Validate action is valid
343
+ if (!VALID_CANONICAL_ACTIONS.has(action)) {
344
+ log.warn(
345
+ { event: 'canonical_decision_invalid_action', requestId, action },
346
+ 'Invalid action for canonical decision',
347
+ );
348
+ return { applied: false, reason: 'invalid_action', detail: `invalid action: ${action}` };
349
+ }
350
+
351
+ // 2c. Validate identity: actor must match guardian_external_user_id
352
+ // unless the actor is trusted (desktop) or the request has no guardian binding.
353
+ if (
354
+ request.guardianExternalUserId &&
355
+ !actorContext.isTrusted &&
356
+ actorContext.externalUserId !== request.guardianExternalUserId
357
+ ) {
358
+ log.warn(
359
+ {
360
+ event: 'canonical_decision_identity_mismatch',
361
+ requestId,
362
+ expectedGuardian: request.guardianExternalUserId,
363
+ actualActor: actorContext.externalUserId,
364
+ },
365
+ 'Actor identity does not match expected guardian',
366
+ );
367
+ return { applied: false, reason: 'identity_mismatch' };
368
+ }
369
+
370
+ // 2d. Check expiry
371
+ if (request.expiresAt && new Date(request.expiresAt).getTime() < Date.now()) {
372
+ log.info(
373
+ { event: 'canonical_decision_expired', requestId, expiresAt: request.expiresAt },
374
+ 'Canonical request has expired',
375
+ );
376
+ return { applied: false, reason: 'expired' };
377
+ }
378
+
379
+ // 3. Downgrade approve_always to approve_once for guardian-on-behalf requests.
380
+ // Guardians cannot permanently allowlist tools on behalf of requesters.
381
+ const effectiveAction: ApprovalAction = action === 'approve_always'
382
+ ? 'approve_once'
383
+ : action;
384
+
385
+ // 4. CAS resolve: atomically transition from 'pending' to terminal status
386
+ const targetStatus: CanonicalRequestStatus = effectiveAction === 'reject'
387
+ ? 'denied'
388
+ : 'approved';
389
+
390
+ const resolved = resolveCanonicalGuardianRequest(requestId, 'pending', {
391
+ status: targetStatus,
392
+ answerText: userText,
393
+ decidedByExternalUserId: actorContext.externalUserId,
394
+ });
395
+
396
+ if (!resolved) {
397
+ // CAS failed — someone else resolved it first
398
+ log.info(
399
+ { event: 'canonical_decision_cas_failed', requestId },
400
+ 'CAS resolution failed (race condition — first writer wins)',
401
+ );
402
+ return { applied: false, reason: 'already_resolved' };
403
+ }
404
+
405
+ // 5. Dispatch to kind-specific resolver
406
+ let resolverFailed = false;
407
+ let resolverFailureReason: string | undefined;
408
+ const resolver = getResolver(request.kind);
409
+ if (resolver) {
410
+ const resolverResult = await resolver.resolve({
411
+ request: resolved,
412
+ decision: { action: effectiveAction, userText },
413
+ actor: actorContext,
414
+ channelDeliveryContext,
415
+ });
416
+
417
+ if (!resolverResult.ok) {
418
+ log.warn(
419
+ {
420
+ event: 'canonical_decision_resolver_failed',
421
+ requestId,
422
+ kind: request.kind,
423
+ reason: resolverResult.reason,
424
+ },
425
+ `Resolver for kind '${request.kind}' failed: ${resolverResult.reason}`,
426
+ );
427
+ // The canonical request is already resolved (CAS succeeded), so we don't
428
+ // roll back. Flag the failure and fall through to grant minting so that
429
+ // callers see applied: true (reflecting the committed DB state) while
430
+ // still being informed that the resolver had an issue.
431
+ resolverFailed = true;
432
+ resolverFailureReason = resolverResult.reason;
433
+ }
434
+ } else {
435
+ log.info(
436
+ { event: 'canonical_decision_no_resolver', requestId, kind: request.kind },
437
+ `No resolver registered for kind '${request.kind}' — CAS resolution only`,
438
+ );
439
+ }
440
+
441
+ // 6. Mint grant if the decision is an approval with tool metadata.
442
+ // Skip when the resolver failed — minting a grant on a failed side effect
443
+ // would allow the tool to execute without the intended resolver action
444
+ // (e.g. answerCall) having succeeded.
445
+ let grantMinted = false;
446
+ if (effectiveAction !== 'reject' && !resolverFailed) {
447
+ const grantResult = mintCanonicalRequestGrant({
448
+ request: resolved,
449
+ actorChannel: actorContext.channel,
450
+ guardianExternalUserId: actorContext.externalUserId ?? resolved.guardianExternalUserId ?? undefined,
451
+ });
452
+ grantMinted = grantResult.minted;
453
+ }
454
+
455
+ log.info(
456
+ {
457
+ event: 'canonical_decision_applied',
458
+ requestId,
459
+ kind: request.kind,
460
+ action: effectiveAction,
461
+ targetStatus,
462
+ grantMinted,
463
+ resolverFailed,
464
+ },
465
+ resolverFailed
466
+ ? 'Canonical guardian decision applied (CAS committed) but resolver failed'
467
+ : 'Canonical guardian decision applied successfully',
468
+ );
469
+
470
+ return {
471
+ applied: true,
472
+ requestId,
473
+ grantMinted,
474
+ ...(resolverFailed ? { resolverFailed, resolverFailureReason } : {}),
475
+ };
476
+ }