@vellumai/assistant 0.3.19 → 0.3.21

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 (199) hide show
  1. package/ARCHITECTURE.md +151 -15
  2. package/Dockerfile +1 -0
  3. package/README.md +40 -4
  4. package/bun.lock +139 -2
  5. package/docs/architecture/integrations.md +7 -11
  6. package/package.json +2 -1
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +54 -0
  8. package/src/__tests__/approval-primitive.test.ts +540 -0
  9. package/src/__tests__/assistant-feature-flag-guard.test.ts +206 -0
  10. package/src/__tests__/assistant-feature-flag-guardrails.test.ts +198 -0
  11. package/src/__tests__/assistant-feature-flags-integration.test.ts +272 -0
  12. package/src/__tests__/call-controller.test.ts +439 -108
  13. package/src/__tests__/channel-invite-transport.test.ts +264 -0
  14. package/src/__tests__/cli.test.ts +42 -1
  15. package/src/__tests__/config-schema.test.ts +11 -127
  16. package/src/__tests__/config-watcher.test.ts +0 -8
  17. package/src/__tests__/daemon-lifecycle.test.ts +1 -0
  18. package/src/__tests__/daemon-server-session-init.test.ts +8 -2
  19. package/src/__tests__/diff.test.ts +22 -0
  20. package/src/__tests__/guardian-action-copy-generator.test.ts +5 -0
  21. package/src/__tests__/guardian-action-grant-mint-consume.test.ts +300 -32
  22. package/src/__tests__/guardian-action-late-reply.test.ts +546 -1
  23. package/src/__tests__/guardian-actions-endpoint.test.ts +774 -0
  24. package/src/__tests__/guardian-control-plane-policy.test.ts +36 -3
  25. package/src/__tests__/guardian-dispatch.test.ts +124 -0
  26. package/src/__tests__/guardian-grant-minting.test.ts +6 -17
  27. package/src/__tests__/inbound-invite-redemption.test.ts +367 -0
  28. package/src/__tests__/invite-redemption-service.test.ts +306 -0
  29. package/src/__tests__/ipc-snapshot.test.ts +57 -0
  30. package/src/__tests__/notification-decision-fallback.test.ts +88 -0
  31. package/src/__tests__/sandbox-diagnostics.test.ts +6 -249
  32. package/src/__tests__/sandbox-host-parity.test.ts +6 -13
  33. package/src/__tests__/scoped-approval-grants.test.ts +6 -6
  34. package/src/__tests__/scoped-grant-security-matrix.test.ts +5 -4
  35. package/src/__tests__/script-proxy-session-manager.test.ts +1 -19
  36. package/src/__tests__/session-load-history-repair.test.ts +169 -2
  37. package/src/__tests__/session-runtime-assembly.test.ts +33 -5
  38. package/src/__tests__/skill-feature-flags-integration.test.ts +171 -0
  39. package/src/__tests__/skill-feature-flags.test.ts +188 -0
  40. package/src/__tests__/skill-load-feature-flag.test.ts +141 -0
  41. package/src/__tests__/skill-mirror-parity.test.ts +1 -0
  42. package/src/__tests__/skill-projection-feature-flag.test.ts +363 -0
  43. package/src/__tests__/system-prompt.test.ts +1 -1
  44. package/src/__tests__/terminal-sandbox.test.ts +142 -9
  45. package/src/__tests__/terminal-tools.test.ts +2 -93
  46. package/src/__tests__/thread-seed-composer.test.ts +18 -0
  47. package/src/__tests__/tool-approval-handler.test.ts +350 -0
  48. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +8 -10
  49. package/src/__tests__/voice-scoped-grant-consumer.test.ts +46 -84
  50. package/src/agent/loop.ts +36 -1
  51. package/src/approvals/approval-primitive.ts +381 -0
  52. package/src/approvals/guardian-decision-primitive.ts +191 -0
  53. package/src/calls/call-controller.ts +252 -209
  54. package/src/calls/call-domain.ts +44 -6
  55. package/src/calls/guardian-dispatch.ts +48 -0
  56. package/src/calls/types.ts +1 -1
  57. package/src/calls/voice-session-bridge.ts +46 -30
  58. package/src/cli/core-commands.ts +0 -4
  59. package/src/cli/mcp.ts +58 -0
  60. package/src/cli.ts +76 -34
  61. package/src/config/__tests__/feature-flag-registry-guard.test.ts +179 -0
  62. package/src/config/assistant-feature-flags.ts +162 -0
  63. package/src/config/bundled-skills/api-mapping/icon.svg +18 -0
  64. package/src/config/bundled-skills/messaging/TOOLS.json +30 -0
  65. package/src/config/bundled-skills/messaging/tools/slack-delete-message.ts +24 -0
  66. package/src/config/bundled-skills/notifications/SKILL.md +1 -1
  67. package/src/config/bundled-skills/reminder/SKILL.md +49 -2
  68. package/src/config/bundled-skills/time-based-actions/SKILL.md +49 -2
  69. package/src/config/bundled-skills/voice-setup/SKILL.md +122 -0
  70. package/src/config/core-schema.ts +1 -1
  71. package/src/config/env-registry.ts +10 -0
  72. package/src/config/feature-flag-registry.json +61 -0
  73. package/src/config/loader.ts +22 -1
  74. package/src/config/mcp-schema.ts +46 -0
  75. package/src/config/sandbox-schema.ts +0 -39
  76. package/src/config/schema.ts +18 -2
  77. package/src/config/skill-state.ts +34 -0
  78. package/src/config/skills-schema.ts +0 -1
  79. package/src/config/skills.ts +9 -0
  80. package/src/config/system-prompt.ts +110 -46
  81. package/src/config/templates/SOUL.md +1 -1
  82. package/src/config/types.ts +19 -1
  83. package/src/config/vellum-skills/catalog.json +1 -1
  84. package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +1 -0
  85. package/src/config/vellum-skills/sms-setup/SKILL.md +1 -1
  86. package/src/config/vellum-skills/telegram-setup/SKILL.md +6 -5
  87. package/src/config/vellum-skills/trusted-contacts/SKILL.md +105 -3
  88. package/src/config/vellum-skills/twilio-setup/SKILL.md +1 -1
  89. package/src/daemon/config-watcher.ts +0 -1
  90. package/src/daemon/daemon-control.ts +1 -1
  91. package/src/daemon/guardian-invite-intent.ts +124 -0
  92. package/src/daemon/handlers/avatar.ts +68 -0
  93. package/src/daemon/handlers/browser.ts +2 -2
  94. package/src/daemon/handlers/guardian-actions.ts +120 -0
  95. package/src/daemon/handlers/index.ts +4 -0
  96. package/src/daemon/handlers/sessions.ts +19 -0
  97. package/src/daemon/handlers/shared.ts +3 -1
  98. package/src/daemon/install-cli-launchers.ts +58 -13
  99. package/src/daemon/ipc-contract/guardian-actions.ts +53 -0
  100. package/src/daemon/ipc-contract/sessions.ts +8 -2
  101. package/src/daemon/ipc-contract/settings.ts +25 -2
  102. package/src/daemon/ipc-contract-inventory.json +10 -0
  103. package/src/daemon/ipc-contract.ts +4 -0
  104. package/src/daemon/lifecycle.ts +14 -2
  105. package/src/daemon/main.ts +1 -0
  106. package/src/daemon/providers-setup.ts +26 -1
  107. package/src/daemon/server.ts +1 -0
  108. package/src/daemon/session-lifecycle.ts +52 -7
  109. package/src/daemon/session-memory.ts +45 -0
  110. package/src/daemon/session-process.ts +258 -432
  111. package/src/daemon/session-runtime-assembly.ts +12 -0
  112. package/src/daemon/session-skill-tools.ts +14 -1
  113. package/src/daemon/session-tool-setup.ts +5 -0
  114. package/src/daemon/session.ts +11 -0
  115. package/src/daemon/shutdown-handlers.ts +11 -0
  116. package/src/daemon/tool-side-effects.ts +35 -9
  117. package/src/index.ts +2 -2
  118. package/src/mcp/client.ts +152 -0
  119. package/src/mcp/manager.ts +139 -0
  120. package/src/memory/conversation-display-order-migration.ts +44 -0
  121. package/src/memory/conversation-queries.ts +2 -0
  122. package/src/memory/conversation-store.ts +91 -0
  123. package/src/memory/db-init.ts +5 -1
  124. package/src/memory/embedding-local.ts +13 -8
  125. package/src/memory/guardian-action-store.ts +125 -2
  126. package/src/memory/ingress-invite-store.ts +95 -1
  127. package/src/memory/migrations/035-guardian-action-supersession.ts +23 -0
  128. package/src/memory/migrations/index.ts +2 -1
  129. package/src/memory/schema.ts +5 -1
  130. package/src/memory/scoped-approval-grants.ts +14 -5
  131. package/src/messaging/providers/slack/client.ts +12 -0
  132. package/src/messaging/providers/slack/types.ts +5 -0
  133. package/src/notifications/decision-engine.ts +49 -12
  134. package/src/notifications/emit-signal.ts +7 -0
  135. package/src/notifications/signal.ts +7 -0
  136. package/src/notifications/thread-seed-composer.ts +2 -1
  137. package/src/runtime/channel-approval-types.ts +16 -6
  138. package/src/runtime/channel-approvals.ts +19 -15
  139. package/src/runtime/channel-invite-transport.ts +85 -0
  140. package/src/runtime/channel-invite-transports/telegram.ts +105 -0
  141. package/src/runtime/guardian-action-grant-minter.ts +92 -35
  142. package/src/runtime/guardian-action-message-composer.ts +30 -0
  143. package/src/runtime/guardian-decision-types.ts +91 -0
  144. package/src/runtime/http-server.ts +23 -1
  145. package/src/runtime/ingress-service.ts +22 -0
  146. package/src/runtime/invite-redemption-service.ts +181 -0
  147. package/src/runtime/invite-redemption-templates.ts +39 -0
  148. package/src/runtime/routes/call-routes.ts +2 -1
  149. package/src/runtime/routes/guardian-action-routes.ts +206 -0
  150. package/src/runtime/routes/guardian-approval-interception.ts +66 -190
  151. package/src/runtime/routes/identity-routes.ts +73 -0
  152. package/src/runtime/routes/inbound-message-handler.ts +486 -394
  153. package/src/runtime/routes/pairing-routes.ts +4 -0
  154. package/src/security/encrypted-store.ts +31 -17
  155. package/src/security/keychain.ts +176 -2
  156. package/src/security/secure-keys.ts +97 -0
  157. package/src/security/tool-approval-digest.ts +1 -1
  158. package/src/tools/browser/browser-execution.ts +2 -2
  159. package/src/tools/browser/browser-manager.ts +46 -32
  160. package/src/tools/browser/browser-screencast.ts +2 -2
  161. package/src/tools/calls/call-start.ts +1 -1
  162. package/src/tools/executor.ts +22 -17
  163. package/src/tools/mcp/mcp-tool-factory.ts +100 -0
  164. package/src/tools/network/script-proxy/session-manager.ts +1 -5
  165. package/src/tools/registry.ts +64 -1
  166. package/src/tools/skills/load.ts +22 -8
  167. package/src/tools/system/avatar-generator.ts +119 -0
  168. package/src/tools/system/navigate-settings.ts +65 -0
  169. package/src/tools/system/open-system-settings.ts +75 -0
  170. package/src/tools/system/voice-config.ts +121 -32
  171. package/src/tools/terminal/backends/native.ts +40 -19
  172. package/src/tools/terminal/backends/types.ts +3 -3
  173. package/src/tools/terminal/parser.ts +1 -1
  174. package/src/tools/terminal/sandbox-diagnostics.ts +6 -87
  175. package/src/tools/terminal/sandbox.ts +1 -12
  176. package/src/tools/terminal/shell.ts +3 -31
  177. package/src/tools/tool-approval-handler.ts +141 -3
  178. package/src/tools/tool-manifest.ts +6 -0
  179. package/src/tools/types.ts +10 -2
  180. package/src/util/diff.ts +36 -13
  181. package/Dockerfile.sandbox +0 -5
  182. package/src/__tests__/doordash-client.test.ts +0 -187
  183. package/src/__tests__/doordash-session.test.ts +0 -154
  184. package/src/__tests__/signup-e2e.test.ts +0 -354
  185. package/src/__tests__/terminal-sandbox-docker.test.ts +0 -1065
  186. package/src/__tests__/terminal-sandbox.integration.test.ts +0 -180
  187. package/src/cli/doordash.ts +0 -1057
  188. package/src/config/bundled-skills/doordash/SKILL.md +0 -163
  189. package/src/config/templates/LOOKS.md +0 -25
  190. package/src/doordash/cart-queries.ts +0 -787
  191. package/src/doordash/client.ts +0 -1016
  192. package/src/doordash/order-queries.ts +0 -85
  193. package/src/doordash/queries.ts +0 -13
  194. package/src/doordash/query-extractor.ts +0 -94
  195. package/src/doordash/search-queries.ts +0 -203
  196. package/src/doordash/session.ts +0 -84
  197. package/src/doordash/store-queries.ts +0 -246
  198. package/src/doordash/types.ts +0 -367
  199. package/src/tools/terminal/backends/docker.ts +0 -379
