@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
@@ -4,7 +4,11 @@
4
4
  * - IPC handlers (guardian-actions.ts)
5
5
  *
6
6
  * Covers: conversationId scoping, stale handling, access-request routing,
7
- * invalid action rejection, pending interaction fallback, and not-found paths.
7
+ * invalid action rejection, and not-found paths.
8
+ *
9
+ * All decisions now go through the canonical guardian decision primitive
10
+ * (`applyCanonicalGuardianDecision`), so tests create canonical requests
11
+ * and mock that function.
8
12
  */
9
13
  import { mkdtempSync, rmSync } from 'node:fs';
10
14
  import { tmpdir } from 'node:os';
@@ -33,49 +37,27 @@ mock.module('../util/logger.js', () => ({
33
37
  }),
34
38
  }));
35
39
 
36
- // Mock applyGuardianDecision to avoid needing the full approval + session machinery
37
- const mockApplyGuardianDecision = mock(
38
- (..._args: any[]): { applied: boolean; requestId?: string; reason?: string; userText?: string } => ({
39
- applied: true,
40
- requestId: 'req-123',
41
- }),
40
+ // Mock applyCanonicalGuardianDecision the single decision write path.
41
+ const mockApplyCanonicalGuardianDecision = mock(
42
+ (..._args: any[]): Promise<{ applied: boolean; requestId?: string; reason?: string; grantMinted?: boolean }> =>
43
+ Promise.resolve({
44
+ applied: true,
45
+ requestId: 'req-123',
46
+ grantMinted: false,
47
+ }),
42
48
  );
43
49
  mock.module('../approvals/guardian-decision-primitive.js', () => ({
44
- applyGuardianDecision: mockApplyGuardianDecision,
45
- }));
46
-
47
- // Mock handleChannelDecision for the pending-interactions fallback path
48
- const mockHandleChannelDecision = mock(
49
- (..._args: any[]): { applied: boolean; requestId?: string } => ({
50
- applied: true,
51
- requestId: 'req-456',
52
- }),
53
- );
54
- mock.module('../runtime/channel-approvals.js', () => ({
55
- handleChannelDecision: mockHandleChannelDecision,
56
- }));
57
-
58
- // Mock handleAccessRequestDecision for ingress_access_request routing
59
- const mockHandleAccessRequestDecision = mock(
60
- (..._args: any[]): { handled: boolean; type: string; verificationSessionId?: string; verificationCode?: string } => ({
61
- handled: true,
62
- type: 'approved',
63
- verificationSessionId: 'vs-1',
64
- verificationCode: '123456',
65
- }),
66
- );
67
- mock.module('../runtime/routes/access-request-decision.js', () => ({
68
- handleAccessRequestDecision: mockHandleAccessRequestDecision,
50
+ applyCanonicalGuardianDecision: mockApplyCanonicalGuardianDecision,
69
51
  }));
70
52
 
71
53
  import { guardianActionsHandlers } from '../daemon/handlers/guardian-actions.js';
72
54
  import {
73
- createApprovalRequest,
74
- } from '../memory/channel-guardian-store.js';
55
+ createCanonicalGuardianRequest,
56
+ generateCanonicalRequestCode,
57
+ } from '../memory/canonical-guardian-store.js';
75
58
  import { initializeDb, resetDb } from '../memory/db.js';
76
59
  import { getDb } from '../memory/db.js';
77
60
  import { conversations } from '../memory/schema.js';
