@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
@@ -50,7 +50,6 @@ import { formatShellOutput, MAX_OUTPUT_LENGTH } from '../tools/shared/shell-outp
50
50
 
51
51
  // Dynamically import modules that depend on the mocked logger
52
52
  const { NativeBackend } = await import('../tools/terminal/backends/native.js');
53
- const { DockerBackend, _resetDockerChecks } = await import('../tools/terminal/backends/docker.js');
54
53
  const { wrapCommand } = await import('../tools/terminal/sandbox.js');
55
54
  const { ToolError } = await import('../util/errors.js');
56
55
 
@@ -589,7 +588,7 @@ describe('SandboxResult shape consistency across backends', () => {
589
588
  });
590
589
 
591
590
  test('wrapCommand disabled returns bash with sandboxed=false', () => {
592
- const result = wrapCommand('echo hi', '/tmp', { enabled: false, backend: 'native', docker: { image: 'vellum-sandbox:latest', shell: 'bash', cpus: 1, memoryMb: 512, pidsLimit: 256, network: 'none' } });
591
+ const result = wrapCommand('echo hi', '/tmp', { enabled: false });
593
592
 
594
593
  expect(result.command).toBe('bash');
595
594
  expect(result.args).toEqual(['-c', '--', 'echo hi']);
@@ -597,7 +596,7 @@ describe('SandboxResult shape consistency across backends', () => {
597
596
  });
598
597
 
599
598
  test('wrapCommand disabled result has same shape as enabled result', () => {
600
- const disabled = wrapCommand('echo hi', '/tmp', { enabled: false, backend: 'native', docker: { image: 'vellum-sandbox:latest', shell: 'bash', cpus: 1, memoryMb: 512, pidsLimit: 256, network: 'none' } });
599
+ const disabled = wrapCommand('echo hi', '/tmp', { enabled: false });
601
600
 
602
601
  // Both must have: command (string), args (string[]), sandboxed (boolean)
603
602
  expect(typeof disabled.command).toBe('string');
@@ -859,26 +858,20 @@ describe('Regression: edge cases in shared FileSystemOps', () => {
859
858
  });
860
859
 
861
860
  // ===========================================================================
862
- // 9. Docker backend shape parity with native backend
861
+ // 9. NativeBackend shape verification
863
862
  // ===========================================================================
864
863
 
865
- describe('DockerBackend vs NativeBackend: SandboxResult shape parity', () => {
866
- test('both backends produce results with command, args, sandboxed fields', () => {
867
- // Verify both classes have a wrap method that returns SandboxResult
864
+ describe('NativeBackend: SandboxResult shape', () => {
865
+ test('NativeBackend has a wrap method', () => {
868
866
  const native = new NativeBackend();
869
867
  expect(typeof native.wrap).toBe('function');
870
-
871
- _resetDockerChecks();
872
- // DockerBackend requires a real sandbox root for construction
873
- const docker = new DockerBackend(realpathSync('/tmp'), undefined, 1000, 1000);
874
- expect(typeof docker.wrap).toBe('function');
875
868
  });
876
869
 
877
870
  test('disabled sandbox returns consistent bash -c -- invocation', () => {
878
871
  // Various commands should all be wrapped consistently when disabled
879
872
  const commands = ['echo hello', 'ls -la', 'cat /etc/hosts', 'true && false'];
880
873
  for (const cmd of commands) {
881
- const result = wrapCommand(cmd, '/tmp', { enabled: false, backend: 'native', docker: { image: 'vellum-sandbox:latest', shell: 'bash', cpus: 1, memoryMb: 512, pidsLimit: 256, network: 'none' } });
874
+ const result = wrapCommand(cmd, '/tmp', { enabled: false });
882
875
  expect(result.command).toBe('bash');
883
876
  expect(result.args[0]).toBe('-c');
884
877
  expect(result.args[1]).toBe('--');
@@ -0,0 +1,521 @@
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(), 'scoped-grants-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
+ import { getDb, initializeDb, resetDb } from '../memory/db.js';
33
+ import { scopedApprovalGrants } from '../memory/schema.js';
34
+ import {
35
+ _internal,
36
+ type CreateScopedApprovalGrantParams,
37
+ expireScopedApprovalGrants,
38
+ revokeScopedApprovalGrantsForContext,
39
+ } from '../memory/scoped-approval-grants.js';
40
+
41
+ const { consumeScopedApprovalGrantByRequestId, consumeScopedApprovalGrantByToolSignature, createScopedApprovalGrant } = _internal;
42
+ import {
43
+ canonicalJsonSerialize,
44
+ computeToolApprovalDigest,
45
+ } from '../security/tool-approval-digest.js';
46
+
47
+ initializeDb();
48
+
49
+ function clearTables(): void {
50
+ const db = getDb();
51
+ db.delete(scopedApprovalGrants).run();
52
+ }
53
+
54
+ afterAll(() => {
55
+ resetDb();
56
+ try {
57
+ rmSync(testDir, { recursive: true });
58
+ } catch {
59
+ /* best effort */
60
+ }
61
+ });
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Helper to build grant params with sensible defaults
65
+ // ---------------------------------------------------------------------------
66
+
67
+ function grantParams(overrides: Partial<CreateScopedApprovalGrantParams> = {}): CreateScopedApprovalGrantParams {
68
+ const futureExpiry = new Date(Date.now() + 60_000).toISOString();
69
+ return {
70
+ assistantId: 'self',
71
+ scopeMode: 'request_id',
72
+ requestChannel: 'telegram',
73
+ decisionChannel: 'telegram',
74
+ expiresAt: futureExpiry,
75
+ ...overrides,
76
+ };
77
+ }
78
+
79
+ // ===========================================================================
80
+ // SCOPE MODE: request_id
81
+ // ===========================================================================
82
+
83
+ describe('scoped-approval-grants / request_id scope', () => {
84
+ beforeEach(() => clearTables());
85
+
86
+ test('create and consume by request_id succeeds', () => {
87
+ const grant = createScopedApprovalGrant(
88
+ grantParams({ scopeMode: 'request_id', requestId: 'req-1' }),
89
+ );
90
+ expect(grant.status).toBe('active');
91
+ expect(grant.requestId).toBe('req-1');
92
+
93
+ const result = consumeScopedApprovalGrantByRequestId('req-1', 'consumer-1', 'self');
94
+ expect(result.ok).toBe(true);
95
+ expect(result.grant).not.toBeNull();
96
+ expect(result.grant!.status).toBe('consumed');
97
+ expect(result.grant!.consumedByRequestId).toBe('consumer-1');
98
+ });
99
+
100
+ test('second consume of same grant fails (one-time use)', () => {
101
+ createScopedApprovalGrant(
102
+ grantParams({ scopeMode: 'request_id', requestId: 'req-2' }),
103
+ );
104
+
105
+ const first = consumeScopedApprovalGrantByRequestId('req-2', 'consumer-a', 'self');
106
+ expect(first.ok).toBe(true);
107
+
108
+ const second = consumeScopedApprovalGrantByRequestId('req-2', 'consumer-b', 'self');
109
+ expect(second.ok).toBe(false);
110
+ expect(second.grant).toBeNull();
111
+ });
112
+
113
+ test('consume fails when no matching grant exists', () => {
114
+ const result = consumeScopedApprovalGrantByRequestId('nonexistent', 'consumer-x', 'self');
115
+ expect(result.ok).toBe(false);
116
+ });
117
+
118
+ test('expired grant cannot be consumed', () => {
119
+ const pastExpiry = new Date(Date.now() - 1_000).toISOString();
120
+ createScopedApprovalGrant(
121
+ grantParams({ scopeMode: 'request_id', requestId: 'req-expired', expiresAt: pastExpiry }),
122
+ );
123
+
124
+ const result = consumeScopedApprovalGrantByRequestId('req-expired', 'consumer-1', 'self');
125
+ expect(result.ok).toBe(false);
126
+ });
127
+ });
128
+
129
+ // ===========================================================================
130
+ // SCOPE MODE: tool_signature
131
+ // ===========================================================================
132
+
133
+ describe('scoped-approval-grants / tool_signature scope', () => {
134
+ beforeEach(() => clearTables());
135
+
136
+ test('create and consume by tool signature succeeds', () => {
137
+ const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
138
+ const grant = createScopedApprovalGrant(
139
+ grantParams({
140
+ scopeMode: 'tool_signature',
141
+ toolName: 'bash',
142
+ inputDigest: digest,
143
+ }),
144
+ );
145
+ expect(grant.status).toBe('active');
146
+ expect(grant.toolName).toBe('bash');
147
+
148
+ const result = consumeScopedApprovalGrantByToolSignature({
149
+ toolName: 'bash',
150
+ inputDigest: digest,
151
+ consumingRequestId: 'consumer-1',
152
+ });
153
+ expect(result.ok).toBe(true);
154
+ expect(result.grant!.status).toBe('consumed');
155
+ });
156
+
157
+ test('second consume of tool_signature grant fails', () => {
158
+ const digest = computeToolApprovalDigest('bash', { cmd: 'rm -rf' });
159
+ createScopedApprovalGrant(
160
+ grantParams({
161
+ scopeMode: 'tool_signature',
162
+ toolName: 'bash',
163
+ inputDigest: digest,
164
+ }),
165
+ );
166
+
167
+ const first = consumeScopedApprovalGrantByToolSignature({
168
+ toolName: 'bash',
169
+ inputDigest: digest,
170
+ consumingRequestId: 'c1',
171
+ });
172
+ expect(first.ok).toBe(true);
173
+
174
+ const second = consumeScopedApprovalGrantByToolSignature({
175
+ toolName: 'bash',
176
+ inputDigest: digest,
177
+ consumingRequestId: 'c2',
178
+ });
179
+ expect(second.ok).toBe(false);
180
+ });
181
+
182
+ test('mismatched input digest fails consume', () => {
183
+ const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
184
+ createScopedApprovalGrant(
185
+ grantParams({
186
+ scopeMode: 'tool_signature',
187
+ toolName: 'bash',
188
+ inputDigest: digest,
189
+ }),
190
+ );
191
+
192
+ const wrongDigest = computeToolApprovalDigest('bash', { cmd: 'pwd' });
193
+ const result = consumeScopedApprovalGrantByToolSignature({
194
+ toolName: 'bash',
195
+ inputDigest: wrongDigest,
196
+ consumingRequestId: 'c1',
197
+ });
198
+ expect(result.ok).toBe(false);
199
+ });
200
+
201
+ test('mismatched tool name fails consume', () => {
202
+ const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
203
+ createScopedApprovalGrant(
204
+ grantParams({
205
+ scopeMode: 'tool_signature',
206
+ toolName: 'bash',
207
+ inputDigest: digest,
208
+ }),
209
+ );
210
+
211
+ const result = consumeScopedApprovalGrantByToolSignature({
212
+ toolName: 'python',
213
+ inputDigest: digest,
214
+ consumingRequestId: 'c1',
215
+ });
216
+ expect(result.ok).toBe(false);
217
+ });
218
+
219
+ test('context constraint: executionChannel must match non-null grant field', () => {
220
+ const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
221
+ createScopedApprovalGrant(
222
+ grantParams({
223
+ scopeMode: 'tool_signature',
224
+ toolName: 'bash',
225
+ inputDigest: digest,
226
+ executionChannel: 'telegram',
227
+ }),
228
+ );
229
+
230
+ // Wrong channel
231
+ const wrong = consumeScopedApprovalGrantByToolSignature({
232
+ toolName: 'bash',
233
+ inputDigest: digest,
234
+ consumingRequestId: 'c1',
235
+ executionChannel: 'sms',
236
+ });
237
+ expect(wrong.ok).toBe(false);
238
+
239
+ // Correct channel
240
+ const correct = consumeScopedApprovalGrantByToolSignature({
241
+ toolName: 'bash',
242
+ inputDigest: digest,
243
+ consumingRequestId: 'c2',
244
+ executionChannel: 'telegram',
245
+ });
246
+ expect(correct.ok).toBe(true);
247
+ });
248
+
249
+ test('null executionChannel on grant means any channel matches', () => {
250
+ const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
251
+ createScopedApprovalGrant(
252
+ grantParams({
253
+ scopeMode: 'tool_signature',
254
+ toolName: 'bash',
255
+ inputDigest: digest,
256
+ executionChannel: null,
257
+ }),
258
+ );
259
+
260
+ const result = consumeScopedApprovalGrantByToolSignature({
261
+ toolName: 'bash',
262
+ inputDigest: digest,
263
+ consumingRequestId: 'c1',
264
+ executionChannel: 'sms',
265
+ });
266
+ expect(result.ok).toBe(true);
267
+ });
268
+
269
+ test('context constraint: conversationId must match non-null grant field', () => {
270
+ const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
271
+ createScopedApprovalGrant(
272
+ grantParams({
273
+ scopeMode: 'tool_signature',
274
+ toolName: 'bash',
275
+ inputDigest: digest,
276
+ conversationId: 'conv-123',
277
+ }),
278
+ );
279
+
280
+ // Mismatched
281
+ const wrong = consumeScopedApprovalGrantByToolSignature({
282
+ toolName: 'bash',
283
+ inputDigest: digest,
284
+ consumingRequestId: 'c1',
285
+ conversationId: 'conv-999',
286
+ });
287
+ expect(wrong.ok).toBe(false);
288
+
289
+ // Matched
290
+ const correct = consumeScopedApprovalGrantByToolSignature({
291
+ toolName: 'bash',
292
+ inputDigest: digest,
293
+ consumingRequestId: 'c2',
294
+ conversationId: 'conv-123',
295
+ });
296
+ expect(correct.ok).toBe(true);
297
+ });
298
+
299
+ test('expired tool_signature grant cannot be consumed', () => {
300
+ const pastExpiry = new Date(Date.now() - 1_000).toISOString();
301
+ const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
302
+ createScopedApprovalGrant(
303
+ grantParams({
304
+ scopeMode: 'tool_signature',
305
+ toolName: 'bash',
306
+ inputDigest: digest,
307
+ expiresAt: pastExpiry,
308
+ }),
309
+ );
310
+
311
+ const result = consumeScopedApprovalGrantByToolSignature({
312
+ toolName: 'bash',
313
+ inputDigest: digest,
314
+ consumingRequestId: 'c1',
315
+ });
316
+ expect(result.ok).toBe(false);
317
+ });
318
+
319
+ test('consume by tool signature only consumes one grant when multiple match', () => {
320
+ const digest = computeToolApprovalDigest('bash', { cmd: 'ls' });
321
+
322
+ // Create a wildcard grant (no executionChannel) and a channel-specific grant.
323
+ // Both match when executionChannel='telegram', but only one should be consumed.
324
+ const wildcardGrant = createScopedApprovalGrant(
325
+ grantParams({
326
+ scopeMode: 'tool_signature',
327
+ toolName: 'bash',
328
+ inputDigest: digest,
329
+ executionChannel: null,
330
+ }),
331
+ );
332
+ const specificGrant = createScopedApprovalGrant(
333
+ grantParams({
334
+ scopeMode: 'tool_signature',
335
+ toolName: 'bash',
336
+ inputDigest: digest,
337
+ executionChannel: 'telegram',
338
+ }),
339
+ );
340
+
341
+ const result = consumeScopedApprovalGrantByToolSignature({
342
+ toolName: 'bash',
343
+ inputDigest: digest,
344
+ consumingRequestId: 'c1',
345
+ executionChannel: 'telegram',
346
+ });
347
+ expect(result.ok).toBe(true);
348
+ // The most specific grant (channel-specific) should be consumed first
349
+ expect(result.grant!.id).toBe(specificGrant.id);
350
+
351
+ // The wildcard grant should still be active and consumable
352
+ const second = consumeScopedApprovalGrantByToolSignature({
353
+ toolName: 'bash',
354
+ inputDigest: digest,
355
+ consumingRequestId: 'c2',
356
+ executionChannel: 'sms',
357
+ });
358
+ expect(second.ok).toBe(true);
359
+ expect(second.grant!.id).toBe(wildcardGrant.id);
360
+ });
361
+ });
362
+
363
+ // ===========================================================================
364
+ // Expiry semantics
365
+ // ===========================================================================
366
+
367
+ describe('scoped-approval-grants / expiry', () => {
368
+ beforeEach(() => clearTables());
369
+
370
+ test('expireScopedApprovalGrants transitions active past-TTL grants to expired', () => {
371
+ const pastExpiry = new Date(Date.now() - 1_000).toISOString();
372
+ createScopedApprovalGrant(
373
+ grantParams({ scopeMode: 'request_id', requestId: 'req-e1', expiresAt: pastExpiry }),
374
+ );
375
+ createScopedApprovalGrant(
376
+ grantParams({ scopeMode: 'request_id', requestId: 'req-e2', expiresAt: pastExpiry }),
377
+ );
378
+ // Still active (future expiry)
379
+ createScopedApprovalGrant(
380
+ grantParams({ scopeMode: 'request_id', requestId: 'req-alive' }),
381
+ );
382
+
383
+ const count = expireScopedApprovalGrants();
384
+ expect(count).toBe(2);
385
+
386
+ // Verify the alive grant is still active
387
+ const alive = consumeScopedApprovalGrantByRequestId('req-alive', 'c1', 'self');
388
+ expect(alive.ok).toBe(true);
389
+ });
390
+
391
+ test('already-consumed grants are not affected by expiry sweep', () => {
392
+ const _pastExpiry = new Date(Date.now() - 1_000).toISOString();
393
+ createScopedApprovalGrant(
394
+ grantParams({ scopeMode: 'request_id', requestId: 'req-consumed', expiresAt: new Date(Date.now() + 60_000).toISOString() }),
395
+ );
396
+ consumeScopedApprovalGrantByRequestId('req-consumed', 'c1', 'self');
397
+
398
+ // Force the expiry time to the past for the consumed grant (simulating time passing)
399
+ // The sweep should not touch consumed grants
400
+ const count = expireScopedApprovalGrants();
401
+ expect(count).toBe(0);
402
+ });
403
+ });
404
+
405
+ // ===========================================================================
406
+ // Revoke semantics
407
+ // ===========================================================================
408
+
409
+ describe('scoped-approval-grants / revoke', () => {
410
+ beforeEach(() => clearTables());
411
+
412
+ test('revokeScopedApprovalGrantsForContext revokes active grants matching context', () => {
413
+ createScopedApprovalGrant(
414
+ grantParams({ scopeMode: 'request_id', requestId: 'req-r1', callSessionId: 'call-1' }),
415
+ );
416
+ createScopedApprovalGrant(
417
+ grantParams({ scopeMode: 'request_id', requestId: 'req-r2', callSessionId: 'call-1' }),
418
+ );
419
+ createScopedApprovalGrant(
420
+ grantParams({ scopeMode: 'request_id', requestId: 'req-r3', callSessionId: 'call-2' }),
421
+ );
422
+
423
+ const count = revokeScopedApprovalGrantsForContext({ callSessionId: 'call-1' });
424
+ expect(count).toBe(2);
425
+
426
+ // Revoked grant cannot be consumed
427
+ const revoked = consumeScopedApprovalGrantByRequestId('req-r1', 'c1', 'self');
428
+ expect(revoked.ok).toBe(false);
429
+
430
+ // Unaffected grant is still consumable
431
+ const alive = consumeScopedApprovalGrantByRequestId('req-r3', 'c1', 'self');
432
+ expect(alive.ok).toBe(true);
433
+ });
434
+
435
+ test('revoked grants cannot be consumed', () => {
436
+ createScopedApprovalGrant(
437
+ grantParams({ scopeMode: 'request_id', requestId: 'req-revoke', conversationId: 'conv-1' }),
438
+ );
439
+
440
+ revokeScopedApprovalGrantsForContext({ conversationId: 'conv-1' });
441
+
442
+ const result = consumeScopedApprovalGrantByRequestId('req-revoke', 'c1', 'self');
443
+ expect(result.ok).toBe(false);
444
+ });
445
+
446
+ test('revokeScopedApprovalGrantsForContext throws when no context filters are provided', () => {
447
+ // Create a grant to ensure the guard is not based on empty results
448
+ createScopedApprovalGrant(
449
+ grantParams({ scopeMode: 'request_id', requestId: 'req-guard', callSessionId: 'call-guard' }),
450
+ );
451
+
452
+ // Empty object: all fields undefined
453
+ expect(() => revokeScopedApprovalGrantsForContext({})).toThrow(
454
+ 'revokeScopedApprovalGrantsForContext requires at least one context filter',
455
+ );
456
+
457
+ // The grant should still be active (not revoked)
458
+ const result = consumeScopedApprovalGrantByRequestId('req-guard', 'c1', 'self');
459
+ expect(result.ok).toBe(true);
460
+ });
461
+ });
462
+
463
+ // ===========================================================================
464
+ // tool-approval-digest: canonical serialization + hash
465
+ // ===========================================================================
466
+
467
+ describe('tool-approval-digest', () => {
468
+ test('canonicalJsonSerialize sorts keys recursively', () => {
469
+ const obj = { z: 1, a: { c: 3, b: 2 } };
470
+ const serialized = canonicalJsonSerialize(obj);
471
+ expect(serialized).toBe('{"a":{"b":2,"c":3},"z":1}');
472
+ });
473
+
474
+ test('canonicalJsonSerialize handles arrays (order preserved)', () => {
475
+ const obj = { items: [3, 1, 2], name: 'test' };
476
+ const serialized = canonicalJsonSerialize(obj);
477
+ expect(serialized).toBe('{"items":[3,1,2],"name":"test"}');
478
+ });
479
+
480
+ test('canonicalJsonSerialize handles null values', () => {
481
+ const obj = { a: null, b: 'hello' };
482
+ const serialized = canonicalJsonSerialize(obj);
483
+ expect(serialized).toBe('{"a":null,"b":"hello"}');
484
+ });
485
+
486
+ test('canonicalJsonSerialize handles nested arrays of objects', () => {
487
+ const obj = { list: [{ z: 1, a: 2 }, { y: 3, b: 4 }] };
488
+ const serialized = canonicalJsonSerialize(obj);
489
+ expect(serialized).toBe('{"list":[{"a":2,"z":1},{"b":4,"y":3}]}');
490
+ });
491
+
492
+ test('computeToolApprovalDigest is deterministic', () => {
493
+ const d1 = computeToolApprovalDigest('bash', { cmd: 'ls -la', cwd: '/tmp' });
494
+ const d2 = computeToolApprovalDigest('bash', { cwd: '/tmp', cmd: 'ls -la' });
495
+ expect(d1).toBe(d2);
496
+ });
497
+
498
+ test('computeToolApprovalDigest differs for different inputs', () => {
499
+ const d1 = computeToolApprovalDigest('bash', { cmd: 'ls' });
500
+ const d2 = computeToolApprovalDigest('bash', { cmd: 'pwd' });
501
+ expect(d1).not.toBe(d2);
502
+ });
503
+
504
+ test('computeToolApprovalDigest differs for different tool names', () => {
505
+ const d1 = computeToolApprovalDigest('bash', { cmd: 'ls' });
506
+ const d2 = computeToolApprovalDigest('python', { cmd: 'ls' });
507
+ expect(d1).not.toBe(d2);
508
+ });
509
+
510
+ test('computeToolApprovalDigest is stable across key orderings (deeply nested)', () => {
511
+ const d1 = computeToolApprovalDigest('tool', {
512
+ config: { nested: { z: 1, a: 2 }, top: true },
513
+ name: 'test',
514
+ });
515
+ const d2 = computeToolApprovalDigest('tool', {
516
+ name: 'test',
517
+ config: { top: true, nested: { a: 2, z: 1 } },
518
+ });
519
+ expect(d1).toBe(d2);
520
+ });
521
+ });