@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
@@ -0,0 +1,444 @@
1
+ /**
2
+ * Security test matrix for channel-agnostic scoped approval grants.
3
+ *
4
+ * This file covers scenarios NOT already tested in:
5
+ * - scoped-approval-grants.test.ts (CRUD, digest, basic consume semantics)
6
+ * - voice-scoped-grant-consumer.test.ts (voice bridge integration)
7
+ * - guardian-grant-minting.test.ts (grant minting on approval decisions)
8
+ *
9
+ * Additional scenarios tested here:
10
+ * 6. Requester identity mismatch denied
11
+ * 8. Concurrent consume attempts: only one succeeds
12
+ * 12. Restart behavior remains fail-closed — grants stored in persistent DB
13
+ *
14
+ * Cross-reference:
15
+ * 1. Voice happy path — voice-scoped-grant-consumer.test.ts
16
+ * 2. Replay denied — scoped-approval-grants.test.ts + voice-scoped-grant-consumer.test.ts
17
+ * 3. Tool mismatch denied — scoped-approval-grants.test.ts + voice-scoped-grant-consumer.test.ts
18
+ * 4. Input mismatch denied — scoped-approval-grants.test.ts
19
+ * 5. Execution-channel mismatch denied — scoped-approval-grants.test.ts
20
+ * 7. Expired grant denied — scoped-approval-grants.test.ts
21
+ * 9. Stale decision cannot mint extra grant — guardian-grant-minting.test.ts
22
+ * 10. Informational ASK_GUARDIAN cannot mint grant — guardian-grant-minting.test.ts
23
+ * 11. Guardian identity mismatch cannot mint grant — guardian-grant-minting.test.ts
24
+ */
25
+
26
+ import { mkdtempSync, rmSync } from 'node:fs';
27
+ import { tmpdir } from 'node:os';
28
+ import { join } from 'node:path';
29
+
30
+ import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test';
31
+
32
+ const testDir = mkdtempSync(join(tmpdir(), 'scoped-grant-security-matrix-'));
33
+
34
+ mock.module('../util/platform.js', () => ({
35
+ getDataDir: () => testDir,
36
+ isMacOS: () => process.platform === 'darwin',
37
+ isLinux: () => process.platform === 'linux',
38
+ isWindows: () => process.platform === 'win32',
39
+ getSocketPath: () => join(testDir, 'test.sock'),
40
+ getPidPath: () => join(testDir, 'test.pid'),
41
+ getDbPath: () => join(testDir, 'test.db'),
42
+ getLogPath: () => join(testDir, 'test.log'),
43
+ ensureDataDir: () => {},
44
+ migrateToDataLayout: () => {},
45
+ migrateToWorkspaceLayout: () => {},
46
+ }));
47
+
48
+ mock.module('../util/logger.js', () => ({
49
+ getLogger: () =>
50
+ new Proxy({} as Record<string, unknown>, {
51
+ get: () => () => {},
52
+ }),
53
+ isDebug: () => false,
54
+ truncateForLog: (value: string) => value,
55
+ }));
56
+
57
+ import { getDb, initializeDb, resetDb } from '../memory/db.js';
58
+ import { scopedApprovalGrants } from '../memory/schema.js';
59
+ import {
60
+ _internal,
61
+ type CreateScopedApprovalGrantParams,
62
+ } from '../memory/scoped-approval-grants.js';
63
+
64
+ const { consumeScopedApprovalGrantByToolSignature, createScopedApprovalGrant } = _internal;
65
+ import { computeToolApprovalDigest } from '../security/tool-approval-digest.js';
66
+
67
+ initializeDb();
68
+
69
+ function clearTables(): void {
70
+ const db = getDb();
71
+ db.delete(scopedApprovalGrants).run();
72
+ }
73
+
74
+ afterAll(() => {
75
+ resetDb();
76
+ try {
77
+ rmSync(testDir, { recursive: true });
78
+ } catch {
79
+ /* best effort */
80
+ }
81
+ });
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Helper to build grant params with sensible defaults
85
+ // ---------------------------------------------------------------------------
86
+
87
+ function grantParams(overrides: Partial<CreateScopedApprovalGrantParams> = {}): CreateScopedApprovalGrantParams {
88
+ const futureExpiry = new Date(Date.now() + 60_000).toISOString();
89
+ return {
90
+ assistantId: 'self',
91
+ scopeMode: 'tool_signature',
92
+ toolName: 'bash',
93
+ inputDigest: computeToolApprovalDigest('bash', { cmd: 'ls' }),
94
+ requestChannel: 'telegram',
95
+ decisionChannel: 'telegram',
96
+ expiresAt: futureExpiry,
97
+ ...overrides,
98
+ };
99
+ }
100
+
101
+ // ===========================================================================
102
+ // 6. Requester identity mismatch denied
103
+ // ===========================================================================
104
+
105
+ describe('security matrix: requester identity mismatch', () => {
106
+ beforeEach(() => clearTables());
107
+
108
+ test('grant scoped to a specific requester cannot be consumed by a different requester', () => {
109
+ const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
110
+ createScopedApprovalGrant(
111
+ grantParams({
112
+ toolName: 'bash',
113
+ inputDigest: digest,
114
+ requesterExternalUserId: 'user-alice',
115
+ }),
116
+ );
117
+
118
+ // Attempt to consume as a different user
119
+ const wrongUser = consumeScopedApprovalGrantByToolSignature({
120
+ toolName: 'bash',
121
+ inputDigest: digest,
122
+ consumingRequestId: 'c1',
123
+ requesterExternalUserId: 'user-bob',
124
+ });
125
+ expect(wrongUser.ok).toBe(false);
126
+
127
+ // Correct user succeeds
128
+ const correctUser = consumeScopedApprovalGrantByToolSignature({
129
+ toolName: 'bash',
130
+ inputDigest: digest,
131
+ consumingRequestId: 'c2',
132
+ requesterExternalUserId: 'user-alice',
133
+ });
134
+ expect(correctUser.ok).toBe(true);
135
+ });
136
+
137
+ test('grant with null requesterExternalUserId allows any requester (wildcard)', () => {
138
+ const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
139
+ createScopedApprovalGrant(
140
+ grantParams({
141
+ toolName: 'bash',
142
+ inputDigest: digest,
143
+ requesterExternalUserId: null,
144
+ }),
145
+ );
146
+
147
+ // Any user can consume when requester is null (wildcard)
148
+ const result = consumeScopedApprovalGrantByToolSignature({
149
+ toolName: 'bash',
150
+ inputDigest: digest,
151
+ consumingRequestId: 'c1',
152
+ requesterExternalUserId: 'user-anyone',
153
+ });
154
+ expect(result.ok).toBe(true);
155
+ });
156
+
157
+ test('consume without providing requester only matches grants with null requester', () => {
158
+ const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
159
+
160
+ // Grant scoped to a specific requester
161
+ createScopedApprovalGrant(
162
+ grantParams({
163
+ toolName: 'bash',
164
+ inputDigest: digest,
165
+ requesterExternalUserId: 'user-alice',
166
+ }),
167
+ );
168
+
169
+ // Consume without specifying requester — should NOT match a requester-scoped grant
170
+ const result = consumeScopedApprovalGrantByToolSignature({
171
+ toolName: 'bash',
172
+ inputDigest: digest,
173
+ consumingRequestId: 'c1',
174
+ // No requesterExternalUserId provided
175
+ });
176
+ expect(result.ok).toBe(false);
177
+ });
178
+ });
179
+
180
+ // ===========================================================================
181
+ // 8. Concurrent consume attempts: only one succeeds
182
+ // ===========================================================================
183
+
184
+ describe('security matrix: concurrent consume (CAS)', () => {
185
+ beforeEach(() => clearTables());
186
+
187
+ test('only one of multiple concurrent consumers succeeds for the same grant', () => {
188
+ const digest = computeToolApprovalDigest('bash', { cmd: 'rm -rf /' });
189
+ createScopedApprovalGrant(
190
+ grantParams({
191
+ toolName: 'bash',
192
+ inputDigest: digest,
193
+ }),
194
+ );
195
+
196
+ // Simulate concurrent consumers racing to consume the same grant.
197
+ // Since SQLite is synchronous in Bun, we simulate by issuing
198
+ // back-to-back consume calls — the CAS mechanism ensures only the
199
+ // first succeeds.
200
+ const results: boolean[] = [];
201
+ for (let i = 0; i < 5; i++) {
202
+ const result = consumeScopedApprovalGrantByToolSignature({
203
+ toolName: 'bash',
204
+ inputDigest: digest,
205
+ consumingRequestId: `concurrent-consumer-${i}`,
206
+ });
207
+ results.push(result.ok);
208
+ }
209
+
210
+ // Exactly one should succeed
211
+ const successes = results.filter(Boolean);
212
+ expect(successes.length).toBe(1);
213
+
214
+ // The first consumer should win
215
+ expect(results[0]).toBe(true);
216
+ });
217
+
218
+ test('with multiple matching grants, each consumer gets at most one grant', () => {
219
+ const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
220
+
221
+ // Create 3 grants for the same tool signature
222
+ for (let i = 0; i < 3; i++) {
223
+ createScopedApprovalGrant(
224
+ grantParams({
225
+ toolName: 'bash',
226
+ inputDigest: digest,
227
+ }),
228
+ );
229
+ }
230
+
231
+ // 5 consumers compete for 3 grants
232
+ const results: boolean[] = [];
233
+ for (let i = 0; i < 5; i++) {
234
+ const result = consumeScopedApprovalGrantByToolSignature({
235
+ toolName: 'bash',
236
+ inputDigest: digest,
237
+ consumingRequestId: `consumer-${i}`,
238
+ });
239
+ results.push(result.ok);
240
+ }
241
+
242
+ // Exactly 3 should succeed (one per grant)
243
+ const successes = results.filter(Boolean);
244
+ expect(successes.length).toBe(3);
245
+
246
+ // The last 2 should fail
247
+ expect(results[3]).toBe(false);
248
+ expect(results[4]).toBe(false);
249
+ });
250
+ });
251
+
252
+ // ===========================================================================
253
+ // 12. Restart behavior remains fail-closed — grants stored in persistent DB
254
+ // ===========================================================================
255
+
256
+ describe('security matrix: persistence and fail-closed behavior', () => {
257
+ beforeEach(() => clearTables());
258
+
259
+ test('grants survive DB re-initialization (simulating daemon restart)', () => {
260
+ const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
261
+
262
+ // Create a grant
263
+ const grant = createScopedApprovalGrant(
264
+ grantParams({
265
+ toolName: 'bash',
266
+ inputDigest: digest,
267
+ }),
268
+ );
269
+ expect(grant.status).toBe('active');
270
+
271
+ // Re-initialize the DB (simulates daemon restart — the SQLite file persists)
272
+ initializeDb();
273
+
274
+ // The grant should still be consumable after restart
275
+ const result = consumeScopedApprovalGrantByToolSignature({
276
+ toolName: 'bash',
277
+ inputDigest: digest,
278
+ consumingRequestId: 'post-restart-consumer',
279
+ });
280
+ expect(result.ok).toBe(true);
281
+ expect(result.grant!.id).toBe(grant.id);
282
+ });
283
+
284
+ test('consumed grants remain consumed after DB re-initialization', () => {
285
+ const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
286
+
287
+ createScopedApprovalGrant(
288
+ grantParams({
289
+ toolName: 'bash',
290
+ inputDigest: digest,
291
+ }),
292
+ );
293
+
294
+ // Consume the grant
295
+ const first = consumeScopedApprovalGrantByToolSignature({
296
+ toolName: 'bash',
297
+ inputDigest: digest,
298
+ consumingRequestId: 'pre-restart-consumer',
299
+ });
300
+ expect(first.ok).toBe(true);
301
+
302
+ // Re-initialize the DB (simulates daemon restart)
303
+ initializeDb();
304
+
305
+ // The consumed grant must NOT be consumable again after restart
306
+ const second = consumeScopedApprovalGrantByToolSignature({
307
+ toolName: 'bash',
308
+ inputDigest: digest,
309
+ consumingRequestId: 'post-restart-consumer',
310
+ });
311
+ expect(second.ok).toBe(false);
312
+ });
313
+
314
+ test('no grants means fail-closed (deny by default)', () => {
315
+ // Empty grant table — no grants at all
316
+ const digest = computeToolApprovalDigest('bash', { cmd: 'dangerous-command' });
317
+
318
+ const result = consumeScopedApprovalGrantByToolSignature({
319
+ toolName: 'bash',
320
+ inputDigest: digest,
321
+ consumingRequestId: 'consumer-1',
322
+ });
323
+
324
+ // Must fail closed — no grant = no permission
325
+ expect(result.ok).toBe(false);
326
+ expect(result.grant).toBeNull();
327
+ });
328
+ });
329
+
330
+ // ===========================================================================
331
+ // Combined cross-scope invariants
332
+ // ===========================================================================
333
+
334
+ describe('security matrix: cross-scope invariants', () => {
335
+ beforeEach(() => clearTables());
336
+
337
+ test('grant for one assistant cannot be consumed by another assistant', () => {
338
+ const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
339
+ createScopedApprovalGrant(
340
+ grantParams({
341
+ toolName: 'bash',
342
+ inputDigest: digest,
343
+ assistantId: 'assistant-alpha',
344
+ }),
345
+ );
346
+
347
+ // Attempt consumption from a different assistant
348
+ const wrongAssistant = consumeScopedApprovalGrantByToolSignature({
349
+ toolName: 'bash',
350
+ inputDigest: digest,
351
+ consumingRequestId: 'c1',
352
+ assistantId: 'assistant-beta',
353
+ });
354
+ expect(wrongAssistant.ok).toBe(false);
355
+
356
+ // Correct assistant succeeds
357
+ const correctAssistant = consumeScopedApprovalGrantByToolSignature({
358
+ toolName: 'bash',
359
+ inputDigest: digest,
360
+ consumingRequestId: 'c2',
361
+ assistantId: 'assistant-alpha',
362
+ });
363
+ expect(correctAssistant.ok).toBe(true);
364
+ });
365
+
366
+ test('all scope fields must match simultaneously for consumption', () => {
367
+ const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
368
+
369
+ // Create a maximally-scoped grant
370
+ createScopedApprovalGrant(
371
+ grantParams({
372
+ toolName: 'bash',
373
+ inputDigest: digest,
374
+ assistantId: 'self',
375
+ executionChannel: 'voice',
376
+ conversationId: 'conv-123',
377
+ callSessionId: 'call-456',
378
+ requesterExternalUserId: 'user-alice',
379
+ }),
380
+ );
381
+
382
+ // Each field mismatch should independently cause failure:
383
+
384
+ // Wrong execution channel
385
+ expect(consumeScopedApprovalGrantByToolSignature({
386
+ toolName: 'bash',
387
+ inputDigest: digest,
388
+ consumingRequestId: 'c-chan',
389
+ assistantId: 'self',
390
+ executionChannel: 'sms',
391
+ conversationId: 'conv-123',
392
+ callSessionId: 'call-456',
393
+ requesterExternalUserId: 'user-alice',
394
+ }).ok).toBe(false);
395
+
396
+ // Wrong conversation
397
+ expect(consumeScopedApprovalGrantByToolSignature({
398
+ toolName: 'bash',
399
+ inputDigest: digest,
400
+ consumingRequestId: 'c-conv',
401
+ assistantId: 'self',
402
+ executionChannel: 'voice',
403
+ conversationId: 'conv-999',
404
+ callSessionId: 'call-456',
405
+ requesterExternalUserId: 'user-alice',
406
+ }).ok).toBe(false);
407
+
408
+ // Wrong call session
409
+ expect(consumeScopedApprovalGrantByToolSignature({
410
+ toolName: 'bash',
411
+ inputDigest: digest,
412
+ consumingRequestId: 'c-call',
413
+ assistantId: 'self',
414
+ executionChannel: 'voice',
415
+ conversationId: 'conv-123',
416
+ callSessionId: 'call-999',
417
+ requesterExternalUserId: 'user-alice',
418
+ }).ok).toBe(false);
419
+
420
+ // Wrong requester
421
+ expect(consumeScopedApprovalGrantByToolSignature({
422
+ toolName: 'bash',
423
+ inputDigest: digest,
424
+ consumingRequestId: 'c-user',
425
+ assistantId: 'self',
426
+ executionChannel: 'voice',
427
+ conversationId: 'conv-123',
428
+ callSessionId: 'call-456',
429
+ requesterExternalUserId: 'user-bob',
430
+ }).ok).toBe(false);
431
+
432
+ // All fields match — succeeds
433
+ expect(consumeScopedApprovalGrantByToolSignature({
434
+ toolName: 'bash',
435
+ inputDigest: digest,
436
+ consumingRequestId: 'c-all',
437
+ assistantId: 'self',
438
+ executionChannel: 'voice',
439
+ conversationId: 'conv-123',
440
+ callSessionId: 'call-456',
441
+ requesterExternalUserId: 'user-alice',
442
+ }).ok).toBe(true);
443
+ });
444
+ });
@@ -169,25 +169,7 @@ describe('session-manager', () => {
169
169
  expect(() => getSessionEnv(session.id)).toThrow(/not active/);
170
170
  });