78
- import * as pendingInteractions from '../runtime/pending-interactions.js';
79
61
  import {
80
62
  handleGuardianActionDecision,
81
63
  handleGuardianActionsPending,
@@ -94,44 +76,44 @@ function ensureConversation(id: string): void {
94
76
 
95
77
  function resetTables(): void {
96
78
  const db = getDb();
79
+ db.run('DELETE FROM canonical_guardian_requests');
97
80
  db.run('DELETE FROM channel_guardian_approval_requests');
98
81
  db.run('DELETE FROM conversations');
99
- pendingInteractions.clear();
100
- mockApplyGuardianDecision.mockClear();
101
- mockHandleChannelDecision.mockClear();
102
- mockHandleAccessRequestDecision.mockClear();
82
+ mockApplyCanonicalGuardianDecision.mockClear();
103
83
  }
104
84
 
105
- /** Create a minimal pending approval for testing. */
106
- function createTestApproval(overrides: {
85
+ /** Create a canonical guardian request for testing. */
86
+ function createTestCanonicalRequest(overrides: {
107
87
  conversationId: string;
108
88
  requestId: string;
89
+ kind?: string;
109
90
  toolName?: string;
110
91
  guardianExternalUserId?: string;
111
- reason?: string;
92
+ questionText?: string;
93
+ expiresAt?: string;
112
94
  }) {
113
95
  ensureConversation(overrides.conversationId);
114
- return createApprovalRequest({
115
- runId: `run-${overrides.requestId}`,
116
- requestId: overrides.requestId,
96
+ return createCanonicalGuardianRequest({
97
+ id: overrides.requestId,
98
+ kind: overrides.kind ?? 'tool_approval',
99
+ sourceType: 'desktop',
100
+ sourceChannel: 'vellum',
117
101
  conversationId: overrides.conversationId,
118
- channel: 'vellum',
119
- requesterExternalUserId: 'user-1',
120
- requesterChatId: 'chat-1',
121
- guardianExternalUserId: overrides.guardianExternalUserId ?? 'guardian-1',
122
- guardianChatId: 'gchat-1',
102
+ guardianExternalUserId: overrides.guardianExternalUserId,
123
103
  toolName: overrides.toolName ?? 'bash',
124
- reason: overrides.reason,
125
- expiresAt: Date.now() + 60_000,
104
+ questionText: overrides.questionText,
105
+ requestCode: generateCanonicalRequestCode(),
106
+ status: 'pending',
107
+ expiresAt: overrides.expiresAt ?? new Date(Date.now() + 60_000).toISOString(),
126
108
  });
127
109
  }
128
110
 
129
- // ── IPC helper ──────────────────────────────────────────────────────────
111
+ // -- IPC helper ---------------------------------------------------------------
130
112
 
131
113
  /** Minimal stub for IPC socket and context to capture sent messages. */
132
114
  function createIpcStub() {
133
115
  const sent: Array<Record<string, unknown>> = [];
134
- const socket = {} as unknown; // opaque the handler just passes it through
116
+ const socket = {} as unknown; // opaque -- the handler just passes it through
135
117
  const ctx = {
136
118
  send: (_socket: unknown, msg: Record<string, unknown>) => {
137
119
  sent.push(msg);
@@ -140,7 +122,7 @@ function createIpcStub() {
140
122
  return { socket, ctx, sent };
141
123
  }
142
124
 
143
- // ── Cleanup ─────────────────────────────────────────────────────────────
125
+ // -- Cleanup ------------------------------------------------------------------
144
126
 
145
127
  afterAll(() => {
146
128
  resetDb();
@@ -191,7 +173,9 @@ describe('HTTP handleGuardianActionDecision', () => {
191
173
  expect(body.error.message).toContain('Invalid action');
192
174
  });
193
175
 
194
- test('returns 404 when no pending approval or interaction exists', async () => {
176
+ test('returns 404 when no canonical request exists (not_found from canonical primitive)', async () => {
177
+ mockApplyCanonicalGuardianDecision.mockResolvedValueOnce({ applied: false, reason: 'not_found' });
178
+
195
179
  const req = new Request('http://localhost/v1/guardian-actions/decision', {
196
180
  method: 'POST',
197
181
  body: JSON.stringify({ requestId: 'nonexistent', action: 'approve_once' }),
@@ -200,9 +184,9 @@ describe('HTTP handleGuardianActionDecision', () => {
200
184
  expect(res.status).toBe(404);
201
185
  });
202
186
 
203
- test('applies decision via applyGuardianDecision for channel approval', async () => {
204
- createTestApproval({ conversationId: 'conv-1', requestId: 'req-gd-1' });
205
- mockApplyGuardianDecision.mockReturnValueOnce({ applied: true, requestId: 'req-gd-1' });
187
+ test('applies decision via applyCanonicalGuardianDecision for tool approval', async () => {
188
+ createTestCanonicalRequest({ conversationId: 'conv-1', requestId: 'req-gd-1' });
189
+ mockApplyCanonicalGuardianDecision.mockResolvedValueOnce({ applied: true, requestId: 'req-gd-1', grantMinted: false });
206
190
 
207
191
  const req = new Request('http://localhost/v1/guardian-actions/decision', {
208
192
  method: 'POST',
@@ -213,26 +197,26 @@ describe('HTTP handleGuardianActionDecision', () => {
213
197
  const body = await res.json();
214
198
  expect(body.applied).toBe(true);
215
199
  expect(body.requestId).toBe('req-gd-1');
216
- expect(mockApplyGuardianDecision).toHaveBeenCalledTimes(1);
200
+ expect(mockApplyCanonicalGuardianDecision).toHaveBeenCalledTimes(1);
217
201
  });
218
202
 
219
- test('rejects decision when conversationId does not match approval', async () => {
220
- createTestApproval({ conversationId: 'conv-1', requestId: 'req-scope-1' });
203
+ test('rejects decision when conversationId does not match canonical request', async () => {
204
+ createTestCanonicalRequest({ conversationId: 'conv-1', requestId: 'req-scope-1' });
221
205
 
222
206
  const req = new Request('http://localhost/v1/guardian-actions/decision', {
223
207
  method: 'POST',
224
208
  body: JSON.stringify({ requestId: 'req-scope-1', action: 'approve_once', conversationId: 'conv-wrong' }),
225
209
  });
226
210
  const res = await handleGuardianActionDecision(req);
227
- expect(res.status).toBe(400);
211
+ expect(res.status).toBe(404);
228
212
  const body = await res.json();
229
- expect(body.error.message).toContain('does not match');
230
- expect(mockApplyGuardianDecision).not.toHaveBeenCalled();
213
+ expect(body.error.message).toContain('No pending guardian action');
214
+ expect(mockApplyCanonicalGuardianDecision).not.toHaveBeenCalled();
231
215
  });
232
216
 
233
- test('allows decision when conversationId matches approval', async () => {
234
- createTestApproval({ conversationId: 'conv-match', requestId: 'req-scope-2' });
235
- mockApplyGuardianDecision.mockReturnValueOnce({ applied: true, requestId: 'req-scope-2' });
217
+ test('allows decision when conversationId matches canonical request', async () => {
218
+ createTestCanonicalRequest({ conversationId: 'conv-match', requestId: 'req-scope-2' });
219
+ mockApplyCanonicalGuardianDecision.mockResolvedValueOnce({ applied: true, requestId: 'req-scope-2', grantMinted: false });
236
220
 
237
221
  const req = new Request('http://localhost/v1/guardian-actions/decision', {
238
222
  method: 'POST',
@@ -245,8 +229,8 @@ describe('HTTP handleGuardianActionDecision', () => {
245
229
  });
246
230
 
247
231
  test('allows decision when no conversationId is provided (backward compat)', async () => {
248
- createTestApproval({ conversationId: 'conv-any', requestId: 'req-scope-3' });
249
- mockApplyGuardianDecision.mockReturnValueOnce({ applied: true, requestId: 'req-scope-3' });
232
+ createTestCanonicalRequest({ conversationId: 'conv-any', requestId: 'req-scope-3' });
233
+ mockApplyCanonicalGuardianDecision.mockResolvedValueOnce({ applied: true, requestId: 'req-scope-3', grantMinted: false });
250
234
 
251
235
  const req = new Request('http://localhost/v1/guardian-actions/decision', {
252
236
  method: 'POST',
@@ -256,13 +240,15 @@ describe('HTTP handleGuardianActionDecision', () => {
256
240
  expect(res.status).toBe(200);
257
241
  });
258
242
 
259
- test('routes ingress_access_request through handleAccessRequestDecision', async () => {
260
- createTestApproval({
243
+ test('applies decision for access_request kind through canonical primitive', async () => {
244
+ createTestCanonicalRequest({
261
245
  conversationId: 'conv-access',
262
246
  requestId: 'req-access-1',
247
+ kind: 'access_request',
263
248
  toolName: 'ingress_access_request',
264
249
  guardianExternalUserId: 'guardian-42',
265
250
  });
251
+ mockApplyCanonicalGuardianDecision.mockResolvedValueOnce({ applied: true, requestId: 'req-access-1', grantMinted: false });
266
252
 
267
253
  const req = new Request('http://localhost/v1/guardian-actions/decision', {
268
254
  method: 'POST',
@@ -272,53 +258,13 @@ describe('HTTP handleGuardianActionDecision', () => {
272
258
  expect(res.status).toBe(200);
273
259
  const body = await res.json();
274
260
  expect(body.applied).toBe(true);
275
- expect(body.accessRequestResult).toBeDefined();
276
- expect(mockHandleAccessRequestDecision).toHaveBeenCalledTimes(1);
277
- // Should NOT call applyGuardianDecision for access requests
278
- expect(mockApplyGuardianDecision).not.toHaveBeenCalled();
279
- });
280
-
281
- test('maps reject to deny for access request decisions', async () => {
282
- createTestApproval({
283
- conversationId: 'conv-access-deny',
284
- requestId: 'req-access-deny',
285
- toolName: 'ingress_access_request',
286
- });
287
-
288
- const req = new Request('http://localhost/v1/guardian-actions/decision', {
289
- method: 'POST',
290
- body: JSON.stringify({ requestId: 'req-access-deny', action: 'reject' }),
291
- });
292
- await handleGuardianActionDecision(req);
293
- const call = mockHandleAccessRequestDecision.mock.calls[0]!;
294
- expect(call[1]).toBe('deny');
295
- });
296
-
297
- test('returns stale when access request decision is stale', async () => {
298
- createTestApproval({
299
- conversationId: 'conv-access-stale',
300
- requestId: 'req-access-stale',
301
- toolName: 'ingress_access_request',
302
- });
303
- mockHandleAccessRequestDecision.mockReturnValueOnce({
304
- handled: false,
305
- type: 'stale' as const,
306
- });
307
-
308
- const req = new Request('http://localhost/v1/guardian-actions/decision', {
309
- method: 'POST',
310
- body: JSON.stringify({ requestId: 'req-access-stale', action: 'approve_once' }),
311
- });
312
- const res = await handleGuardianActionDecision(req);
313
- const body = await res.json();
314
- expect(body.applied).toBe(false);
315
- expect(body.reason).toBe('stale');
316
- expect(body.requestId).toBe('req-access-stale');
261
+ // All decisions go through the canonical primitive
262
+ expect(mockApplyCanonicalGuardianDecision).toHaveBeenCalledTimes(1);
317
263
  });
318
264
 
319
- test('preserves requestId in response when applyGuardianDecision returns stale without requestId', async () => {
320
- createTestApproval({ conversationId: 'conv-stale', requestId: 'req-stale-1' });
321
- mockApplyGuardianDecision.mockReturnValueOnce({ applied: false, reason: 'stale' });
265
+ test('returns stale reason from canonical decision primitive', async () => {
266
+ createTestCanonicalRequest({ conversationId: 'conv-stale', requestId: 'req-stale-1' });
267
+ mockApplyCanonicalGuardianDecision.mockResolvedValueOnce({ applied: false, reason: 'already_resolved' });
322
268
 
323
269
  const req = new Request('http://localhost/v1/guardian-actions/decision', {
324
270
  method: 'POST',
@@ -327,60 +273,25 @@ describe('HTTP handleGuardianActionDecision', () => {
327
273
  const res = await handleGuardianActionDecision(req);
328
274
  const body = await res.json();
329
275
  expect(body.applied).toBe(false);
330
- expect(body.reason).toBe('stale');
331
- // requestId should fall back to the original msg requestId
276
+ expect(body.reason).toBe('already_resolved');
277
+ // requestId should fall back to the original request ID
332
278
  expect(body.requestId).toBe('req-stale-1');
333
279
  });
334
280
 
335
- test('falls back to pending interactions when no channel approval exists', async () => {
336
- const fakeSession = {} as any;
337
- pendingInteractions.register('req-pi-1', {
338
- session: fakeSession,
339
- conversationId: 'conv-pi',
340
- kind: 'confirmation',
341
- });
342
- mockHandleChannelDecision.mockReturnValueOnce({ applied: true, requestId: 'req-pi-1' });
343
-
344
- const req = new Request('http://localhost/v1/guardian-actions/decision', {
345
- method: 'POST',
346
- body: JSON.stringify({ requestId: 'req-pi-1', action: 'approve_always' }),
347
- });
348
- const res = await handleGuardianActionDecision(req);
349
- const body = await res.json();
350
- expect(body.applied).toBe(true);
351
- expect(mockHandleChannelDecision).toHaveBeenCalledTimes(1);
352
- expect(mockApplyGuardianDecision).not.toHaveBeenCalled();
353
- });
354
-
355
- test('rejects interaction decision when conversationId mismatches', async () => {
356
- const fakeSession = {} as any;
357
- pendingInteractions.register('req-pi-scope', {
358
- session: fakeSession,
359
- conversationId: 'conv-pi-correct',
360
- kind: 'confirmation',
361
- });
362
-
363
- const req = new Request('http://localhost/v1/guardian-actions/decision', {
364
- method: 'POST',
365
- body: JSON.stringify({ requestId: 'req-pi-scope', action: 'approve_once', conversationId: 'conv-pi-wrong' }),
366
- });
367
- const res = await handleGuardianActionDecision(req);
368
- expect(res.status).toBe(400);
369
- expect(mockHandleChannelDecision).not.toHaveBeenCalled();
370
- });
371
-
372
- test('passes actorExternalUserId as undefined (unauthenticated endpoint)', async () => {
373
- createTestApproval({ conversationId: 'conv-actor', requestId: 'req-actor-1' });
374
- mockApplyGuardianDecision.mockReturnValueOnce({ applied: true, requestId: 'req-actor-1' });
281
+ test('passes actorContext with undefined externalUserId (unauthenticated endpoint)', async () => {
282
+ createTestCanonicalRequest({ conversationId: 'conv-actor', requestId: 'req-actor-1' });
283
+ mockApplyCanonicalGuardianDecision.mockResolvedValueOnce({ applied: true, requestId: 'req-actor-1', grantMinted: false });
375
284
 
376
285
  const req = new Request('http://localhost/v1/guardian-actions/decision', {
377
286
  method: 'POST',
378
287
  body: JSON.stringify({ requestId: 'req-actor-1', action: 'approve_once' }),
379
288
  });
380
289
  await handleGuardianActionDecision(req);
381
- const call = mockApplyGuardianDecision.mock.calls[0]![0] as Record<string, unknown>;
382
- expect(call.actorExternalUserId).toBeUndefined();
383
- expect(call.actorChannel).toBe('vellum');
290
+ const call = mockApplyCanonicalGuardianDecision.mock.calls[0]![0] as Record<string, unknown>;
291
+ const actorContext = call.actorContext as Record<string, unknown>;
292
+ expect(actorContext.externalUserId).toBeUndefined();
293
+ expect(actorContext.channel).toBe('vellum');
294
+ expect(actorContext.isTrusted).toBe(true);
384
295
  });
385
296
  });
386
297
 
@@ -397,8 +308,12 @@ describe('HTTP handleGuardianActionsPending', () => {
397
308
  expect(res.status).toBe(400);
398
309
  });
399
310
 
400
- test('returns prompts for a conversation with pending approvals', () => {
401
- createTestApproval({ conversationId: 'conv-list', requestId: 'req-list-1', reason: 'Run bash: ls' });
311
+ test('returns prompts for a conversation with pending canonical requests', () => {
312
+ createTestCanonicalRequest({
313
+ conversationId: 'conv-list',
314
+ requestId: 'req-list-1',
315
+ questionText: 'Run bash: ls',
316
+ });
402
317
 
403
318
  const req = new Request('http://localhost/v1/guardian-actions/pending?conversationId=conv-list');
404
319
  const res = handleGuardianActionsPending(req);
@@ -411,7 +326,7 @@ describe('HTTP handleGuardianActionsPending', () => {
411
326
  expect(prompts[0].questionText).toBe('Run bash: ls');
412
327
  });
413
328
 
414
- test('returns empty prompts for a conversation with no pending approvals', () => {
329
+ test('returns empty prompts for a conversation with no pending requests', () => {
415
330
  const prompts = listGuardianDecisionPrompts({ conversationId: 'conv-empty' });
416
331
  expect(prompts).toHaveLength(0);
417
332
  });
@@ -424,100 +339,108 @@ describe('HTTP handleGuardianActionsPending', () => {
424
339
  describe('listGuardianDecisionPrompts', () => {
425
340
  beforeEach(resetTables);
426
341
 
427
- test('excludes expired approvals', () => {
342
+ test('excludes expired canonical requests', () => {
428
343
  ensureConversation('conv-expired');
429
- // Create approval that's already expired
430
- createApprovalRequest({
431
- runId: 'run-expired',
432
- requestId: 'req-expired',
344
+ createCanonicalGuardianRequest({
345
+ id: 'req-expired',
346
+ kind: 'tool_approval',
347
+ sourceType: 'desktop',
348
+ sourceChannel: 'vellum',
433
349
  conversationId: 'conv-expired',
434
- channel: 'vellum',
435
- requesterExternalUserId: 'user-1',
436
- requesterChatId: 'chat-1',
437
- guardianExternalUserId: 'guardian-1',
438
- guardianChatId: 'gchat-1',
439
350
  toolName: 'bash',
440
- expiresAt: Date.now() - 1000, // already expired
351
+ requestCode: generateCanonicalRequestCode(),
352
+ status: 'pending',
353
+ expiresAt: new Date(Date.now() - 1000).toISOString(), // already expired
441
354
  });
442
355
 
443
356
  const prompts = listGuardianDecisionPrompts({ conversationId: 'conv-expired' });
444
357
  expect(prompts).toHaveLength(0);
445
358
  });
446
359
 
447
- test('excludes approvals without requestId', () => {
448
- ensureConversation('conv-no-reqid');
449
- createApprovalRequest({
450
- runId: 'run-no-reqid',
451
- // no requestId
452
- conversationId: 'conv-no-reqid',
453
- channel: 'vellum',
454
- requesterExternalUserId: 'user-1',
455
- requesterChatId: 'chat-1',
456
- guardianExternalUserId: 'guardian-1',
457
- guardianChatId: 'gchat-1',
458
- toolName: 'bash',
459
- expiresAt: Date.now() + 60_000,
360
+ test('includes pending canonical requests with toolName', () => {
361
+ createTestCanonicalRequest({
362
+ conversationId: 'conv-tool',
363
+ requestId: 'req-tool-prompt',
364
+ toolName: 'read_file',
460
365
  });
461
366
 
462
- const prompts = listGuardianDecisionPrompts({ conversationId: 'conv-no-reqid' });
463
- expect(prompts).toHaveLength(0);
367
+ const prompts = listGuardianDecisionPrompts({ conversationId: 'conv-tool' });
368
+ expect(prompts).toHaveLength(1);
369
+ expect(prompts[0].toolName).toBe('read_file');
370
+ expect(prompts[0].requestId).toBe('req-tool-prompt');
464
371
  });
465
372
 
466
- test('includes pending interaction confirmations', () => {
467
- const fakeSession = {} as any;
468
- pendingInteractions.register('req-int-prompt', {
469
- session: fakeSession,
470
- conversationId: 'conv-int-prompt',
471
- kind: 'confirmation',
472
- confirmationDetails: {
473
- toolName: 'read_file',
474
- input: { path: '/etc/passwd' },
475
- riskLevel: 'high',
476
- allowlistOptions: [],
477
- scopeOptions: [],
478
- persistentDecisionsAllowed: true,
479
- },
373
+ test('generates questionText from toolName when questionText is not set', () => {
374
+ createTestCanonicalRequest({
375
+ conversationId: 'conv-gen-qt',
376
+ requestId: 'req-gen-qt',
377
+ toolName: 'bash',
480
378
  });
481
379
 
482
- const prompts = listGuardianDecisionPrompts({ conversationId: 'conv-int-prompt' });
380
+ const prompts = listGuardianDecisionPrompts({ conversationId: 'conv-gen-qt' });
483
381
  expect(prompts).toHaveLength(1);
484
- expect(prompts[0].toolName).toBe('read_file');
485
- expect(prompts[0].requestId).toBe('req-int-prompt');
486
- });
487
-
488
- test('deduplicates interactions that share a requestId with a channel approval', () => {
489
- createTestApproval({ conversationId: 'conv-dedup', requestId: 'req-dedup-shared' });
490
-
491
- const fakeSession = {} as any;
492
- pendingInteractions.register('req-dedup-shared', {
493
- session: fakeSession,
494
- conversationId: 'conv-dedup',
495
- kind: 'confirmation',
496
- confirmationDetails: {
497
- toolName: 'bash',
498
- input: {},
499
- riskLevel: 'medium',
500
- allowlistOptions: [],
501
- scopeOptions: [],
502
- },
382
+ expect(prompts[0].questionText).toBe('Approve tool: bash');
383
+ });
384
+
385
+ test('uses questionText when it is set', () => {
386
+ createTestCanonicalRequest({
387
+ conversationId: 'conv-qt',
388
+ requestId: 'req-qt',
389
+ toolName: 'bash',
390
+ questionText: 'Run bash: ls -la',
503
391
  });
504
392
 
505
- const prompts = listGuardianDecisionPrompts({ conversationId: 'conv-dedup' });
506
- // Should only appear once (from the channel approval)
393
+ const prompts = listGuardianDecisionPrompts({ conversationId: 'conv-qt' });
507
394
  expect(prompts).toHaveLength(1);
508
- expect(prompts[0].requestId).toBe('req-dedup-shared');
395
+ expect(prompts[0].questionText).toBe('Run bash: ls -la');
509
396
  });
510
397
 
511
- test('skips non-confirmation interactions', () => {
512
- const fakeSession = {} as any;
513
- pendingInteractions.register('req-secret', {
514
- session: fakeSession,
515
- conversationId: 'conv-secret',
516
- kind: 'secret',
398
+ test('returns prompt with correct shape fields', () => {
399
+ createTestCanonicalRequest({
400
+ conversationId: 'conv-shape',
401
+ requestId: 'req-shape',
402
+ toolName: 'bash',
403
+ questionText: 'Test prompt',
517
404
  });
518
405
 
519
- const prompts = listGuardianDecisionPrompts({ conversationId: 'conv-secret' });
520
- expect(prompts).toHaveLength(0);
406
+ const prompts = listGuardianDecisionPrompts({ conversationId: 'conv-shape' });
407
+ expect(prompts).toHaveLength(1);
408
+ const prompt = prompts[0];
409
+ expect(prompt.requestId).toBe('req-shape');
410
+ expect(prompt.state).toBe('pending');
411
+ expect(prompt.conversationId).toBe('conv-shape');
412
+ expect(prompt.toolName).toBe('bash');
413
+ expect(prompt.actions).toBeDefined();
414
+ expect(prompt.expiresAt).toBeGreaterThan(Date.now() - 5000);
415
+ expect(prompt.kind).toBe('tool_approval');
416
+ });
417
+
418
+ test('includes access_request kind canonical requests', () => {
419
+ createTestCanonicalRequest({
420
+ conversationId: 'conv-ar-prompt',
421
+ requestId: 'req-ar-prompt',
422
+ kind: 'access_request',
423
+ toolName: 'ingress_access_request',
424
+ questionText: 'User wants access',
425
+ });
426
+
427
+ const prompts = listGuardianDecisionPrompts({ conversationId: 'conv-ar-prompt' });
428
+ expect(prompts).toHaveLength(1);
429
+ expect(prompts[0].kind).toBe('access_request');
430
+ expect(prompts[0].questionText).toBe('User wants access');
431
+ });
432
+
433
+ test('only returns requests for the given conversationId', () => {
434
+ createTestCanonicalRequest({ conversationId: 'conv-a', requestId: 'req-a' });
435
+ createTestCanonicalRequest({ conversationId: 'conv-b', requestId: 'req-b' });
436
+
437
+ const promptsA = listGuardianDecisionPrompts({ conversationId: 'conv-a' });
438
+ expect(promptsA).toHaveLength(1);
439
+ expect(promptsA[0].requestId).toBe('req-a');
440
+
441
+ const promptsB = listGuardianDecisionPrompts({ conversationId: 'conv-b' });
442
+ expect(promptsB).toHaveLength(1);
443
+ expect(promptsB[0].requestId).toBe('req-b');
521
444
  });
522
445
  });
523
446
 
@@ -530,9 +453,9 @@ describe('IPC guardian_action_decision', () => {
530
453
 
531
454
  const handler = guardianActionsHandlers.guardian_action_decision;
532
455
 
533
- test('rejects invalid action', () => {
456
+ test('rejects invalid action', async () => {
534
457
  const { socket, ctx, sent } = createIpcStub();
535
- handler(
458
+ await handler(
536
459
  { type: 'guardian_action_decision', requestId: 'req-ipc-1', action: 'self_destruct' } as any,
537
460
  socket as any,
538
461
  ctx as any,
@@ -543,9 +466,11 @@ describe('IPC guardian_action_decision', () => {
543
466
  expect(sent[0].requestId).toBe('req-ipc-1');
544
467
  });
545
468
 
546
- test('returns not_found when no approval or interaction exists', () => {
469
+ test('returns not_found when no canonical request exists', async () => {
470
+ mockApplyCanonicalGuardianDecision.mockResolvedValueOnce({ applied: false, reason: 'not_found' });
471
+
547
472
  const { socket, ctx, sent } = createIpcStub();
548
- handler(
473
+ await handler(
549
474
  { type: 'guardian_action_decision', requestId: 'req-ghost', action: 'approve_once' } as any,
550
475
  socket as any,
551
476
  ctx as any,
@@ -555,12 +480,12 @@ describe('IPC guardian_action_decision', () => {
555
480
  expect(sent[0].reason).toBe('not_found');
556
481
  });
557
482
 
558
- test('applies decision via applyGuardianDecision for channel approval', () => {
559
- createTestApproval({ conversationId: 'conv-ipc-1', requestId: 'req-ipc-gd' });
560
- mockApplyGuardianDecision.mockReturnValueOnce({ applied: true, requestId: 'req-ipc-gd' });
483
+ test('applies decision via applyCanonicalGuardianDecision for tool approval', async () => {
484
+ createTestCanonicalRequest({ conversationId: 'conv-ipc-1', requestId: 'req-ipc-gd' });
485
+ mockApplyCanonicalGuardianDecision.mockResolvedValueOnce({ applied: true, requestId: 'req-ipc-gd', grantMinted: false });
561
486
 
562
487
  const { socket, ctx, sent } = createIpcStub();
563
- handler(
488
+ await handler(
564
489
  { type: 'guardian_action_decision', requestId: 'req-ipc-gd', action: 'approve_once' } as any,
565
490
  socket as any,
566
491
  ctx as any,
@@ -568,14 +493,14 @@ describe('IPC guardian_action_decision', () => {
568
493
  expect(sent).toHaveLength(1);
569
494
  expect(sent[0].applied).toBe(true);
570
495
  expect(sent[0].requestId).toBe('req-ipc-gd');
571
- expect(mockApplyGuardianDecision).toHaveBeenCalledTimes(1);
496
+ expect(mockApplyCanonicalGuardianDecision).toHaveBeenCalledTimes(1);
572
497
  });
573
498
 
574
- test('rejects decision when conversationId does not match approval', () => {
575
- createTestApproval({ conversationId: 'conv-ipc-correct', requestId: 'req-ipc-scope' });
499
+ test('rejects decision when conversationId does not match canonical request', async () => {
500
+ createTestCanonicalRequest({ conversationId: 'conv-ipc-correct', requestId: 'req-ipc-scope' });
576
501
 
577
502
  const { socket, ctx, sent } = createIpcStub();
578
- handler(
503
+ await handler(
579
504
  {
580
505
  type: 'guardian_action_decision',
581
506
  requestId: 'req-ipc-scope',
@@ -587,17 +512,16 @@ describe('IPC guardian_action_decision', () => {
587
512
  );
588
513
  expect(sent).toHaveLength(1);
589
514
  expect(sent[0].applied).toBe(false);
590
- expect(sent[0].reason).toBe('conversation_mismatch');
591
- expect(sent[0].requestId).toBe('req-ipc-scope');
592
- expect(mockApplyGuardianDecision).not.toHaveBeenCalled();
515
+ expect(sent[0].reason).toBe('not_found');
516
+ expect(mockApplyCanonicalGuardianDecision).not.toHaveBeenCalled();
593
517
  });
594
518
 
595
- test('allows decision when conversationId matches', () => {
596
- createTestApproval({ conversationId: 'conv-ipc-match', requestId: 'req-ipc-match' });
597
- mockApplyGuardianDecision.mockReturnValueOnce({ applied: true, requestId: 'req-ipc-match' });
519
+ test('allows decision when conversationId matches', async () => {
520
+ createTestCanonicalRequest({ conversationId: 'conv-ipc-match', requestId: 'req-ipc-match' });
521
+ mockApplyCanonicalGuardianDecision.mockResolvedValueOnce({ applied: true, requestId: 'req-ipc-match', grantMinted: false });
598
522
 
599
523
  const { socket, ctx, sent } = createIpcStub();
600
- handler(
524
+ await handler(
601
525
  {
602
526
  type: 'guardian_action_decision',
603
527
  requestId: 'req-ipc-match',
@@ -611,125 +535,57 @@ describe('IPC guardian_action_decision', () => {
611
535
  expect(sent[0].applied).toBe(true);
612
536
  });
613
537
 
614
- test('routes ingress_access_request through handleAccessRequestDecision', () => {
615
- createTestApproval({
538
+ test('applies decision for access_request kind through canonical primitive', async () => {
539
+ createTestCanonicalRequest({
616
540
  conversationId: 'conv-ipc-access',
617
541
  requestId: 'req-ipc-access',
542
+ kind: 'access_request',
618
543
  toolName: 'ingress_access_request',
619
544
  guardianExternalUserId: 'guardian-99',
620
545
  });
546
+ mockApplyCanonicalGuardianDecision.mockResolvedValueOnce({ applied: true, requestId: 'req-ipc-access', grantMinted: false });
621
547
 
622
548
  const { socket, ctx, sent } = createIpcStub();
623
- handler(
549
+ await handler(
624
550
  { type: 'guardian_action_decision', requestId: 'req-ipc-access', action: 'approve_once' } as any,
625
551
  socket as any,
626
552
  ctx as any,
627
553
  );
628
554
  expect(sent).toHaveLength(1);
629
555
  expect(sent[0].applied).toBe(true);
630
- expect(mockHandleAccessRequestDecision).toHaveBeenCalledTimes(1);
631
- // Actor is 'desktop' because this endpoint is unauthenticated —
632
- // we cannot verify the caller is the assigned guardian.
633
- const call = mockHandleAccessRequestDecision.mock.calls[0]!;
634
- expect(call[2]).toBe('desktop');
556
+ expect(mockApplyCanonicalGuardianDecision).toHaveBeenCalledTimes(1);
635
557
  });
636
558
 
637
- test('returns stale for stale access request', () => {
638
- createTestApproval({
639
- conversationId: 'conv-ipc-stale-ar',
640
- requestId: 'req-ipc-stale-ar',
641
- toolName: 'ingress_access_request',
642
- });
643
- mockHandleAccessRequestDecision.mockReturnValueOnce({
644
- handled: false,
645
- type: 'stale' as const,
646
- });
559
+ test('returns already_resolved for stale canonical request', async () => {
560
+ createTestCanonicalRequest({ conversationId: 'conv-ipc-stale', requestId: 'req-ipc-stale' });
561
+ mockApplyCanonicalGuardianDecision.mockResolvedValueOnce({ applied: false, reason: 'already_resolved' });
647
562
 
648
563
  const { socket, ctx, sent } = createIpcStub();
649
- handler(
650
- { type: 'guardian_action_decision', requestId: 'req-ipc-stale-ar', action: 'approve_once' } as any,
651
- socket as any,
652
- ctx as any,
653
- );
654
- expect(sent).toHaveLength(1);
655
- expect(sent[0].applied).toBe(false);
656
- expect(sent[0].reason).toBe('stale');
657
- expect(sent[0].requestId).toBe('req-ipc-stale-ar');
658
- });
659
-
660
- test('preserves requestId when applyGuardianDecision returns without one', () => {
661
- createTestApproval({ conversationId: 'conv-ipc-stale', requestId: 'req-ipc-stale' });
662
- mockApplyGuardianDecision.mockReturnValueOnce({ applied: false, reason: 'stale' });
663
-
664
- const { socket, ctx, sent } = createIpcStub();
665
- handler(
564
+ await handler(
666
565
  { type: 'guardian_action_decision', requestId: 'req-ipc-stale', action: 'approve_once' } as any,
667
566
  socket as any,
668
567
  ctx as any,
669
568
  );
670
569
  expect(sent).toHaveLength(1);
671
570
  expect(sent[0].requestId).toBe('req-ipc-stale');
672
- expect(sent[0].reason).toBe('stale');
673
- });
674
-
675
- test('falls back to pending interactions', () => {
676
- const fakeSession = {} as any;
677
- pendingInteractions.register('req-ipc-pi', {
678
- session: fakeSession,
679
- conversationId: 'conv-ipc-pi',
680
- kind: 'confirmation',
681
- });
682
- mockHandleChannelDecision.mockReturnValueOnce({ applied: true, requestId: 'req-ipc-pi' });
683
-
684
- const { socket, ctx, sent } = createIpcStub();
685
- handler(
686
- { type: 'guardian_action_decision', requestId: 'req-ipc-pi', action: 'approve_always' } as any,
687
- socket as any,
688
- ctx as any,
689
- );
690
- expect(sent).toHaveLength(1);
691
- expect(sent[0].applied).toBe(true);
692
- expect(mockHandleChannelDecision).toHaveBeenCalledTimes(1);
693
- });
694
-
695
- test('rejects interaction fallback when conversationId mismatches', () => {
696
- const fakeSession = {} as any;
697
- pendingInteractions.register('req-ipc-pi-scope', {
698
- session: fakeSession,
699
- conversationId: 'conv-ipc-pi-right',
700
- kind: 'confirmation',
701
- });
702
-
703
- const { socket, ctx, sent } = createIpcStub();
704
- handler(
705
- {
706
- type: 'guardian_action_decision',
707
- requestId: 'req-ipc-pi-scope',
708
- action: 'approve_once',
709
- conversationId: 'conv-ipc-pi-wrong',
710
- } as any,
711
- socket as any,
712
- ctx as any,
713
- );
714
- expect(sent).toHaveLength(1);
715
- expect(sent[0].applied).toBe(false);
716
- expect(sent[0].reason).toBe('conversation_mismatch');
717
- expect(mockHandleChannelDecision).not.toHaveBeenCalled();
571
+ expect(sent[0].reason).toBe('already_resolved');
718
572
  });
719
573
 
720
- test('passes actorExternalUserId as undefined (unauthenticated endpoint)', () => {
721
- createTestApproval({ conversationId: 'conv-ipc-actor', requestId: 'req-ipc-actor' });
722
- mockApplyGuardianDecision.mockReturnValueOnce({ applied: true, requestId: 'req-ipc-actor' });
574
+ test('passes actorContext with undefined externalUserId (unauthenticated endpoint)', async () => {
575
+ createTestCanonicalRequest({ conversationId: 'conv-ipc-actor', requestId: 'req-ipc-actor' });
576
+ mockApplyCanonicalGuardianDecision.mockResolvedValueOnce({ applied: true, requestId: 'req-ipc-actor', grantMinted: false });
723
577
 
724
578
  const { socket, ctx } = createIpcStub();
725
- handler(
579
+ await handler(
726
580
  { type: 'guardian_action_decision', requestId: 'req-ipc-actor', action: 'approve_once' } as any,
727
581
  socket as any,
728
582
  ctx as any,
729
583
  );
730
- const call = mockApplyGuardianDecision.mock.calls[0]![0] as Record<string, unknown>;
731
- expect(call.actorExternalUserId).toBeUndefined();
732
- expect(call.actorChannel).toBe('vellum');
584
+ const call = mockApplyCanonicalGuardianDecision.mock.calls[0]![0] as Record<string, unknown>;
585
+ const actorContext = call.actorContext as Record<string, unknown>;
586
+ expect(actorContext.externalUserId).toBeUndefined();
587
+ expect(actorContext.channel).toBe('vellum');
588
+ expect(actorContext.isTrusted).toBe(true);
733
589
  });
734
590
  });
735
591
 
@@ -743,7 +599,11 @@ describe('IPC guardian_actions_pending_request', () => {
743
599
  const handler = guardianActionsHandlers.guardian_actions_pending_request;
744
600
 
745
601
  test('returns prompts for a conversation', () => {
746
- createTestApproval({ conversationId: 'conv-ipc-list', requestId: 'req-ipc-list', reason: 'Run bash: pwd' });
602
+ createTestCanonicalRequest({
603
+ conversationId: 'conv-ipc-list',
604
+ requestId: 'req-ipc-list',
605
+ questionText: 'Run bash: pwd',
606
+ });
747
607
 
748
608
  const { socket, ctx, sent } = createIpcStub();
749
609
  handler(
@@ -760,7 +620,7 @@ describe('IPC guardian_actions_pending_request', () => {
760
620
  expect(prompts[0].questionText).toBe('Run bash: pwd');
761
621
  });
762
622
 
763
- test('returns empty prompts for conversation with no pending approvals', () => {
623
+ test('returns empty prompts for conversation with no pending requests', () => {
764
624
  const { socket, ctx, sent } = createIpcStub();
765
625
  handler(
766
626
  { type: 'guardian_actions_pending_request', conversationId: 'conv-empty-ipc' } as any,