@vellumai/assistant 0.4.2 → 0.4.4

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 (221) hide show
  1. package/.env.example +3 -0
  2. package/ARCHITECTURE.md +124 -10
  3. package/README.md +43 -35
  4. package/docs/trusted-contact-access.md +20 -0
  5. package/package.json +1 -1
  6. package/scripts/ipc/generate-swift.ts +1 -0
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +58 -120
  8. package/src/__tests__/access-request-decision.test.ts +0 -1
  9. package/src/__tests__/actor-token-service.test.ts +1099 -0
  10. package/src/__tests__/agent-loop.test.ts +51 -0
  11. package/src/__tests__/approval-routes-http.test.ts +2 -0
  12. package/src/__tests__/assistant-events-sse-hardening.test.ts +7 -5
  13. package/src/__tests__/assistant-id-boundary-guard.test.ts +415 -0
  14. package/src/__tests__/call-controller.test.ts +49 -0
  15. package/src/__tests__/call-pointer-message-composer.test.ts +171 -0
  16. package/src/__tests__/call-pointer-messages.test.ts +93 -3
  17. package/src/__tests__/call-pointer-no-hardcoded-copy.guard.test.ts +42 -0
  18. package/src/__tests__/call-routes-http.test.ts +0 -25
  19. package/src/__tests__/callback-handoff-copy.test.ts +186 -0
  20. package/src/__tests__/channel-approval-routes.test.ts +133 -12
  21. package/src/__tests__/channel-guardian.test.ts +0 -86
  22. package/src/__tests__/channel-readiness-service.test.ts +10 -16
  23. package/src/__tests__/checker.test.ts +33 -12
  24. package/src/__tests__/config-schema.test.ts +6 -0
  25. package/src/__tests__/confirmation-request-guardian-bridge.test.ts +410 -0
  26. package/src/__tests__/conversation-routes-guardian-reply.test.ts +256 -0
  27. package/src/__tests__/conversation-routes.test.ts +12 -3
  28. package/src/__tests__/credential-security-invariants.test.ts +1 -1
  29. package/src/__tests__/daemon-server-session-init.test.ts +4 -0
  30. package/src/__tests__/deterministic-verification-control-plane.test.ts +0 -1
  31. package/src/__tests__/guardian-actions-endpoint.test.ts +39 -13
  32. package/src/__tests__/guardian-dispatch.test.ts +8 -0
  33. package/src/__tests__/guardian-outbound-http.test.ts +4 -5
  34. package/src/__tests__/guardian-question-mode.test.ts +200 -0
  35. package/src/__tests__/guardian-routing-invariants.test.ts +178 -0
  36. package/src/__tests__/guardian-routing-state.test.ts +525 -0
  37. package/src/__tests__/handle-user-message-secret-resume.test.ts +2 -0
  38. package/src/__tests__/handlers-telegram-config.test.ts +0 -83
  39. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +55 -0
  40. package/src/__tests__/headless-browser-navigate.test.ts +2 -0
  41. package/src/__tests__/inbound-invite-redemption.test.ts +0 -1
  42. package/src/__tests__/ingress-routes-http.test.ts +55 -0
  43. package/src/__tests__/ipc-snapshot.test.ts +18 -51
  44. package/src/__tests__/non-member-access-request.test.ts +159 -9
  45. package/src/__tests__/notification-decision-fallback.test.ts +129 -4
  46. package/src/__tests__/notification-decision-strategy.test.ts +106 -2
  47. package/src/__tests__/notification-guardian-path.test.ts +3 -0
  48. package/src/__tests__/recording-intent-handler.test.ts +1 -0
  49. package/src/__tests__/relay-server.test.ts +1475 -33
  50. package/src/__tests__/send-endpoint-busy.test.ts +5 -0
  51. package/src/__tests__/session-agent-loop.test.ts +1 -0
  52. package/src/__tests__/session-confirmation-signals.test.ts +523 -0
  53. package/src/__tests__/session-init.benchmark.test.ts +0 -2
  54. package/src/__tests__/session-runtime-assembly.test.ts +4 -1
  55. package/src/__tests__/session-surfaces-task-progress.test.ts +44 -1
  56. package/src/__tests__/session-tool-setup-app-refresh.test.ts +81 -2
  57. package/src/__tests__/session-tool-setup-memory-scope.test.ts +1 -1
  58. package/src/__tests__/session-tool-setup-side-effect-flag.test.ts +1 -1
  59. package/src/__tests__/tool-executor.test.ts +21 -2
  60. package/src/__tests__/tool-grant-request-escalation.test.ts +333 -27
  61. package/src/__tests__/trusted-contact-approval-notifier.test.ts +678 -0
  62. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +1064 -0
  63. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +11 -1
  64. package/src/__tests__/trusted-contact-multichannel.test.ts +0 -1
  65. package/src/__tests__/trusted-contact-verification.test.ts +0 -1
  66. package/src/__tests__/twilio-config.test.ts +2 -13
  67. package/src/__tests__/twilio-routes.test.ts +4 -3
  68. package/src/__tests__/update-bulletin.test.ts +0 -1
  69. package/src/agent/loop.ts +1 -1
  70. package/src/approvals/guardian-decision-primitive.ts +12 -3
  71. package/src/approvals/guardian-request-resolvers.ts +169 -11
  72. package/src/calls/call-constants.ts +29 -0
  73. package/src/calls/call-controller.ts +11 -3
  74. package/src/calls/call-domain.ts +33 -11
  75. package/src/calls/call-pointer-message-composer.ts +154 -0
  76. package/src/calls/call-pointer-messages.ts +106 -27
  77. package/src/calls/guardian-dispatch.ts +4 -2
  78. package/src/calls/relay-server.ts +921 -112
  79. package/src/calls/twilio-config.ts +4 -11
  80. package/src/calls/twilio-routes.ts +4 -6
  81. package/src/calls/types.ts +3 -1
  82. package/src/calls/voice-session-bridge.ts +4 -3
  83. package/src/cli/core-commands.ts +7 -4
  84. package/src/cli.ts +5 -4
  85. package/src/config/bundled-skills/agentmail/SKILL.md +4 -0
  86. package/src/config/bundled-skills/app-builder/SKILL.md +309 -10
  87. package/src/config/bundled-skills/app-builder/TOOLS.json +1 -1
  88. package/src/config/bundled-skills/email-setup/SKILL.md +1 -1
  89. package/src/config/bundled-skills/google-oauth-setup/SKILL.md +105 -81
  90. package/src/config/bundled-skills/messaging/SKILL.md +61 -12
  91. package/src/config/bundled-skills/messaging/TOOLS.json +58 -0
  92. package/src/config/bundled-skills/messaging/tools/gmail-sender-digest.ts +6 -1
  93. package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +35 -0
  94. package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +52 -0
  95. package/src/config/bundled-skills/phone-calls/SKILL.md +30 -39
  96. package/src/config/bundled-skills/twitter/SKILL.md +3 -3
  97. package/src/config/bundled-skills/vercel-token-setup/SKILL.md +215 -0
  98. package/src/config/calls-schema.ts +36 -0
  99. package/src/config/env.ts +22 -0
  100. package/src/config/feature-flag-registry.json +8 -8
  101. package/src/config/schema.ts +2 -2
  102. package/src/config/skills.ts +11 -0
  103. package/src/config/system-prompt.ts +11 -1
  104. package/src/config/templates/SOUL.md +2 -0
  105. package/src/config/vellum-skills/sms-setup/SKILL.md +71 -82
  106. package/src/config/vellum-skills/trusted-contacts/SKILL.md +8 -1
  107. package/src/config/vellum-skills/twilio-setup/SKILL.md +88 -73
  108. package/src/daemon/call-pointer-generators.ts +59 -0
  109. package/src/daemon/computer-use-session.ts +2 -5
  110. package/src/daemon/handlers/apps.ts +76 -20
  111. package/src/daemon/handlers/config-channels.ts +9 -61
  112. package/src/daemon/handlers/config-inbox.ts +11 -3
  113. package/src/daemon/handlers/config-ingress.ts +28 -3
  114. package/src/daemon/handlers/config-telegram.ts +12 -0
  115. package/src/daemon/handlers/config.ts +2 -6
  116. package/src/daemon/handlers/index.ts +2 -1
  117. package/src/daemon/handlers/pairing.ts +2 -0
  118. package/src/daemon/handlers/publish.ts +11 -46
  119. package/src/daemon/handlers/sessions.ts +59 -5
  120. package/src/daemon/handlers/shared.ts +17 -2
  121. package/src/daemon/ipc-contract/apps.ts +1 -0
  122. package/src/daemon/ipc-contract/inbox.ts +4 -0
  123. package/src/daemon/ipc-contract/integrations.ts +1 -97
  124. package/src/daemon/ipc-contract/messages.ts +47 -1
  125. package/src/daemon/ipc-contract/notifications.ts +11 -0
  126. package/src/daemon/ipc-contract-inventory.json +2 -4
  127. package/src/daemon/lifecycle.ts +17 -0
  128. package/src/daemon/server.ts +16 -2
  129. package/src/daemon/session-agent-loop-handlers.ts +20 -0
  130. package/src/daemon/session-agent-loop.ts +24 -12
  131. package/src/daemon/session-lifecycle.ts +1 -1
  132. package/src/daemon/session-process.ts +11 -1
  133. package/src/daemon/session-runtime-assembly.ts +6 -1
  134. package/src/daemon/session-surfaces.ts +32 -3
  135. package/src/daemon/session.ts +88 -1
  136. package/src/daemon/tool-side-effects.ts +22 -0
  137. package/src/home-base/prebuilt/brain-graph.html +1483 -0
  138. package/src/home-base/prebuilt/index.html +40 -0
  139. package/src/inbound/platform-callback-registration.ts +157 -0
  140. package/src/memory/canonical-guardian-store.ts +1 -1
  141. package/src/memory/conversation-crud.ts +2 -1
  142. package/src/memory/conversation-title-service.ts +16 -2
  143. package/src/memory/db-init.ts +8 -0
  144. package/src/memory/delivery-crud.ts +2 -1
  145. package/src/memory/guardian-action-store.ts +2 -1
  146. package/src/memory/guardian-approvals.ts +3 -2
  147. package/src/memory/ingress-invite-store.ts +12 -2
  148. package/src/memory/ingress-member-store.ts +4 -3
  149. package/src/memory/migrations/038-actor-token-records.ts +39 -0
  150. package/src/memory/migrations/124-voice-invite-display-metadata.ts +14 -0
  151. package/src/memory/migrations/index.ts +2 -0
  152. package/src/memory/schema.ts +26 -5
  153. package/src/messaging/provider-types.ts +24 -0
  154. package/src/messaging/provider.ts +7 -0
  155. package/src/messaging/providers/gmail/adapter.ts +127 -0
  156. package/src/messaging/providers/sms/adapter.ts +40 -37
  157. package/src/notifications/adapters/macos.ts +45 -2
  158. package/src/notifications/broadcaster.ts +16 -0
  159. package/src/notifications/copy-composer.ts +50 -2
  160. package/src/notifications/decision-engine.ts +22 -9
  161. package/src/notifications/destination-resolver.ts +16 -2
  162. package/src/notifications/emit-signal.ts +18 -9
  163. package/src/notifications/guardian-question-mode.ts +419 -0
  164. package/src/notifications/signal.ts +14 -3
  165. package/src/permissions/checker.ts +13 -1
  166. package/src/permissions/prompter.ts +14 -0
  167. package/src/providers/anthropic/client.ts +20 -0
  168. package/src/providers/provider-send-message.ts +15 -3
  169. package/src/runtime/access-request-helper.ts +82 -4
  170. package/src/runtime/actor-token-service.ts +234 -0
  171. package/src/runtime/actor-token-store.ts +236 -0
  172. package/src/runtime/actor-trust-resolver.ts +2 -2
  173. package/src/runtime/assistant-scope.ts +10 -0
  174. package/src/runtime/channel-approvals.ts +5 -3
  175. package/src/runtime/channel-readiness-service.ts +23 -64
  176. package/src/runtime/channel-readiness-types.ts +3 -4
  177. package/src/runtime/channel-retry-sweep.ts +4 -1
  178. package/src/runtime/confirmation-request-guardian-bridge.ts +197 -0
  179. package/src/runtime/guardian-action-followup-executor.ts +1 -1
  180. package/src/runtime/guardian-context-resolver.ts +82 -0
  181. package/src/runtime/guardian-outbound-actions.ts +5 -7
  182. package/src/runtime/guardian-reply-router.ts +67 -30
  183. package/src/runtime/guardian-vellum-migration.ts +57 -0
  184. package/src/runtime/http-server.ts +75 -31
  185. package/src/runtime/http-types.ts +13 -0
  186. package/src/runtime/ingress-service.ts +14 -0
  187. package/src/runtime/invite-redemption-service.ts +10 -1
  188. package/src/runtime/local-actor-identity.ts +76 -0
  189. package/src/runtime/middleware/actor-token.ts +271 -0
  190. package/src/runtime/middleware/twilio-validation.ts +2 -4
  191. package/src/runtime/routes/approval-routes.ts +82 -7
  192. package/src/runtime/routes/brain-graph-routes.ts +222 -0
  193. package/src/runtime/routes/call-routes.ts +2 -1
  194. package/src/runtime/routes/channel-readiness-routes.ts +71 -0
  195. package/src/runtime/routes/channel-route-shared.ts +3 -3
  196. package/src/runtime/routes/conversation-attention-routes.ts +2 -1
  197. package/src/runtime/routes/conversation-routes.ts +142 -53
  198. package/src/runtime/routes/events-routes.ts +22 -8
  199. package/src/runtime/routes/guardian-action-routes.ts +45 -3
  200. package/src/runtime/routes/guardian-approval-interception.ts +29 -0
  201. package/src/runtime/routes/guardian-bootstrap-routes.ts +145 -0
  202. package/src/runtime/routes/inbound-conversation.ts +4 -3
  203. package/src/runtime/routes/inbound-message-handler.ts +147 -5
  204. package/src/runtime/routes/ingress-routes.ts +2 -0
  205. package/src/runtime/routes/integration-routes.ts +7 -15
  206. package/src/runtime/routes/pairing-routes.ts +163 -0
  207. package/src/runtime/routes/twilio-routes.ts +934 -0
  208. package/src/runtime/tool-grant-request-helper.ts +3 -1
  209. package/src/security/oauth2.ts +27 -2
  210. package/src/security/token-manager.ts +46 -10
  211. package/src/tools/browser/browser-execution.ts +4 -3
  212. package/src/tools/browser/browser-handoff.ts +10 -18
  213. package/src/tools/browser/browser-manager.ts +80 -25
  214. package/src/tools/browser/browser-screencast.ts +35 -119
  215. package/src/tools/calls/call-start.ts +2 -1
  216. package/src/tools/permission-checker.ts +15 -4
  217. package/src/tools/terminal/parser.ts +12 -0
  218. package/src/tools/tool-approval-handler.ts +244 -19
  219. package/src/workspace/git-service.ts +19 -0
  220. package/src/__tests__/handlers-twilio-config.test.ts +0 -1928
  221. package/src/daemon/handlers/config-twilio.ts +0 -1082
