@vellumai/assistant 0.3.27 → 0.4.0

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 (247) hide show
  1. package/ARCHITECTURE.md +81 -4
  2. package/Dockerfile +2 -2
  3. package/bun.lock +4 -1
  4. package/docs/trusted-contact-access.md +9 -2
  5. package/package.json +6 -3
  6. package/scripts/ipc/generate-swift.ts +9 -5
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +80 -0
  8. package/src/__tests__/agent-loop-thinking.test.ts +1 -1
  9. package/src/__tests__/agent-loop.test.ts +119 -0
  10. package/src/__tests__/approval-routes-http.test.ts +13 -5
  11. package/src/__tests__/asset-materialize-tool.test.ts +2 -0
  12. package/src/__tests__/asset-search-tool.test.ts +2 -0
  13. package/src/__tests__/assistant-events-sse-hardening.test.ts +4 -2
  14. package/src/__tests__/attachments-store.test.ts +2 -0
  15. package/src/__tests__/browser-skill-endstate.test.ts +3 -3
  16. package/src/__tests__/bundled-asset.test.ts +107 -0
  17. package/src/__tests__/call-controller.test.ts +30 -29
  18. package/src/__tests__/call-routes-http.test.ts +34 -32
  19. package/src/__tests__/call-start-guardian-guard.test.ts +2 -0
  20. package/src/__tests__/canonical-guardian-store.test.ts +636 -0
  21. package/src/__tests__/channel-approval-routes.test.ts +174 -1
  22. package/src/__tests__/channel-invite-transport.test.ts +6 -6
  23. package/src/__tests__/channel-reply-delivery.test.ts +19 -0
  24. package/src/__tests__/channel-retry-sweep.test.ts +130 -0
  25. package/src/__tests__/clarification-resolver.test.ts +2 -0
  26. package/src/__tests__/claude-code-skill-regression.test.ts +2 -0
  27. package/src/__tests__/claude-code-tool-profiles.test.ts +2 -0
  28. package/src/__tests__/commit-message-enrichment-service.test.ts +9 -1
  29. package/src/__tests__/computer-use-session-lifecycle.test.ts +2 -0
  30. package/src/__tests__/computer-use-session-working-dir.test.ts +1 -0
  31. package/src/__tests__/computer-use-skill-lifecycle-cleanup.test.ts +2 -0
  32. package/src/__tests__/config-schema.test.ts +5 -5
  33. package/src/__tests__/config-watcher.test.ts +3 -1
  34. package/src/__tests__/connection-policy.test.ts +14 -5
  35. package/src/__tests__/contacts-tools.test.ts +3 -1
  36. package/src/__tests__/contradiction-checker.test.ts +2 -0
  37. package/src/__tests__/conversation-pairing.test.ts +10 -0
  38. package/src/__tests__/conversation-routes.test.ts +1 -1
  39. package/src/__tests__/credential-security-invariants.test.ts +16 -6
  40. package/src/__tests__/credential-vault-unit.test.ts +2 -2
  41. package/src/__tests__/credential-vault.test.ts +5 -4
  42. package/src/__tests__/daemon-lifecycle.test.ts +9 -0
  43. package/src/__tests__/daemon-server-session-init.test.ts +27 -0
  44. package/src/__tests__/elevenlabs-config.test.ts +2 -0
  45. package/src/__tests__/emit-signal-routing-intent.test.ts +43 -1
  46. package/src/__tests__/encrypted-store.test.ts +10 -5
  47. package/src/__tests__/followup-tools.test.ts +3 -1
  48. package/src/__tests__/gateway-only-enforcement.test.ts +21 -21
  49. package/src/__tests__/gmail-integration.test.ts +0 -1
  50. package/src/__tests__/guardian-actions-endpoint.test.ts +205 -345
  51. package/src/__tests__/guardian-control-plane-policy.test.ts +19 -19
  52. package/src/__tests__/guardian-decision-primitive-canonical.test.ts +599 -0
  53. package/src/__tests__/guardian-dispatch.test.ts +21 -19
  54. package/src/__tests__/guardian-grant-minting.test.ts +68 -1
  55. package/src/__tests__/guardian-outbound-http.test.ts +12 -9
  56. package/src/__tests__/guardian-routing-invariants.test.ts +1092 -0
  57. package/src/__tests__/handle-user-message-secret-resume.test.ts +1 -0
  58. package/src/__tests__/handlers-slack-config.test.ts +3 -1
  59. package/src/__tests__/handlers-telegram-config.test.ts +3 -1
  60. package/src/__tests__/handlers-twilio-config.test.ts +3 -1
  61. package/src/__tests__/handlers-twitter-config.test.ts +3 -1
  62. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +318 -0
  63. package/src/__tests__/heartbeat-service.test.ts +20 -0
  64. package/src/__tests__/inbound-invite-redemption.test.ts +33 -0
  65. package/src/__tests__/ingress-reconcile.test.ts +3 -1
  66. package/src/__tests__/ingress-routes-http.test.ts +231 -4
  67. package/src/__tests__/intent-routing.test.ts +2 -0
  68. package/src/__tests__/ipc-snapshot.test.ts +13 -0
  69. package/src/__tests__/mcp-cli.test.ts +77 -0
  70. package/src/__tests__/media-generate-image.test.ts +21 -0
  71. package/src/__tests__/media-reuse-story.e2e.test.ts +2 -0
  72. package/src/__tests__/memory-regressions.test.ts +20 -20
  73. package/src/__tests__/non-member-access-request.test.ts +212 -36
  74. package/src/__tests__/notification-decision-fallback.test.ts +63 -3
  75. package/src/__tests__/notification-decision-strategy.test.ts +78 -0
  76. package/src/__tests__/notification-guardian-path.test.ts +15 -15
  77. package/src/__tests__/oauth-connect-handler.test.ts +3 -1
  78. package/src/__tests__/oauth2-gateway-transport.test.ts +2 -0
  79. package/src/__tests__/onboarding-starter-tasks.test.ts +4 -4
  80. package/src/__tests__/onboarding-template-contract.test.ts +116 -21
  81. package/src/__tests__/pairing-routes.test.ts +171 -0
  82. package/src/__tests__/playbook-execution.test.ts +3 -1
  83. package/src/__tests__/playbook-tools.test.ts +3 -1
  84. package/src/__tests__/provider-error-scenarios.test.ts +59 -8
  85. package/src/__tests__/proxy-approval-callback.test.ts +2 -0
  86. package/src/__tests__/recording-handler.test.ts +11 -0
  87. package/src/__tests__/recording-intent-handler.test.ts +15 -0
  88. package/src/__tests__/recording-state-machine.test.ts +13 -2
  89. package/src/__tests__/registry.test.ts +7 -3
  90. package/src/__tests__/relay-server.test.ts +148 -28
  91. package/src/__tests__/runtime-attachment-metadata.test.ts +4 -2
  92. package/src/__tests__/runtime-events-sse-parity.test.ts +21 -0
  93. package/src/__tests__/runtime-events-sse.test.ts +4 -2
  94. package/src/__tests__/sandbox-diagnostics.test.ts +2 -0
  95. package/src/__tests__/schedule-tools.test.ts +3 -1
  96. package/src/__tests__/secret-scanner-executor.test.ts +59 -0
  97. package/src/__tests__/secret-scanner.test.ts +8 -0
  98. package/src/__tests__/send-endpoint-busy.test.ts +4 -0
  99. package/src/__tests__/sensitive-output-placeholders.test.ts +208 -0
  100. package/src/__tests__/session-abort-tool-results.test.ts +23 -0
  101. package/src/__tests__/session-agent-loop.test.ts +16 -0
  102. package/src/__tests__/session-conflict-gate.test.ts +21 -0
  103. package/src/__tests__/session-load-history-repair.test.ts +27 -17
  104. package/src/__tests__/session-pre-run-repair.test.ts +23 -0
  105. package/src/__tests__/session-profile-injection.test.ts +21 -0
  106. package/src/__tests__/session-provider-retry-repair.test.ts +20 -0
  107. package/src/__tests__/session-queue.test.ts +23 -0
  108. package/src/__tests__/session-runtime-assembly.test.ts +126 -59
  109. package/src/__tests__/session-skill-tools.test.ts +27 -5
  110. package/src/__tests__/session-slash-known.test.ts +23 -0
  111. package/src/__tests__/session-slash-queue.test.ts +23 -0
  112. package/src/__tests__/session-slash-unknown.test.ts +23 -0
  113. package/src/__tests__/session-workspace-cache-state.test.ts +7 -0
  114. package/src/__tests__/session-workspace-injection.test.ts +21 -0
  115. package/src/__tests__/session-workspace-tool-tracking.test.ts +21 -0
  116. package/src/__tests__/shell-credential-ref.test.ts +2 -0
  117. package/src/__tests__/skill-feature-flags-integration.test.ts +6 -6
  118. package/src/__tests__/skill-load-feature-flag.test.ts +5 -4
  119. package/src/__tests__/skill-projection-feature-flag.test.ts +22 -0
  120. package/src/__tests__/skills.test.ts +8 -4
  121. package/src/__tests__/slack-channel-config.test.ts +3 -1
  122. package/src/__tests__/subagent-tools.test.ts +19 -0
  123. package/src/__tests__/swarm-recursion.test.ts +2 -0
  124. package/src/__tests__/swarm-session-integration.test.ts +2 -0
  125. package/src/__tests__/swarm-tool.test.ts +2 -0
  126. package/src/__tests__/system-prompt.test.ts +3 -1
  127. package/src/__tests__/task-compiler.test.ts +3 -1
  128. package/src/__tests__/task-management-tools.test.ts +3 -1
  129. package/src/__tests__/task-tools.test.ts +3 -1
  130. package/src/__tests__/terminal-sandbox.test.ts +13 -12
  131. package/src/__tests__/terminal-tools.test.ts +2 -0
  132. package/src/__tests__/tool-approval-handler.test.ts +15 -15
  133. package/src/__tests__/tool-execution-abort-cleanup.test.ts +2 -0
  134. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +2 -0
  135. package/src/__tests__/tool-grant-request-escalation.test.ts +497 -0
  136. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +48 -0
  137. package/src/__tests__/trusted-contact-multichannel.test.ts +22 -19
  138. package/src/__tests__/trusted-contact-verification.test.ts +91 -0
  139. package/src/__tests__/twilio-routes-elevenlabs.test.ts +2 -0
  140. package/src/__tests__/twitter-auth-handler.test.ts +3 -1
  141. package/src/__tests__/twitter-cli-routing.test.ts +3 -1
  142. package/src/__tests__/view-image-tool.test.ts +3 -1
  143. package/src/__tests__/voice-invite-redemption.test.ts +329 -0
  144. package/src/__tests__/voice-scoped-grant-consumer.test.ts +7 -5
  145. package/src/__tests__/voice-session-bridge.test.ts +10 -10
  146. package/src/__tests__/work-item-output.test.ts +3 -1
  147. package/src/__tests__/workspace-lifecycle.test.ts +13 -2
  148. package/src/agent/loop.ts +46 -3
  149. package/src/approvals/guardian-decision-primitive.ts +285 -0
  150. package/src/approvals/guardian-request-resolvers.ts +539 -0
  151. package/src/calls/call-controller.ts +26 -23
  152. package/src/calls/guardian-action-sweep.ts +10 -2
  153. package/src/calls/guardian-dispatch.ts +46 -40
  154. package/src/calls/relay-server.ts +358 -24
  155. package/src/calls/types.ts +1 -1
  156. package/src/calls/voice-session-bridge.ts +3 -3
  157. package/src/cli.ts +12 -0
  158. package/src/config/agent-schema.ts +14 -3
  159. package/src/config/calls-schema.ts +6 -6
  160. package/src/config/core-schema.ts +3 -3
  161. package/src/config/feature-flag-registry.json +8 -0
  162. package/src/config/mcp-schema.ts +1 -1
  163. package/src/config/memory-schema.ts +27 -19
  164. package/src/config/schema.ts +21 -21
  165. package/src/config/skills-schema.ts +7 -7
  166. package/src/config/system-prompt.ts +2 -1
  167. package/src/config/templates/BOOTSTRAP.md +47 -31
  168. package/src/config/templates/USER.md +5 -0
  169. package/src/config/update-bulletin-template-path.ts +4 -1
  170. package/src/config/vellum-skills/trusted-contacts/SKILL.md +149 -21
  171. package/src/daemon/handlers/config-inbox.ts +4 -4
  172. package/src/daemon/handlers/guardian-actions.ts +45 -66
  173. package/src/daemon/handlers/sessions.ts +148 -4
  174. package/src/daemon/ipc-contract/guardian-actions.ts +7 -0
  175. package/src/daemon/ipc-contract/messages.ts +16 -0
  176. package/src/daemon/ipc-contract-inventory.json +1 -0
  177. package/src/daemon/lifecycle.ts +22 -16
  178. package/src/daemon/pairing-store.ts +86 -3
  179. package/src/daemon/server.ts +18 -0
  180. package/src/daemon/session-agent-loop-handlers.ts +5 -4
  181. package/src/daemon/session-agent-loop.ts +33 -6
  182. package/src/daemon/session-lifecycle.ts +25 -17
  183. package/src/daemon/session-memory.ts +2 -2
  184. package/src/daemon/session-process.ts +68 -326
  185. package/src/daemon/session-runtime-assembly.ts +119 -25
  186. package/src/daemon/session-tool-setup.ts +3 -2
  187. package/src/daemon/session.ts +4 -3
  188. package/src/home-base/prebuilt/seed.ts +2 -1
  189. package/src/hooks/templates.ts +2 -1
  190. package/src/memory/canonical-guardian-store.ts +586 -0
  191. package/src/memory/channel-guardian-store.ts +2 -0
  192. package/src/memory/conversation-crud.ts +7 -7
  193. package/src/memory/db-init.ts +20 -0
  194. package/src/memory/embedding-local.ts +257 -39
  195. package/src/memory/embedding-runtime-manager.ts +471 -0
  196. package/src/memory/guardian-action-store.ts +7 -60
  197. package/src/memory/guardian-approvals.ts +9 -4
  198. package/src/memory/guardian-bindings.ts +25 -1
  199. package/src/memory/indexer.ts +3 -3
  200. package/src/memory/ingress-invite-store.ts +45 -0
  201. package/src/memory/job-handlers/backfill.ts +16 -9
  202. package/src/memory/migrations/036-normalize-phone-identities.ts +289 -0
  203. package/src/memory/migrations/037-voice-invite-columns.ts +16 -0
  204. package/src/memory/migrations/118-reminder-routing-intent.ts +3 -3
  205. package/src/memory/migrations/121-canonical-guardian-requests.ts +59 -0
  206. package/src/memory/migrations/122-canonical-guardian-requester-chat-id.ts +15 -0
  207. package/src/memory/migrations/123-canonical-guardian-deliveries-destination-index.ts +15 -0
  208. package/src/memory/migrations/index.ts +5 -0
  209. package/src/memory/migrations/registry.ts +5 -0
  210. package/src/memory/qdrant-client.ts +31 -22
  211. package/src/memory/schema-migration.ts +1 -0
  212. package/src/memory/schema.ts +56 -0
  213. package/src/notifications/copy-composer.ts +31 -4
  214. package/src/notifications/decision-engine.ts +57 -0
  215. package/src/permissions/defaults.ts +2 -0
  216. package/src/runtime/access-request-helper.ts +173 -0
  217. package/src/runtime/actor-trust-resolver.ts +221 -0
  218. package/src/runtime/channel-guardian-service.ts +12 -4
  219. package/src/runtime/channel-invite-transports/voice.ts +58 -0
  220. package/src/runtime/channel-retry-sweep.ts +18 -6
  221. package/src/runtime/guardian-context-resolver.ts +38 -71
  222. package/src/runtime/guardian-decision-types.ts +6 -0
  223. package/src/runtime/guardian-reply-router.ts +717 -0
  224. package/src/runtime/http-server.ts +8 -0
  225. package/src/runtime/ingress-service.ts +80 -3
  226. package/src/runtime/invite-redemption-service.ts +141 -2
  227. package/src/runtime/routes/canonical-guardian-expiry-sweep.ts +116 -0
  228. package/src/runtime/routes/channel-route-shared.ts +1 -1
  229. package/src/runtime/routes/channel-routes.ts +1 -1
  230. package/src/runtime/routes/conversation-routes.ts +20 -2
  231. package/src/runtime/routes/guardian-action-routes.ts +100 -109
  232. package/src/runtime/routes/guardian-approval-interception.ts +17 -6
  233. package/src/runtime/routes/inbound-message-handler.ts +205 -529
  234. package/src/runtime/routes/ingress-routes.ts +52 -4
  235. package/src/runtime/routes/pairing-routes.ts +3 -0
  236. package/src/runtime/tool-grant-request-helper.ts +195 -0
  237. package/src/tools/executor.ts +13 -1
  238. package/src/tools/guardian-control-plane-policy.ts +2 -2
  239. package/src/tools/sensitive-output-placeholders.ts +203 -0
  240. package/src/tools/tool-approval-handler.ts +53 -10
  241. package/src/tools/types.ts +13 -2
  242. package/src/util/bundled-asset.ts +31 -0
  243. package/src/util/canonicalize-identity.ts +52 -0
  244. package/src/util/logger.ts +20 -8
  245. package/src/util/platform.ts +10 -0
  246. package/src/util/voice-code.ts +29 -0
  247. package/src/daemon/guardian-invite-intent.ts +0 -124
