@vellumai/assistant 0.3.22 → 0.3.24

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 (49) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +0 -84
  3. package/src/__tests__/approval-primitive.test.ts +72 -0
  4. package/src/__tests__/assistant-feature-flags-integration.test.ts +0 -4
  5. package/src/__tests__/host-shell-tool.test.ts +25 -0
  6. package/src/__tests__/ipc-snapshot.test.ts +0 -42
  7. package/src/__tests__/mcp-cli.test.ts +120 -3
  8. package/src/__tests__/skill-feature-flags-integration.test.ts +0 -4
  9. package/src/__tests__/terminal-tools.test.ts +19 -1
  10. package/src/__tests__/tool-approval-handler.test.ts +94 -5
  11. package/src/__tests__/tool-executor.test.ts +1 -1
  12. package/src/cli/mcp.ts +25 -0
  13. package/src/config/bundled-skills/google-oauth-setup/SKILL.md +13 -8
  14. package/src/config/bundled-skills/phone-calls/SKILL.md +1 -1
  15. package/src/config/bundled-skills/public-ingress/SKILL.md +6 -6
  16. package/src/config/bundled-skills/reminder/SKILL.md +7 -6
  17. package/src/config/bundled-skills/time-based-actions/SKILL.md +7 -6
  18. package/src/config/feature-flag-registry.json +8 -0
  19. package/src/config/schema.ts +10 -10
  20. package/src/config/system-prompt.ts +0 -72
  21. package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +7 -7
  22. package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +14 -6
  23. package/src/config/vellum-skills/telegram-setup/SKILL.md +4 -4
  24. package/src/config/vellum-skills/trusted-contacts/SKILL.md +10 -10
  25. package/src/config/vellum-skills/twilio-setup/SKILL.md +2 -2
  26. package/src/daemon/handlers/config.ts +0 -4
  27. package/src/daemon/handlers/navigate-settings.ts +0 -1
  28. package/src/daemon/ipc-contract-inventory.json +0 -10
  29. package/src/daemon/ipc-contract.ts +0 -4
  30. package/src/daemon/lifecycle.ts +14 -2
  31. package/src/daemon/session-process.ts +2 -2
  32. package/src/daemon/shutdown-handlers.ts +1 -1
  33. package/src/instrument.ts +15 -1
  34. package/src/mcp/client.ts +3 -3
  35. package/src/memory/conversation-crud.ts +26 -4
  36. package/src/memory/migrations/119-schema-indexes-and-columns.ts +46 -18
  37. package/src/permissions/checker.ts +4 -4
  38. package/src/runtime/routes/inbound-message-handler.ts +2 -2
  39. package/src/runtime/routes/ingress-routes.ts +7 -2
  40. package/src/tools/executor.ts +2 -2
  41. package/src/tools/host-terminal/host-shell.ts +4 -29
  42. package/src/tools/swarm/delegate.ts +3 -0
  43. package/src/tools/system/navigate-settings.ts +0 -1
  44. package/src/tools/terminal/safe-env.ts +9 -0
  45. package/src/tools/tool-approval-handler.ts +2 -33
  46. package/src/tools/tool-manifest.ts +33 -88
  47. package/src/daemon/handlers/config-parental.ts +0 -164
  48. package/src/daemon/ipc-contract/parental-control.ts +0 -109
  49. package/src/security/parental-control-store.ts +0 -184
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.3.22",
3
+ "version": "0.3.24",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "vellum": "./src/index.ts"
@@ -1015,43 +1015,6 @@ exports[`IPC message snapshots ClientMessage types dictation_request serializes
1015
1015
  }
1016
1016
  `;
1017
1017
 
1018
- exports[`IPC message snapshots ClientMessage types parental_control_get serializes to expected JSON 1`] = `
1019
- {
1020
- "type": "parental_control_get",
1021
- }
1022
- `;
1023
-
1024
- exports[`IPC message snapshots ClientMessage types parental_control_verify_pin serializes to expected JSON 1`] = `
1025
- {
1026
- "pin": "123456",
1027
- "type": "parental_control_verify_pin",
1028
- }
1029
- `;
1030
-
1031
- exports[`IPC message snapshots ClientMessage types parental_control_set_pin serializes to expected JSON 1`] = `
1032
- {
1033
- "current_pin": "123456",
1034
- "new_pin": "654321",
1035
- "type": "parental_control_set_pin",
1036
- }
1037
- `;
1038
-
1039
- exports[`IPC message snapshots ClientMessage types parental_control_update serializes to expected JSON 1`] = `
1040
- {
1041
- "blocked_tool_categories": [
1042
- "shell",
1043
- "network",
1044
- ],
1045
- "content_restrictions": [
1046
- "violence",
1047
- "adult_content",
1048
- ],
1049
- "enabled": true,
1050
- "pin": "123456",
1051
- "type": "parental_control_update",
1052
- }
1053
- `;
1054
-
1055
1018
  exports[`IPC message snapshots ClientMessage types ingress_invite serializes to expected JSON 1`] = `
1056
1019
  {
1057
1020
  "action": "create",
@@ -2944,53 +2907,6 @@ exports[`IPC message snapshots ServerMessage types dictation_response serializes
2944
2907
  }
