@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,521 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test';
|
|
6
|
+
|
|
7
|
+
const testDir = mkdtempSync(join(tmpdir(), 'scoped-grants-test-'));
|
|
8
|
+
|
|
9
|
+
mock.module('../util/platform.js', () => ({
|
|
10
|
+
getDataDir: () => testDir,
|
|
11
|
+
isMacOS: () => process.platform === 'darwin',
|
|
12
|
+
isLinux: () => process.platform === 'linux',
|
|
13
|
+
isWindows: () => process.platform === 'win32',
|
|
14
|
+
getSocketPath: () => join(testDir, 'test.sock'),
|
|
15
|
+
getPidPath: () => join(testDir, 'test.pid'),
|
|
16
|
+
getDbPath: () => join(testDir, 'test.db'),
|
|
17
|
+
getLogPath: () => join(testDir, 'test.log'),
|
|
18
|
+
ensureDataDir: () => {},
|
|
19
|
+
migrateToDataLayout: () => {},
|
|
20
|
+
migrateToWorkspaceLayout: () => {},
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
mock.module('../util/logger.js', () => ({
|
|
24
|
+
getLogger: () =>
|
|
25
|
+
new Proxy({} as Record<string, unknown>, {
|
|
26
|
+
get: () => () => {},
|
|
27
|
+
}),
|
|
28
|
+
isDebug: () => false,
|
|
29
|
+
truncateForLog: (value: string) => value,
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
import {
|
|
33
|
+
type CreateScopedApprovalGrantParams,
|
|
34
|
+
consumeScopedApprovalGrantByRequestId,
|
|
35
|
+
consumeScopedApprovalGrantByToolSignature,
|
|
36
|
+
createScopedApprovalGrant,
|
|
37
|
+
expireScopedApprovalGrants,
|
|
38
|
+
revokeScopedApprovalGrantsForContext,
|
|
39
|
+
} from '../memory/scoped-approval-grants.js';
|
|
40
|
+
import { getDb, initializeDb, resetDb } from '../memory/db.js';
|
|
41
|
+
import { scopedApprovalGrants } from '../memory/schema.js';
|
|
42
|
+
import {
|
|
43
|
+
canonicalJsonSerialize,
|
|
44
|
+
computeToolApprovalDigest,
|
|
45
|
+
} from '../security/tool-approval-digest.js';
|
|
46
|
+
|
|
47
|
+
initializeDb();
|
|
48
|
+
|
|
49
|
+
function clearTables(): void {
|
|
50
|
+
const db = getDb();
|
|
51
|
+
db.delete(scopedApprovalGrants).run();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
afterAll(() => {
|
|
55
|
+
resetDb();
|
|
56
|
+
try {
|
|
57
|
+
rmSync(testDir, { recursive: true });
|
|
58
|
+
} catch {
|
|
59
|
+
/* best effort */
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Helper to build grant params with sensible defaults
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
function grantParams(overrides: Partial<CreateScopedApprovalGrantParams> = {}): CreateScopedApprovalGrantParams {
|
|
68
|
+
const futureExpiry = new Date(Date.now() + 60_000).toISOString();
|
|
69
|
+
return {
|
|
70
|
+
assistantId: 'self',
|
|
71
|
+
scopeMode: 'request_id',
|
|
72
|
+
requestChannel: 'telegram',
|
|
73
|
+
decisionChannel: 'telegram',
|
|
74
|
+
expiresAt: futureExpiry,
|
|
75
|
+
...overrides,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ===========================================================================
|
|
80
|
+
// SCOPE MODE: request_id
|
|
81
|
+
// ===========================================================================
|
|
82
|
+
|
|
83
|
+
describe('scoped-approval-grants / request_id scope', () => {
|
|
84
|
+
beforeEach(() => clearTables());
|
|
85
|
+
|
|
86
|
+
test('create and consume by request_id succeeds', () => {
|
|
87
|
+
const grant = createScopedApprovalGrant(
|
|
88
|
+
grantParams({ scopeMode: 'request_id', requestId: 'req-1' }),
|
|
89
|
+
);
|
|
90
|
+
expect(grant.status).toBe('active');
|
|
91
|
+
expect(grant.requestId).toBe('req-1');
|
|
92
|
+
|
|
93
|
+
const result = consumeScopedApprovalGrantByRequestId('req-1', 'consumer-1', 'self');
|
|
94
|
+
expect(result.ok).toBe(true);
|
|
95
|
+
expect(result.grant).not.toBeNull();
|
|
96
|
+
expect(result.grant!.status).toBe('consumed');
|
|
97
|
+
expect(result.grant!.consumedByRequestId).toBe('consumer-1');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('second consume of same grant fails (one-time use)', () => {
|
|
101
|
+
createScopedApprovalGrant(
|
|
102
|
+
grantParams({ scopeMode: 'request_id', requestId: 'req-2' }),
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const first = consumeScopedApprovalGrantByRequestId('req-2', 'consumer-a', 'self');
|
|
106
|
+
expect(first.ok).toBe(true);
|
|
107
|
+
|
|
108
|
+
const second = consumeScopedApprovalGrantByRequestId('req-2', 'consumer-b', 'self');
|
|
109
|
+
expect(second.ok).toBe(false);
|
|
110
|
+
expect(second.grant).toBeNull();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('consume fails when no matching grant exists', () => {
|
|
114
|
+
const result = consumeScopedApprovalGrantByRequestId('nonexistent', 'consumer-x', 'self');
|
|
115
|
+
expect(result.ok).toBe(false);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('expired grant cannot be consumed', () => {
|
|
119
|
+
const pastExpiry = new Date(Date.now() - 1_000).toISOString();
|
|
120
|
+
createScopedApprovalGrant(
|
|
121
|
+
grantParams({ scopeMode: 'request_id', requestId: 'req-expired', expiresAt: pastExpiry }),
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const result = consumeScopedApprovalGrantByRequestId('req-expired', 'consumer-1', 'self');
|
|
125
|
+
expect(result.ok).toBe(false);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// ===========================================================================
|
|
130
|
+
// SCOPE MODE: tool_signature
|
|
131
|
+
// ===========================================================================
|
|
132
|
+
|
|
133
|
+
describe('scoped-approval-grants / tool_signature scope', () => {
|
|
134
|
+
beforeEach(() => clearTables());
|
|
135
|
+
|
|
136
|
+
test('create and consume by tool signature succeeds', () => {
|
|
137
|
+
const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
|
|
138
|
+
const grant = createScopedApprovalGrant(
|
|
139
|
+
grantParams({
|
|
140
|
+
scopeMode: 'tool_signature',
|
|
141
|
+
toolName: 'bash',
|
|
142
|
+
inputDigest: digest,
|
|
143
|
+
}),
|
|
144
|
+
);
|
|
145
|
+
expect(grant.status).toBe('active');
|
|
146
|
+
expect(grant.toolName).toBe('bash');
|
|
147
|
+
|
|
148
|
+
const result = consumeScopedApprovalGrantByToolSignature({
|
|
149
|
+
toolName: 'bash',
|
|
150
|
+
inputDigest: digest,
|
|
151
|
+
consumingRequestId: 'consumer-1',
|
|
152
|
+
});
|
|
153
|
+
expect(result.ok).toBe(true);
|
|
154
|
+
expect(result.grant!.status).toBe('consumed');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('second consume of tool_signature grant fails', () => {
|
|
158
|
+
const digest = computeToolApprovalDigest('bash', { cmd: 'rm -rf' });
|
|
159
|
+
createScopedApprovalGrant(
|
|
160
|
+
grantParams({
|
|
161
|
+
scopeMode: 'tool_signature',
|
|
162
|
+
toolName: 'bash',
|
|
163
|
+
inputDigest: digest,
|
|
164
|
+
}),
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
const first = consumeScopedApprovalGrantByToolSignature({
|
|
168
|
+
toolName: 'bash',
|
|
169
|
+
inputDigest: digest,
|
|
170
|
+
consumingRequestId: 'c1',
|
|
171
|
+
});
|
|
172
|
+
expect(first.ok).toBe(true);
|
|
173
|
+
|
|
174
|
+
const second = consumeScopedApprovalGrantByToolSignature({
|
|
175
|
+
toolName: 'bash',
|
|
176
|
+
inputDigest: digest,
|
|
177
|
+
consumingRequestId: 'c2',
|
|
178
|
+
});
|
|
179
|
+
expect(second.ok).toBe(false);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('mismatched input digest fails consume', () => {
|
|
183
|
+
const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
|
|
184
|
+
createScopedApprovalGrant(
|
|
185
|
+
grantParams({
|
|
186
|
+
scopeMode: 'tool_signature',
|
|
187
|
+
toolName: 'bash',
|
|
188
|
+
inputDigest: digest,
|
|
189
|
+
}),
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
const wrongDigest = computeToolApprovalDigest('bash', { cmd: 'pwd' });
|
|
193
|
+
const result = consumeScopedApprovalGrantByToolSignature({
|
|
194
|
+
toolName: 'bash',
|
|
195
|
+
inputDigest: wrongDigest,
|
|
196
|
+
consumingRequestId: 'c1',
|
|
197
|
+
});
|
|
198
|
+
expect(result.ok).toBe(false);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('mismatched tool name fails consume', () => {
|
|
202
|
+
const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
|
|
203
|
+
createScopedApprovalGrant(
|
|
204
|
+
grantParams({
|
|
205
|
+
scopeMode: 'tool_signature',
|
|
206
|
+
toolName: 'bash',
|
|
207
|
+
inputDigest: digest,
|
|
208
|
+
}),
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
const result = consumeScopedApprovalGrantByToolSignature({
|
|
212
|
+
toolName: 'python',
|
|
213
|
+
inputDigest: digest,
|
|
214
|
+
consumingRequestId: 'c1',
|
|
215
|
+
});
|
|
216
|
+
expect(result.ok).toBe(false);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('context constraint: executionChannel must match non-null grant field', () => {
|
|
220
|
+
const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
|
|
221
|
+
createScopedApprovalGrant(
|
|
222
|
+
grantParams({
|
|
223
|
+
scopeMode: 'tool_signature',
|
|
224
|
+
toolName: 'bash',
|
|
225
|
+
inputDigest: digest,
|
|
226
|
+
executionChannel: 'telegram',
|
|
227
|
+
}),
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
// Wrong channel
|
|
231
|
+
const wrong = consumeScopedApprovalGrantByToolSignature({
|
|
232
|
+
toolName: 'bash',
|
|
233
|
+
inputDigest: digest,
|
|
234
|
+
consumingRequestId: 'c1',
|
|
235
|
+
executionChannel: 'sms',
|
|
236
|
+
});
|
|
237
|
+
expect(wrong.ok).toBe(false);
|
|
238
|
+
|
|
239
|
+
// Correct channel
|
|
240
|
+
const correct = consumeScopedApprovalGrantByToolSignature({
|
|
241
|
+
toolName: 'bash',
|
|
242
|
+
inputDigest: digest,
|
|
243
|
+
consumingRequestId: 'c2',
|
|
244
|
+
executionChannel: 'telegram',
|
|
245
|
+
});
|
|
246
|
+
expect(correct.ok).toBe(true);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test('null executionChannel on grant means any channel matches', () => {
|
|
250
|
+
const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
|
|
251
|
+
createScopedApprovalGrant(
|
|
252
|
+
grantParams({
|
|
253
|
+
scopeMode: 'tool_signature',
|
|
254
|
+
toolName: 'bash',
|
|
255
|
+
inputDigest: digest,
|
|
256
|
+
executionChannel: null,
|
|
257
|
+
}),
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
const result = consumeScopedApprovalGrantByToolSignature({
|
|
261
|
+
toolName: 'bash',
|
|
262
|
+
inputDigest: digest,
|
|
263
|
+
consumingRequestId: 'c1',
|
|
264
|
+
executionChannel: 'sms',
|
|
265
|
+
});
|
|
266
|
+
expect(result.ok).toBe(true);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test('context constraint: conversationId must match non-null grant field', () => {
|
|
270
|
+
const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
|
|
271
|
+
createScopedApprovalGrant(
|
|
272
|
+
grantParams({
|
|
273
|
+
scopeMode: 'tool_signature',
|
|
274
|
+
toolName: 'bash',
|
|
275
|
+
inputDigest: digest,
|
|
276
|
+
conversationId: 'conv-123',
|
|
277
|
+
}),
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
// Mismatched
|
|
281
|
+
const wrong = consumeScopedApprovalGrantByToolSignature({
|
|
282
|
+
toolName: 'bash',
|
|
283
|
+
inputDigest: digest,
|
|
284
|
+
consumingRequestId: 'c1',
|
|
285
|
+
conversationId: 'conv-999',
|
|
286
|
+
});
|
|
287
|
+
expect(wrong.ok).toBe(false);
|
|
288
|
+
|
|
289
|
+
// Matched
|
|
290
|
+
const correct = consumeScopedApprovalGrantByToolSignature({
|
|
291
|
+
toolName: 'bash',
|
|
292
|
+
inputDigest: digest,
|
|
293
|
+
consumingRequestId: 'c2',
|
|
294
|
+
conversationId: 'conv-123',
|
|
295
|
+
});
|
|
296
|
+
expect(correct.ok).toBe(true);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test('expired tool_signature grant cannot be consumed', () => {
|
|
300
|
+
const pastExpiry = new Date(Date.now() - 1_000).toISOString();
|
|
301
|
+
const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
|
|
302
|
+
createScopedApprovalGrant(
|
|
303
|
+
grantParams({
|
|
304
|
+
scopeMode: 'tool_signature',
|
|
305
|
+
toolName: 'bash',
|
|
306
|
+
inputDigest: digest,
|
|
307
|
+
expiresAt: pastExpiry,
|
|
308
|
+
}),
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
const result = consumeScopedApprovalGrantByToolSignature({
|
|
312
|
+
toolName: 'bash',
|
|
313
|
+
inputDigest: digest,
|
|
314
|
+
consumingRequestId: 'c1',
|
|
315
|
+
});
|
|
316
|
+
expect(result.ok).toBe(false);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test('consume by tool signature only consumes one grant when multiple match', () => {
|
|
320
|
+
const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
|
|
321
|
+
|
|
322
|
+
// Create a wildcard grant (no executionChannel) and a channel-specific grant.
|
|
323
|
+
// Both match when executionChannel='telegram', but only one should be consumed.
|
|
324
|
+
const wildcardGrant = createScopedApprovalGrant(
|
|
325
|
+
grantParams({
|
|
326
|
+
scopeMode: 'tool_signature',
|
|
327
|
+
toolName: 'bash',
|
|
328
|
+
inputDigest: digest,
|
|
329
|
+
executionChannel: null,
|
|
330
|
+
}),
|
|
331
|
+
);
|
|
332
|
+
const specificGrant = createScopedApprovalGrant(
|
|
333
|
+
grantParams({
|
|
334
|
+
scopeMode: 'tool_signature',
|
|
335
|
+
toolName: 'bash',
|
|
336
|
+
inputDigest: digest,
|
|
337
|
+
executionChannel: 'telegram',
|
|
338
|
+
}),
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
const result = consumeScopedApprovalGrantByToolSignature({
|
|
342
|
+
toolName: 'bash',
|
|
343
|
+
inputDigest: digest,
|
|
344
|
+
consumingRequestId: 'c1',
|
|
345
|
+
executionChannel: 'telegram',
|
|
346
|
+
});
|
|
347
|
+
expect(result.ok).toBe(true);
|
|
348
|
+
// The most specific grant (channel-specific) should be consumed first
|
|
349
|
+
expect(result.grant!.id).toBe(specificGrant.id);
|
|
350
|
+
|
|
351
|
+
// The wildcard grant should still be active and consumable
|
|
352
|
+
const second = consumeScopedApprovalGrantByToolSignature({
|
|
353
|
+
toolName: 'bash',
|
|
354
|
+
inputDigest: digest,
|
|
355
|
+
consumingRequestId: 'c2',
|
|
356
|
+
executionChannel: 'sms',
|
|
357
|
+
});
|
|
358
|
+
expect(second.ok).toBe(true);
|
|
359
|
+
expect(second.grant!.id).toBe(wildcardGrant.id);
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
// ===========================================================================
|
|
364
|
+
// Expiry semantics
|
|
365
|
+
// ===========================================================================
|
|
366
|
+
|
|
367
|
+
describe('scoped-approval-grants / expiry', () => {
|
|
368
|
+
beforeEach(() => clearTables());
|
|
369
|
+
|
|
370
|
+
test('expireScopedApprovalGrants transitions active past-TTL grants to expired', () => {
|
|
371
|
+
const pastExpiry = new Date(Date.now() - 1_000).toISOString();
|
|
372
|
+
createScopedApprovalGrant(
|
|
373
|
+
grantParams({ scopeMode: 'request_id', requestId: 'req-e1', expiresAt: pastExpiry }),
|
|
374
|
+
);
|
|
375
|
+
createScopedApprovalGrant(
|
|
376
|
+
grantParams({ scopeMode: 'request_id', requestId: 'req-e2', expiresAt: pastExpiry }),
|
|
377
|
+
);
|
|
378
|
+
// Still active (future expiry)
|
|
379
|
+
createScopedApprovalGrant(
|
|
380
|
+
grantParams({ scopeMode: 'request_id', requestId: 'req-alive' }),
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
const count = expireScopedApprovalGrants();
|
|
384
|
+
expect(count).toBe(2);
|
|
385
|
+
|
|
386
|
+
// Verify the alive grant is still active
|
|
387
|
+
const alive = consumeScopedApprovalGrantByRequestId('req-alive', 'c1', 'self');
|
|
388
|
+
expect(alive.ok).toBe(true);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test('already-consumed grants are not affected by expiry sweep', () => {
|
|
392
|
+
const pastExpiry = new Date(Date.now() - 1_000).toISOString();
|
|
393
|
+
createScopedApprovalGrant(
|
|
394
|
+
grantParams({ scopeMode: 'request_id', requestId: 'req-consumed', expiresAt: new Date(Date.now() + 60_000).toISOString() }),
|
|
395
|
+
);
|
|
396
|
+
consumeScopedApprovalGrantByRequestId('req-consumed', 'c1', 'self');
|
|
397
|
+
|
|
398
|
+
// Force the expiry time to the past for the consumed grant (simulating time passing)
|
|
399
|
+
// The sweep should not touch consumed grants
|
|
400
|
+
const count = expireScopedApprovalGrants();
|
|
401
|
+
expect(count).toBe(0);
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
// ===========================================================================
|
|
406
|
+
// Revoke semantics
|
|
407
|
+
// ===========================================================================
|
|
408
|
+
|
|
409
|
+
describe('scoped-approval-grants / revoke', () => {
|
|
410
|
+
beforeEach(() => clearTables());
|
|
411
|
+
|
|
412
|
+
test('revokeScopedApprovalGrantsForContext revokes active grants matching context', () => {
|
|
413
|
+
createScopedApprovalGrant(
|
|
414
|
+
grantParams({ scopeMode: 'request_id', requestId: 'req-r1', callSessionId: 'call-1' }),
|
|
415
|
+
);
|
|
416
|
+
createScopedApprovalGrant(
|
|
417
|
+
grantParams({ scopeMode: 'request_id', requestId: 'req-r2', callSessionId: 'call-1' }),
|
|
418
|
+
);
|
|
419
|
+
createScopedApprovalGrant(
|
|
420
|
+
grantParams({ scopeMode: 'request_id', requestId: 'req-r3', callSessionId: 'call-2' }),
|
|
421
|
+
);
|
|
422
|
+
|
|
423
|
+
const count = revokeScopedApprovalGrantsForContext({ callSessionId: 'call-1' });
|
|
424
|
+
expect(count).toBe(2);
|
|
425
|
+
|
|
426
|
+
// Revoked grant cannot be consumed
|
|
427
|
+
const revoked = consumeScopedApprovalGrantByRequestId('req-r1', 'c1', 'self');
|
|
428
|
+
expect(revoked.ok).toBe(false);
|
|
429
|
+
|
|
430
|
+
// Unaffected grant is still consumable
|
|
431
|
+
const alive = consumeScopedApprovalGrantByRequestId('req-r3', 'c1', 'self');
|
|
432
|
+
expect(alive.ok).toBe(true);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
test('revoked grants cannot be consumed', () => {
|
|
436
|
+
createScopedApprovalGrant(
|
|
437
|
+
grantParams({ scopeMode: 'request_id', requestId: 'req-revoke', conversationId: 'conv-1' }),
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
revokeScopedApprovalGrantsForContext({ conversationId: 'conv-1' });
|
|
441
|
+
|
|
442
|
+
const result = consumeScopedApprovalGrantByRequestId('req-revoke', 'c1', 'self');
|
|
443
|
+
expect(result.ok).toBe(false);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
test('revokeScopedApprovalGrantsForContext throws when no context filters are provided', () => {
|
|
447
|
+
// Create a grant to ensure the guard is not based on empty results
|
|
448
|
+
createScopedApprovalGrant(
|
|
449
|
+
grantParams({ scopeMode: 'request_id', requestId: 'req-guard', callSessionId: 'call-guard' }),
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
// Empty object: all fields undefined
|
|
453
|
+
expect(() => revokeScopedApprovalGrantsForContext({})).toThrow(
|
|
454
|
+
'revokeScopedApprovalGrantsForContext requires at least one context filter',
|
|
455
|
+
);
|
|
456
|
+
|
|
457
|
+
// The grant should still be active (not revoked)
|
|
458
|
+
const result = consumeScopedApprovalGrantByRequestId('req-guard', 'c1', 'self');
|
|
459
|
+
expect(result.ok).toBe(true);
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
// ===========================================================================
|
|
464
|
+
// tool-approval-digest: canonical serialization + hash
|
|
465
|
+
// ===========================================================================
|
|
466
|
+
|
|
467
|
+
describe('tool-approval-digest', () => {
|
|
468
|
+
test('canonicalJsonSerialize sorts keys recursively', () => {
|
|
469
|
+
const obj = { z: 1, a: { c: 3, b: 2 } };
|
|
470
|
+
const serialized = canonicalJsonSerialize(obj);
|
|
471
|
+
expect(serialized).toBe('{"a":{"b":2,"c":3},"z":1}');
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
test('canonicalJsonSerialize handles arrays (order preserved)', () => {
|
|
475
|
+
const obj = { items: [3, 1, 2], name: 'test' };
|
|
476
|
+
const serialized = canonicalJsonSerialize(obj);
|
|
477
|
+
expect(serialized).toBe('{"items":[3,1,2],"name":"test"}');
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
test('canonicalJsonSerialize handles null values', () => {
|
|
481
|
+
const obj = { a: null, b: 'hello' };
|
|
482
|
+
const serialized = canonicalJsonSerialize(obj);
|
|
483
|
+
expect(serialized).toBe('{"a":null,"b":"hello"}');
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
test('canonicalJsonSerialize handles nested arrays of objects', () => {
|
|
487
|
+
const obj = { list: [{ z: 1, a: 2 }, { y: 3, b: 4 }] };
|
|
488
|
+
const serialized = canonicalJsonSerialize(obj);
|
|
489
|
+
expect(serialized).toBe('{"list":[{"a":2,"z":1},{"b":4,"y":3}]}');
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
test('computeToolApprovalDigest is deterministic', () => {
|
|
493
|
+
const d1 = computeToolApprovalDigest('bash', { cmd: 'ls -la', cwd: '/tmp' });
|
|
494
|
+
const d2 = computeToolApprovalDigest('bash', { cwd: '/tmp', cmd: 'ls -la' });
|
|
495
|
+
expect(d1).toBe(d2);
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
test('computeToolApprovalDigest differs for different inputs', () => {
|
|
499
|
+
const d1 = computeToolApprovalDigest('bash', { cmd: 'ls' });
|
|
500
|
+
const d2 = computeToolApprovalDigest('bash', { cmd: 'pwd' });
|
|
501
|
+
expect(d1).not.toBe(d2);
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
test('computeToolApprovalDigest differs for different tool names', () => {
|
|
505
|
+
const d1 = computeToolApprovalDigest('bash', { cmd: 'ls' });
|
|
506
|
+
const d2 = computeToolApprovalDigest('python', { cmd: 'ls' });
|
|
507
|
+
expect(d1).not.toBe(d2);
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
test('computeToolApprovalDigest is stable across key orderings (deeply nested)', () => {
|
|
511
|
+
const d1 = computeToolApprovalDigest('tool', {
|
|
512
|
+
config: { nested: { z: 1, a: 2 }, top: true },
|
|
513
|
+
name: 'test',
|
|
514
|
+
});
|
|
515
|
+
const d2 = computeToolApprovalDigest('tool', {
|
|
516
|
+
name: 'test',
|
|
517
|
+
config: { top: true, nested: { a: 2, z: 1 } },
|
|
518
|
+
});
|
|
519
|
+
expect(d1).toBe(d2);
|
|
520
|
+
});
|
|
521
|
+
});
|