@vellumai/assistant 0.3.16 → 0.3.19

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 (114) hide show
  1. package/ARCHITECTURE.md +74 -13
  2. package/README.md +6 -0
  3. package/docs/architecture/http-token-refresh.md +23 -1
  4. package/docs/architecture/security.md +80 -0
  5. package/package.json +1 -1
  6. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +4 -0
  7. package/src/__tests__/access-request-decision.test.ts +4 -7
  8. package/src/__tests__/call-controller.test.ts +170 -0
  9. package/src/__tests__/channel-guardian.test.ts +3 -1
  10. package/src/__tests__/checker.test.ts +139 -48
  11. package/src/__tests__/config-watcher.test.ts +11 -13
  12. package/src/__tests__/conversation-pairing.test.ts +103 -3
  13. package/src/__tests__/guardian-action-conversation-turn.test.ts +1 -1
  14. package/src/__tests__/guardian-action-followup-executor.test.ts +1 -1
  15. package/src/__tests__/guardian-action-grant-mint-consume.test.ts +511 -0
  16. package/src/__tests__/guardian-action-late-reply.test.ts +131 -0
  17. package/src/__tests__/guardian-action-store.test.ts +182 -0
  18. package/src/__tests__/guardian-dispatch.test.ts +180 -0
  19. package/src/__tests__/guardian-grant-minting.test.ts +543 -0
  20. package/src/__tests__/ipc-snapshot.test.ts +22 -0
  21. package/src/__tests__/non-member-access-request.test.ts +1 -2
  22. package/src/__tests__/notification-broadcaster.test.ts +115 -4
  23. package/src/__tests__/notification-decision-strategy.test.ts +2 -1
  24. package/src/__tests__/notification-deep-link.test.ts +44 -1
  25. package/src/__tests__/notification-guardian-path.test.ts +157 -0
  26. package/src/__tests__/notification-thread-candidate-validation.test.ts +215 -0
  27. package/src/__tests__/remote-skill-policy.test.ts +215 -0
  28. package/src/__tests__/scoped-approval-grants.test.ts +521 -0
  29. package/src/__tests__/scoped-grant-security-matrix.test.ts +443 -0
  30. package/src/__tests__/slack-channel-config.test.ts +3 -3
  31. package/src/__tests__/trust-store.test.ts +23 -21
  32. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +5 -7
  33. package/src/__tests__/trusted-contact-multichannel.test.ts +2 -6
  34. package/src/__tests__/trusted-contact-verification.test.ts +9 -9
  35. package/src/__tests__/update-bulletin-state.test.ts +1 -1
  36. package/src/__tests__/update-bulletin.test.ts +66 -3
  37. package/src/__tests__/update-template-contract.test.ts +6 -11
  38. package/src/__tests__/voice-scoped-grant-consumer.test.ts +571 -0
  39. package/src/__tests__/voice-session-bridge.test.ts +109 -9
  40. package/src/calls/call-controller.ts +150 -8
  41. package/src/calls/call-domain.ts +12 -0
  42. package/src/calls/guardian-action-sweep.ts +1 -1
  43. package/src/calls/guardian-dispatch.ts +16 -0
  44. package/src/calls/relay-server.ts +13 -0
  45. package/src/calls/voice-session-bridge.ts +46 -5
  46. package/src/cli/core-commands.ts +41 -1
  47. package/src/config/bundled-skills/notifications/SKILL.md +18 -0
  48. package/src/config/schema.ts +6 -0
  49. package/src/config/skills-schema.ts +27 -0
  50. package/src/config/templates/UPDATES.md +5 -6
  51. package/src/config/update-bulletin-format.ts +2 -0
  52. package/src/config/update-bulletin-state.ts +1 -1
  53. package/src/config/update-bulletin-template-path.ts +6 -0
  54. package/src/config/update-bulletin.ts +21 -6
  55. package/src/daemon/config-watcher.ts +3 -2
  56. package/src/daemon/daemon-control.ts +64 -10
  57. package/src/daemon/handlers/config-channels.ts +18 -0
  58. package/src/daemon/handlers/config-slack-channel.ts +1 -1
  59. package/src/daemon/handlers/identity.ts +45 -25
  60. package/src/daemon/handlers/sessions.ts +1 -1
  61. package/src/daemon/handlers/skills.ts +45 -2
  62. package/src/daemon/ipc-contract/sessions.ts +1 -1
  63. package/src/daemon/ipc-contract/skills.ts +1 -0
  64. package/src/daemon/ipc-contract/workspace.ts +12 -1
  65. package/src/daemon/ipc-contract-inventory.json +1 -0
  66. package/src/daemon/lifecycle.ts +8 -0
  67. package/src/daemon/server.ts +25 -3
  68. package/src/daemon/session-process.ts +450 -184
  69. package/src/daemon/tls-certs.ts +17 -12
  70. package/src/daemon/tool-side-effects.ts +1 -1
  71. package/src/memory/channel-delivery-store.ts +18 -20
  72. package/src/memory/channel-guardian-store.ts +39 -42
  73. package/src/memory/conversation-crud.ts +2 -2
  74. package/src/memory/conversation-queries.ts +2 -2
  75. package/src/memory/conversation-store.ts +24 -25
  76. package/src/memory/db-init.ts +17 -1
  77. package/src/memory/embedding-local.ts +16 -7
  78. package/src/memory/fts-reconciler.ts +41 -26
  79. package/src/memory/guardian-action-store.ts +65 -7
  80. package/src/memory/guardian-verification.ts +1 -0
  81. package/src/memory/jobs-worker.ts +2 -2
  82. package/src/memory/migrations/032-guardian-delivery-conversation-index.ts +15 -0
  83. package/src/memory/migrations/032-notification-delivery-thread-decision.ts +20 -0
  84. package/src/memory/migrations/033-scoped-approval-grants.ts +51 -0
  85. package/src/memory/migrations/034-guardian-action-tool-metadata.ts +12 -0
  86. package/src/memory/migrations/index.ts +6 -2
  87. package/src/memory/schema-migration.ts +1 -0
  88. package/src/memory/schema.ts +36 -1
  89. package/src/memory/scoped-approval-grants.ts +509 -0
  90. package/src/memory/search/semantic.ts +3 -3
  91. package/src/notifications/README.md +158 -17
  92. package/src/notifications/broadcaster.ts +68 -50
  93. package/src/notifications/conversation-pairing.ts +96 -18
  94. package/src/notifications/decision-engine.ts +6 -3
  95. package/src/notifications/deliveries-store.ts +12 -0
  96. package/src/notifications/emit-signal.ts +1 -0
  97. package/src/notifications/thread-candidates.ts +60 -25
  98. package/src/notifications/types.ts +2 -1
  99. package/src/permissions/checker.ts +28 -16
  100. package/src/permissions/defaults.ts +14 -4
  101. package/src/runtime/guardian-action-followup-executor.ts +1 -1
  102. package/src/runtime/guardian-action-grant-minter.ts +97 -0
  103. package/src/runtime/http-server.ts +11 -11
  104. package/src/runtime/routes/access-request-decision.ts +1 -1
  105. package/src/runtime/routes/debug-routes.ts +4 -4
  106. package/src/runtime/routes/guardian-approval-interception.ts +120 -4
  107. package/src/runtime/routes/inbound-message-handler.ts +100 -33
  108. package/src/runtime/routes/integration-routes.ts +2 -2
  109. package/src/security/tool-approval-digest.ts +67 -0
  110. package/src/skills/remote-skill-policy.ts +131 -0
  111. package/src/tools/permission-checker.ts +1 -2
  112. package/src/tools/secret-detection-handler.ts +1 -1
  113. package/src/tools/system/voice-config.ts +1 -1
  114. package/src/version.ts +29 -2
