@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.
Files changed (82) hide show
  1. package/ARCHITECTURE.md +48 -1
  2. package/Dockerfile +2 -2
  3. package/package.json +1 -1
  4. package/scripts/ipc/generate-swift.ts +6 -2
  5. package/src/__tests__/agent-loop.test.ts +119 -0
  6. package/src/__tests__/bundled-asset.test.ts +107 -0
  7. package/src/__tests__/canonical-guardian-store.test.ts +636 -0
  8. package/src/__tests__/channel-approval-routes.test.ts +174 -1
  9. package/src/__tests__/emit-signal-routing-intent.test.ts +43 -1
  10. package/src/__tests__/guardian-actions-endpoint.test.ts +205 -345
  11. package/src/__tests__/guardian-decision-primitive-canonical.test.ts +599 -0
  12. package/src/__tests__/guardian-dispatch.test.ts +19 -19
  13. package/src/__tests__/guardian-routing-invariants.test.ts +954 -0
  14. package/src/__tests__/mcp-cli.test.ts +77 -0
  15. package/src/__tests__/non-member-access-request.test.ts +31 -29
  16. package/src/__tests__/notification-decision-fallback.test.ts +61 -3
  17. package/src/__tests__/notification-decision-strategy.test.ts +17 -0
  18. package/src/__tests__/notification-guardian-path.test.ts +13 -15
  19. package/src/__tests__/onboarding-template-contract.test.ts +116 -21
  20. package/src/__tests__/secret-scanner-executor.test.ts +59 -0
  21. package/src/__tests__/secret-scanner.test.ts +8 -0
  22. package/src/__tests__/sensitive-output-placeholders.test.ts +208 -0
  23. package/src/__tests__/session-runtime-assembly.test.ts +76 -47
  24. package/src/__tests__/tool-grant-request-escalation.test.ts +497 -0
  25. package/src/agent/loop.ts +46 -3
  26. package/src/approvals/guardian-decision-primitive.ts +285 -0
  27. package/src/approvals/guardian-request-resolvers.ts +539 -0
  28. package/src/calls/guardian-dispatch.ts +46 -40
  29. package/src/calls/relay-server.ts +147 -2
  30. package/src/calls/types.ts +1 -1
  31. package/src/config/system-prompt.ts +2 -1
  32. package/src/config/templates/BOOTSTRAP.md +47 -31
  33. package/src/config/templates/USER.md +5 -0
  34. package/src/config/update-bulletin-template-path.ts +4 -1
  35. package/src/config/vellum-skills/trusted-contacts/SKILL.md +22 -17
  36. package/src/daemon/handlers/guardian-actions.ts +45 -66
  37. package/src/daemon/ipc-contract/guardian-actions.ts +7 -0
  38. package/src/daemon/lifecycle.ts +3 -16
  39. package/src/daemon/server.ts +18 -0
  40. package/src/daemon/session-agent-loop-handlers.ts +5 -4
  41. package/src/daemon/session-agent-loop.ts +32 -5
  42. package/src/daemon/session-process.ts +68 -307
  43. package/src/daemon/session-runtime-assembly.ts +112 -24
  44. package/src/daemon/session-tool-setup.ts +1 -0
  45. package/src/daemon/session.ts +1 -0
  46. package/src/home-base/prebuilt/seed.ts +2 -1
  47. package/src/hooks/templates.ts +2 -1
  48. package/src/memory/canonical-guardian-store.ts +524 -0
  49. package/src/memory/channel-guardian-store.ts +1 -0
  50. package/src/memory/db-init.ts +16 -0
  51. package/src/memory/guardian-action-store.ts +7 -60
  52. package/src/memory/guardian-approvals.ts +9 -4
  53. package/src/memory/migrations/036-normalize-phone-identities.ts +289 -0
  54. package/src/memory/migrations/118-reminder-routing-intent.ts +3 -3
  55. package/src/memory/migrations/121-canonical-guardian-requests.ts +59 -0
  56. package/src/memory/migrations/122-canonical-guardian-requester-chat-id.ts +15 -0
  57. package/src/memory/migrations/123-canonical-guardian-deliveries-destination-index.ts +15 -0
  58. package/src/memory/migrations/index.ts +4 -0
  59. package/src/memory/migrations/registry.ts +5 -0
  60. package/src/memory/schema-migration.ts +1 -0
  61. package/src/memory/schema.ts +52 -0
  62. package/src/notifications/copy-composer.ts +16 -4
  63. package/src/notifications/decision-engine.ts +57 -0
  64. package/src/permissions/defaults.ts +2 -0
  65. package/src/runtime/access-request-helper.ts +137 -0
  66. package/src/runtime/actor-trust-resolver.ts +225 -0
  67. package/src/runtime/channel-guardian-service.ts +12 -4
  68. package/src/runtime/guardian-context-resolver.ts +32 -7
  69. package/src/runtime/guardian-decision-types.ts +6 -0
  70. package/src/runtime/guardian-reply-router.ts +687 -0
  71. package/src/runtime/http-server.ts +8 -0
  72. package/src/runtime/routes/canonical-guardian-expiry-sweep.ts +116 -0
  73. package/src/runtime/routes/conversation-routes.ts +18 -0
  74. package/src/runtime/routes/guardian-action-routes.ts +100 -109
  75. package/src/runtime/routes/inbound-message-handler.ts +170 -525
  76. package/src/runtime/tool-grant-request-helper.ts +195 -0
  77. package/src/tools/executor.ts +13 -1
  78. package/src/tools/sensitive-output-placeholders.ts +203 -0
  79. package/src/tools/tool-approval-handler.ts +44 -1
  80. package/src/tools/types.ts +11 -0
  81. package/src/util/bundled-asset.ts +31 -0
  82. package/src/util/canonicalize-identity.ts +52 -0
