@vellumai/assistant 0.3.18 → 0.3.20

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 (202) hide show
  1. package/ARCHITECTURE.md +155 -15
  2. package/Dockerfile +1 -0
  3. package/README.md +40 -4
  4. package/docs/architecture/integrations.md +7 -11
  5. package/docs/architecture/security.md +80 -0
  6. package/package.json +1 -1
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +58 -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 +605 -104
  13. package/src/__tests__/channel-invite-transport.test.ts +264 -0
  14. package/src/__tests__/checker.test.ts +60 -0
  15. package/src/__tests__/cli.test.ts +42 -1
  16. package/src/__tests__/config-schema.test.ts +11 -127
  17. package/src/__tests__/config-watcher.test.ts +0 -8
  18. package/src/__tests__/daemon-lifecycle.test.ts +1 -0
  19. package/src/__tests__/daemon-server-session-init.test.ts +8 -2
  20. package/src/__tests__/diff.test.ts +22 -0
  21. package/src/__tests__/guardian-action-copy-generator.test.ts +5 -0
  22. package/src/__tests__/guardian-action-grant-mint-consume.test.ts +779 -0
  23. package/src/__tests__/guardian-action-late-reply.test.ts +546 -1
  24. package/src/__tests__/guardian-actions-endpoint.test.ts +774 -0
  25. package/src/__tests__/guardian-control-plane-policy.test.ts +36 -3
  26. package/src/__tests__/guardian-dispatch.test.ts +185 -1
  27. package/src/__tests__/guardian-grant-minting.test.ts +532 -0
  28. package/src/__tests__/inbound-invite-redemption.test.ts +367 -0
  29. package/src/__tests__/invite-redemption-service.test.ts +306 -0
  30. package/src/__tests__/ipc-snapshot.test.ts +58 -0
  31. package/src/__tests__/notification-decision-fallback.test.ts +88 -0
  32. package/src/__tests__/remote-skill-policy.test.ts +215 -0
  33. package/src/__tests__/sandbox-diagnostics.test.ts +6 -249
  34. package/src/__tests__/sandbox-host-parity.test.ts +6 -13
  35. package/src/__tests__/scoped-approval-grants.test.ts +521 -0
  36. package/src/__tests__/scoped-grant-security-matrix.test.ts +444 -0
  37. package/src/__tests__/script-proxy-session-manager.test.ts +1 -19
  38. package/src/__tests__/session-load-history-repair.test.ts +169 -2
  39. package/src/__tests__/session-runtime-assembly.test.ts +33 -5
  40. package/src/__tests__/skill-feature-flags-integration.test.ts +171 -0
  41. package/src/__tests__/skill-feature-flags.test.ts +188 -0
  42. package/src/__tests__/skill-load-feature-flag.test.ts +141 -0
  43. package/src/__tests__/skill-mirror-parity.test.ts +1 -0
  44. package/src/__tests__/skill-projection-feature-flag.test.ts +363 -0
  45. package/src/__tests__/system-prompt.test.ts +1 -1
  46. package/src/__tests__/terminal-sandbox.test.ts +142 -9
  47. package/src/__tests__/terminal-tools.test.ts +2 -93
  48. package/src/__tests__/thread-seed-composer.test.ts +18 -0
  49. package/src/__tests__/tool-approval-handler.test.ts +350 -0
  50. package/src/__tests__/trust-store.test.ts +2 -0
  51. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +8 -10
  52. package/src/__tests__/voice-scoped-grant-consumer.test.ts +533 -0
  53. package/src/agent/loop.ts +36 -1
  54. package/src/approvals/approval-primitive.ts +381 -0
  55. package/src/approvals/guardian-decision-primitive.ts +191 -0
  56. package/src/calls/call-controller.ts +276 -212
  57. package/src/calls/call-domain.ts +56 -6
  58. package/src/calls/guardian-dispatch.ts +56 -0
  59. package/src/calls/relay-server.ts +13 -0
  60. package/src/calls/types.ts +1 -1
  61. package/src/calls/voice-session-bridge.ts +59 -4
  62. package/src/cli/core-commands.ts +0 -4
  63. package/src/cli.ts +76 -34
  64. package/src/config/__tests__/feature-flag-registry-guard.test.ts +179 -0
  65. package/src/config/assistant-feature-flags.ts +162 -0
  66. package/src/config/bundled-skills/api-mapping/icon.svg +18 -0
  67. package/src/config/bundled-skills/messaging/TOOLS.json +30 -0
  68. package/src/config/bundled-skills/messaging/tools/slack-delete-message.ts +24 -0
  69. package/src/config/bundled-skills/notifications/SKILL.md +18 -0
  70. package/src/config/bundled-skills/reminder/SKILL.md +49 -2
  71. package/src/config/bundled-skills/time-based-actions/SKILL.md +49 -2
  72. package/src/config/bundled-skills/voice-setup/SKILL.md +122 -0
  73. package/src/config/core-schema.ts +1 -1
  74. package/src/config/env-registry.ts +10 -0
  75. package/src/config/feature-flag-registry.json +61 -0
  76. package/src/config/loader.ts +22 -1
  77. package/src/config/sandbox-schema.ts +0 -39
  78. package/src/config/schema.ts +12 -2
  79. package/src/config/skill-state.ts +34 -0
  80. package/src/config/skills-schema.ts +26 -0
  81. package/src/config/skills.ts +9 -0
  82. package/src/config/system-prompt.ts +110 -46
  83. package/src/config/templates/SOUL.md +1 -1
  84. package/src/config/types.ts +19 -1
  85. package/src/config/vellum-skills/catalog.json +1 -1
  86. package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +1 -0
  87. package/src/config/vellum-skills/sms-setup/SKILL.md +1 -1
  88. package/src/config/vellum-skills/telegram-setup/SKILL.md +1 -1
  89. package/src/config/vellum-skills/trusted-contacts/SKILL.md +104 -3
  90. package/src/config/vellum-skills/twilio-setup/SKILL.md +1 -1
  91. package/src/daemon/config-watcher.ts +0 -1
  92. package/src/daemon/daemon-control.ts +1 -1
  93. package/src/daemon/guardian-invite-intent.ts +124 -0
  94. package/src/daemon/handlers/avatar.ts +68 -0
  95. package/src/daemon/handlers/browser.ts +2 -2
  96. package/src/daemon/handlers/config-channels.ts +18 -0
  97. package/src/daemon/handlers/guardian-actions.ts +120 -0
  98. package/src/daemon/handlers/index.ts +4 -0
  99. package/src/daemon/handlers/sessions.ts +19 -0
  100. package/src/daemon/handlers/shared.ts +3 -1
  101. package/src/daemon/handlers/skills.ts +45 -2
  102. package/src/daemon/install-cli-launchers.ts +58 -13
  103. package/src/daemon/ipc-contract/guardian-actions.ts +53 -0
  104. package/src/daemon/ipc-contract/sessions.ts +8 -2
  105. package/src/daemon/ipc-contract/settings.ts +25 -2
  106. package/src/daemon/ipc-contract/skills.ts +1 -0
  107. package/src/daemon/ipc-contract-inventory.json +10 -0
  108. package/src/daemon/ipc-contract.ts +4 -0
  109. package/src/daemon/lifecycle.ts +6 -2
  110. package/src/daemon/main.ts +1 -0
  111. package/src/daemon/server.ts +1 -0
  112. package/src/daemon/session-lifecycle.ts +52 -7
  113. package/src/daemon/session-memory.ts +45 -0
  114. package/src/daemon/session-process.ts +260 -422
  115. package/src/daemon/session-runtime-assembly.ts +12 -0
  116. package/src/daemon/session-skill-tools.ts +14 -1
  117. package/src/daemon/session-tool-setup.ts +5 -0
  118. package/src/daemon/session.ts +11 -0
  119. package/src/daemon/tool-side-effects.ts +35 -9
  120. package/src/index.ts +0 -2
  121. package/src/memory/conversation-display-order-migration.ts +44 -0
  122. package/src/memory/conversation-queries.ts +2 -0
  123. package/src/memory/conversation-store.ts +91 -0
  124. package/src/memory/db-init.ts +13 -1
  125. package/src/memory/embedding-local.ts +22 -8
  126. package/src/memory/guardian-action-store.ts +133 -2
  127. package/src/memory/guardian-verification.ts +1 -1
  128. package/src/memory/ingress-invite-store.ts +95 -1
  129. package/src/memory/migrations/033-scoped-approval-grants.ts +51 -0
  130. package/src/memory/migrations/034-guardian-action-tool-metadata.ts +12 -0
  131. package/src/memory/migrations/035-guardian-action-supersession.ts +23 -0
  132. package/src/memory/migrations/index.ts +3 -0
  133. package/src/memory/schema.ts +35 -1
  134. package/src/memory/scoped-approval-grants.ts +518 -0
  135. package/src/messaging/providers/slack/client.ts +12 -0
  136. package/src/messaging/providers/slack/types.ts +5 -0
  137. package/src/notifications/decision-engine.ts +49 -12
  138. package/src/notifications/emit-signal.ts +7 -0
  139. package/src/notifications/signal.ts +7 -0
  140. package/src/notifications/thread-seed-composer.ts +2 -1
  141. package/src/permissions/checker.ts +27 -0
  142. package/src/runtime/channel-approval-types.ts +16 -6
  143. package/src/runtime/channel-approvals.ts +19 -15
  144. package/src/runtime/channel-invite-transport.ts +85 -0
  145. package/src/runtime/channel-invite-transports/telegram.ts +105 -0
  146. package/src/runtime/guardian-action-grant-minter.ts +154 -0
  147. package/src/runtime/guardian-action-message-composer.ts +30 -0
  148. package/src/runtime/guardian-decision-types.ts +91 -0
  149. package/src/runtime/http-server.ts +23 -1
  150. package/src/runtime/ingress-service.ts +22 -0
  151. package/src/runtime/invite-redemption-service.ts +181 -0
  152. package/src/runtime/invite-redemption-templates.ts +39 -0
  153. package/src/runtime/routes/call-routes.ts +2 -1
  154. package/src/runtime/routes/guardian-action-routes.ts +206 -0
  155. package/src/runtime/routes/guardian-approval-interception.ts +66 -74
  156. package/src/runtime/routes/inbound-message-handler.ts +568 -409
  157. package/src/runtime/routes/pairing-routes.ts +4 -0
  158. package/src/security/encrypted-store.ts +31 -17
  159. package/src/security/keychain.ts +176 -2
  160. package/src/security/secure-keys.ts +97 -0
  161. package/src/security/tool-approval-digest.ts +67 -0
  162. package/src/skills/remote-skill-policy.ts +131 -0
  163. package/src/tools/browser/browser-execution.ts +2 -2
  164. package/src/tools/browser/browser-manager.ts +46 -32
  165. package/src/tools/browser/browser-screencast.ts +2 -2
  166. package/src/tools/calls/call-start.ts +1 -1
  167. package/src/tools/executor.ts +22 -17
  168. package/src/tools/network/script-proxy/session-manager.ts +1 -5
  169. package/src/tools/skills/load.ts +22 -8
  170. package/src/tools/system/avatar-generator.ts +119 -0
  171. package/src/tools/system/navigate-settings.ts +65 -0
  172. package/src/tools/system/open-system-settings.ts +75 -0
  173. package/src/tools/system/voice-config.ts +121 -32
  174. package/src/tools/terminal/backends/native.ts +40 -19
  175. package/src/tools/terminal/backends/types.ts +3 -3
  176. package/src/tools/terminal/parser.ts +1 -1
  177. package/src/tools/terminal/sandbox-diagnostics.ts +6 -87
  178. package/src/tools/terminal/sandbox.ts +1 -12
  179. package/src/tools/terminal/shell.ts +3 -31
  180. package/src/tools/tool-approval-handler.ts +141 -3
  181. package/src/tools/tool-manifest.ts +6 -0
  182. package/src/tools/types.ts +6 -0
  183. package/src/util/diff.ts +36 -13
  184. package/Dockerfile.sandbox +0 -5
  185. package/src/__tests__/doordash-client.test.ts +0 -187
  186. package/src/__tests__/doordash-session.test.ts +0 -154
  187. package/src/__tests__/signup-e2e.test.ts +0 -354
  188. package/src/__tests__/terminal-sandbox-docker.test.ts +0 -1065
  189. package/src/__tests__/terminal-sandbox.integration.test.ts +0 -180
  190. package/src/cli/doordash.ts +0 -1057
  191. package/src/config/bundled-skills/doordash/SKILL.md +0 -163
  192. package/src/config/templates/LOOKS.md +0 -25
  193. package/src/doordash/cart-queries.ts +0 -787
  194. package/src/doordash/client.ts +0 -1016
  195. package/src/doordash/order-queries.ts +0 -85
  196. package/src/doordash/queries.ts +0 -13
  197. package/src/doordash/query-extractor.ts +0 -94
  198. package/src/doordash/search-queries.ts +0 -203
  199. package/src/doordash/session.ts +0 -84
  200. package/src/doordash/store-queries.ts +0 -246
  201. package/src/doordash/types.ts +0 -367
  202. package/src/tools/terminal/backends/docker.ts +0 -379
