@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
@@ -0,0 +1,533 @@
1
+ /**
2
+ * Tests that the voice bridge consumes scoped approval grants via the
3
+ * unified approval primitive before auto-denying non-guardian callers.
4
+ *
5
+ * Some confirmation_request events originate from proxy/network paths
6
+ * (e.g. PermissionPrompter in createProxyApprovalCallback) that bypass
7
+ * the pre-exec gate. The bridge must check for a matching scoped grant
8
+ * and allow the confirmation if one exists.
9
+ *
10
+ * Verifies:
11
+ * 1. Non-guardian confirmation requests are auto-allowed when a
12
+ * matching grant exists (bridge consumes it via the primitive).
13
+ * 2. Non-guardian confirmation requests are auto-denied when no
14
+ * matching grant exists.
15
+ * 3. Guardian auto-allow path remains unchanged.
16
+ * 4. Grants are revoked on call end (controller.destroy).
17
+ */
18
+
19
+ import { mkdtempSync, rmSync } from 'node:fs';
20
+ import { tmpdir } from 'node:os';
21
+ import { join } from 'node:path';
22
+
23
+ import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test';
24
+
25
+ const testDir = mkdtempSync(join(tmpdir(), 'voice-scoped-grant-consumer-test-'));
26
+
27
+ // ── Platform + logger mocks (must come before any source imports) ────
28
+
29
+ mock.module('../util/platform.js', () => ({
30
+ getRootDir: () => testDir,
31
+ getDataDir: () => testDir,
32
+ isMacOS: () => process.platform === 'darwin',
33
+ isLinux: () => process.platform === 'linux',
34
+ isWindows: () => process.platform === 'win32',
35
+ getSocketPath: () => join(testDir, 'test.sock'),
36
+ getPidPath: () => join(testDir, 'test.pid'),
37
+ getDbPath: () => join(testDir, 'test.db'),
38
+ getLogPath: () => join(testDir, 'test.log'),
39
+ ensureDataDir: () => {},
40
+ readHttpToken: () => null,
41
+ }));
42
+
43
+ mock.module('../util/logger.js', () => ({
44
+ getLogger: () =>
45
+ new Proxy({} as Record<string, unknown>, {
46
+ get: () => () => {},
47
+ }),
48
+ isDebug: () => false,
49
+ truncateForLog: (value: string) => value,
50
+ }));
51
+
52
+ // ── Config mock ─────────────────────────────────────────────────────
53
+
54
+ mock.module('../config/loader.js', () => ({
55
+ getConfig: () => ({
56
+ provider: 'anthropic',
57
+ providerOrder: ['anthropic'],
58
+ apiKeys: { anthropic: 'test-key' },
59
+ calls: {
60
+ enabled: true,
61
+ provider: 'twilio',
62
+ maxDurationSeconds: 12 * 60,
63
+ userConsultTimeoutSeconds: 90,
64
+ userConsultationTimeoutSeconds: 90,
65
+ silenceTimeoutSeconds: 30,
66
+ disclosure: { enabled: false, text: '' },
67
+ safety: { denyCategories: [] },
68
+ model: undefined,
69
+ },
70
+ memory: { enabled: false },
71
+ }),
72
+ }));
73
+
74
+ // ── Secret ingress mock ────────────────────────────────────────────
75
+
76
+ mock.module('../security/secret-ingress.js', () => ({
77
+ checkIngressForSecrets: () => ({ blocked: false }),
78
+ }));
79
+
80
+ // ── Assistant event hub mock ───────────────────────────────────────
81
+
82
+ mock.module('../runtime/assistant-event-hub.js', () => ({
83
+ assistantEventHub: {
84
+ publish: async () => {},
85
+ },
86
+ }));
87
+
88
+ mock.module('../runtime/assistant-event.js', () => ({
89
+ buildAssistantEvent: () => ({}),
90
+ }));
91
+
92
+ // ── Session runtime assembly mock ──────────────────────────────────
93
+
94
+ mock.module('../daemon/session-runtime-assembly.js', () => ({
95
+ resolveChannelCapabilities: () => ({
96
+ supportsRichText: false,
97
+ supportsDynamicUi: false,
98
+ supportsVoiceInput: true,
99
+ }),
100
+ }));
101
+
102
+
103
+ // ── Import source modules after all mocks are registered ────────────
104
+
105
+ import { and, eq } from 'drizzle-orm';
106
+
107
+ import { setVoiceBridgeDeps, startVoiceTurn } from '../calls/voice-session-bridge.js';
108
+ import type { ServerMessage } from '../daemon/ipc-protocol.js';
109
+ import type { GuardianRuntimeContext } from '../daemon/session-runtime-assembly.js';
110
+ import { getDb, initializeDb, resetDb } from '../memory/db.js';
111
+ import { scopedApprovalGrants } from '../memory/schema.js';
112
+ import {
113
+ _internal,
114
+ type CreateScopedApprovalGrantParams,
115
+ revokeScopedApprovalGrantsForContext,
116
+ } from '../memory/scoped-approval-grants.js';
117
+
118
+ const { createScopedApprovalGrant } = _internal;
119
+ import { computeToolApprovalDigest } from '../security/tool-approval-digest.js';
120
+
121
+ initializeDb();
122
+
123
+ afterAll(() => {
124
+ resetDb();
125
+ try { rmSync(testDir, { recursive: true }); } catch { /* best effort */ }
126
+ });
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // Mock session that triggers a confirmation_request on processMessage
130
+ // ---------------------------------------------------------------------------
131
+
132
+ const TOOL_NAME = 'execute_shell';
133
+ const TOOL_INPUT = { command: 'rm -rf /tmp/test' };
134
+ const ASSISTANT_ID = 'self';
135
+ const CONVERSATION_ID = 'conv-voice-grant-test';
136
+ const CALL_SESSION_ID = 'call-session-voice-grant-test';
137
+
138
+ /**
139
+ * Create a mock session that, when runAgentLoop is called, emits a
140
+ * confirmation_request through the updateClient callback before completing.
141
+ */
142
+ function createMockSession(opts?: {
143
+ confirmationRequestId?: string;
144
+ toolName?: string;
145
+ toolInput?: Record<string, unknown>;
146
+ }) {
147
+ const requestId = opts?.confirmationRequestId ?? `req-${crypto.randomUUID()}`;
148
+ const toolName = opts?.toolName ?? TOOL_NAME;
149
+ const toolInput = opts?.toolInput ?? TOOL_INPUT;
150
+
151
+ let clientCallback: ((msg: ServerMessage) => void) | null = null;
152
+ let confirmationDecision: { requestId: string; decision: string; reason?: string } | null = null;
153
+
154
+ const session = {
155
+ isProcessing: () => false,
156
+ memoryPolicy: {},
157
+ setAssistantId: () => {},
158
+ setGuardianContext: () => {},
159
+ setCommandIntent: () => {},
160
+ setTurnChannelContext: () => {},
161
+ setChannelCapabilities: () => {},
162
+ setVoiceCallControlPrompt: () => {},
163
+ currentRequestId: requestId,
164
+ abort: () => {},
165
+ persistUserMessage: async () => 'msg-1',
166
+ updateClient: (cb: (msg: ServerMessage) => void, _reset?: boolean) => {
167
+ clientCallback = cb;
168
+ },
169
+ handleConfirmationResponse: (
170
+ reqId: string,
171
+ decision: string,
172
+ _pattern?: string,
173
+ _scope?: string,
174
+ reason?: string,
175
+ ) => {
176
+ confirmationDecision = { requestId: reqId, decision, reason };
177
+ },
178
+ handleSecretResponse: () => {},
179
+ runAgentLoop: async (
180
+ _content: string,
181
+ _messageId: string,
182
+ broadcastFn: (msg: ServerMessage) => void,
183
+ ) => {
184
+ // Emit a confirmation_request through the client callback
185
+ if (clientCallback) {
186
+ clientCallback({
187
+ type: 'confirmation_request',
188
+ requestId,
189
+ toolName,
190
+ input: toolInput,
191
+ riskLevel: 'medium',
192
+ allowlistOptions: [],
193
+ scopeOptions: [],
194
+ } as ServerMessage);
195
+ }
196
+ // Then complete the turn
197
+ broadcastFn({ type: 'message_complete' } as ServerMessage);
198
+ },
199
+ };
200
+
201
+ return {
202
+ session,
203
+ requestId,
204
+ getConfirmationDecision: () => confirmationDecision,
205
+ };
206
+ }
207
+
208
+ // ---------------------------------------------------------------------------
209
+ // Setup: inject mock deps into voice-session-bridge
210
+ // ---------------------------------------------------------------------------
211
+
212
+ function setupBridgeDeps(sessionFactory: () => ReturnType<typeof createMockSession>['session']) {
213
+ let currentSession: ReturnType<typeof createMockSession>['session'] | null = null;
214
+ setVoiceBridgeDeps({
215
+ getOrCreateSession: async () => {
216
+ currentSession = sessionFactory();
217
+ return currentSession as any;
218
+ },
219
+ resolveAttachments: () => [],
220
+ deriveDefaultStrictSideEffects: () => true,
221
+ });
222
+ }
223
+
224
+ // ---------------------------------------------------------------------------
225
+ // Helpers
226
+ // ---------------------------------------------------------------------------
227
+
228
+ function clearTables(): void {
229
+ const db = getDb();
230
+ try { db.run('DELETE FROM scoped_approval_grants'); } catch { /* table may not exist */ }
231
+ }
232
+
233
+ function grantParams(overrides: Partial<CreateScopedApprovalGrantParams> = {}): CreateScopedApprovalGrantParams {
234
+ const futureExpiry = new Date(Date.now() + 60_000).toISOString();
235
+ return {
236
+ assistantId: ASSISTANT_ID,
237
+ scopeMode: 'tool_signature',
238
+ toolName: TOOL_NAME,
239
+ inputDigest: computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT),
240
+ requestChannel: 'voice',
241
+ decisionChannel: 'telegram',
242
+ executionChannel: 'voice',
243
+ conversationId: CONVERSATION_ID,
244
+ callSessionId: CALL_SESSION_ID,
245
+ expiresAt: futureExpiry,
246
+ ...overrides,
247
+ };
248
+ }
249
+
250
+ // ===========================================================================
251
+ // Tests
252
+ // ===========================================================================
253
+
254
+ describe('voice bridge confirmation handling (grant consumption via primitive)', () => {
255
+ beforeEach(() => {
256
+ clearTables();
257
+ });
258
+
259
+ test('non-guardian with matching grant: auto-allowed (bridge consumes grant via primitive)', async () => {
260
+ // A matching grant should be consumed and the confirmation allowed.
261
+ // This covers proxy/network confirmation requests that bypass the pre-exec gate.
262
+ createScopedApprovalGrant(grantParams());
263
+
264
+ const mockData = createMockSession();
265
+ setupBridgeDeps(() => mockData.session);
266
+
267
+ const guardianContext: GuardianRuntimeContext = {
268
+ sourceChannel: 'voice',
269
+ actorRole: 'non-guardian',
270
+ requesterExternalUserId: 'caller-123',
271
+ };
272
+
273
+ await startVoiceTurn({
274
+ conversationId: CONVERSATION_ID,
275
+ callSessionId: CALL_SESSION_ID,
276
+ content: 'test utterance',
277
+ assistantId: ASSISTANT_ID,
278
+ guardianContext,
279
+ isInbound: true,
280
+ onTextDelta: () => {},
281
+ onComplete: () => {},
282
+ onError: () => {},
283
+ });
284
+
285
+ // Wait for the async agent loop to finish
286
+ await new Promise(resolve => setTimeout(resolve, 100));
287
+
288
+ const decision = mockData.getConfirmationDecision();
289
+ expect(decision).not.toBeNull();
290
+ expect(decision!.decision).toBe('allow');
291
+ expect(decision!.reason).toContain('guardian pre-approved via scoped grant');
292
+
293
+ // The grant should be consumed (no longer active)
294
+ const db = getDb();
295
+ const activeGrants = db.select()
296
+ .from(scopedApprovalGrants)
297
+ .where(eq(scopedApprovalGrants.status, 'active'))
298
+ .all();
299
+ expect(activeGrants.length).toBe(0);
300
+ });
301
+
302
+ test('non-guardian without grant: auto-denied', async () => {
303
+ // No grant created
304
+
305
+ const mockData = createMockSession();
306
+ setupBridgeDeps(() => mockData.session);
307
+
308
+ const guardianContext: GuardianRuntimeContext = {
309
+ sourceChannel: 'voice',
310
+ actorRole: 'non-guardian',
311
+ requesterExternalUserId: 'caller-123',
312
+ };
313
+
314
+ await startVoiceTurn({
315
+ conversationId: CONVERSATION_ID,
316
+ callSessionId: CALL_SESSION_ID,
317
+ content: 'test utterance',
318
+ assistantId: ASSISTANT_ID,
319
+ guardianContext,
320
+ isInbound: true,
321
+ onTextDelta: () => {},
322
+ onComplete: () => {},
323
+ onError: () => {},
324
+ });
325
+
326
+ await new Promise(resolve => setTimeout(resolve, 100));
327
+
328
+ const decision = mockData.getConfirmationDecision();
329
+ expect(decision).not.toBeNull();
330
+ expect(decision!.decision).toBe('deny');
331
+ expect(decision!.reason).toContain('Permission denied');
332
+ });
333
+
334
+ test('non-guardian with mismatched tool name: auto-denied', async () => {
335
+ // Create a grant for a different tool
336
+ createScopedApprovalGrant(grantParams({
337
+ toolName: 'read_file',
338
+ inputDigest: computeToolApprovalDigest('read_file', TOOL_INPUT),
339
+ }));
340
+
341
+ const mockData = createMockSession();
342
+ setupBridgeDeps(() => mockData.session);
343
+
344
+ const guardianContext: GuardianRuntimeContext = {
345
+ sourceChannel: 'voice',
346
+ actorRole: 'non-guardian',
347
+ };
348
+
349
+ await startVoiceTurn({
350
+ conversationId: CONVERSATION_ID,
351
+ callSessionId: CALL_SESSION_ID,
352
+ content: 'test utterance',
353
+ assistantId: ASSISTANT_ID,
354
+ guardianContext,
355
+ isInbound: true,
356
+ onTextDelta: () => {},
357
+ onComplete: () => {},
358
+ onError: () => {},
359
+ });
360
+
361
+ await new Promise(resolve => setTimeout(resolve, 100));
362
+
363
+ const decision = mockData.getConfirmationDecision();
364
+ expect(decision).not.toBeNull();
365
+ expect(decision!.decision).toBe('deny');
366
+ });
367
+
368
+ test('guardian caller: auto-allowed regardless of grants', async () => {
369
+ // No grant needed — guardian should auto-allow
370
+
371
+ const mockData = createMockSession();
372
+ setupBridgeDeps(() => mockData.session);
373
+
374
+ const guardianContext: GuardianRuntimeContext = {
375
+ sourceChannel: 'voice',
376
+ actorRole: 'guardian',
377
+ };
378
+
379
+ await startVoiceTurn({
380
+ conversationId: CONVERSATION_ID,
381
+ callSessionId: CALL_SESSION_ID,
382
+ content: 'test utterance',
383
+ assistantId: ASSISTANT_ID,
384
+ guardianContext,
385
+ isInbound: true,
386
+ onTextDelta: () => {},
387
+ onComplete: () => {},
388
+ onError: () => {},
389
+ });
390
+
391
+ await new Promise(resolve => setTimeout(resolve, 100));
392
+
393
+ const decision = mockData.getConfirmationDecision();
394
+ expect(decision).not.toBeNull();
395
+ expect(decision!.decision).toBe('allow');
396
+ expect(decision!.reason).toContain('guardian voice call');
397
+ });
398
+
399
+ test('non-guardian with grant for different assistantId: auto-denied', async () => {
400
+ // Create a grant scoped to a different assistant
401
+ createScopedApprovalGrant(grantParams({
402
+ assistantId: 'other-assistant',
403
+ }));
404
+
405
+ const mockData = createMockSession();
406
+ setupBridgeDeps(() => mockData.session);
407
+
408
+ const guardianContext: GuardianRuntimeContext = {
409
+ sourceChannel: 'voice',
410
+ actorRole: 'non-guardian',
411
+ requesterExternalUserId: 'caller-123',
412
+ };
413
+
414
+ await startVoiceTurn({
415
+ conversationId: CONVERSATION_ID,
416
+ callSessionId: CALL_SESSION_ID,
417
+ content: 'test utterance',
418
+ assistantId: ASSISTANT_ID,
419
+ guardianContext,
420
+ isInbound: true,
421
+ onTextDelta: () => {},
422
+ onComplete: () => {},
423
+ onError: () => {},
424
+ });
425
+
426
+ await new Promise(resolve => setTimeout(resolve, 100));
427
+
428
+ const decision = mockData.getConfirmationDecision();
429
+ expect(decision).not.toBeNull();
430
+ expect(decision!.decision).toBe('deny');
431
+ });
432
+
433
+ test('grants revoked when revokeScopedApprovalGrantsForContext is called with callSessionId', () => {
434
+ const db = getDb();
435
+ const testCallSessionId = 'call-session-revoke-test';
436
+
437
+ // Create two grants: one for our call session, one for another
438
+ createScopedApprovalGrant(grantParams({ callSessionId: testCallSessionId }));
439
+ createScopedApprovalGrant(grantParams({ callSessionId: 'other-call-session' }));
440
+
441
+ // Verify both grants are active
442
+ const allActive = db.select()
443
+ .from(scopedApprovalGrants)
444
+ .where(eq(scopedApprovalGrants.status, 'active'))
445
+ .all();
446
+ expect(allActive.length).toBe(2);
447
+
448
+ // Revoke grants for the specific call session (simulates call end)
449
+ const revokedCount = revokeScopedApprovalGrantsForContext({ callSessionId: testCallSessionId });
450
+ expect(revokedCount).toBe(1);
451
+
452
+ // Only the target call session's grant should be revoked
453
+ const activeAfter = db.select()
454
+ .from(scopedApprovalGrants)
455
+ .where(and(
456
+ eq(scopedApprovalGrants.callSessionId, testCallSessionId),
457
+ eq(scopedApprovalGrants.status, 'active'),
458
+ ))
459
+ .all();
460
+ expect(activeAfter.length).toBe(0);
461
+
462
+ const revokedAfter = db.select()
463
+ .from(scopedApprovalGrants)
464
+ .where(and(
465
+ eq(scopedApprovalGrants.callSessionId, testCallSessionId),
466
+ eq(scopedApprovalGrants.status, 'revoked'),
467
+ ))
468
+ .all();
469
+ expect(revokedAfter.length).toBe(1);
470
+
471
+ // The other call session's grant should still be active
472
+ const otherActive = db.select()
473
+ .from(scopedApprovalGrants)
474
+ .where(and(
475
+ eq(scopedApprovalGrants.callSessionId, 'other-call-session'),
476
+ eq(scopedApprovalGrants.status, 'active'),
477
+ ))
478
+ .all();
479
+ expect(otherActive.length).toBe(1);
480
+ });
481
+
482
+ test('grants with null callSessionId are revoked by conversationId', () => {
483
+ const db = getDb();
484
+ const testConversationId = 'conv-revoke-by-conversation';
485
+
486
+ // Simulate the guardian-approval-interception minting path which sets
487
+ // callSessionId: null but always sets conversationId
488
+ createScopedApprovalGrant(grantParams({
489
+ callSessionId: null,
490
+ conversationId: testConversationId,
491
+ }));
492
+ createScopedApprovalGrant(grantParams({
493
+ callSessionId: null,
494
+ conversationId: 'other-conversation',
495
+ }));
496
+
497
+ // Verify both grants are active
498
+ const allActive = db.select()
499
+ .from(scopedApprovalGrants)
500
+ .where(eq(scopedApprovalGrants.status, 'active'))
501
+ .all();
502
+ expect(allActive.length).toBe(2);
503
+
504
+ // callSessionId-based revocation should miss grants with null callSessionId
505
+ // because the filter matches on the column value, not NULL
506
+ const revokedByCallSession = revokeScopedApprovalGrantsForContext({ callSessionId: CALL_SESSION_ID });
507
+ expect(revokedByCallSession).toBe(0);
508
+
509
+ // conversationId-based revocation catches the grant
510
+ const revokedByConversation = revokeScopedApprovalGrantsForContext({ conversationId: testConversationId });
511
+ expect(revokedByConversation).toBe(1);
512
+
513
+ // The target conversation's grant should be revoked
514
+ const revokedAfter = db.select()
515
+ .from(scopedApprovalGrants)
516
+ .where(and(
517
+ eq(scopedApprovalGrants.conversationId, testConversationId),
518
+ eq(scopedApprovalGrants.status, 'revoked'),
519
+ ))
520
+ .all();
521
+ expect(revokedAfter.length).toBe(1);
522
+
523
+ // The other conversation's grant should still be active
524
+ const otherActive = db.select()
525
+ .from(scopedApprovalGrants)
526
+ .where(and(
527
+ eq(scopedApprovalGrants.conversationId, 'other-conversation'),
528
+ eq(scopedApprovalGrants.status, 'active'),
529
+ ))
530
+ .all();
531
+ expect(otherActive.length).toBe(1);
532
+ });
533
+ });
package/src/agent/loop.ts CHANGED
@@ -312,6 +312,31 @@ export class AgentLoop {
312
312
  break;
313
313
  }
