@vellumai/assistant 0.3.3 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +8 -16
  2. package/package.json +1 -1
  3. package/src/__tests__/call-orchestrator.test.ts +321 -0
  4. package/src/__tests__/channel-approval-routes.test.ts +382 -124
  5. package/src/__tests__/channel-approvals.test.ts +51 -2
  6. package/src/__tests__/channel-delivery-store.test.ts +30 -4
  7. package/src/__tests__/channel-guardian.test.ts +187 -0
  8. package/src/__tests__/config-schema.test.ts +1 -1
  9. package/src/__tests__/daemon-lifecycle.test.ts +635 -0
  10. package/src/__tests__/gateway-only-enforcement.test.ts +19 -13
  11. package/src/__tests__/handlers-twilio-config.test.ts +73 -0
  12. package/src/__tests__/secret-scanner.test.ts +223 -0
  13. package/src/__tests__/shell-parser-property.test.ts +357 -2
  14. package/src/__tests__/system-prompt.test.ts +25 -1
  15. package/src/__tests__/tool-executor-lifecycle-events.test.ts +34 -1
  16. package/src/__tests__/user-reference.test.ts +68 -0
  17. package/src/calls/call-orchestrator.ts +63 -11
  18. package/src/cli/map.ts +6 -0
  19. package/src/commands/__tests__/cc-command-registry.test.ts +67 -0
  20. package/src/commands/cc-command-registry.ts +14 -1
  21. package/src/config/bundled-skills/claude-code/TOOLS.json +10 -3
  22. package/src/config/bundled-skills/messaging/SKILL.md +4 -0
  23. package/src/config/defaults.ts +1 -1
  24. package/src/config/schema.ts +3 -3
  25. package/src/config/skills.ts +5 -32
  26. package/src/config/system-prompt.ts +16 -0
  27. package/src/config/user-reference.ts +29 -0
  28. package/src/config/vellum-skills/catalog.json +52 -0
  29. package/src/config/vellum-skills/telegram-setup/SKILL.md +6 -1
  30. package/src/config/vellum-skills/twilio-setup/SKILL.md +38 -0
  31. package/src/daemon/auth-manager.ts +103 -0
  32. package/src/daemon/computer-use-session.ts +8 -1
  33. package/src/daemon/config-watcher.ts +253 -0
  34. package/src/daemon/handlers/config.ts +36 -13
  35. package/src/daemon/handlers/skills.ts +6 -7
  36. package/src/daemon/ipc-contract.ts +6 -0
  37. package/src/daemon/ipc-handler.ts +87 -0
  38. package/src/daemon/lifecycle.ts +16 -4
  39. package/src/daemon/ride-shotgun-handler.ts +11 -1
  40. package/src/daemon/server.ts +105 -502
  41. package/src/daemon/session-agent-loop.ts +5 -14
  42. package/src/daemon/session-runtime-assembly.ts +60 -44
  43. package/src/daemon/session.ts +8 -1
  44. package/src/memory/db-connection.ts +28 -0
  45. package/src/memory/db-init.ts +1019 -0
  46. package/src/memory/db.ts +2 -2007
  47. package/src/memory/embedding-backend.ts +79 -11
  48. package/src/memory/indexer.ts +2 -0
  49. package/src/memory/job-utils.ts +64 -4
  50. package/src/memory/jobs-worker.ts +7 -1
  51. package/src/memory/recall-cache.ts +107 -0
  52. package/src/memory/retriever.ts +30 -1
  53. package/src/memory/schema-migration.ts +984 -0
  54. package/src/memory/schema.ts +1 -0
  55. package/src/memory/search/types.ts +2 -0
  56. package/src/permissions/prompter.ts +14 -3
  57. package/src/permissions/trust-store.ts +7 -0
  58. package/src/runtime/channel-approvals.ts +17 -3
  59. package/src/runtime/gateway-client.ts +2 -1
  60. package/src/runtime/http-server.ts +15 -4
  61. package/src/runtime/routes/channel-routes.ts +172 -84
  62. package/src/runtime/routes/run-routes.ts +7 -1
  63. package/src/runtime/run-orchestrator.ts +8 -1
  64. package/src/security/secret-scanner.ts +218 -0
  65. package/src/skills/frontmatter.ts +63 -0
  66. package/src/skills/slash-commands.ts +23 -0
  67. package/src/skills/vellum-catalog-remote.ts +107 -0
  68. package/src/tools/browser/auto-navigate.ts +132 -24
  69. package/src/tools/browser/browser-manager.ts +67 -61
  70. package/src/tools/claude-code/claude-code.ts +55 -3
  71. package/src/tools/executor.ts +10 -2
  72. package/src/tools/skills/vellum-catalog.ts +61 -156
  73. package/src/tools/terminal/parser.ts +21 -5
  74. package/src/util/platform.ts +8 -1
  75. package/src/util/retry.ts +4 -4
