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