@@ -0,0 +1,497 @@
1
+ /**
2
+ * Tests for the non-guardian tool grant escalation path:
3
+ *
4
+ * 1. ToolApprovalHandler grant-miss escalation behavior
5
+ * 2. tool_grant_request resolver registration and behavior
6
+ * 3. Canonical decision primitive grant minting for tool_grant_request kind
7
+ * 4. End-to-end: deny -> approve -> consume grant flow
8
+ */
9
+
10
+ import { mkdtempSync, rmSync } from 'node:fs';
11
+ import { tmpdir } from 'node:os';
12
+ import { join } from 'node:path';
13
+
14
+ import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test';
15
+
16
+ const testDir = mkdtempSync(join(tmpdir(), 'tool-grant-escalation-test-'));
17
+
18
+ mock.module('../util/platform.js', () => ({
19
+ getDataDir: () => testDir,
20
+ isMacOS: () => process.platform === 'darwin',
21
+ isLinux: () => process.platform === 'linux',
22
+ isWindows: () => process.platform === 'win32',
23
+ getSocketPath: () => join(testDir, 'test.sock'),
24
+ getPidPath: () => join(testDir, 'test.pid'),
25
+ getDbPath: () => join(testDir, 'test.db'),
26
+ getLogPath: () => join(testDir, 'test.log'),
27
+ ensureDataDir: () => {},
28
+ migrateToDataLayout: () => {},
29
+ migrateToWorkspaceLayout: () => {},
30
+ }));
31
+
32
+ mock.module('../util/logger.js', () => ({
33
+ getLogger: () =>
34
+ new Proxy({} as Record<string, unknown>, {
35
+ get: () => () => {},
36
+ }),
37
+ isDebug: () => false,
38
+ truncateForLog: (value: string) => value,
39
+ }));
40
+
41
+ // Mock guardian control-plane policy — not targeting control-plane by default
42
+ mock.module('../tools/guardian-control-plane-policy.js', () => ({
43
+ enforceGuardianOnlyPolicy: () => ({ denied: false }),
44
+ }));
45
+
46
+ // Mock task run rules — no task run rules by default
47
+ mock.module('../tasks/ephemeral-permissions.js', () => ({
48
+ getTaskRunRules: () => [],
49
+ }));
50
+
51
+ // Mock tool registry — return a fake tool for 'bash'
52
+ const fakeTool = {
53
+ name: 'bash',
54
+ description: 'Run a shell command',
55
+ category: 'shell',
56
+ defaultRiskLevel: 'high',
57
+ getDefinition: () => ({ name: 'bash', description: 'Run a shell command', input_schema: {} }),
58
+ execute: async () => ({ content: 'ok', isError: false }),
59
+ };
60
+
61
+ mock.module('../tools/registry.js', () => ({
62
+ getTool: (name: string) => (name === 'bash' ? fakeTool : undefined),
63
+ getAllTools: () => [fakeTool],
64
+ }));
65
+
66
+ // Mock notification emission — capture calls without running the full pipeline
67
+ const emittedSignals: Array<Record<string, unknown>> = [];
68
+ mock.module('../notifications/emit-signal.js', () => ({
69
+ emitNotificationSignal: async (params: Record<string, unknown>) => {
70
+ emittedSignals.push(params);
71
+ return { signalId: 'test-signal', deduplicated: false, dispatched: true, reason: 'ok', deliveryResults: [] };
72
+ },
73
+ registerBroadcastFn: () => {},
74
+ }));
75
+
76
+ // Mock channel guardian service — provide a guardian binding for 'self' + 'telegram'
77
+ mock.module('../runtime/channel-guardian-service.js', () => ({
78
+ getGuardianBinding: (assistantId: string, channel: string) => {
79
+ if (assistantId === 'self' && channel === 'telegram') {
80
+ return {
81
+ id: 'binding-1',
82
+ assistantId: 'self',
83
+ channel: 'telegram',
84
+ guardianExternalUserId: 'guardian-1',
85
+ guardianDeliveryChatId: 'guardian-chat-1',
86
+ status: 'active',
87
+ };
88
+ }
89
+ return null;
90
+ },
91
+ createOutboundSession: () => ({
92
+ sessionId: 'test-session',
93
+ secret: '123456',
94
+ }),
95
+ }));
96
+
97
+ // Mock gateway client — capture delivery calls
98
+ const deliveredReplies: Array<{ chatId: string; text: string }> = [];
99
+ mock.module('../runtime/gateway-client.js', () => ({
100
+ deliverChannelReply: async (_url: string, payload: { chatId: string; text: string }) => {
101
+ deliveredReplies.push(payload);
102
+ },
103
+ }));
104
+
105
+ import {
106
+ applyCanonicalGuardianDecision,
107
+ } from '../approvals/guardian-decision-primitive.js';
108
+ import type { ActorContext } from '../approvals/guardian-request-resolvers.js';
109
+ import { getRegisteredKinds, getResolver } from '../approvals/guardian-request-resolvers.js';
110
+ import {
111
+ createCanonicalGuardianRequest,
112
+ getCanonicalGuardianRequest,
113
+ listCanonicalGuardianRequests,
114
+ } from '../memory/canonical-guardian-store.js';
115
+ import { getDb, initializeDb, resetDb } from '../memory/db.js';
116
+ import { scopedApprovalGrants } from '../memory/schema.js';
117
+ import { computeToolApprovalDigest } from '../security/tool-approval-digest.js';
118
+ import { ToolApprovalHandler } from '../tools/tool-approval-handler.js';
119
+ import type { ToolContext, ToolLifecycleEvent } from '../tools/types.js';
120
+
121
+ initializeDb();
122
+
123
+ function resetTables(): void {
124
+ const db = getDb();
125
+ db.delete(scopedApprovalGrants).run();
126
+ db.run('DELETE FROM canonical_guardian_deliveries');
127
+ db.run('DELETE FROM canonical_guardian_requests');
128
+ }
129
+
130
+ afterAll(() => {
131
+ resetDb();
132
+ try {
133
+ rmSync(testDir, { recursive: true });
134
+ } catch {
135
+ /* best effort */
136
+ }
137
+ });
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // Helpers
141
+ // ---------------------------------------------------------------------------
142
+
143
+ function makeContext(overrides: Partial<ToolContext> = {}): ToolContext {
144
+ return {
145
+ workingDir: testDir,
146
+ sessionId: 'session-1',
147
+ conversationId: 'conv-1',
148
+ assistantId: 'self',
149
+ requestId: 'req-1',
150
+ guardianTrustClass: 'trusted_contact',
151
+ executionChannel: 'telegram',
152
+ requesterExternalUserId: 'requester-1',
153
+ ...overrides,
154
+ };
155
+ }
156
+
157
+ function guardianActor(overrides: Partial<ActorContext> = {}): ActorContext {
158
+ return {
159
+ externalUserId: 'guardian-1',
160
+ channel: 'telegram',
161
+ isTrusted: false,
162
+ ...overrides,
163
+ };
164
+ }
165
+
166
+ // ===========================================================================
167
+ // TESTS
168
+ // ===========================================================================
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // 1. tool_grant_request resolver registration
172
+ // ---------------------------------------------------------------------------
173
+
174
+ describe('tool_grant_request resolver registration', () => {
175
+ test('tool_grant_request resolver is registered', () => {
176
+ const kinds = getRegisteredKinds();
177
+ expect(kinds).toContain('tool_grant_request');
178
+ });
179
+
180
+ test('getResolver returns resolver for tool_grant_request', () => {
181
+ const resolver = getResolver('tool_grant_request');
182
+ expect(resolver).toBeDefined();
183
+ expect(resolver!.kind).toBe('tool_grant_request');
184
+ });
185
+ });
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // 2. Grant-miss escalation behavior in ToolApprovalHandler
189
+ // ---------------------------------------------------------------------------
190
+
191
+ describe('ToolApprovalHandler / grant-miss escalation', () => {
192
+ const handler = new ToolApprovalHandler();
193
+ const events: ToolLifecycleEvent[] = [];
194
+ const emitLifecycleEvent = (event: ToolLifecycleEvent) => { events.push(event); };
195
+
196
+ beforeEach(() => {
197
+ resetTables();
198
+ events.length = 0;
199
+ emittedSignals.length = 0;
200
+ deliveredReplies.length = 0;
201
+ });
202
+
203
+ test('non-guardian + grant miss + host tool creates canonical tool_grant_request', async () => {
204
+ const toolName = 'bash';
205
+ const input = { command: 'cat /etc/passwd' };
206
+
207
+ const context = makeContext({ guardianTrustClass: 'trusted_contact' });
208
+ const result = await handler.checkPreExecutionGates(
209
+ toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
210
+ );
211
+
212
+ expect(result.allowed).toBe(false);
213
+ if (result.allowed) return;
214
+
215
+ // A canonical tool_grant_request should have been created
216
+ const requests = listCanonicalGuardianRequests({
217
+ kind: 'tool_grant_request',
218
+ status: 'pending',
219
+ });
220
+ expect(requests.length).toBe(1);
221
+ expect(requests[0].toolName).toBe('bash');
222
+ expect(requests[0].requesterExternalUserId).toBe('requester-1');
223
+ expect(requests[0].guardianExternalUserId).toBe('guardian-1');
224
+
225
+ // Notification signal should have been emitted
226
+ expect(emittedSignals.length).toBe(1);
227
+ expect(emittedSignals[0].sourceEventName).toBe('guardian.question');
228
+ });
229
+
230
+ test('non-guardian grant-miss response includes request code', async () => {
231
+ const toolName = 'bash';
232
+ const input = { command: 'deploy' };
233
+
234
+ const context = makeContext({ guardianTrustClass: 'trusted_contact' });
235
+ const result = await handler.checkPreExecutionGates(
236
+ toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
237
+ );
238
+
239
+ expect(result.allowed).toBe(false);
240
+ if (result.allowed) return;
241
+ expect(result.result.content).toContain('request has been sent to the guardian');
242
+ expect(result.result.content).toContain('request code:');
243
+ expect(result.result.content).toContain('Please retry after the guardian approves');
244
+ });
245
+
246
+ test('non-guardian duplicate grant-miss deduplicates the request', async () => {
247
+ const toolName = 'bash';
248
+ const input = { command: 'rm -rf /' };
249
+
250
+ const context = makeContext({ guardianTrustClass: 'trusted_contact' });
251
+
252
+ // First invocation creates the request
253
+ await handler.checkPreExecutionGates(
254
+ toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
255
+ );
256
+
257
+ const firstRequests = listCanonicalGuardianRequests({
258
+ kind: 'tool_grant_request',
259
+ status: 'pending',
260
+ });
261
+ expect(firstRequests.length).toBe(1);
262
+
263
+ // Reset notification tracking
264
+ emittedSignals.length = 0;
265
+
266
+ // Second invocation with same tool+input deduplicates
267
+ const result = await handler.checkPreExecutionGates(
268
+ toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
269
+ );
270
+
271
+ expect(result.allowed).toBe(false);
272
+ if (result.allowed) return;
273
+ expect(result.result.content).toContain('already pending');
274
+
275
+ // Still only one canonical request
276
+ const requests = listCanonicalGuardianRequests({
277
+ kind: 'tool_grant_request',
278
+ status: 'pending',
279
+ });
280
+ expect(requests.length).toBe(1);
281
+
282
+ // No duplicate notification
283
+ expect(emittedSignals.length).toBe(0);
284
+ });
285
+
286
+ test('unverified_channel does NOT create escalation request', async () => {
287
+ const toolName = 'bash';
288
+ const input = { command: 'ls' };
289
+
290
+ const context = makeContext({
291
+ guardianTrustClass: 'unknown',
292
+ executionChannel: 'telegram',
293
+ requesterExternalUserId: 'unknown-user',
294
+ });
295
+ const result = await handler.checkPreExecutionGates(
296
+ toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
297
+ );
298
+
299
+ expect(result.allowed).toBe(false);
300
+ if (result.allowed) return;
301
+ // Should get the generic denial message, not escalation
302
+ expect(result.result.content).toContain('verified channel identity');
303
+
304
+ // No canonical request should have been created
305
+ const requests = listCanonicalGuardianRequests({
306
+ kind: 'tool_grant_request',
307
+ status: 'pending',
308
+ });
309
+ expect(requests.length).toBe(0);
310
+ });
311
+
312
+ test('non-guardian without executionChannel falls back to generic denial', async () => {
313
+ const toolName = 'bash';
314
+ const input = { command: 'deploy' };
315
+
316
+ const context = makeContext({
317
+ guardianTrustClass: 'trusted_contact',
318
+ executionChannel: undefined, // no channel info
319
+ });
320
+ const result = await handler.checkPreExecutionGates(
321
+ toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
322
+ );
323
+
324
+ expect(result.allowed).toBe(false);
325
+ if (result.allowed) return;
326
+ // Generic denial, no escalation attempted
327
+ expect(result.result.content).toContain('guardian approval');
328
+ expect(result.result.content).not.toContain('request has been sent');
329
+
330
+ const requests = listCanonicalGuardianRequests({
331
+ kind: 'tool_grant_request',
332
+ status: 'pending',
333
+ });
334
+ expect(requests.length).toBe(0);
335
+ });
336
+ });
337
+
338
+ // ---------------------------------------------------------------------------
339
+ // 3. Canonical decision and grant minting for tool_grant_request kind
340
+ // ---------------------------------------------------------------------------
341
+
342
+ describe('applyCanonicalGuardianDecision / tool_grant_request', () => {
343
+ beforeEach(() => {
344
+ resetTables();
345
+ deliveredReplies.length = 0;
346
+ });
347
+
348
+ test('approving tool_grant_request with tool metadata mints a grant', async () => {
349
+ const req = createCanonicalGuardianRequest({
350
+ kind: 'tool_grant_request',
351
+ sourceType: 'channel',
352
+ sourceChannel: 'telegram',
353
+ conversationId: 'conv-1',
354
+ requesterExternalUserId: 'requester-1',
355
+ guardianExternalUserId: 'guardian-1',
356
+ toolName: 'bash',
357
+ inputDigest: 'sha256:testdigest',
358
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
359
+ });
360
+
361
+ const result = await applyCanonicalGuardianDecision({
362
+ requestId: req.id,
363
+ action: 'approve_once',
364
+ actorContext: guardianActor(),
365
+ });
366
+
367
+ expect(result.applied).toBe(true);
368
+ if (!result.applied) return;
369
+ expect(result.grantMinted).toBe(true);
370
+
371
+ // Verify canonical request is approved
372
+ const resolved = getCanonicalGuardianRequest(req.id);
373
+ expect(resolved!.status).toBe('approved');
374
+ expect(resolved!.decidedByExternalUserId).toBe('guardian-1');
375
+ });
376
+
377
+ test('rejecting tool_grant_request does NOT mint a grant', async () => {
378
+ const req = createCanonicalGuardianRequest({
379
+ kind: 'tool_grant_request',
380
+ sourceType: 'channel',
381
+ sourceChannel: 'telegram',
382
+ conversationId: 'conv-1',
383
+ requesterExternalUserId: 'requester-1',
384
+ guardianExternalUserId: 'guardian-1',
385
+ toolName: 'bash',
386
+ inputDigest: 'sha256:testdigest',
387
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
388
+ });
389
+
390
+ const result = await applyCanonicalGuardianDecision({
391
+ requestId: req.id,
392
+ action: 'reject',
393
+ actorContext: guardianActor(),
394
+ });
395
+
396
+ expect(result.applied).toBe(true);
397
+ if (!result.applied) return;
398
+ expect(result.grantMinted).toBe(false);
399
+
400
+ const resolved = getCanonicalGuardianRequest(req.id);
401
+ expect(resolved!.status).toBe('denied');
402
+ });
403
+
404
+ test('identity mismatch blocks tool_grant_request approval', async () => {
405
+ const req = createCanonicalGuardianRequest({
406
+ kind: 'tool_grant_request',
407
+ sourceType: 'channel',
408
+ sourceChannel: 'telegram',
409
+ conversationId: 'conv-1',
410
+ requesterExternalUserId: 'requester-1',
411
+ guardianExternalUserId: 'guardian-1',
412
+ toolName: 'bash',
413
+ inputDigest: 'sha256:testdigest',
414
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
415
+ });
416
+
417
+ const result = await applyCanonicalGuardianDecision({
418
+ requestId: req.id,
419
+ action: 'approve_once',
420
+ actorContext: guardianActor({ externalUserId: 'imposter-99' }),
421
+ });
422
+
423
+ expect(result.applied).toBe(false);
424
+ if (result.applied) return;
425
+ expect(result.reason).toBe('identity_mismatch');
426
+
427
+ const unchanged = getCanonicalGuardianRequest(req.id);
428
+ expect(unchanged!.status).toBe('pending');
429
+ });
430
+ });
431
+
432
+ // ---------------------------------------------------------------------------
433
+ // 4. End-to-end: deny -> approve -> consume grant flow
434
+ // ---------------------------------------------------------------------------
435
+
436
+ describe('end-to-end: tool grant escalation -> approval -> consume', () => {
437
+ const handler = new ToolApprovalHandler();
438
+ const events: ToolLifecycleEvent[] = [];
439
+ const emitLifecycleEvent = (event: ToolLifecycleEvent) => { events.push(event); };
440
+
441
+ beforeEach(() => {
442
+ resetTables();
443
+ events.length = 0;
444
+ emittedSignals.length = 0;
445
+ });
446
+
447
+ test('first invocation denied + request created; guardian approves; second invocation succeeds; replay denied', async () => {
448
+ const toolName = 'bash';
449
+ const input = { command: 'echo secret' };
450
+ const _inputDigest = computeToolApprovalDigest(toolName, input);
451
+
452
+ const context = makeContext({ guardianTrustClass: 'trusted_contact' });
453
+
454
+ // Step 1: First invocation is denied, but a tool_grant_request is created
455
+ const firstResult = await handler.checkPreExecutionGates(
456
+ toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
457
+ );
458
+ expect(firstResult.allowed).toBe(false);
459
+
460
+ // Verify the canonical request was created
461
+ const pendingRequests = listCanonicalGuardianRequests({
462
+ kind: 'tool_grant_request',
463
+ status: 'pending',
464
+ toolName: 'bash',
465
+ });
466
+ expect(pendingRequests.length).toBe(1);
467
+ const canonicalRequestId = pendingRequests[0].id;
468
+
469
+ // Step 2: Guardian approves the canonical request -> grant is minted
470
+ const approvalResult = await applyCanonicalGuardianDecision({
471
+ requestId: canonicalRequestId,
472
+ action: 'approve_once',
473
+ actorContext: guardianActor(),
474
+ });
475
+ expect(approvalResult.applied).toBe(true);
476
+ if (!approvalResult.applied) return;
477
+ expect(approvalResult.grantMinted).toBe(true);
478
+
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
484
+ const secondResult = await handler.checkPreExecutionGates(
485
+ toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
486
+ );
487
+ expect(secondResult.allowed).toBe(true);
488
+ if (!secondResult.allowed) return;
489
+ expect(secondResult.grantConsumed).toBe(true);
490
+
491
+ // Step 4: Replay is denied (one-time grant semantics)
492
+ const replayResult = await handler.checkPreExecutionGates(
493
+ toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
494
+ );
495
+ expect(replayResult.allowed).toBe(false);
496
+ });
497
+ });
@@ -419,6 +419,54 @@ describe('trusted contact activated notification signal', () => {
419
419
  expect(hints.urgency).toBe('low');
420
420
  });