@@ -127,12 +127,21 @@ function effectivePromptText(
127
127
  return plainTextFallback;
128
128
  }
129
129
 
130
- // ---------------------------------------------------------------------------
131
- // Feature flag
132
- // ---------------------------------------------------------------------------
130
+ /**
131
+ * Build contextual deny guidance for guardian-gated auto-deny paths.
132
+ * This is passed through the confirmation pipeline so the assistant can
133
+ * produce a single, user-facing message with next steps.
134
+ */
135
+ function buildGuardianDenyContext(
136
+ toolName: string,
137
+ denialReason: DenialReason,
138
+ sourceChannel: string,
139
+ ): string {
140
+ if (denialReason === 'no_identity') {
141
+ return `Permission denied: the action "${toolName}" requires guardian approval, but your identity could not be verified on ${sourceChannel}. Do not retry yet. Explain this clearly, ask the user to message from a verifiable direct account/chat, and then retry after identity is available.`;
142
+ }
133
143
 
134
- export function isChannelApprovalsEnabled(): boolean {
135
- return process.env.CHANNEL_APPROVALS_ENABLED === 'true';
144
+ return `Permission denied: the action "${toolName}" requires guardian approval, but no guardian is configured for this ${sourceChannel} channel. Do not retry yet. Explain that a guardian must be set up first. The guardian/admin should open the Channels section in Settings and click "Verify Guardian", or ask the assistant to set up guardian verification. The setup flow will provide a verification token to send as /guardian_verify <token> in the ${sourceChannel} chat.`;
136
145
  }
137
146
 
138
147
  // ---------------------------------------------------------------------------
@@ -169,13 +178,22 @@ export async function handleDeleteConversation(req: Request, assistantId: string
169
178
  return Response.json({ error: 'externalChatId is required' }, { status: 400 });
170
179
  }
171
180
 
172
- // Delete both legacy and scoped conversation key aliases to handle
173
- // migration scenarios where either or both keys may exist.
181
+ // Delete the assistant-scoped key unconditionally. The legacy key is
182
+ // canonical for the self assistant and must not be deleted from non-self
183
+ // routes, otherwise a non-self reset can accidentally reset self state.
174
184
  const legacyKey = `${sourceChannel}:${externalChatId}`;
175
185
  const scopedKey = `asst:${assistantId}:${sourceChannel}:${externalChatId}`;
176
- deleteConversationKey(legacyKey);
177
186
  deleteConversationKey(scopedKey);
178
- externalConversationStore.deleteBindingByChannelChat(sourceChannel, externalChatId);
187
+ if (assistantId === 'self') {
188
+ deleteConversationKey(legacyKey);
189
+ }
190
+ // external_conversation_bindings is currently assistant-agnostic
191
+ // (unique by sourceChannel + externalChatId). Restrict mutations to the
192
+ // canonical self-assistant route so multi-assistant legacy routes do not
193
+ // clobber each other's bindings.
194
+ if (assistantId === 'self') {
195
+ externalConversationStore.deleteBindingByChannelChat(sourceChannel, externalChatId);
196
+ }
179
197
 
180
198
  return Response.json({ ok: true });
181
199
  }
@@ -338,15 +356,19 @@ export async function handleChannelInbound(
338
356
  { sourceMessageId, assistantId },
339
357
  );
340
358
 
341
- // Upsert external conversation binding with sender metadata
342
- externalConversationStore.upsertBinding({
343
- conversationId: result.conversationId,
344
- sourceChannel,
345
- externalChatId,
346
- externalUserId: body.senderExternalUserId ?? null,
347
- displayName: body.senderName ?? null,
348
- username: body.senderUsername ?? null,
349
- });
359
+ // external_conversation_bindings is assistant-agnostic. Restrict writes to
360
+ // self so assistant-scoped legacy routes do not overwrite each other's
361
+ // channel binding metadata for the same chat.
362
+ if (assistantId === 'self') {
363
+ externalConversationStore.upsertBinding({
364
+ conversationId: result.conversationId,
365
+ sourceChannel,
366
+ externalChatId,
367
+ externalUserId: body.senderExternalUserId ?? null,
368
+ displayName: body.senderName ?? null,
369
+ username: body.senderUsername ?? null,
370
+ });
371
+ }
350
372
 