@@ -0,0 +1,954 @@
1
+ /**
2
+ * Guard tests for canonical guardian request routing invariants.
3
+ *
4
+ * These tests verify that the canonical guardian request system maintains
5
+ * its key architectural invariants:
6
+ *
7
+ * 1. All decision paths route through `applyCanonicalGuardianDecision`
8
+ * 2. Identity checks are enforced before decisions are applied
9
+ * 3. Stale/expired/already-resolved decisions are rejected
10
+ * 4. Code-only messages return clarification (not auto-approve)
11
+ * 5. Disambiguation with multiple pending requests stays fail-closed
12
+ *
13
+ * The tests combine import-verification (ensuring callers reference the
14
+ * canonical primitive) and unit tests of the router and primitive functions.
15
+ */
16
+
17
+ import { readFileSync } from 'node:fs';
18
+ import { mkdtempSync, rmSync } from 'node:fs';
19
+ import { tmpdir } from 'node:os';
20
+ import { join, resolve } from 'node:path';
21
+
22
+ import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test';
23
+
24
+ const testDir = mkdtempSync(join(tmpdir(), 'guardian-routing-invariants-test-'));
25
+
26
+ mock.module('../util/platform.js', () => ({
27
+ getDataDir: () => testDir,
28
+ isMacOS: () => process.platform === 'darwin',
29
+ isLinux: () => process.platform === 'linux',
30
+ isWindows: () => process.platform === 'win32',
31
+ getSocketPath: () => join(testDir, 'test.sock'),
32
+ getPidPath: () => join(testDir, 'test.pid'),
33
+ getDbPath: () => join(testDir, 'test.db'),
34
+ getLogPath: () => join(testDir, 'test.log'),
35
+ ensureDataDir: () => {},
36
+ migrateToDataLayout: () => {},
37
+ migrateToWorkspaceLayout: () => {},
38
+ }));
39
+
40
+ mock.module('../util/logger.js', () => ({
41
+ getLogger: () =>
42
+ new Proxy({} as Record<string, unknown>, {
43
+ get: () => () => {},
44
+ }),
45
+ isDebug: () => false,
46
+ truncateForLog: (value: string) => value,
47
+ }));
48
+
49
+ import {
50
+ applyCanonicalGuardianDecision,
51
+ } from '../approvals/guardian-decision-primitive.js';
52
+ import type { ActorContext } from '../approvals/guardian-request-resolvers.js';
53
+ import {
54
+ getRegisteredKinds,
55
+ getResolver,
56
+ } from '../approvals/guardian-request-resolvers.js';
57
+ import {
58
+ createCanonicalGuardianRequest,
59
+ getCanonicalGuardianRequest,
60
+ } from '../memory/canonical-guardian-store.js';
61
+ import { getDb, initializeDb, resetDb } from '../memory/db.js';
62
+ import {
63
+ type GuardianReplyContext,
64
+ routeGuardianReply,
65
+ } from '../runtime/guardian-reply-router.js';
66
+ import * as pendingInteractions from '../runtime/pending-interactions.js';
67
+
68
+ initializeDb();
69
+
70
+ function resetTables(): void {
71
+ const db = getDb();
72
+ db.run('DELETE FROM scoped_approval_grants');
73
+ db.run('DELETE FROM canonical_guardian_deliveries');
74
+ db.run('DELETE FROM canonical_guardian_requests');
75
+ pendingInteractions.clear();
76
+ }
77
+
78
+ afterAll(() => {
79
+ resetDb();
80
+ try {
81
+ rmSync(testDir, { recursive: true });
82
+ } catch {
83
+ // best-effort cleanup
84
+ }
85
+ });
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // Helpers
89
+ // ---------------------------------------------------------------------------
90
+
91
+ function guardianActor(overrides: Partial<ActorContext> = {}): ActorContext {
92
+ return {
93
+ externalUserId: 'guardian-1',
94
+ channel: 'telegram',
95
+ isTrusted: false,
96
+ ...overrides,
97
+ };
98
+ }
99
+
100
+ function trustedActor(overrides: Partial<ActorContext> = {}): ActorContext {
101
+ return {
102
+ externalUserId: undefined,
103
+ channel: 'desktop',
104
+ isTrusted: true,
105
+ ...overrides,
106
+ };
107
+ }
108
+
109
+ function replyCtx(overrides: Partial<GuardianReplyContext> = {}): GuardianReplyContext {
110
+ return {
111
+ messageText: '',
112
+ channel: 'telegram',
113
+ actor: guardianActor(),
114
+ conversationId: 'conv-test',
115
+ ...overrides,
116
+ };
117
+ }
118
+
119
+ function registerPendingToolApprovalInteraction(
120
+ requestId: string,
121
+ conversationId: string,
122
+ toolName: string = 'shell',
123
+ ): ReturnType<typeof mock> {
124
+ const handleConfirmationResponse = mock(() => {});
125
+ const mockSession = {
126
+ handleConfirmationResponse,
127
+ } as unknown as import('../daemon/session.js').Session;
128
+
129
+ pendingInteractions.register(requestId, {
130
+ session: mockSession,
131
+ conversationId,
132
+ kind: 'confirmation',
133
+ confirmationDetails: {
134
+ toolName,
135
+ input: { command: 'echo hello' },
136
+ riskLevel: 'medium',
137
+ allowlistOptions: [
138
+ {
139
+ label: 'echo hello',
140
+ description: 'echo hello',
141
+ pattern: 'echo hello',
142
+ },
143
+ ],
144
+ scopeOptions: [
145
+ {
146
+ label: 'everywhere',
147
+ scope: 'everywhere',
148
+ },
149
+ ],
150
+ },
151
+ });
152
+
153
+ return handleConfirmationResponse;
154
+ }
155
+
156
+ // ===========================================================================
157
+ // SECTION 1: Import-verification guard tests
158
+ //
159
+ // These verify that all known decision entrypoints import from and call
160
+ // `applyCanonicalGuardianDecision` rather than inlining decision logic.
161
+ // ===========================================================================
162
+
163
+ describe('routing invariant: all decision paths reference applyCanonicalGuardianDecision', () => {
164
+ const srcRoot = resolve(__dirname, '..');
165
+
166
+ // The files that constitute decision entrypoints. Each must import
167
+ // `applyCanonicalGuardianDecision` from the guardian-decision-primitive.
168
+ const DECISION_ENTRYPOINTS = [
169
+ // Inbound channel router (Telegram/SMS/WhatsApp)
170
+ 'runtime/guardian-reply-router.ts',
171
+ // HTTP API route handler (desktop and API clients)
172
+ 'runtime/routes/guardian-action-routes.ts',
173
+ // IPC handler (desktop socket clients)
174
+ 'daemon/handlers/guardian-actions.ts',
175
+ ];
176
+
177
+ for (const relPath of DECISION_ENTRYPOINTS) {
178
+ test(`${relPath} imports applyCanonicalGuardianDecision`, () => {
179
+ const fullPath = join(srcRoot, relPath);
180
+ const source = readFileSync(fullPath, 'utf-8');
181
+ expect(source).toContain('applyCanonicalGuardianDecision');
182
+ });
183
+ }
184
+
185
+ // The inbound message handler and session-process both use routeGuardianReply
186
+ // which itself calls applyCanonicalGuardianDecision. Verify they reference
187
+ // the shared router rather than inlining decision logic.
188
+ const ROUTER_CONSUMERS = [
189
+ 'runtime/routes/inbound-message-handler.ts',
190
+ 'daemon/session-process.ts',
191
+ ];
192
+
193
+ for (const relPath of ROUTER_CONSUMERS) {
194
+ test(`${relPath} uses routeGuardianReply (shared router)`, () => {
195
+ const fullPath = join(srcRoot, relPath);
196
+ const source = readFileSync(fullPath, 'utf-8');
197
+ expect(source).toContain('routeGuardianReply');
198
+ });
199
+ }
200
+
201
+ test('daemon/session-process.ts no longer references legacy guardian-action interception', () => {
202
+ const fullPath = join(srcRoot, 'daemon/session-process.ts');
203
+ const source = readFileSync(fullPath, 'utf-8');
204
+ expect(source).not.toContain("../memory/guardian-action-store.js");
205
+ expect(source).not.toContain('getPendingDeliveriesByConversation');
206
+ });
207
+
208
+ test('daemon/session-process.ts seeds router hints from delivery and conversation scopes', () => {
209
+ const fullPath = join(srcRoot, 'daemon/session-process.ts');
210
+ const source = readFileSync(fullPath, 'utf-8');
211
+ expect(source).toContain('listPendingCanonicalGuardianRequestsByDestinationConversation');
212
+ expect(source).toContain('listCanonicalGuardianRequests');
213
+ });
214
+
215
+ test('guardian-reply-router routes all decisions through applyCanonicalGuardianDecision', () => {
216
+ const fullPath = join(srcRoot, 'runtime/guardian-reply-router.ts');
217
+ const source = readFileSync(fullPath, 'utf-8');
218
+ // The router must import and call the canonical primitive, not applyGuardianDecision
219
+ expect(source).toContain('applyCanonicalGuardianDecision');
220
+ // The router must NOT directly call the legacy applyGuardianDecision
221
+ expect(source).not.toContain('applyGuardianDecision(');
222
+ });
223
+ });
224
+
225
+ // ===========================================================================
226
+ // SECTION 2: Identity enforcement invariants
227
+ // ===========================================================================
228
+
229
+ describe('routing invariant: identity checks enforced before decisions', () => {
230
+ beforeEach(() => resetTables());
231
+
232
+ test('non-matching actor identity is rejected by canonical primitive', async () => {
233
+ const req = createCanonicalGuardianRequest({
234
+ kind: 'tool_approval',
235
+ sourceType: 'channel',
236
+ conversationId: 'conv-1',
237
+ guardianExternalUserId: 'guardian-1',
238
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
239
+ });
240
+
241
+ const result = await applyCanonicalGuardianDecision({
242
+ requestId: req.id,
243
+ action: 'approve_once',
244
+ actorContext: guardianActor({ externalUserId: 'imposter-99' }),
245
+ });
246
+
247
+ expect(result.applied).toBe(false);
248
+ if (result.applied) return;
249
+ expect(result.reason).toBe('identity_mismatch');
250
+
251
+ // Request must remain pending (no state change)
252
+ const unchanged = getCanonicalGuardianRequest(req.id);
253
+ expect(unchanged!.status).toBe('pending');
254
+ });
255
+
256
+ test('trusted (desktop) actor bypasses identity check', async () => {
257
+ const req = createCanonicalGuardianRequest({
258
+ kind: 'tool_approval',
259
+ sourceType: 'desktop',
260
+ conversationId: 'conv-1',
261
+ guardianExternalUserId: 'guardian-1',
262
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
263
+ });
264
+
265
+ const result = await applyCanonicalGuardianDecision({
266
+ requestId: req.id,
267
+ action: 'approve_once',
268
+ actorContext: trustedActor(),
269
+ });
270
+
271
+ expect(result.applied).toBe(true);
272
+ });
273
+
274
+ test('request with no guardian binding accepts any actor', async () => {
275
+ const req = createCanonicalGuardianRequest({
276
+ kind: 'tool_approval',
277
+ sourceType: 'channel',
278
+ conversationId: 'conv-1',
279
+ // No guardianExternalUserId — open request
280
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
281
+ });
282
+
283
+ const result = await applyCanonicalGuardianDecision({
284
+ requestId: req.id,
285
+ action: 'approve_once',
286
+ actorContext: guardianActor({ externalUserId: 'anyone' }),
287
+ });
288
+
289
+ expect(result.applied).toBe(true);
290
+ });
291
+
292
+ test('identity mismatch on code-only message blocks detail leakage', async () => {
293
+ createCanonicalGuardianRequest({
294
+ kind: 'tool_approval',
295
+ sourceType: 'channel',
296
+ conversationId: 'conv-1',
297
+ guardianExternalUserId: 'guardian-1',
298
+ requestCode: 'ABC123',
299
+ toolName: 'shell',
300
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
301
+ });
302
+
303
+ const result = await routeGuardianReply(replyCtx({
304
+ messageText: 'ABC123',
305
+ actor: guardianActor({ externalUserId: 'imposter' }),
306
+ conversationId: 'conv-1',
307
+ }));
308
+
309
+ // Code-only clarification should be returned but must NOT reveal tool details
310
+ expect(result.consumed).toBe(true);
311
+ expect(result.type).toBe('code_only_clarification');
312
+ expect(result.replyText).toBe('Request not found.');
313
+ expect(result.decisionApplied).toBe(false);
314
+ });
315
+ });
316
+
317
+ // ===========================================================================
318
+ // SECTION 3: Stale / expired / already-resolved rejection
319
+ // ===========================================================================
320
+
321
+ describe('routing invariant: stale/expired/already-resolved decisions rejected', () => {
322
+ beforeEach(() => resetTables());
323
+
324
+ test('expired request is rejected by canonical primitive', async () => {
325
+ const req = createCanonicalGuardianRequest({
326
+ kind: 'tool_approval',
327
+ sourceType: 'channel',
328
+ conversationId: 'conv-1',
329
+ guardianExternalUserId: 'guardian-1',
330
+ expiresAt: new Date(Date.now() - 10_000).toISOString(), // already expired
331
+ });
332
+
333
+ const result = await applyCanonicalGuardianDecision({
334
+ requestId: req.id,
335
+ action: 'approve_once',
336
+ actorContext: guardianActor(),
337
+ });
338
+
339
+ expect(result.applied).toBe(false);
340
+ if (result.applied) return;
341
+ expect(result.reason).toBe('expired');
342
+ });
343
+
344
+ test('already-resolved request is rejected (first-writer-wins)', async () => {
345
+ const req = createCanonicalGuardianRequest({
346
+ kind: 'tool_approval',
347
+ sourceType: 'channel',
348
+ conversationId: 'conv-1',
349
+ guardianExternalUserId: 'guardian-1',
350
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
351
+ });
352
+
353
+ // First decision succeeds
354
+ const first = await applyCanonicalGuardianDecision({
355
+ requestId: req.id,
356
+ action: 'approve_once',
357
+ actorContext: guardianActor(),
358
+ });
359
+ expect(first.applied).toBe(true);
360
+
361
+ // Second decision fails — request is no longer pending
362
+ const second = await applyCanonicalGuardianDecision({
363
+ requestId: req.id,
364
+ action: 'reject',
365
+ actorContext: guardianActor(),
366
+ });
367
+ expect(second.applied).toBe(false);
368
+ if (second.applied) return;
369
+ expect(second.reason).toBe('already_resolved');
370
+
371
+ // First decision stuck
372
+ const final = getCanonicalGuardianRequest(req.id);
373
+ expect(final!.status).toBe('approved');
374
+ });
375
+
376
+ test('nonexistent request returns not_found', async () => {
377
+ const result = await applyCanonicalGuardianDecision({
378
+ requestId: 'nonexistent-id',
379
+ action: 'approve_once',
380
+ actorContext: guardianActor(),
381
+ });
382
+
383
+ expect(result.applied).toBe(false);
384
+ if (result.applied) return;
385
+ expect(result.reason).toBe('not_found');
386
+ });
387
+
388
+ test('already-resolved request via router returns not_consumed (code lookup filters pending only)', async () => {
389
+ const req = createCanonicalGuardianRequest({
390
+ kind: 'tool_approval',
391
+ sourceType: 'channel',
392
+ conversationId: 'conv-1',
393
+ guardianExternalUserId: 'guardian-1',
394
+ requestCode: 'ABC123',
395
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
396
+ });
397
+
398
+ // Resolve the request first
399
+ await applyCanonicalGuardianDecision({
400
+ requestId: req.id,
401
+ action: 'approve_once',
402
+ actorContext: guardianActor(),
403
+ });
404
+
405
+ // Attempt to resolve again via router with code prefix.
406
+ // Since getCanonicalGuardianRequestByCode only returns pending requests,
407
+ // the resolved request won't be found and the code won't match.
408
+ const result = await routeGuardianReply(replyCtx({
409
+ messageText: 'ABC123 approve',
410
+ conversationId: 'conv-1',
411
+ }));
412
+
413
+ // Code lookup filters by status='pending', so the resolved request is invisible.
414
+ // The router does not match the code and returns not_consumed.
415
+ expect(result.consumed).toBe(false);
416
+ });
417
+
418
+ test('expired request via callback returns stale type', async () => {
419
+ const req = createCanonicalGuardianRequest({
420
+ kind: 'tool_approval',
421
+ sourceType: 'channel',
422
+ conversationId: 'conv-1',
423
+ guardianExternalUserId: 'guardian-1',
424
+ expiresAt: new Date(Date.now() - 10_000).toISOString(), // already expired
425
+ });
426
+
427
+ const result = await routeGuardianReply(replyCtx({
428
+ messageText: '',
429
+ callbackData: `apr:${req.id}:approve_once`,
430
+ conversationId: 'conv-1',
431
+ }));
432
+
433
+ expect(result.consumed).toBe(true);
434
+ expect(result.type).toBe('canonical_decision_stale');
435
+ expect(result.decisionApplied).toBe(false);
436
+ });
437
+ });
438
+
439
+ // ===========================================================================
440
+ // SECTION 4: Code-only messages return clarification, not auto-approve
441
+ // ===========================================================================
442
+
443
+ describe('routing invariant: code-only messages return clarification', () => {
444
+ beforeEach(() => resetTables());
445
+
446
+ test('code-only message returns clarification with request details', async () => {
447
+ const req = createCanonicalGuardianRequest({
448
+ kind: 'tool_approval',
449
+ sourceType: 'channel',
450
+ conversationId: 'conv-1',
451
+ guardianExternalUserId: 'guardian-1',
452
+ requestCode: 'A1B2C3',
453
+ toolName: 'shell',
454
+ questionText: 'Run shell command: ls -la',
455
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
456
+ });
457
+
458
+ const result = await routeGuardianReply(replyCtx({
459
+ messageText: 'A1B2C3',
460
+ conversationId: 'conv-1',
461
+ }));
462
+
463
+ expect(result.consumed).toBe(true);
464
+ expect(result.type).toBe('code_only_clarification');
465
+ expect(result.decisionApplied).toBe(false);
466
+ // Must provide actionable instructions
467
+ expect(result.replyText).toContain('A1B2C3');
468
+ expect(result.replyText).toContain('approve');
469
+ expect(result.replyText).toContain('reject');
470
+
471
+ // The request must remain pending — NOT auto-approved
472
+ const unchanged = getCanonicalGuardianRequest(req.id);
473
+ expect(unchanged!.status).toBe('pending');
474
+ });
475
+
476
+ test('code with decision text does apply the decision', async () => {
477
+ const req = createCanonicalGuardianRequest({
478
+ kind: 'tool_approval',
479
+ sourceType: 'channel',
480
+ conversationId: 'conv-1',
481
+ guardianExternalUserId: 'guardian-1',
482
+ requestCode: 'A1B2C3',
483
+ toolName: 'shell',
484
+ inputDigest: 'sha256:abc',
485
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
486
+ });
487
+ registerPendingToolApprovalInteraction(req.id, 'conv-1', 'shell');
488
+
489
+ const result = await routeGuardianReply(replyCtx({
490
+ messageText: 'A1B2C3 approve',
491
+ conversationId: 'conv-1',
492
+ }));
493
+
494
+ expect(result.consumed).toBe(true);
495
+ expect(result.type).toBe('canonical_decision_applied');
496
+ expect(result.decisionApplied).toBe(true);
497
+
498
+ const resolved = getCanonicalGuardianRequest(req.id);
499
+ expect(resolved!.status).toBe('approved');
500
+ });
501
+
502
+ test('code with reject text denies the request', async () => {
503
+ const req = createCanonicalGuardianRequest({
504
+ kind: 'tool_approval',
505
+ sourceType: 'channel',
506
+ conversationId: 'conv-1',
507
+ guardianExternalUserId: 'guardian-1',
508
+ requestCode: 'D4E5F6',
509
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
510
+ });
511
+ registerPendingToolApprovalInteraction(req.id, 'conv-1', 'shell');
512
+
513
+ const result = await routeGuardianReply(replyCtx({
514
+ messageText: 'D4E5F6 reject',
515
+ conversationId: 'conv-1',
516
+ }));
517
+
518
+ expect(result.consumed).toBe(true);
519
+ expect(result.decisionApplied).toBe(true);
520
+
521
+ const resolved = getCanonicalGuardianRequest(req.id);
522
+ expect(resolved!.status).toBe('denied');
523
+ });
524
+ });
525
+
526
+ // ===========================================================================
527
+ // SECTION 5: Disambiguation with multiple pending requests stays fail-closed
528
+ // ===========================================================================
529
+
530
+ describe('routing invariant: disambiguation stays fail-closed', () => {
531
+ beforeEach(() => resetTables());
532
+
533
+ test('single hinted pending request accepts explicit plain-text approve without NL generator', async () => {
534
+ const req = createCanonicalGuardianRequest({
535
+ kind: 'tool_approval',
536
+ sourceType: 'channel',
537
+ conversationId: 'conv-1',
538
+ guardianExternalUserId: 'guardian-1',
539
+ requestCode: 'DDD444',
540
+ toolName: 'shell',
541
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
542
+ });
543
+ registerPendingToolApprovalInteraction(req.id, 'conv-1', 'shell');
544
+
545
+ const result = await routeGuardianReply(replyCtx({
546
+ messageText: 'approve',
547
+ conversationId: 'conv-guardian-thread',
548
+ pendingRequestIds: [req.id],
549
+ approvalConversationGenerator: undefined,
550
+ }));
551
+
552
+ expect(result.consumed).toBe(true);
553
+ expect(result.type).toBe('canonical_decision_applied');
554
+ expect(result.decisionApplied).toBe(true);
555
+
556
+ const resolved = getCanonicalGuardianRequest(req.id);
557
+ expect(resolved!.status).toBe('approved');
558
+ });
559
+
560
+ test('single hinted pending request does not auto-approve broad acknowledgment text', async () => {
561
+ const req = createCanonicalGuardianRequest({
562
+ kind: 'tool_approval',
563
+ sourceType: 'channel',
564
+ conversationId: 'conv-1',
565
+ guardianExternalUserId: 'guardian-1',
566
+ requestCode: 'GGG777',
567
+ toolName: 'shell',
568
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
569
+ });
570
+
571
+ const result = await routeGuardianReply(replyCtx({
572
+ messageText: 'ok, what is this for?',
573
+ conversationId: 'conv-guardian-thread',
574
+ pendingRequestIds: [req.id],
575
+ approvalConversationGenerator: undefined,
576
+ }));
577
+
578
+ expect(result.consumed).toBe(false);
579
+ expect(result.type).toBe('not_consumed');
580
+ expect(result.decisionApplied).toBe(false);
581
+
582
+ const unchanged = getCanonicalGuardianRequest(req.id);
583
+ expect(unchanged!.status).toBe('pending');
584
+ });
585
+
586
+ test('explicit empty pendingRequestIds hint stays fail-closed for trusted actors', async () => {
587
+ createCanonicalGuardianRequest({
588
+ kind: 'tool_approval',
589
+ sourceType: 'channel',
590
+ conversationId: 'conv-other',
591
+ guardianExternalUserId: 'guardian-1',
592
+ requestCode: 'HHH888',
593
+ toolName: 'shell',
594
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
595
+ });
596
+
597
+ const result = await routeGuardianReply(replyCtx({
598
+ messageText: 'approve',
599
+ actor: trustedActor(),
600
+ conversationId: 'conv-unrelated',
601
+ pendingRequestIds: [],
602
+ approvalConversationGenerator: undefined,
603
+ }));
604
+
605
+ expect(result.consumed).toBe(false);
606
+ expect(result.type).toBe('not_consumed');
607
+ expect(result.decisionApplied).toBe(false);
608
+ });
609
+
610
+ test('multiple hinted pending requests with plain-text approve returns disambiguation', async () => {
611
+ const req1 = createCanonicalGuardianRequest({
612
+ kind: 'tool_approval',
613
+ sourceType: 'channel',
614
+ conversationId: 'conv-1',
615
+ guardianExternalUserId: 'guardian-1',
616
+ requestCode: 'EEE555',
617
+ toolName: 'shell',
618
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
619
+ });
620
+
621
+ const req2 = createCanonicalGuardianRequest({
622
+ kind: 'tool_approval',
623
+ sourceType: 'channel',
624
+ conversationId: 'conv-1',
625
+ guardianExternalUserId: 'guardian-1',
626
+ requestCode: 'FFF666',
627
+ toolName: 'file_write',
628
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
629
+ });
630
+
631
+ const result = await routeGuardianReply(replyCtx({
632
+ messageText: 'approve',
633
+ conversationId: 'conv-guardian-thread',
634
+ pendingRequestIds: [req1.id, req2.id],
635
+ approvalConversationGenerator: undefined,
636
+ }));
637
+
638
+ expect(result.consumed).toBe(true);
639
+ expect(result.type).toBe('disambiguation_needed');
640
+ expect(result.decisionApplied).toBe(false);
641
+ expect(result.replyText).toContain('EEE555');
642
+ expect(result.replyText).toContain('FFF666');
643
+
644
+ const r1 = getCanonicalGuardianRequest(req1.id);
645
+ const r2 = getCanonicalGuardianRequest(req2.id);
646
+ expect(r1!.status).toBe('pending');
647
+ expect(r2!.status).toBe('pending');
648
+ });
649
+
650
+ test('multiple pending requests without target return disambiguation (not auto-resolve)', async () => {
651
+ // Create two pending requests for the same guardian
652
+ const req1 = createCanonicalGuardianRequest({
653
+ kind: 'tool_approval',
654
+ sourceType: 'channel',
655
+ conversationId: 'conv-1',
656
+ guardianExternalUserId: 'guardian-1',
657
+ requestCode: 'AAA111',
658
+ toolName: 'shell',
659
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
660
+ });
661
+
662
+ const req2 = createCanonicalGuardianRequest({
663
+ kind: 'tool_approval',
664
+ sourceType: 'channel',
665
+ conversationId: 'conv-1',
666
+ guardianExternalUserId: 'guardian-1',
667
+ requestCode: 'BBB222',
668
+ toolName: 'file_write',
669
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
670
+ });
671
+
672
+ // The NL engine mock: returns a decision but no specific target.
673
+ // This simulates a guardian saying "yes" without specifying which request.
674
+ const mockGenerator = async () => ({
675
+ disposition: 'approve_once' as const,
676
+ replyText: 'Approved!',
677
+ targetRequestId: undefined,
678
+ });
679
+
680
+ const result = await routeGuardianReply(replyCtx({
681
+ messageText: 'yes approve it',
682
+ conversationId: 'conv-1',
683
+ pendingRequestIds: [req1.id, req2.id],
684
+ approvalConversationGenerator: mockGenerator as any,
685
+ }));
686
+
687
+ expect(result.consumed).toBe(true);
688
+ expect(result.type).toBe('disambiguation_needed');
689
+ expect(result.decisionApplied).toBe(false);
690
+
691
+ // Both requests must remain pending — fail-closed
692
+ const r1 = getCanonicalGuardianRequest(req1.id);
693
+ const r2 = getCanonicalGuardianRequest(req2.id);
694
+ expect(r1!.status).toBe('pending');
695
+ expect(r2!.status).toBe('pending');
696
+
697
+ // Disambiguation reply should list request codes
698
+ expect(result.replyText).toContain('AAA111');
699
+ expect(result.replyText).toContain('BBB222');
700
+ });
701
+
702
+ test('single pending request does not need disambiguation', async () => {
703
+ const req = createCanonicalGuardianRequest({
704
+ kind: 'tool_approval',
705
+ sourceType: 'channel',
706
+ conversationId: 'conv-1',
707
+ guardianExternalUserId: 'guardian-1',
708
+ requestCode: 'CCC333',
709
+ toolName: 'shell',
710
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
711
+ });
712
+ registerPendingToolApprovalInteraction(req.id, 'conv-1', 'shell');
713
+
714
+ // NL engine returns a decision without specifying target — but only one
715
+ // request is pending, so it should be resolved without disambiguation.
716
+ const mockGenerator = async () => ({
717
+ disposition: 'approve_once' as const,
718
+ replyText: 'Approved!',
719
+ targetRequestId: undefined,
720
+ });
721
+
722
+ const result = await routeGuardianReply(replyCtx({
723
+ messageText: 'yes',
724
+ conversationId: 'conv-1',
725
+ pendingRequestIds: [req.id],
726
+ approvalConversationGenerator: mockGenerator as any,
727
+ }));
728
+
729
+ expect(result.consumed).toBe(true);
730
+ expect(result.decisionApplied).toBe(true);
731
+
732
+ const resolved = getCanonicalGuardianRequest(req.id);
733
+ expect(resolved!.status).toBe('approved');
734
+ });
735
+ });
736
+
737
+ // ===========================================================================
738
+ // SECTION 6: Resolver registry integrity
739
+ // ===========================================================================
740
+
741
+ describe('routing invariant: resolver registry covers all built-in kinds', () => {
742
+ test('tool_approval resolver is registered', () => {
743
+ const resolver = getResolver('tool_approval');
744
+ expect(resolver).toBeDefined();
745
+ expect(resolver!.kind).toBe('tool_approval');
746
+ });
747
+
748
+ test('pending_question resolver is registered', () => {
749
+ const resolver = getResolver('pending_question');
750
+ expect(resolver).toBeDefined();
751
+ expect(resolver!.kind).toBe('pending_question');
752
+ });
753
+
754
+ test('unknown kind returns undefined (no default fallback)', () => {
755
+ expect(getResolver('nonexistent_kind')).toBeUndefined();
756
+ });
757
+
758
+ test('registered kinds include at least tool_approval and pending_question', () => {
759
+ const kinds = getRegisteredKinds();
760
+ expect(kinds).toContain('tool_approval');
761
+ expect(kinds).toContain('pending_question');
762
+ });
763
+ });
764
+
765
+ // ===========================================================================
766
+ // SECTION 7: approve_always downgrade invariant
767
+ // ===========================================================================
768
+
769
+ describe('routing invariant: approve_always downgraded for guardian-on-behalf', () => {
770
+ beforeEach(() => resetTables());
771
+
772
+ test('approve_always is silently downgraded to approve_once by canonical primitive', async () => {
773
+ const req = createCanonicalGuardianRequest({
774
+ kind: 'tool_approval',
775
+ sourceType: 'channel',
776
+ conversationId: 'conv-1',
777
+ guardianExternalUserId: 'guardian-1',
778
+ toolName: 'shell',
779
+ inputDigest: 'sha256:abc',
780
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
781
+ });
782
+
783
+ const result = await applyCanonicalGuardianDecision({
784
+ requestId: req.id,
785
+ action: 'approve_always',
786
+ actorContext: guardianActor(),
787
+ });
788
+
789
+ expect(result.applied).toBe(true);
790
+
791
+ // Status should be 'approved' (not some 'always_approved' state)
792
+ const resolved = getCanonicalGuardianRequest(req.id);
793
+ expect(resolved!.status).toBe('approved');
794
+ });
795
+ });
796
+
797
+ // ===========================================================================
798
+ // SECTION 8: Callback routing uses applyCanonicalGuardianDecision
799
+ // ===========================================================================
800
+
801
+ describe('routing invariant: callback buttons route through canonical primitive', () => {
802
+ beforeEach(() => resetTables());
803
+
804
+ test('valid callback data applies decision via canonical primitive', async () => {
805
+ const req = createCanonicalGuardianRequest({
806
+ kind: 'tool_approval',
807
+ sourceType: 'channel',
808
+ conversationId: 'conv-1',
809
+ guardianExternalUserId: 'guardian-1',
810
+ toolName: 'shell',
811
+ inputDigest: 'sha256:abc',
812
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
813
+ });
814
+ registerPendingToolApprovalInteraction(req.id, 'conv-1', 'shell');
815
+
816
+ const result = await routeGuardianReply(replyCtx({
817
+ messageText: '',
818
+ callbackData: `apr:${req.id}:approve_once`,
819
+ conversationId: 'conv-1',
820
+ }));
821
+
822
+ expect(result.consumed).toBe(true);
823
+ expect(result.type).toBe('canonical_decision_applied');
824
+ expect(result.decisionApplied).toBe(true);
825
+
826
+ const resolved = getCanonicalGuardianRequest(req.id);
827
+ expect(resolved!.status).toBe('approved');
828
+ });
829
+
830
+ test('callback with reject action denies the request', async () => {
831
+ const req = createCanonicalGuardianRequest({
832
+ kind: 'tool_approval',
833
+ sourceType: 'channel',
834
+ conversationId: 'conv-1',
835
+ guardianExternalUserId: 'guardian-1',
836
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
837
+ });
838
+ registerPendingToolApprovalInteraction(req.id, 'conv-1', 'shell');
839
+
840
+ const result = await routeGuardianReply(replyCtx({
841
+ messageText: '',
842
+ callbackData: `apr:${req.id}:reject`,
843
+ conversationId: 'conv-1',
844
+ }));
845
+
846
+ expect(result.consumed).toBe(true);
847
+ expect(result.decisionApplied).toBe(true);
848
+
849
+ const resolved = getCanonicalGuardianRequest(req.id);
850
+ expect(resolved!.status).toBe('denied');
851
+ });
852
+
853
+ test('callback targeting different conversation is still processed (conversationId scoping removed for cross-channel)', async () => {
854
+ const req = createCanonicalGuardianRequest({
855
+ kind: 'tool_approval',
856
+ sourceType: 'channel',
857
+ conversationId: 'conv-other',
858
+ guardianExternalUserId: 'guardian-1',
859
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
860
+ });
861
+ registerPendingToolApprovalInteraction(req.id, 'conv-other', 'shell');
862
+
863
+ const result = await routeGuardianReply(replyCtx({
864
+ messageText: '',
865
+ callbackData: `apr:${req.id}:approve_once`,
866
+ conversationId: 'conv-1', // different conversation — no longer rejected
867
+ }));
868
+
869
+ // Should be consumed — conversationId scoping was removed because in
870
+ // cross-channel flows the guardian's conversation differs from the
871
+ // requester's. Identity validation in the canonical decision primitive
872
+ // (guardianExternalUserId match) is the correct security boundary.
873
+ expect(result.consumed).toBe(true);
874
+ expect(result.decisionApplied).toBe(true);
875
+
876
+ // Request should be approved
877
+ const resolved = getCanonicalGuardianRequest(req.id);
878
+ expect(resolved!.status).toBe('approved');
879
+ });
880
+ });
881
+
882
+ // ===========================================================================
883
+ // SECTION 9: Destination hint-based NL approval for missing guardianExternalUserId
884
+ // ===========================================================================
885
+
886
+ describe('routing invariant: destination hints enable NL approval without guardianExternalUserId', () => {
887
+ beforeEach(() => resetTables());
888
+
889
+ test('explicit pendingRequestIds from destination hints allows deterministic plain-text approval when guardianExternalUserId is missing', async () => {
890
+ // Voice-originated pending_question: no guardianExternalUserId
891
+ const req = createCanonicalGuardianRequest({
892
+ kind: 'tool_approval',
893
+ sourceType: 'voice',
894
+ sourceChannel: 'twilio',
895
+ conversationId: 'conv-voice-1',
896
+ toolName: 'shell',
897
+ requestCode: 'NL1234',
898
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
899
+ // guardianExternalUserId intentionally omitted
900
+ });
901
+ registerPendingToolApprovalInteraction(req.id, 'conv-voice-1', 'shell');
902
+
903
+ // The channel inbound router would compute pendingRequestIds from
904
+ // delivery-scoped lookup and pass them here. Simulate that.
905
+ const result = await routeGuardianReply(replyCtx({
906
+ messageText: 'approve',
907
+ channel: 'telegram',
908
+ actor: guardianActor({ externalUserId: 'guardian-tg-user' }),
909
+ conversationId: 'conv-guardian-chat',
910
+ pendingRequestIds: [req.id],
911
+ approvalConversationGenerator: undefined,
912
+ }));
913
+
914
+ expect(result.consumed).toBe(true);
915
+ expect(result.type).toBe('canonical_decision_applied');
916
+ expect(result.decisionApplied).toBe(true);
917
+
918
+ const resolved = getCanonicalGuardianRequest(req.id);
919
+ expect(resolved!.status).toBe('approved');
920
+ });
921
+
922
+ test('without destination hints, missing guardianExternalUserId means no pending requests found', async () => {
923
+ // Voice-originated request: no guardianExternalUserId
924
+ const req = createCanonicalGuardianRequest({
925
+ kind: 'tool_approval',
926
+ sourceType: 'voice',
927
+ sourceChannel: 'twilio',
928
+ conversationId: 'conv-voice-2',
929
+ toolName: 'shell',
930
+ requestCode: 'NL5678',
931
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
932
+ });
933
+
934
+ // No pendingRequestIds passed — identity-based fallback uses
935
+ // actor.externalUserId which does not match any request's
936
+ // guardianExternalUserId (since it's null).
937
+ const result = await routeGuardianReply(replyCtx({
938
+ messageText: 'approve',
939
+ channel: 'telegram',
940
+ actor: guardianActor({ externalUserId: 'guardian-tg-user' }),
941
+ conversationId: 'conv-guardian-chat',
942
+ // pendingRequestIds: undefined — no delivery hints
943
+ approvalConversationGenerator: undefined,
944
+ }));
945
+
946
+ // Identity-based lookup finds nothing because the request has no
947
+ // guardianExternalUserId, so the router returns not_consumed.
948
+ expect(result.consumed).toBe(false);
949
+ expect(result.type).toBe('not_consumed');
950
+
951
+ const unchanged = getCanonicalGuardianRequest(req.id);
952
+ expect(unchanged!.status).toBe('pending');
953
+ });
954
+ });