@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,511 @@
|
|
|
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 {
|
|
50
|
+
createGuardianActionRequest,
|
|
51
|
+
resolveGuardianActionRequest,
|
|
52
|
+
} from '../memory/guardian-action-store.js';
|
|
53
|
+
import {
|
|
54
|
+
consumeScopedApprovalGrantByToolSignature,
|
|
55
|
+
type CreateScopedApprovalGrantParams,
|
|
56
|
+
createScopedApprovalGrant,
|
|
57
|
+
} from '../memory/scoped-approval-grants.js';
|
|
58
|
+
import { getDb, initializeDb, resetDb } from '../memory/db.js';
|
|
59
|
+
import { conversations, scopedApprovalGrants } from '../memory/schema.js';
|
|
60
|
+
import { computeToolApprovalDigest } from '../security/tool-approval-digest.js';
|
|
61
|
+
import { tryMintGuardianActionGrant } from '../runtime/guardian-action-grant-minter.js';
|
|
62
|
+
import { createCallSession, createPendingQuestion } from '../calls/call-store.js';
|
|
63
|
+
|
|
64
|
+
initializeDb();
|
|
65
|
+
|
|
66
|
+
afterAll(() => {
|
|
67
|
+
resetDb();
|
|
68
|
+
try {
|
|
69
|
+
rmSync(testDir, { recursive: true });
|
|
70
|
+
} catch {
|
|
71
|
+
/* best effort */
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// ── Constants ───────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
const ASSISTANT_ID = 'self';
|
|
78
|
+
const TOOL_NAME = 'execute_shell';
|
|
79
|
+
const TOOL_INPUT = { command: 'rm -rf /tmp/test' };
|
|
80
|
+
const CONVERSATION_ID = 'conv-e2e';
|
|
81
|
+
|
|
82
|
+
// Mutable references populated by ensureFkParents()
|
|
83
|
+
let CALL_SESSION_ID = '';
|
|
84
|
+
let PENDING_QUESTION_IDS: string[] = [];
|
|
85
|
+
let pqIndex = 0;
|
|
86
|
+
|
|
87
|
+
function ensureConversation(id: string): void {
|
|
88
|
+
const db = getDb();
|
|
89
|
+
const now = Date.now();
|
|
90
|
+
db.insert(conversations).values({
|
|
91
|
+
id,
|
|
92
|
+
title: `Conversation ${id}`,
|
|
93
|
+
createdAt: now,
|
|
94
|
+
updatedAt: now,
|
|
95
|
+
}).run();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Create the FK parent rows required by guardian_action_requests. */
|
|
99
|
+
function ensureFkParents(): void {
|
|
100
|
+
ensureConversation(CONVERSATION_ID);
|
|
101
|
+
const session = createCallSession({
|
|
102
|
+
conversationId: CONVERSATION_ID,
|
|
103
|
+
provider: 'twilio',
|
|
104
|
+
fromNumber: '+15550001111',
|
|
105
|
+
toNumber: '+15550002222',
|
|
106
|
+
});
|
|
107
|
+
CALL_SESSION_ID = session.id;
|
|
108
|
+
|
|
109
|
+
// Pre-create enough pending questions for all tests in a suite run
|
|
110
|
+
PENDING_QUESTION_IDS = [];
|
|
111
|
+
pqIndex = 0;
|
|
112
|
+
for (let i = 0; i < 10; i++) {
|
|
113
|
+
const pq = createPendingQuestion(session.id, `Question ${i}`);
|
|
114
|
+
PENDING_QUESTION_IDS.push(pq.id);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function nextPendingQuestionId(): string {
|
|
119
|
+
return PENDING_QUESTION_IDS[pqIndex++];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function clearTables(): void {
|
|
123
|
+
const db = getDb();
|
|
124
|
+
try {
|
|
125
|
+
db.run('DELETE FROM scoped_approval_grants');
|
|
126
|
+
} catch {
|
|
127
|
+
/* table may not exist */
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
db.run('DELETE FROM guardian_action_deliveries');
|
|
131
|
+
} catch {
|
|
132
|
+
/* table may not exist */
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
db.run('DELETE FROM guardian_action_requests');
|
|
136
|
+
} catch {
|
|
137
|
+
/* table may not exist */
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
db.run('DELETE FROM call_pending_questions');
|
|
141
|
+
} catch {
|
|
142
|
+
/* table may not exist */
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
db.run('DELETE FROM call_events');
|
|
146
|
+
} catch {
|
|
147
|
+
/* table may not exist */
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
db.run('DELETE FROM call_sessions');
|
|
151
|
+
} catch {
|
|
152
|
+
/* table may not exist */
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
db.run('DELETE FROM conversations');
|
|
156
|
+
} catch {
|
|
157
|
+
/* table may not exist */
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── Tests ───────────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
describe('guardian-action grant mint -> voice consume integration', () => {
|
|
164
|
+
beforeEach(() => {
|
|
165
|
+
clearTables();
|
|
166
|
+
ensureFkParents();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('full flow: resolve guardian action with tool metadata -> mint grant -> voice consume succeeds once', () => {
|
|
170
|
+
const inputDigest = computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT);
|
|
171
|
+
|
|
172
|
+
// Step 1: Create a guardian action request with tool metadata
|
|
173
|
+
// (simulates the voice ASK_GUARDIAN path)
|
|
174
|
+
const request = createGuardianActionRequest({
|
|
175
|
+
assistantId: ASSISTANT_ID,
|
|
176
|
+
kind: 'ask_guardian',
|
|
177
|
+
sourceChannel: 'voice',
|
|
178
|
+
sourceConversationId: CONVERSATION_ID,
|
|
179
|
+
callSessionId: CALL_SESSION_ID,
|
|
180
|
+
pendingQuestionId: nextPendingQuestionId(),
|
|
181
|
+
questionText: 'Can I run rm -rf /tmp/test?',
|
|
182
|
+
expiresAt: Date.now() + 60_000,
|
|
183
|
+
toolName: TOOL_NAME,
|
|
184
|
+
inputDigest,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
expect(request.toolName).toBe(TOOL_NAME);
|
|
188
|
+
expect(request.inputDigest).toBe(inputDigest);
|
|
189
|
+
expect(request.status).toBe('pending');
|
|
190
|
+
|
|
191
|
+
// Step 2: Guardian answers -> resolve the request
|
|
192
|
+
const resolved = resolveGuardianActionRequest(
|
|
193
|
+
request.id,
|
|
194
|
+
'yes',
|
|
195
|
+
'telegram',
|
|
196
|
+
'guardian-user-123',
|
|
197
|
+
);
|
|
198
|
+
expect(resolved).not.toBeNull();
|
|
199
|
+
expect(resolved!.status).toBe('answered');
|
|
200
|
+
|
|
201
|
+
// Step 3: Mint a scoped grant from the resolved request
|
|
202
|
+
tryMintGuardianActionGrant({
|
|
203
|
+
resolvedRequest: resolved!,
|
|
204
|
+
answerText: 'yes',
|
|
205
|
+
decisionChannel: 'telegram',
|
|
206
|
+
guardianExternalUserId: 'guardian-user-123',
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// Verify the grant was created
|
|
210
|
+
const db = getDb();
|
|
211
|
+
const grants = db
|
|
212
|
+
.select()
|
|
213
|
+
.from(scopedApprovalGrants)
|
|
214
|
+
.all();
|
|
215
|
+
expect(grants.length).toBe(1);
|
|
216
|
+
expect(grants[0].toolName).toBe(TOOL_NAME);
|
|
217
|
+
expect(grants[0].inputDigest).toBe(inputDigest);
|
|
218
|
+
expect(grants[0].scopeMode).toBe('tool_signature');
|
|
219
|
+
expect(grants[0].status).toBe('active');
|
|
220
|
+
expect(grants[0].assistantId).toBe(ASSISTANT_ID);
|
|
221
|
+
expect(grants[0].callSessionId).toBe(CALL_SESSION_ID);
|
|
222
|
+
|
|
223
|
+
// Step 4: Voice consumer consumes the grant
|
|
224
|
+
const consumeResult = consumeScopedApprovalGrantByToolSignature({
|
|
225
|
+
toolName: TOOL_NAME,
|
|
226
|
+
inputDigest,
|
|
227
|
+
consumingRequestId: 'voice-req-1',
|
|
228
|
+
assistantId: ASSISTANT_ID,
|
|
229
|
+
executionChannel: 'voice',
|
|
230
|
+
callSessionId: CALL_SESSION_ID,
|
|
231
|
+
conversationId: CONVERSATION_ID,
|
|
232
|
+
});
|
|
233
|
+
expect(consumeResult.ok).toBe(true);
|
|
234
|
+
expect(consumeResult.grant).not.toBeNull();
|
|
235
|
+
expect(consumeResult.grant!.status).toBe('consumed');
|
|
236
|
+
expect(consumeResult.grant!.consumedByRequestId).toBe('voice-req-1');
|
|
237
|
+
|
|
238
|
+
// Step 5: Second consume attempt fails (one-time use)
|
|
239
|
+
const secondConsume = consumeScopedApprovalGrantByToolSignature({
|
|
240
|
+
toolName: TOOL_NAME,
|
|
241
|
+
inputDigest,
|
|
242
|
+
consumingRequestId: 'voice-req-2',
|
|
243
|
+
assistantId: ASSISTANT_ID,
|
|
244
|
+
executionChannel: 'voice',
|
|
245
|
+
callSessionId: CALL_SESSION_ID,
|
|
246
|
+
conversationId: CONVERSATION_ID,
|
|
247
|
+
});
|
|
248
|
+
expect(secondConsume.ok).toBe(false);
|
|
249
|
+
expect(secondConsume.grant).toBeNull();
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test('grant minted for one assistantId cannot be consumed by another', () => {
|
|
253
|
+
const inputDigest = computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT);
|
|
254
|
+
|
|
255
|
+
const request = createGuardianActionRequest({
|
|
256
|
+
assistantId: ASSISTANT_ID,
|
|
257
|
+
kind: 'ask_guardian',
|
|
258
|
+
sourceChannel: 'voice',
|
|
259
|
+
sourceConversationId: CONVERSATION_ID,
|
|
260
|
+
callSessionId: CALL_SESSION_ID,
|
|
261
|
+
pendingQuestionId: nextPendingQuestionId(),
|
|
262
|
+
questionText: 'Can I run the command?',
|
|
263
|
+
expiresAt: Date.now() + 60_000,
|
|
264
|
+
toolName: TOOL_NAME,
|
|
265
|
+
inputDigest,
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
const resolved = resolveGuardianActionRequest(request.id, 'Yes', 'telegram');
|
|
269
|
+
expect(resolved).not.toBeNull();
|
|
270
|
+
|
|
271
|
+
tryMintGuardianActionGrant({
|
|
272
|
+
resolvedRequest: resolved!,
|
|
273
|
+
answerText: 'Yes',
|
|
274
|
+
decisionChannel: 'telegram',
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// Attempt to consume with a different assistantId
|
|
278
|
+
const wrongAssistant = consumeScopedApprovalGrantByToolSignature({
|
|
279
|
+
toolName: TOOL_NAME,
|
|
280
|
+
inputDigest,
|
|
281
|
+
consumingRequestId: 'voice-req-wrong',
|
|
282
|
+
assistantId: 'other-assistant',
|
|
283
|
+
executionChannel: 'voice',
|
|
284
|
+
callSessionId: CALL_SESSION_ID,
|
|
285
|
+
conversationId: CONVERSATION_ID,
|
|
286
|
+
});
|
|
287
|
+
expect(wrongAssistant.ok).toBe(false);
|
|
288
|
+
|
|
289
|
+
// Correct assistantId succeeds
|
|
290
|
+
const correctAssistant = consumeScopedApprovalGrantByToolSignature({
|
|
291
|
+
toolName: TOOL_NAME,
|
|
292
|
+
inputDigest,
|
|
293
|
+
consumingRequestId: 'voice-req-correct',
|
|
294
|
+
assistantId: ASSISTANT_ID,
|
|
295
|
+
executionChannel: 'voice',
|
|
296
|
+
callSessionId: CALL_SESSION_ID,
|
|
297
|
+
conversationId: CONVERSATION_ID,
|
|
298
|
+
});
|
|
299
|
+
expect(correctAssistant.ok).toBe(true);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test('no grant minted when guardian action request lacks tool metadata', () => {
|
|
303
|
+
// Create a request without toolName/inputDigest (informational consult)
|
|
304
|
+
const request = createGuardianActionRequest({
|
|
305
|
+
assistantId: ASSISTANT_ID,
|
|
306
|
+
kind: 'ask_guardian',
|
|
307
|
+
sourceChannel: 'voice',
|
|
308
|
+
sourceConversationId: CONVERSATION_ID,
|
|
309
|
+
callSessionId: CALL_SESSION_ID,
|
|
310
|
+
pendingQuestionId: nextPendingQuestionId(),
|
|
311
|
+
questionText: 'What should I tell the caller?',
|
|
312
|
+
expiresAt: Date.now() + 60_000,
|
|
313
|
+
// No toolName or inputDigest
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
const resolved = resolveGuardianActionRequest(request.id, 'Tell them to call back', 'vellum');
|
|
317
|
+
expect(resolved).not.toBeNull();
|
|
318
|
+
|
|
319
|
+
tryMintGuardianActionGrant({
|
|
320
|
+
resolvedRequest: resolved!,
|
|
321
|
+
answerText: 'Tell them to call back',
|
|
322
|
+
decisionChannel: 'vellum',
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
// No grant should have been created
|
|
326
|
+
const db = getDb();
|
|
327
|
+
const grants = db
|
|
328
|
+
.select()
|
|
329
|
+
.from(scopedApprovalGrants)
|
|
330
|
+
.all();
|
|
331
|
+
expect(grants.length).toBe(0);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test('grant minted via desktop/vellum channel also consumable by voice', () => {
|
|
335
|
+
const inputDigest = computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT);
|
|
336
|
+
|
|
337
|
+
const request = createGuardianActionRequest({
|
|
338
|
+
assistantId: ASSISTANT_ID,
|
|
339
|
+
kind: 'ask_guardian',
|
|
340
|
+
sourceChannel: 'voice',
|
|
341
|
+
sourceConversationId: CONVERSATION_ID,
|
|
342
|
+
callSessionId: CALL_SESSION_ID,
|
|
343
|
+
pendingQuestionId: nextPendingQuestionId(),
|
|
344
|
+
questionText: 'Permission to execute?',
|
|
345
|
+
expiresAt: Date.now() + 60_000,
|
|
346
|
+
toolName: TOOL_NAME,
|
|
347
|
+
inputDigest,
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
// Guardian answers via desktop (vellum channel)
|
|
351
|
+
const resolved = resolveGuardianActionRequest(request.id, 'approve', 'vellum');
|
|
352
|
+
expect(resolved).not.toBeNull();
|
|
353
|
+
|
|
354
|
+
// Mint with decisionChannel: 'vellum' (desktop path)
|
|
355
|
+
tryMintGuardianActionGrant({
|
|
356
|
+
resolvedRequest: resolved!,
|
|
357
|
+
answerText: 'approve',
|
|
358
|
+
decisionChannel: 'vellum',
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// The grant should have executionChannel: null (wildcard), so voice can consume
|
|
362
|
+
const consumeResult = consumeScopedApprovalGrantByToolSignature({
|
|
363
|
+
toolName: TOOL_NAME,
|
|
364
|
+
inputDigest,
|
|
365
|
+
consumingRequestId: 'voice-req-desktop',
|
|
366
|
+
assistantId: ASSISTANT_ID,
|
|
367
|
+
executionChannel: 'voice',
|
|
368
|
+
callSessionId: CALL_SESSION_ID,
|
|
369
|
+
conversationId: CONVERSATION_ID,
|
|
370
|
+
});
|
|
371
|
+
expect(consumeResult.ok).toBe(true);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test('no grant minted when guardian answer is a denial', () => {
|
|
375
|
+
const inputDigest = computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT);
|
|
376
|
+
|
|
377
|
+
const request = createGuardianActionRequest({
|
|
378
|
+
assistantId: ASSISTANT_ID,
|
|
379
|
+
kind: 'ask_guardian',
|
|
380
|
+
sourceChannel: 'voice',
|
|
381
|
+
sourceConversationId: CONVERSATION_ID,
|
|
382
|
+
callSessionId: CALL_SESSION_ID,
|
|
383
|
+
pendingQuestionId: nextPendingQuestionId(),
|
|
384
|
+
questionText: 'Can I run rm -rf /tmp/test?',
|
|
385
|
+
expiresAt: Date.now() + 60_000,
|
|
386
|
+
toolName: TOOL_NAME,
|
|
387
|
+
inputDigest,
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
// Guardian explicitly denies the action
|
|
391
|
+
const resolved = resolveGuardianActionRequest(request.id, 'No', 'telegram', 'guardian-user-456');
|
|
392
|
+
expect(resolved).not.toBeNull();
|
|
393
|
+
|
|
394
|
+
tryMintGuardianActionGrant({
|
|
395
|
+
resolvedRequest: resolved!,
|
|
396
|
+
answerText: 'No',
|
|
397
|
+
decisionChannel: 'telegram',
|
|
398
|
+
guardianExternalUserId: 'guardian-user-456',
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
// No grant should have been created for a denial
|
|
402
|
+
const db = getDb();
|
|
403
|
+
const grants = db
|
|
404
|
+
.select()
|
|
405
|
+
.from(scopedApprovalGrants)
|
|
406
|
+
.all();
|
|
407
|
+
expect(grants.length).toBe(0);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test.each(['no', 'reject', 'deny', 'cancel'])('no grant minted for denial keyword: %s', (denialWord) => {
|
|
411
|
+
const inputDigest = computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT);
|
|
412
|
+
|
|
413
|
+
const request = createGuardianActionRequest({
|
|
414
|
+
assistantId: ASSISTANT_ID,
|
|
415
|
+
kind: 'ask_guardian',
|
|
416
|
+
sourceChannel: 'voice',
|
|
417
|
+
sourceConversationId: CONVERSATION_ID,
|
|
418
|
+
callSessionId: CALL_SESSION_ID,
|
|
419
|
+
pendingQuestionId: nextPendingQuestionId(),
|
|
420
|
+
questionText: 'Permission to execute?',
|
|
421
|
+
expiresAt: Date.now() + 60_000,
|
|
422
|
+
toolName: TOOL_NAME,
|
|
423
|
+
inputDigest,
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
const resolved = resolveGuardianActionRequest(request.id, denialWord, 'telegram');
|
|
427
|
+
expect(resolved).not.toBeNull();
|
|
428
|
+
|
|
429
|
+
tryMintGuardianActionGrant({
|
|
430
|
+
resolvedRequest: resolved!,
|
|
431
|
+
answerText: denialWord,
|
|
432
|
+
decisionChannel: 'telegram',
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
const db = getDb();
|
|
436
|
+
const grants = db
|
|
437
|
+
.select()
|
|
438
|
+
.from(scopedApprovalGrants)
|
|
439
|
+
.all();
|
|
440
|
+
expect(grants.length).toBe(0);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test('no grant minted for unrecognised free-form answer (fail-closed)', () => {
|
|
444
|
+
const inputDigest = computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT);
|
|
445
|
+
|
|
446
|
+
const request = createGuardianActionRequest({
|
|
447
|
+
assistantId: ASSISTANT_ID,
|
|
448
|
+
kind: 'ask_guardian',
|
|
449
|
+
sourceChannel: 'voice',
|
|
450
|
+
sourceConversationId: CONVERSATION_ID,
|
|
451
|
+
callSessionId: CALL_SESSION_ID,
|
|
452
|
+
pendingQuestionId: nextPendingQuestionId(),
|
|
453
|
+
questionText: 'Can I run the command?',
|
|
454
|
+
expiresAt: Date.now() + 60_000,
|
|
455
|
+
toolName: TOOL_NAME,
|
|
456
|
+
inputDigest,
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
// Free-form text that doesn't match a known approval phrase
|
|
460
|
+
const resolved = resolveGuardianActionRequest(request.id, 'Sure, go ahead and run it', 'telegram');
|
|
461
|
+
expect(resolved).not.toBeNull();
|
|
462
|
+
|
|
463
|
+
tryMintGuardianActionGrant({
|
|
464
|
+
resolvedRequest: resolved!,
|
|
465
|
+
answerText: 'Sure, go ahead and run it',
|
|
466
|
+
decisionChannel: 'telegram',
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
// No grant — unrecognised text is not treated as approval (fail-closed)
|
|
470
|
+
const db = getDb();
|
|
471
|
+
const grants = db
|
|
472
|
+
.select()
|
|
473
|
+
.from(scopedApprovalGrants)
|
|
474
|
+
.all();
|
|
475
|
+
expect(grants.length).toBe(0);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
test.each(['yes', 'approve', 'approve once', 'allow', 'go ahead'])('grant IS minted for approval keyword: %s', (approveWord) => {
|
|
479
|
+
const inputDigest = computeToolApprovalDigest(TOOL_NAME, TOOL_INPUT);
|
|
480
|
+
|
|
481
|
+
const request = createGuardianActionRequest({
|
|
482
|
+
assistantId: ASSISTANT_ID,
|
|
483
|
+
kind: 'ask_guardian',
|
|
484
|
+
sourceChannel: 'voice',
|
|
485
|
+
sourceConversationId: CONVERSATION_ID,
|
|
486
|
+
callSessionId: CALL_SESSION_ID,
|
|
487
|
+
pendingQuestionId: nextPendingQuestionId(),
|
|
488
|
+
questionText: 'Can I run the command?',
|
|
489
|
+
expiresAt: Date.now() + 60_000,
|
|
490
|
+
toolName: TOOL_NAME,
|
|
491
|
+
inputDigest,
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
const resolved = resolveGuardianActionRequest(request.id, approveWord, 'telegram');
|
|
495
|
+
expect(resolved).not.toBeNull();
|
|
496
|
+
|
|
497
|
+
tryMintGuardianActionGrant({
|
|
498
|
+
resolvedRequest: resolved!,
|
|
499
|
+
answerText: approveWord,
|
|
500
|
+
decisionChannel: 'telegram',
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
const db = getDb();
|
|
504
|
+
const grants = db
|
|
505
|
+
.select()
|
|
506
|
+
.from(scopedApprovalGrants)
|
|
507
|
+
.all();
|
|
508
|
+
expect(grants.length).toBe(1);
|
|
509
|
+
expect(grants[0].toolName).toBe(TOOL_NAME);
|
|
510
|
+
});
|
|
511
|
+
});
|
|
@@ -336,10 +336,70 @@ describe('guardian-dispatch', () => {
|
|
|
336
336
|
expect(vellumDelivery!.destination_conversation_id).toBe('conv-from-thread-created');
|
|
337
337
|
});
|
|
338
338
|
|
|
339
|
-
test('
|
|
339
|
+
test('persists toolName and inputDigest on guardian action request for tool-approval dispatches', async () => {
|
|
340
340
|
const convId = 'conv-dispatch-5';
|
|
341
341
|
ensureConversation(convId);
|
|
342
342
|
|
|
343
|
+
const session = createCallSession({
|
|
344
|
+
conversationId: convId,
|
|
345
|
+
provider: 'twilio',
|
|
346
|
+
fromNumber: '+15550001111',
|
|
347
|
+
toNumber: '+15550002222',
|
|
348
|
+
});
|
|
349
|
+
const pq = createPendingQuestion(session.id, 'Allow send_email to bob@example.com?');
|
|
350
|
+
|
|
351
|
+
await dispatchGuardianQuestion({
|
|
352
|
+
callSessionId: session.id,
|
|
353
|
+
conversationId: convId,
|
|
354
|
+
assistantId: 'self',
|
|
355
|
+
pendingQuestion: pq,
|
|
356
|
+
toolName: 'send_email',
|
|
357
|
+
inputDigest: 'abc123def456',
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
const db = getDb();
|
|
361
|
+
const raw = (db as unknown as { $client: import('bun:sqlite').Database }).$client;
|
|
362
|
+
const request = raw.query('SELECT * FROM guardian_action_requests WHERE call_session_id = ?').get(session.id) as
|
|
363
|
+
| { id: string; tool_name: string | null; input_digest: string | null }
|
|
364
|
+
| undefined;
|
|
365
|
+
expect(request).toBeDefined();
|
|
366
|
+
expect(request!.tool_name).toBe('send_email');
|
|
367
|
+
expect(request!.input_digest).toBe('abc123def456');
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test('omitting toolName and inputDigest stores null for informational ASK_GUARDIAN dispatches', async () => {
|
|
371
|
+
const convId = 'conv-dispatch-6';
|
|
372
|
+
ensureConversation(convId);
|
|
373
|
+
|
|
374
|
+
const session = createCallSession({
|
|
375
|
+
conversationId: convId,
|
|
376
|
+
provider: 'twilio',
|
|
377
|
+
fromNumber: '+15550001111',
|
|
378
|
+
toNumber: '+15550002222',
|
|
379
|
+
});
|
|
380
|
+
const pq = createPendingQuestion(session.id, 'What time works?');
|
|
381
|
+
|
|
382
|
+
await dispatchGuardianQuestion({
|
|
383
|
+
callSessionId: session.id,
|
|
384
|
+
conversationId: convId,
|
|
385
|
+
assistantId: 'self',
|
|
386
|
+
pendingQuestion: pq,
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
const db = getDb();
|
|
390
|
+
const raw = (db as unknown as { $client: import('bun:sqlite').Database }).$client;
|
|
391
|
+
const request = raw.query('SELECT * FROM guardian_action_requests WHERE call_session_id = ?').get(session.id) as
|
|
392
|
+
| { id: string; tool_name: string | null; input_digest: string | null }
|
|
393
|
+
| undefined;
|
|
394
|
+
expect(request).toBeDefined();
|
|
395
|
+
expect(request!.tool_name).toBeNull();
|
|
396
|
+
expect(request!.input_digest).toBeNull();
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test('includes activeGuardianRequestCount in context payload', async () => {
|
|
400
|
+
const convId = 'conv-dispatch-7';
|
|
401
|
+
ensureConversation(convId);
|
|
402
|
+
|
|
343
403
|
const session = createCallSession({
|
|
344
404
|
conversationId: convId,
|
|
345
405
|
provider: 'twilio',
|