421
421
 
422
+ test('re-verification preserves an existing guardian-managed member display name', async () => {
423
+ createBinding({
424
+ assistantId: 'self',
425
+ channel: 'telegram',
426
+ guardianExternalUserId: 'guardian-user-789',
427
+ guardianDeliveryChatId: 'guardian-chat-789',
428
+ });
429
+
430
+ upsertMember({
431
+ assistantId: 'self',
432
+ sourceChannel: 'telegram',
433
+ externalUserId: 'requester-user-456',
434
+ externalChatId: 'chat-123',
435
+ status: 'revoked',
436
+ policy: 'allow',
437
+ displayName: 'Jeff',
438
+ });
439
+
440
+ const session = createOutboundSession({
441
+ assistantId: 'self',
442
+ channel: 'telegram',
443
+ expectedExternalUserId: 'requester-user-456',
444
+ expectedChatId: 'chat-123',
445
+ identityBindingStatus: 'bound',
446
+ destinationAddress: 'chat-123',
447
+ verificationPurpose: 'trusted_contact',
448
+ });
449
+
450
+ const verifyReq = buildInboundRequest({
451
+ content: session.secret,
452
+ externalChatId: 'chat-123',
453
+ senderExternalUserId: 'requester-user-456',
454
+ senderName: 'Noa Flaherty',
455
+ });
456
+
457
+ await handleChannelInbound(verifyReq, undefined, TEST_BEARER_TOKEN);
458
+
459
+ const member = findMember({
460
+ assistantId: 'self',
461
+ sourceChannel: 'telegram',
462
+ externalUserId: 'requester-user-456',
463
+ externalChatId: 'chat-123',
464
+ });
465
+ expect(member).not.toBeNull();
466
+ expect(member!.status).toBe('active');
467
+ expect(member!.displayName).toBe('Jeff');
468
+ });
469
+
422
470
  test('guardian verification does NOT emit activated signal', async () => {
423
471
  // Create an inbound challenge (guardian flow, not trusted contact)
424
472
  // eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -59,6 +59,19 @@ mock.module('../notifications/emit-signal.js', () => ({
59
59
  deliveryResults: [],
60
60
  };
61
61
  },