351
373
  const metadataHintsRaw = sourceMetadata?.hints;
352
374
  const metadataHints = Array.isArray(metadataHintsRaw)
@@ -384,6 +406,7 @@ export async function handleChannelInbound(
384
406
  await deliverChannelReply(replyCallbackUrl, {
385
407
  chatId: externalChatId,
386
408
  text: replyText,
409
+ assistantId,
387
410
  }, bearerToken);
388
411
  } catch (err) {
389
412
  log.error({ err, externalChatId }, 'Failed to deliver guardian verification reply');
@@ -403,9 +426,7 @@ export async function handleChannelInbound(
403
426
  // When a guardian binding exists, non-guardian actors get stricter
404
427
  // side-effect controls and their approvals route to the guardian's chat.
405
428
  //
406
- // Guardian enforcement runs independently of CHANNEL_APPROVALS_ENABLED.
407
- // The approval flag only gates the interactive approval prompting UX;
408
- // actor-role resolution and fail-closed denial are always active.
429
+ // Guardian actor-role resolution always runs.
409
430
  let guardianCtx: GuardianContext = { actorRole: 'guardian' };
410
431
  if (body.senderExternalUserId) {
411
432
  const senderIsGuardian = isGuardian(assistantId, sourceChannel, body.senderExternalUserId);
@@ -435,23 +456,20 @@ export async function handleChannelInbound(
435
456
  }
436
457
  }
437
458
  } else {
438
- // No sender identity available — fail-closed when guardian enforcement
439
- // is active for this channel. If a binding exists, unknown actors must
440
- // not be granted default guardian permissions.
441
- const binding = getGuardianBinding(assistantId, sourceChannel);
442
- if (binding) {
443
- guardianCtx = {
444
- actorRole: 'unverified_channel',
445
- denialReason: 'no_identity',
446
- requesterExternalUserId: undefined,
447
- requesterChatId: externalChatId,
448
- };
449
- }
459
+ // No sender identity available — treat as unverified and fail closed.
460
+ // Multi-actor channels must not grant default guardian permissions when
461
+ // the inbound actor cannot be identified.
462
+ guardianCtx = {
463
+ actorRole: 'unverified_channel',
464
+ denialReason: 'no_identity',
465
+ requesterExternalUserId: undefined,
466
+ requesterChatId: externalChatId,
467
+ };
450
468
  }
451
469
 
452
- // ── Approval interception (gated behind feature flag) ──
470
+ // ── Approval interception ──
471
+ // Keep this active whenever orchestrator + callback context are available.
453
472
  if (
454
- isChannelApprovalsEnabled() &&
455
473
  runOrchestrator &&
456
474
  replyCallbackUrl &&
457
475
  !result.duplicate
@@ -507,6 +525,7 @@ export async function handleChannelInbound(
507
525
  senderExternalUserId: body.senderExternalUserId,
508
526
  senderUsername: body.senderUsername,
509
527
  replyCallbackUrl,
528
+ assistantId,
510
529
  });
511
530
 
512
531
  const contentToCheck = content ?? '';
@@ -522,13 +541,15 @@ export async function handleChannelInbound(
522
541
  throw new IngressBlockedError(ingressCheck.userNotice!, ingressCheck.detectedTypes);
523
542
  }
524
543
 
525
- // When approval flow is enabled and we have an orchestrator, use the
526
- // orchestrator-backed path which properly intercepts confirmation_request
527
- // events and sends proactive approval prompts to the channel.
528
- const useApprovalPath =
529
- isChannelApprovalsEnabled() && runOrchestrator && replyCallbackUrl;
544
+ // Use the approval-aware orchestrator path whenever orchestration and a
545
+ // callback delivery target are available. This keeps approval handling
546
+ // consistent across all channels and avoids silent prompt timeouts.
547
+ const useApprovalPath = Boolean(
548
+ runOrchestrator &&
549
+ replyCallbackUrl,
550
+ );
530
551
 
531
- if (useApprovalPath) {
552
+ if (useApprovalPath && runOrchestrator && replyCallbackUrl) {
532
553
  processChannelMessageWithApprovals({
533
554
  orchestrator: runOrchestrator,
534
555
  conversationId: result.conversationId,
@@ -557,6 +578,7 @@ export async function handleChannelInbound(
557
578
  metadataUxBrief,
558
579
  replyCallbackUrl,
559
580
  bearerToken,
581
+ assistantId,
560
582
  });
561
583
  }
562
584
  }
@@ -580,6 +602,7 @@ interface BackgroundProcessingParams {
580
602
  metadataUxBrief?: string;
581
603
  replyCallbackUrl?: string;
582
604
  bearerToken?: string;
605
+ assistantId?: string;
583
606
  }
584
607
 
585
608
  function processChannelMessageInBackground(params: BackgroundProcessingParams): void {
@@ -595,6 +618,7 @@ function processChannelMessageInBackground(params: BackgroundProcessingParams):
595
618
  metadataUxBrief,
596
619
  replyCallbackUrl,
597
620
  bearerToken,
621
+ assistantId,
598
622
  } = params;
599
623
 
600
624
  (async () => {
@@ -616,7 +640,13 @@ function processChannelMessageInBackground(params: BackgroundProcessingParams):
616
640
  channelDeliveryStore.markProcessed(eventId);
617
641
 
618
642
  if (replyCallbackUrl) {
619
- await deliverReplyViaCallback(conversationId, externalChatId, replyCallbackUrl, bearerToken);
643
+ await deliverReplyViaCallback(
644
+ conversationId,
645
+ externalChatId,
646
+ replyCallbackUrl,
647
+ bearerToken,
648
+ assistantId,
649
+ );
620
650
  }
621
651
  } catch (err) {
622
652
  log.error({ err, conversationId }, 'Background channel message processing failed');
@@ -714,6 +744,14 @@ function processChannelMessageWithApprovals(params: ApprovalProcessingParams): v
714
744
  const startTime = Date.now();
715
745
  const pollMaxWait = getEffectivePollMaxWait();
716
746
  let lastStatus = run.status;
747
+ // Track whether a post-decision delivery path is guaranteed for this
748
+ // run. Set to true only when the approval prompt is successfully
749
+ // delivered (guardian or standard path), meaning
750
+ // handleApprovalInterception will schedule schedulePostDecisionDelivery
751
+ // when a decision arrives. Auto-deny paths (unverified channel, prompt
752
+ // delivery failures) do NOT set this flag because no post-decision
753
+ // delivery is scheduled in those cases.
754
+ let hasPostDecisionDelivery = false;
717
755
 
718
756
  while (Date.now() - startTime < pollMaxWait) {
719
757
  await new Promise((resolve) => setTimeout(resolve, RUN_POLL_INTERVAL_MS));
@@ -726,18 +764,16 @@ function processChannelMessageWithApprovals(params: ApprovalProcessingParams): v
726
764
 
727
765
  if (isUnverifiedChannel && pending.length > 0) {
728
766
  // Unverified channel — auto-deny the sensitive action (fail-closed).
729
- handleChannelDecision(conversationId, { action: 'reject', source: 'plain_text' }, orchestrator);
730
- const denialText = guardianCtx.denialReason === 'no_identity'
731
- ? `The action "${pending[0].toolName}" requires guardian approval, but your identity could not be determined. The action has been denied. Please ensure your messaging client sends user identity information.`
732
- : `The action "${pending[0].toolName}" requires guardian approval, but no guardian has been set up for this channel. The action has been denied. Please ask an administrator to configure a guardian.`;
733
- try {
734
- await deliverChannelReply(replyCallbackUrl, {
735
- chatId: externalChatId,
736
- text: denialText,
737
- }, bearerToken);
738
- } catch (err) {
739
- log.error({ err, runId: run.id }, 'Failed to deliver unverified-channel denial notice');
740
- }
767
+ handleChannelDecision(
768
+ conversationId,
769
+ { action: 'reject', source: 'plain_text' },
770
+ orchestrator,
771
+ buildGuardianDenyContext(
772
+ pending[0].toolName,
773
+ guardianCtx.denialReason ?? 'no_binding',
774
+ sourceChannel,
775
+ ),
776
+ );
741
777
  } else if (isNonGuardian && guardianCtx.guardianChatId && pending.length > 0) {
742
778
  // Non-guardian actor: route the approval prompt to the guardian's chat
743
779
  const guardianPrompt = buildGuardianApprovalPrompt(
@@ -774,9 +810,11 @@ function processChannelMessageWithApprovals(params: ApprovalProcessingParams): v
774
810
  guardianCtx.guardianChatId,
775
811
  guardianText,
776
812
  uiMetadata,
813
+ assistantId,
777
814
  bearerToken,
778
815
  );
779
816
  guardianNotified = true;
817
+ hasPostDecisionDelivery = true;
780
818
  } catch (err) {
781
819
  log.error({ err, runId: run.id }, 'Failed to deliver guardian approval prompt');
782
820
  // Deny the approval and the underlying run — fail-closed. If
@@ -789,6 +827,7 @@ function processChannelMessageWithApprovals(params: ApprovalProcessingParams): v
789
827
  await deliverChannelReply(replyCallbackUrl, {
790
828
  chatId: guardianCtx.requesterChatId ?? externalChatId,
791
829
  text: `Your request to run "${pending[0].toolName}" could not be sent to the guardian for approval. The action has been denied.`,
830
+ assistantId,
792
831
  }, bearerToken);
793
832
  } catch (notifyErr) {
794
833
  log.error({ err: notifyErr, runId: run.id }, 'Failed to notify requester of guardian delivery failure');
@@ -801,6 +840,7 @@ function processChannelMessageWithApprovals(params: ApprovalProcessingParams): v
801
840
  await deliverChannelReply(replyCallbackUrl, {
802
841
  chatId: guardianCtx.requesterChatId ?? externalChatId,
803
842
  text: `Your request to run "${pending[0].toolName}" has been sent to the guardian for approval.`,
843
+ assistantId,
804
844
  }, bearerToken);
805
845
  } catch (err) {
806
846
  log.error({ err, runId: run.id }, 'Failed to notify requester of pending guardian approval');
@@ -823,8 +863,10 @@ function processChannelMessageWithApprovals(params: ApprovalProcessingParams): v
823
863
  externalChatId,
824
864
  promptTextForChannel,
825
865
  uiMetadata,
866
+ assistantId,
826
867
  bearerToken,
827
868
  );
869
+ hasPostDecisionDelivery = true;
828
870
  } catch (err) {
829
871
  // Fail-closed: if we cannot deliver the approval prompt, the
830
872
  // user will never see it and the run would hang indefinitely
@@ -867,7 +909,13 @@ function processChannelMessageWithApprovals(params: ApprovalProcessingParams): v
867
909
  // rather than permanently losing the reply.
868
910
  if (channelDeliveryStore.claimRunDelivery(run.id)) {
869
911
  try {
870
- await deliverReplyViaCallback(conversationId, externalChatId, replyCallbackUrl, bearerToken);
912
+ await deliverReplyViaCallback(
913
+ conversationId,
914
+ externalChatId,
915
+ replyCallbackUrl,
916
+ bearerToken,
917
+ assistantId,
918
+ );
871
919
  } catch (deliveryErr) {
872
920
  channelDeliveryStore.resetRunDeliveryClaim(run.id);
873
921
  throw deliveryErr;
@@ -884,23 +932,39 @@ function processChannelMessageWithApprovals(params: ApprovalProcessingParams): v
884
932
  updateApprovalDecision(approvalReq.id, { status: outcomeStatus });
885
933
  }
886
934
  }
887
- } else if (finalRun?.status === 'needs_confirmation') {
888
- // The run is waiting for an approval decision but the poll window has
889
- // elapsed. Mark the event as processed rather than failed — the run
890
- // will resume when the user clicks approve/reject, and
891
- // `handleApprovalInterception` will deliver the reply via its own
892
- // post-decision poll. Marking it failed would cause the generic retry
893
- // sweep to replay through `processMessage`, which throws "Session is
935
+ } else if (
936
+ finalRun?.status === 'needs_confirmation' ||
937
+ (hasPostDecisionDelivery && finalRun?.status === 'running')
938
+ ) {
939
+ // The run is either still waiting for an approval decision or was
940
+ // recently approved and has resumed execution. In both cases, mark
941
+ // the event as processed rather than failed:
942
+ //
943
+ // - needs_confirmation: the run will resume when the user clicks
944
+ // approve/reject, and `handleApprovalInterception` will deliver
945
+ // the reply via `schedulePostDecisionDelivery`.
946
+ //
947
+ // - running (after successful prompt delivery): an approval was
948
+ // applied near the poll deadline and the run resumed but hasn't
949
+ // reached terminal state yet. `handleApprovalInterception` has
950
+ // already scheduled post-decision delivery, so the final reply
951
+ // will be delivered. This condition is only true when the approval
952
+ // prompt was actually delivered (not in auto-deny paths), ensuring
953
+ // we don't suppress retry/dead-letter for cases where no
954
+ // post-decision delivery path exists.
955
+ //
956
+ // Marking either state as failed would cause the generic retry sweep
957
+ // to replay through `processMessage`, which throws "Session is
894
958
  // already processing a message" and dead-letters a valid conversation.
895
959
  log.warn(
896
- { runId: run.id, status: finalRun.status, conversationId },
897
- 'Approval-path poll loop timed out while run awaits approval decision; marking event as processed',
960
+ { runId: run.id, status: finalRun.status, conversationId, hasPostDecisionDelivery },
961
+ 'Approval-path poll loop timed out while run is in approval-related state; marking event as processed',
898
962
  );
899
963
  channelDeliveryStore.markProcessed(eventId);
900
964
  } else {
901
- // The run is in a non-terminal, non-approval state (e.g. running,
902
- // needs_secret, or disappeared). Record a processing failure so the
903
- // retry/dead-letter machinery can handle it.
965
+ // The run is in a non-terminal, non-approval state (e.g. running
966
+ // without prior approval, needs_secret, or disappeared). Record a
967
+ // processing failure so the retry/dead-letter machinery can handle it.
904
968
  const timeoutErr = new Error(
905
969
  `Approval poll timeout: run did not reach terminal state within ${pollMaxWait}ms (status: ${finalRun?.status ?? 'null'})`,
906
970
  );
@@ -1003,6 +1067,7 @@ async function handleApprovalInterception(
1003
1067
  await deliverChannelReply(replyCallbackUrl, {
1004
1068
  chatId: externalChatId,
1005
1069
  text: `You have ${allPending.length} pending approval requests. Please use the approval buttons to respond to a specific request.`,
1070
+ assistantId,
1006
1071
  }, bearerToken);
1007
1072
  } catch (err) {
1008
1073
  log.error({ err, externalChatId }, 'Failed to deliver disambiguation notice');
@@ -1035,6 +1100,7 @@ async function handleApprovalInterception(
1035
1100
  await deliverChannelReply(replyCallbackUrl, {
1036
1101
  chatId: externalChatId,
1037
1102
  text: 'Only the verified guardian can approve or deny this request.',
1103
+ assistantId,
1038
1104
  }, bearerToken);
1039
1105
  } catch (err) {
1040
1106
  log.error({ err, externalChatId }, 'Failed to deliver guardian identity rejection notice');
@@ -1075,6 +1141,7 @@ async function handleApprovalInterception(
1075
1141
  await deliverChannelReply(replyCallbackUrl, {
1076
1142
  chatId: guardianApproval.requesterChatId,
1077
1143
  text: outcomeText,
1144
+ assistantId,
1078
1145
  }, bearerToken);
1079
1146
  } catch (err) {
1080
1147
  log.error({ err, conversationId: guardianApproval.conversationId }, 'Failed to notify requester of guardian decision');
@@ -1090,6 +1157,7 @@ async function handleApprovalInterception(
1090
1157
  guardianApproval.requesterChatId,
1091
1158
  replyCallbackUrl,
1092
1159
  bearerToken,
1160
+ assistantId,
1093
1161
  );
1094
1162
  }
1095
1163
  }
@@ -1117,6 +1185,7 @@ async function handleApprovalInterception(
1117
1185
  externalChatId,
1118
1186
  reminderText,
1119
1187
  uiMetadata,
1188
+ assistantId,
1120
1189
  bearerToken,
1121
1190
  );
1122
1191
  } catch (err) {
@@ -1126,11 +1195,6 @@ async function handleApprovalInterception(
1126
1195
 
1127
1196
  return { handled: true, type: 'reminder_sent' };
1128
1197
  }
1129
-
1130
- // Callback with a run ID that no longer has a pending approval — stale button
1131
- if (decision?.runId) {
1132
- return { handled: true, type: 'stale_ignored' };
1133
- }
1134
1198
  }
1135
1199
 
1136
1200
  // ── Standard approval interception (existing flow) ──
@@ -1142,17 +1206,26 @@ async function handleApprovalInterception(
1142
1206
  if (guardianCtx.actorRole === 'unverified_channel') {
1143
1207
  const pending = getPendingConfirmationsByConversation(conversationId);
1144
1208
  if (pending.length > 0) {
1145
- handleChannelDecision(conversationId, { action: 'reject', source: 'plain_text' }, orchestrator);
1146
- const denialText = guardianCtx.denialReason === 'no_identity'
1147
- ? `The action "${pending[0].toolName}" requires guardian approval, but your identity could not be determined. The action has been denied.`
1148
- : `The action "${pending[0].toolName}" requires guardian approval, but no guardian has been set up for this channel. The action has been denied.`;
1149
- try {
1150
- await deliverChannelReply(replyCallbackUrl, {
1151
- chatId: externalChatId,
1152
- text: denialText,
1153
- }, bearerToken);
1154
- } catch (err) {
1155
- log.error({ err, conversationId }, 'Failed to deliver unverified-channel denial notice during interception');
1209
+ const denyResult = handleChannelDecision(
1210
+ conversationId,
1211
+ { action: 'reject', source: 'plain_text' },
1212
+ orchestrator,
1213
+ buildGuardianDenyContext(
1214
+ pending[0].toolName,
1215
+ guardianCtx.denialReason ?? 'no_binding',
1216
+ sourceChannel,
1217
+ ),
1218
+ );
1219
+ if (denyResult.applied && denyResult.runId) {
1220
+ schedulePostDecisionDelivery(
1221
+ orchestrator,
1222
+ denyResult.runId,
1223
+ conversationId,
1224
+ externalChatId,
1225
+ replyCallbackUrl,
1226
+ bearerToken,
1227
+ assistantId,
1228
+ );
1156
1229
  }
1157
1230
  return { handled: true, type: 'decision_applied' };
1158
1231
  }
@@ -1170,6 +1243,7 @@ async function handleApprovalInterception(
1170
1243
  await deliverChannelReply(replyCallbackUrl, {
1171
1244
  chatId: externalChatId,
1172
1245
  text: 'Your request is pending guardian approval. Only the verified guardian can approve or deny this request.',
1246
+ assistantId,
1173
1247
  }, bearerToken);
1174
1248
  } catch (err) {
1175
1249
  log.error({ err, conversationId }, 'Failed to deliver guardian-pending notice to requester');
@@ -1196,6 +1270,7 @@ async function handleApprovalInterception(
1196
1270
  await deliverChannelReply(replyCallbackUrl, {
1197
1271
  chatId: externalChatId,
1198
1272
  text: 'Your guardian approval request has expired and the action has been denied. Please try again.',
1273
+ assistantId,
1199
1274
  }, bearerToken);
1200
1275
  } catch (err) {
1201
1276
  log.error({ err, conversationId }, 'Failed to deliver guardian-expiry notice to requester');
@@ -1247,6 +1322,7 @@ async function handleApprovalInterception(
1247
1322
  externalChatId,
1248
1323
  replyCallbackUrl,
1249
1324
  bearerToken,
1325
+ assistantId,
1250
1326
  );
1251
1327
  }
1252
1328
 
@@ -1269,6 +1345,7 @@ async function handleApprovalInterception(
1269
1345
  externalChatId,
1270
1346
  reminderText,
1271
1347
  uiMetadata,
1348
+ assistantId,
1272
1349
  bearerToken,
1273
1350
  );
1274
1351
  } catch (err) {
@@ -1296,6 +1373,7 @@ function schedulePostDecisionDelivery(
1296
1373
  externalChatId: string,
1297
1374
  replyCallbackUrl: string,
1298
1375
  bearerToken?: string,
1376
+ assistantId?: string,
1299
1377
  ): void {
1300
1378
  (async () => {
1301
1379
  try {
@@ -1307,7 +1385,13 @@ function schedulePostDecisionDelivery(
1307
1385
  if (current.status === 'completed' || current.status === 'failed') {
1308
1386
  if (channelDeliveryStore.claimRunDelivery(runId)) {
1309
1387
  try {
1310
- await deliverReplyViaCallback(conversationId, externalChatId, replyCallbackUrl, bearerToken);
1388
+ await deliverReplyViaCallback(
1389
+ conversationId,
1390
+ externalChatId,
1391
+ replyCallbackUrl,
1392
+ bearerToken,
1393
+ assistantId,
1394
+ );
1311
1395
  } catch (deliveryErr) {
1312
1396
  channelDeliveryStore.resetRunDeliveryClaim(runId);
1313
1397
  throw deliveryErr;
@@ -1331,6 +1415,7 @@ async function deliverReplyViaCallback(
1331
1415
  externalChatId: string,
1332
1416
  callbackUrl: string,
1333
1417
  bearerToken?: string,
1418
+ assistantId?: string,
1334
1419
  ): Promise<void> {
1335
1420
  const msgs = conversationStore.getMessages(conversationId);
1336
1421
  for (let i = msgs.length - 1; i >= 0; i--) {
@@ -1353,6 +1438,7 @@ async function deliverReplyViaCallback(
1353
1438
  chatId: externalChatId,
1354
1439
  text: rendered.text || undefined,
1355
1440
  attachments: replyAttachments.length > 0 ? replyAttachments : undefined,
1441
+ assistantId,
1356
1442
  }, bearerToken);
1357
1443
  }
1358
1444
  break;
@@ -1453,6 +1539,7 @@ export function sweepExpiredGuardianApprovals(
1453
1539
  deliverChannelReply(deliverUrl, {
1454
1540
  chatId: approval.requesterChatId,
1455
1541
  text: `Your guardian approval request for "${approval.toolName}" has expired and the action has been denied. Please try again.`,
1542
+ assistantId: approval.assistantId,
1456
1543
  }, bearerToken).catch((err) => {
1457
1544
  log.error({ err, runId: approval.runId }, 'Failed to notify requester of guardian approval expiry');
1458
1545
  });
@@ -1461,6 +1548,7 @@ export function sweepExpiredGuardianApprovals(
1461
1548
  deliverChannelReply(deliverUrl, {
1462
1549
  chatId: approval.guardianChatId,
1463
1550
  text: `The approval request for "${approval.toolName}" from user ${approval.requesterExternalUserId} has expired and was automatically denied.`,
1551
+ assistantId: approval.assistantId,
1464
1552
  }, bearerToken).catch((err) => {
1465
1553
  log.error({ err, runId: approval.runId }, 'Failed to notify guardian of approval expiry');
1466
1554
  });
@@ -5,6 +5,7 @@ import { getOrCreateConversation } from '../../memory/conversation-key-store.js'
5
5
  import * as attachmentsStore from '../../memory/attachments-store.js';
6
6
  import * as runsStore from '../../memory/runs-store.js';
7
7
  import { addRule } from '../../permissions/trust-store.js';
8
+ import { getTool } from '../../tools/registry.js';
8
9
  import { getLogger } from '../../util/logger.js';
9
10
  import type { RunOrchestrator } from '../run-orchestrator.js';
10
11
 
@@ -200,8 +201,13 @@ export async function handleAddTrustRule(
200
201
  }
201
202
 
202
203
  try {
204
+ // Only persist executionTarget for skill-origin tools — core tools don't
205
+ // set it in their PolicyContext, so a persisted value would prevent the
206
+ // rule from ever matching on subsequent permission checks.
207
+ const tool = getTool(confirmation.toolName);
208
+ const executionTarget = tool?.origin === 'skill' ? confirmation.executionTarget : undefined;
203
209
  addRule(confirmation.toolName, pattern, scope, decision, undefined, {
204
- executionTarget: confirmation.executionTarget,
210
+ executionTarget,
205
211
  });
206
212
  log.info(
207
213
  { tool: confirmation.toolName, pattern, scope, decision, runId },
@@ -251,13 +251,20 @@ export class RunOrchestrator {
251
251
  * - `'run_not_found'` – no run exists with the given ID
252
252
  * - `'no_pending_decision'` – run exists but is not awaiting a confirmation
253
253
  */
254
- submitDecision(runId: string, decision: UserDecision): 'applied' | 'run_not_found' | 'no_pending_decision' {
254
+ submitDecision(
255
+ runId: string,
256
+ decision: UserDecision,
257
+ decisionContext?: string,
258
+ ): 'applied' | 'run_not_found' | 'no_pending_decision' {
255
259
  const pendingState = this.pending.get(runId);
256
260
  if (pendingState) {
257
261
  runsStore.clearRunConfirmation(runId);
258
262
  pendingState.session.handleConfirmationResponse(
259
263
  pendingState.prompterRequestId,
260
264
  decision,
265
+ undefined,
266
+ undefined,
267
+ decisionContext,
261
268
  );
262
269
  this.pending.delete(runId);
263
270
  return 'applied';