171
171
 
172
- test('returns host.docker.internal URL when dockerMode is true', async () => {
173
- const session = createSession(CONV_ID, CRED_IDS);
174
- const started = await startSession(session.id);
175
- const env = getSessionEnv(session.id, { dockerMode: true });
176
-
177
- expect(env.HTTP_PROXY).toBe(`http://host.docker.internal:${started.port}`);
178
- expect(env.HTTPS_PROXY).toBe(`http://host.docker.internal:${started.port}`);
179
- });
180
-
181
- test('returns 127.0.0.1 URL when dockerMode is false', async () => {
182
- const session = createSession(CONV_ID, CRED_IDS);
183
- const started = await startSession(session.id);
184
- const env = getSessionEnv(session.id, { dockerMode: false });
185
-
186
- expect(env.HTTP_PROXY).toBe(`http://127.0.0.1:${started.port}`);
187
- expect(env.HTTPS_PROXY).toBe(`http://127.0.0.1:${started.port}`);
188
- });
189
-
190
- test('returns 127.0.0.1 URL when no options are passed', async () => {
172
+ test('returns 127.0.0.1 URL for active session', async () => {
191
173
  const session = createSession(CONV_ID, CRED_IDS);
192
174
  const started = await startSession(session.id);
193
175
  const env = getSessionEnv(session.id);
@@ -1,4 +1,4 @@
1
- import { describe, expect, mock, test } from 'bun:test';
1
+ import { beforeEach, describe, expect, mock, test } from 'bun:test';
2
2
 
3
3
  import type { Message } from '../providers/types.js';
4
4
 
@@ -49,14 +49,27 @@ mock.module('../security/secret-allowlist.js', () => ({
49
49
  }));
50
50
 
51
51
  // Mutable store so each test can configure its own messages
52
- let mockDbMessages: Array<{ id: string; role: string; content: string }> = [];
52
+ let mockDbMessages: Array<{ id: string; role: string; content: string; metadata?: string | null }> = [];
53
53
  let mockConversation: Record<string, unknown> | null = null;
54
+ let nextMockMessageId = 1;
54
55
 
55
56
  mock.module('../memory/conversation-store.js', () => ({
56
57
  getMessages: () => mockDbMessages,
57
58
  getConversation: () => mockConversation,
58
59
  createConversation: () => ({ id: 'conv-1' }),
59
60
  listConversations: () => [],
61
+ addMessage: async (_conversationId: string, role: string, content: string, metadata?: Record<string, unknown>) => {
62
+ const id = `persisted-${nextMockMessageId++}`;
63
+ mockDbMessages.push({
64
+ id,
65
+ role,
66
+ content,
67
+ metadata: metadata ? JSON.stringify(metadata) : null,
68
+ });
69
+ return { id };
70
+ },
71
+ setConversationOriginChannelIfUnset: () => {},
72
+ setConversationOriginInterfaceIfUnset: () => {},
60
73
  }));
61
74
 
62
75
  import { Session } from '../daemon/session.js';
@@ -67,6 +80,10 @@ function makeSession(): Session {
67
80
  }
68
81
 
69
82
  describe('loadFromDb history repair', () => {
83
+ beforeEach(() => {
84
+ nextMockMessageId = 1;
85
+ });
86
+
70
87
  test('repairs corrupt persisted history: missing tool_result inserted', async () => {
71
88
  mockConversation = {
72
89
  id: 'conv-1',
@@ -220,4 +237,154 @@ describe('loadFromDb history repair', () => {
220
237
  expect(messages).toHaveLength(2);
221
238
  expect(messages[1].content).toEqual([{ type: 'text', text: 'Sure' }]);
222
239
  });
240
+
241
+ test('untrusted actor load hides guardian-provenance history and context summary', async () => {
242
+ mockConversation = {
243
+ id: 'conv-1',
244
+ contextSummary: 'Sensitive guardian summary',
245
+ contextCompactedMessageCount: 3,
246
+ totalInputTokens: 0,
247
+ totalOutputTokens: 0,
248
+ totalEstimatedCost: 0,
249
+ };
250
+ mockDbMessages = [
251
+ {
252
+ id: 'm1',
253
+ role: 'user',
254
+ content: JSON.stringify([{ type: 'text', text: 'Guardian secret question' }]),
255
+ metadata: JSON.stringify({ provenanceActorRole: 'guardian', provenanceSourceChannel: 'telegram' }),
256
+ },
257
+ {
258
+ id: 'm2',
259
+ role: 'assistant',
260
+ content: JSON.stringify([{ type: 'text', text: 'Guardian-only answer' }]),
261
+ metadata: JSON.stringify({ provenanceActorRole: 'guardian', provenanceSourceChannel: 'telegram' }),
262
+ },
263
+ {
264
+ id: 'm3',
265
+ role: 'user',
266
+ content: JSON.stringify([{ type: 'text', text: 'Untrusted follow-up' }]),
267
+ metadata: JSON.stringify({ provenanceActorRole: 'unverified_channel', provenanceSourceChannel: 'telegram' }),
268
+ },
269
+ {
270
+ id: 'm4',
271
+ role: 'assistant',
272
+ content: JSON.stringify([{ type: 'text', text: 'Untrusted-safe reply' }]),
273
+ metadata: JSON.stringify({ provenanceActorRole: 'unverified_channel', provenanceSourceChannel: 'telegram' }),
274
+ },
275
+ ];
276
+
277
+ const session = makeSession();
278
+ session.setGuardianContext({ actorRole: 'unverified_channel', sourceChannel: 'telegram' });
279
+ await session.loadFromDb();
280
+ const messages = session.getMessages();
281
+
282
+ expect(messages).toHaveLength(2);
283
+ expect(messages[0].role).toBe('user');
284
+ expect(messages[0].content).toEqual([{ type: 'text', text: 'Untrusted follow-up' }]);
285
+ expect(messages[1].role).toBe('assistant');
286
+ expect(messages[1].content).toEqual([{ type: 'text', text: 'Untrusted-safe reply' }]);
287
+ });
288
+
289
+ test('ensureActorScopedHistory reloads when actor role changes', async () => {
290
+ mockConversation = {
291
+ id: 'conv-1',
292
+ contextSummary: null,
293
+ contextCompactedMessageCount: 0,
294
+ totalInputTokens: 0,
295
+ totalOutputTokens: 0,
296
+ totalEstimatedCost: 0,
297
+ };
298
+ mockDbMessages = [
299
+ {
300
+ id: 'm1',
301
+ role: 'user',
302
+ content: JSON.stringify([{ type: 'text', text: 'Guardian question' }]),
303
+ metadata: JSON.stringify({ provenanceActorRole: 'guardian', provenanceSourceChannel: 'telegram' }),
304
+ },
305
+ {
306
+ id: 'm2',
307
+ role: 'assistant',
308
+ content: JSON.stringify([{ type: 'text', text: 'Guardian answer' }]),
309
+ metadata: JSON.stringify({ provenanceActorRole: 'guardian', provenanceSourceChannel: 'telegram' }),
310
+ },
311
+ {
312
+ id: 'm3',
313
+ role: 'user',
314
+ content: JSON.stringify([{ type: 'text', text: 'Unverified ping' }]),
315
+ metadata: JSON.stringify({ provenanceActorRole: 'unverified_channel', provenanceSourceChannel: 'telegram' }),
316
+ },
317
+ {
318
+ id: 'm4',
319
+ role: 'assistant',
320
+ content: JSON.stringify([{ type: 'text', text: 'Unverified reply' }]),
321
+ metadata: JSON.stringify({ provenanceActorRole: 'unverified_channel', provenanceSourceChannel: 'telegram' }),
322
+ },
323
+ ];
324
+
325
+ const session = makeSession();
326
+
327
+ session.setGuardianContext({ actorRole: 'guardian', sourceChannel: 'telegram' });
328
+ await session.ensureActorScopedHistory();
329
+ expect(session.getMessages()).toHaveLength(4);
330
+
331
+ session.setGuardianContext({ actorRole: 'unverified_channel', sourceChannel: 'telegram' });
332
+ await session.ensureActorScopedHistory();
333
+ const downgradedMessages = session.getMessages();
334
+ expect(downgradedMessages).toHaveLength(2);
335
+ expect(downgradedMessages[0].content).toEqual([{ type: 'text', text: 'Unverified ping' }]);
336
+ expect(downgradedMessages[1].content).toEqual([{ type: 'text', text: 'Unverified reply' }]);
337
+ });
338
+
339
+ test('persistUserMessage reloads actor-scoped history before persisting on role switch', async () => {
340
+ mockConversation = {
341
+ id: 'conv-1',
342
+ contextSummary: null,
343
+ contextCompactedMessageCount: 0,
344
+ totalInputTokens: 0,
345
+ totalOutputTokens: 0,
346
+ totalEstimatedCost: 0,
347
+ };
348
+ mockDbMessages = [
349
+ {
350
+ id: 'm1',
351
+ role: 'user',
352
+ content: JSON.stringify([{ type: 'text', text: 'Guardian-only question' }]),
353
+ metadata: JSON.stringify({ provenanceActorRole: 'guardian', provenanceSourceChannel: 'telegram' }),
354
+ },
355
+ {
356
+ id: 'm2',
357
+ role: 'assistant',
358
+ content: JSON.stringify([{ type: 'text', text: 'Guardian-only answer' }]),
359
+ metadata: JSON.stringify({ provenanceActorRole: 'guardian', provenanceSourceChannel: 'telegram' }),
360
+ },
361
+ {
362
+ id: 'm3',
363
+ role: 'user',
364
+ content: JSON.stringify([{ type: 'text', text: 'Unverified ping' }]),
365
+ metadata: JSON.stringify({ provenanceActorRole: 'unverified_channel', provenanceSourceChannel: 'telegram' }),
366
+ },
367
+ {
368
+ id: 'm4',
369
+ role: 'assistant',
370
+ content: JSON.stringify([{ type: 'text', text: 'Unverified reply' }]),
371
+ metadata: JSON.stringify({ provenanceActorRole: 'unverified_channel', provenanceSourceChannel: 'telegram' }),
372
+ },
373
+ ];
374
+
375
+ const session = makeSession();
376
+
377
+ session.setGuardianContext({ actorRole: 'unverified_channel', sourceChannel: 'telegram' });
378
+ await session.ensureActorScopedHistory();
379
+ expect(session.getMessages()).toHaveLength(2);
380
+
381
+ session.setGuardianContext({ actorRole: 'guardian', sourceChannel: 'telegram' });
382
+ await session.persistUserMessage('Guardian follow-up', []);
383
+ const messagesAfterPersist = session.getMessages();
384
+
385
+ expect(messagesAfterPersist).toHaveLength(5);
386
+ expect(messagesAfterPersist[0].content).toEqual([{ type: 'text', text: 'Guardian-only question' }]);
387
+ expect(messagesAfterPersist[1].content).toEqual([{ type: 'text', text: 'Guardian-only answer' }]);
388
+ expect(messagesAfterPersist[4].content).toEqual([{ type: 'text', text: 'Guardian follow-up' }]);
389
+ });
223
390
  });