@vellumai/assistant 0.3.19 → 0.3.21

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 (199) hide show
  1. package/ARCHITECTURE.md +151 -15
  2. package/Dockerfile +1 -0
  3. package/README.md +40 -4
  4. package/bun.lock +139 -2
  5. package/docs/architecture/integrations.md +7 -11
  6. package/package.json +2 -1
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +54 -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 +439 -108
  13. package/src/__tests__/channel-invite-transport.test.ts +264 -0
  14. package/src/__tests__/cli.test.ts +42 -1
  15. package/src/__tests__/config-schema.test.ts +11 -127
  16. package/src/__tests__/config-watcher.test.ts +0 -8
  17. package/src/__tests__/daemon-lifecycle.test.ts +1 -0
  18. package/src/__tests__/daemon-server-session-init.test.ts +8 -2
  19. package/src/__tests__/diff.test.ts +22 -0
  20. package/src/__tests__/guardian-action-copy-generator.test.ts +5 -0
  21. package/src/__tests__/guardian-action-grant-mint-consume.test.ts +300 -32
  22. package/src/__tests__/guardian-action-late-reply.test.ts +546 -1
  23. package/src/__tests__/guardian-actions-endpoint.test.ts +774 -0
  24. package/src/__tests__/guardian-control-plane-policy.test.ts +36 -3
  25. package/src/__tests__/guardian-dispatch.test.ts +124 -0
  26. package/src/__tests__/guardian-grant-minting.test.ts +6 -17
  27. package/src/__tests__/inbound-invite-redemption.test.ts +367 -0
  28. package/src/__tests__/invite-redemption-service.test.ts +306 -0
  29. package/src/__tests__/ipc-snapshot.test.ts +57 -0
  30. package/src/__tests__/notification-decision-fallback.test.ts +88 -0
  31. package/src/__tests__/sandbox-diagnostics.test.ts +6 -249
  32. package/src/__tests__/sandbox-host-parity.test.ts +6 -13
  33. package/src/__tests__/scoped-approval-grants.test.ts +6 -6
  34. package/src/__tests__/scoped-grant-security-matrix.test.ts +5 -4
  35. package/src/__tests__/script-proxy-session-manager.test.ts +1 -19
  36. package/src/__tests__/session-load-history-repair.test.ts +169 -2
  37. package/src/__tests__/session-runtime-assembly.test.ts +33 -5
  38. package/src/__tests__/skill-feature-flags-integration.test.ts +171 -0
  39. package/src/__tests__/skill-feature-flags.test.ts +188 -0
  40. package/src/__tests__/skill-load-feature-flag.test.ts +141 -0
  41. package/src/__tests__/skill-mirror-parity.test.ts +1 -0
  42. package/src/__tests__/skill-projection-feature-flag.test.ts +363 -0
  43. package/src/__tests__/system-prompt.test.ts +1 -1
  44. package/src/__tests__/terminal-sandbox.test.ts +142 -9
  45. package/src/__tests__/terminal-tools.test.ts +2 -93
  46. package/src/__tests__/thread-seed-composer.test.ts +18 -0
  47. package/src/__tests__/tool-approval-handler.test.ts +350 -0
  48. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +8 -10
  49. package/src/__tests__/voice-scoped-grant-consumer.test.ts +46 -84
  50. package/src/agent/loop.ts +36 -1
  51. package/src/approvals/approval-primitive.ts +381 -0
  52. package/src/approvals/guardian-decision-primitive.ts +191 -0
  53. package/src/calls/call-controller.ts +252 -209
  54. package/src/calls/call-domain.ts +44 -6
  55. package/src/calls/guardian-dispatch.ts +48 -0
  56. package/src/calls/types.ts +1 -1
  57. package/src/calls/voice-session-bridge.ts +46 -30
  58. package/src/cli/core-commands.ts +0 -4
  59. package/src/cli/mcp.ts +58 -0
  60. package/src/cli.ts +76 -34
  61. package/src/config/__tests__/feature-flag-registry-guard.test.ts +179 -0
  62. package/src/config/assistant-feature-flags.ts +162 -0
  63. package/src/config/bundled-skills/api-mapping/icon.svg +18 -0
  64. package/src/config/bundled-skills/messaging/TOOLS.json +30 -0
  65. package/src/config/bundled-skills/messaging/tools/slack-delete-message.ts +24 -0
  66. package/src/config/bundled-skills/notifications/SKILL.md +1 -1
  67. package/src/config/bundled-skills/reminder/SKILL.md +49 -2
  68. package/src/config/bundled-skills/time-based-actions/SKILL.md +49 -2
  69. package/src/config/bundled-skills/voice-setup/SKILL.md +122 -0
  70. package/src/config/core-schema.ts +1 -1
  71. package/src/config/env-registry.ts +10 -0
  72. package/src/config/feature-flag-registry.json +61 -0
  73. package/src/config/loader.ts +22 -1
  74. package/src/config/mcp-schema.ts +46 -0
  75. package/src/config/sandbox-schema.ts +0 -39
  76. package/src/config/schema.ts +18 -2
  77. package/src/config/skill-state.ts +34 -0
  78. package/src/config/skills-schema.ts +0 -1
  79. package/src/config/skills.ts +9 -0
  80. package/src/config/system-prompt.ts +110 -46
  81. package/src/config/templates/SOUL.md +1 -1
  82. package/src/config/types.ts +19 -1
  83. package/src/config/vellum-skills/catalog.json +1 -1
  84. package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +1 -0
  85. package/src/config/vellum-skills/sms-setup/SKILL.md +1 -1
  86. package/src/config/vellum-skills/telegram-setup/SKILL.md +6 -5
  87. package/src/config/vellum-skills/trusted-contacts/SKILL.md +105 -3
  88. package/src/config/vellum-skills/twilio-setup/SKILL.md +1 -1
  89. package/src/daemon/config-watcher.ts +0 -1
  90. package/src/daemon/daemon-control.ts +1 -1
  91. package/src/daemon/guardian-invite-intent.ts +124 -0
  92. package/src/daemon/handlers/avatar.ts +68 -0
  93. package/src/daemon/handlers/browser.ts +2 -2
  94. package/src/daemon/handlers/guardian-actions.ts +120 -0
  95. package/src/daemon/handlers/index.ts +4 -0
  96. package/src/daemon/handlers/sessions.ts +19 -0
  97. package/src/daemon/handlers/shared.ts +3 -1
  98. package/src/daemon/install-cli-launchers.ts +58 -13
  99. package/src/daemon/ipc-contract/guardian-actions.ts +53 -0
  100. package/src/daemon/ipc-contract/sessions.ts +8 -2
  101. package/src/daemon/ipc-contract/settings.ts +25 -2
  102. package/src/daemon/ipc-contract-inventory.json +10 -0
  103. package/src/daemon/ipc-contract.ts +4 -0
  104. package/src/daemon/lifecycle.ts +14 -2
  105. package/src/daemon/main.ts +1 -0
  106. package/src/daemon/providers-setup.ts +26 -1
  107. package/src/daemon/server.ts +1 -0
  108. package/src/daemon/session-lifecycle.ts +52 -7
  109. package/src/daemon/session-memory.ts +45 -0
  110. package/src/daemon/session-process.ts +258 -432
  111. package/src/daemon/session-runtime-assembly.ts +12 -0
  112. package/src/daemon/session-skill-tools.ts +14 -1
  113. package/src/daemon/session-tool-setup.ts +5 -0
  114. package/src/daemon/session.ts +11 -0
  115. package/src/daemon/shutdown-handlers.ts +11 -0
  116. package/src/daemon/tool-side-effects.ts +35 -9
  117. package/src/index.ts +2 -2
  118. package/src/mcp/client.ts +152 -0
  119. package/src/mcp/manager.ts +139 -0
  120. package/src/memory/conversation-display-order-migration.ts +44 -0
  121. package/src/memory/conversation-queries.ts +2 -0
  122. package/src/memory/conversation-store.ts +91 -0
  123. package/src/memory/db-init.ts +5 -1
  124. package/src/memory/embedding-local.ts +13 -8
  125. package/src/memory/guardian-action-store.ts +125 -2
  126. package/src/memory/ingress-invite-store.ts +95 -1
  127. package/src/memory/migrations/035-guardian-action-supersession.ts +23 -0
  128. package/src/memory/migrations/index.ts +2 -1
  129. package/src/memory/schema.ts +5 -1
  130. package/src/memory/scoped-approval-grants.ts +14 -5
  131. package/src/messaging/providers/slack/client.ts +12 -0
  132. package/src/messaging/providers/slack/types.ts +5 -0
  133. package/src/notifications/decision-engine.ts +49 -12
  134. package/src/notifications/emit-signal.ts +7 -0
  135. package/src/notifications/signal.ts +7 -0
  136. package/src/notifications/thread-seed-composer.ts +2 -1
  137. package/src/runtime/channel-approval-types.ts +16 -6
  138. package/src/runtime/channel-approvals.ts +19 -15
  139. package/src/runtime/channel-invite-transport.ts +85 -0
  140. package/src/runtime/channel-invite-transports/telegram.ts +105 -0
  141. package/src/runtime/guardian-action-grant-minter.ts +92 -35
  142. package/src/runtime/guardian-action-message-composer.ts +30 -0
  143. package/src/runtime/guardian-decision-types.ts +91 -0
  144. package/src/runtime/http-server.ts +23 -1
  145. package/src/runtime/ingress-service.ts +22 -0
  146. package/src/runtime/invite-redemption-service.ts +181 -0
  147. package/src/runtime/invite-redemption-templates.ts +39 -0
  148. package/src/runtime/routes/call-routes.ts +2 -1
  149. package/src/runtime/routes/guardian-action-routes.ts +206 -0
  150. package/src/runtime/routes/guardian-approval-interception.ts +66 -190
  151. package/src/runtime/routes/identity-routes.ts +73 -0
  152. package/src/runtime/routes/inbound-message-handler.ts +486 -394
  153. package/src/runtime/routes/pairing-routes.ts +4 -0
  154. package/src/security/encrypted-store.ts +31 -17
  155. package/src/security/keychain.ts +176 -2
  156. package/src/security/secure-keys.ts +97 -0
  157. package/src/security/tool-approval-digest.ts +1 -1
  158. package/src/tools/browser/browser-execution.ts +2 -2
  159. package/src/tools/browser/browser-manager.ts +46 -32
  160. package/src/tools/browser/browser-screencast.ts +2 -2
  161. package/src/tools/calls/call-start.ts +1 -1
  162. package/src/tools/executor.ts +22 -17
  163. package/src/tools/mcp/mcp-tool-factory.ts +100 -0
  164. package/src/tools/network/script-proxy/session-manager.ts +1 -5
  165. package/src/tools/registry.ts +64 -1
  166. package/src/tools/skills/load.ts +22 -8
  167. package/src/tools/system/avatar-generator.ts +119 -0
  168. package/src/tools/system/navigate-settings.ts +65 -0
  169. package/src/tools/system/open-system-settings.ts +75 -0
  170. package/src/tools/system/voice-config.ts +121 -32
  171. package/src/tools/terminal/backends/native.ts +40 -19
  172. package/src/tools/terminal/backends/types.ts +3 -3
  173. package/src/tools/terminal/parser.ts +1 -1
  174. package/src/tools/terminal/sandbox-diagnostics.ts +6 -87
  175. package/src/tools/terminal/sandbox.ts +1 -12
  176. package/src/tools/terminal/shell.ts +3 -31
  177. package/src/tools/tool-approval-handler.ts +141 -3
  178. package/src/tools/tool-manifest.ts +6 -0
  179. package/src/tools/types.ts +10 -2
  180. package/src/util/diff.ts +36 -13
  181. package/Dockerfile.sandbox +0 -5
  182. package/src/__tests__/doordash-client.test.ts +0 -187
  183. package/src/__tests__/doordash-session.test.ts +0 -154
  184. package/src/__tests__/signup-e2e.test.ts +0 -354
  185. package/src/__tests__/terminal-sandbox-docker.test.ts +0 -1065
  186. package/src/__tests__/terminal-sandbox.integration.test.ts +0 -180
  187. package/src/cli/doordash.ts +0 -1057
  188. package/src/config/bundled-skills/doordash/SKILL.md +0 -163
  189. package/src/config/templates/LOOKS.md +0 -25
  190. package/src/doordash/cart-queries.ts +0 -787
  191. package/src/doordash/client.ts +0 -1016
  192. package/src/doordash/order-queries.ts +0 -85
  193. package/src/doordash/queries.ts +0 -13
  194. package/src/doordash/query-extractor.ts +0 -94
  195. package/src/doordash/search-queries.ts +0 -203
  196. package/src/doordash/session.ts +0 -84
  197. package/src/doordash/store-queries.ts +0 -246
  198. package/src/doordash/types.ts +0 -367
  199. package/src/tools/terminal/backends/docker.ts +0 -379