@@ -62,7 +62,7 @@ function makeCtx(overrides: Partial<ToolSetupContext> = {}): ToolSetupContext {
62
62
  sendToClient: mock(() => {}),
63
63
  pendingSurfaceActions: new Map(),
64
64
  lastSurfaceAction: new Map(),
65
- surfaceState: new Map<string, { surfaceType: SurfaceType; data: SurfaceData }>(),
65
+ surfaceState: new Map<string, { surfaceType: SurfaceType; data: SurfaceData; title?: string }>(),
66
66
  surfaceUndoStacks: new Map(),
67
67
  currentTurnSurfaces: [],
68
68
  isProcessing: () => false,
@@ -392,6 +392,85 @@ describe('session-tool-setup app refresh side effects', () => {
392
392
  });
393
393
  });
394
394
 
395
+ // ── app_create side effects ─────────────────────────────────────────
396
+
397
+ describe('app_create side effects', () => {
398
+ test('broadcasts app_files_changed after app_create', async () => {
399
+ const ctx = makeCtx();
400
+ const executor = makeFakeExecutor({
401
+ content: JSON.stringify({ id: 'new-app-1', name: 'My App' }),
402
+ isError: false,
403
+ });
404
+ const broadcastSpy = mock(() => {});
405
+
406
+ const toolFn = createToolExecutor(
407
+ executor as unknown as ToolExecutor, noopPrompter, noopSecretPrompter,
408
+ ctx, noopLifecycleHandler, broadcastSpy,
409
+ );
410
+
411
+ await toolFn('app_create', { name: 'My App', html: '<h1>hi</h1>' });
412
+
413
+ expect(broadcastSpy).toHaveBeenCalledTimes(1);
414
+ expect((broadcastSpy.mock.calls as unknown[][])[0][0]).toEqual({
415
+ type: 'app_files_changed',
416
+ appId: 'new-app-1',
417
+ });
418
+ });
419
+
420
+ test('skips side effects when app_create result is an error', async () => {
421
+ const ctx = makeCtx();
422
+ const executor = makeFakeExecutor({ content: 'Error', isError: true });
423
+ const broadcastSpy = mock(() => {});
424
+
425
+ const toolFn = createToolExecutor(
426
+ executor as unknown as ToolExecutor, noopPrompter, noopSecretPrompter,
427
+ ctx, noopLifecycleHandler, broadcastSpy,
428
+ );
429
+
430
+ await toolFn('app_create', { name: 'Bad', html: '' });
431
+
432
+ expect(broadcastSpy).not.toHaveBeenCalled();
433
+ });
434
+ });
435
+
436
+ // ── app_delete side effects ────────────────────────────────────────
437
+
438
+ describe('app_delete side effects', () => {
439
+ test('broadcasts app_files_changed after app_delete', async () => {
440
+ const ctx = makeCtx();
441
+ const executor = makeFakeExecutor({ content: '{}', isError: false });
442
+ const broadcastSpy = mock(() => {});
443
+
444
+ const toolFn = createToolExecutor(
445
+ executor as unknown as ToolExecutor, noopPrompter, noopSecretPrompter,
446
+ ctx, noopLifecycleHandler, broadcastSpy,
447
+ );
448
+
449
+ await toolFn('app_delete', { app_id: 'del-app-1' });
450
+
451
+ expect(broadcastSpy).toHaveBeenCalledTimes(1);
452
+ expect((broadcastSpy.mock.calls as unknown[][])[0][0]).toEqual({
453
+ type: 'app_files_changed',
454
+ appId: 'del-app-1',
455
+ });
456
+ });
457
+
458
+ test('skips side effects when app_delete result is an error', async () => {
459
+ const ctx = makeCtx();
460
+ const executor = makeFakeExecutor({ content: 'Error', isError: true });
461
+ const broadcastSpy = mock(() => {});
462
+
463
+ const toolFn = createToolExecutor(
464
+ executor as unknown as ToolExecutor, noopPrompter, noopSecretPrompter,
465
+ ctx, noopLifecycleHandler, broadcastSpy,
466
+ );
467
+
468
+ await toolFn('app_delete', { app_id: 'del-err' });
469
+
470
+ expect(broadcastSpy).not.toHaveBeenCalled();
471
+ });
472
+ });
473
+
395
474
  // ── Name-based hook targeting (skill-origin tools) ──────────────────
