@vellumai/assistant 0.3.26 → 0.3.28

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 (82) hide show
  1. package/ARCHITECTURE.md +48 -1
  2. package/Dockerfile +2 -2
  3. package/package.json +1 -1
  4. package/scripts/ipc/generate-swift.ts +6 -2
  5. package/src/__tests__/agent-loop.test.ts +119 -0
  6. package/src/__tests__/bundled-asset.test.ts +107 -0
  7. package/src/__tests__/canonical-guardian-store.test.ts +636 -0
  8. package/src/__tests__/channel-approval-routes.test.ts +174 -1
  9. package/src/__tests__/emit-signal-routing-intent.test.ts +43 -1
  10. package/src/__tests__/guardian-actions-endpoint.test.ts +205 -345
  11. package/src/__tests__/guardian-decision-primitive-canonical.test.ts +599 -0
  12. package/src/__tests__/guardian-dispatch.test.ts +19 -19
  13. package/src/__tests__/guardian-routing-invariants.test.ts +954 -0
  14. package/src/__tests__/mcp-cli.test.ts +77 -0
  15. package/src/__tests__/non-member-access-request.test.ts +31 -29
  16. package/src/__tests__/notification-decision-fallback.test.ts +61 -3
  17. package/src/__tests__/notification-decision-strategy.test.ts +17 -0
  18. package/src/__tests__/notification-guardian-path.test.ts +13 -15
  19. package/src/__tests__/onboarding-template-contract.test.ts +116 -21
  20. package/src/__tests__/secret-scanner-executor.test.ts +59 -0
  21. package/src/__tests__/secret-scanner.test.ts +8 -0
  22. package/src/__tests__/sensitive-output-placeholders.test.ts +208 -0
  23. package/src/__tests__/session-runtime-assembly.test.ts +76 -47
  24. package/src/__tests__/tool-grant-request-escalation.test.ts +497 -0
  25. package/src/agent/loop.ts +46 -3
  26. package/src/approvals/guardian-decision-primitive.ts +285 -0
  27. package/src/approvals/guardian-request-resolvers.ts +539 -0
  28. package/src/calls/guardian-dispatch.ts +46 -40
  29. package/src/calls/relay-server.ts +147 -2
  30. package/src/calls/types.ts +1 -1
  31. package/src/config/system-prompt.ts +2 -1
  32. package/src/config/templates/BOOTSTRAP.md +47 -31
  33. package/src/config/templates/USER.md +5 -0
  34. package/src/config/update-bulletin-template-path.ts +4 -1
  35. package/src/config/vellum-skills/trusted-contacts/SKILL.md +22 -17
  36. package/src/daemon/handlers/guardian-actions.ts +45 -66
  37. package/src/daemon/ipc-contract/guardian-actions.ts +7 -0
  38. package/src/daemon/lifecycle.ts +3 -16
  39. package/src/daemon/server.ts +18 -0
  40. package/src/daemon/session-agent-loop-handlers.ts +5 -4
  41. package/src/daemon/session-agent-loop.ts +32 -5
  42. package/src/daemon/session-process.ts +68 -307
  43. package/src/daemon/session-runtime-assembly.ts +112 -24
  44. package/src/daemon/session-tool-setup.ts +1 -0
  45. package/src/daemon/session.ts +1 -0
  46. package/src/home-base/prebuilt/seed.ts +2 -1
  47. package/src/hooks/templates.ts +2 -1
  48. package/src/memory/canonical-guardian-store.ts +524 -0
  49. package/src/memory/channel-guardian-store.ts +1 -0
  50. package/src/memory/db-init.ts +16 -0
  51. package/src/memory/guardian-action-store.ts +7 -60
  52. package/src/memory/guardian-approvals.ts +9 -4
  53. package/src/memory/migrations/036-normalize-phone-identities.ts +289 -0
  54. package/src/memory/migrations/118-reminder-routing-intent.ts +3 -3
  55. package/src/memory/migrations/121-canonical-guardian-requests.ts +59 -0
  56. package/src/memory/migrations/122-canonical-guardian-requester-chat-id.ts +15 -0
  57. package/src/memory/migrations/123-canonical-guardian-deliveries-destination-index.ts +15 -0
  58. package/src/memory/migrations/index.ts +4 -0
  59. package/src/memory/migrations/registry.ts +5 -0
  60. package/src/memory/schema-migration.ts +1 -0
  61. package/src/memory/schema.ts +52 -0
  62. package/src/notifications/copy-composer.ts +16 -4
  63. package/src/notifications/decision-engine.ts +57 -0
  64. package/src/permissions/defaults.ts +2 -0
  65. package/src/runtime/access-request-helper.ts +137 -0
  66. package/src/runtime/actor-trust-resolver.ts +225 -0
  67. package/src/runtime/channel-guardian-service.ts +12 -4
  68. package/src/runtime/guardian-context-resolver.ts +32 -7
  69. package/src/runtime/guardian-decision-types.ts +6 -0
  70. package/src/runtime/guardian-reply-router.ts +687 -0
  71. package/src/runtime/http-server.ts +8 -0
  72. package/src/runtime/routes/canonical-guardian-expiry-sweep.ts +116 -0
  73. package/src/runtime/routes/conversation-routes.ts +18 -0
  74. package/src/runtime/routes/guardian-action-routes.ts +100 -109
  75. package/src/runtime/routes/inbound-message-handler.ts +170 -525
  76. package/src/runtime/tool-grant-request-helper.ts +195 -0
  77. package/src/tools/executor.ts +13 -1
  78. package/src/tools/sensitive-output-placeholders.ts +203 -0
  79. package/src/tools/tool-approval-handler.ts +44 -1
  80. package/src/tools/types.ts +11 -0
  81. package/src/util/bundled-asset.ts +31 -0
  82. package/src/util/canonicalize-identity.ts +52 -0