2945
2908
  `;
2946
2909
 
2947
- exports[`IPC message snapshots ServerMessage types parental_control_get_response serializes to expected JSON 1`] = `
2948
- {
2949
- "blocked_tool_categories": [
2950
- "shell",
2951
- "network",
2952
- ],
2953
- "content_restrictions": [
2954
- "violence",
2955
- "adult_content",
2956
- ],
2957
- "enabled": true,
2958
- "has_pin": true,
2959
- "type": "parental_control_get_response",
2960
- }
2961
- `;
2962
-
2963
- exports[`IPC message snapshots ServerMessage types parental_control_verify_pin_response serializes to expected JSON 1`] = `
2964
- {
2965
- "type": "parental_control_verify_pin_response",
2966
- "verified": true,
2967
- }
2968
- `;
2969
-
2970
- exports[`IPC message snapshots ServerMessage types parental_control_set_pin_response serializes to expected JSON 1`] = `
2971
- {
2972
- "success": true,
2973
- "type": "parental_control_set_pin_response",
2974
- }
2975
- `;
2976
-
2977
- exports[`IPC message snapshots ServerMessage types parental_control_update_response serializes to expected JSON 1`] = `
2978
- {
2979
- "blocked_tool_categories": [
2980
- "shell",
2981
- "network",
2982
- ],
2983
- "content_restrictions": [
2984
- "violence",
2985
- "adult_content",
2986
- ],
2987
- "enabled": true,
2988
- "has_pin": true,
2989
- "success": true,
2990
- "type": "parental_control_update_response",
2991
- }
2992
- `;
2993
-
2994
2910
  exports[`IPC message snapshots ServerMessage types ingress_invite_response serializes to expected JSON 1`] = `
