@vellumai/assistant 0.3.26 → 0.3.28
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 +48 -1
- package/Dockerfile +2 -2
- package/package.json +1 -1
- package/scripts/ipc/generate-swift.ts +6 -2
- package/src/__tests__/agent-loop.test.ts +119 -0
- package/src/__tests__/bundled-asset.test.ts +107 -0
- package/src/__tests__/canonical-guardian-store.test.ts +636 -0
- package/src/__tests__/channel-approval-routes.test.ts +174 -1
- package/src/__tests__/emit-signal-routing-intent.test.ts +43 -1
- package/src/__tests__/guardian-actions-endpoint.test.ts +205 -345
- package/src/__tests__/guardian-decision-primitive-canonical.test.ts +599 -0
- package/src/__tests__/guardian-dispatch.test.ts +19 -19
- package/src/__tests__/guardian-routing-invariants.test.ts +954 -0
- package/src/__tests__/mcp-cli.test.ts +77 -0
- package/src/__tests__/non-member-access-request.test.ts +31 -29
- package/src/__tests__/notification-decision-fallback.test.ts +61 -3
- package/src/__tests__/notification-decision-strategy.test.ts +17 -0
- package/src/__tests__/notification-guardian-path.test.ts +13 -15
- package/src/__tests__/onboarding-template-contract.test.ts +116 -21
- package/src/__tests__/secret-scanner-executor.test.ts +59 -0
- package/src/__tests__/secret-scanner.test.ts +8 -0
- package/src/__tests__/sensitive-output-placeholders.test.ts +208 -0
- package/src/__tests__/session-runtime-assembly.test.ts +76 -47
- package/src/__tests__/tool-grant-request-escalation.test.ts +497 -0
- package/src/agent/loop.ts +46 -3
- package/src/approvals/guardian-decision-primitive.ts +285 -0
- package/src/approvals/guardian-request-resolvers.ts +539 -0
- package/src/calls/guardian-dispatch.ts +46 -40
- package/src/calls/relay-server.ts +147 -2
- package/src/calls/types.ts +1 -1
- package/src/config/system-prompt.ts +2 -1
- package/src/config/templates/BOOTSTRAP.md +47 -31
- package/src/config/templates/USER.md +5 -0
- package/src/config/update-bulletin-template-path.ts +4 -1
- package/src/config/vellum-skills/trusted-contacts/SKILL.md +22 -17
- package/src/daemon/handlers/guardian-actions.ts +45 -66
- package/src/daemon/ipc-contract/guardian-actions.ts +7 -0
- package/src/daemon/lifecycle.ts +3 -16
- package/src/daemon/server.ts +18 -0
- package/src/daemon/session-agent-loop-handlers.ts +5 -4
- package/src/daemon/session-agent-loop.ts +32 -5
- package/src/daemon/session-process.ts +68 -307
- package/src/daemon/session-runtime-assembly.ts +112 -24
- package/src/daemon/session-tool-setup.ts +1 -0
- package/src/daemon/session.ts +1 -0
- package/src/home-base/prebuilt/seed.ts +2 -1
- package/src/hooks/templates.ts +2 -1
- package/src/memory/canonical-guardian-store.ts +524 -0
- package/src/memory/channel-guardian-store.ts +1 -0
- package/src/memory/db-init.ts +16 -0
- package/src/memory/guardian-action-store.ts +7 -60
- package/src/memory/guardian-approvals.ts +9 -4
- package/src/memory/migrations/036-normalize-phone-identities.ts +289 -0
- package/src/memory/migrations/118-reminder-routing-intent.ts +3 -3
- package/src/memory/migrations/121-canonical-guardian-requests.ts +59 -0
- package/src/memory/migrations/122-canonical-guardian-requester-chat-id.ts +15 -0
- package/src/memory/migrations/123-canonical-guardian-deliveries-destination-index.ts +15 -0
- package/src/memory/migrations/index.ts +4 -0
- package/src/memory/migrations/registry.ts +5 -0
- package/src/memory/schema-migration.ts +1 -0
- package/src/memory/schema.ts +52 -0
- package/src/notifications/copy-composer.ts +16 -4
- package/src/notifications/decision-engine.ts +57 -0
- package/src/permissions/defaults.ts +2 -0
- package/src/runtime/access-request-helper.ts +137 -0
- package/src/runtime/actor-trust-resolver.ts +225 -0
- package/src/runtime/channel-guardian-service.ts +12 -4
- package/src/runtime/guardian-context-resolver.ts +32 -7
- package/src/runtime/guardian-decision-types.ts +6 -0
- package/src/runtime/guardian-reply-router.ts +687 -0
- package/src/runtime/http-server.ts +8 -0
- package/src/runtime/routes/canonical-guardian-expiry-sweep.ts +116 -0
- package/src/runtime/routes/conversation-routes.ts +18 -0
- package/src/runtime/routes/guardian-action-routes.ts +100 -109
- package/src/runtime/routes/inbound-message-handler.ts +170 -525
- package/src/runtime/tool-grant-request-helper.ts +195 -0
- package/src/tools/executor.ts +13 -1
- package/src/tools/sensitive-output-placeholders.ts +203 -0
- package/src/tools/tool-approval-handler.ts +44 -1
- package/src/tools/types.ts +11 -0
- package/src/util/bundled-asset.ts +31 -0
- package/src/util/canonicalize-identity.ts +52 -0
|
@@ -0,0 +1,599 @@
|
|
|
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(), 'canonical-decision-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
|
+
applyCanonicalGuardianDecision,
|
|
34
|
+
mintCanonicalRequestGrant,
|
|
35
|
+
} from '../approvals/guardian-decision-primitive.js';
|
|
36
|
+
import type { ActorContext } from '../approvals/guardian-request-resolvers.js';
|
|
37
|
+
import { getRegisteredKinds,getResolver } from '../approvals/guardian-request-resolvers.js';
|
|
38
|
+
import {
|
|
39
|
+
createCanonicalGuardianRequest,
|
|
40
|
+
getCanonicalGuardianRequest,
|
|
41
|
+
} from '../memory/canonical-guardian-store.js';
|
|
42
|
+
import { getDb, initializeDb, resetDb } from '../memory/db.js';
|
|
43
|
+
import { scopedApprovalGrants } from '../memory/schema.js';
|
|
44
|
+
|
|
45
|
+
initializeDb();
|
|
46
|
+
|
|
47
|
+
function resetTables(): void {
|
|
48
|
+
const db = getDb();
|
|
49
|
+
db.run('DELETE FROM scoped_approval_grants');
|
|
50
|
+
db.run('DELETE FROM canonical_guardian_deliveries');
|
|
51
|
+
db.run('DELETE FROM canonical_guardian_requests');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
afterAll(() => {
|
|
55
|
+
resetDb();
|
|
56
|
+
try {
|
|
57
|
+
rmSync(testDir, { recursive: true });
|
|
58
|
+
} catch {
|
|
59
|
+
// best-effort cleanup
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Helpers
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
function guardianActor(overrides: Partial<ActorContext> = {}): ActorContext {
|
|
68
|
+
return {
|
|
69
|
+
externalUserId: 'guardian-1',
|
|
70
|
+
channel: 'telegram',
|
|
71
|
+
isTrusted: false,
|
|
72
|
+
...overrides,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function trustedActor(overrides: Partial<ActorContext> = {}): ActorContext {
|
|
77
|
+
return {
|
|
78
|
+
externalUserId: undefined,
|
|
79
|
+
channel: 'desktop',
|
|
80
|
+
isTrusted: true,
|
|
81
|
+
...overrides,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// Resolver registry tests
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
describe('guardian-request-resolvers / registry', () => {
|
|
90
|
+
test('built-in resolvers are registered', () => {
|
|
91
|
+
const kinds = getRegisteredKinds();
|
|
92
|
+
expect(kinds).toContain('tool_approval');
|
|
93
|
+
expect(kinds).toContain('pending_question');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('getResolver returns undefined for unknown kind', () => {
|
|
97
|
+
expect(getResolver('nonexistent_kind')).toBeUndefined();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('getResolver returns resolver for known kind', () => {
|
|
101
|
+
const resolver = getResolver('tool_approval');
|
|
102
|
+
expect(resolver).toBeDefined();
|
|
103
|
+
expect(resolver!.kind).toBe('tool_approval');
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// applyCanonicalGuardianDecision tests
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
describe('applyCanonicalGuardianDecision', () => {
|
|
112
|
+
beforeEach(() => resetTables());
|
|
113
|
+
|
|
114
|
+
// ── Successful approval ─────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
test('approves a pending tool_approval request', async () => {
|
|
117
|
+
const req = createCanonicalGuardianRequest({
|
|
118
|
+
kind: 'tool_approval',
|
|
119
|
+
sourceType: 'channel',
|
|
120
|
+
sourceChannel: 'telegram',
|
|
121
|
+
conversationId: 'conv-1',
|
|
122
|
+
guardianExternalUserId: 'guardian-1',
|
|
123
|
+
toolName: 'shell',
|
|
124
|
+
inputDigest: 'sha256:abc',
|
|
125
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const result = await applyCanonicalGuardianDecision({
|
|
129
|
+
requestId: req.id,
|
|
130
|
+
action: 'approve_once',
|
|
131
|
+
actorContext: guardianActor(),
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
expect(result.applied).toBe(true);
|
|
135
|
+
if (!result.applied) return;
|
|
136
|
+
expect(result.requestId).toBe(req.id);
|
|
137
|
+
// Grant is not minted because the tool_approval resolver fails (no pending
|
|
138
|
+
// interaction registered in the test environment). The decision primitive
|
|
139
|
+
// correctly skips grant minting when the resolver reports a failure.
|
|
140
|
+
expect(result.grantMinted).toBe(false);
|
|
141
|
+
expect(result.resolverFailed).toBe(true);
|
|
142
|
+
|
|
143
|
+
// Verify canonical request state
|
|
144
|
+
const resolved = getCanonicalGuardianRequest(req.id);
|
|
145
|
+
expect(resolved!.status).toBe('approved');
|
|
146
|
+
expect(resolved!.decidedByExternalUserId).toBe('guardian-1');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('denies a pending tool_approval request', async () => {
|
|
150
|
+
const req = createCanonicalGuardianRequest({
|
|
151
|
+
kind: 'tool_approval',
|
|
152
|
+
sourceType: 'channel',
|
|
153
|
+
sourceChannel: 'telegram',
|
|
154
|
+
conversationId: 'conv-1',
|
|
155
|
+
guardianExternalUserId: 'guardian-1',
|
|
156
|
+
toolName: 'shell',
|
|
157
|
+
inputDigest: 'sha256:abc',
|
|
158
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const result = await applyCanonicalGuardianDecision({
|
|
162
|
+
requestId: req.id,
|
|
163
|
+
action: 'reject',
|
|
164
|
+
actorContext: guardianActor(),
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
expect(result.applied).toBe(true);
|
|
168
|
+
if (!result.applied) return;
|
|
169
|
+
expect(result.grantMinted).toBe(false);
|
|
170
|
+
|
|
171
|
+
const resolved = getCanonicalGuardianRequest(req.id);
|
|
172
|
+
expect(resolved!.status).toBe('denied');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('approves a pending_question request with answer text', async () => {
|
|
176
|
+
const req = createCanonicalGuardianRequest({
|
|
177
|
+
kind: 'pending_question',
|
|
178
|
+
sourceType: 'voice',
|
|
179
|
+
sourceChannel: 'twilio',
|
|
180
|
+
guardianExternalUserId: 'guardian-1',
|
|
181
|
+
callSessionId: 'call-1',
|
|
182
|
+
pendingQuestionId: 'pq-1',
|
|
183
|
+
questionText: 'What is the gate code?',
|
|
184
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const result = await applyCanonicalGuardianDecision({
|
|
188
|
+
requestId: req.id,
|
|
189
|
+
action: 'approve_once',
|
|
190
|
+
actorContext: guardianActor(),
|
|
191
|
+
userText: '1234',
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
expect(result.applied).toBe(true);
|
|
195
|
+
if (!result.applied) return;
|
|
196
|
+
|
|
197
|
+
const resolved = getCanonicalGuardianRequest(req.id);
|
|
198
|
+
expect(resolved!.status).toBe('approved');
|
|
199
|
+
expect(resolved!.answerText).toBe('1234');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// ── Identity mismatch ──────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
test('rejects decision when actor does not match guardian', async () => {
|
|
205
|
+
const req = createCanonicalGuardianRequest({
|
|
206
|
+
kind: 'tool_approval',
|
|
207
|
+
sourceType: 'channel',
|
|
208
|
+
conversationId: 'conv-1',
|
|
209
|
+
guardianExternalUserId: 'guardian-1',
|
|
210
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const result = await applyCanonicalGuardianDecision({
|
|
214
|
+
requestId: req.id,
|
|
215
|
+
action: 'approve_once',
|
|
216
|
+
actorContext: guardianActor({ externalUserId: 'imposter-99' }),
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
expect(result.applied).toBe(false);
|
|
220
|
+
if (result.applied) return;
|
|
221
|
+
expect(result.reason).toBe('identity_mismatch');
|
|
222
|
+
|
|
223
|
+
// Request remains pending
|
|
224
|
+
const unchanged = getCanonicalGuardianRequest(req.id);
|
|
225
|
+
expect(unchanged!.status).toBe('pending');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('trusted actor bypasses identity check', async () => {
|
|
229
|
+
const req = createCanonicalGuardianRequest({
|
|
230
|
+
kind: 'tool_approval',
|
|
231
|
+
sourceType: 'desktop',
|
|
232
|
+
conversationId: 'conv-1',
|
|
233
|
+
guardianExternalUserId: 'guardian-1',
|
|
234
|
+
toolName: 'shell',
|
|
235
|
+
inputDigest: 'sha256:abc',
|
|
236
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const result = await applyCanonicalGuardianDecision({
|
|
240
|
+
requestId: req.id,
|
|
241
|
+
action: 'approve_once',
|
|
242
|
+
actorContext: trustedActor(),
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
expect(result.applied).toBe(true);
|
|
246
|
+
// No grant minted because trusted actor has no externalUserId
|
|
247
|
+
if (!result.applied) return;
|
|
248
|
+
expect(result.grantMinted).toBe(false);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test('allows decision when request has no guardian binding', async () => {
|
|
252
|
+
const req = createCanonicalGuardianRequest({
|
|
253
|
+
kind: 'tool_approval',
|
|
254
|
+
sourceType: 'channel',
|
|
255
|
+
conversationId: 'conv-1',
|
|
256
|
+
// No guardianExternalUserId — open request
|
|
257
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
const result = await applyCanonicalGuardianDecision({
|
|
261
|
+
requestId: req.id,
|
|
262
|
+
action: 'approve_once',
|
|
263
|
+
actorContext: guardianActor({ externalUserId: 'anyone' }),
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
expect(result.applied).toBe(true);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// ── Stale / already-resolved (race condition) ──────────────────────
|
|
270
|
+
|
|
271
|
+
test('second concurrent decision fails (first-writer-wins)', async () => {
|
|
272
|
+
const req = createCanonicalGuardianRequest({
|
|
273
|
+
kind: 'tool_approval',
|
|
274
|
+
sourceType: 'channel',
|
|
275
|
+
conversationId: 'conv-1',
|
|
276
|
+
guardianExternalUserId: 'guardian-1',
|
|
277
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
// First decision succeeds
|
|
281
|
+
const first = await applyCanonicalGuardianDecision({
|
|
282
|
+
requestId: req.id,
|
|
283
|
+
action: 'approve_once',
|
|
284
|
+
actorContext: guardianActor(),
|
|
285
|
+
});
|
|
286
|
+
expect(first.applied).toBe(true);
|
|
287
|
+
|
|
288
|
+
// Second decision fails — request is no longer pending
|
|
289
|
+
const second = await applyCanonicalGuardianDecision({
|
|
290
|
+
requestId: req.id,
|
|
291
|
+
action: 'reject',
|
|
292
|
+
actorContext: guardianActor(),
|
|
293
|
+
});
|
|
294
|
+
expect(second.applied).toBe(false);
|
|
295
|
+
if (second.applied) return;
|
|
296
|
+
expect(second.reason).toBe('already_resolved');
|
|
297
|
+
|
|
298
|
+
// First decision stuck
|
|
299
|
+
const final = getCanonicalGuardianRequest(req.id);
|
|
300
|
+
expect(final!.status).toBe('approved');
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// ── Not found ──────────────────────────────────────────────────────
|
|
304
|
+
|
|
305
|
+
test('returns not_found for nonexistent request', async () => {
|
|
306
|
+
const result = await applyCanonicalGuardianDecision({
|
|
307
|
+
requestId: 'nonexistent-id',
|
|
308
|
+
action: 'approve_once',
|
|
309
|
+
actorContext: guardianActor(),
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
expect(result.applied).toBe(false);
|
|
313
|
+
if (result.applied) return;
|
|
314
|
+
expect(result.reason).toBe('not_found');
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// ── Invalid action ─────────────────────────────────────────────────
|
|
318
|
+
|
|
319
|
+
test('rejects invalid action', async () => {
|
|
320
|
+
const req = createCanonicalGuardianRequest({
|
|
321
|
+
kind: 'tool_approval',
|
|
322
|
+
sourceType: 'channel',
|
|
323
|
+
conversationId: 'conv-1',
|
|
324
|
+
guardianExternalUserId: 'guardian-1',
|
|
325
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
const result = await applyCanonicalGuardianDecision({
|
|
329
|
+
requestId: req.id,
|
|
330
|
+
action: 'bogus_action' as any,
|
|
331
|
+
actorContext: guardianActor(),
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
expect(result.applied).toBe(false);
|
|
335
|
+
if (result.applied) return;
|
|
336
|
+
expect(result.reason).toBe('invalid_action');
|
|
337
|
+
|
|
338
|
+
// Request remains pending
|
|
339
|
+
const unchanged = getCanonicalGuardianRequest(req.id);
|
|
340
|
+
expect(unchanged!.status).toBe('pending');
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
// ── approve_always downgrade ───────────────────────────────────────
|
|
344
|
+
|
|
345
|
+
test('downgrades approve_always to approve_once', async () => {
|
|
346
|
+
const req = createCanonicalGuardianRequest({
|
|
347
|
+
kind: 'tool_approval',
|
|
348
|
+
sourceType: 'channel',
|
|
349
|
+
sourceChannel: 'telegram',
|
|
350
|
+
conversationId: 'conv-1',
|
|
351
|
+
guardianExternalUserId: 'guardian-1',
|
|
352
|
+
toolName: 'shell',
|
|
353
|
+
inputDigest: 'sha256:abc',
|
|
354
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
const result = await applyCanonicalGuardianDecision({
|
|
358
|
+
requestId: req.id,
|
|
359
|
+
action: 'approve_always',
|
|
360
|
+
actorContext: guardianActor(),
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
// Should succeed — approve_always was silently downgraded to approve_once
|
|
364
|
+
expect(result.applied).toBe(true);
|
|
365
|
+
if (!result.applied) return;
|
|
366
|
+
|
|
367
|
+
// The canonical request should be approved (not "always approved")
|
|
368
|
+
const resolved = getCanonicalGuardianRequest(req.id);
|
|
369
|
+
expect(resolved!.status).toBe('approved');
|
|
370
|
+
|
|
371
|
+
// Grant is not minted because the tool_approval resolver fails (no pending
|
|
372
|
+
// interaction registered in the test environment). The decision primitive
|
|
373
|
+
// correctly skips grant minting when the resolver reports a failure.
|
|
374
|
+
expect(result.grantMinted).toBe(false);
|
|
375
|
+
expect(result.resolverFailed).toBe(true);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
// ── Expired request ────────────────────────────────────────────────
|
|
379
|
+
|
|
380
|
+
test('rejects decision on expired request', async () => {
|
|
381
|
+
const req = createCanonicalGuardianRequest({
|
|
382
|
+
kind: 'tool_approval',
|
|
383
|
+
sourceType: 'channel',
|
|
384
|
+
conversationId: 'conv-1',
|
|
385
|
+
guardianExternalUserId: 'guardian-1',
|
|
386
|
+
expiresAt: new Date(Date.now() - 10_000).toISOString(), // already expired
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
const result = await applyCanonicalGuardianDecision({
|
|
390
|
+
requestId: req.id,
|
|
391
|
+
action: 'approve_once',
|
|
392
|
+
actorContext: guardianActor(),
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
expect(result.applied).toBe(false);
|
|
396
|
+
if (result.applied) return;
|
|
397
|
+
expect(result.reason).toBe('expired');
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test('allows decision on request with no expiresAt', async () => {
|
|
401
|
+
const req = createCanonicalGuardianRequest({
|
|
402
|
+
kind: 'tool_approval',
|
|
403
|
+
sourceType: 'channel',
|
|
404
|
+
conversationId: 'conv-1',
|
|
405
|
+
guardianExternalUserId: 'guardian-1',
|
|
406
|
+
// No expiresAt
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
const result = await applyCanonicalGuardianDecision({
|
|
410
|
+
requestId: req.id,
|
|
411
|
+
action: 'approve_once',
|
|
412
|
+
actorContext: guardianActor(),
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
expect(result.applied).toBe(true);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
// ── Resolver dispatch ──────────────────────────────────────────────
|
|
419
|
+
|
|
420
|
+
test('dispatches to tool_approval resolver', async () => {
|
|
421
|
+
const req = createCanonicalGuardianRequest({
|
|
422
|
+
kind: 'tool_approval',
|
|
423
|
+
sourceType: 'channel',
|
|
424
|
+
sourceChannel: 'telegram',
|
|
425
|
+
conversationId: 'conv-1',
|
|
426
|
+
guardianExternalUserId: 'guardian-1',
|
|
427
|
+
toolName: 'file_read',
|
|
428
|
+
inputDigest: 'sha256:def',
|
|
429
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
const result = await applyCanonicalGuardianDecision({
|
|
433
|
+
requestId: req.id,
|
|
434
|
+
action: 'approve_once',
|
|
435
|
+
actorContext: guardianActor(),
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
expect(result.applied).toBe(true);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
test('dispatches to pending_question resolver', async () => {
|
|
442
|
+
const req = createCanonicalGuardianRequest({
|
|
443
|
+
kind: 'pending_question',
|
|
444
|
+
sourceType: 'voice',
|
|
445
|
+
sourceChannel: 'twilio',
|
|
446
|
+
guardianExternalUserId: 'guardian-1',
|
|
447
|
+
callSessionId: 'call-99',
|
|
448
|
+
pendingQuestionId: 'pq-99',
|
|
449
|
+
questionText: 'What is the password?',
|
|
450
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
const result = await applyCanonicalGuardianDecision({
|
|
454
|
+
requestId: req.id,
|
|
455
|
+
action: 'approve_once',
|
|
456
|
+
actorContext: guardianActor(),
|
|
457
|
+
userText: 'secret123',
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
expect(result.applied).toBe(true);
|
|
461
|
+
const resolved = getCanonicalGuardianRequest(req.id);
|
|
462
|
+
expect(resolved!.answerText).toBe('secret123');
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
test('succeeds even with no resolver for unknown kind', async () => {
|
|
466
|
+
const req = createCanonicalGuardianRequest({
|
|
467
|
+
kind: 'unknown_kind',
|
|
468
|
+
sourceType: 'channel',
|
|
469
|
+
conversationId: 'conv-1',
|
|
470
|
+
guardianExternalUserId: 'guardian-1',
|
|
471
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
// Should still succeed — CAS resolution happens regardless of resolver
|
|
475
|
+
const result = await applyCanonicalGuardianDecision({
|
|
476
|
+
requestId: req.id,
|
|
477
|
+
action: 'approve_once',
|
|
478
|
+
actorContext: guardianActor(),
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
expect(result.applied).toBe(true);
|
|
482
|
+
const resolved = getCanonicalGuardianRequest(req.id);
|
|
483
|
+
expect(resolved!.status).toBe('approved');
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
test('trusted desktop actor still mints scoped grant for approved canonical request', async () => {
|
|
487
|
+
const req = createCanonicalGuardianRequest({
|
|
488
|
+
kind: 'unknown_kind',
|
|
489
|
+
sourceType: 'voice',
|
|
490
|
+
sourceChannel: 'voice',
|
|
491
|
+
conversationId: 'conv-voice-1',
|
|
492
|
+
callSessionId: 'call-voice-1',
|
|
493
|
+
toolName: 'host_bash',
|
|
494
|
+
inputDigest: 'sha256:voice-digest-1',
|
|
495
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
const result = await applyCanonicalGuardianDecision({
|
|
499
|
+
requestId: req.id,
|
|
500
|
+
action: 'approve_once',
|
|
501
|
+
actorContext: trustedActor(),
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
expect(result.applied).toBe(true);
|
|
505
|
+
if (!result.applied) return;
|
|
506
|
+
expect(result.grantMinted).toBe(true);
|
|
507
|
+
|
|
508
|
+
const db = getDb();
|
|
509
|
+
const grants = db.select().from(scopedApprovalGrants).all();
|
|
510
|
+
expect(grants.length).toBe(1);
|
|
511
|
+
expect(grants[0].toolName).toBe('host_bash');
|
|
512
|
+
expect(grants[0].conversationId).toBe('conv-voice-1');
|
|
513
|
+
expect(grants[0].callSessionId).toBe('call-voice-1');
|
|
514
|
+
expect(grants[0].guardianExternalUserId).toBeNull();
|
|
515
|
+
});
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
// ---------------------------------------------------------------------------
|
|
519
|
+
// mintCanonicalRequestGrant tests
|
|
520
|
+
// ---------------------------------------------------------------------------
|
|
521
|
+
|
|
522
|
+
describe('mintCanonicalRequestGrant', () => {
|
|
523
|
+
beforeEach(() => resetTables());
|
|
524
|
+
|
|
525
|
+
test('mints grant for request with tool metadata', () => {
|
|
526
|
+
const req = createCanonicalGuardianRequest({
|
|
527
|
+
kind: 'tool_approval',
|
|
528
|
+
sourceType: 'channel',
|
|
529
|
+
sourceChannel: 'telegram',
|
|
530
|
+
conversationId: 'conv-1',
|
|
531
|
+
toolName: 'shell',
|
|
532
|
+
inputDigest: 'sha256:abc',
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
const result = mintCanonicalRequestGrant({
|
|
536
|
+
request: req,
|
|
537
|
+
actorChannel: 'telegram',
|
|
538
|
+
guardianExternalUserId: 'guardian-1',
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
expect(result.minted).toBe(true);
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
test('mints grant when guardianExternalUserId is omitted', () => {
|
|
545
|
+
const req = createCanonicalGuardianRequest({
|
|
546
|
+
kind: 'tool_approval',
|
|
547
|
+
sourceType: 'channel',
|
|
548
|
+
sourceChannel: 'telegram',
|
|
549
|
+
conversationId: 'conv-2',
|
|
550
|
+
toolName: 'shell',
|
|
551
|
+
inputDigest: 'sha256:xyz',
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
const result = mintCanonicalRequestGrant({
|
|
555
|
+
request: req,
|
|
556
|
+
actorChannel: 'vellum',
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
expect(result.minted).toBe(true);
|
|
560
|
+
|
|
561
|
+
const db = getDb();
|
|
562
|
+
const grants = db.select().from(scopedApprovalGrants).all();
|
|
563
|
+
expect(grants.length).toBe(1);
|
|
564
|
+
expect(grants[0].guardianExternalUserId).toBeNull();
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
test('skips grant for request without tool metadata', () => {
|
|
568
|
+
const req = createCanonicalGuardianRequest({
|
|
569
|
+
kind: 'pending_question',
|
|
570
|
+
sourceType: 'voice',
|
|
571
|
+
// No toolName or inputDigest
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
const result = mintCanonicalRequestGrant({
|
|
575
|
+
request: req,
|
|
576
|
+
actorChannel: 'telegram',
|
|
577
|
+
guardianExternalUserId: 'guardian-1',
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
expect(result.minted).toBe(false);
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
test('skips grant when toolName present but inputDigest missing', () => {
|
|
584
|
+
const req = createCanonicalGuardianRequest({
|
|
585
|
+
kind: 'tool_approval',
|
|
586
|
+
sourceType: 'channel',
|
|
587
|
+
toolName: 'shell',
|
|
588
|
+
// No inputDigest
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
const result = mintCanonicalRequestGrant({
|
|
592
|
+
request: req,
|
|
593
|
+
actorChannel: 'telegram',
|
|
594
|
+
guardianExternalUserId: 'guardian-1',
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
expect(result.minted).toBe(false);
|
|
598
|
+
});
|
|
599
|
+
});
|