@vellumai/assistant 0.3.18 → 0.3.20

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 (202) hide show
  1. package/ARCHITECTURE.md +155 -15
  2. package/Dockerfile +1 -0
  3. package/README.md +40 -4
  4. package/docs/architecture/integrations.md +7 -11
  5. package/docs/architecture/security.md +80 -0
  6. package/package.json +1 -1
  7. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +58 -0
  8. package/src/__tests__/approval-primitive.test.ts +540 -0
  9. package/src/__tests__/assistant-feature-flag-guard.test.ts +206 -0
  10. package/src/__tests__/assistant-feature-flag-guardrails.test.ts +198 -0
  11. package/src/__tests__/assistant-feature-flags-integration.test.ts +272 -0
  12. package/src/__tests__/call-controller.test.ts +605 -104
  13. package/src/__tests__/channel-invite-transport.test.ts +264 -0
  14. package/src/__tests__/checker.test.ts +60 -0
  15. package/src/__tests__/cli.test.ts +42 -1
  16. package/src/__tests__/config-schema.test.ts +11 -127
  17. package/src/__tests__/config-watcher.test.ts +0 -8
  18. package/src/__tests__/daemon-lifecycle.test.ts +1 -0
  19. package/src/__tests__/daemon-server-session-init.test.ts +8 -2
  20. package/src/__tests__/diff.test.ts +22 -0
  21. package/src/__tests__/guardian-action-copy-generator.test.ts +5 -0
  22. package/src/__tests__/guardian-action-grant-mint-consume.test.ts +779 -0
  23. package/src/__tests__/guardian-action-late-reply.test.ts +546 -1
  24. package/src/__tests__/guardian-actions-endpoint.test.ts +774 -0
  25. package/src/__tests__/guardian-control-plane-policy.test.ts +36 -3
  26. package/src/__tests__/guardian-dispatch.test.ts +185 -1
  27. package/src/__tests__/guardian-grant-minting.test.ts +532 -0
  28. package/src/__tests__/inbound-invite-redemption.test.ts +367 -0
  29. package/src/__tests__/invite-redemption-service.test.ts +306 -0
  30. package/src/__tests__/ipc-snapshot.test.ts +58 -0
  31. package/src/__tests__/notification-decision-fallback.test.ts +88 -0
  32. package/src/__tests__/remote-skill-policy.test.ts +215 -0
  33. package/src/__tests__/sandbox-diagnostics.test.ts +6 -249
  34. package/src/__tests__/sandbox-host-parity.test.ts +6 -13
  35. package/src/__tests__/scoped-approval-grants.test.ts +521 -0
  36. package/src/__tests__/scoped-grant-security-matrix.test.ts +444 -0
  37. package/src/__tests__/script-proxy-session-manager.test.ts +1 -19
  38. package/src/__tests__/session-load-history-repair.test.ts +169 -2
  39. package/src/__tests__/session-runtime-assembly.test.ts +33 -5
  40. package/src/__tests__/skill-feature-flags-integration.test.ts +171 -0
  41. package/src/__tests__/skill-feature-flags.test.ts +188 -0
  42. package/src/__tests__/skill-load-feature-flag.test.ts +141 -0
  43. package/src/__tests__/skill-mirror-parity.test.ts +1 -0
  44. package/src/__tests__/skill-projection-feature-flag.test.ts +363 -0
  45. package/src/__tests__/system-prompt.test.ts +1 -1
  46. package/src/__tests__/terminal-sandbox.test.ts +142 -9
  47. package/src/__tests__/terminal-tools.test.ts +2 -93
  48. package/src/__tests__/thread-seed-composer.test.ts +18 -0
  49. package/src/__tests__/tool-approval-handler.test.ts +350 -0
  50. package/src/__tests__/trust-store.test.ts +2 -0
  51. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +8 -10
  52. package/src/__tests__/voice-scoped-grant-consumer.test.ts +533 -0
  53. package/src/agent/loop.ts +36 -1
  54. package/src/approvals/approval-primitive.ts +381 -0
  55. package/src/approvals/guardian-decision-primitive.ts +191 -0
  56. package/src/calls/call-controller.ts +276 -212
  57. package/src/calls/call-domain.ts +56 -6
  58. package/src/calls/guardian-dispatch.ts +56 -0
  59. package/src/calls/relay-server.ts +13 -0
  60. package/src/calls/types.ts +1 -1
  61. package/src/calls/voice-session-bridge.ts +59 -4
  62. package/src/cli/core-commands.ts +0 -4
  63. package/src/cli.ts +76 -34
  64. package/src/config/__tests__/feature-flag-registry-guard.test.ts +179 -0
  65. package/src/config/assistant-feature-flags.ts +162 -0
  66. package/src/config/bundled-skills/api-mapping/icon.svg +18 -0
  67. package/src/config/bundled-skills/messaging/TOOLS.json +30 -0
  68. package/src/config/bundled-skills/messaging/tools/slack-delete-message.ts +24 -0
  69. package/src/config/bundled-skills/notifications/SKILL.md +18 -0
  70. package/src/config/bundled-skills/reminder/SKILL.md +49 -2
  71. package/src/config/bundled-skills/time-based-actions/SKILL.md +49 -2
  72. package/src/config/bundled-skills/voice-setup/SKILL.md +122 -0
  73. package/src/config/core-schema.ts +1 -1
  74. package/src/config/env-registry.ts +10 -0
  75. package/src/config/feature-flag-registry.json +61 -0
  76. package/src/config/loader.ts +22 -1
  77. package/src/config/sandbox-schema.ts +0 -39
  78. package/src/config/schema.ts +12 -2
  79. package/src/config/skill-state.ts +34 -0
  80. package/src/config/skills-schema.ts +26 -0
  81. package/src/config/skills.ts +9 -0
  82. package/src/config/system-prompt.ts +110 -46
  83. package/src/config/templates/SOUL.md +1 -1
  84. package/src/config/types.ts +19 -1
  85. package/src/config/vellum-skills/catalog.json +1 -1
  86. package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +1 -0
  87. package/src/config/vellum-skills/sms-setup/SKILL.md +1 -1
  88. package/src/config/vellum-skills/telegram-setup/SKILL.md +1 -1
  89. package/src/config/vellum-skills/trusted-contacts/SKILL.md +104 -3
  90. package/src/config/vellum-skills/twilio-setup/SKILL.md +1 -1
  91. package/src/daemon/config-watcher.ts +0 -1
  92. package/src/daemon/daemon-control.ts +1 -1
  93. package/src/daemon/guardian-invite-intent.ts +124 -0
  94. package/src/daemon/handlers/avatar.ts +68 -0
  95. package/src/daemon/handlers/browser.ts +2 -2
  96. package/src/daemon/handlers/config-channels.ts +18 -0
  97. package/src/daemon/handlers/guardian-actions.ts +120 -0
  98. package/src/daemon/handlers/index.ts +4 -0
  99. package/src/daemon/handlers/sessions.ts +19 -0
  100. package/src/daemon/handlers/shared.ts +3 -1
  101. package/src/daemon/handlers/skills.ts +45 -2
  102. package/src/daemon/install-cli-launchers.ts +58 -13
  103. package/src/daemon/ipc-contract/guardian-actions.ts +53 -0
  104. package/src/daemon/ipc-contract/sessions.ts +8 -2
  105. package/src/daemon/ipc-contract/settings.ts +25 -2
  106. package/src/daemon/ipc-contract/skills.ts +1 -0
  107. package/src/daemon/ipc-contract-inventory.json +10 -0
  108. package/src/daemon/ipc-contract.ts +4 -0
  109. package/src/daemon/lifecycle.ts +6 -2
  110. package/src/daemon/main.ts +1 -0
  111. package/src/daemon/server.ts +1 -0
  112. package/src/daemon/session-lifecycle.ts +52 -7
  113. package/src/daemon/session-memory.ts +45 -0
  114. package/src/daemon/session-process.ts +260 -422
  115. package/src/daemon/session-runtime-assembly.ts +12 -0
  116. package/src/daemon/session-skill-tools.ts +14 -1
  117. package/src/daemon/session-tool-setup.ts +5 -0
  118. package/src/daemon/session.ts +11 -0
  119. package/src/daemon/tool-side-effects.ts +35 -9
  120. package/src/index.ts +0 -2
  121. package/src/memory/conversation-display-order-migration.ts +44 -0
  122. package/src/memory/conversation-queries.ts +2 -0
  123. package/src/memory/conversation-store.ts +91 -0
  124. package/src/memory/db-init.ts +13 -1
  125. package/src/memory/embedding-local.ts +22 -8
  126. package/src/memory/guardian-action-store.ts +133 -2
  127. package/src/memory/guardian-verification.ts +1 -1
  128. package/src/memory/ingress-invite-store.ts +95 -1
  129. package/src/memory/migrations/033-scoped-approval-grants.ts +51 -0
  130. package/src/memory/migrations/034-guardian-action-tool-metadata.ts +12 -0
  131. package/src/memory/migrations/035-guardian-action-supersession.ts +23 -0
  132. package/src/memory/migrations/index.ts +3 -0
  133. package/src/memory/schema.ts +35 -1
  134. package/src/memory/scoped-approval-grants.ts +518 -0
  135. package/src/messaging/providers/slack/client.ts +12 -0
  136. package/src/messaging/providers/slack/types.ts +5 -0
  137. package/src/notifications/decision-engine.ts +49 -12
  138. package/src/notifications/emit-signal.ts +7 -0
  139. package/src/notifications/signal.ts +7 -0
  140. package/src/notifications/thread-seed-composer.ts +2 -1
  141. package/src/permissions/checker.ts +27 -0
  142. package/src/runtime/channel-approval-types.ts +16 -6
  143. package/src/runtime/channel-approvals.ts +19 -15
  144. package/src/runtime/channel-invite-transport.ts +85 -0
  145. package/src/runtime/channel-invite-transports/telegram.ts +105 -0
  146. package/src/runtime/guardian-action-grant-minter.ts +154 -0
  147. package/src/runtime/guardian-action-message-composer.ts +30 -0
  148. package/src/runtime/guardian-decision-types.ts +91 -0
  149. package/src/runtime/http-server.ts +23 -1
  150. package/src/runtime/ingress-service.ts +22 -0
  151. package/src/runtime/invite-redemption-service.ts +181 -0
  152. package/src/runtime/invite-redemption-templates.ts +39 -0
  153. package/src/runtime/routes/call-routes.ts +2 -1
  154. package/src/runtime/routes/guardian-action-routes.ts +206 -0
  155. package/src/runtime/routes/guardian-approval-interception.ts +66 -74
  156. package/src/runtime/routes/inbound-message-handler.ts +568 -409
  157. package/src/runtime/routes/pairing-routes.ts +4 -0
  158. package/src/security/encrypted-store.ts +31 -17
  159. package/src/security/keychain.ts +176 -2
  160. package/src/security/secure-keys.ts +97 -0
  161. package/src/security/tool-approval-digest.ts +67 -0
  162. package/src/skills/remote-skill-policy.ts +131 -0
  163. package/src/tools/browser/browser-execution.ts +2 -2
  164. package/src/tools/browser/browser-manager.ts +46 -32
  165. package/src/tools/browser/browser-screencast.ts +2 -2
  166. package/src/tools/calls/call-start.ts +1 -1
  167. package/src/tools/executor.ts +22 -17
  168. package/src/tools/network/script-proxy/session-manager.ts +1 -5
  169. package/src/tools/skills/load.ts +22 -8
  170. package/src/tools/system/avatar-generator.ts +119 -0
  171. package/src/tools/system/navigate-settings.ts +65 -0
  172. package/src/tools/system/open-system-settings.ts +75 -0
  173. package/src/tools/system/voice-config.ts +121 -32
  174. package/src/tools/terminal/backends/native.ts +40 -19
  175. package/src/tools/terminal/backends/types.ts +3 -3
  176. package/src/tools/terminal/parser.ts +1 -1
  177. package/src/tools/terminal/sandbox-diagnostics.ts +6 -87
  178. package/src/tools/terminal/sandbox.ts +1 -12
  179. package/src/tools/terminal/shell.ts +3 -31
  180. package/src/tools/tool-approval-handler.ts +141 -3
  181. package/src/tools/tool-manifest.ts +6 -0
  182. package/src/tools/types.ts +6 -0
  183. package/src/util/diff.ts +36 -13
  184. package/Dockerfile.sandbox +0 -5
  185. package/src/__tests__/doordash-client.test.ts +0 -187
  186. package/src/__tests__/doordash-session.test.ts +0 -154
  187. package/src/__tests__/signup-e2e.test.ts +0 -354
  188. package/src/__tests__/terminal-sandbox-docker.test.ts +0 -1065
  189. package/src/__tests__/terminal-sandbox.integration.test.ts +0 -180
  190. package/src/cli/doordash.ts +0 -1057
  191. package/src/config/bundled-skills/doordash/SKILL.md +0 -163
  192. package/src/config/templates/LOOKS.md +0 -25
  193. package/src/doordash/cart-queries.ts +0 -787
  194. package/src/doordash/client.ts +0 -1016
  195. package/src/doordash/order-queries.ts +0 -85
  196. package/src/doordash/queries.ts +0 -13
  197. package/src/doordash/query-extractor.ts +0 -94
  198. package/src/doordash/search-queries.ts +0 -203
  199. package/src/doordash/session.ts +0 -84
  200. package/src/doordash/store-queries.ts +0 -246
  201. package/src/doordash/types.ts +0 -367
  202. package/src/tools/terminal/backends/docker.ts +0 -379