2995
2911
  {
2996
2912
  "invite": {
@@ -537,4 +537,76 @@ describe('approval-primitive / consumeGrantForInvocation retry', () => {
537
537
  expect(elapsed).toBeGreaterThanOrEqual(450);
538
538
  expect(elapsed).toBeLessThan(1_500);
539
539
  });
540
+
541
+ test('returns aborted when signal fires during retry polling', async () => {
542
+ const digest = computeToolApprovalDigest('shell', { command: 'aborted' });
543
+ const controller = new AbortController();
544
+
545
+ // Abort after 200ms — well before the 2s max wait
546
+ setTimeout(() => controller.abort(), 200);
547
+
548
+ const start = Date.now();
549
+ const result = await consumeGrantForInvocation(
550
+ {
551
+ toolName: 'shell',
552
+ inputDigest: digest,
553
+ consumingRequestId: 'consumer-aborted',
554
+ assistantId: 'self',
555
+ },
556
+ { maxWaitMs: 2_000, intervalMs: 50, signal: controller.signal },
557
+ );
558
+ const elapsed = Date.now() - start;
559
+
560
+ expect(result.ok).toBe(false);
561
+ if (result.ok) return;
562
+ expect(result.reason).toBe('aborted');
563
+ // Should have exited shortly after the abort (200ms), not waited the full 2s
564
+ expect(elapsed).toBeLessThan(1_000);
565
+ });
566
+
567
+ test('returns aborted immediately when signal is already aborted', async () => {
568
+ const digest = computeToolApprovalDigest('shell', { command: 'pre-aborted' });
569
+ const controller = new AbortController();
570
+ controller.abort();
571
+
572
+ const start = Date.now();
573
+ const result = await consumeGrantForInvocation(
574
+ {
575
+ toolName: 'shell',
576
+ inputDigest: digest,
577
+ consumingRequestId: 'consumer-pre-aborted',
578
+ assistantId: 'self',
579
+ },
580
+ { maxWaitMs: 2_000, intervalMs: 50, signal: controller.signal },
581
+ );
582
+ const elapsed = Date.now() - start;
583
+
584
+ expect(result.ok).toBe(false);
585
+ if (result.ok) return;
586
+ expect(result.reason).toBe('aborted');
587
+ // Should return nearly instantly since signal was already aborted
588
+ expect(elapsed).toBeLessThan(200);
589
+ });
590
+
591
+ test('skips retry entirely when maxWaitMs is 0', async () => {
592
+ const digest = computeToolApprovalDigest('shell', { command: 'no-retry' });
593
+
594
+ const start = Date.now();
595
+ const result = await consumeGrantForInvocation(
596
+ {
597
+ toolName: 'shell',
598
+ inputDigest: digest,
599
+ consumingRequestId: 'consumer-no-retry',
600
+ assistantId: 'self',
601
+ },
602
+ { maxWaitMs: 0 },
603
+ );
604
+ const elapsed = Date.now() - start;
605
+
606
+ expect(result.ok).toBe(false);
607
+ if (result.ok) return;
608
+ expect(result.reason).toBe('no_match');
609
+ // Should return nearly instantly — no retry loop
610
+ expect(elapsed).toBeLessThan(100);
611
+ });
540
612
  });
@@ -73,10 +73,6 @@ mock.module('../config/user-reference.js', () => ({
73
73
  resolveUserReference: () => 'TestUser',
74
74
  }));
75
75
 
76
- mock.module('../security/parental-control-store.js', () => ({
77
- getParentalControlSettings: () => ({ enabled: false, contentRestrictions: [], blockedToolCategories: [] }),
78
- }));
79
-
80
76
  mock.module('../tools/credentials/metadata-store.js', () => ({
81
77
  listCredentialMetadata: () => [],
82
78
  }));
@@ -424,6 +424,31 @@ describe('host_bash — environment setup', () => {
424
424
  expect(result.content).toContain('HOME=');
425
425
  expect(result.content.trim()).not.toBe('HOME=');
426
426
  });
427
+
428
+ test('injects INTERNAL_GATEWAY_BASE_URL and GATEWAY_BASE_URL for host_bash commands', async () => {
429
+ const originalGatewayBase = process.env.GATEWAY_INTERNAL_BASE_URL;
430
+ const originalIngressBase = process.env.INGRESS_PUBLIC_BASE_URL;
431
+ process.env.GATEWAY_INTERNAL_BASE_URL = 'http://gateway.internal:9000/';
432
+ process.env.INGRESS_PUBLIC_BASE_URL = 'https://gw.example.com/';
433
+ try {
434
+ const result = await hostShellTool.execute({
435
+ command: 'echo "$INTERNAL_GATEWAY_BASE_URL|$GATEWAY_BASE_URL"',
436
+ }, makeContext());
437
+ expect(result.isError).toBe(false);
438
+ expect(result.content.trim()).toBe('http://gateway.internal:9000|https://gw.example.com');
439
+ } finally {
440
+ if (originalGatewayBase === undefined) {
441
+ delete process.env.GATEWAY_INTERNAL_BASE_URL;
442
+ } else {
443
+ process.env.GATEWAY_INTERNAL_BASE_URL = originalGatewayBase;
444
+ }
445
+ if (originalIngressBase === undefined) {
446
+ delete process.env.INGRESS_PUBLIC_BASE_URL;
447
+ } else {
448
+ process.env.INGRESS_PUBLIC_BASE_URL = originalIngressBase;
449
+ }
450
+ }
451
+ });
427
452
  });