@@ -0,0 +1,539 @@
1
+ /**
2
+ * Resolver registry for canonical guardian requests.
3
+ *
4
+ * Dispatches to kind-specific resolvers after the unified decision primitive
5
+ * has validated identity, status, and performed CAS resolution. Each
6
+ * resolver adapts the existing side-effect logic (channel approval handling,
7
+ * voice call answer delivery) to the canonical request domain.
8
+ *
9
+ * The registry is intentionally a simple Map keyed by request kind. New
10
+ * request kinds (access_request, etc.) can register resolvers here without
11
+ * touching the core decision primitive.
12
+ */
13
+
14
+ import { answerCall } from '../calls/call-domain.js';
15
+ import type { CanonicalGuardianRequest } from '../memory/canonical-guardian-store.js';
16
+ import { emitNotificationSignal } from '../notifications/emit-signal.js';
17
+ import { addRule } from '../permissions/trust-store.js';
18
+ import type { ApprovalAction } from '../runtime/channel-approval-types.js';
19
+ import { createOutboundSession } from '../runtime/channel-guardian-service.js';
20
+ import { deliverChannelReply } from '../runtime/gateway-client.js';
21
+ import * as pendingInteractions from '../runtime/pending-interactions.js';
22
+ import { getTool } from '../tools/registry.js';
23
+ import { getLogger } from '../util/logger.js';
24
+
25
+ const log = getLogger('guardian-request-resolvers');
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Types
29
+ // ---------------------------------------------------------------------------
30
+
31
+ /** Actor context for the entity making the decision. */
32
+ export interface ActorContext {
33
+ /** External user ID of the deciding actor (undefined for desktop/trusted). */
34
+ externalUserId: string | undefined;
35
+ /** Channel the decision arrived on. */
36
+ channel: string;
37
+ /** Whether the actor is a trusted/desktop context. */
38
+ isTrusted: boolean;
39
+ }
40
+
41
+ /** The decision being applied. */
42
+ export interface ResolverDecision {
43
+ /** The effective action after any downgrade (e.g. approve_always -> approve_once). */
44
+ action: ApprovalAction;
45
+ /** Optional user-supplied text (e.g. answer text for pending questions). */
46
+ userText?: string;
47
+ }
48
+
49
+ /** Channel delivery context for resolvers that need to send messages. */
50
+ export interface ChannelDeliveryContext {
51
+ /** URL to POST channel replies to. */
52
+ replyCallbackUrl: string;
53
+ /** Chat ID of the guardian receiving the reply. */
54
+ guardianChatId: string;
55
+ /** Assistant ID for attribution. */
56
+ assistantId: string;
57
+ /** Optional bearer token for authenticated delivery. */
58
+ bearerToken?: string;
59
+ }
60
+
61
+ /** Context passed to each resolver after CAS resolution succeeds. */
62
+ export interface ResolverContext {
63
+ /** The canonical request record (already resolved to its terminal status). */
64
+ request: CanonicalGuardianRequest;
65
+ /** The decision being applied. */
66
+ decision: ResolverDecision;
67
+ /** Actor context for the entity making the decision. */
68
+ actor: ActorContext;
69
+ /** Optional channel delivery context — present when the decision arrived via a channel message. */
70
+ channelDeliveryContext?: ChannelDeliveryContext;
71
+ }
72
+
73
+ /** Discriminated result from a resolver. */
74
+ export type ResolverResult =
75
+ | { ok: true; applied: true; grantMinted?: boolean }
76
+ | { ok: false; reason: string };
77
+
78
+ /** Interface that kind-specific resolvers implement. */
79
+ export interface GuardianRequestResolver {
80
+ /** The request kind this resolver handles (matches canonical_guardian_requests.kind). */
81
+ kind: string;
82
+ /** Execute kind-specific side effects after CAS resolution. */
83
+ resolve(context: ResolverContext): Promise<ResolverResult>;
84
+ }
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Resolver implementations
88
+ // ---------------------------------------------------------------------------
89
+
90
+ /**
91
+ * Resolves `tool_approval` requests — the channel/desktop approval path.
92
+ *
93
+ * Adapts the existing `handleChannelDecision` logic: looks up the pending
94
+ * interaction by conversation ID, maps the canonical decision to the
95
+ * session's confirmation response, and resolves the interaction.
96
+ *
97
+ * Side effects are deferred to callers that wire into existing channel
98
+ * approval infrastructure. This resolver focuses on validating that the
99
+ * request shape is appropriate for tool_approval handling.
100
+ */
101
+ const pendingInteractionResolver: GuardianRequestResolver = {
102
+ kind: 'tool_approval',
103
+
104
+ async resolve(ctx: ResolverContext): Promise<ResolverResult> {
105
+ const { request, decision } = ctx;
106
+
107
+ if (!request.conversationId) {
108
+ return { ok: false, reason: 'tool_approval request missing conversationId' };
109
+ }
110
+
111
+ // Look up the pending interaction directly by requestId.
112
+ const interaction = pendingInteractions.get(request.id);
113
+ if (!interaction) {
114
+ // The pending interaction was already consumed (stale) or not found.
115
+ // The canonical CAS already committed, so this is not an error — just
116
+ // means the interaction was resolved by another path (e.g. timeout).
117
+ log.warn(
118
+ {
119
+ event: 'resolver_tool_approval_stale',
120
+ requestId: request.id,
121
+ conversationId: request.conversationId,
122
+ },
123
+ 'Tool approval resolver: pending interaction not found (already consumed or timed out)',
124
+ );
125
+ return { ok: false, reason: 'pending_interaction_not_found' };
126
+ }
127
+
128
+ // Handle approve_always: persist a trust rule when the confirmation
129
+ // explicitly allows persistence and provides explicit options.
130
+ if (decision.action === 'approve_always' || decision.action === 'approve_once') {
131
+ const details = interaction.confirmationDetails;
132
+ if (
133
+ decision.action === 'approve_always' &&
134
+ details &&
135
+ details.persistentDecisionsAllowed !== false &&
136
+ details.allowlistOptions?.length &&
137
+ details.scopeOptions?.length
138
+ ) {
139
+ const pattern = details.allowlistOptions[0].pattern;
140
+ const scope = details.scopeOptions[0].scope;
141
+ const tool = getTool(details.toolName);
142
+ const executionTarget = tool?.origin === 'skill' ? details.executionTarget : undefined;
143
+ addRule(details.toolName, pattern, scope, 'allow', 100, { executionTarget });
144
+ }
145
+ }
146
+
147
+ // Resolve the interaction: remove from tracker and get the session.
148
+ const resolved = pendingInteractions.resolve(request.id);
149
+ if (!resolved) {
150
+ // Race condition: interaction was consumed between get() and resolve().
151
+ log.warn(
152
+ {
153
+ event: 'resolver_tool_approval_resolve_race',
154
+ requestId: request.id,
155
+ },
156
+ 'Tool approval resolver: pending interaction consumed between lookup and resolve',
157
+ );
158
+ return { ok: false, reason: 'pending_interaction_race' };
159
+ }
160
+
161
+ // Map action to the permission system's UserDecision type and notify session.
162
+ const userDecision = decision.action === 'reject' ? 'deny' as const : 'allow' as const;
163
+ resolved.session.handleConfirmationResponse(request.id, userDecision);
164
+
165
+ log.info(
166
+ {
167
+ event: 'resolver_tool_approval_applied',
168
+ requestId: request.id,
169
+ action: decision.action,
170
+ conversationId: request.conversationId,
171
+ toolName: request.toolName,
172
+ },
173
+ 'Tool approval resolver: pending interaction resolved',
174
+ );
175
+
176
+ return { ok: true, applied: true };
177
+ },
178
+ };
179
+
180
+ /**
181
+ * Resolves `pending_question` requests — the voice call question path.
182
+ *
183
+ * Adapts the existing `answerCall` + `resolveGuardianActionRequest` logic:
184
+ * validates that voice-specific fields (callSessionId, pendingQuestionId)
185
+ * are present, and signals that the answer has been captured.
186
+ *
187
+ * Actual call session answer delivery is handled downstream by existing
188
+ * voice infrastructure. This resolver validates the request shape and
189
+ * records the resolution.
190
+ */
191
+ const pendingQuestionResolver: GuardianRequestResolver = {
192
+ kind: 'pending_question',
193
+
194
+ async resolve(ctx: ResolverContext): Promise<ResolverResult> {
195
+ const { request, decision, actor: _actor } = ctx;
196
+
197
+ if (!request.callSessionId) {
198
+ return { ok: false, reason: 'pending_question request missing callSessionId' };
199
+ }
200
+
201
+ if (!request.pendingQuestionId) {
202
+ return { ok: false, reason: 'pending_question request missing pendingQuestionId' };
203
+ }
204
+
205
+ // Derive the answer text from the decision. For approve actions, use the
206
+ // guardian's text if present; otherwise use a default affirmative answer.
207
+ // For reject, use the text or a default denial.
208
+ const answerText = decision.userText
209
+ ?? (decision.action === 'reject' ? 'No' : 'Yes');
210
+
211
+ // 1. Deliver the answer to the voice call session.
212
+ const answerResult = await answerCall({
213
+ callSessionId: request.callSessionId,
214
+ answer: answerText,
215
+ pendingQuestionId: request.pendingQuestionId,
216
+ });
217
+
218
+ if (!('ok' in answerResult) || !answerResult.ok) {
219
+ const errorMsg = 'error' in answerResult ? answerResult.error : 'Unknown error';
220
+ log.warn(
221
+ {
222
+ event: 'resolver_pending_question_answer_failed',
223
+ requestId: request.id,
224
+ callSessionId: request.callSessionId,
225
+ error: errorMsg,
226
+ },
227
+ 'Pending question resolver: answerCall failed',
228
+ );
229
+ // The canonical CAS has already committed so we don't roll back the
230
+ // resolution, but we signal failure so the decision primitive skips
231
+ // grant minting and callers see the side-effect failure.
232
+ return { ok: false, reason: 'answer_call_failed' };
233
+ }
234
+
235
+ log.info(
236
+ {
237
+ event: 'resolver_pending_question_applied',
238
+ requestId: request.id,
239
+ action: decision.action,
240
+ callSessionId: request.callSessionId,
241
+ pendingQuestionId: request.pendingQuestionId,
242
+ answerText,
243
+ answerCallOk: 'ok' in (answerResult as Record<string, unknown>) ? (answerResult as Record<string, unknown>).ok : false,
244
+ },
245
+ 'Pending question resolver: canonical decision applied',
246
+ );
247
+
248
+ return { ok: true, applied: true };
249
+ },
250
+ };
251
+
252
+ /**
253
+ * Resolves `access_request` requests — channel access request approvals.
254
+ *
255
+ * Access requests don't have pending interactions in the session tracker.
256
+ * Instead, they create identity-bound verification sessions so the requester
257
+ * can prove their identity.
258
+ *
259
+ * This resolver directly mints the verification session on approve rather
260
+ * than going through handleAccessRequestDecision -> resolveApprovalRequest,
261
+ * because canonical requests have no legacy channel_guardian_approval_requests
262
+ * row, making the resolveApprovalRequest step a no-op that returns 'stale'.
263
+ *
264
+ * When a `channelDeliveryContext` is provided (channel path), the resolver
265
+ * also delivers the verification code to the guardian, notifies the requester,
266
+ * and emits lifecycle notification signals — mirroring the legacy
267
+ * handleAccessRequestApproval side effects.
268
+ *
269
+ * For deny: notifies the requester and emits denial lifecycle signals when
270
+ * channelDeliveryContext is available.
271
+ */
272
+ const accessRequestResolver: GuardianRequestResolver = {
273
+ kind: 'access_request',
274
+
275
+ async resolve(ctx: ResolverContext): Promise<ResolverResult> {
276
+ const { request, decision, channelDeliveryContext } = ctx;
277
+ const channel = request.sourceChannel ?? 'unknown';
278
+ const requesterExternalUserId = request.requesterExternalUserId ?? '';
279
+ const requesterChatId = request.requesterChatId ?? request.requesterExternalUserId ?? '';
280
+ const decidedByExternalUserId = ctx.actor.externalUserId ?? '';
281
+ const assistantId = channelDeliveryContext?.assistantId ?? 'self';
282
+
283
+ if (decision.action === 'reject') {
284
+ log.info(
285
+ { event: 'resolver_access_request_denied', requestId: request.id },
286
+ 'Access request resolver: deny',
287
+ );
288
+
289
+ // Deliver denial notification and lifecycle signals when channel context is available
290
+ if (channelDeliveryContext) {
291
+ try {
292
+ await deliverChannelReply(channelDeliveryContext.replyCallbackUrl, {
293
+ chatId: requesterChatId,
294
+ text: 'Your access request has been denied by the guardian.',
295
+ assistantId,
296
+ }, channelDeliveryContext.bearerToken);
297
+ } catch (err) {
298
+ log.error({ err, requesterChatId }, 'Failed to notify requester of access request denial');
299
+ }
300
+
301
+ const deniedPayload = {
302
+ sourceChannel: channel,
303
+ requesterExternalUserId,
304
+ requesterChatId,
305
+ decidedByExternalUserId,
306
+ decision: 'denied' as const,
307
+ };
308
+
309
+ void emitNotificationSignal({
310
+ sourceEventName: 'ingress.trusted_contact.guardian_decision',
311
+ sourceChannel: channel,
312
+ sourceSessionId: request.conversationId ?? '',
313
+ assistantId,
314
+ attentionHints: {
315
+ requiresAction: false,
316
+ urgency: 'medium',
317
+ isAsyncBackground: false,
318
+ visibleInSourceNow: false,
319
+ },
320
+ contextPayload: deniedPayload,
321
+ dedupeKey: `trusted-contact:guardian-decision:${request.id}`,
322
+ });
323
+
324
+ void emitNotificationSignal({
325
+ sourceEventName: 'ingress.trusted_contact.denied',
326
+ sourceChannel: channel,
327
+ sourceSessionId: request.conversationId ?? '',
328
+ assistantId,
329
+ attentionHints: {
330
+ requiresAction: false,
331
+ urgency: 'low',
332
+ isAsyncBackground: false,
333
+ visibleInSourceNow: false,
334
+ },
335
+ contextPayload: deniedPayload,
336
+ dedupeKey: `trusted-contact:denied:${request.id}`,
337
+ });
338
+ }
339
+
340
+ return { ok: true, applied: true };
341
+ }
342
+
343
+ // On approve: mint an identity-bound verification session so the
344
+ // requester can verify their identity.
345
+ const session = createOutboundSession({
346
+ assistantId,
347
+ channel,
348
+ expectedExternalUserId: requesterExternalUserId,
349
+ expectedChatId: requesterChatId,
350
+ identityBindingStatus: 'bound',
351
+ destinationAddress: requesterChatId,
352
+ verificationPurpose: 'trusted_contact',
353
+ });
354
+
355
+ log.info(
356
+ {
357
+ event: 'resolver_access_request_approved',
358
+ requestId: request.id,
359
+ verificationSessionId: session.sessionId,
360
+ channel,
361
+ requesterExternalUserId,
362
+ },
363
+ 'Access request resolver: minted verification session',
364
+ );
365
+
366
+ // Deliver the verification code to the guardian and notify the requester
367
+ // when channel delivery context is available (channel message path).
368
+ if (channelDeliveryContext) {
369
+ let codeDelivered = true;
370
+
371
+ // Deliver verification code to guardian
372
+ try {
373
+ const codeText = `You approved access for ${requesterExternalUserId}. `
374
+ + `Give them this verification code: ${session.secret}. `
375
+ + `The code expires in 10 minutes.`;
376
+ await deliverChannelReply(channelDeliveryContext.replyCallbackUrl, {
377
+ chatId: channelDeliveryContext.guardianChatId,
378
+ text: codeText,
379
+ assistantId,
380
+ }, channelDeliveryContext.bearerToken);
381
+ } catch (err) {
382
+ log.error(
383
+ { err, guardianChatId: channelDeliveryContext.guardianChatId },
384
+ 'Failed to deliver verification code to guardian',
385
+ );
386
+ codeDelivered = false;
387
+ }
388
+
389
+ // Notify the requester
390
+ if (codeDelivered) {
391
+ try {
392
+ await deliverChannelReply(channelDeliveryContext.replyCallbackUrl, {
393
+ chatId: requesterChatId,
394
+ text: 'Your access request has been approved! '
395
+ + 'Please enter the 6-digit verification code you receive from the guardian.',
396
+ assistantId,
397
+ }, channelDeliveryContext.bearerToken);
398
+ } catch (err) {
399
+ log.error({ err, requesterChatId }, 'Failed to notify requester of access request approval');
400
+ }
401
+ } else {
402
+ try {
403
+ await deliverChannelReply(channelDeliveryContext.replyCallbackUrl, {
404
+ chatId: requesterChatId,
405
+ text: 'Your access request was approved, but we were unable to '
406
+ + 'deliver the verification code. Please try again later.',
407
+ assistantId,
408
+ }, channelDeliveryContext.bearerToken);
409
+ } catch (err) {
410
+ log.error({ err, requesterChatId }, 'Failed to notify requester of delivery failure');
411
+ }
412
+ }
413
+
414
+ // Emit verification_sent with visibleInSourceNow=true so the notification
415
+ // pipeline suppresses delivery — the guardian already received the code.
416
+ if (codeDelivered) {
417
+ void emitNotificationSignal({
418
+ sourceEventName: 'ingress.trusted_contact.verification_sent',
419
+ sourceChannel: channel,
420
+ sourceSessionId: request.conversationId ?? '',
421
+ assistantId,
422
+ attentionHints: {
423
+ requiresAction: false,
424
+ urgency: 'low',
425
+ isAsyncBackground: true,
426
+ visibleInSourceNow: true,
427
+ },
428
+ contextPayload: {
429
+ sourceChannel: channel,
430
+ requesterExternalUserId,
431
+ requesterChatId,
432
+ verificationSessionId: session.sessionId,
433
+ },
434
+ dedupeKey: `trusted-contact:verification-sent:${session.sessionId}`,
435
+ });
436
+ }
437
+ }
438
+
439
+ return { ok: true, applied: true };
440
+ },
441
+ };
442
+
443
+ /**
444
+ * Resolves `tool_grant_request` requests — asynchronous grant escalation for
445
+ * non-guardian channel actors.
446
+ *
447
+ * Unlike `tool_approval`, this kind does NOT require a pending interaction in
448
+ * the session tracker. The request represents an async escalation: the
449
+ * requester's tool call was already denied, and the canonical request exists
450
+ * solely so the guardian can mint a scoped grant.
451
+ *
452
+ * On approve: the canonical decision primitive mints the grant (step 6 in
453
+ * applyCanonicalGuardianDecision). This resolver optionally notifies the
454
+ * requester to retry.
455
+ *
456
+ * On reject: optionally notifies the requester that their request was denied.
457
+ */
458
+ const toolGrantRequestResolver: GuardianRequestResolver = {
459
+ kind: 'tool_grant_request',
460
+
461
+ async resolve(ctx: ResolverContext): Promise<ResolverResult> {
462
+ const { request, decision, channelDeliveryContext } = ctx;
463
+ const requesterChatId = request.requesterChatId ?? request.requesterExternalUserId ?? '';
464
+ const assistantId = channelDeliveryContext?.assistantId ?? 'self';
465
+
466
+ if (decision.action === 'reject') {
467
+ log.info(
468
+ { event: 'resolver_tool_grant_request_denied', requestId: request.id, toolName: request.toolName },
469
+ 'Tool grant request resolver: deny',
470
+ );
471
+
472
+ if (channelDeliveryContext && requesterChatId) {
473
+ try {
474
+ await deliverChannelReply(channelDeliveryContext.replyCallbackUrl, {
475
+ chatId: requesterChatId,
476
+ text: `Your request to use "${request.toolName}" has been denied by the guardian.`,
477
+ assistantId,
478
+ }, channelDeliveryContext.bearerToken);
479
+ } catch (err) {
480
+ log.error({ err, requesterChatId }, 'Failed to notify requester of tool grant request denial');
481
+ }
482
+ }
483
+
484
+ return { ok: true, applied: true };
485
+ }
486
+
487
+ // On approve: grant minting is handled by the canonical decision primitive
488
+ // (step 6). This resolver only handles requester notification.
489
+ log.info(
490
+ {
491
+ event: 'resolver_tool_grant_request_approved',
492
+ requestId: request.id,
493
+ toolName: request.toolName,
494
+ },
495
+ 'Tool grant request resolver: approved (grant minting deferred to canonical primitive)',
496
+ );
497
+
498
+ if (channelDeliveryContext && requesterChatId) {
499
+ try {
500
+ await deliverChannelReply(channelDeliveryContext.replyCallbackUrl, {
501
+ chatId: requesterChatId,
502
+ text: `Your request to use "${request.toolName}" has been approved. Please retry your request.`,
503
+ assistantId,
504
+ }, channelDeliveryContext.bearerToken);
505
+ } catch (err) {
506
+ log.error({ err, requesterChatId }, 'Failed to notify requester of tool grant request approval');
507
+ }
508
+ }
509
+
510
+ return { ok: true, applied: true, grantMinted: false };
511
+ },
512
+ };
513
+
514
+ // ---------------------------------------------------------------------------
515
+ // Registry
516
+ // ---------------------------------------------------------------------------
517
+
518
+ const resolverRegistry = new Map<string, GuardianRequestResolver>();
519
+
520
+ /** Register a resolver for a given request kind. */
521
+ export function registerResolver(resolver: GuardianRequestResolver): void {
522
+ resolverRegistry.set(resolver.kind, resolver);
523
+ }
524
+
525
+ /** Look up the resolver for a given request kind. */
526
+ export function getResolver(kind: string): GuardianRequestResolver | undefined {
527
+ return resolverRegistry.get(kind);
528
+ }
529
+
530
+ /** Return all registered resolver kinds (for diagnostics). */
531
+ export function getRegisteredKinds(): string[] {
532
+ return Array.from(resolverRegistry.keys());
533
+ }
534
+
535
+ // Register built-in resolvers
536
+ registerResolver(pendingInteractionResolver);
537
+ registerResolver(pendingQuestionResolver);
538
+ registerResolver(accessRequestResolver);
539
+ registerResolver(toolGrantRequestResolver);