@@ -175,6 +175,24 @@ describe('composeThreadSeed', () => {
175
175
  expect(seed).toContain('Action required');
176
176
  });
177
177
 
178
+ test('does not duplicate "Action required" when copy already includes it', () => {
179
+ const signal = makeSignal({
180
+ attentionHints: {
181
+ requiresAction: true,
182
+ urgency: 'high',
183
+ isAsyncBackground: false,
184
+ visibleInSourceNow: false,
185
+ },
186
+ });
187
+ const copy = makeCopy({
188
+ title: 'Guardian Question',
189
+ body: 'Action required: What is the gate code?',
190
+ });
191
+ const seed = composeThreadSeed(signal, 'vellum' as NotificationChannel, copy);
192
+ const markerCount = (seed.match(/action required/gi) ?? []).length;
193
+ expect(markerCount).toBe(1);
194
+ });
195
+
178
196
  test('omits "Notification" generic title', () => {
179
197
  const signal = makeSignal();
180
198
  const copy = makeCopy({ title: 'Notification', body: 'Something new.' });
@@ -0,0 +1,350 @@
1
+ import { mkdtempSync, rmSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+
5
+ import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test';
6
+
7
+ const testDir = mkdtempSync(join(tmpdir(), 'tool-approval-handler-test-'));
8
+
9
+ mock.module('../util/platform.js', () => ({
10
+ getDataDir: () => testDir,
11
+ isMacOS: () => process.platform === 'darwin',
12
+ isLinux: () => process.platform === 'linux',
13
+ isWindows: () => process.platform === 'win32',
14
+ getSocketPath: () => join(testDir, 'test.sock'),
15
+ getPidPath: () => join(testDir, 'test.pid'),
16
+ getDbPath: () => join(testDir, 'test.db'),
17
+ getLogPath: () => join(testDir, 'test.log'),
18
+ ensureDataDir: () => {},
19
+ migrateToDataLayout: () => {},
20
+ migrateToWorkspaceLayout: () => {},
21
+ }));
22
+
23
+ mock.module('../util/logger.js', () => ({
24
+ getLogger: () =>
25
+ new Proxy({} as Record<string, unknown>, {
26
+ get: () => () => {},
27
+ }),
28
+ isDebug: () => false,
29
+ truncateForLog: (value: string) => value,
30
+ }));
31
+
32
+ // Mock parental controls — no tools blocked by default
33
+ mock.module('../security/parental-control-store.js', () => ({
34
+ isToolBlocked: () => false,
35
+ }));
36
+
37
+ // Mock guardian control-plane policy — not targeting control-plane by default
38
+ mock.module('../tools/guardian-control-plane-policy.js', () => ({
39
+ enforceGuardianOnlyPolicy: () => ({ denied: false }),
40
+ }));
41
+
42
+ // Mock task run rules — no task run rules by default
43
+ mock.module('../tasks/ephemeral-permissions.js', () => ({
44
+ getTaskRunRules: () => [],
45
+ }));
46
+
47
+ // Mock tool registry — return a fake tool for 'bash'
48
+ const fakeTool = {
49
+ name: 'bash',
50
+ description: 'Run a shell command',
51
+ category: 'shell',
52
+ defaultRiskLevel: 'high',
53
+ getDefinition: () => ({ name: 'bash', description: 'Run a shell command', input_schema: {} }),
54
+ execute: async () => ({ content: 'ok', isError: false }),
55
+ };
56
+
57
+ mock.module('../tools/registry.js', () => ({
58
+ getTool: (name: string) => (name === 'bash' ? fakeTool : undefined),
59
+ getAllTools: () => [fakeTool],
60
+ }));
61
+
62
+ import { mintGrantFromDecision, type MintGrantParams } from '../approvals/approval-primitive.js';
63
+ import { getDb, initializeDb, resetDb } from '../memory/db.js';
64
+ import { scopedApprovalGrants } from '../memory/schema.js';
65
+ import { computeToolApprovalDigest } from '../security/tool-approval-digest.js';
66
+ import { ToolApprovalHandler } from '../tools/tool-approval-handler.js';
67
+ import type { ToolContext, ToolLifecycleEvent } from '../tools/types.js';
68
+
69
+ initializeDb();
70
+
71
+ function clearTables(): void {
72
+ const db = getDb();
73
+ db.delete(scopedApprovalGrants).run();
74
+ }
75
+
76
+ afterAll(() => {
77
+ resetDb();
78
+ try {
79
+ rmSync(testDir, { recursive: true });
80
+ } catch {
81
+ /* best effort */
82
+ }
83
+ });
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Helpers
87
+ // ---------------------------------------------------------------------------
88
+
89
+ function mintParams(overrides: Partial<MintGrantParams> = {}): MintGrantParams {
90
+ const futureExpiry = new Date(Date.now() + 60_000).toISOString();
91
+ return {
92
+ assistantId: 'self',
93
+ scopeMode: 'tool_signature',
94
+ requestChannel: 'telegram',
95
+ decisionChannel: 'telegram',
96
+ expiresAt: futureExpiry,
97
+ ...overrides,
98
+ };
99
+ }
100
+
101
+ function makeContext(overrides: Partial<ToolContext> = {}): ToolContext {
102
+ return {
103
+ workingDir: testDir,
104
+ sessionId: 'session-1',
105
+ conversationId: 'conv-1',
106
+ assistantId: 'self',
107
+ requestId: 'req-1',
108
+ guardianActorRole: 'non-guardian',
109
+ ...overrides,
110
+ };
111
+ }
112
+
113
+ // ===========================================================================
114
+ // TESTS
115
+ // ===========================================================================
116
+
117
+ describe('ToolApprovalHandler / pre-exec gate grant check', () => {
118
+ const handler = new ToolApprovalHandler();
119
+ const events: ToolLifecycleEvent[] = [];
120
+ const emitLifecycleEvent = (event: ToolLifecycleEvent) => { events.push(event); };
121
+
122
+ beforeEach(() => {
123
+ clearTables();
124
+ events.length = 0;
125
+ });
126
+
127
+ test('untrusted actor + matching tool_signature grant -> allow', async () => {
128
+ const toolName = 'bash';
129
+ const input = { command: 'ls -la' };
130
+ const digest = computeToolApprovalDigest(toolName, input);
131
+
132
+ // Mint a grant that matches the invocation
133
+ const mintResult = mintGrantFromDecision(
134
+ mintParams({
135
+ scopeMode: 'tool_signature',
136
+ toolName,
137
+ inputDigest: digest,
138
+ }),
139
+ );
140
+ expect(mintResult.ok).toBe(true);
141
+
142
+ const context = makeContext({ guardianActorRole: 'non-guardian' });
143
+ const result = await handler.checkPreExecutionGates(
144
+ toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
145
+ );
146
+
147
+ expect(result.allowed).toBe(true);
148
+ // No permission_denied events should have been emitted
149
+ const deniedEvents = events.filter((e) => e.type === 'permission_denied');
150
+ expect(deniedEvents.length).toBe(0);
151
+ });
152
+
153
+ test('untrusted actor + no matching grant -> deny with guardian_approval_required', async () => {
154
+ const toolName = 'bash';
155
+ const input = { command: 'rm -rf /' };
156
+
157
+ const context = makeContext({ guardianActorRole: 'non-guardian' });
158
+ const result = await handler.checkPreExecutionGates(
159
+ toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
160
+ );
161
+
162
+ expect(result.allowed).toBe(false);
163
+ if (result.allowed) return;
164
+ expect(result.result.isError).toBe(true);
165
+ expect(result.result.content).toContain('guardian approval');
166
+
167
+ // A permission_denied event should have been emitted
168
+ const deniedEvents = events.filter((e) => e.type === 'permission_denied');
169
+ expect(deniedEvents.length).toBe(1);
170
+ });
171
+
172
+ test('unverified_channel actor + matching grant -> allow', async () => {
173
+ const toolName = 'bash';
174
+ const input = { command: 'echo hello' };
175
+ const digest = computeToolApprovalDigest(toolName, input);
176
+
177
+ mintGrantFromDecision(
178
+ mintParams({
179
+ scopeMode: 'tool_signature',
180
+ toolName,
181
+ inputDigest: digest,
182
+ }),
183
+ );
184
+
185
+ const context = makeContext({ guardianActorRole: 'unverified_channel' });
186
+ const result = await handler.checkPreExecutionGates(
187
+ toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
188
+ );
189
+
190
+ expect(result.allowed).toBe(true);
191
+ });
192
+
193
+ test('unverified_channel actor + no grant -> deny', async () => {
194
+ const toolName = 'bash';
195
+ const input = { command: 'deploy' };
196
+
197
+ const context = makeContext({ guardianActorRole: 'unverified_channel' });
198
+ const result = await handler.checkPreExecutionGates(
199
+ toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
200
+ );
201
+
202
+ expect(result.allowed).toBe(false);
203
+ if (result.allowed) return;
204
+ expect(result.result.content).toContain('verified channel identity');
205
+ });
206
+
207
+ test('grant is one-time: second invocation with same input denied', async () => {
208
+ const toolName = 'bash';
209
+ const input = { command: 'ls' };
210
+ const digest = computeToolApprovalDigest(toolName, input);
211
+
212
+ mintGrantFromDecision(
213
+ mintParams({
214
+ scopeMode: 'tool_signature',
215
+ toolName,
216
+ inputDigest: digest,
217
+ }),
218
+ );
219
+
220
+ const context = makeContext({ guardianActorRole: 'non-guardian' });
221
+
222
+ // First invocation — should consume the grant and allow
223
+ const first = await handler.checkPreExecutionGates(
224
+ toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
225
+ );
226
+ expect(first.allowed).toBe(true);
227
+
228
+ // Second invocation — grant already consumed, should deny
229
+ const second = await handler.checkPreExecutionGates(
230
+ toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
231
+ );
232
+ expect(second.allowed).toBe(false);
233
+ });
234
+
235
+ test('grant with mismatched input digest -> deny', async () => {
236
+ const toolName = 'bash';
237
+ const grantInput = { command: 'ls' };
238
+ const invokeInput = { command: 'rm -rf /' };
239
+ const grantDigest = computeToolApprovalDigest(toolName, grantInput);
240
+
241
+ mintGrantFromDecision(
242
+ mintParams({
243
+ scopeMode: 'tool_signature',
244
+ toolName,
245
+ inputDigest: grantDigest,
246
+ }),
247
+ );
248
+
249
+ const context = makeContext({ guardianActorRole: 'non-guardian' });
250
+ const result = await handler.checkPreExecutionGates(
251
+ toolName, invokeInput, context, 'host', 'high', Date.now(), emitLifecycleEvent,
252
+ );
253
+
254
+ expect(result.allowed).toBe(false);
255
+ });
256
+
257
+ test('expired grant -> deny', async () => {
258
+ const toolName = 'bash';
259
+ const input = { command: 'ls' };
260
+ const digest = computeToolApprovalDigest(toolName, input);
261
+ const pastExpiry = new Date(Date.now() - 60_000).toISOString();
262
+
263
+ mintGrantFromDecision(
264
+ mintParams({
265
+ scopeMode: 'tool_signature',
266
+ toolName,
267
+ inputDigest: digest,
268
+ expiresAt: pastExpiry,
269
+ }),
270
+ );
271
+
272
+ const context = makeContext({ guardianActorRole: 'non-guardian' });
273
+ const result = await handler.checkPreExecutionGates(
274
+ toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
275
+ );
276
+
277
+ expect(result.allowed).toBe(false);
278
+ });
279
+
280
+ test('guardian actor bypasses grant check entirely (no grant needed)', async () => {
281
+ const toolName = 'bash';
282
+ const input = { command: 'deploy' };
283
+
284
+ // No grants minted at all
285
+ const context = makeContext({ guardianActorRole: 'guardian' });
286
+ const result = await handler.checkPreExecutionGates(
287
+ toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
288
+ );
289
+
290
+ // Guardian should pass through — the untrusted gate is not triggered
291
+ expect(result.allowed).toBe(true);
292
+ });
293
+
294
+ test('undefined actor role (desktop/trusted) bypasses grant check', async () => {
295
+ const toolName = 'bash';
296
+ const input = { command: 'deploy' };
297
+
298
+ const context = makeContext({ guardianActorRole: undefined });
299
+ const result = await handler.checkPreExecutionGates(
300
+ toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
301
+ );
302
+
303
+ expect(result.allowed).toBe(true);
304
+ });
305
+
306
+ test('grant with matching request_id scope -> allow', async () => {
307
+ const toolName = 'bash';
308
+ const input = { command: 'ls' };
309
+
310
+ mintGrantFromDecision(
311
+ mintParams({
312
+ scopeMode: 'request_id',
313
+ requestId: 'req-1',
314
+ }),
315
+ );
316
+
317
+ const context = makeContext({ guardianActorRole: 'non-guardian', requestId: 'req-1' });
318
+ const result = await handler.checkPreExecutionGates(
319
+ toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
320
+ );
321
+
322
+ expect(result.allowed).toBe(true);
323
+ });
324
+
325
+ test('grant with context fields (conversationId) must match', async () => {
326
+ const toolName = 'bash';
327
+ const input = { command: 'ls' };
328
+ const digest = computeToolApprovalDigest(toolName, input);
329
+
330
+ mintGrantFromDecision(
331
+ mintParams({
332
+ scopeMode: 'tool_signature',
333
+ toolName,
334
+ inputDigest: digest,
335
+ conversationId: 'conv-other',
336
+ }),
337
+ );
338
+
339
+ // Context conversationId does not match the grant's conversationId
340
+ const context = makeContext({
341
+ guardianActorRole: 'non-guardian',
342
+ conversationId: 'conv-1',
343
+ });
344
+ const result = await handler.checkPreExecutionGates(
345
+ toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
346
+ );
347
+
348
+ expect(result.allowed).toBe(false);
349
+ });
350
+ });
@@ -838,6 +838,7 @@ describe('Trust Store', () => {
838
838
  expect(match).not.toBeNull();
839
839
  expect(match!.id).toBe('default:allow-bash-rm-bootstrap');
840
840
  expect(match!.decision).toBe('allow');
841
+ expect(match!.allowHighRisk).toBe(true);
841
842
  // Outside workspace, the bootstrap rule doesn't match — the global
842
843
  // default:allow-bash-global rule matches instead (not the bootstrap rule).
843
844
  const other = findHighestPriorityRule('bash', ['rm BOOTSTRAP.md'], '/tmp/other-project');
@@ -852,6 +853,7 @@ describe('Trust Store', () => {
852
853
  expect(match).not.toBeNull();
853
854
  expect(match!.id).toBe('default:allow-bash-rm-updates');
854
855
  expect(match!.decision).toBe('allow');
856
+ expect(match!.allowHighRisk).toBe(true);
855
857
  // Outside workspace, should NOT match the updates rule
856
858
  const other = findHighestPriorityRule('bash', ['rm UPDATES.md'], '/tmp/other-project');
857
859
  expect(other).not.toBeNull();
@@ -276,7 +276,9 @@ describe('trusted contact lifecycle notification signals', () => {
276
276
 
277
277
  await handleChannelInbound(guardianReq, undefined, TEST_BEARER_TOKEN);
278
278
 
279
- // Should emit guardian_decision (approved) and verification_sent signals
279
+ // guardian_decision should NOT fire at approval time when verification
280
+ // is still pending — it would cause the notification pipeline to send a
281
+ // premature "approved" message to the guardian's chat.
280
282
  const guardianDecisionSignals = emitSignalCalls.filter(
281
283
  (c) => c.sourceEventName === 'ingress.trusted_contact.guardian_decision',
282
284
  );
@@ -284,19 +286,15 @@ describe('trusted contact lifecycle notification signals', () => {
284
286
  (c) => c.sourceEventName === 'ingress.trusted_contact.verification_sent',
285
287
  );
286
288
 
287
- expect(guardianDecisionSignals.length).toBe(1);
289
+ expect(guardianDecisionSignals.length).toBe(0);
288
290
  expect(verificationSentSignals.length).toBe(1);
289
291
 
290
- // Verify guardian_decision payload
291
- const gdPayload = guardianDecisionSignals[0].contextPayload as Record<string, unknown>;
292
- expect(gdPayload.decision).toBe('approved');
293
- expect(gdPayload.requesterExternalUserId).toBe('requester-user-456');
294
- expect(gdPayload.decidedByExternalUserId).toBe('guardian-user-789');
295
-
296
- // Verify verification_sent payload
297
- const vsPayload = verificationSentSignals[0].contextPayload as Record<string, unknown>;
292
+ // Verify verification_sent payload and that it's suppressed from delivery
293
+ const vsSignal = verificationSentSignals[0];
294
+ const vsPayload = vsSignal.contextPayload as Record<string, unknown>;
298
295
  expect(vsPayload.requesterExternalUserId).toBe('requester-user-456');
299
296
  expect(vsPayload.verificationSessionId).toBeDefined();
297
+ expect((vsSignal.attentionHints as Record<string, unknown>).visibleInSourceNow).toBe(true);
300
298
 
301
299
  // Should NOT emit denied signal
302
300
  const deniedSignals = emitSignalCalls.filter(