396
475
 
397
476
  describe('name-based hooks fire for skill-origin tools', () => {
@@ -437,7 +516,7 @@ describe('session-tool-setup app refresh side effects', () => {
437
516
  ctx, noopLifecycleHandler, broadcastSpy,
438
517
  );
439
518
 
440
- for (const toolName of ['read_file', 'write_file', 'shell', 'app_create', 'app_list', 'app_delete']) {
519
+ for (const toolName of ['read_file', 'write_file', 'shell', 'app_list']) {
441
520
  refreshSpy.mockClear();
442
521
  broadcastSpy.mockClear();
443
522
  updatePublishedSpy.mockClear();
@@ -49,7 +49,7 @@ function makeCtx(overrides: Partial<ToolSetupContext> = {}): ToolSetupContext {
49
49
  sendToClient: mock(() => {}),
50
50
  pendingSurfaceActions: new Map(),
51
51
  lastSurfaceAction: new Map(),
52
- surfaceState: new Map<string, { surfaceType: SurfaceType; data: SurfaceData }>(),
52
+ surfaceState: new Map<string, { surfaceType: SurfaceType; data: SurfaceData; title?: string }>(),
53
53
  surfaceUndoStacks: new Map(),
54
54
  currentTurnSurfaces: [],
55
55
  isProcessing: () => false,
@@ -49,7 +49,7 @@ function makeCtx(overrides: Partial<ToolSetupContext> = {}): ToolSetupContext {
49
49
  sendToClient: mock(() => {}),
50
50
  pendingSurfaceActions: new Map(),
51
51
  lastSurfaceAction: new Map(),
52
- surfaceState: new Map<string, { surfaceType: SurfaceType; data: SurfaceData }>(),
52
+ surfaceState: new Map<string, { surfaceType: SurfaceType; data: SurfaceData; title?: string }>(),
53
53
  surfaceUndoStacks: new Map(),
54
54
  currentTurnSurfaces: [],
55
55
  isProcessing: () => false,
@@ -30,6 +30,9 @@ let checkResultOverride: { decision: string; reason: string } | undefined;
30
30
  /** Function override for check() — when set, takes precedence over the static override. */
31
31
  let checkFnOverride: ((toolName: string, input: Record<string, unknown>, workingDir: string, policyContext?: PolicyContext) => Promise<{ decision: string; reason: string }>) | undefined;
32
32
 
33
+ /** Override for generateScopeOptions — when set, returns this value instead of the default. */
34
+ let scopeOptionsOverride: ScopeOption[] | undefined;
35
+
33
36
  /** Spy on addRule to capture calls without replacing the real implementation. */
34
37
  let addRuleSpy: ReturnType<typeof spyOn> | undefined;
35
38
 
@@ -61,7 +64,7 @@ mock.module('../permissions/checker.js', () => ({
61
64
  return { decision: 'allow', reason: 'allowed' };
62
65
  },
63
66
  generateAllowlistOptions: () => [{ label: 'exact', description: 'exact', pattern: 'exact' }],
64
- generateScopeOptions: () => [{ label: '/tmp', scope: '/tmp' }],
67
+ generateScopeOptions: () => scopeOptionsOverride ?? [{ label: '/tmp', scope: '/tmp' }],
65
68
  }));
66
69
 
67
70
  mock.module('../memory/tool-usage-store.js', () => ({
@@ -317,6 +320,7 @@ describe('ToolExecutor contextual rule creation', () => {
317
320
  getToolOverride = undefined;
318
321
  checkResultOverride = undefined;
319
322
  checkFnOverride = undefined;
323
+ scopeOptionsOverride = undefined;
320
324
  if (addRuleSpy) { addRuleSpy.mockRestore(); addRuleSpy = undefined; }
321
325
  });
322
326
 
@@ -434,7 +438,7 @@ describe('ToolExecutor contextual rule creation', () => {
434
438
  expect(spy).not.toHaveBeenCalled();
435
439
  });
436
440
 
437
- test('always_allow without selectedScope does not create a rule', async () => {
441
+ test('always_allow without selectedScope for scoped tool does not create a rule', async () => {
438
442
  checkResultOverride = { decision: 'prompt', reason: 'test prompt' };
439
443
  const spy = setupAddRuleSpy();
440
444
 
@@ -446,6 +450,21 @@ describe('ToolExecutor contextual rule creation', () => {
446
450
  expect(spy).not.toHaveBeenCalled();
447
451
  });
448
452
 
453
+ test('always_allow without selectedScope for non-scoped tool creates rule with everywhere scope', async () => {
454
+ checkResultOverride = { decision: 'prompt', reason: 'test prompt' };
455
+ scopeOptionsOverride = [];
456
+ const spy = setupAddRuleSpy();
457
+
458
+ const prompter = makePrompterWithDecision('always_allow', 'some_tool:*', undefined);
459
+ const executor = new ToolExecutor(prompter);
460
+ const result = await executor.execute('some_tool', {}, makeContext());
461
+
462
+ expect(result.isError).toBe(false);
463
+ expect(spy).toHaveBeenCalledTimes(1);
464
+ const [, , scope] = spy.mock.calls[0];
465
+ expect(scope).toBe('everywhere');
466
+ });
467
+
449
468
  test('always_allow_high_risk for core tool sets allowHighRisk without execution target', async () => {
450
469
  checkResultOverride = { decision: 'prompt', reason: 'test prompt' };
451
470
  const spy = setupAddRuleSpy();
@@ -5,6 +5,7 @@
5
5
  * 2. tool_grant_request resolver registration and behavior
6
6
  * 3. Canonical decision primitive grant minting for tool_grant_request kind
7
7
  * 4. End-to-end: deny -> approve -> consume grant flow
8
+ * 5. Inline wait-and-resume for trusted-contact grant-gated tools
8
9
  */
9
10
 
10
11
  import { mkdtempSync, rmSync } from 'node:fs';
@@ -115,9 +116,12 @@ import {
115
116
  import { getDb, initializeDb, resetDb } from '../memory/db.js';
116
117
  import { scopedApprovalGrants } from '../memory/schema.js';
117
118
  import { computeToolApprovalDigest } from '../security/tool-approval-digest.js';
118
- import { ToolApprovalHandler } from '../tools/tool-approval-handler.js';
119
+ import { ToolApprovalHandler, waitForInlineGrant } from '../tools/tool-approval-handler.js';
119
120
  import type { ToolContext, ToolLifecycleEvent } from '../tools/types.js';
120
121
 
122
+ /** Short wait config for tests — avoids blocking test suite on the 60s default. */
123
+ const TEST_INLINE_WAIT_CONFIG = { maxWaitMs: 100, intervalMs: 20 };
124
+
121
125
  initializeDb();
122
126
 
123
127
  function resetTables(): void {
@@ -189,7 +193,7 @@ describe('tool_grant_request resolver registration', () => {
189
193
  // ---------------------------------------------------------------------------
190
194
 
191
195
  describe('ToolApprovalHandler / grant-miss escalation', () => {
192
- const handler = new ToolApprovalHandler();
196
+ const handler = new ToolApprovalHandler({ inlineGrantWait: TEST_INLINE_WAIT_CONFIG });
193
197
  const events: ToolLifecycleEvent[] = [];
194
198
  const emitLifecycleEvent = (event: ToolLifecycleEvent) => { events.push(event); };
195
199
 
@@ -225,9 +229,11 @@ describe('ToolApprovalHandler / grant-miss escalation', () => {
225
229
  // Notification signal should have been emitted
226
230
  expect(emittedSignals.length).toBe(1);
227
231
  expect(emittedSignals[0].sourceEventName).toBe('guardian.question');
232
+ const payload = emittedSignals[0].contextPayload as Record<string, unknown>;
233
+ expect(payload.requestKind).toBe('tool_grant_request');
228
234
  });
229
235
 
230
- test('non-guardian grant-miss response includes request code', async () => {
236
+ test('non-guardian grant-miss response includes request code after timeout', async () => {
231
237
  const toolName = 'bash';
232
238
  const input = { command: 'deploy' };
233
239
 
@@ -238,9 +244,10 @@ describe('ToolApprovalHandler / grant-miss escalation', () => {
238
244
 
239
245
  expect(result.allowed).toBe(false);
240
246
  if (result.allowed) return;
241
- expect(result.result.content).toContain('request has been sent to the guardian');
247
+ // After inline wait times out, the message should include the request code
248
+ // and indicate the guardian did not approve in time.
249
+ expect(result.result.content).toContain('guardian approval was not received in time');
242
250
  expect(result.result.content).toContain('request code:');
243
- expect(result.result.content).toContain('Please retry after the guardian approves');
244
251
  });
245
252
 
246
253
  test('non-guardian duplicate grant-miss deduplicates the request', async () => {
@@ -249,7 +256,7 @@ describe('ToolApprovalHandler / grant-miss escalation', () => {
249
256
 
250
257
  const context = makeContext({ guardianTrustClass: 'trusted_contact' });
251
258
 
252
- // First invocation creates the request
259
+ // First invocation creates the request (and waits, then times out)
253
260
  await handler.checkPreExecutionGates(
254
261
  toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
255
262
  );
@@ -263,14 +270,15 @@ describe('ToolApprovalHandler / grant-miss escalation', () => {
263
270
  // Reset notification tracking
264
271
  emittedSignals.length = 0;
265
272
 
266
- // Second invocation with same tool+input deduplicates
273
+ // Second invocation with same tool+input deduplicates (reuses the request, waits, times out)
267
274
  const result = await handler.checkPreExecutionGates(
268
275
  toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
269
276
  );
270
277
 
271
278
  expect(result.allowed).toBe(false);
272
279
  if (result.allowed) return;
273
- expect(result.result.content).toContain('already pending');
280
+ // Both calls get the timeout message since inline wait now runs for deduped requests too
281
+ expect(result.result.content).toContain('guardian approval was not received in time');
274
282
 
275
283
  // Still only one canonical request
276
284
  const requests = listCanonicalGuardianRequests({
@@ -434,7 +442,8 @@ describe('applyCanonicalGuardianDecision / tool_grant_request', () => {
434
442
  // ---------------------------------------------------------------------------
435
443
 
436
444
  describe('end-to-end: tool grant escalation -> approval -> consume', () => {
437
- const handler = new ToolApprovalHandler();
445
+ // Use a wider wait window so the delayed guardian approval arrives in time
446
+ const handler = new ToolApprovalHandler({ inlineGrantWait: { maxWaitMs: 2_000, intervalMs: 20 } });
438
447
  const events: ToolLifecycleEvent[] = [];
439
448
  const emitLifecycleEvent = (event: ToolLifecycleEvent) => { events.push(event); };
440
449
 
@@ -444,54 +453,351 @@ describe('end-to-end: tool grant escalation -> approval -> consume', () => {
444
453
  emittedSignals.length = 0;
445
454
  });
446
455
 
447
- test('first invocation denied + request created; guardian approves; second invocation succeeds; replay denied', async () => {
456
+ test('inline wait: guardian approves during wait -> tool proceeds inline', async () => {
457
+ const toolName = 'bash';
458
+ const input = { command: 'echo secret' };
459
+ const _inputDigest = computeToolApprovalDigest(toolName, input);
460
+
461
+ const context = makeContext({ guardianTrustClass: 'trusted_contact' });
462
+
463
+ // Schedule guardian approval after 100ms — within the 2s wait window.
464
+ // The approval happens asynchronously while checkPreExecutionGates is
465
+ // polling for the grant.
466
+ const approvalPromise = (async () => {
467
+ await new Promise((r) => setTimeout(r, 100));
468
+ const pendingRequests = listCanonicalGuardianRequests({
469
+ kind: 'tool_grant_request',
470
+ status: 'pending',
471
+ toolName: 'bash',
472
+ });
473
+ if (pendingRequests.length === 0) return;
474
+ await applyCanonicalGuardianDecision({
475
+ requestId: pendingRequests[0].id,
476
+ action: 'approve_once',
477
+ actorContext: guardianActor(),
478
+ });
479
+ })();
480
+
481
+ const result = await handler.checkPreExecutionGates(
482
+ toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
483
+ );
484
+
485
+ await approvalPromise;
486
+
487
+ // The tool invocation should have succeeded inline
488
+ expect(result.allowed).toBe(true);
489
+ if (!result.allowed) return;
490
+ expect(result.grantConsumed).toBe(true);
491
+
492
+ // Replay is denied (one-time grant semantics)
493
+ const replayResult = await handler.checkPreExecutionGates(
494
+ toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
495
+ );
496
+ expect(replayResult.allowed).toBe(false);
497
+ });
498
+
499
+ test('pre-existing grant from prior approval is consumed immediately (no wait)', async () => {
448
500
  const toolName = 'bash';
449
501
  const input = { command: 'echo secret' };
450
502
  const _inputDigest = computeToolApprovalDigest(toolName, input);
451
503
 
452
504
  const context = makeContext({ guardianTrustClass: 'trusted_contact' });
453
505
 
454
- // Step 1: First invocation is denied, but a tool_grant_request is created
455
- const firstResult = await handler.checkPreExecutionGates(
506
+ // Step 1: First invocation times out (short wait, no approval)
507
+ const shortHandler = new ToolApprovalHandler({ inlineGrantWait: TEST_INLINE_WAIT_CONFIG });
508
+ await shortHandler.checkPreExecutionGates(
456
509
  toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
457
510
  );
458
- expect(firstResult.allowed).toBe(false);
459
511
 
460
- // Verify the canonical request was created
512
+ // Verify request was created
461
513
  const pendingRequests = listCanonicalGuardianRequests({
462
514
  kind: 'tool_grant_request',
463
515
  status: 'pending',
464
516
  toolName: 'bash',
465
517
  });
466
518
  expect(pendingRequests.length).toBe(1);
467
- const canonicalRequestId = pendingRequests[0].id;
468
519
 
469
- // Step 2: Guardian approves the canonical request -> grant is minted
520
+ // Step 2: Guardian approves the request (mints a grant)
470
521
  const approvalResult = await applyCanonicalGuardianDecision({
471
- requestId: canonicalRequestId,
522
+ requestId: pendingRequests[0].id,
472
523
  action: 'approve_once',
473
524
  actorContext: guardianActor(),
474
525
  });
475
526
  expect(approvalResult.applied).toBe(true);
476
- if (!approvalResult.applied) return;
477
- expect(approvalResult.grantMinted).toBe(true);
478
527
 
479
- // Verify request is now approved
480
- const resolvedRequest = getCanonicalGuardianRequest(canonicalRequestId);
481
- expect(resolvedRequest!.status).toBe('approved');
482
-
483
- // Step 3: Second identical invocation consumes the grant and succeeds
528
+ // Step 3: Second invocation finds the pre-existing grant immediately
529
+ const start = Date.now();
484
530
  const secondResult = await handler.checkPreExecutionGates(
485
531
  toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
486
532
  );
533
+ const elapsed = Date.now() - start;
534
+
487
535
  expect(secondResult.allowed).toBe(true);
488
536
  if (!secondResult.allowed) return;
489
537
  expect(secondResult.grantConsumed).toBe(true);
538
+ // Should be nearly instant since the grant already exists
539
+ expect(elapsed).toBeLessThan(500);
540
+ });
541
+ });
490
542
 
491
- // Step 4: Replay is denied (one-time grant semantics)
492
- const replayResult = await handler.checkPreExecutionGates(
543
+ // ---------------------------------------------------------------------------
544
+ // 5. Inline wait-and-resume for trusted-contact grant-gated tools
545
+ // ---------------------------------------------------------------------------
546
+
547
+ describe('inline wait-and-resume', () => {
548
+ const events: ToolLifecycleEvent[] = [];
549
+ const emitLifecycleEvent = (event: ToolLifecycleEvent) => { events.push(event); };
550
+
551
+ beforeEach(() => {
552
+ resetTables();
553
+ events.length = 0;
554
+ emittedSignals.length = 0;
555
+ deliveredReplies.length = 0;
556
+ });
557
+
558
+ test('waitForInlineGrant returns granted when grant appears during wait', async () => {
559
+ // Create a canonical request manually
560
+ const req = createCanonicalGuardianRequest({
561
+ kind: 'tool_grant_request',
562
+ sourceType: 'channel',
563
+ sourceChannel: 'telegram',
564
+ conversationId: 'conv-1',
565
+ requesterExternalUserId: 'requester-1',
566
+ guardianExternalUserId: 'guardian-1',
567
+ toolName: 'bash',
568
+ inputDigest: 'sha256:waitgrant',
569
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
570
+ });
571
+
572
+ // Schedule approval after 50ms
573
+ setTimeout(async () => {
574
+ await applyCanonicalGuardianDecision({
575
+ requestId: req.id,
576
+ action: 'approve_once',
577
+ actorContext: guardianActor(),
578
+ });
579
+ }, 50);
580
+
581
+ const result = await waitForInlineGrant(
582
+ req.id,
583
+ {
584
+ toolName: 'bash',
585
+ inputDigest: 'sha256:waitgrant',
586
+ consumingRequestId: 'consume-1',
587
+ assistantId: 'self',
588
+ conversationId: 'conv-1',
589
+ requesterExternalUserId: 'requester-1',
590
+ executionChannel: 'telegram',
591
+ },
592
+ { maxWaitMs: 2_000, intervalMs: 20 },
593
+ );
594
+
595
+ expect(result.outcome).toBe('granted');
596
+ if (result.outcome === 'granted') {
597
+ expect(result.grant.id).toBeDefined();
598
+ }
599
+ });
600
+
601
+ test('waitForInlineGrant returns denied when guardian rejects during wait', async () => {
602
+ const req = createCanonicalGuardianRequest({
603
+ kind: 'tool_grant_request',
604
+ sourceType: 'channel',
605
+ sourceChannel: 'telegram',
606
+ conversationId: 'conv-1',
607
+ requesterExternalUserId: 'requester-1',
608
+ guardianExternalUserId: 'guardian-1',
609
+ toolName: 'bash',
610
+ inputDigest: 'sha256:denywait',
611
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
612
+ });
613
+
614
+ // Schedule rejection after 50ms
615
+ setTimeout(async () => {
616
+ await applyCanonicalGuardianDecision({
617
+ requestId: req.id,
618
+ action: 'reject',
619
+ actorContext: guardianActor(),
620
+ });
621
+ }, 50);
622
+
623
+ const result = await waitForInlineGrant(
624
+ req.id,
625
+ {
626
+ toolName: 'bash',
627
+ inputDigest: 'sha256:denywait',
628
+ consumingRequestId: 'consume-1',
629
+ assistantId: 'self',
630
+ },
631
+ { maxWaitMs: 2_000, intervalMs: 20 },
632
+ );
633
+
634
+ expect(result.outcome).toBe('denied');
635
+ if (result.outcome === 'denied') {
636
+ expect(result.requestId).toBe(req.id);
637
+ }
638
+ });
639
+
640
+ test('waitForInlineGrant returns timeout when no decision arrives', async () => {
641
+ const req = createCanonicalGuardianRequest({
642
+ kind: 'tool_grant_request',
643
+ sourceType: 'channel',
644
+ sourceChannel: 'telegram',
645
+ conversationId: 'conv-1',
646
+ requesterExternalUserId: 'requester-1',
647
+ guardianExternalUserId: 'guardian-1',
648
+ toolName: 'bash',
649
+ inputDigest: 'sha256:timeoutwait',
650
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
651
+ });
652
+
653
+ const start = Date.now();
654
+ const result = await waitForInlineGrant(
655
+ req.id,
656
+ {
657
+ toolName: 'bash',
658
+ inputDigest: 'sha256:timeoutwait',
659
+ consumingRequestId: 'consume-1',
660
+ assistantId: 'self',
661
+ },
662
+ { maxWaitMs: 100, intervalMs: 20 },
663
+ );
664
+ const elapsed = Date.now() - start;
665
+
666
+ expect(result.outcome).toBe('timeout');
667
+ if (result.outcome === 'timeout') {
668
+ expect(result.requestId).toBe(req.id);
669
+ }
670
+ expect(elapsed).toBeGreaterThanOrEqual(80);
671
+ expect(elapsed).toBeLessThan(500);
672
+ });
673
+
674
+ test('waitForInlineGrant returns aborted when signal fires during wait', async () => {
675
+ const req = createCanonicalGuardianRequest({
676
+ kind: 'tool_grant_request',
677
+ sourceType: 'channel',
678
+ sourceChannel: 'telegram',
679
+ conversationId: 'conv-1',
680
+ requesterExternalUserId: 'requester-1',
681
+ guardianExternalUserId: 'guardian-1',
682
+ toolName: 'bash',
683
+ inputDigest: 'sha256:abortwait',
684
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
685
+ });
686
+
687
+ const controller = new AbortController();
688
+ setTimeout(() => controller.abort(), 50);
689
+
690
+ const start = Date.now();
691
+ const result = await waitForInlineGrant(
692
+ req.id,
693
+ {
694
+ toolName: 'bash',
695
+ inputDigest: 'sha256:abortwait',
696
+ consumingRequestId: 'consume-1',
697
+ assistantId: 'self',
698
+ },
699
+ { maxWaitMs: 5_000, intervalMs: 20, signal: controller.signal },
700
+ );
701
+ const elapsed = Date.now() - start;
702
+
703
+ expect(result.outcome).toBe('aborted');
704
+ expect(elapsed).toBeLessThan(500);
705
+ });
706
+
707
+ test('inline wait: guardian rejects -> handler returns explicit denial', async () => {
708
+ const handler = new ToolApprovalHandler({ inlineGrantWait: { maxWaitMs: 2_000, intervalMs: 20 } });
709
+
710
+ const toolName = 'bash';
711
+ const input = { command: 'rm -rf /' };
712
+ const context = makeContext({ guardianTrustClass: 'trusted_contact' });
713
+
714
+ // Schedule rejection after 100ms
715
+ const rejectionPromise = (async () => {
716
+ await new Promise((r) => setTimeout(r, 100));
717
+ const pending = listCanonicalGuardianRequests({
718
+ kind: 'tool_grant_request',
719
+ status: 'pending',
720
+ toolName: 'bash',
721
+ });
722
+ if (pending.length === 0) return;
723
+ await applyCanonicalGuardianDecision({
724
+ requestId: pending[0].id,
725
+ action: 'reject',
726
+ actorContext: guardianActor(),
727
+ });
728
+ })();
729
+
730
+ const start = Date.now();
731
+ const result = await handler.checkPreExecutionGates(
493
732
  toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
494
733
  );
495
- expect(replayResult.allowed).toBe(false);
734
+ const elapsed = Date.now() - start;
735
+
736
+ await rejectionPromise;
737
+
738
+ expect(result.allowed).toBe(false);
739
+ if (result.allowed) return;
740
+ expect(result.result.content).toContain('guardian rejected the request');
741
+ // Should exit well before the 2s timeout
742
+ expect(elapsed).toBeLessThan(1_000);
743
+ });
744
+
745
+ test('inline wait: abort signal cancels cleanly during wait', async () => {
746
+ const handler = new ToolApprovalHandler({ inlineGrantWait: { maxWaitMs: 5_000, intervalMs: 20 } });
747
+
748
+ const toolName = 'bash';
749
+ const input = { command: 'do something' };
750
+ const controller = new AbortController();
751
+ const context = makeContext({
752
+ guardianTrustClass: 'trusted_contact',
753
+ signal: controller.signal,
754
+ });
755
+
756
+ // Abort after 100ms
757
+ setTimeout(() => controller.abort(), 100);
758
+
759
+ const start = Date.now();
760
+ const result = await handler.checkPreExecutionGates(
761
+ toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
762
+ );
763
+ const elapsed = Date.now() - start;
764
+
765
+ expect(result.allowed).toBe(false);
766
+ if (result.allowed) return;
767
+ expect(result.result.content).toBe('Cancelled');
768
+ expect(result.result.isError).toBe(true);
769
+ expect(elapsed).toBeLessThan(1_000);
770
+ });
771
+
772
+ test('unknown/unverified actors do NOT get inline wait behavior', async () => {
773
+ const handler = new ToolApprovalHandler({ inlineGrantWait: { maxWaitMs: 2_000, intervalMs: 20 } });
774
+
775
+ const toolName = 'bash';
776
+ const input = { command: 'ls' };
777
+ const context = makeContext({
778
+ guardianTrustClass: 'unknown',
779
+ executionChannel: 'telegram',
780
+ requesterExternalUserId: 'unknown-user',
781
+ });
782
+
783
+ const start = Date.now();
784
+ const result = await handler.checkPreExecutionGates(
785
+ toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
786
+ );
787
+ const elapsed = Date.now() - start;
788
+
789
+ expect(result.allowed).toBe(false);
790
+ if (result.allowed) return;
791
+ // Unknown actors get the generic fail-closed message, not the wait behavior
792
+ expect(result.result.content).toContain('verified channel identity');
793
+ // Should be near-instant, no waiting
794
+ expect(elapsed).toBeLessThan(200);
795
+
796
+ // No canonical request created
797
+ const requests = listCanonicalGuardianRequests({
798
+ kind: 'tool_grant_request',
799
+ status: 'pending',
800
+ });
801
+ expect(requests.length).toBe(0);
496
802
  });
497
803
  });