314
314
 
315
+ // Guard against dual-control-mode conflicts in a single turn.
316
+ // If the model escalates to foreground computer control, browser_* tools
317
+ // in the same response create competing browser sessions/windows and can
318
+ // thrash renderer CPU. Reject browser_* calls in that turn.
319
+ const hasComputerUseEscalation = toolUseBlocks.some(
320
+ (toolUse) => toolUse.name === 'computer_use_request_control',
321
+ );
322
+ const blockedBrowserToolIds = hasComputerUseEscalation
323
+ ? new Set(
324
+ toolUseBlocks
325
+ .filter((toolUse) => toolUse.name.startsWith('browser_'))
326
+ .map((toolUse) => toolUse.id),
327
+ )
328
+ : new Set<string>();
329
+
330
+ if (blockedBrowserToolIds.size > 0) {
331
+ log.warn(
332
+ {
333
+ blockedBrowserToolCount: blockedBrowserToolIds.size,
334
+ toolNames: toolUseBlocks.map((toolUse) => toolUse.name),
335
+ },
336
+ 'Blocking browser_* tools: computer_use_request_control was requested in same turn',
337
+ );
338
+ }
339
+
315
340
  // Execute all tools concurrently for reduced latency.
316
341
  // Race against the abort signal so cancellation isn't blocked by