@@ -8,6 +8,18 @@ import type { ServerMessage } from '../daemon/ipc-protocol.js';
8
8
  import type { Session } from '../daemon/session.js';
9
9
 
10
10
  const testDir = mkdtempSync(join(tmpdir(), 'voice-bridge-test-'));
11
+ let mockedConfig: {
12
+ secretDetection: { enabled: boolean };
13
+ calls: { disclosure: { enabled: boolean; text: string } };
14
+ } = {
15
+ secretDetection: { enabled: false },
16
+ calls: {
17
+ disclosure: {
18
+ enabled: false,
19
+ text: '',
20
+ },
21
+ },
22
+ };
11
23
 
12
24
  mock.module('../util/platform.js', () => ({
13
25
  getRootDir: () => testDir,
@@ -29,15 +41,7 @@ mock.module('../util/logger.js', () => ({
29
41
  }));
30
42
 
31
43
  mock.module('../config/loader.js', () => ({
32
- getConfig: () => ({
33
- secretDetection: { enabled: false },
34
- calls: {
35
- disclosure: {
36
- enabled: false,
37
- text: '',
38
- },
39
- },
40
- }),
44
+ getConfig: () => mockedConfig,
41
45
  }));
42
46
 
43
47
  import { setVoiceBridgeDeps, startVoiceTurn } from '../calls/voice-session-bridge.js';
@@ -85,6 +89,15 @@ function injectDeps(sessionFactory: () => Session): void {
85
89
 
86
90
  describe('voice-session-bridge', () => {
87
91
  beforeEach(() => {
92
+ mockedConfig = {
93
+ secretDetection: { enabled: false },
94
+ calls: {
95
+ disclosure: {
96
+ enabled: false,
97
+ text: '',
98
+ },
99
+ },
100
+ };
88
101
  const db = getDb();
89
102
  db.run('DELETE FROM messages');
90
103
  db.run('DELETE FROM conversations');
@@ -415,6 +428,93 @@ describe('voice-session-bridge', () => {
415
428
  expect(capturedGuardianContext).toEqual(guardianCtx);
416
429
  });
417
430
 
431
+ test('inbound non-guardian opener prompt uses pickup framing instead of outbound phrasing', async () => {
432
+ const conversation = createConversation('voice bridge inbound opener framing test');
433
+ const events: ServerMessage[] = [
434
+ { type: 'message_complete', sessionId: conversation.id },
435
+ ];
436
+
437
+ let capturedPrompt: string | null = null;
438
+ const session = {
439
+ ...makeStreamingSession(events),
440
+ setVoiceCallControlPrompt: (prompt: string | null) => {
441
+ if (prompt != null) capturedPrompt = prompt;
442
+ },
443
+ } as unknown as Session;
444
+
445
+ injectDeps(() => session);
446
+
447
+ await startVoiceTurn({
448
+ conversationId: conversation.id,
449
+ content: 'Hello there',
450
+ isInbound: true,
451
+ guardianContext: {
452
+ sourceChannel: 'voice',
453
+ actorRole: 'non-guardian',
454
+ },
455
+ onTextDelta: () => {},
456
+ onComplete: () => {},
457
+ onError: () => {},
458
+ });
459
+
460
+ await new Promise((r) => setTimeout(r, 50));
461
+ if (!capturedPrompt) throw new Error('Expected voice call control prompt to be set');
462
+ const prompt: string = capturedPrompt;
463
+
464
+ expect(prompt).toContain('this is an inbound call you are answering (not a call you initiated)');
465
+ expect(prompt).toContain('Introduce yourself once at the start using your assistant name if you know it');
466
+ expect(prompt).toContain('If your assistant name is not known, skip the name and just identify yourself as the guardian\'s assistant.');
467
+ expect(prompt).toContain('Do NOT say "I\'m calling" or "I\'m calling on behalf of".');
468
+ });
469
+
470
+ test('inbound disclosure guidance is rewritten for pickup context', async () => {
471
+ mockedConfig = {
472
+ secretDetection: { enabled: false },
473
+ calls: {
474
+ disclosure: {
475
+ enabled: true,
476
+ text: 'At the very beginning of the call, introduce yourself as an assistant calling on behalf of the person you represent.',
477
+ },
478
+ },
479
+ };
480
+
481
+ const conversation = createConversation('voice bridge inbound disclosure rewrite test');
482
+ const events: ServerMessage[] = [
483
+ { type: 'message_complete', sessionId: conversation.id },
484
+ ];
485
+
486
+ let capturedPrompt: string | null = null;
487
+ const session = {
488
+ ...makeStreamingSession(events),
489
+ setVoiceCallControlPrompt: (prompt: string | null) => {
490
+ if (prompt != null) capturedPrompt = prompt;
491
+ },
492
+ } as unknown as Session;
493
+
494
+ injectDeps(() => session);
495
+
496
+ await startVoiceTurn({
497
+ conversationId: conversation.id,
498
+ content: 'Hi',
499
+ isInbound: true,
500
+ guardianContext: {
501
+ sourceChannel: 'voice',
502
+ actorRole: 'non-guardian',
503
+ },
504
+ onTextDelta: () => {},
505
+ onComplete: () => {},
506
+ onError: () => {},
507
+ });
508
+
509
+ await new Promise((r) => setTimeout(r, 50));
510
+ if (!capturedPrompt) throw new Error('Expected voice call control prompt to be set');
511
+ const prompt: string = capturedPrompt;
512
+
513
+ expect(prompt).toContain('At the very beginning of the call, introduce yourself as an assistant calling on behalf of the person you represent.');
514
+ expect(prompt).toContain('rewrite any disclosure naturally for pickup context');
515
+ expect(prompt).toContain('Do NOT say "I\'m calling", "I called you", or "I\'m calling on behalf of".');
516
+ });
517
+
418
518
  test('auto-denies confirmation requests for non-guardian voice turns', async () => {
419
519
  const conversation = createConversation('voice bridge auto-deny non-guardian test');
420
520
 
@@ -8,6 +8,7 @@
8
8
  * barge-in, state machine, guardian verification).
9
9
  */
10
10
 
11
+ import { getGatewayInternalBaseUrl } from '../config/env.js';
11
12
  import type { ServerMessage } from '../daemon/ipc-contract.js';
12
13
  import type { GuardianRuntimeContext } from '../daemon/session-runtime-assembly.js';
13
14
  import {
@@ -15,7 +16,9 @@ import {
15
16
  getPendingRequestByCallSessionId,
16
17
  markTimedOutWithReason,
17
18
  } from '../memory/guardian-action-store.js';
19
+ import { revokeScopedApprovalGrantsForContext } from '../memory/scoped-approval-grants.js';
18
20
  import { getLogger } from '../util/logger.js';
21
+ import { readHttpToken } from '../util/platform.js';
19
22
  import { getMaxCallDurationMs, getUserConsultationTimeoutMs, SILENCE_TIMEOUT_MS } from './call-constants.js';
20
23
  import { persistCallCompletionMessage } from './call-conversation-messages.js';
21
24
  import { addPointerMessage, formatDuration } from './call-pointer-messages.js';
@@ -27,10 +30,9 @@ import {
27
30
  recordCallEvent,
28
31
  updateCallSession,
29
32
  } from './call-store.js';
30
- import { getGatewayInternalBaseUrl } from '../config/env.js';
31
- import { readHttpToken } from '../util/platform.js';
32
- import { dispatchGuardianQuestion } from './guardian-dispatch.js';
33
+ import { computeToolApprovalDigest } from '../security/tool-approval-digest.js';
33
34
  import { sendGuardianExpiryNotices } from './guardian-action-sweep.js';
35
+ import { dispatchGuardianQuestion } from './guardian-dispatch.js';
34
36
  import type { RelayConnection } from './relay-server.js';
35
37
  import type { PromptSpeakerContext } from './speaker-identification.js';
36
38
  import { startVoiceTurn, type VoiceTurnHandle } from './voice-session-bridge.js';
@@ -41,6 +43,104 @@ type ControllerState = 'idle' | 'processing' | 'waiting_on_user' | 'speaking';
41
43
 
42
44
  const ASK_GUARDIAN_CAPTURE_REGEX = /\[ASK_GUARDIAN:\s*(.+?)\]/;
43
45
  const ASK_GUARDIAN_MARKER_REGEX = /\[ASK_GUARDIAN:\s*.+?\]/g;
46
+
47
+ // Flexible prefix for ASK_GUARDIAN_APPROVAL — tolerates variable whitespace
48
+ // after the colon so the marker is recognized even if the model omits the
49
+ // space or inserts a newline.
50
+ const ASK_GUARDIAN_APPROVAL_PREFIX_RE = /\[ASK_GUARDIAN_APPROVAL:\s*/;
51
+
52
+ /**
53
+ * Extract a balanced JSON object from text that starts with an
54
+ * ASK_GUARDIAN_APPROVAL prefix. Uses brace counting with string-literal
55
+ * awareness so that `}` or `}]` inside JSON string values does not
56
+ * terminate the match prematurely.
57
+ *
58
+ * Returns the extracted JSON string, the full marker text
59
+ * (prefix + JSON + "]"), and the start index — or null when:
60
+ * - no prefix is found,
61
+ * - braces are unbalanced (still streaming), or
62
+ * - the closing `]` has not yet arrived (prevents stripping
63
+ * the marker body while the bracket leaks into TTS in a later delta).
64
+ */
65
+ function extractBalancedJson(
66
+ text: string,
67
+ ): { json: string; fullMatch: string; startIndex: number } | null {
68
+ const prefixMatch = ASK_GUARDIAN_APPROVAL_PREFIX_RE.exec(text);
69
+ if (!prefixMatch) return null;
70
+
71
+ const prefixIdx = prefixMatch.index;
72
+ const jsonStart = prefixIdx + prefixMatch[0].length;
73
+ if (jsonStart >= text.length || text[jsonStart] !== '{') return null;
74
+
75
+ let depth = 0;
76
+ let inString = false;
77
+ let escape = false;
78
+
79
+ for (let i = jsonStart; i < text.length; i++) {
80
+ const ch = text[i];
81
+
82
+ if (escape) {
83
+ escape = false;
84
+ continue;
85
+ }
86
+
87
+ if (ch === '\\' && inString) {
88
+ escape = true;
89
+ continue;
90
+ }
91
+
92
+ if (ch === '"') {
93
+ inString = !inString;
94
+ continue;
95
+ }
96
+
97
+ if (inString) continue;
98
+
99
+ if (ch === '{') {
100
+ depth++;
101
+ } else if (ch === '}') {
102
+ depth--;
103
+ if (depth === 0) {
104
+ const jsonEnd = i + 1;
105
+ const json = text.slice(jsonStart, jsonEnd);
106
+ // Skip any whitespace between the closing '}' and the expected ']'.
107
+ // Models sometimes emit formatted markers with spaces or newlines
108
+ // before the bracket (e.g. `{ ... }\n]` or `{ ... } ]`).
109
+ let bracketIdx = jsonEnd;
110
+ while (bracketIdx < text.length && /\s/.test(text[bracketIdx])) {
111
+ bracketIdx++;
112
+ }
113
+ // Require the closing ']' to be present before considering this
114
+ // a complete match. If it hasn't arrived yet (streaming), return
115
+ // null so the caller keeps buffering.
116
+ if (bracketIdx >= text.length || text[bracketIdx] !== ']') {
117
+ return null;
118
+ }
119
+ const fullMatchEnd = bracketIdx + 1;
120
+ const fullMatch = text.slice(prefixIdx, fullMatchEnd);
121
+ return { json, fullMatch, startIndex: prefixIdx };
122
+ }
123
+ }
124
+ }
125
+
126
+ return null; // Unbalanced braces — still streaming
127
+ }
128
+
129
+ /**
130
+ * Strip all balanced ASK_GUARDIAN_APPROVAL markers from text, handling
131
+ * nested braces, string literals, and flexible whitespace correctly.
132
+ * Only strips complete markers (prefix + balanced JSON + closing `]`).
133
+ */
134
+ function stripGuardianApprovalMarkers(text: string): string {
135
+ let result = text;
136
+ for (;;) {
137
+ const match = extractBalancedJson(result);
138
+ if (!match) break;
139
+ result = result.slice(0, match.startIndex) + result.slice(match.startIndex + match.fullMatch.length);
140
+ }
141
+ return result;
142
+ }
143
+
44
144
  const USER_ANSWERED_MARKER_REGEX = /\[USER_ANSWERED:\s*.+?\]/g;
45
145
  const USER_INSTRUCTION_MARKER_REGEX = /\[USER_INSTRUCTION:\s*.+?\]/g;
46
146
  const CALL_OPENING_MARKER_REGEX = /\[CALL_OPENING\]/g;
@@ -53,7 +153,8 @@ const CALL_OPENING_ACK_MARKER = '[CALL_OPENING_ACK]';
53
153
  const END_CALL_MARKER = '[END_CALL]';
54
154
 
55
155
  function stripInternalSpeechMarkers(text: string): string {
56
- return text
156
+ let result = stripGuardianApprovalMarkers(text);
157
+ result = result
57
158
  .replace(ASK_GUARDIAN_MARKER_REGEX, '')
58
159
  .replace(USER_ANSWERED_MARKER_REGEX, '')
59
160
  .replace(USER_INSTRUCTION_MARKER_REGEX, '')
@@ -62,6 +163,7 @@ function stripInternalSpeechMarkers(text: string): string {
62
163
  .replace(END_CALL_MARKER_REGEX, '')
63
164
  .replace(GUARDIAN_TIMEOUT_MARKER_REGEX, '')
64
165
  .replace(GUARDIAN_UNAVAILABLE_MARKER_REGEX, '');
166
+ return result;
65
167
  }
66
168
 
67
169
  export class CallController {
@@ -336,6 +438,21 @@ export class CallController {
336
438
  this.abortCurrentTurn();
337
439
  this.currentTurnPromise = null;
338
440
  unregisterCallController(this.callSessionId);
441
+
442
+ // Revoke any scoped approval grants bound to this call session.
443
+ // Revoke by both callSessionId and conversationId because the
444
+ // guardian-approval-interception minting path sets callSessionId: null
445
+ // but always sets conversationId.
446
+ try {
447
+ let revoked = revokeScopedApprovalGrantsForContext({ callSessionId: this.callSessionId });
448
+ revoked += revokeScopedApprovalGrantsForContext({ conversationId: this.conversationId });
449
+ if (revoked > 0) {
450
+ log.info({ callSessionId: this.callSessionId, conversationId: this.conversationId, revokedCount: revoked }, 'Revoked scoped grants on call end');
451
+ }
452
+ } catch (err) {
453
+ log.warn({ err, callSessionId: this.callSessionId }, 'Failed to revoke scoped grants on call end');
454
+ }
455
+
339
456
  log.info({ callSessionId: this.callSessionId }, 'CallController destroyed');
340
457
  }
341
458
 
@@ -414,6 +531,7 @@ export class CallController {
414
531
  // bracketed text (e.g. "[A]", "[note]") doesn't stall TTS.
415
532
  const afterBracket = ttsBuffer;
416
533
  const couldBeControl =
534
+ '[ASK_GUARDIAN_APPROVAL:'.startsWith(afterBracket) ||
417
535
  '[ASK_GUARDIAN:'.startsWith(afterBracket) ||
418
536
  '[USER_ANSWERED:'.startsWith(afterBracket) ||
419
537
  '[USER_INSTRUCTION:'.startsWith(afterBracket) ||
@@ -422,6 +540,7 @@ export class CallController {
422
540
  '[END_CALL]'.startsWith(afterBracket) ||
423
541
  '[GUARDIAN_TIMEOUT]'.startsWith(afterBracket) ||
424
542
  '[GUARDIAN_UNAVAILABLE]'.startsWith(afterBracket) ||
543
+ afterBracket.startsWith('[ASK_GUARDIAN_APPROVAL:') ||
425
544
  afterBracket.startsWith('[ASK_GUARDIAN:') ||
426
545
  afterBracket.startsWith('[USER_ANSWERED:') ||
427
546
  afterBracket.startsWith('[USER_INSTRUCTION:') ||
@@ -472,6 +591,7 @@ export class CallController {
472
591
  // Start the voice turn through the session bridge
473
592
  startVoiceTurn({
474
593
  conversationId: this.conversationId,
594
+ callSessionId: this.callSessionId,
475
595
  content,
476
596
  assistantId: this.assistantId,
477
597
  guardianContext: this.guardianContext ?? undefined,
@@ -528,11 +648,31 @@ export class CallController {
528
648
  }
529
649
  }
530
650
 
531
- // Check for ASK_GUARDIAN pattern
532
- const askMatch = responseText.match(ASK_GUARDIAN_CAPTURE_REGEX);
533
- if (askMatch) {
534
- const questionText = askMatch[1];
651
+ // Check for structured tool-approval ASK_GUARDIAN_APPROVAL first,
652
+ // then informational ASK_GUARDIAN. Uses brace-balanced extraction so
653
+ // `}]` inside JSON string values does not truncate the payload or
654
+ // leak partial JSON into TTS output.
655
+ const approvalMatch = extractBalancedJson(responseText);
656
+ let toolApprovalMeta: { question: string; toolName: string; inputDigest: string } | null = null;
657
+ if (approvalMatch) {
658
+ try {
659
+ const parsed = JSON.parse(approvalMatch.json) as { question?: string; toolName?: string; input?: Record<string, unknown> };
660
+ if (parsed.question && parsed.toolName && parsed.input) {
661
+ const digest = computeToolApprovalDigest(parsed.toolName, parsed.input);
662
+ toolApprovalMeta = { question: parsed.question, toolName: parsed.toolName, inputDigest: digest };
663
+ }
664
+ } catch {
665
+ log.warn({ callSessionId: this.callSessionId }, 'Failed to parse ASK_GUARDIAN_APPROVAL JSON payload');
666
+ }
667
+ }
668
+
669
+ const askMatch = toolApprovalMeta
670
+ ? null // structured approval takes precedence
671
+ : responseText.match(ASK_GUARDIAN_CAPTURE_REGEX);
672
+
673
+ const questionText = toolApprovalMeta?.question ?? (askMatch ? askMatch[1] : null);
535
674
 
675
+ if (questionText) {
536
676
  if (this.isCallerGuardian()) {
537
677
  // Caller IS the guardian — don't dispatch cross-channel.
538
678
  // Queue an instruction so the next turn asks them directly.
@@ -569,6 +709,8 @@ export class CallController {
569
709
  conversationId: session.conversationId,
570
710
  assistantId: this.assistantId,
571
711
  pendingQuestion,
712
+ toolName: toolApprovalMeta?.toolName,
713
+ inputDigest: toolApprovalMeta?.inputDigest,
572
714
  });
573
715
  }
574
716
 
@@ -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';
@@ -489,6 +490,17 @@ export async function cancelCall(input: CancelCallInput): Promise<{ ok: true; se
489
490
  // Expire any pending questions so they don't linger
490
491
  expirePendingQuestions(callSessionId);
491
492
 
493
+ // Revoke any scoped approval grants bound to this call session.
494
+ // Revoke by both callSessionId and conversationId because the
495
+ // guardian-approval-interception minting path sets callSessionId: null
496
+ // but always sets conversationId.
497
+ try {
498
+ revokeScopedApprovalGrantsForContext({ callSessionId });
499
+ revokeScopedApprovalGrantsForContext({ conversationId: session.conversationId });
500
+ } catch (err) {
501
+ log.warn({ err, callSessionId }, 'Failed to revoke scoped grants on call cancel');
502
+ }
503
+
492
504
  // Re-check final status: a concurrent transition (e.g. Twilio callback) may have
493
505
  // moved the session to a terminal state before our update, causing it to be skipped.
494
506
  const updated = getCallSession(callSessionId);
@@ -17,8 +17,8 @@ import {
17
17
  getExpiredGuardianActionRequests,
18
18
  } from '../memory/guardian-action-store.js';
19
19
  import { deliverChannelReply } from '../runtime/gateway-client.js';
20
- import type { GuardianActionCopyGenerator } from '../runtime/http-types.js';
21
20
  import { composeGuardianActionMessageGenerative } from '../runtime/guardian-action-message-composer.js';
21
+ import type { GuardianActionCopyGenerator } from '../runtime/http-types.js';
22
22
  import { getLogger } from '../util/logger.js';
23
23
  import { expirePendingQuestions } from './call-store.js';
24
24
 
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { getActiveBinding } from '../memory/channel-guardian-store.js';
11
11
  import {
12
+ countPendingRequestsByCallSessionId,
12
13
  createGuardianActionDelivery,
13
14
  createGuardianActionRequest,
14
15
  updateDeliveryStatus,
@@ -26,6 +27,10 @@ export interface GuardianDispatchParams {
26
27
  conversationId: string;
27
28
  assistantId: string;
28
29
  pendingQuestion: CallPendingQuestion;
30
+ /** Tool identity for tool-approval requests (absent for informational ASK_GUARDIAN). */
31
+ toolName?: string;
32
+ /** Canonical SHA-256 digest of tool input for tool-approval requests. */
33
+ inputDigest?: string;
29
34
  }
30
35
 
31
36
  function applyDeliveryStatus(deliveryId: string, result: NotificationDeliveryResult): void {
@@ -47,6 +52,8 @@ export async function dispatchGuardianQuestion(params: GuardianDispatchParams):
47
52
  conversationId,
48
53
  assistantId,
49
54
  pendingQuestion,
55
+ toolName,
56
+ inputDigest,
50
57
  } = params;
51
58
 
52
59
  try {
@@ -62,6 +69,8 @@ export async function dispatchGuardianQuestion(params: GuardianDispatchParams):
62
69
  pendingQuestionId: pendingQuestion.id,
63
70
  questionText: pendingQuestion.questionText,
64
71
  expiresAt,
72
+ toolName,
73
+ inputDigest,
65
74
  });
66
75
 
67
76
  log.info(
@@ -69,6 +78,12 @@ export async function dispatchGuardianQuestion(params: GuardianDispatchParams):
69
78
  'Created guardian action request',
70
79
  );
71
80
 
81
+ // Count how many guardian requests are already pending for this call.
82
+ // This count is a candidate-affinity hint: the decision engine uses it
83
+ // to prefer reusing an existing thread when multiple questions arise
84
+ // in the same call session.
85
+ const activeGuardianRequestCount = countPendingRequestsByCallSessionId(callSessionId);
86
+
72
87
  // Route through the canonical notification pipeline. The paired vellum
73
88
  // conversation from this pipeline is the canonical guardian thread.
74
89
  let vellumDeliveryId: string | null = null;
@@ -90,6 +105,7 @@ export async function dispatchGuardianQuestion(params: GuardianDispatchParams):
90
105
  callSessionId,
91
106
  questionText: pendingQuestion.questionText,
92
107
  pendingQuestionId: pendingQuestion.id,
108
+ activeGuardianRequestCount,
93
109
  },
94
110
  dedupeKey: `guardian:${request.id}`,
95
111
  onThreadCreated: (info) => {
@@ -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
  });
@@ -18,7 +18,9 @@ import type { GuardianRuntimeContext } from '../daemon/session-runtime-assembly.
18
18
  import { resolveChannelCapabilities } from '../daemon/session-runtime-assembly.js';
19
19
  import { buildAssistantEvent } from '../runtime/assistant-event.js';
20
20
  import { assistantEventHub } from '../runtime/assistant-event-hub.js';
21
+ import { consumeScopedApprovalGrantByToolSignature } from '../memory/scoped-approval-grants.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. */
@@ -129,7 +133,9 @@ function buildVoiceCallControlPrompt(opts: {
129
133
  const disclosureEnabled = config.calls?.disclosure?.enabled === true;
130
134
  const disclosureText = config.calls?.disclosure?.text?.trim();
131
135
  const disclosureRule = disclosureEnabled && disclosureText
132
- ? `0. ${disclosureText}`
136
+ ? opts.isInbound
137
+ ? `0. ${disclosureText} This is an inbound call you are answering, so rewrite any disclosure naturally for pickup context. Do NOT say "I'm calling", "I called you", or "I'm calling on behalf of".`
138
+ : `0. ${disclosureText}`
133
139
  : '0. Begin the conversation naturally.';
134
140
 
135
141
  const lines: string[] = ['<voice_call_control>'];
@@ -145,7 +151,12 @@ function buildVoiceCallControlPrompt(opts: {
145
151
  '1. Be concise — keep responses to 1-3 sentences. Phone conversations should be brief and natural.',
146
152
  ...(opts.isCallerGuardian
147
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.']
148
- : ['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')]
149
160
  ),
150
161
  );
151
162
 
@@ -174,7 +185,7 @@ function buildVoiceCallControlPrompt(opts: {
174
185
  );
175
186
  } else {
176
187
  lines.push(
177
- '7. If the latest user turn is "(call connected — deliver opening greeting)", greet the caller warmly and ask how you can help. Vary the wording; do not use a fixed template.',
188
+ '7. If the latest user turn is "(call connected — deliver opening greeting)", this is an inbound call you are answering (not a call you initiated). Greet the caller warmly and ask how you can help. Introduce yourself once at the start using your assistant name if you know it (for example: "Hey there, this is Ava, Sam\'s assistant. How can I help?"). If your assistant name is not known, skip the name and just identify yourself as the guardian\'s assistant. Do NOT say "I\'m calling" or "I\'m calling on behalf of". Vary the wording; do not use a fixed template.',
178
189
  );
179
190
  }
180
191
  lines.push(
@@ -192,7 +203,7 @@ function buildVoiceCallControlPrompt(opts: {
192
203
 
193
204
  lines.push(
194
205
  '9. After the opening greeting turn, treat the Task field as background context only — do not re-execute its instructions on subsequent turns.',
195
- '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":{...}}].',
196
207
  '</voice_call_control>',
197
208
  );
198
209
 
@@ -337,9 +348,39 @@ export async function startVoiceTurn(opts: VoiceTurnOptions): Promise<VoiceTurnH
337
348
  session.updateClient((msg: ServerMessage) => {
338
349
  if (msg.type === 'confirmation_request') {
339
350
  if (autoDeny) {
351
+ // Before auto-denying, check if a guardian from another channel
352
+ // has pre-approved this exact tool invocation via a scoped grant.
353
+ const inputDigest = computeToolApprovalDigest(msg.toolName, msg.input);
354
+ const consumeResult = consumeScopedApprovalGrantByToolSignature({
355
+ toolName: msg.toolName,
356
+ inputDigest,
357
+ consumingRequestId: msg.requestId,
358
+ assistantId: opts.assistantId,
359
+ executionChannel: 'voice',
360
+ conversationId: opts.conversationId,
361
+ callSessionId: opts.callSessionId,
362
+ requesterExternalUserId: opts.guardianContext?.requesterExternalUserId,
363
+ });
364
+
365
+ if (consumeResult.ok) {
366
+ log.info(
367
+ { turnId, toolName: msg.toolName, grantId: consumeResult.grant?.id },
368
+ 'Consumed scoped grant — allowing non-guardian voice confirmation',
369
+ );
370
+ session.handleConfirmationResponse(
371
+ msg.requestId,
372
+ 'allow',
373
+ undefined,
374
+ undefined,
375
+ `Permission approved for "${msg.toolName}": guardian pre-approved via scoped grant.`,
376
+ );
377
+ publishToHub(msg);
378
+ return;
379
+ }
380
+
340
381
  log.info(
341
382
  { turnId, toolName: msg.toolName },
342
- 'Auto-denying confirmation request for voice turn (forceStrictSideEffects)',
383
+ 'Auto-denying confirmation request for voice turn (no matching scoped grant)',
343
384
  );
344
385
  session.handleConfirmationResponse(
345
386
  msg.requestId,
@@ -109,7 +109,7 @@ export function registerDevCommand(program: Command): void {
109
109
  .command('dev')
110
110
  .description('Run the daemon in dev mode with auto-restart on file changes')
111
111
  .action(async () => {
112
- const status = await getDaemonStatus();
112
+ let status = await getDaemonStatus();
113
113
  if (status.running) {
114
114
  log.info('Stopping existing daemon...');
115
115
  const stopResult = await stopDaemon();
@@ -117,6 +117,46 @@ export function registerDevCommand(program: Command): void {
117
117
  log.error('Failed to stop existing daemon — process survived SIGKILL');
118
118
  process.exit(1);
119
119
  }
120
+ } else if (status.pid) {
121
+ // PID file references a live process but the socket is unresponsive.
122
+ // This can happen during the daemon startup window before the socket
123
+ // is bound. Wait briefly for it to come up before replacing.
124
+ log.info('Daemon process alive but socket unresponsive — waiting for startup...');
125
+ const maxWait = 5000;
126
+ const interval = 500;
127
+ let waited = 0;
128
+ let resolved = false;
129
+ while (waited < maxWait) {
130
+ await new Promise((r) => setTimeout(r, interval));
131
+ waited += interval;
132
+ status = await getDaemonStatus();
133
+ if (status.running) {
134
+ // Socket came up — stop the daemon normally.
135
+ log.info('Daemon became responsive, stopping it...');
136
+ const stopResult = await stopDaemon();
137
+ if (!stopResult.stopped && stopResult.reason === 'stop_failed') {
138
+ log.error('Failed to stop existing daemon — process survived SIGKILL');
139
+ process.exit(1);
140
+ }
141
+ resolved = true;
142
+ break;
143
+ }
144
+ if (!status.pid) {
145
+ // Process exited on its own — PID file already cleaned up.
146
+ resolved = true;
147
+ break;
148
+ }
149
+ }
150
+ if (!resolved) {
151
+ // Still alive but unresponsive after waiting — stop it via stopDaemon()
152
+ // which handles SIGTERM → SIGKILL escalation and PID file cleanup.
153
+ log.info('Daemon still unresponsive after wait — stopping it...');
154
+ const stopResult = await stopDaemon();
155
+ if (!stopResult.stopped && stopResult.reason === 'stop_failed') {
156
+ log.error('Failed to stop existing daemon — process survived SIGKILL');
157
+ process.exit(1);
158
+ }
159
+ }
120
160
  }
121
161
 
122
162
  const mainPath = `${import.meta.dirname}/../daemon/main.ts`;