@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,532 @@
1
+ /**
2
+ * Tests for M3: scoped grant minting on guardian tool-approval decisions.
3
+ *
4
+ * When a guardian approves a tool-approval request (one with toolName + input),
5
+ * the approval interception flow should mint a `tool_signature` scoped grant.
6
+ * Non-tool-approval requests and rejections must NOT mint grants.
7
+ */
8
+
9
+ import { mkdtempSync, rmSync } from 'node:fs';
10
+ import { tmpdir } from 'node:os';
11
+ import { join } from 'node:path';
12
+
13
+ import { afterAll, beforeEach, describe, expect, mock, spyOn, test } from 'bun:test';
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Test isolation: in-memory SQLite via temp directory
17
+ // ---------------------------------------------------------------------------
18
+
19
+ const testDir = mkdtempSync(join(tmpdir(), 'guardian-grant-minting-test-'));
20
+
21
+ mock.module('../util/platform.js', () => ({
22
+ getRootDir: () => testDir,
23
+ getDataDir: () => testDir,
24
+ isMacOS: () => process.platform === 'darwin',
25
+ isLinux: () => process.platform === 'linux',
26
+ isWindows: () => process.platform === 'win32',
27
+ getSocketPath: () => join(testDir, 'test.sock'),
28
+ getPidPath: () => join(testDir, 'test.pid'),
29
+ getDbPath: () => join(testDir, 'test.db'),
30
+ getLogPath: () => join(testDir, 'test.log'),
31
+ ensureDataDir: () => {},
32
+ }));
33
+
34
+ mock.module('../util/logger.js', () => ({
35
+ getLogger: () => new Proxy({} as Record<string, unknown>, {
36
+ get: () => () => {},
37
+ }),
38
+ }));
39
+
40
+ import { GRANT_TTL_MS } from '../approvals/guardian-decision-primitive.js';
41
+ import type { Session } from '../daemon/session.js';
42
+ import {
43
+ createApprovalRequest,
44
+ type GuardianApprovalRequest,
45
+ } from '../memory/channel-guardian-store.js';
46
+ import { getDb, initializeDb, resetDb } from '../memory/db.js';
47
+ import * as approvalMessageComposer from '../runtime/approval-message-composer.js';
48
+ import * as gatewayClient from '../runtime/gateway-client.js';
49
+ import * as pendingInteractions from '../runtime/pending-interactions.js';
50
+ import type { GuardianContext } from '../runtime/routes/channel-route-shared.js';
51
+ import {
52
+ handleApprovalInterception,
53
+ } from '../runtime/routes/guardian-approval-interception.js';
54
+ import { computeToolApprovalDigest } from '../security/tool-approval-digest.js';
55
+
56
+ import '../memory/scoped-approval-grants.js';
57
+
58
+ initializeDb();
59
+
60
+ afterAll(() => {
61
+ resetDb();
62
+ try { rmSync(testDir, { recursive: true }); } catch { /* best effort */ }
63
+ });
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Helpers
67
+ // ---------------------------------------------------------------------------
68
+
69
+ const ASSISTANT_ID = 'self';
70
+ const GUARDIAN_USER = 'guardian-user-1';
71
+ const GUARDIAN_CHAT = 'guardian-chat-1';
72
+ const REQUESTER_USER = 'requester-user-1';
73
+ const REQUESTER_CHAT = 'requester-chat-1';
74
+ const CONVERSATION_ID = 'conv-1';
75
+ const TOOL_NAME = 'execute_shell';
76
+ const TOOL_INPUT = { command: 'rm -rf /tmp/test' };
77
+
78
+ function resetTables(): void {
79
+ try {
80
+ const db = getDb();
81
+ db.run('DELETE FROM channel_guardian_approval_requests');
82
+ db.run('DELETE FROM scoped_approval_grants');
83
+ } catch { /* tables may not exist yet */ }
84
+ pendingInteractions.clear();
85
+ }
86
+
87
+ function createTestGuardianApproval(
88
+ requestId: string,
89
+ overrides: Partial<Parameters<typeof createApprovalRequest>[0]> = {},
90
+ ): GuardianApprovalRequest {
91
+ return createApprovalRequest({
92
+ runId: `run-${requestId}`,
93
+ requestId,
94
+ conversationId: CONVERSATION_ID,
95
+ assistantId: ASSISTANT_ID,
96
+ channel: 'telegram',
97
+ requesterExternalUserId: REQUESTER_USER,
98
+ requesterChatId: REQUESTER_CHAT,
99
+ guardianExternalUserId: GUARDIAN_USER,
100
+ guardianChatId: GUARDIAN_CHAT,
101
+ toolName: TOOL_NAME,
102
+ expiresAt: Date.now() + 300_000,
103
+ ...overrides,
104
+ });
105
+ }
106
+
107
+ function registerPendingInteraction(
108
+ requestId: string,
109
+ conversationId: string,
110
+ toolName: string,
111
+ input: Record<string, unknown> = TOOL_INPUT,
112
+ ): ReturnType<typeof mock> {
113
+ const handleConfirmationResponse = mock(() => {});
114
+ const mockSession = {
115
+ handleConfirmationResponse,
116
+ } as unknown as Session;
117
+
118
+ pendingInteractions.register(requestId, {
119
+ session: mockSession,
120
+ conversationId,
121
+ kind: 'confirmation',
122
+ confirmationDetails: {
123
+ toolName,
124
+ input,
125
+ riskLevel: 'high',
126
+ allowlistOptions: [
127
+ { label: 'test', description: 'test', pattern: 'test' },
128
+ ],
129
+ scopeOptions: [
130
+ { label: 'everywhere', scope: 'everywhere' },
131
+ ],
132
+ },
133
+ });
134
+
135
+ return handleConfirmationResponse;
136
+ }
137
+
138
+ function makeGuardianContext(): GuardianContext {
139
+ return {
140
+ actorRole: 'guardian',
141
+ denialReason: undefined,
142
+ };
143
+ }
144
+
145
+ function countGrants(): number {
146
+ try {
147
+ const db = getDb();
148
+ const row = db.$client.prepare('SELECT count(*) as cnt FROM scoped_approval_grants').get() as { cnt: number };
149
+ return row.cnt;
150
+ } catch {
151
+ return 0;
152
+ }
153
+ }
154
+
155
+ function getLatestGrant(): Record<string, unknown> | null {
156
+ try {
157
+ const db = getDb();
158
+ const row = db.$client.prepare('SELECT * FROM scoped_approval_grants ORDER BY created_at DESC LIMIT 1').get();
159
+ return (row as Record<string, unknown>) ?? null;
160
+ } catch {
161
+ return null;
162
+ }
163
+ }
164
+
165
+ // ═══════════════════════════════════════════════════════════════════════════
166
+ // Tests
167
+ // ═══════════════════════════════════════════════════════════════════════════
168
+
169
+ describe('guardian grant minting on tool-approval decisions', () => {
170
+ let deliverSpy: ReturnType<typeof spyOn>;
171
+ let composeSpy: ReturnType<typeof spyOn>;
172
+
173
+ beforeEach(() => {
174
+ resetTables();
175
+ deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
176
+ composeSpy = spyOn(approvalMessageComposer, 'composeApprovalMessageGenerative')
177
+ .mockResolvedValue('test message');
178
+ });
179
+
180
+ // ── 1. approve_once via callback mints a grant ──
181
+
182
+ test('approve_once via callback for tool-approval request mints a scoped grant', async () => {
183
+ const requestId = 'req-grant-cb-1';
184
+ createTestGuardianApproval(requestId);
185
+ registerPendingInteraction(requestId, CONVERSATION_ID, TOOL_NAME, TOOL_INPUT);
186
+
187
+ const result = await handleApprovalInterception({
188
+ conversationId: 'guardian-conv-1',
189
+ callbackData: `apr:${requestId}:approve_once`,
190
+ content: '',
191
+ externalChatId: GUARDIAN_CHAT,
192
+ sourceChannel: 'telegram',
193
+ senderExternalUserId: GUARDIAN_USER,
194
+ replyCallbackUrl: 'https://gateway.test/deliver',
195
+ guardianCtx: makeGuardianContext(),
196
+ assistantId: ASSISTANT_ID,
197
+ });
198
+
199
+ expect(result.handled).toBe(true);
200
+ expect(result.type).toBe('guardian_decision_applied');
201
+
202
+ // Verify a grant was minted
203
+ expect(countGrants()).toBe(1);
204
+
205
+ const grant = getLatestGrant();
206
+ expect(grant).not.toBeNull();
207
+ expect(grant!.scope_mode).toBe('tool_signature');
208
+ expect(grant!.tool_name).toBe(TOOL_NAME);
209
+ expect(grant!.status).toBe('active');
210
+ expect(grant!.request_channel).toBe('telegram');
211
+ expect(grant!.decision_channel).toBe('telegram');
212
+ expect(grant!.guardian_external_user_id).toBe(GUARDIAN_USER);
213
+ expect(grant!.requester_external_user_id).toBe(REQUESTER_USER);
214
+ expect(grant!.conversation_id).toBe(CONVERSATION_ID);
215
+ expect(grant!.execution_channel).toBeNull();
216
+ expect(grant!.call_session_id).toBeNull();
217
+
218
+ // Verify the input digest matches what computeToolApprovalDigest produces
219
+ const expectedDigest = computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT);
220
+ expect(grant!.input_digest).toBe(expectedDigest);
221
+
222
+ deliverSpy.mockRestore();
223
+ composeSpy.mockRestore();
224
+ });
225
+
226
+ // ── 2. approve_once for non-tool-approval does NOT mint a grant ──
227
+
228
+ test('approve_once for informational request (no toolName) does NOT mint a grant', async () => {
229
+ const requestId = 'req-no-grant-1';
230
+ // Informational requests have no meaningful tool name — the empty string
231
+ // signals that this is not a tool-approval request.
232
+ createTestGuardianApproval(requestId, { toolName: '' });
233
+ registerPendingInteraction(requestId, CONVERSATION_ID, '', {});
234
+
235
+ const result = await handleApprovalInterception({
236
+ conversationId: 'guardian-conv-2',
237
+ callbackData: `apr:${requestId}:approve_once`,
238
+ content: '',
239
+ externalChatId: GUARDIAN_CHAT,
240
+ sourceChannel: 'telegram',
241
+ senderExternalUserId: GUARDIAN_USER,
242
+ replyCallbackUrl: 'https://gateway.test/deliver',
243
+ guardianCtx: makeGuardianContext(),
244
+ assistantId: ASSISTANT_ID,
245
+ });
246
+
247
+ expect(result.handled).toBe(true);
248
+ expect(result.type).toBe('guardian_decision_applied');
249
+
250
+ // No grant should have been minted
251
+ expect(countGrants()).toBe(0);
252
+
253
+ deliverSpy.mockRestore();
254
+ composeSpy.mockRestore();
255
+ });
256
+
257
+ // ── 2b. approve_once for zero-argument tool call DOES mint a grant ──
258
+
259
+ test('approve_once for zero-argument tool call mints a scoped grant', async () => {
260
+ const requestId = 'req-grant-zero-arg';
261
+ const zeroArgTool = 'get_system_status';
262
+ createTestGuardianApproval(requestId, { toolName: zeroArgTool });
263
+ // Register with empty input object to simulate a zero-argument tool call
264
+ registerPendingInteraction(requestId, CONVERSATION_ID, zeroArgTool, {});
265
+
266
+ const result = await handleApprovalInterception({
267
+ conversationId: 'guardian-conv-2b',
268
+ callbackData: `apr:${requestId}:approve_once`,
269
+ content: '',
270
+ externalChatId: GUARDIAN_CHAT,
271
+ sourceChannel: 'telegram',
272
+ senderExternalUserId: GUARDIAN_USER,
273
+ replyCallbackUrl: 'https://gateway.test/deliver',
274
+ guardianCtx: makeGuardianContext(),
275
+ assistantId: ASSISTANT_ID,
276
+ });
277
+
278
+ expect(result.handled).toBe(true);
279
+ expect(result.type).toBe('guardian_decision_applied');
280
+
281
+ // A grant MUST be minted even though input is {}
282
+ expect(countGrants()).toBe(1);
283
+
284
+ const grant = getLatestGrant();
285
+ expect(grant).not.toBeNull();
286
+ expect(grant!.scope_mode).toBe('tool_signature');
287
+ expect(grant!.tool_name).toBe(zeroArgTool);
288
+ expect(grant!.status).toBe('active');
289
+
290
+ // Verify the input digest matches what computeToolApprovalDigest produces for empty input
291
+ const expectedDigest = computeToolApprovalDigest(zeroArgTool, {});
292
+ expect(grant!.input_digest).toBe(expectedDigest);
293
+
294
+ deliverSpy.mockRestore();
295
+ composeSpy.mockRestore();
296
+ });
297
+
298
+ // ── 3. reject does NOT mint a grant ──
299
+
300
+ test('reject decision does NOT mint a scoped grant', async () => {
301
+ const requestId = 'req-no-grant-rej';
302
+ createTestGuardianApproval(requestId);
303
+ registerPendingInteraction(requestId, CONVERSATION_ID, TOOL_NAME, TOOL_INPUT);
304
+
305
+ const result = await handleApprovalInterception({
306
+ conversationId: 'guardian-conv-3',
307
+ callbackData: `apr:${requestId}:reject`,
308
+ content: '',
309
+ externalChatId: GUARDIAN_CHAT,
310
+ sourceChannel: 'telegram',
311
+ senderExternalUserId: GUARDIAN_USER,
312
+ replyCallbackUrl: 'https://gateway.test/deliver',
313
+ guardianCtx: makeGuardianContext(),
314
+ assistantId: ASSISTANT_ID,
315
+ });
316
+
317
+ expect(result.handled).toBe(true);
318
+ expect(result.type).toBe('guardian_decision_applied');
319
+
320
+ // No grant should have been minted
321
+ expect(countGrants()).toBe(0);
322
+
323
+ deliverSpy.mockRestore();
324
+ composeSpy.mockRestore();
325
+ });
326
+
327
+ // ── 4. Identity mismatch remains fail-closed (no grant minted) ──
328
+
329
+ test('identity mismatch does NOT mint a grant and fails closed', async () => {
330
+ const requestId = 'req-mismatch-1';
331
+ createTestGuardianApproval(requestId);
332
+ registerPendingInteraction(requestId, CONVERSATION_ID, TOOL_NAME, TOOL_INPUT);
333
+
334
+ const result = await handleApprovalInterception({
335
+ conversationId: 'guardian-conv-4',
336
+ callbackData: `apr:${requestId}:approve_once`,
337
+ content: '',
338
+ externalChatId: GUARDIAN_CHAT,
339
+ sourceChannel: 'telegram',
340
+ senderExternalUserId: 'wrong-guardian-user',
341
+ replyCallbackUrl: 'https://gateway.test/deliver',
342
+ guardianCtx: makeGuardianContext(),
343
+ assistantId: ASSISTANT_ID,
344
+ });
345
+
346
+ expect(result.handled).toBe(true);
347
+ // Identity mismatch results in guardian_decision_applied (fail-closed, no actual decision applied)
348
+ expect(result.type).toBe('guardian_decision_applied');
349
+
350
+ // No grant should have been minted
351
+ expect(countGrants()).toBe(0);
352
+
353
+ deliverSpy.mockRestore();
354
+ composeSpy.mockRestore();
355
+ });
356
+
357
+ // ── 5. Stale/already-resolved request does NOT mint a grant ──
358
+
359
+ test('stale request (already resolved) does NOT mint a grant', async () => {
360
+ const requestId = 'req-stale-1';
361
+ // Create guardian approval but do NOT register a pending interaction
362
+ // This simulates the pending interaction being already resolved
363
+ createTestGuardianApproval(requestId);
364
+
365
+ const result = await handleApprovalInterception({
366
+ conversationId: 'guardian-conv-5',
367
+ callbackData: `apr:${requestId}:approve_once`,
368
+ content: '',
369
+ externalChatId: GUARDIAN_CHAT,
370
+ sourceChannel: 'telegram',
371
+ senderExternalUserId: GUARDIAN_USER,
372
+ replyCallbackUrl: 'https://gateway.test/deliver',
373
+ guardianCtx: makeGuardianContext(),
374
+ assistantId: ASSISTANT_ID,
375
+ });
376
+
377
+ expect(result.handled).toBe(true);
378
+ expect(result.type).toBe('stale_ignored');
379
+
380
+ // No grant should have been minted
381
+ expect(countGrants()).toBe(0);
382
+
383
+ deliverSpy.mockRestore();
384
+ composeSpy.mockRestore();
385
+ });
386
+
387
+ // ── 6. approve_once via conversation engine mints a grant ──
388
+
389
+ test('approve_once via conversation engine mints a scoped grant', async () => {
390
+ const requestId = 'req-grant-eng-1';
391
+ createTestGuardianApproval(requestId);
392
+ registerPendingInteraction(requestId, CONVERSATION_ID, TOOL_NAME, TOOL_INPUT);
393
+
394
+ const mockGenerator = async () => ({
395
+ disposition: 'approve_once' as const,
396
+ replyText: 'Approved!',
397
+ targetRequestId: requestId,
398
+ });
399
+
400
+ const result = await handleApprovalInterception({
401
+ conversationId: 'guardian-conv-6',
402
+ content: 'yes, approve it',
403
+ externalChatId: GUARDIAN_CHAT,
404
+ sourceChannel: 'telegram',
405
+ senderExternalUserId: GUARDIAN_USER,
406
+ replyCallbackUrl: 'https://gateway.test/deliver',
407
+ guardianCtx: makeGuardianContext(),
408
+ assistantId: ASSISTANT_ID,
409
+ approvalConversationGenerator: mockGenerator,
410
+ });
411
+
412
+ expect(result.handled).toBe(true);
413
+ expect(result.type).toBe('guardian_decision_applied');
414
+
415
+ // Verify a grant was minted
416
+ expect(countGrants()).toBe(1);
417
+
418
+ const grant = getLatestGrant();
419
+ expect(grant).not.toBeNull();
420
+ expect(grant!.scope_mode).toBe('tool_signature');
421
+ expect(grant!.tool_name).toBe(TOOL_NAME);
422
+ expect(grant!.status).toBe('active');
423
+
424
+ deliverSpy.mockRestore();
425
+ composeSpy.mockRestore();
426
+ });
427
+
428
+ // ── 7. reject via conversation engine does NOT mint a grant ──
429
+
430
+ test('reject via conversation engine does NOT mint a grant', async () => {
431
+ const requestId = 'req-no-grant-eng-rej';
432
+ createTestGuardianApproval(requestId);
433
+ registerPendingInteraction(requestId, CONVERSATION_ID, TOOL_NAME, TOOL_INPUT);
434
+
435
+ const mockGenerator = async () => ({
436
+ disposition: 'reject' as const,
437
+ replyText: 'Denied.',
438
+ targetRequestId: requestId,
439
+ });
440
+
441
+ const result = await handleApprovalInterception({
442
+ conversationId: 'guardian-conv-7',
443
+ content: 'no, deny it',
444
+ externalChatId: GUARDIAN_CHAT,
445
+ sourceChannel: 'telegram',
446
+ senderExternalUserId: GUARDIAN_USER,
447
+ replyCallbackUrl: 'https://gateway.test/deliver',
448
+ guardianCtx: makeGuardianContext(),
449
+ assistantId: ASSISTANT_ID,
450
+ approvalConversationGenerator: mockGenerator,
451
+ });
452
+
453
+ expect(result.handled).toBe(true);
454
+ expect(result.type).toBe('guardian_decision_applied');
455
+
456
+ // No grant should have been minted
457
+ expect(countGrants()).toBe(0);
458
+
459
+ deliverSpy.mockRestore();
460
+ composeSpy.mockRestore();
461
+ });
462
+
463
+ // ── 8. approve_once via legacy parser mints a grant ──
464
+
465
+ test('approve_once via legacy parser mints a scoped grant', async () => {
466
+ const requestId = 'req-grant-leg-1';
467
+ createTestGuardianApproval(requestId);
468
+ registerPendingInteraction(requestId, CONVERSATION_ID, TOOL_NAME, TOOL_INPUT);
469
+
470
+ // No approvalConversationGenerator => legacy parser path
471
+ const result = await handleApprovalInterception({
472
+ conversationId: 'guardian-conv-8',
473
+ content: 'yes',
474
+ externalChatId: GUARDIAN_CHAT,
475
+ sourceChannel: 'telegram',
476
+ senderExternalUserId: GUARDIAN_USER,
477
+ replyCallbackUrl: 'https://gateway.test/deliver',
478
+ guardianCtx: makeGuardianContext(),
479
+ assistantId: ASSISTANT_ID,
480
+ });
481
+
482
+ expect(result.handled).toBe(true);
483
+ expect(result.type).toBe('guardian_decision_applied');
484
+
485
+ // Verify a grant was minted
486
+ expect(countGrants()).toBe(1);
487
+
488
+ const grant = getLatestGrant();
489
+ expect(grant).not.toBeNull();
490
+ expect(grant!.scope_mode).toBe('tool_signature');
491
+ expect(grant!.tool_name).toBe(TOOL_NAME);
492
+
493
+ deliverSpy.mockRestore();
494
+ composeSpy.mockRestore();
495
+ });
496
+
497
+ // ── 9. Grant TTL is approximately 5 minutes ──
498
+
499
+ test('minted grant has approximately 5-minute TTL', async () => {
500
+ const requestId = 'req-grant-ttl-1';
501
+ createTestGuardianApproval(requestId);
502
+ registerPendingInteraction(requestId, CONVERSATION_ID, TOOL_NAME, TOOL_INPUT);
503
+
504
+ const beforeTime = Date.now();
505
+
506
+ const result = await handleApprovalInterception({
507
+ conversationId: 'guardian-conv-9',
508
+ callbackData: `apr:${requestId}:approve_once`,
509
+ content: '',
510
+ externalChatId: GUARDIAN_CHAT,
511
+ sourceChannel: 'telegram',
512
+ senderExternalUserId: GUARDIAN_USER,
513
+ replyCallbackUrl: 'https://gateway.test/deliver',
514
+ guardianCtx: makeGuardianContext(),
515
+ assistantId: ASSISTANT_ID,
516
+ });
517
+
518
+ expect(result.type).toBe('guardian_decision_applied');
519
+
520
+ const grant = getLatestGrant();
521
+ expect(grant).not.toBeNull();
522
+
523
+ const expiresAt = new Date(grant!.expires_at as string).getTime();
524
+ const expectedMin = beforeTime + GRANT_TTL_MS - 1000; // 1s tolerance
525
+ const expectedMax = beforeTime + GRANT_TTL_MS + 5000; // 5s tolerance
526
+ expect(expiresAt).toBeGreaterThanOrEqual(expectedMin);
527
+ expect(expiresAt).toBeLessThanOrEqual(expectedMax);
528
+
529
+ deliverSpy.mockRestore();
530
+ composeSpy.mockRestore();
531
+ });
532
+ });