317
342
  // stuck tools (e.g. a hung browser navigation).
@@ -319,6 +344,16 @@ export class AgentLoop {
319
344
  toolUseBlocks.map(async (toolUse) => {
320
345
  const toolStart = Date.now();
321
346
 
347
+ if (blockedBrowserToolIds.has(toolUse.id)) {
348
+ return {
349
+ toolUse,
350
+ result: {
351
+ content: 'Error: browser_* tools cannot run in the same turn as computer_use_request_control. Continue using the foreground computer-use session only.',
352
+ isError: true,
353
+ },
354
+ };
355
+ }
356
+
322
357
  const result = await this.toolExecutor!(toolUse.name, toolUse.input, (chunk) => {
323
358
  onEvent({ type: 'tool_output_chunk', toolUseId: toolUse.id, chunk });
324
359
  });
@@ -431,7 +466,7 @@ export class AgentLoop {
431
466
  if (hasTextBlock) {
432
467
  resultBlocks.push({
433
468
  type: 'text',
434
- text: '<system_notice>Your previous text was already displayed to the user in real-time as you generated it. Continue naturally from where you left off do not repeat or rephrase what you already said above.</system_notice>',
469
+ text: '<system_notice>Your previous text was already shown to the user in real time. Do not repeat or rephrase it. Do not narrate retries or internal process chatter ("let me try", "that didn\'t work"). Keep working with tools silently unless you need user input, and only send user-facing text when you have concrete progress or final results.</system_notice>',
435
470
  });
436
471
  }
437
472