@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,779 @@
1
+ /**
2
+ * Integration test: guardian-action answer resolution mints a scoped grant
3
+ * that the voice consumer can consume exactly once.
4
+ *
5
+ * Exercises the original voice bug scenario end-to-end:
6
+ * 1. Voice ASK_GUARDIAN fires -> guardian action request created with tool metadata
7
+ * 2. Guardian answers via desktop/Telegram -> request resolved
8
+ * 3. tryMintGuardianActionGrant mints a tool_signature grant
9
+ * 4. Voice consumer can consume the grant for the same tool+input
10
+ * 5. Second consume attempt is denied (one-time use)
11
+ * 6. Grant for a different assistantId is not consumable
12
+ */
13
+
14
+ import { mkdtempSync, rmSync } from 'node:fs';
15
+ import { tmpdir } from 'node:os';
16
+ import { join } from 'node:path';
17
+
18
+ import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test';
19
+
20
+ const testDir = mkdtempSync(join(tmpdir(), 'guardian-action-grant-e2e-'));
21
+
22
+ // ── Platform + logger mocks ─────────────────────────────────────────
23
+
24
+ mock.module('../util/platform.js', () => ({
25
+ getDataDir: () => testDir,
26
+ isMacOS: () => process.platform === 'darwin',
27
+ isLinux: () => process.platform === 'linux',
28
+ isWindows: () => process.platform === 'win32',
29
+ getSocketPath: () => join(testDir, 'test.sock'),
30
+ getPidPath: () => join(testDir, 'test.pid'),
31
+ getDbPath: () => join(testDir, 'test.db'),
32
+ getLogPath: () => join(testDir, 'test.log'),
33
+ ensureDataDir: () => {},
34
+ migrateToDataLayout: () => {},
35
+ migrateToWorkspaceLayout: () => {},
36
+ }));
37
+
38
+ mock.module('../util/logger.js', () => ({
39
+ getLogger: () =>
40
+ new Proxy({} as Record<string, unknown>, {
41
+ get: () => () => {},
42
+ }),
43
+ isDebug: () => false,
44
+ truncateForLog: (value: string) => value,
45
+ }));
46
+
47
+ // ── Imports (after mocks) ───────────────────────────────────────────
48
+
49
+ import { createCallSession, createPendingQuestion } from '../calls/call-store.js';
50
+ import { getDb, initializeDb, resetDb } from '../memory/db.js';
51
+ import {
52
+ createGuardianActionRequest,
53
+ resolveGuardianActionRequest,
54
+ } from '../memory/guardian-action-store.js';
55
+ import { conversations, scopedApprovalGrants } from '../memory/schema.js';
56
+ import {
57
+ _internal,
58
+ } from '../memory/scoped-approval-grants.js';
59
+
60
+ const { consumeScopedApprovalGrantByToolSignature } = _internal;
61
+ import { tryMintGuardianActionGrant } from '../runtime/guardian-action-grant-minter.js';
62
+ import type { ApprovalConversationGenerator } from '../runtime/http-types.js';
63
+ import { computeToolApprovalDigest } from '../security/tool-approval-digest.js';
64
+
65
+ initializeDb();
66
+
67
+ afterAll(() => {
68
+ resetDb();
69
+ try {
70
+ rmSync(testDir, { recursive: true });
71
+ } catch {
72
+ /* best effort */
73
+ }
74
+ });
75
+
76
+ // ── Constants ───────────────────────────────────────────────────────
77
+
78
+ const ASSISTANT_ID = 'self';
79
+ const TOOL_NAME = 'execute_shell';
80
+ const TOOL_INPUT = { command: 'rm -rf /tmp/test' };
81
+ const CONVERSATION_ID = 'conv-e2e';
82
+
83
+ // Mutable references populated by ensureFkParents()
84
+ let CALL_SESSION_ID = '';
85
+ let PENDING_QUESTION_IDS: string[] = [];
86
+ let pqIndex = 0;
87
+
88
+ function ensureConversation(id: string): void {
89
+ const db = getDb();
90
+ const now = Date.now();
91
+ db.insert(conversations).values({
92
+ id,
93
+ title: `Conversation ${id}`,
94
+ createdAt: now,
95
+ updatedAt: now,
96
+ }).run();
97
+ }
98
+
99
+ /** Create the FK parent rows required by guardian_action_requests. */
100
+ function ensureFkParents(): void {
101
+ ensureConversation(CONVERSATION_ID);
102
+ const session = createCallSession({
103
+ conversationId: CONVERSATION_ID,
104
+ provider: 'twilio',
105
+ fromNumber: '+15550001111',
106
+ toNumber: '+15550002222',
107
+ });
108
+ CALL_SESSION_ID = session.id;
109
+
110
+ // Pre-create enough pending questions for all tests in a suite run
111
+ PENDING_QUESTION_IDS = [];
112
+ pqIndex = 0;
113
+ for (let i = 0; i < 20; i++) {
114
+ const pq = createPendingQuestion(session.id, `Question ${i}`);
115
+ PENDING_QUESTION_IDS.push(pq.id);
116
+ }
117
+ }
118
+
119
+ function nextPendingQuestionId(): string {
120
+ return PENDING_QUESTION_IDS[pqIndex++];
121
+ }
122
+
123
+ function clearTables(): void {
124
+ const db = getDb();
125
+ try {
126
+ db.run('DELETE FROM scoped_approval_grants');
127
+ } catch {
128
+ /* table may not exist */
129
+ }
130
+ try {
131
+ db.run('DELETE FROM guardian_action_deliveries');
132
+ } catch {
133
+ /* table may not exist */
134
+ }
135
+ try {
136
+ db.run('DELETE FROM guardian_action_requests');
137
+ } catch {
138
+ /* table may not exist */
139
+ }
140
+ try {
141
+ db.run('DELETE FROM call_pending_questions');
142
+ } catch {
143
+ /* table may not exist */
144
+ }
145
+ try {
146
+ db.run('DELETE FROM call_events');
147
+ } catch {
148
+ /* table may not exist */
149
+ }
150
+ try {
151
+ db.run('DELETE FROM call_sessions');
152
+ } catch {
153
+ /* table may not exist */
154
+ }
155
+ try {
156
+ db.run('DELETE FROM conversations');
157
+ } catch {
158
+ /* table may not exist */
159
+ }
160
+ }
161
+
162
+ // ── Tests ───────────────────────────────────────────────────────────
163
+
164
+ describe('guardian-action grant mint -> voice consume integration', () => {
165
+ beforeEach(() => {
166
+ clearTables();
167
+ ensureFkParents();
168
+ });
169
+
170
+ test('full flow: resolve guardian action with tool metadata -> mint grant -> voice consume succeeds once', async () => {
171
+ const inputDigest = computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT);
172
+
173
+ // Step 1: Create a guardian action request with tool metadata
174
+ // (simulates the voice ASK_GUARDIAN path)
175
+ const request = createGuardianActionRequest({
176
+ assistantId: ASSISTANT_ID,
177
+ kind: 'ask_guardian',
178
+ sourceChannel: 'voice',
179
+ sourceConversationId: CONVERSATION_ID,
180
+ callSessionId: CALL_SESSION_ID,
181
+ pendingQuestionId: nextPendingQuestionId(),
182
+ questionText: 'Can I run rm -rf /tmp/test?',
183
+ expiresAt: Date.now() + 60_000,
184
+ toolName: TOOL_NAME,
185
+ inputDigest,
186
+ });
187
+
188
+ expect(request.toolName).toBe(TOOL_NAME);
189
+ expect(request.inputDigest).toBe(inputDigest);
190
+ expect(request.status).toBe('pending');
191
+
192
+ // Step 2: Guardian answers -> resolve the request
193
+ const resolved = resolveGuardianActionRequest(
194
+ request.id,
195
+ 'yes',
196
+ 'telegram',
197
+ 'guardian-user-123',
198
+ );
199
+ expect(resolved).not.toBeNull();
200
+ expect(resolved!.status).toBe('answered');
201
+
202
+ // Step 3: Mint a scoped grant from the resolved request
203
+ await tryMintGuardianActionGrant({
204
+ request: resolved!,
205
+ answerText: 'yes',
206
+ decisionChannel: 'telegram',
207
+ guardianExternalUserId: 'guardian-user-123',
208
+ });
209
+
210
+ // Verify the grant was created
211
+ const db = getDb();
212
+ const grants = db
213
+ .select()
214
+ .from(scopedApprovalGrants)
215
+ .all();
216
+ expect(grants.length).toBe(1);
217
+ expect(grants[0].toolName).toBe(TOOL_NAME);
218
+ expect(grants[0].inputDigest).toBe(inputDigest);
219
+ expect(grants[0].scopeMode).toBe('tool_signature');
220
+ expect(grants[0].status).toBe('active');
221
+ expect(grants[0].assistantId).toBe(ASSISTANT_ID);
222
+ expect(grants[0].callSessionId).toBe(CALL_SESSION_ID);
223
+
224
+ // Step 4: Voice consumer consumes the grant
225
+ const consumeResult = consumeScopedApprovalGrantByToolSignature({
226
+ toolName: TOOL_NAME,
227
+ inputDigest,
228
+ consumingRequestId: 'voice-req-1',
229
+ assistantId: ASSISTANT_ID,
230
+ executionChannel: 'voice',
231
+ callSessionId: CALL_SESSION_ID,
232
+ conversationId: CONVERSATION_ID,
233
+ });
234
+ expect(consumeResult.ok).toBe(true);
235
+ expect(consumeResult.grant).not.toBeNull();
236
+ expect(consumeResult.grant!.status).toBe('consumed');
237
+ expect(consumeResult.grant!.consumedByRequestId).toBe('voice-req-1');
238
+
239
+ // Step 5: Second consume attempt fails (one-time use)
240
+ const secondConsume = consumeScopedApprovalGrantByToolSignature({
241
+ toolName: TOOL_NAME,
242
+ inputDigest,
243
+ consumingRequestId: 'voice-req-2',
244
+ assistantId: ASSISTANT_ID,
245
+ executionChannel: 'voice',
246
+ callSessionId: CALL_SESSION_ID,
247
+ conversationId: CONVERSATION_ID,
248
+ });
249
+ expect(secondConsume.ok).toBe(false);
250
+ expect(secondConsume.grant).toBeNull();
251
+ });
252
+
253
+ test('grant minted for one assistantId cannot be consumed by another', async () => {
254
+ const inputDigest = computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT);
255
+
256
+ const request = createGuardianActionRequest({
257
+ assistantId: ASSISTANT_ID,
258
+ kind: 'ask_guardian',
259
+ sourceChannel: 'voice',
260
+ sourceConversationId: CONVERSATION_ID,
261
+ callSessionId: CALL_SESSION_ID,
262
+ pendingQuestionId: nextPendingQuestionId(),
263
+ questionText: 'Can I run the command?',
264
+ expiresAt: Date.now() + 60_000,
265
+ toolName: TOOL_NAME,
266
+ inputDigest,
267
+ });
268
+
269
+ const resolved = resolveGuardianActionRequest(request.id, 'Yes', 'telegram');
270
+ expect(resolved).not.toBeNull();
271
+
272
+ await tryMintGuardianActionGrant({
273
+ request: resolved!,
274
+ answerText: 'Yes',
275
+ decisionChannel: 'telegram',
276
+ });
277
+
278
+ // Attempt to consume with a different assistantId
279
+ const wrongAssistant = consumeScopedApprovalGrantByToolSignature({
280
+ toolName: TOOL_NAME,
281
+ inputDigest,
282
+ consumingRequestId: 'voice-req-wrong',
283
+ assistantId: 'other-assistant',
284
+ executionChannel: 'voice',
285
+ callSessionId: CALL_SESSION_ID,
286
+ conversationId: CONVERSATION_ID,
287
+ });
288
+ expect(wrongAssistant.ok).toBe(false);
289
+
290
+ // Correct assistantId succeeds
291
+ const correctAssistant = consumeScopedApprovalGrantByToolSignature({
292
+ toolName: TOOL_NAME,
293
+ inputDigest,
294
+ consumingRequestId: 'voice-req-correct',
295
+ assistantId: ASSISTANT_ID,
296
+ executionChannel: 'voice',
297
+ callSessionId: CALL_SESSION_ID,
298
+ conversationId: CONVERSATION_ID,
299
+ });
300
+ expect(correctAssistant.ok).toBe(true);
301
+ });
302
+
303
+ test('no grant minted when guardian action request lacks tool metadata', async () => {
304
+ // Create a request without toolName/inputDigest (informational consult)
305
+ const request = createGuardianActionRequest({
306
+ assistantId: ASSISTANT_ID,
307
+ kind: 'ask_guardian',
308
+ sourceChannel: 'voice',
309
+ sourceConversationId: CONVERSATION_ID,
310
+ callSessionId: CALL_SESSION_ID,
311
+ pendingQuestionId: nextPendingQuestionId(),
312
+ questionText: 'What should I tell the caller?',
313
+ expiresAt: Date.now() + 60_000,
314
+ // No toolName or inputDigest
315
+ });
316
+
317
+ const resolved = resolveGuardianActionRequest(request.id, 'Tell them to call back', 'vellum');
318
+ expect(resolved).not.toBeNull();
319
+
320
+ await tryMintGuardianActionGrant({
321
+ request: resolved!,
322
+ answerText: 'Tell them to call back',
323
+ decisionChannel: 'vellum',
324
+ });
325
+
326
+ // No grant should have been created
327
+ const db = getDb();
328
+ const grants = db
329
+ .select()
330
+ .from(scopedApprovalGrants)
331
+ .all();
332
+ expect(grants.length).toBe(0);
333
+ });
334
+
335
+ test('grant minted via desktop/vellum channel also consumable by voice', async () => {
336
+ const inputDigest = computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT);
337
+
338
+ const request = createGuardianActionRequest({
339
+ assistantId: ASSISTANT_ID,
340
+ kind: 'ask_guardian',
341
+ sourceChannel: 'voice',
342
+ sourceConversationId: CONVERSATION_ID,
343
+ callSessionId: CALL_SESSION_ID,
344
+ pendingQuestionId: nextPendingQuestionId(),
345
+ questionText: 'Permission to execute?',
346
+ expiresAt: Date.now() + 60_000,
347
+ toolName: TOOL_NAME,
348
+ inputDigest,
349
+ });
350
+
351
+ // Guardian answers via desktop (vellum channel)
352
+ const resolved = resolveGuardianActionRequest(request.id, 'approve', 'vellum');
353
+ expect(resolved).not.toBeNull();
354
+
355
+ // Mint with decisionChannel: 'vellum' (desktop path)
356
+ await tryMintGuardianActionGrant({
357
+ request: resolved!,
358
+ answerText: 'approve',
359
+ decisionChannel: 'vellum',
360
+ });
361
+
362
+ // The grant should have executionChannel: null (wildcard), so voice can consume
363
+ const consumeResult = consumeScopedApprovalGrantByToolSignature({
364
+ toolName: TOOL_NAME,
365
+ inputDigest,
366
+ consumingRequestId: 'voice-req-desktop',
367
+ assistantId: ASSISTANT_ID,
368
+ executionChannel: 'voice',
369
+ callSessionId: CALL_SESSION_ID,
370
+ conversationId: CONVERSATION_ID,
371
+ });
372
+ expect(consumeResult.ok).toBe(true);
373
+ });
374
+
375
+ test('no grant minted when guardian answer is a denial', async () => {
376
+ const inputDigest = computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT);
377
+
378
+ const request = createGuardianActionRequest({
379
+ assistantId: ASSISTANT_ID,
380
+ kind: 'ask_guardian',
381
+ sourceChannel: 'voice',
382
+ sourceConversationId: CONVERSATION_ID,
383
+ callSessionId: CALL_SESSION_ID,
384
+ pendingQuestionId: nextPendingQuestionId(),
385
+ questionText: 'Can I run rm -rf /tmp/test?',
386
+ expiresAt: Date.now() + 60_000,
387
+ toolName: TOOL_NAME,
388
+ inputDigest,
389
+ });
390
+
391
+ // Guardian explicitly denies the action
392
+ const resolved = resolveGuardianActionRequest(request.id, 'No', 'telegram', 'guardian-user-456');
393
+ expect(resolved).not.toBeNull();
394
+
395
+ await tryMintGuardianActionGrant({
396
+ request: resolved!,
397
+ answerText: 'No',
398
+ decisionChannel: 'telegram',
399
+ guardianExternalUserId: 'guardian-user-456',
400
+ });
401
+
402
+ // No grant should have been created for a denial
403
+ const db = getDb();
404
+ const grants = db
405
+ .select()
406
+ .from(scopedApprovalGrants)
407
+ .all();
408
+ expect(grants.length).toBe(0);
409
+ });
410
+
411
+ test.each(['no', 'reject', 'deny', 'cancel'])('no grant minted for denial keyword: %s', async (denialWord) => {
412
+ const inputDigest = computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT);
413
+
414
+ const request = createGuardianActionRequest({
415
+ assistantId: ASSISTANT_ID,
416
+ kind: 'ask_guardian',
417
+ sourceChannel: 'voice',
418
+ sourceConversationId: CONVERSATION_ID,
419
+ callSessionId: CALL_SESSION_ID,
420
+ pendingQuestionId: nextPendingQuestionId(),
421
+ questionText: 'Permission to execute?',
422
+ expiresAt: Date.now() + 60_000,
423
+ toolName: TOOL_NAME,
424
+ inputDigest,
425
+ });
426
+
427
+ const resolved = resolveGuardianActionRequest(request.id, denialWord, 'telegram');
428
+ expect(resolved).not.toBeNull();
429
+
430
+ await tryMintGuardianActionGrant({
431
+ request: resolved!,
432
+ answerText: denialWord,
433
+ decisionChannel: 'telegram',
434
+ });
435
+
436
+ const db = getDb();
437
+ const grants = db
438
+ .select()
439
+ .from(scopedApprovalGrants)
440
+ .all();
441
+ expect(grants.length).toBe(0);
442
+ });
443
+
444
+ test('no grant minted for unrecognised free-form answer without generator (fail-closed)', async () => {
445
+ const inputDigest = computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT);
446
+
447
+ const request = createGuardianActionRequest({
448
+ assistantId: ASSISTANT_ID,
449
+ kind: 'ask_guardian',
450
+ sourceChannel: 'voice',
451
+ sourceConversationId: CONVERSATION_ID,
452
+ callSessionId: CALL_SESSION_ID,
453
+ pendingQuestionId: nextPendingQuestionId(),
454
+ questionText: 'Can I run the command?',
455
+ expiresAt: Date.now() + 60_000,
456
+ toolName: TOOL_NAME,
457
+ inputDigest,
458
+ });
459
+
460
+ // Free-form text that doesn't match a known approval phrase
461
+ const resolved = resolveGuardianActionRequest(request.id, 'Sure, go ahead and run it', 'telegram');
462
+ expect(resolved).not.toBeNull();
463
+
464
+ await tryMintGuardianActionGrant({
465
+ request: resolved!,
466
+ answerText: 'Sure, go ahead and run it',
467
+ decisionChannel: 'telegram',
468
+ });
469
+
470
+ // No grant — unrecognised text is not treated as approval (fail-closed)
471
+ const db = getDb();
472
+ const grants = db
473
+ .select()
474
+ .from(scopedApprovalGrants)
475
+ .all();
476
+ expect(grants.length).toBe(0);
477
+ });
478
+
479
+ test.each(['yes', 'approve', 'approve once', 'allow', 'go ahead'])('grant IS minted for approval keyword: %s', async (approveWord) => {
480
+ const inputDigest = computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT);
481
+
482
+ const request = createGuardianActionRequest({
483
+ assistantId: ASSISTANT_ID,
484
+ kind: 'ask_guardian',
485
+ sourceChannel: 'voice',
486
+ sourceConversationId: CONVERSATION_ID,
487
+ callSessionId: CALL_SESSION_ID,
488
+ pendingQuestionId: nextPendingQuestionId(),
489
+ questionText: 'Can I run the command?',
490
+ expiresAt: Date.now() + 60_000,
491
+ toolName: TOOL_NAME,
492
+ inputDigest,
493
+ });
494
+
495
+ const resolved = resolveGuardianActionRequest(request.id, approveWord, 'telegram');
496
+ expect(resolved).not.toBeNull();
497
+
498
+ await tryMintGuardianActionGrant({
499
+ request: resolved!,
500
+ answerText: approveWord,
501
+ decisionChannel: 'telegram',
502
+ });
503
+
504
+ const db = getDb();
505
+ const grants = db
506
+ .select()
507
+ .from(scopedApprovalGrants)
508
+ .all();
509
+ expect(grants.length).toBe(1);
510
+ expect(grants[0].toolName).toBe(TOOL_NAME);
511
+ });
512
+ });
513
+
514
+ // ---------------------------------------------------------------------------
515
+ // LLM fallback two-tier classification tests
516
+ // ---------------------------------------------------------------------------
517
+
518
+ describe('guardian-action grant minter: two-tier classification (deterministic + LLM fallback)', () => {
519
+ beforeEach(() => {
520
+ clearTables();
521
+ ensureFkParents();
522
+ });
523
+
524
+ test('deterministic parser works for exact phrases without needing the generator', async () => {
525
+ const inputDigest = computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT);
526
+
527
+ const request = createGuardianActionRequest({
528
+ assistantId: ASSISTANT_ID,
529
+ kind: 'ask_guardian',
530
+ sourceChannel: 'voice',
531
+ sourceConversationId: CONVERSATION_ID,
532
+ callSessionId: CALL_SESSION_ID,
533
+ pendingQuestionId: nextPendingQuestionId(),
534
+ questionText: 'Can I run the command?',
535
+ expiresAt: Date.now() + 60_000,
536
+ toolName: TOOL_NAME,
537
+ inputDigest,
538
+ });
539
+
540
+ const resolved = resolveGuardianActionRequest(request.id, 'yes', 'telegram');
541
+ expect(resolved).not.toBeNull();
542
+
543
+ // Provide a generator that should NOT be called (deterministic match first)
544
+ const generatorSpy: ApprovalConversationGenerator = async () => {
545
+ throw new Error('Generator should not be called for exact phrase match');
546
+ };
547
+
548
+ await tryMintGuardianActionGrant({
549
+ request: resolved!,
550
+ answerText: 'yes',
551
+ decisionChannel: 'telegram',
552
+ approvalConversationGenerator: generatorSpy,
553
+ });
554
+
555
+ const db = getDb();
556
+ const grants = db.select().from(scopedApprovalGrants).all();
557
+ expect(grants.length).toBe(1);
558
+ expect(grants[0].toolName).toBe(TOOL_NAME);
559
+ });
560
+
561
+ test('free-form approval via LLM fallback mints a grant', async () => {
562
+ const inputDigest = computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT);
563
+
564
+ const request = createGuardianActionRequest({
565
+ assistantId: ASSISTANT_ID,
566
+ kind: 'ask_guardian',
567
+ sourceChannel: 'voice',
568
+ sourceConversationId: CONVERSATION_ID,
569
+ callSessionId: CALL_SESSION_ID,
570
+ pendingQuestionId: nextPendingQuestionId(),
571
+ questionText: 'Can I run the command?',
572
+ expiresAt: Date.now() + 60_000,
573
+ toolName: TOOL_NAME,
574
+ inputDigest,
575
+ });
576
+
577
+ const resolved = resolveGuardianActionRequest(request.id, 'Sure, go ahead and run it', 'telegram');
578
+ expect(resolved).not.toBeNull();
579
+
580
+ const mockGenerator: ApprovalConversationGenerator = async () => ({
581
+ disposition: 'approve_once',
582
+ replyText: 'Approved.',
583
+ });
584
+
585
+ await tryMintGuardianActionGrant({
586
+ request: resolved!,
587
+ answerText: 'Sure, go ahead and run it',
588
+ decisionChannel: 'telegram',
589
+ approvalConversationGenerator: mockGenerator,
590
+ });
591
+
592
+ const db = getDb();
593
+ const grants = db.select().from(scopedApprovalGrants).all();
594
+ expect(grants.length).toBe(1);
595
+ expect(grants[0].toolName).toBe(TOOL_NAME);
596
+ });
597
+
598
+ test('ambiguous text returns keep_pending from generator, no grant minted', async () => {
599
+ const inputDigest = computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT);
600
+
601
+ const request = createGuardianActionRequest({
602
+ assistantId: ASSISTANT_ID,
603
+ kind: 'ask_guardian',
604
+ sourceChannel: 'voice',
605
+ sourceConversationId: CONVERSATION_ID,
606
+ callSessionId: CALL_SESSION_ID,
607
+ pendingQuestionId: nextPendingQuestionId(),
608
+ questionText: 'Can I run the command?',
609
+ expiresAt: Date.now() + 60_000,
610
+ toolName: TOOL_NAME,
611
+ inputDigest,
612
+ });
613
+
614
+ const resolved = resolveGuardianActionRequest(request.id, "I'm not sure about this", 'telegram');
615
+ expect(resolved).not.toBeNull();
616
+
617
+ const mockGenerator: ApprovalConversationGenerator = async () => ({
618
+ disposition: 'keep_pending',
619
+ replyText: 'Could you clarify?',
620
+ });
621
+
622
+ await tryMintGuardianActionGrant({
623
+ request: resolved!,
624
+ answerText: "I'm not sure about this",
625
+ decisionChannel: 'telegram',
626
+ approvalConversationGenerator: mockGenerator,
627
+ });
628
+
629
+ const db = getDb();
630
+ const grants = db.select().from(scopedApprovalGrants).all();
631
+ expect(grants.length).toBe(0);
632
+ });
633
+
634
+ test('generator failure falls back to no grant (fail-closed)', async () => {
635
+ const inputDigest = computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT);
636
+
637
+ const request = createGuardianActionRequest({
638
+ assistantId: ASSISTANT_ID,
639
+ kind: 'ask_guardian',
640
+ sourceChannel: 'voice',
641
+ sourceConversationId: CONVERSATION_ID,
642
+ callSessionId: CALL_SESSION_ID,
643
+ pendingQuestionId: nextPendingQuestionId(),
644
+ questionText: 'Can I run the command?',
645
+ expiresAt: Date.now() + 60_000,
646
+ toolName: TOOL_NAME,
647
+ inputDigest,
648
+ });
649
+
650
+ const resolved = resolveGuardianActionRequest(request.id, 'Sure, go ahead and run it', 'telegram');
651
+ expect(resolved).not.toBeNull();
652
+
653
+ const failingGenerator: ApprovalConversationGenerator = async () => {
654
+ throw new Error('LLM provider unavailable');
655
+ };
656
+
657
+ await tryMintGuardianActionGrant({
658
+ request: resolved!,
659
+ answerText: 'Sure, go ahead and run it',
660
+ decisionChannel: 'telegram',
661
+ approvalConversationGenerator: failingGenerator,
662
+ });
663
+
664
+ const db = getDb();
665
+ const grants = db.select().from(scopedApprovalGrants).all();
666
+ expect(grants.length).toBe(0);
667
+ });
668
+
669
+ test('no generator provided and unrecognised text produces no grant', async () => {
670
+ const inputDigest = computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT);
671
+
672
+ const request = createGuardianActionRequest({
673
+ assistantId: ASSISTANT_ID,
674
+ kind: 'ask_guardian',
675
+ sourceChannel: 'voice',
676
+ sourceConversationId: CONVERSATION_ID,
677
+ callSessionId: CALL_SESSION_ID,
678
+ pendingQuestionId: nextPendingQuestionId(),
679
+ questionText: 'Can I run the command?',
680
+ expiresAt: Date.now() + 60_000,
681
+ toolName: TOOL_NAME,
682
+ inputDigest,
683
+ });
684
+
685
+ const resolved = resolveGuardianActionRequest(request.id, 'Sure, go ahead and run it', 'telegram');
686
+ expect(resolved).not.toBeNull();
687
+
688
+ // No generator provided — behaves like before, no LLM fallback
689
+ await tryMintGuardianActionGrant({
690
+ request: resolved!,
691
+ answerText: 'Sure, go ahead and run it',
692
+ decisionChannel: 'telegram',
693
+ });
694
+
695
+ const db = getDb();
696
+ const grants = db.select().from(scopedApprovalGrants).all();
697
+ expect(grants.length).toBe(0);
698
+ });
699
+
700
+ test('deterministic "approve always" still mints a one-time grant (normalized to approve_once semantics)', async () => {
701
+ const inputDigest = computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT);
702
+
703
+ const request = createGuardianActionRequest({
704
+ assistantId: ASSISTANT_ID,
705
+ kind: 'ask_guardian',
706
+ sourceChannel: 'voice',
707
+ sourceConversationId: CONVERSATION_ID,
708
+ callSessionId: CALL_SESSION_ID,
709
+ pendingQuestionId: nextPendingQuestionId(),
710
+ questionText: 'Can I run the command?',
711
+ expiresAt: Date.now() + 60_000,
712
+ toolName: TOOL_NAME,
713
+ inputDigest,
714
+ });
715
+
716
+ const resolved = resolveGuardianActionRequest(request.id, 'approve always', 'telegram');
717
+ expect(resolved).not.toBeNull();
718
+
719
+ // Generator should NOT be called -- deterministic parser matches "approve always"
720
+ const generatorSpy: ApprovalConversationGenerator = async () => {
721
+ throw new Error('Generator should not be called for deterministic match');
722
+ };
723
+
724
+ await tryMintGuardianActionGrant({
725
+ request: resolved!,
726
+ answerText: 'approve always',
727
+ decisionChannel: 'telegram',
728
+ approvalConversationGenerator: generatorSpy,
729
+ });
730
+
731
+ // Grant is minted (approve_always treated as approval), but it is still
732
+ // a one-time tool_signature grant -- no broader privilege is granted.
733
+ const db = getDb();
734
+ const grants = db.select().from(scopedApprovalGrants).all();
735
+ expect(grants.length).toBe(1);
736
+ expect(grants[0].scopeMode).toBe('tool_signature');
737
+ });
738
+
739
+ test('LLM fallback allowedActions excludes approve_always (guardian invariant)', async () => {
740
+ const inputDigest = computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT);
741
+
742
+ const request = createGuardianActionRequest({
743
+ assistantId: ASSISTANT_ID,
744
+ kind: 'ask_guardian',
745
+ sourceChannel: 'voice',
746
+ sourceConversationId: CONVERSATION_ID,
747
+ callSessionId: CALL_SESSION_ID,
748
+ pendingQuestionId: nextPendingQuestionId(),
749
+ questionText: 'Can I run the command?',
750
+ expiresAt: Date.now() + 60_000,
751
+ toolName: TOOL_NAME,
752
+ inputDigest,
753
+ });
754
+
755
+ const resolved = resolveGuardianActionRequest(request.id, 'Sure, go ahead and run it', 'telegram');
756
+ expect(resolved).not.toBeNull();
757
+
758
+ // Generator returns approve_always -- but the allowedActions constraint
759
+ // in the minter restricts to approve_once/reject, so the approval-
760
+ // conversation-turn layer will normalize this to keep_pending.
761
+ const mockGenerator: ApprovalConversationGenerator = async () => ({
762
+ disposition: 'approve_always',
763
+ replyText: 'Approved permanently.',
764
+ });
765
+
766
+ await tryMintGuardianActionGrant({
767
+ request: resolved!,
768
+ answerText: 'Sure, go ahead and run it',
769
+ decisionChannel: 'telegram',
770
+ approvalConversationGenerator: mockGenerator,
771
+ });
772
+
773
+ // No grant -- approve_always is not in LLM fallback allowedActions,
774
+ // so the disposition gets normalized to keep_pending (fail-closed).
775
+ const db = getDb();
776
+ const grants = db.select().from(scopedApprovalGrants).all();
777
+ expect(grants.length).toBe(0);
778
+ });
779
+ });