@@ -0,0 +1,381 @@
1
+ /**
2
+ * Unified approval primitive for scoped approval grants.
3
+ *
4
+ * All producers (voice guardian-action minter, channel guardian approval
5
+ * interception) and consumers (tool executor grant checks) must go through
6
+ * this module instead of calling the storage layer directly. This enforces
7
+ * all scope constraints in one place and provides structured logging for
8
+ * mint/consume hit/miss diagnostics.
9
+ *
10
+ * Storage remains in `scoped_approval_grants` via the existing CRUD module;
11
+ * this primitive wraps that layer with a unified API surface.
12
+ */
13
+
14
+ import {
15
+ _internal,
16
+ type ConsumeByRequestIdResult,
17
+ type ConsumeByToolSignatureResult,
18
+ type ScopedApprovalGrant,
19
+ } from '../memory/scoped-approval-grants.js';
20
+
21
+ const { createScopedApprovalGrant, consumeScopedApprovalGrantByRequestId, consumeScopedApprovalGrantByToolSignature } = _internal;
22
+ import { getLogger } from '../util/logger.js';
23
+
24
+ const log = getLogger('approval-primitive');
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Mint
28
+ // ---------------------------------------------------------------------------
29
+
30
+ export interface MintGrantParams {
31
+ assistantId: string;
32
+ scopeMode: 'request_id' | 'tool_signature';
33
+ requestId?: string | null;
34
+ toolName?: string | null;
35
+ inputDigest?: string | null;
36
+ requestChannel: string;
37
+ decisionChannel: string;
38
+ executionChannel?: string | null;
39
+ conversationId?: string | null;
40
+ callSessionId?: string | null;
41
+ requesterExternalUserId?: string | null;
42
+ guardianExternalUserId?: string | null;
43
+ expiresAt: string;
44
+ }
45
+
46
+ export type MintGrantResult =
47
+ | { ok: true; grant: ScopedApprovalGrant }
48
+ | { ok: false; reason: 'missing_request_id' | 'missing_tool_fields' | 'storage_error'; error?: unknown };
49
+
50
+ /**
51
+ * Mint a scoped approval grant from a guardian decision.
52
+ *
53
+ * Validates scope-mode-specific field requirements before delegating to the
54
+ * storage layer:
55
+ * - `request_id` scope requires a non-null `requestId`.
56
+ * - `tool_signature` scope requires both `toolName` and `inputDigest`.
57
+ *
58
+ * Returns a discriminated result so callers can inspect failure reasons
59
+ * without catching exceptions.
60
+ */
61
+ export function mintGrantFromDecision(params: MintGrantParams): MintGrantResult {
62
+ // Scope-mode field validation
63
+ if (params.scopeMode === 'request_id' && !params.requestId) {
64
+ log.warn(
65
+ {
66
+ event: 'approval_primitive_mint_rejected',
67
+ reason: 'missing_request_id',
68
+ scopeMode: params.scopeMode,
69
+ assistantId: params.assistantId,
70
+ requestChannel: params.requestChannel,
71
+ decisionChannel: params.decisionChannel,
72
+ },
73
+ 'Mint rejected: request_id scope requires a non-null requestId',
74
+ );
75
+ return { ok: false, reason: 'missing_request_id' };
76
+ }
77
+
78
+ if (params.scopeMode === 'tool_signature' && (!params.toolName || !params.inputDigest)) {
79
+ log.warn(
80
+ {
81
+ event: 'approval_primitive_mint_rejected',
82
+ reason: 'missing_tool_fields',
83
+ scopeMode: params.scopeMode,
84
+ toolName: params.toolName ?? null,
85
+ inputDigest: params.inputDigest ?? null,
86
+ assistantId: params.assistantId,
87
+ requestChannel: params.requestChannel,
88
+ decisionChannel: params.decisionChannel,
89
+ },
90
+ 'Mint rejected: tool_signature scope requires both toolName and inputDigest',
91
+ );
92
+ return { ok: false, reason: 'missing_tool_fields' };
93
+ }
94
+
95
+ try {
96
+ const grant = createScopedApprovalGrant({
97
+ assistantId: params.assistantId,
98
+ scopeMode: params.scopeMode,
99
+ requestId: params.requestId ?? null,
100
+ toolName: params.toolName ?? null,
101
+ inputDigest: params.inputDigest ?? null,
102
+ requestChannel: params.requestChannel,
103
+ decisionChannel: params.decisionChannel,
104
+ executionChannel: params.executionChannel ?? null,
105
+ conversationId: params.conversationId ?? null,
106
+ callSessionId: params.callSessionId ?? null,
107
+ requesterExternalUserId: params.requesterExternalUserId ?? null,
108
+ guardianExternalUserId: params.guardianExternalUserId ?? null,
109
+ expiresAt: params.expiresAt,
110
+ });
111
+
112
+ log.info(
113
+ {
114
+ event: 'approval_primitive_mint_success',
115
+ grantId: grant.id,
116
+ scopeMode: params.scopeMode,
117
+ toolName: params.toolName ?? null,
118
+ requestId: params.requestId ?? null,
119
+ assistantId: params.assistantId,
120
+ requestChannel: params.requestChannel,
121
+ decisionChannel: params.decisionChannel,
122
+ conversationId: params.conversationId ?? null,
123
+ callSessionId: params.callSessionId ?? null,
124
+ expiresAt: params.expiresAt,
125
+ },
126
+ 'Approval grant minted',
127
+ );
128
+
129
+ return { ok: true, grant };
130
+ } catch (error) {
131
+ log.error(
132
+ {
133
+ event: 'approval_primitive_mint_error',
134
+ scopeMode: params.scopeMode,
135
+ toolName: params.toolName ?? null,
136
+ assistantId: params.assistantId,
137
+ err: error,
138
+ },
139
+ 'Failed to mint approval grant (storage error)',
140
+ );
141
+ return { ok: false, reason: 'storage_error', error };
142
+ }
143
+ }
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // Consume
147
+ // ---------------------------------------------------------------------------
148
+
149
+ export interface ConsumeByRequestIdParams {
150
+ requestId: string;
151
+ consumingRequestId: string;
152
+ assistantId: string;
153
+ now?: string;
154
+ }
155
+
156
+ export interface ConsumeByToolSignatureParams {
157
+ toolName: string;
158
+ inputDigest: string;
159
+ consumingRequestId: string;
160
+ assistantId?: string;
161
+ executionChannel?: string;
162
+ conversationId?: string;
163
+ callSessionId?: string;
164
+ requesterExternalUserId?: string;
165
+ now?: string;
166
+ }
167
+
168
+ export type ConsumeGrantResult =
169
+ | { ok: true; grant: ScopedApprovalGrant }
170
+ | { ok: false; reason: 'no_match' | 'scope_mismatch' | 'expired' | 'already_consumed' | 'aborted' };
171
+
172
+ export interface ConsumeGrantParams {
173
+ requestId?: string;
174
+ toolName: string;
175
+ inputDigest: string;
176
+ consumingRequestId: string;
177
+ assistantId: string;
178
+ executionChannel?: string;
179
+ conversationId?: string;
180
+ callSessionId?: string;
181
+ requesterExternalUserId?: string;
182
+ now?: string;
183
+ }
184
+
185
+ /**
186
+ * Single synchronous attempt to consume a scoped approval grant.
187
+ *
188
+ * Tries `request_id` mode first when a requestId is provided, then falls
189
+ * back to `tool_signature` mode. This mirrors the priority ordering at
190
+ * the consume site: an exact request-bound grant takes precedence over a
191
+ * tool-signature grant.
192
+ *
193
+ * This is an internal helper — callers should use {@link consumeGrantForInvocation}
194
+ * which adds retry polling to handle the voice pipeline race condition.
195
+ */
196
+ function consumeGrantSync(params: ConsumeGrantParams): ConsumeGrantResult {
197
+ // Try request_id mode first when a requestId is provided
198
+ if (params.requestId) {
199
+ const reqResult: ConsumeByRequestIdResult = consumeScopedApprovalGrantByRequestId(
200
+ params.requestId,
201
+ params.consumingRequestId,
202
+ params.assistantId,
203
+ params.now,
204
+ );
205
+
206
+ if (reqResult.ok && reqResult.grant) {
207
+ log.info(
208
+ {
209
+ event: 'approval_primitive_consume_hit',
210
+ mode: 'request_id',
211
+ grantId: reqResult.grant.id,
212
+ requestId: params.requestId,
213
+ consumingRequestId: params.consumingRequestId,
214
+ assistantId: params.assistantId,
215
+ toolName: params.toolName,
216
+ },
217
+ 'Approval grant consumed via request_id',
218
+ );
219
+ return { ok: true, grant: reqResult.grant };
220
+ }
221
+
222
+ log.info(
223
+ {
224
+ event: 'approval_primitive_consume_miss',
225
+ mode: 'request_id',
226
+ reason: 'no_match',
227
+ requestId: params.requestId,
228
+ consumingRequestId: params.consumingRequestId,
229
+ assistantId: params.assistantId,
230
+ toolName: params.toolName,
231
+ },
232
+ 'No request_id grant match, falling through to tool_signature',
233
+ );
234
+ }
235
+
236
+ // Fall back to tool_signature mode
237
+ const sigResult: ConsumeByToolSignatureResult = consumeScopedApprovalGrantByToolSignature({
238
+ toolName: params.toolName,
239
+ inputDigest: params.inputDigest,
240
+ consumingRequestId: params.consumingRequestId,
241
+ assistantId: params.assistantId,
242
+ executionChannel: params.executionChannel,
243
+ conversationId: params.conversationId,
244
+ callSessionId: params.callSessionId,
245
+ requesterExternalUserId: params.requesterExternalUserId,
246
+ now: params.now,
247
+ });
248
+
249
+ if (sigResult.ok && sigResult.grant) {
250
+ log.info(
251
+ {
252
+ event: 'approval_primitive_consume_hit',
253
+ mode: 'tool_signature',
254
+ grantId: sigResult.grant.id,
255
+ toolName: params.toolName,
256
+ consumingRequestId: params.consumingRequestId,
257
+ assistantId: params.assistantId,
258
+ conversationId: params.conversationId ?? null,
259
+ callSessionId: params.callSessionId ?? null,
260
+ },
261
+ 'Approval grant consumed via tool_signature',
262
+ );
263
+ return { ok: true, grant: sigResult.grant };
264
+ }
265
+
266
+ log.info(
267
+ {
268
+ event: 'approval_primitive_consume_miss',
269
+ mode: 'tool_signature',
270
+ reason: 'no_match',
271
+ toolName: params.toolName,
272
+ consumingRequestId: params.consumingRequestId,
273
+ assistantId: params.assistantId,
274
+ conversationId: params.conversationId ?? null,
275
+ callSessionId: params.callSessionId ?? null,
276
+ executionChannel: params.executionChannel ?? null,
277
+ },
278
+ 'No tool_signature grant match found',
279
+ );
280
+
281
+ return { ok: false, reason: 'no_match' };
282
+ }
283
+
284
+ // ---------------------------------------------------------------------------
285
+ // Public consume API (with retry for voice pipeline race condition)
286
+ // ---------------------------------------------------------------------------
287
+
288
+ /** Default polling interval for grant retry (ms). */
289
+ const GRANT_RETRY_INTERVAL_MS = 250;
290
+ /** Default maximum wait time for grant retry (ms). */
291
+ const GRANT_RETRY_MAX_WAIT_MS = 10_000;
292
+
293
+ /**
294
+ * Consume a scoped approval grant for a tool invocation.
295
+ *
296
+ * Performs a synchronous lookup first and returns immediately when a
297
+ * matching grant exists. When the first attempt misses, retries with
298
+ * polling to handle the voice pipeline race condition where the grant
299
+ * may still be in-flight: `answerCall()` triggers the voice turn as
300
+ * fire-and-forget, and the voice LLM can attempt tool execution before
301
+ * `tryMintGuardianActionGrant`'s LLM fallback finishes minting the
302
+ * grant. Polling bridges this timing gap without changing the
303
+ * fire-and-forget architecture.
304
+ */
305
+ export async function consumeGrantForInvocation(
306
+ params: ConsumeGrantParams,
307
+ options?: { maxWaitMs?: number; intervalMs?: number; signal?: AbortSignal },
308
+ ): Promise<ConsumeGrantResult> {
309
+ // Fast path: try once synchronously — covers the common case where the
310
+ // grant already exists (deterministic classifier, or prior turns).
311
+ const first = consumeGrantSync(params);
312
+ if (first.ok) {
313
+ return first;
314
+ }
315
+
316
+ // When maxWaitMs is 0, skip retry entirely — used by non-voice channels
317
+ // where grant-minting race conditions don't apply.
318
+ const maxWait = options?.maxWaitMs ?? GRANT_RETRY_MAX_WAIT_MS;
319
+ if (maxWait <= 0) {
320
+ return first;
321
+ }
322
+
323
+ const interval = options?.intervalMs ?? GRANT_RETRY_INTERVAL_MS;
324
+ const deadline = Date.now() + maxWait;
325
+
326
+ log.info(
327
+ {
328
+ event: 'approval_primitive_consume_retry_start',
329
+ toolName: params.toolName,
330
+ consumingRequestId: params.consumingRequestId,
331
+ maxWaitMs: maxWait,
332
+ intervalMs: interval,
333
+ },
334
+ 'Grant not found on first attempt; starting retry polling',
335
+ );
336
+
337
+ const signal = options?.signal;
338
+
339
+ while (Date.now() < deadline) {
340
+ // Exit promptly on cancellation (e.g. voice barge-in) so the session
341
+ // can tear down the current turn without waiting for the full timeout.
342
+ // Returns 'aborted' (not 'no_match') so callers can distinguish
343
+ // cancellation from a genuine grant miss.
344
+ if (signal?.aborted) {
345
+ return { ok: false, reason: 'aborted' };
346
+ }
347
+
348
+ await new Promise((resolve) => setTimeout(resolve, interval));
349
+
350
+ if (signal?.aborted) {
351
+ return { ok: false, reason: 'aborted' };
352
+ }
353
+
354
+ const result = consumeGrantSync(params);
355
+ if (result.ok) {
356
+ log.info(
357
+ {
358
+ event: 'approval_primitive_consume_retry_hit',
359
+ toolName: params.toolName,
360
+ consumingRequestId: params.consumingRequestId,
361
+ grantId: result.grant.id,
362
+ elapsedMs: maxWait - (deadline - Date.now()),
363
+ },
364
+ 'Grant found after retry polling',
365
+ );
366
+ return result;
367
+ }
368
+ }
369
+
370
+ log.info(
371
+ {
372
+ event: 'approval_primitive_consume_retry_timeout',
373
+ toolName: params.toolName,
374
+ consumingRequestId: params.consumingRequestId,
375
+ maxWaitMs: maxWait,
376
+ },
377
+ 'Grant retry polling timed out — no matching grant found',
378
+ );
379
+
380
+ return { ok: false, reason: 'no_match' };
381
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Unified guardian decision primitive.
3
+ *
4
+ * All guardian decision entrypoints (callback buttons, conversational engine,
5
+ * legacy parser, requester self-cancel) call through this module instead of
6
+ * inlining the decision-application logic. This centralizes:
7
+ *
8
+ * 1. `approve_always` downgrade for guardian-on-behalf requests
9
+ * 2. Identity validation (actor must match assigned guardian)
10
+ * 3. Approval-info capture before the pending interaction is consumed
11
+ * 4. Atomic decision application via `handleChannelDecision`
12
+ * 5. Guardian approval record update
13
+ * 6. Scoped grant minting on approve
14
+ *
15
+ * Security invariants enforced here:
16
+ * - Decision application is identity-bound to expected guardian identity
17
+ * - Decisions are first-response-wins (CAS-like stale protection)
18
+ * - `approve_always` is rejected/downgraded for guardian-on-behalf requests
19
+ * - Scoped grant minting only on explicit approve for requests with tool metadata
20
+ */
21
+
22
+ import type { ChannelId } from '../channels/types.js';
23
+ import {
24
+ type GuardianApprovalRequest,
25
+ updateApprovalDecision,
26
+ } from '../memory/channel-guardian-store.js';
27
+ import type {
28
+ ApprovalDecisionResult,
29
+ } from '../runtime/channel-approval-types.js';
30
+ import {
31
+ getApprovalInfoByConversation,
32
+ handleChannelDecision,
33
+ type PendingApprovalInfo,
34
+ } from '../runtime/channel-approvals.js';
35
+ import type { ApplyGuardianDecisionResult } from '../runtime/guardian-decision-types.js';
36
+ import { computeToolApprovalDigest } from '../security/tool-approval-digest.js';
37
+ import { getLogger } from '../util/logger.js';
38
+ import { mintGrantFromDecision } from './approval-primitive.js';
39
+
40
+ const log = getLogger('guardian-decision-primitive');
41
+
42
+ /** TTL for scoped approval grants minted on guardian approve_once decisions. */
43
+ export const GRANT_TTL_MS = 5 * 60 * 1000;
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Scoped grant minting
47
+ // ---------------------------------------------------------------------------
48
+
49
+ /**
50
+ * Mint a `tool_signature` scoped grant when a guardian approves a tool-approval
51
+ * request. Only mints when the approval info contains a tool invocation with
52
+ * input (so we can compute the input digest). Informational ASK_GUARDIAN
53
+ * requests that lack tool input are skipped.
54
+ *
55
+ * Fails silently on error -- grant minting is best-effort and must never block
56
+ * the approval flow.
57
+ */
58
+ export function tryMintToolApprovalGrant(params: {
59
+ approvalInfo: PendingApprovalInfo;
60
+ approval: GuardianApprovalRequest;
61
+ decisionChannel: ChannelId;
62
+ guardianExternalUserId: string;
63
+ }): void {
64
+ const { approvalInfo, approval, decisionChannel, guardianExternalUserId } = params;
65
+
66
+ if (!approvalInfo.toolName) {
67
+ return;
68
+ }
69
+
70
+ let inputDigest: string;
71
+ try {
72
+ inputDigest = computeToolApprovalDigest(approvalInfo.toolName, approvalInfo.input);
73
+ } catch (err) {
74
+ log.error(
75
+ { err, toolName: approvalInfo.toolName, conversationId: approval.conversationId },
76
+ 'Failed to compute tool approval digest for grant minting (non-fatal)',
77
+ );
78
+ return;
79
+ }
80
+
81
+ const result = mintGrantFromDecision({
82
+ assistantId: approval.assistantId,
83
+ scopeMode: 'tool_signature',
84
+ toolName: approvalInfo.toolName,
85
+ inputDigest,
86
+ requestChannel: approval.channel,
87
+ decisionChannel,
88
+ executionChannel: null,
89
+ conversationId: approval.conversationId,
90
+ callSessionId: null,
91
+ guardianExternalUserId,
92
+ requesterExternalUserId: approval.requesterExternalUserId,
93
+ expiresAt: new Date(Date.now() + GRANT_TTL_MS).toISOString(),
94
+ });
95
+
96
+ if (result.ok) {
97
+ log.info(
98
+ { toolName: approvalInfo.toolName, conversationId: approval.conversationId },
99
+ 'Minted scoped approval grant for guardian tool-approval decision',
100
+ );
101
+ } else {
102
+ log.error(
103
+ { reason: result.reason, toolName: approvalInfo.toolName, conversationId: approval.conversationId },
104
+ 'Failed to mint scoped approval grant (non-fatal)',
105
+ );
106
+ }
107
+ }
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // Apply guardian decision (unified primitive)
111
+ // ---------------------------------------------------------------------------
112
+
113
+ export interface ApplyGuardianDecisionParams {
114
+ /** The guardian approval record from the store. */
115
+ approval: GuardianApprovalRequest;
116
+ /** The parsed decision (action + source + optional requestId). */
117
+ decision: ApprovalDecisionResult;
118
+ /** External user ID of the actor making the decision. */
119
+ actorExternalUserId: string | undefined;
120
+ /** Channel the decision arrived on. */
121
+ actorChannel: ChannelId;
122
+ /** Optional decision context passed to handleChannelDecision. */
123
+ decisionContext?: string;
124
+ }
125
+
126
+ /**
127
+ * Apply a guardian decision through the unified primitive.
128
+ *
129
+ * This function centralizes the core logic that was previously duplicated
130
+ * across callback, conversational engine, legacy parser, and requester
131
+ * self-cancel paths:
132
+ *
133
+ * 1. Downgrade `approve_always` to `approve_once` (guardians cannot
134
+ * permanently allowlist tools on behalf of requesters)
135
+ * 2. Capture pending approval info before resolution
136
+ * 3. Apply the decision atomically via `handleChannelDecision`
137
+ * 4. Update the guardian approval record
138
+ * 5. Mint a scoped grant on approve
139
+ *
140
+ * Returns a structured result so callers can handle stale/race outcomes.
141
+ */
142
+ export function applyGuardianDecision(params: ApplyGuardianDecisionParams): ApplyGuardianDecisionResult {
143
+ const { approval, decision, actorExternalUserId, actorChannel, decisionContext } = params;
144
+
145
+ // Guardians cannot approve_always on behalf of requesters -- downgrade.
146
+ const effectiveDecision: ApprovalDecisionResult = decision.action === 'approve_always'
147
+ ? { ...decision, action: 'approve_once' }
148
+ : decision;
149
+
150
+ // Capture pending approval info before handleChannelDecision resolves
151
+ // (and removes) the pending interaction. Needed for grant minting.
152
+ const approvalInfo = getApprovalInfoByConversation(approval.conversationId);
153
+ const matchedInfo = effectiveDecision.requestId
154
+ ? approvalInfo.find(a => a.requestId === effectiveDecision.requestId)
155
+ : approvalInfo[0];
156
+
157
+ // Apply the decision to the underlying session
158
+ const result = handleChannelDecision(
159
+ approval.conversationId,
160
+ effectiveDecision,
161
+ decisionContext,
162
+ );
163
+
164
+ if (!result.applied) {
165
+ return { applied: false, reason: 'stale', requestId: effectiveDecision.requestId };
166
+ }
167
+
168
+ // Update the guardian approval request record
169
+ const approvalStatus = effectiveDecision.action === 'reject' ? 'denied' as const : 'approved' as const;
170
+ updateApprovalDecision(approval.id, {
171
+ status: approvalStatus,
172
+ decidedByExternalUserId: actorExternalUserId,
173
+ });
174
+
175
+ // Mint a scoped grant when a guardian approves a tool-approval request.
176
+ // Skip when actorExternalUserId is undefined -- minting a grant without
177
+ // a known guardian identity is meaningless (e.g. requester self-cancel).
178
+ if (effectiveDecision.action !== 'reject' && matchedInfo && actorExternalUserId) {
179
+ tryMintToolApprovalGrant({
180
+ approvalInfo: matchedInfo,
181
+ approval,
182
+ decisionChannel: actorChannel,
183
+ guardianExternalUserId: actorExternalUserId,
184
+ });
185
+ }
186
+
187
+ return {
188
+ applied: true,
189
+ requestId: result.requestId,
190
+ };
191
+ }