62
+ registerBroadcastFn: () => {},
63
+ }));
64
+
65
+ // Mock access-request-helper directly to capture notification calls.
66
+ // Bun's mock.module does not intercept transitive imports reliably, so
67
+ // mocking emit-signal.js alone is not sufficient — access-request-helper
68
+ // imports emit-signal before the mock takes effect.
69
+ const notifyGuardianCalls: Array<Record<string, unknown>> = [];
70
+ mock.module('../runtime/access-request-helper.js', () => ({
71
+ notifyGuardianOfAccessRequest: (params: Record<string, unknown>) => {
72
+ notifyGuardianCalls.push(params);
73
+ return { notified: true, created: true, requestId: `mock-req-${Date.now()}` };
74
+ },
62
75
  }));
63
76
 
64
77
  const deliverReplyCalls: Array<{ url: string; payload: Record<string, unknown> }> = [];
@@ -75,7 +88,6 @@ mock.module('../runtime/approval-message-composer.js', () => ({
75
88
 
76
89
  import {
77
90
  createBinding,
78
- findPendingAccessRequestForRequester,
79
91
  } from '../memory/channel-guardian-store.js';
80
92
  import { getDb, initializeDb, resetDb } from '../memory/db.js';
81
93
  import { findMember, upsertMember } from '../memory/ingress-member-store.js';
@@ -109,6 +121,7 @@ function resetState(): void {
109
121
  db.run('DELETE FROM notification_events');
110
122
  db.run('DELETE FROM assistant_ingress_members');
111
123
  emitSignalCalls.length = 0;
124
+ notifyGuardianCalls.length = 0;
112
125
  deliverReplyCalls.length = 0;
113
126
  }
114
127
 
@@ -186,7 +199,10 @@ for (const config of CHANNEL_CONFIGS) {
186
199
  expect(json.denied).toBe(true);
187
200
  expect(json.reason).toBe('not_a_member');
188
201
  expect(deliverReplyCalls.length).toBe(1);
189
- expect((deliverReplyCalls[0].payload as Record<string, unknown>).text).toContain("you haven't been approved");
202
+ const replyText = (deliverReplyCalls[0].payload as Record<string, unknown>).text as string;
203
+ expect(
204
+ replyText.includes("you haven't been approved") || replyText.includes("you don't have access"),
205
+ ).toBe(true);
190
206
  });
191
207
 
192
208
  test('guardian is notified when a non-member messages', async () => {
@@ -203,23 +219,10 @@ for (const config of CHANNEL_CONFIGS) {
203
219
 
204
220
  expect(json.denied).toBe(true);
205
221
 
206
- // Notification signal was emitted for the correct channel
207
- expect(emitSignalCalls.length).toBe(1);
208
- expect(emitSignalCalls[0].sourceEventName).toBe('ingress.access_request');
209
- expect(emitSignalCalls[0].sourceChannel).toBe(config.channel);
210
-
211
- const payload = emitSignalCalls[0].contextPayload as Record<string, unknown>;
212
- expect(payload.senderExternalUserId).toBe(config.senderExternalUserId);
213
-
214
- // Approval request was created for the correct channel
215
- const pending = findPendingAccessRequestForRequester(
216
- 'self',
217
- config.channel,
218
- config.senderExternalUserId,
219
- 'ingress_access_request',
220
- );
221
- expect(pending).not.toBeNull();
222
- expect(pending!.channel).toBe(config.channel);
222
+ // Guardian notification helper was called for the correct channel
223
+ expect(notifyGuardianCalls.length).toBe(1);
224
+ expect(notifyGuardianCalls[0].sourceChannel).toBe(config.channel);
225
+ expect(notifyGuardianCalls[0].senderExternalUserId).toBe(config.senderExternalUserId);
223
226
  });
224
227
 
225
228
  test('verification creates active member for channel', () => {