@@ -13,6 +13,7 @@ import { getTwilioStatusCallbackUrl,getTwilioVoiceWebhookUrl } from '../inbound/
13
13
  import { getOrCreateConversation } from '../memory/conversation-key-store.js';
14
14
  import { queueGenerateConversationTitle } from '../memory/conversation-title-service.js';
15
15
  import { upsertBinding } from '../memory/external-conversation-store.js';
16
+ import { revokeScopedApprovalGrantsForContext } from '../memory/scoped-approval-grants.js';
16
17
  import { isGuardian } from '../runtime/channel-guardian-service.js';
17
18
  import { getSecureKey } from '../security/secure-keys.js';
18
19
  import { getLogger } from '../util/logger.js';
@@ -71,6 +72,8 @@ export type CancelCallInput = {
71
72
  export type AnswerCallInput = {
72
73
  callSessionId: string;
73
74
  answer: string;
75
+ /** When provided, the answer is matched to this specific pending question/consultation. */
76
+ pendingQuestionId?: string;
74
77
  };
75
78
 
76
79
  export type RelayInstructionInput = {
@@ -489,6 +492,17 @@ export async function cancelCall(input: CancelCallInput): Promise<{ ok: true; se
489
492
  // Expire any pending questions so they don't linger
490
493
  expirePendingQuestions(callSessionId);
491
494
 
495
+ // Revoke any scoped approval grants bound to this call session.
496
+ // Revoke by both callSessionId and conversationId because the
497
+ // guardian-approval-interception minting path sets callSessionId: null
498
+ // but always sets conversationId.
499
+ try {
500
+ revokeScopedApprovalGrantsForContext({ callSessionId });
501
+ revokeScopedApprovalGrantsForContext({ conversationId: session.conversationId });
502
+ } catch (err) {
503
+ log.warn({ err, callSessionId }, 'Failed to revoke scoped grants on call cancel');
504
+ }
505
+
492
506
  // Re-check final status: a concurrent transition (e.g. Twilio callback) may have
493
507
  // moved the session to a terminal state before our update, causing it to be skipped.
494
508
  const updated = getCallSession(callSessionId);
@@ -504,30 +518,66 @@ export async function cancelCall(input: CancelCallInput): Promise<{ ok: true; se
504
518
 
505
519
  /**
506
520
  * Answer a pending question for an active call.
521
+ *
522
+ * When `pendingQuestionId` is provided, the answer is matched to that specific
523
+ * pending question/consultation rather than relying on transient controller
524
+ * state. This allows answers to arrive while the call is active and not paused,
525
+ * as long as the referenced question is still pending.
507
526
  */
508
527
  export async function answerCall(input: AnswerCallInput): Promise<{ ok: true; questionId: string } | CallError> {
509
- const { callSessionId, answer } = input;
528
+ const { callSessionId, answer, pendingQuestionId } = input;
510
529
 
511
530
  if (!answer || typeof answer !== 'string') {
512
531
  return { ok: false, error: 'Missing answer', status: 400 };
513
532
  }
514
533
 
534
+ const controller = getCallController(callSessionId);
535
+ if (!controller) {
536
+ log.warn({ callSessionId }, 'answerCall: no active controller for call session');
537
+ return { ok: false, error: 'No active controller for this call', status: 409 };
538
+ }
539
+
540
+ // When a specific question is targeted, validate it matches the active
541
+ // consultation. This prevents stale or duplicate answers from being
542
+ // applied to the wrong consultation.
543
+ if (pendingQuestionId) {
544
+ const activeQuestionId = controller.getPendingConsultationQuestionId();
545
+ if (!activeQuestionId) {
546
+ log.warn(
547
+ { callSessionId, pendingQuestionId },
548
+ 'answerCall: pendingQuestionId provided but no consultation is active',
549
+ );
550
+ return { ok: false, error: 'Referenced question is no longer pending', status: 409 };
551
+ }
552
+ if (activeQuestionId !== pendingQuestionId) {
553
+ log.warn(
554
+ { callSessionId, pendingQuestionId, activeQuestionId },
555
+ 'answerCall: pendingQuestionId does not match active consultation',
556
+ );
557
+ return { ok: false, error: 'Referenced question is stale — a newer consultation has superseded it', status: 409 };
558
+ }
559
+ }
560
+
561
+ // Look up the pending question in the store for record-keeping
515
562
  const question = getPendingQuestion(callSessionId);
516
563
  if (!question) {
517
564
  return { ok: false, error: 'No pending question found', status: 404 };
518
565
  }
519
566
 
520
- const controller = getCallController(callSessionId);
521
- if (!controller) {
522
- log.warn({ callSessionId }, 'answerCall: no active controller for call session');
523
- return { ok: false, error: 'No active controller for this call', status: 409 };
567
+ // When pendingQuestionId is given, double-check it matches the store record
568
+ if (pendingQuestionId && question.id !== pendingQuestionId) {
569
+ log.warn(
570
+ { callSessionId, pendingQuestionId, storeQuestionId: question.id },
571
+ 'answerCall: store pending question does not match requested pendingQuestionId',
572
+ );
573
+ return { ok: false, error: 'Referenced question is stale', status: 409 };
524
574
  }
525
575
 
526
576
  const accepted = await controller.handleUserAnswer(answer);
527
577
  if (!accepted) {
528
578
  log.warn(
529
579
  { callSessionId },
530
- 'answerCall: controller rejected the answer (not in waiting_on_user state)',
580
+ 'answerCall: controller rejected the answer (no pending consultation)',
531
581
  );
532
582
  return { ok: false, error: 'Controller is not waiting for an answer', status: 409 };
533
583
  }
@@ -12,6 +12,7 @@ import {
12
12
  countPendingRequestsByCallSessionId,
13
13
  createGuardianActionDelivery,
14
14
  createGuardianActionRequest,
15
+ getGuardianConversationIdForCallSession,
15
16
  updateDeliveryStatus,
16
17
  } from '../memory/guardian-action-store.js';
17
18
  import { emitNotificationSignal } from '../notifications/emit-signal.js';
@@ -22,11 +23,20 @@ import type { CallPendingQuestion } from './types.js';
22
23
 
23
24
  const log = getLogger('guardian-dispatch');
24
25
 
26
+ // Per-callSessionId serialization lock. Ensures that concurrent dispatches for
27
+ // the same call session are serialized so the second dispatch always sees the
28
+ // delivery row (and thus the guardian conversation ID) persisted by the first.
29
+ const pendingDispatches = new Map<string, Promise<void>>();
30
+
25
31
  export interface GuardianDispatchParams {
26
32
  callSessionId: string;
27
33
  conversationId: string;
28
34
  assistantId: string;
29
35
  pendingQuestion: CallPendingQuestion;
36
+ /** Tool identity for tool-approval requests (absent for informational ASK_GUARDIAN). */
37
+ toolName?: string;
38
+ /** Canonical SHA-256 digest of tool input for tool-approval requests. */
39
+ inputDigest?: string;
30
40
  }
31
41
 
32
42
  function applyDeliveryStatus(deliveryId: string, result: NotificationDeliveryResult): void {
@@ -43,11 +53,38 @@ function applyDeliveryStatus(deliveryId: string, result: NotificationDeliveryRes
43
53
  * Fire-and-forget: errors are logged but do not propagate.
44
54
  */
45
55
  export async function dispatchGuardianQuestion(params: GuardianDispatchParams): Promise<void> {
56
+ const { callSessionId } = params;
57
+
58
+ // Serialize concurrent dispatches for the same call session so the second
59
+ // dispatch always sees the guardian conversation ID persisted by the first.
60
+ const preceding = pendingDispatches.get(callSessionId);
61
+ const current = (preceding ?? Promise.resolve()).then(() =>
62
+ dispatchGuardianQuestionInner(params),
63
+ );
64
+ // Store a suppressed-error variant so the chain never rejects, and keep
65
+ // a stable reference for the cleanup identity check below.
66
+ const suppressed = current.catch(() => {});
67
+ pendingDispatches.set(callSessionId, suppressed);
68
+
69
+ try {
70
+ await current;
71
+ } finally {
72
+ // Clean up the map entry only if it still points to our promise, to avoid
73
+ // removing a later dispatch's entry.
74
+ if (pendingDispatches.get(callSessionId) === suppressed) {
75
+ pendingDispatches.delete(callSessionId);
76
+ }
77
+ }
78
+ }
79
+
80
+ async function dispatchGuardianQuestionInner(params: GuardianDispatchParams): Promise<void> {
46
81
  const {
47
82
  callSessionId,
48
83
  conversationId,
49
84
  assistantId,
50
85
  pendingQuestion,
86
+ toolName,
87
+ inputDigest,
51
88
  } = params;
52
89
 
53
90
  try {
@@ -63,6 +100,8 @@ export async function dispatchGuardianQuestion(params: GuardianDispatchParams):
63
100
  pendingQuestionId: pendingQuestion.id,
64
101
  questionText: pendingQuestion.questionText,
65
102
  expiresAt,
103
+ toolName,
104
+ inputDigest,
66
105
  });
67
106
 
68
107
  log.info(
@@ -76,6 +115,22 @@ export async function dispatchGuardianQuestion(params: GuardianDispatchParams):
76
115
  // in the same call session.
77
116
  const activeGuardianRequestCount = countPendingRequestsByCallSessionId(callSessionId);
78
117
 
118
+ // Look up the vellum conversation used for the first guardian question
119
+ // in this call session. When found, pass it as an affinity hint so the
120
+ // notification pipeline deterministically routes to the same conversation
121
+ // instead of letting the LLM choose a different thread.
122
+ const existingGuardianConversationId = getGuardianConversationIdForCallSession(callSessionId);
123
+ const conversationAffinityHint = existingGuardianConversationId
124
+ ? { vellum: existingGuardianConversationId }
125
+ : undefined;
126
+
127
+ if (existingGuardianConversationId) {
128
+ log.info(
129
+ { callSessionId, existingGuardianConversationId },
130
+ 'Found existing guardian conversation for call session — enforcing thread affinity',
131
+ );
132
+ }
133
+
79
134
  // Route through the canonical notification pipeline. The paired vellum
80
135
  // conversation from this pipeline is the canonical guardian thread.
81
136
  let vellumDeliveryId: string | null = null;
@@ -99,6 +154,7 @@ export async function dispatchGuardianQuestion(params: GuardianDispatchParams):
99
154
  pendingQuestionId: pendingQuestion.id,
100
155
  activeGuardianRequestCount,
101
156
  },
157
+ conversationAffinityHint,
102
158
  dedupeKey: `guardian:${request.id}`,
103
159
  onThreadCreated: (info) => {
104
160
  if (info.sourceEventName !== 'guardian.question' || vellumDeliveryId) return;
@@ -12,6 +12,7 @@ import type { ServerWebSocket } from 'bun';
12
12
 
13
13
  import { getConfig } from '../config/loader.js';
14
14
  import * as conversationStore from '../memory/conversation-store.js';
15
+ import { revokeScopedApprovalGrantsForContext } from '../memory/scoped-approval-grants.js';
15
16
  import {
16
17
  getPendingChallenge,
17
18
  validateAndConsumeChallenge,
@@ -351,6 +352,18 @@ export class RelayConnection {
351
352
  }
352
353
 
353
354
  expirePendingQuestions(this.callSessionId);
355
+
356
+ // Revoke any scoped approval grants bound to this call session.
357
+ // Revoke by both callSessionId and conversationId because the
358
+ // guardian-approval-interception minting path sets callSessionId: null
359
+ // but always sets conversationId.
360
+ try {
361
+ revokeScopedApprovalGrantsForContext({ callSessionId: this.callSessionId });
362
+ revokeScopedApprovalGrantsForContext({ conversationId: session.conversationId });
363
+ } catch (err) {
364
+ log.warn({ err, callSessionId: this.callSessionId }, 'Failed to revoke scoped grants on transport close');
365
+ }
366
+
354
367
  persistCallCompletionMessage(session.conversationId, this.callSessionId).catch((err) => {
355
368
  log.error({ err, conversationId: session.conversationId, callSessionId: this.callSessionId }, 'Failed to persist call completion message');
356
369
  });
@@ -1,5 +1,5 @@
1
1
  export type CallStatus = 'initiated' | 'ringing' | 'in_progress' | 'waiting_on_user' | 'completed' | 'failed' | 'cancelled';
2
- export type CallEventType = 'call_started' | 'call_connected' | 'caller_spoke' | 'assistant_spoke' | 'user_question_asked' | 'user_answered' | 'user_instruction_relayed' | 'call_ended' | 'call_failed' | 'callee_verification_started' | 'callee_verification_succeeded' | 'callee_verification_failed' | 'guardian_voice_verification_started' | 'guardian_voice_verification_succeeded' | 'guardian_voice_verification_failed' | 'outbound_guardian_voice_verification_started' | 'outbound_guardian_voice_verification_succeeded' | 'outbound_guardian_voice_verification_failed' | 'guardian_consultation_timed_out' | 'guardian_unavailable_skipped';
2
+ export type CallEventType = 'call_started' | 'call_connected' | 'caller_spoke' | 'assistant_spoke' | 'user_question_asked' | 'user_answered' | 'user_instruction_relayed' | 'call_ended' | 'call_failed' | 'callee_verification_started' | 'callee_verification_succeeded' | 'callee_verification_failed' | 'guardian_voice_verification_started' | 'guardian_voice_verification_succeeded' | 'guardian_voice_verification_failed' | 'outbound_guardian_voice_verification_started' | 'outbound_guardian_voice_verification_succeeded' | 'outbound_guardian_voice_verification_failed' | 'guardian_consultation_timed_out' | 'guardian_unavailable_skipped' | 'guardian_consult_deferred' | 'guardian_consult_coalesced';
3
3
  export type PendingQuestionStatus = 'pending' | 'answered' | 'expired' | 'cancelled';
4
4
 
5
5
  /**
@@ -10,6 +10,7 @@
10
10
  * dependencies at startup via `setVoiceBridgeDeps()`.
11
11
  */
12
12
 
13
+ import { consumeGrantForInvocation } from '../approvals/approval-primitive.js';
13
14
  import type { ChannelId } from '../channels/types.js';
14
15
  import { getConfig } from '../config/loader.js';
15
16
  import type { ServerMessage } from '../daemon/ipc-protocol.js';
@@ -19,6 +20,7 @@ import { resolveChannelCapabilities } from '../daemon/session-runtime-assembly.j
19
20
  import { buildAssistantEvent } from '../runtime/assistant-event.js';
20
21
  import { assistantEventHub } from '../runtime/assistant-event-hub.js';
21
22
  import { checkIngressForSecrets } from '../security/secret-ingress.js';
23
+ import { computeToolApprovalDigest } from '../security/tool-approval-digest.js';
22
24
  import { IngressBlockedError } from '../util/errors.js';
23
25
  import { getLogger } from '../util/logger.js';
24
26
 
@@ -81,6 +83,8 @@ export interface VoiceRunEventSink {
81
83
  export interface VoiceTurnOptions {
82
84
  /** The conversation ID for this voice call's session. */
83
85
  conversationId: string;
86
+ /** The call session ID for scoped grant matching. */
87
+ callSessionId?: string;
84
88
  /** The transcribed caller utterance or synthetic marker. */
85
89
  content: string;
86
90
  /** Assistant scope for multi-assistant channels. */
@@ -147,7 +151,12 @@ function buildVoiceCallControlPrompt(opts: {
147
151
  '1. Be concise — keep responses to 1-3 sentences. Phone conversations should be brief and natural.',
148
152
  ...(opts.isCallerGuardian
149
153
  ? ['2. You are speaking directly with your guardian (your user). Do NOT use [ASK_GUARDIAN:]. If you need permission, information, or confirmation, ask them directly in the conversation. They can answer you right now.']
150
- : ['2. You can consult your guardian at any time by including [ASK_GUARDIAN: your question here] in your response. When you do, add a natural hold message like "Let me check on that for you."']
154
+ : [[
155
+ '2. You can consult your guardian in two ways:',
156
+ ' - For general questions or information: [ASK_GUARDIAN: your question here]',
157
+ ' - For tool/action permission requests: [ASK_GUARDIAN_APPROVAL: {"question":"Describe what you need permission for","toolName":"the_tool_name","input":{...tool input object...}}]',
158
+ ' Use ASK_GUARDIAN_APPROVAL when you need permission to execute a specific tool or action. Use ASK_GUARDIAN for everything else (general questions, advice, information). When you use either marker, add a natural hold message like "Let me check on that for you."',
159
+ ].join('\n')]
151
160
  ),
152
161
  );
153
162
 
@@ -194,7 +203,7 @@ function buildVoiceCallControlPrompt(opts: {
194
203
 
195
204
  lines.push(
196
205
  '9. After the opening greeting turn, treat the Task field as background context only — do not re-execute its instructions on subsequent turns.',
197
- '10. Do not make up information. If you are unsure, use [ASK_GUARDIAN: your question] to consult your guardian.',
206
+ '10. Do not make up information. If you are unsure, use [ASK_GUARDIAN: your question] to consult your guardian. For tool permission requests, use [ASK_GUARDIAN_APPROVAL: {"question":"...","toolName":"...","input":{...}}].',
198
207
  '</voice_call_control>',
199
208
  );
200
209
 
@@ -298,6 +307,7 @@ export async function startVoiceTurn(opts: VoiceTurnOptions): Promise<VoiceTurnH
298
307
  strictSideEffects,
299
308
  };
300
309
  session.setAssistantId(opts.assistantId ?? 'self');
310
+ session.callSessionId = opts.callSessionId;
301
311
  session.setGuardianContext(opts.guardianContext ?? null);
302
312
  session.setCommandIntent(null);
303
313
  session.setTurnChannelContext({
@@ -336,12 +346,56 @@ export async function startVoiceTurn(opts: VoiceTurnOptions): Promise<VoiceTurnH
336
346
  const autoDeny = !isGuardian;
337
347
  const autoAllow = isGuardian;
338
348
  let lastError: string | null = null;
339
- session.updateClient((msg: ServerMessage) => {
349
+ session.updateClient(async (msg: ServerMessage) => {
340
350
  if (msg.type === 'confirmation_request') {
341
351
  if (autoDeny) {
352
+ // Non-guardian voice callers have no interactive approval UI.
353
+ // The pre-exec gate (tool-approval-handler.ts) handles grant
354
+ // consumption with retry for tool execution confirmations, but
355
+ // some confirmation_request events originate from proxy/network
356
+ // paths (e.g. PermissionPrompter in createProxyApprovalCallback)
357
+ // that bypass the pre-exec gate. We do a single sync lookup here
358
+ // (maxWaitMs: 0) since the primary retry path is in the pre-exec
359
+ // gate; this secondary path just needs a quick check.
360
+ try {
361
+ const inputDigest = computeToolApprovalDigest(msg.toolName, msg.input);
362
+ const consumeResult = await consumeGrantForInvocation({
363
+ requestId: msg.requestId,
364
+ toolName: msg.toolName,
365
+ inputDigest,
366
+ consumingRequestId: msg.requestId,
367
+ assistantId: opts.assistantId ?? 'self',
368
+ executionChannel: 'voice',
369
+ conversationId: opts.conversationId,
370
+ callSessionId: opts.callSessionId,
371
+ requesterExternalUserId: opts.guardianContext?.requesterExternalUserId,
372
+ }, { maxWaitMs: 0 });
373
+
374
+ if (consumeResult.ok) {
375
+ log.info(
376
+ { turnId, toolName: msg.toolName, grantId: consumeResult.grant.id },
377
+ 'Consumed scoped grant — allowing non-guardian voice confirmation',
378
+ );
379
+ session.handleConfirmationResponse(
380
+ msg.requestId,
381
+ 'allow',
382
+ undefined,
383
+ undefined,
384
+ `Permission approved for "${msg.toolName}": guardian pre-approved via scoped grant.`,
385
+ );
386
+ publishToHub(msg);
387
+ return;
388
+ }
389
+ } catch (err) {
390
+ log.error(
391
+ { err, turnId, toolName: msg.toolName },
392
+ 'Error consuming grant in voice confirmation handler — falling through to deny',
393
+ );
394
+ }
395
+
342
396
  log.info(
343
397
  { turnId, toolName: msg.toolName },
344
- 'Auto-denying confirmation request for voice turn (forceStrictSideEffects)',
398
+ 'Auto-denying confirmation request for non-guardian voice turn (no matching scoped grant)',
345
399
  );
346
400
  session.handleConfirmationResponse(
347
401
  msg.requestId,
@@ -390,6 +444,7 @@ export async function startVoiceTurn(opts: VoiceTurnOptions): Promise<VoiceTurnH
390
444
  session.setCommandIntent(null);
391
445
  session.setAssistantId('self');
392
446
  session.setVoiceCallControlPrompt(null);
447
+ session.callSessionId = undefined;
393
448
  // Reset the session's client callback to a no-op so the stale
394
449
  // closure doesn't intercept events from future turns on the same session.
395
450
  session.updateClient(() => {}, true);
@@ -649,11 +649,7 @@ export function registerDoctorCommand(program: Command): void {
649
649
  const { runSandboxDiagnostics } = await import('../tools/terminal/sandbox-diagnostics.js');
650
650
  const sandbox = runSandboxDiagnostics();
651
651
  log.info(`\n Sandbox: ${sandbox.config.enabled ? 'enabled' : 'disabled'}`);
652
- log.info(` Backend: ${sandbox.config.backend}`);
653
652
  log.info(` Reason: ${sandbox.activeBackendReason}`);
654
- if (sandbox.config.backend === 'docker') {
655
- log.info(` Image: ${sandbox.config.dockerImage}`);
656
- }
657
653
  log.info('');
658
654
  for (const check of sandbox.checks) {
659
655
  if (check.ok) {
package/src/cli.ts CHANGED
@@ -43,6 +43,72 @@ export function sanitizeUrlForDisplay(rawUrl: unknown): string {
43
43
  }
44
44
  }
45
45
 
46
+ function stringifyConfirmationInputValue(value: unknown): string {
47
+ if (typeof value === 'string') return value;
48
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
49
+ if (value == null) return 'null';
50
+ try {
51
+ return JSON.stringify(value);
52
+ } catch {
53
+ return String(value);
54
+ }
55
+ }
56
+
57
+ export function formatConfirmationInputLines(input: Record<string, unknown>): string[] {
58
+ const lines: string[] = [];
59
+ for (const key of Object.keys(input).sort()) {
60
+ const rawValue = input[key];
61
+ const value = key.toLowerCase().includes('url') && typeof rawValue === 'string'
62
+ ? sanitizeUrlForDisplay(rawValue)
63
+ : rawValue;
64
+ const rendered = stringifyConfirmationInputValue(value);
65
+ const renderedLines = rendered.split('\n');
66
+ if (renderedLines.length === 0) {
67
+ lines.push(`${key}:`);
68
+ continue;
69
+ }
70
+ lines.push(`${key}: ${renderedLines[0]}`);
71
+ for (const continuation of renderedLines.slice(1)) {
72
+ lines.push(` ${continuation}`);
73
+ }
74
+ }
75
+ return lines;
76
+ }
77
+
78
+ export function formatConfirmationCommandPreview(req: Pick<ConfirmationRequest, 'toolName' | 'input'>): string {
79
+ if (req.toolName === 'bash' || req.toolName === 'host_bash') {
80
+ return String(req.input.command ?? '');
81
+ }
82
+ if (req.toolName === 'file_read' || req.toolName === 'host_file_read') {
83
+ return `read ${req.input.path ?? ''}`;
84
+ }
85
+ if (req.toolName === 'file_write' || req.toolName === 'host_file_write') {
86
+ return `write ${req.input.path ?? ''}`;
87
+ }
88
+ if (req.toolName === 'file_edit' || req.toolName === 'host_file_edit') {
89
+ return `edit ${req.input.path ?? ''}`;
90
+ }
91
+ if (req.toolName === 'web_fetch') {
92
+ return `fetch ${sanitizeUrlForDisplay(req.input.url ?? '')}`;
93
+ }
94
+ if (req.toolName === 'browser_navigate') {
95
+ return `navigate ${sanitizeUrlForDisplay(req.input.url ?? '')}`;
96
+ }
97
+ if (req.toolName === 'browser_close') {
98
+ return req.input.close_all_pages ? 'close all browser pages' : 'close browser page';
99
+ }
100
+ if (req.toolName === 'browser_click') {
101
+ return `click ${req.input.element_id ?? req.input.selector ?? ''}`;
102
+ }
103
+ if (req.toolName === 'browser_type') {
104
+ return `type into ${req.input.element_id ?? req.input.selector ?? ''}`;
105
+ }
106
+ if (req.toolName === 'browser_press_key') {
107
+ return `press "${req.input.key ?? ''}"`;
108
+ }
109
+ return req.toolName;
110
+ }
111
+
46
112
 
47
113
  export async function startCli(): Promise<void> {
48
114
  const socketPath = getSocketPath();
@@ -138,48 +204,24 @@ export async function startCli(): Promise<void> {
138
204
  return false;
139
205
  }
140
206
 
141
- function formatCommandPreview(req: ConfirmationRequest): string {
142
- if (req.toolName === 'bash') {
143
- return String(req.input.command ?? '');
144
- }
145
- if (req.toolName === 'file_read') {
146
- return `read ${req.input.path ?? ''}`;
147
- }
148
- if (req.toolName === 'file_write') {
149
- return `write ${req.input.path ?? ''}`;
150
- }
151
- if (req.toolName === 'web_fetch') {
152
- return `fetch ${sanitizeUrlForDisplay(req.input.url ?? '')}`;
153
- }
154
- if (req.toolName === 'browser_navigate') {
155
- return `navigate ${sanitizeUrlForDisplay(req.input.url ?? '')}`;
156
- }
157
- if (req.toolName === 'browser_close') {
158
- return req.input.close_all_pages ? 'close all browser pages' : 'close browser page';
159
- }
160
- if (req.toolName === 'browser_click') {
161
- return `click ${req.input.element_id ?? req.input.selector ?? ''}`;
162
- }
163
- if (req.toolName === 'browser_type') {
164
- return `type into ${req.input.element_id ?? req.input.selector ?? ''}`;
165
- }
166
- if (req.toolName === 'browser_press_key') {
167
- return `press "${req.input.key ?? ''}"`;
168
- }
169
- return `${req.toolName}: ${truncate(JSON.stringify(req.input), 80)}`;
170
- }
171
-
172
207
  function renderConfirmationPrompt(req: ConfirmationRequest): void {
173
- const preview = formatCommandPreview(req);
208
+ const preview = formatConfirmationCommandPreview(req);
209
+ const inputLines = formatConfirmationInputLines(req.input);
174
210
  process.stdout.write('\n');
175
211
  process.stdout.write(`\u250C ${req.toolName}: ${preview}\n`);
176
212
  process.stdout.write(`\u2502 Risk: ${req.riskLevel}${req.sandboxed ? ' [sandboxed]' : ''}\n`);
177
213
  if (req.executionTarget) {
178
214
  process.stdout.write(`\u2502 Target: ${req.executionTarget}\n`);
179
215
  }
216
+ if (inputLines.length > 0) {
217
+ process.stdout.write(`\u2502\n`);
218
+ for (const line of inputLines) {
219
+ process.stdout.write(`\u2502 ${line}\n`);
220
+ }
221
+ }
180
222
  if (req.diff) {
181
223
  const diffOutput = req.diff.isNewFile
182
- ? formatNewFileDiff(req.diff.newContent, req.diff.filePath)
224
+ ? formatNewFileDiff(req.diff.newContent, req.diff.filePath, null)
183
225
  : formatDiff(req.diff.oldContent, req.diff.newContent, req.diff.filePath);
184
226
  if (diffOutput) {
185
227
  process.stdout.write(`\u2502\n`);
@@ -506,7 +548,7 @@ export async function startCli(): Promise<void> {
506
548
  toolStreaming = false;
507
549
  if (msg.diff) {
508
550
  const diffOutput = msg.diff.isNewFile
509
- ? formatNewFileDiff(msg.diff.newContent, msg.diff.filePath)
551
+ ? formatNewFileDiff(msg.diff.newContent, msg.diff.filePath, null)
510
552
  : formatDiff(msg.diff.oldContent, msg.diff.newContent, msg.diff.filePath);
511
553
  if (diffOutput) {
512
554
  process.stdout.write(diffOutput);