@@ -175,6 +175,24 @@ describe('composeThreadSeed', () => {
175
175
  expect(seed).toContain('Action required');
176
176
  });
177
177
 
178
+ test('does not duplicate "Action required" when copy already includes it', () => {
179
+ const signal = makeSignal({
180
+ attentionHints: {
181
+ requiresAction: true,
182
+ urgency: 'high',
183
+ isAsyncBackground: false,
184
+ visibleInSourceNow: false,
185
+ },
186
+ });
187
+ const copy = makeCopy({
188
+ title: 'Guardian Question',
189
+ body: 'Action required: What is the gate code?',
190
+ });
191
+ const seed = composeThreadSeed(signal, 'vellum' as NotificationChannel, copy);
192
+ const markerCount = (seed.match(/action required/gi) ?? []).length;
193
+ expect(markerCount).toBe(1);
194
+ });
195
+
178
196
  test('omits "Notification" generic title', () => {
179
197
  const signal = makeSignal();
180
198
  const copy = makeCopy({ title: 'Notification', body: 'Something new.' });
@@ -0,0 +1,350 @@
1
+ import { mkdtempSync, rmSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+
5
+ import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test';
6
+
7
+ const testDir = mkdtempSync(join(tmpdir(), 'tool-approval-handler-test-'));
8
+
9
+ mock.module('../util/platform.js', () => ({
10
+ getDataDir: () => testDir,
11
+ isMacOS: () => process.platform === 'darwin',
12
+ isLinux: () => process.platform === 'linux',
13
+ isWindows: () => process.platform === 'win32',
14
+ getSocketPath: () => join(testDir, 'test.sock'),
15
+ getPidPath: () => join(testDir, 'test.pid'),
16
+ getDbPath: () => join(testDir, 'test.db'),
17
+ getLogPath: () => join(testDir, 'test.log'),
18
+ ensureDataDir: () => {},
19
+ migrateToDataLayout: () => {},
20
+ migrateToWorkspaceLayout: () => {},
21
+ }));
22
+
23
+ mock.module('../util/logger.js', () => ({
24
+ getLogger: () =>
25
+ new Proxy({} as Record<string, unknown>, {
26
+ get: () => () => {},
27
+ }),
28
+ isDebug: () => false,
29
+ truncateForLog: (value: string) => value,
30
+ }));
31
+
32
+ // Mock parental controls — no tools blocked by default
33
+ mock.module('../security/parental-control-store.js', () => ({
34
+ isToolBlocked: () => false,
35
+ }));
36
+
37
+ // Mock guardian control-plane policy — not targeting control-plane by default
38
+ mock.module('../tools/guardian-control-plane-policy.js', () => ({
39
+ enforceGuardianOnlyPolicy: () => ({ denied: false }),
40
+ }));
41
+
42
+ // Mock task run rules — no task run rules by default
43
+ mock.module('../tasks/ephemeral-permissions.js', () => ({
44
+ getTaskRunRules: () => [],
45
+ }));
46
+
47
+ // Mock tool registry — return a fake tool for 'bash'
48
+ const fakeTool = {
49
+ name: 'bash',
50
+ description: 'Run a shell command',
51
+ category: 'shell',
52
+ defaultRiskLevel: 'high',
53
+ getDefinition: () => ({ name: 'bash', description: 'Run a shell command', input_schema: {} }),
54
+ execute: async () => ({ content: 'ok', isError: false }),
55
+ };
56
+
57
+ mock.module('../tools/registry.js', () => ({
58
+ getTool: (name: string) => (name === 'bash' ? fakeTool : undefined),
59
+ getAllTools: () => [fakeTool],
60
+ }));
61
+
62
+ import { mintGrantFromDecision, type MintGrantParams } from '../approvals/approval-primitive.js';
63
+ import { getDb, initializeDb, resetDb } from '../memory/db.js';
64
+ import { scopedApprovalGrants } from '../memory/schema.js';
65
+ import { computeToolApprovalDigest } from '../security/tool-approval-digest.js';
66
+ import { ToolApprovalHandler } from '../tools/tool-approval-handler.js';
67
+ import type { ToolContext, ToolLifecycleEvent } from '../tools/types.js';
68
+
69
+ initializeDb();
70
+
71
+ function clearTables(): void {
72
+ const db = getDb();
73
+ db.delete(scopedApprovalGrants).run();
74
+ }
75
+
76
+ afterAll(() => {
77
+ resetDb();
78
+ try {
79
+ rmSync(testDir, { recursive: true });
80
+ } catch {
81
+ /* best effort */
82
+ }
83
+ });
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Helpers
87
+ // ---------------------------------------------------------------------------
88
+
89
+ function mintParams(overrides: Partial<MintGrantParams> = {}): MintGrantParams {
90
+ const futureExpiry = new Date(Date.now() + 60_000).toISOString();
91
+ return {
92
+ assistantId: 'self',
93
+ scopeMode: 'tool_signature',
94
+ requestChannel: 'telegram',
95
+ decisionChannel: 'telegram',
96
+ expiresAt: futureExpiry,
97
+ ...overrides,
98
+ };
99
+ }
100
+
101
+ function makeContext(overrides: Partial<ToolContext> = {}): ToolContext {
102
+ return {
103
+ workingDir: testDir,
104
+ sessionId: 'session-1',
105
+ conversationId: 'conv-1',
106
+ assistantId: 'self',
107
+ requestId: 'req-1',
108
+ guardianActorRole: 'non-guardian',
109
+ ...overrides,
110
+ };
111
+ }
112
+
113
+ // ===========================================================================
114
+ // TESTS
115
+ // ===========================================================================
116
+
117
+ describe('ToolApprovalHandler / pre-exec gate grant check', () => {
118
+ const handler = new ToolApprovalHandler();
119
+ const events: ToolLifecycleEvent[] = [];
120
+ const emitLifecycleEvent = (event: ToolLifecycleEvent) => { events.push(event); };
121
+
122
+ beforeEach(() => {
123
+ clearTables();
124
+ events.length = 0;
125
+ });
126
+
127
+ test('untrusted actor + matching tool_signature grant -> allow', async () => {
128
+ const toolName = 'bash';
129
+ const input = { command: 'ls -la' };
130
+ const digest = computeToolApprovalDigest(toolName, input);
131
+
132
+ // Mint a grant that matches the invocation
133
+ const mintResult = mintGrantFromDecision(
134
+ mintParams({
135
+ scopeMode: 'tool_signature',
136
+ toolName,
137
+ inputDigest: digest,
138
+ }),
139
+ );
140
+ expect(mintResult.ok).toBe(true);
141
+
142
+ const context = makeContext({ guardianActorRole: 'non-guardian' });
143
+ const result = await handler.checkPreExecutionGates(
144
+ toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
145
+ );
146
+
147
+ expect(result.allowed).toBe(true);
148
+ // No permission_denied events should have been emitted
149
+ const deniedEvents = events.filter((e) => e.type === 'permission_denied');
150
+ expect(deniedEvents.length).toBe(0);
151
+ });
152
+
153
+ test('untrusted actor + no matching grant -> deny with guardian_approval_required', async () => {
154
+ const toolName = 'bash';
155
+ const input = { command: 'rm -rf /' };
156
+
157
+ const context = makeContext({ guardianActorRole: 'non-guardian' });
158
+ const result = await handler.checkPreExecutionGates(
159
+ toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
160
+ );
161
+
162
+ expect(result.allowed).toBe(false);
163
+ if (result.allowed) return;
164
+ expect(result.result.isError).toBe(true);
165
+ expect(result.result.content).toContain('guardian approval');
166
+
167
+ // A permission_denied event should have been emitted
168
+ const deniedEvents = events.filter((e) => e.type === 'permission_denied');
169
+ expect(deniedEvents.length).toBe(1);
170
+ });
171
+
172
+ test('unverified_channel actor + matching grant -> allow', async () => {
173
+ const toolName = 'bash';
174
+ const input = { command: 'echo hello' };
175
+ const digest = computeToolApprovalDigest(toolName, input);
176
+
177
+ mintGrantFromDecision(
178
+ mintParams({
179
+ scopeMode: 'tool_signature',
180
+ toolName,
181
+ inputDigest: digest,
182
+ }),
183
+ );
184
+
185
+ const context = makeContext({ guardianActorRole: 'unverified_channel' });
186
+ const result = await handler.checkPreExecutionGates(
187
+ toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
188
+ );
189
+
190
+ expect(result.allowed).toBe(true);
191
+ });
192
+
193
+ test('unverified_channel actor + no grant -> deny', async () => {
194
+ const toolName = 'bash';
195
+ const input = { command: 'deploy' };
196
+
197
+ const context = makeContext({ guardianActorRole: 'unverified_channel' });
198
+ const result = await handler.checkPreExecutionGates(
199
+ toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
200
+ );
201
+
202
+ expect(result.allowed).toBe(false);
203
+ if (result.allowed) return;
204
+ expect(result.result.content).toContain('verified channel identity');
205
+ });
206
+
207
+ test('grant is one-time: second invocation with same input denied', async () => {
208
+ const toolName = 'bash';
209
+ const input = { command: 'ls' };
210
+ const digest = computeToolApprovalDigest(toolName, input);
211
+
212
+ mintGrantFromDecision(
213
+ mintParams({
214
+ scopeMode: 'tool_signature',
215
+ toolName,
216
+ inputDigest: digest,
217
+ }),
218
+ );
219
+
220
+ const context = makeContext({ guardianActorRole: 'non-guardian' });
221
+
222
+ // First invocation — should consume the grant and allow
223
+ const first = await handler.checkPreExecutionGates(
224
+ toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
225
+ );
226
+ expect(first.allowed).toBe(true);
227
+
228
+ // Second invocation — grant already consumed, should deny
229
+ const second = await handler.checkPreExecutionGates(
230
+ toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
231
+ );
232
+ expect(second.allowed).toBe(false);
233
+ });
234
+
235
+ test('grant with mismatched input digest -> deny', async () => {
236
+ const toolName = 'bash';
237
+ const grantInput = { command: 'ls' };
238
+ const invokeInput = { command: 'rm -rf /' };
239
+ const grantDigest = computeToolApprovalDigest(toolName, grantInput);
240
+
241
+ mintGrantFromDecision(
242
+ mintParams({
243
+ scopeMode: 'tool_signature',
244
+ toolName,
245
+ inputDigest: grantDigest,
246
+ }),
247
+ );
248
+
249
+ const context = makeContext({ guardianActorRole: 'non-guardian' });
250
+ const result = await handler.checkPreExecutionGates(
251
+ toolName, invokeInput, context, 'host', 'high', Date.now(), emitLifecycleEvent,
252
+ );
253
+
254
+ expect(result.allowed).toBe(false);
255
+ });
256
+
257
+ test('expired grant -> deny', async () => {
258
+ const toolName = 'bash';
259
+ const input = { command: 'ls' };
260
+ const digest = computeToolApprovalDigest(toolName, input);
261
+ const pastExpiry = new Date(Date.now() - 60_000).toISOString();
262
+
263
+ mintGrantFromDecision(
264
+ mintParams({
265
+ scopeMode: 'tool_signature',
266
+ toolName,
267
+ inputDigest: digest,
268
+ expiresAt: pastExpiry,
269
+ }),
270
+ );
271
+
272
+ const context = makeContext({ guardianActorRole: 'non-guardian' });
273
+ const result = await handler.checkPreExecutionGates(
274
+ toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
275
+ );
276
+
277
+ expect(result.allowed).toBe(false);
278
+ });
279
+
280
+ test('guardian actor bypasses grant check entirely (no grant needed)', async () => {
281
+ const toolName = 'bash';
282
+ const input = { command: 'deploy' };
283
+
284
+ // No grants minted at all
285
+ const context = makeContext({ guardianActorRole: 'guardian' });
286
+ const result = await handler.checkPreExecutionGates(
287
+ toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
288
+ );
289
+
290
+ // Guardian should pass through — the untrusted gate is not triggered
291
+ expect(result.allowed).toBe(true);
292
+ });
293
+
294
+ test('undefined actor role (desktop/trusted) bypasses grant check', async () => {
295
+ const toolName = 'bash';
296
+ const input = { command: 'deploy' };
297
+
298
+ const context = makeContext({ guardianActorRole: undefined });
299
+ const result = await handler.checkPreExecutionGates(
300
+ toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
301
+ );
302
+
303
+ expect(result.allowed).toBe(true);
304
+ });
305
+
306
+ test('grant with matching request_id scope -> allow', async () => {
307
+ const toolName = 'bash';
308
+ const input = { command: 'ls' };
309
+
310
+ mintGrantFromDecision(
311
+ mintParams({
312
+ scopeMode: 'request_id',
313
+ requestId: 'req-1',
314
+ }),
315
+ );
316
+
317
+ const context = makeContext({ guardianActorRole: 'non-guardian', requestId: 'req-1' });
318
+ const result = await handler.checkPreExecutionGates(
319
+ toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
320
+ );
321
+
322
+ expect(result.allowed).toBe(true);
323
+ });
324
+
325
+ test('grant with context fields (conversationId) must match', async () => {
326
+ const toolName = 'bash';
327
+ const input = { command: 'ls' };
328
+ const digest = computeToolApprovalDigest(toolName, input);
329
+
330
+ mintGrantFromDecision(
331
+ mintParams({
332
+ scopeMode: 'tool_signature',
333
+ toolName,
334
+ inputDigest: digest,
335
+ conversationId: 'conv-other',
336
+ }),
337
+ );
338
+
339
+ // Context conversationId does not match the grant's conversationId
340
+ const context = makeContext({
341
+ guardianActorRole: 'non-guardian',
342
+ conversationId: 'conv-1',
343
+ });
344
+ const result = await handler.checkPreExecutionGates(
345
+ toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
346
+ );
347
+
348
+ expect(result.allowed).toBe(false);
349
+ });
350
+ });
@@ -276,7 +276,9 @@ describe('trusted contact lifecycle notification signals', () => {
276
276
 
277
277
  await handleChannelInbound(guardianReq, undefined, TEST_BEARER_TOKEN);
278
278
 
279
- // Should emit guardian_decision (approved) and verification_sent signals
279
+ // guardian_decision should NOT fire at approval time when verification
280
+ // is still pending — it would cause the notification pipeline to send a
281
+ // premature "approved" message to the guardian's chat.
280
282
  const guardianDecisionSignals = emitSignalCalls.filter(
281
283
  (c) => c.sourceEventName === 'ingress.trusted_contact.guardian_decision',
282
284
  );
@@ -284,19 +286,15 @@ describe('trusted contact lifecycle notification signals', () => {
284
286
  (c) => c.sourceEventName === 'ingress.trusted_contact.verification_sent',
285
287
  );
286
288
 
287
- expect(guardianDecisionSignals.length).toBe(1);
289
+ expect(guardianDecisionSignals.length).toBe(0);
288
290
  expect(verificationSentSignals.length).toBe(1);
289
291
 
290
- // Verify guardian_decision payload
291
- const gdPayload = guardianDecisionSignals[0].contextPayload as Record<string, unknown>;
292
- expect(gdPayload.decision).toBe('approved');
293
- expect(gdPayload.requesterExternalUserId).toBe('requester-user-456');
294
- expect(gdPayload.decidedByExternalUserId).toBe('guardian-user-789');
295
-
296
- // Verify verification_sent payload
297
- const vsPayload = verificationSentSignals[0].contextPayload as Record<string, unknown>;
292
+ // Verify verification_sent payload and that it's suppressed from delivery
293
+ const vsSignal = verificationSentSignals[0];
294
+ const vsPayload = vsSignal.contextPayload as Record<string, unknown>;
298
295
  expect(vsPayload.requesterExternalUserId).toBe('requester-user-456');
299
296
  expect(vsPayload.verificationSessionId).toBeDefined();
297
+ expect((vsSignal.attentionHints as Record<string, unknown>).visibleInSourceNow).toBe(true);
300
298
 
301
299
  // Should NOT emit denied signal
302
300
  const deniedSignals = emitSignalCalls.filter(
@@ -1,20 +1,26 @@
1
1
  /**
2
- * Tests for M4: voice consumer checks scoped grants before auto-denying
3
- * non-guardian confirmation requests.
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.
4
9
  *
5
10
  * Verifies:
6
- * 1. A matching grant allows a non-guardian voice confirmation (exactly once).
7
- * 2. No grant or mismatched grant still auto-denies.
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.
8
15
  * 3. Guardian auto-allow path remains unchanged.
9
16
  * 4. Grants are revoked on call end (controller.destroy).
10
- * 5. Second identical invocation after consume is denied (one-time use).
11
17
  */
12
18
 
13
19
  import { mkdtempSync, rmSync } from 'node:fs';
14
20
  import { tmpdir } from 'node:os';
15
21
  import { join } from 'node:path';
16
22
 
17
- import { afterAll, beforeEach, describe, expect, type Mock, mock, test } from 'bun:test';
23
+ import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test';
18
24
 
19
25
  const testDir = mkdtempSync(join(tmpdir(), 'voice-scoped-grant-consumer-test-'));
20
26
 
@@ -98,17 +104,19 @@ mock.module('../daemon/session-runtime-assembly.js', () => ({
98
104
 
99
105
  import { and, eq } from 'drizzle-orm';
100
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';
101
112
  import {
102
- createScopedApprovalGrant,
113
+ _internal,
103
114
  type CreateScopedApprovalGrantParams,
104
115
  revokeScopedApprovalGrantsForContext,
105
116
  } from '../memory/scoped-approval-grants.js';
106
- import { getDb, initializeDb, resetDb } from '../memory/db.js';
107
- import { conversations, scopedApprovalGrants } from '../memory/schema.js';
117
+
118
+ const { createScopedApprovalGrant } = _internal;
108
119
  import { computeToolApprovalDigest } from '../security/tool-approval-digest.js';
109
- import type { ServerMessage } from '../daemon/ipc-protocol.js';
110
- import { setVoiceBridgeDeps, startVoiceTurn } from '../calls/voice-session-bridge.js';
111
- import type { GuardianRuntimeContext } from '../daemon/session-runtime-assembly.js';
112
120
 
113
121
  initializeDb();
114
122
 
@@ -243,13 +251,14 @@ function grantParams(overrides: Partial<CreateScopedApprovalGrantParams> = {}):
243
251
  // Tests
244
252
  // ===========================================================================
245
253
 
246
- describe('voice scoped grant consumer', () => {
254
+ describe('voice bridge confirmation handling (grant consumption via primitive)', () => {
247
255
  beforeEach(() => {
248
256
  clearTables();
249
257
  });
250
258
 
251
- test('non-guardian with matching grant: consumed and allowed', async () => {
252
- // Create a matching grant
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.
253
262
  createScopedApprovalGrant(grantParams());
254
263
 
255
264
  const mockData = createMockSession();
@@ -261,7 +270,7 @@ describe('voice scoped grant consumer', () => {
261
270
  requesterExternalUserId: 'caller-123',
262
271
  };
263
272
 
264
- const handle = await startVoiceTurn({
273
+ await startVoiceTurn({
265
274
  conversationId: CONVERSATION_ID,
266
275
  callSessionId: CALL_SESSION_ID,
267
276
  content: 'test utterance',
@@ -279,7 +288,15 @@ describe('voice scoped grant consumer', () => {
279
288
  const decision = mockData.getConfirmationDecision();
280
289
  expect(decision).not.toBeNull();
281
290
  expect(decision!.decision).toBe('allow');
282
- expect(decision!.reason).toContain('scoped grant');
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);
283
300
  });
284
301
 
285
302
  test('non-guardian without grant: auto-denied', async () => {
@@ -294,7 +311,7 @@ describe('voice scoped grant consumer', () => {
294
311
  requesterExternalUserId: 'caller-123',
295
312
  };
296
313
 
297
- const handle = await startVoiceTurn({
314
+ await startVoiceTurn({
298
315
  conversationId: CONVERSATION_ID,
299
316
  callSessionId: CALL_SESSION_ID,
300
317
  content: 'test utterance',
@@ -379,13 +396,14 @@ describe('voice scoped grant consumer', () => {
379
396
  expect(decision!.reason).toContain('guardian voice call');
380
397
  });
381
398
 
382
- test('one-time use: second identical invocation after consume is denied', async () => {
383
- // Create a single grant
384
- createScopedApprovalGrant(grantParams());
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
+ }));
385
404
 
386
- // First invocation — should consume the grant and allow
387
- const mockData1 = createMockSession({ confirmationRequestId: 'req-first' });
388
- setupBridgeDeps(() => mockData1.session);
405
+ const mockData = createMockSession();
406
+ setupBridgeDeps(() => mockData.session);
389
407
 
390
408
  const guardianContext: GuardianRuntimeContext = {
391
409
  sourceChannel: 'voice',
@@ -396,29 +414,7 @@ describe('voice scoped grant consumer', () => {
396
414
  await startVoiceTurn({
397
415
  conversationId: CONVERSATION_ID,
398
416
  callSessionId: CALL_SESSION_ID,
399
- content: 'first utterance',
400
- assistantId: ASSISTANT_ID,
401
- guardianContext,
402
- isInbound: true,
403
- onTextDelta: () => {},
404
- onComplete: () => {},
405
- onError: () => {},
406
- });
407
-
408
- await new Promise(resolve => setTimeout(resolve, 100));
409
-
410
- const decision1 = mockData1.getConfirmationDecision();
411
- expect(decision1).not.toBeNull();
412
- expect(decision1!.decision).toBe('allow');
413
-
414
- // Second invocation — grant already consumed, should deny
415
- const mockData2 = createMockSession({ confirmationRequestId: 'req-second' });
416
- setupBridgeDeps(() => mockData2.session);
417
-
418
- await startVoiceTurn({
419
- conversationId: CONVERSATION_ID,
420
- callSessionId: CALL_SESSION_ID,
421
- content: 'second utterance',
417
+ content: 'test utterance',
422
418
  assistantId: ASSISTANT_ID,
423
419
  guardianContext,
424
420
  isInbound: true,
@@ -429,9 +425,9 @@ describe('voice scoped grant consumer', () => {
429
425
 
430
426
  await new Promise(resolve => setTimeout(resolve, 100));
431
427
 
432
- const decision2 = mockData2.getConfirmationDecision();
433
- expect(decision2).not.toBeNull();
434
- expect(decision2!.decision).toBe('deny');
428
+ const decision = mockData.getConfirmationDecision();
429
+ expect(decision).not.toBeNull();
430
+ expect(decision!.decision).toBe('deny');
435
431
  });
436
432
 
437
433
  test('grants revoked when revokeScopedApprovalGrantsForContext is called with callSessionId', () => {
@@ -534,38 +530,4 @@ describe('voice scoped grant consumer', () => {
534
530
  .all();
535
531
  expect(otherActive.length).toBe(1);
536
532
  });
537
-
538
- test('non-guardian with grant for different assistantId: auto-denied', async () => {
539
- // Create a grant scoped to a different assistant
540
- createScopedApprovalGrant(grantParams({
541
- assistantId: 'other-assistant',
542
- }));
543
-
544
- const mockData = createMockSession();
545
- setupBridgeDeps(() => mockData.session);
546
-
547
- const guardianContext: GuardianRuntimeContext = {
548
- sourceChannel: 'voice',
549
- actorRole: 'non-guardian',
550
- requesterExternalUserId: 'caller-123',
551
- };
552
-
553
- await startVoiceTurn({
554
- conversationId: CONVERSATION_ID,
555
- callSessionId: CALL_SESSION_ID,
556
- content: 'test utterance',
557
- assistantId: ASSISTANT_ID,
558
- guardianContext,
559
- isInbound: true,
560
- onTextDelta: () => {},
561
- onComplete: () => {},
562
- onError: () => {},
563
- });
564
-
565
- await new Promise(resolve => setTimeout(resolve, 100));
566
-
567
- const decision = mockData.getConfirmationDecision();
568
- expect(decision).not.toBeNull();
569
- expect(decision!.decision).toBe('deny');
570
- });
571
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