428
453
 
429
454
  // ---------------------------------------------------------------------------
@@ -630,25 +630,6 @@ const clientMessages: Record<ClientMessageType, ClientMessage> = {
630
630
  cursorInTextField: true,
631
631
  },
632
632
  },
633
- parental_control_get: {
634
- type: 'parental_control_get',
635
- },
636
- parental_control_verify_pin: {
637
- type: 'parental_control_verify_pin',
638
- pin: '123456',
639
- },
640
- parental_control_set_pin: {
641
- type: 'parental_control_set_pin',
642
- current_pin: '123456',
643
- new_pin: '654321',
644
- },
645
- parental_control_update: {
646
- type: 'parental_control_update',
647
- pin: '123456',
648
- enabled: true,
649
- content_restrictions: ['violence', 'adult_content'],
650
- blocked_tool_categories: ['shell', 'network'],
651
- },
652
633
  ingress_invite: {
653
634
  type: 'ingress_invite',
654
635
  action: 'create',
@@ -1889,29 +1870,6 @@ const serverMessages: Record<ServerMessageType, ServerMessage> = {
1889
1870
  mode: 'dictation',
1890
1871
  actionPlan: undefined,
1891
1872
  },
1892
- parental_control_get_response: {
1893
- type: 'parental_control_get_response',
1894
- enabled: true,
1895
- has_pin: true,
1896
- content_restrictions: ['violence', 'adult_content'],
1897
- blocked_tool_categories: ['shell', 'network'],
1898
- },
1899
- parental_control_verify_pin_response: {
1900
- type: 'parental_control_verify_pin_response',
1901
- verified: true,
1902
- },
1903
- parental_control_set_pin_response: {
1904
- type: 'parental_control_set_pin_response',
1905
- success: true,
1906
- },
1907
- parental_control_update_response: {
1908
- type: 'parental_control_update_response',
1909
- success: true,
1910
- enabled: true,
1911
- has_pin: true,
1912
- content_restrictions: ['violence', 'adult_content'],
1913
- blocked_tool_categories: ['shell', 'network'],
1914
- },
1915
1873
  ingress_invite_response: {
1916
1874
  type: 'ingress_invite_response',
1917
1875
  success: true,
@@ -1,5 +1,5 @@
1
1
  import { spawnSync } from 'node:child_process';
2
- import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
 
@@ -10,22 +10,35 @@ const CLI = join(import.meta.dir, '..', 'index.ts');
10
10
  let testDataDir: string;
11
11
  let configPath: string;
12
12
 
13
- function runMcpList(args: string[] = []): { stdout: string; exitCode: number } {
14
- const result = spawnSync('bun', ['run', CLI, 'mcp', 'list', ...args], {
13
+ function runMcp(subcommand: string, args: string[] = []): { stdout: string; stderr: string; exitCode: number } {
14
+ const result = spawnSync('bun', ['run', CLI, 'mcp', subcommand, ...args], {
15
15
  encoding: 'utf-8',
16
16
  timeout: 10_000,
17
17
  env: { ...process.env, BASE_DATA_DIR: testDataDir },
18
18
  });
19
19
  return {
20
20
  stdout: (result.stdout ?? '').toString(),
21
+ stderr: (result.stderr ?? '').toString(),
21
22
  exitCode: result.status ?? 1,
22
23
  };
23
24
  }
24
25
 
26
+ function runMcpList(args: string[] = []) {
27
+ return runMcp('list', args);
28
+ }
29
+
30
+ function runMcpAdd(name: string, args: string[]) {
31
+ return runMcp('add', [name, ...args]);
32
+ }
33
+
25
34
  function writeConfig(config: Record<string, unknown>): void {
26
35
  writeFileSync(configPath, JSON.stringify(config), 'utf-8');
27
36
  }
28
37
 
38
+ function readConfig(): Record<string, unknown> {
39
+ return JSON.parse(readFileSync(configPath, 'utf-8'));
40
+ }
41
+
29
42
  describe('vellum mcp list', () => {
30
43
  beforeAll(() => {
31
44
  testDataDir = join(tmpdir(), `vellum-mcp-cli-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
@@ -139,3 +152,107 @@ describe('vellum mcp list', () => {
139
152
  expect(parsed).toEqual([]);
140
153
  });
141
154
  });
155
+
156
+ describe('vellum mcp add', () => {
157
+ beforeAll(() => {
158
+ testDataDir = join(tmpdir(), `vellum-mcp-add-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
159
+ const workspaceDir = join(testDataDir, '.vellum', 'workspace');
160
+ mkdirSync(workspaceDir, { recursive: true });
161
+ configPath = join(workspaceDir, 'config.json');
162
+ writeConfig({});
163
+ });
164
+
165
+ afterAll(() => {
166
+ rmSync(testDataDir, { recursive: true, force: true });
167
+ });
168
+
169
+ beforeEach(() => {
170
+ writeConfig({});
171
+ });
172
+
173
+ test('adds a streamable-http server', () => {
174
+ const { stdout, exitCode } = runMcpAdd('test-http', [
175
+ '-t', 'streamable-http', '-u', 'https://example.com/mcp', '-r', 'medium',
176
+ ]);
177
+ expect(exitCode).toBe(0);
178
+ expect(stdout).toContain('Added MCP server "test-http"');
179
+
180
+ const updated = readConfig();
181
+ const servers = (updated.mcp as Record<string, unknown> | undefined)?.servers as Record<string, unknown> | undefined;
182
+ const server = servers?.['test-http'] as Record<string, unknown>;
183
+ expect(server).toBeDefined();
184
+ expect((server.transport as Record<string, unknown>).type).toBe('streamable-http');
185
+ expect((server.transport as Record<string, unknown>).url).toBe('https://example.com/mcp');
186
+ expect(server.defaultRiskLevel).toBe('medium');
187
+ expect(server.enabled).toBe(true);
188
+ });
189
+
190
+ test('adds a stdio server with args', () => {
191
+ const { stdout, exitCode } = runMcpAdd('test-stdio', [
192
+ '-t', 'stdio', '-c', 'npx', '-a', '-y', 'some-server', '-r', 'low',
193
+ ]);
194
+ expect(exitCode).toBe(0);
195
+ expect(stdout).toContain('Added MCP server "test-stdio"');
196
+
197
+ const updated = readConfig();
198
+ const servers = (updated.mcp as Record<string, unknown> | undefined)?.servers as Record<string, unknown> | undefined;
199
+ const server = servers?.['test-stdio'] as Record<string, unknown>;
200
+ const transport = server.transport as Record<string, unknown>;
201
+ expect(transport.type).toBe('stdio');
202
+ expect(transport.command).toBe('npx');
203
+ expect(transport.args).toEqual(['-y', 'some-server']);
204
+ });
205
+
206
+ test('adds server as disabled with --disabled flag', () => {
207
+ const { exitCode } = runMcpAdd('test-disabled', [
208
+ '-t', 'sse', '-u', 'https://example.com/sse', '--disabled',
209
+ ]);
210
+ expect(exitCode).toBe(0);
211
+
212
+ const updated = readConfig();
213
+ const servers = (updated.mcp as Record<string, unknown> | undefined)?.servers as Record<string, unknown> | undefined;
214
+ const server = servers?.['test-disabled'] as Record<string, unknown>;
215
+ expect(server.enabled).toBe(false);
216
+ });
217
+
218
+ test('rejects duplicate server name', () => {
219
+ writeConfig({
220
+ mcp: {
221
+ servers: {
222
+ existing: {
223
+ transport: { type: 'sse', url: 'https://example.com' },
224
+ enabled: true,
225
+ defaultRiskLevel: 'high',
226
+ },
227
+ },
228
+ },
229
+ });
230
+
231
+ const { stderr } = runMcpAdd('existing', [
232
+ '-t', 'sse', '-u', 'https://other.com',
233
+ ]);
234
+ expect(stderr).toContain('already exists');
235
+ });
236
+
237
+ test('rejects stdio without --command', () => {
238
+ const { stderr } = runMcpAdd('bad-stdio', ['-t', 'stdio']);
239
+ expect(stderr).toContain('--command is required');
240
+ });
241
+
242
+ test('rejects streamable-http without --url', () => {
243
+ const { stderr } = runMcpAdd('bad-http', ['-t', 'streamable-http']);
244
+ expect(stderr).toContain('--url is required');
245
+ });
246
+
247
+ test('defaults risk to high', () => {
248
+ const { exitCode } = runMcpAdd('default-risk', [
249
+ '-t', 'sse', '-u', 'https://example.com/sse',
250
+ ]);
251
+ expect(exitCode).toBe(0);
252
+
253
+ const updated = readConfig();
254
+ const servers = (updated.mcp as Record<string, unknown> | undefined)?.servers as Record<string, unknown> | undefined;
255
+ const server = servers?.['default-risk'] as Record<string, unknown>;
256
+ expect(server.defaultRiskLevel).toBe('high');
257
+ });
258
+ });
@@ -68,10 +68,6 @@ mock.module('../config/user-reference.js', () => ({
68
68
  resolveUserReference: () => 'TestUser',
69
69
  }));
70
70
 
71
- mock.module('../security/parental-control-store.js', () => ({
72
- getParentalControlSettings: () => ({ enabled: false, contentRestrictions: [], blockedToolCategories: [] }),
73
- }));
74
-
75
71
  mock.module('../tools/credentials/metadata-store.js', () => ({
76
72
  listCredentialMetadata: () => [],
77
73
  }));
@@ -446,6 +446,23 @@ describe('buildSanitizedEnv', () => {
446
446
  expect(env.LC_CTYPE).toBe('UTF-8');
447
447
  });
448
448
 
449
+ test('injects INTERNAL_GATEWAY_BASE_URL from gateway config', () => {
450
+ process.env.GATEWAY_INTERNAL_BASE_URL = 'http://gateway.internal:9000/';
451
+ const env = buildSanitizedEnv();
452
+ expect(env.INTERNAL_GATEWAY_BASE_URL).toBe('http://gateway.internal:9000');
453
+ delete process.env.GATEWAY_INTERNAL_BASE_URL;
454
+ });
455
+
456
+ test('injects GATEWAY_BASE_URL from public ingress when configured', () => {
457
+ process.env.GATEWAY_INTERNAL_BASE_URL = 'http://gateway.internal:9000/';
458
+ process.env.INGRESS_PUBLIC_BASE_URL = 'https://gw.example.com/';
459
+ const env = buildSanitizedEnv();
460
+ expect(env.INTERNAL_GATEWAY_BASE_URL).toBe('http://gateway.internal:9000');
461
+ expect(env.GATEWAY_BASE_URL).toBe('https://gw.example.com');
462
+ delete process.env.GATEWAY_INTERNAL_BASE_URL;
463
+ delete process.env.INGRESS_PUBLIC_BASE_URL;
464
+ });
465
+
449
466
  test('result is a plain object with no prototype-inherited secrets', () => {
450
467
  const env = buildSanitizedEnv();
451
468
  const keys = Object.keys(env);
@@ -453,6 +470,8 @@ describe('buildSanitizedEnv', () => {
453
470
  'PATH', 'HOME', 'TERM', 'LANG', 'EDITOR', 'SHELL', 'USER', 'TMPDIR',
454
471
  'LC_ALL', 'LC_CTYPE', 'XDG_RUNTIME_DIR', 'DISPLAY', 'COLORTERM',
455
472
  'TERM_PROGRAM', 'SSH_AUTH_SOCK', 'SSH_AGENT_PID', 'GPG_TTY', 'GNUPGHOME',
473
+ 'INTERNAL_GATEWAY_BASE_URL',
474
+ 'GATEWAY_BASE_URL',
456
475
  ];
457
476
  for (const key of keys) {
458
477
  expect(safeKeys).toContain(key);
@@ -684,4 +703,3 @@ describe('formatShellOutput', () => {
684
703
  expect(result.content.length).toBeLessThan(60_000);
685
704
  });
686
705
  });
687
-
@@ -29,11 +29,6 @@ mock.module('../util/logger.js', () => ({
29
29
  truncateForLog: (value: string) => value,
30
30
  }));
31
31
 
32
- // Mock parental controls — no tools blocked by default
33
- mock.module('../security/parental-control-store.js', () => ({
34
- isToolBlocked: () => false,
35
- }));
36
-
37
32
  // Mock guardian control-plane policy — not targeting control-plane by default
38
33
  mock.module('../tools/guardian-control-plane-policy.js', () => ({
39
34
  enforceGuardianOnlyPolicy: () => ({ denied: false }),
@@ -347,4 +342,98 @@ describe('ToolApprovalHandler / pre-exec gate grant check', () => {
347
342
 
348
343
  expect(result.allowed).toBe(false);
349
344
  });
345
+
346
+ test('non-voice channel denial is instant (no retry polling)', async () => {
347
+ const toolName = 'bash';
348
+ const input = { command: 'rm -rf /' };
349
+
350
+ // executionChannel defaults to undefined (non-voice)
351
+ const context = makeContext({
352
+ guardianActorRole: 'non-guardian',
353
+ executionChannel: 'telegram',
354
+ });
355
+
356
+ const start = Date.now();
357
+ const result = await handler.checkPreExecutionGates(
358
+ toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
359
+ );
360
+ const elapsed = Date.now() - start;
361
+
362
+ expect(result.allowed).toBe(false);
363
+ if (result.allowed) return;
364
+ expect(result.result.content).toContain('guardian approval');
365
+ // Non-voice denials should be nearly instant — no 10s retry polling
366
+ expect(elapsed).toBeLessThan(500);
367
+ });
368
+
369
+ test('voice channel with delayed grant succeeds via retry polling', async () => {
370
+ const toolName = 'bash';
371
+ const input = { command: 'echo hello' };
372
+ const digest = computeToolApprovalDigest(toolName, input);
373
+
374
+ // Mint the grant after 300ms — the voice retry polling should find it
375
+ setTimeout(() => {
376
+ mintGrantFromDecision(
377
+ mintParams({
378
+ scopeMode: 'tool_signature',
379
+ toolName,
380
+ inputDigest: digest,
381
+ }),
382
+ );
383
+ }, 300);
384
+
385
+ const context = makeContext({
386
+ guardianActorRole: 'non-guardian',
387
+ executionChannel: 'voice',
388
+ });
389
+
390
+ const start = Date.now();
391
+ const result = await handler.checkPreExecutionGates(
392
+ toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
393
+ );
394
+ const elapsed = Date.now() - start;
395
+
396
+ expect(result.allowed).toBe(true);
397
+ // Should have taken at least ~300ms (the minting delay) but not the full 10s
398
+ expect(elapsed).toBeGreaterThanOrEqual(250);
399
+ expect(elapsed).toBeLessThan(5_000);
400
+ });
401
+
402
+ test('voice channel abort returns Cancelled instead of guardian_approval_required', async () => {
403
+ const toolName = 'bash';
404
+ const input = { command: 'deploy --force' };
405
+
406
+ const controller = new AbortController();
407
+ // Abort after 200ms to simulate voice barge-in
408
+ setTimeout(() => controller.abort(), 200);
409
+
410
+ const context = makeContext({
411
+ guardianActorRole: 'non-guardian',
412
+ executionChannel: 'voice',
413
+ signal: controller.signal,
414
+ });
415
+
416
+ const start = Date.now();
417
+ const result = await handler.checkPreExecutionGates(
418
+ toolName, input, context, 'host', 'high', Date.now(), emitLifecycleEvent,
419
+ );
420
+ const elapsed = Date.now() - start;
421
+
422
+ expect(result.allowed).toBe(false);
423
+ if (result.allowed) return;
424
+ // Should return 'Cancelled', not a guardian_approval_required message
425
+ expect(result.result.content).toBe('Cancelled');
426
+ expect(result.result.isError).toBe(true);
427
+ // Should exit promptly after the abort signal, not wait full 10s
428
+ expect(elapsed).toBeLessThan(2_000);
429
+
430
+ // The lifecycle event should be an error with 'Cancelled', not permission_denied
431
+ const errorEvents = events.filter((e) => e.type === 'error');
432
+ expect(errorEvents.length).toBeGreaterThanOrEqual(1);
433
+ const lastError = errorEvents[errorEvents.length - 1];
434
+ if (lastError.type === 'error') {
435
+ expect(lastError.errorMessage).toBe('Cancelled');
436
+ expect(lastError.isExpected).toBe(true);
437
+ }
438
+ });
350
439
  });
@@ -1844,7 +1844,7 @@ describe('buildSanitizedEnv — baseline: credential exclusion', () => {
1844
1844
  'PATH', 'HOME', 'TERM', 'LANG', 'EDITOR', 'SHELL', 'USER',
1845
1845
  'TMPDIR', 'LC_ALL', 'LC_CTYPE', 'XDG_RUNTIME_DIR', 'DISPLAY',
1846
1846
  'COLORTERM', 'TERM_PROGRAM', 'SSH_AUTH_SOCK', 'SSH_AGENT_PID',
1847
- 'GPG_TTY', 'GNUPGHOME',
1847
+ 'GPG_TTY', 'GNUPGHOME', 'INTERNAL_GATEWAY_BASE_URL', 'GATEWAY_BASE_URL',
1848
1848
  ];
1849
1849
 
1850
1850
  const env = buildSanitizedEnv();
package/src/cli/mcp.ts CHANGED
@@ -87,6 +87,7 @@ export function registerMcpCommand(program: Command): void {
87
87
 
88
88
  if (servers[name]) {
89
89
  log.error(`MCP server "${name}" already exists. Remove it first with: vellum mcp remove ${name}`);
90
+ process.exitCode = 1;
90
91
  return;
91
92
  }
92
93
 
@@ -95,6 +96,7 @@ export function registerMcpCommand(program: Command): void {
95
96
  case 'stdio':
96
97
  if (!opts.command) {
97
98
  log.error('--command is required for stdio transport');
99
+ process.exitCode = 1;
98
100
  return;
99
101
  }
100
102
  transport = { type: 'stdio', command: opts.command, args: opts.args ?? [] };
@@ -103,17 +105,20 @@ export function registerMcpCommand(program: Command): void {
103
105
  case 'streamable-http':
104
106
  if (!opts.url) {
105
107
  log.error(`--url is required for ${opts.transportType} transport`);
108
+ process.exitCode = 1;
106
109
  return;
107
110
  }
108
111
  transport = { type: opts.transportType, url: opts.url };
109
112
  break;
110
113
  default:
111
114
  log.error(`Unknown transport type: ${opts.transportType}. Must be stdio, sse, or streamable-http`);
115
+ process.exitCode = 1;
112
116
  return;
113
117
  }
114
118
 
115
119
  if (!['low', 'medium', 'high'].includes(opts.risk)) {
116
120
  log.error(`Invalid risk level: ${opts.risk}. Must be low, medium, or high`);
121
+ process.exitCode = 1;
117
122
  return;
118
123
  }
119
124
 
@@ -127,4 +132,24 @@ export function registerMcpCommand(program: Command): void {
127
132
  log.info(`Added MCP server "${name}" (${opts.transportType})`);
128
133
  log.info('Restart the daemon for changes to take effect: vellum daemon restart');
129
134
  });
135
+
136
+ mcp
137
+ .command('remove <name>')
138
+ .description('Remove an MCP server configuration')
139
+ .action((name: string) => {
140
+ const raw = loadRawConfig();
141
+ const mcpConfig = raw.mcp as Record<string, unknown> | undefined;
142
+ const servers = mcpConfig?.servers as Record<string, unknown> | undefined;
143
+
144
+ if (!servers || !servers[name]) {
145
+ log.error(`MCP server "${name}" not found.`);
146
+ process.exitCode = 1;
147
+ return;
148
+ }
149
+
150
+ delete servers[name];
151
+ saveRawConfig(raw);
152
+ log.info(`Removed MCP server "${name}".`);
153
+ log.info('Restart the daemon for changes to take effect: vellum daemon restart');
154
+ });
130
155
  }