subrouter-cli 0.0.0-stage → 0.1.0

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 (62) hide show
  1. package/CLAUDE.md +51 -0
  2. package/README.md +397 -2
  3. package/config.example.json +9 -0
  4. package/package.json +21 -4
  5. package/scripts/sse-harness.ts +252 -0
  6. package/scripts/sub-wrapper.sh +11 -0
  7. package/src/adapters/anthropic.ts +311 -0
  8. package/src/adapters/openai.ts +227 -0
  9. package/src/approval.ts +21 -0
  10. package/src/chatviewport.ts +123 -0
  11. package/src/client.ts +398 -0
  12. package/src/clipboard.ts +62 -0
  13. package/src/commandpolicy.ts +272 -0
  14. package/src/commands.ts +18 -0
  15. package/src/config.ts +107 -0
  16. package/src/effort.ts +18 -0
  17. package/src/images.ts +77 -0
  18. package/src/index.ts +248 -0
  19. package/src/lineinput.ts +726 -0
  20. package/src/loop.ts +201 -0
  21. package/src/markdown.ts +244 -0
  22. package/src/repl.ts +700 -0
  23. package/src/sessions.ts +58 -0
  24. package/src/sse.ts +64 -0
  25. package/src/terminal.ts +228 -0
  26. package/src/token.ts +36 -0
  27. package/src/toolpreview.ts +54 -0
  28. package/src/tools.ts +790 -0
  29. package/src/types.ts +73 -0
  30. package/src/ui.ts +813 -0
  31. package/src/usage.ts +186 -0
  32. package/test/absolute-tools.test.ts +296 -0
  33. package/test/absolute-ui.test.ts +153 -0
  34. package/test/adapters.test.ts +205 -0
  35. package/test/anthropic.test.ts +246 -0
  36. package/test/auto-ui.test.ts +106 -0
  37. package/test/chatviewport.test.ts +49 -0
  38. package/test/client.test.ts +327 -0
  39. package/test/clipboard.test.ts +63 -0
  40. package/test/command-input.test.ts +183 -0
  41. package/test/command-tools.test.ts +141 -0
  42. package/test/commandpolicy.test.ts +252 -0
  43. package/test/disk-tools.test.ts +220 -0
  44. package/test/effort.test.ts +123 -0
  45. package/test/fixtures/openai-tools.sse +84 -0
  46. package/test/image-adapters.test.ts +78 -0
  47. package/test/image-ui.test.ts +151 -0
  48. package/test/images.test.ts +60 -0
  49. package/test/loop.test.ts +387 -0
  50. package/test/m5.test.ts +127 -0
  51. package/test/markdown.test.ts +201 -0
  52. package/test/repl-ui.test.ts +293 -0
  53. package/test/sse.test.ts +74 -0
  54. package/test/steering-ui.test.ts +182 -0
  55. package/test/steering.test.ts +68 -0
  56. package/test/terminal-ui.test.ts +144 -0
  57. package/test/terminal.test.ts +229 -0
  58. package/test/toolpreview.test.ts +51 -0
  59. package/test/tools.test.ts +227 -0
  60. package/test/ui.test.ts +635 -0
  61. package/test/usage-footer.test.ts +180 -0
  62. package/tsconfig.json +17 -0
@@ -0,0 +1,141 @@
1
+ import assert from 'node:assert/strict';
2
+ import fs from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { afterEach, beforeEach, test } from 'node:test';
6
+ import { executeTool, prepareCommand, TOOL_DEFINITIONS } from '../src/tools.ts';
7
+ import type { ToolCall } from '../src/types.ts';
8
+
9
+ let base: string, cwd: string;
10
+ beforeEach(async () => {
11
+ base = await fs.mkdtemp(path.join(os.tmpdir(), 'sub-command-tools-'));
12
+ cwd = path.join(base, 'project');
13
+ await fs.mkdir(cwd);
14
+ await fs.mkdir(path.join(base, 'outside'));
15
+ });
16
+ afterEach(async () => { await fs.rm(base, { recursive: true, force: true }); });
17
+ function call(args: Record<string, unknown>): ToolCall {
18
+ return { id: 'command', name: 'run_command', arguments: args, argumentsJson: JSON.stringify(args) };
19
+ }
20
+
21
+ test('command metadata requires independent approval and cwd preparation is confined', async () => {
22
+ const tool = TOOL_DEFINITIONS.find((tool) => tool.name === 'run_command');
23
+ assert.equal(tool?.requiresApproval, true);
24
+ assert.equal(tool?.approvalScope, 'always');
25
+ await fs.mkdir(path.join(cwd, 'src'));
26
+ await fs.writeFile(path.join(cwd, 'file'), 'x');
27
+ await fs.symlink(path.join(base, 'outside'), path.join(cwd, 'escape'));
28
+ await fs.symlink('src', path.join(cwd, 'internal'));
29
+ const prepared = await prepareCommand(cwd, { command: 'pwd', cwd: 'internal' });
30
+ assert.equal(prepared.cwd, await fs.realpath(path.join(cwd, 'src')));
31
+ assert.equal(prepared.timeoutMs, 60000);
32
+ for (const directory of ['../outside', path.join(base, 'outside'), '~', 'escape', 'missing', 'file', '.sub-recovery', null]) {
33
+ await assert.rejects(() => prepareCommand(cwd, { command: 'pwd', cwd: directory }));
34
+ }
35
+ await assert.rejects(() => prepareCommand(cwd, { command: 'echo synthetic-key' }, ['synthetic-key']), /credential|secret|protected/);
36
+ await assert.rejects(() => prepareCommand(cwd, { command: 'pwd', cwd: 'synthetic-key' }, ['synthetic-key']), /credential/);
37
+ });
38
+
39
+ test('direct command execution without an explicit approval snapshot never spawns', async () => {
40
+ const result = await executeTool(call({ command: 'printf nope > marker' }), cwd);
41
+ assert.equal(result.isError, true);
42
+ assert.match(result.content, /separate explicit approval/);
43
+ await assert.rejects(() => fs.stat(path.join(cwd, 'marker')));
44
+ });
45
+
46
+ test('approved commands use the exact cwd / timeout and report nonzero exit as an error', async () => {
47
+ await fs.mkdir(path.join(cwd, 'src'));
48
+ const request = call({ command: 'pwd; printf warning >&2; exit 7', cwd: 'src', timeout_ms: 1000 });
49
+ const approved = await prepareCommand(cwd, request.arguments);
50
+ const result = await executeTool(request, cwd, { commandApproval: approved });
51
+ assert.equal(result.isError, true);
52
+ const output = JSON.parse(result.content);
53
+ assert.equal(output.status, 'failed');
54
+ assert.equal(output.exit_code, 7);
55
+ assert.equal(output.stdout.trim(), approved.cwd);
56
+ assert.equal(output.stderr, 'warning');
57
+ });
58
+
59
+ test('changing command arguments or directory resolution after approval cannot spawn', async () => {
60
+ const request = call({ command: 'printf ok' });
61
+ const approved = await prepareCommand(cwd, request.arguments);
62
+ request.arguments.command = 'printf nope > marker';
63
+ const result = await executeTool(request, cwd, { commandApproval: approved });
64
+ assert.equal(result.isError, true);
65
+ assert.match(result.content, /changed after approval/);
66
+ await assert.rejects(() => fs.stat(path.join(cwd, 'marker')));
67
+ await fs.mkdir(path.join(cwd, 'src'));
68
+ await fs.mkdir(path.join(cwd, 'other'));
69
+ await fs.symlink('src', path.join(cwd, 'link'));
70
+ const linked = call({ command: 'printf nope > marker', cwd: 'link' });
71
+ const linkedApproval = await prepareCommand(cwd, linked.arguments);
72
+ await fs.unlink(path.join(cwd, 'link'));
73
+ await fs.symlink('other', path.join(cwd, 'link'));
74
+ assert.equal((await executeTool(linked, cwd, { commandApproval: linkedApproval })).isError, true);
75
+ await assert.rejects(() => fs.stat(path.join(cwd, 'other/marker')));
76
+ });
77
+
78
+ test('a cancelled queued command never spawns and does not poison the mutation queue', async () => {
79
+ const first = call({ command: 'sleep 0.2; printf done', timeout_ms: 1000 });
80
+ let started!: () => void;
81
+ const ready = new Promise<void>((resolve) => { started = resolve; });
82
+ const firstApproval = await prepareCommand(cwd, first.arguments);
83
+ const task = executeTool(first, cwd, { commandApproval: firstApproval, onStart: started });
84
+ await ready;
85
+ const second = call({ command: 'printf nope > marker' });
86
+ const secondApproval = await prepareCommand(cwd, second.arguments);
87
+ const controller = new AbortController();
88
+ let secondStarted = false;
89
+ const queued = executeTool(second, cwd, { commandApproval: secondApproval, signal: controller.signal, onStart: () => { secondStarted = true; } });
90
+ controller.abort();
91
+ assert.equal((await task).isError, undefined);
92
+ assert.equal((await queued).isError, true);
93
+ assert.equal(secondStarted, false);
94
+ await assert.rejects(() => fs.stat(path.join(cwd, 'marker')));
95
+ const final = call({ command: 'printf final' });
96
+ const finalResult = await executeTool(final, cwd, { commandApproval: await prepareCommand(cwd, final.arguments) });
97
+ assert.equal(JSON.parse(finalResult.content).stdout, 'final');
98
+ });
99
+
100
+ test('automatic authorization requires live auto mode and rechecks command eligibility', async () => {
101
+ const request = call({ command: 'pwd' });
102
+ const approved = { ...await prepareCommand(cwd, request.arguments), kind: 'auto' as const };
103
+ for (const autoEnabled of [undefined, () => false]) {
104
+ let started = false;
105
+ const result = await executeTool(request, cwd, { commandApproval: approved, autoEnabled, onStart: () => { started = true; } });
106
+ assert.equal(result.isError, true);
107
+ assert.match(result.content, /revoked/);
108
+ assert.equal(started, false);
109
+ }
110
+ const result = await executeTool(request, cwd, { commandApproval: approved, autoEnabled: () => true });
111
+ assert.equal(result.isError, undefined);
112
+ assert.equal(JSON.parse(result.content).stdout.trim(), approved.cwd);
113
+ const unknown = call({ command: 'printf no > marker' });
114
+ const rejected = await executeTool(unknown, cwd, { commandApproval: { ...await prepareCommand(cwd, unknown.arguments), kind: 'auto' }, autoEnabled: () => true });
115
+ assert.equal(rejected.isError, true);
116
+ await assert.rejects(fs.stat(path.join(cwd, 'marker')));
117
+ });
118
+
119
+ test('queued automatic commands cannot run after revocation or a package script change', async () => {
120
+ for (const changeScript of [false, true]) {
121
+ await fs.writeFile(path.join(cwd, 'package.json'), JSON.stringify({ scripts: { test: 'node --test' } }));
122
+ const first = call({ command: 'sleep 0.1' });
123
+ let started!: () => void;
124
+ const ready = new Promise<void>((resolve) => { started = resolve; });
125
+ const task = executeTool(first, cwd, { commandApproval: await prepareCommand(cwd, first.arguments), onStart: started });
126
+ await ready;
127
+ const second = call({ command: 'npm test' });
128
+ let enabled = true, didStart = false;
129
+ const queued = executeTool(second, cwd, {
130
+ commandApproval: { ...await prepareCommand(cwd, second.arguments), kind: 'auto' },
131
+ autoEnabled: () => enabled,
132
+ onStart: () => { didStart = true; },
133
+ });
134
+ if (changeScript) await fs.writeFile(path.join(cwd, 'package.json'), JSON.stringify({ scripts: { test: 'printf no > marker' } }));
135
+ else enabled = false;
136
+ await task;
137
+ assert.equal((await queued).isError, true);
138
+ assert.equal(didStart, false);
139
+ await assert.rejects(fs.stat(path.join(cwd, 'marker')));
140
+ }
141
+ });
@@ -0,0 +1,252 @@
1
+ import assert from 'node:assert/strict';
2
+ import { constants } from 'node:fs';
3
+ import fs from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { afterEach, beforeEach, test } from 'node:test';
7
+ import { commandPolicy } from '../src/commandpolicy.ts';
8
+
9
+ let base: string;
10
+ let cwd: string;
11
+ beforeEach(async () => {
12
+ base = await fs.mkdtemp(path.join(os.tmpdir(), 'sub-command-policy-'));
13
+ cwd = path.join(base, 'project');
14
+ await fs.mkdir(cwd);
15
+ await fs.mkdir(path.join(cwd, 'src'));
16
+ await fs.mkdir(path.join(cwd, 'test'));
17
+ await fs.writeFile(path.join(cwd, 'src', 'example.ts'), '');
18
+ await fs.writeFile(path.join(cwd, 'test', 'example.test.ts'), '');
19
+ await fs.writeFile(path.join(cwd, 'test', 'with space.test.ts'), '');
20
+ await fs.writeFile(path.join(cwd, 'tsconfig.json'), '{}');
21
+ await manifest({ test: 'node --test', typecheck: 'tsc --noEmit', build: 'vite build', lint: 'eslint src', check: 'npm run typecheck && npm run lint' });
22
+ });
23
+ afterEach(async () => { await fs.rm(base, { recursive: true, force: true }); });
24
+ async function manifest(scripts: Record<string, unknown>): Promise<void> {
25
+ await fs.writeFile(path.join(cwd, 'package.json'), JSON.stringify({ scripts }));
26
+ }
27
+ async function allowed(commands: string[]): Promise<void> {
28
+ for (const command of commands) {
29
+ const result = await commandPolicy(command, cwd);
30
+ assert.equal(result.auto, true, command);
31
+ assert.equal(typeof result.reason, 'string');
32
+ }
33
+ }
34
+ async function asked(commands: string[]): Promise<void> {
35
+ for (const command of commands) {
36
+ const result = await commandPolicy(command, cwd);
37
+ assert.equal(result.auto, false, command);
38
+ assert.equal(result.reason, 'This command requires explicit approval.');
39
+ }
40
+ }
41
+
42
+ test('exact literals, whole quoted tokens, and checked && segments', async () => {
43
+ await allowed(['pwd', ' pwd ', '"pwd"', "'pwd'", 'pwd&&pwd', 'pwd && node --test', 'node --test "test/with space.test.ts"', "node --test 'test/example.test.ts'"]);
44
+ await asked(['', ' ', '&&pwd', 'pwd&&', 'pwd & pwd', 'pwd&&&pwd', 'p"wd"', '"pwd"x', 'pwd ""', "'pwd", 'pwd\t', 'pwd\n', 'pwd\r', 'pwd\0', 'pwd\x1b', 'pwd', `pwd ${'x'.repeat(8192)}`]);
45
+ });
46
+
47
+ test('shell syntax is rejected even inside quotes, without executing examples', async () => {
48
+ await asked([
49
+ 'pwd;pwd', 'pwd | pwd', 'pwd || pwd', 'pwd > out', 'pwd 2>&1', 'pwd < in', 'pwd # comment',
50
+ '$(pwd)', '`pwd`', '$PWD', 'node --test "$PWD"', 'node --test "$(pwd)"',
51
+ 'pwd\\', 'node --test test/*.ts', 'node --test "test/*.ts"', 'node --test test/?.ts',
52
+ 'node --test [abc].ts', 'node --test {a,b}.ts', 'node --test ~/test.ts',
53
+ '(pwd)', 'pwd &', '! pwd', 'FOO=bar pwd', 'export FOO=bar', 'pwd && FOO=bar node --test',
54
+ 'node --test "test/a;pwd.ts"', 'node --test "test/a#b.ts"', 'node --test "test/a\\b.ts"',
55
+ ]);
56
+ });
57
+
58
+ test('unknown executables, wrappers, networking, destructive operations, and interpreters ask', async () => {
59
+ await asked([
60
+ 'sudo pwd', 'rm file', 'mkdir new', 'touch file', 'mv a b', 'cp a b', 'dd if=a of=b',
61
+ 'git reset --hard', 'git push --force', 'git clean -fd', 'git checkout main',
62
+ 'curl https://example.com', 'wget https://example.com', 'npm install', 'npm ci', 'npx tsc --noEmit',
63
+ 'bash -c pwd', 'sh -c pwd', 'zsh -f -c pwd', 'env pwd', 'command pwd', 'exec pwd',
64
+ 'python -c pass', 'ruby -e exit', 'node src/example.ts', 'node -e code', '/bin/pwd', './pwd', 'pwd --help',
65
+ 'pwd && curl https://example.com', 'NODE_OPTIONS=x node --test',
66
+ ]);
67
+ });
68
+
69
+ test('Git uses exact guarded inspection forms and limited flags', async () => {
70
+ await allowed([
71
+ 'git --no-pager status', 'git --no-pager status --short --branch',
72
+ 'git --no-pager status --porcelain=v2 --untracked-files=no',
73
+ 'git --no-pager diff --no-ext-diff --no-textconv',
74
+ 'git --no-pager diff --no-ext-diff --no-textconv --cached --stat -- src',
75
+ 'git --no-pager diff --no-ext-diff --no-textconv --check',
76
+ 'git --no-pager log --oneline --max-count=5', 'git --no-pager log -n 2 HEAD',
77
+ 'git --no-pager show --no-ext-diff --no-textconv HEAD',
78
+ 'git --no-pager ls-files --cached --stage -- src/example.ts',
79
+ ]);
80
+ await asked([
81
+ 'git status', 'git diff', 'git --no-pager diff', 'git --no-pager diff --no-ext-diff',
82
+ 'git --no-pager show HEAD', 'git --no-pager log -p', 'git --no-pager log --patch',
83
+ 'git -c core.pager=x status', 'git --no-pager -C src status', 'git --no-pager --git-dir=src status',
84
+ 'git --no-pager diff --no-ext-diff --no-textconv --output=out',
85
+ 'git --no-pager diff --no-ext-diff --no-textconv --ext-diff',
86
+ 'git --no-pager show --no-ext-diff --no-textconv --textconv HEAD',
87
+ 'git --no-pager log --format=x', 'git --no-pager log --max-count=0', 'git --no-pager log -n 1001',
88
+ 'git --no-pager log --max-count=1 --max-count=2', 'git --no-pager log --oneline --oneline',
89
+ 'git --no-pager log HEAD main', 'git --no-pager log --all',
90
+ 'git --no-pager ls-files --error-unmatch', 'git --no-pager status -- ../outside',
91
+ 'git --no-pager status -- :/src', 'git --no-pager status -- /tmp',
92
+ ]);
93
+ });
94
+
95
+ test('Node only permits test mode with built-in reporters and strict test flags', async () => {
96
+ await allowed([
97
+ 'node --test', 'node --test --test-concurrency=2 --test-reporter=dot',
98
+ 'node --test --test-concurrency 2 --test-reporter spec test/example.test.ts',
99
+ 'node --test --test-only --test-timeout=1000 "--test-name-pattern=simple test"',
100
+ 'node --test --test-skip-pattern=slow -- test/example.test.ts',
101
+ ]);
102
+ await asked([
103
+ 'node --test --eval=x', 'node --test -e x', 'node --test --import=src/example.ts',
104
+ 'node --test --require=src/example.ts', 'node --test --loader=src/example.ts', 'node --test --watch',
105
+ 'node --test --test-reporter=src/example.ts', 'node --test --test-reporter-destination=out',
106
+ 'node --test --test-concurrency=0', 'node --test --test-concurrency=257', 'node --test --test-concurrency=2x',
107
+ 'node --test --test-reporter', 'node --test --test-reporter=dot --test-reporter=tap',
108
+ 'node --test --test-only=true', 'node --test --test-timeout=-1', 'node --test --test-timeout=600001',
109
+ 'node --test --test-name-pattern=--import', 'node --test tsconfig.json', 'node --test test',
110
+ 'node --test -- --import=src/example.ts', 'node --test -- src/missing.ts',
111
+ ]);
112
+ });
113
+
114
+ test('compiler, Vite build, and lint flags stay narrow and output customization asks', async () => {
115
+ await allowed([
116
+ 'tsc', 'tsc --noEmit', 'tsc --noEmit --pretty false', 'tsc --strict --skipLibCheck',
117
+ 'tsc --noEmit --project tsconfig.json', 'tsc -p ./tsconfig.json --noEmit true',
118
+ 'tsc --build', 'tsc -b tsconfig.json', 'vite build',
119
+ 'eslint .', 'eslint src test --quiet --max-warnings=0',
120
+ 'eslint --ext .ts,.js --format json src', 'eslint --no-fix -- src/example.ts',
121
+ ]);
122
+ await asked([
123
+ 'tsc --watch', 'tsc -w', 'tsc --outDir elsewhere', 'tsc --outFile output.js', 'tsc --noEmit false',
124
+ 'tsc --project ../tsconfig.json', 'tsc --project tsconfig.json --project tsconfig.json',
125
+ 'tsc --build --clean', 'tsc --build --force', 'tsc --unknown', 'tsc --noEmit --noEmit',
126
+ 'vite', 'vite dev', 'vite build --outDir elsewhere', 'vite build --config src/example.ts',
127
+ 'eslint', 'eslint --fix src', 'eslint --cache src', 'eslint --output-file out src',
128
+ 'eslint --config src/example.ts src', 'eslint --format src/example.ts src',
129
+ 'eslint --rule custom src', 'eslint --max-warnings=-1 src', 'eslint --ext .exe src',
130
+ 'eslint --quiet=true src', 'eslint --max-warnings src', 'eslint -- --fix',
131
+ ]);
132
+ });
133
+
134
+ test('paths reject traversal, absolutes, missing paths, and external symlinks', async () => {
135
+ await fs.writeFile(path.join(base, 'outside.ts'), '');
136
+ await fs.symlink(path.join(base, 'outside.ts'), path.join(cwd, 'escape.ts'));
137
+ await fs.symlink('test/example.test.ts', path.join(cwd, 'internal.ts'));
138
+ await allowed(['node --test ./test/example.test.ts', 'node --test internal.ts']);
139
+ await asked([
140
+ `node --test ${path.join(base, 'outside.ts')}`, 'node --test ../outside.ts',
141
+ 'node --test test/../../outside.ts', 'node --test test/../test/example.test.ts',
142
+ 'node --test escape.ts', 'eslint escape.ts', 'node --test missing.ts',
143
+ 'eslint /tmp', 'eslint ../', 'eslint src/--option', 'node --test file:src/example.ts',
144
+ ]);
145
+ });
146
+
147
+ test('npm exact allowed scripts recursively validate pre/post lifecycle commands', async () => {
148
+ await allowed(['npm test', 'npm run test', 'npm run build', 'npm run lint', 'npm run typecheck', 'npm run check']);
149
+ await asked([
150
+ 'npm test -- --test-only', 'npm run typecheck --', 'npm run build --prefix src',
151
+ 'npm --prefix src test', 'npm run test --workspace=x', 'npm -w x test',
152
+ 'npm run test --ignore-scripts', 'npm run start', 'npm start', 'npm run harness',
153
+ 'npm run test extra', 'npm run', 'npm run TEST',
154
+ ]);
155
+ await manifest({ test: 'node --test', pretest: 'tsc --noEmit', posttest: 'eslint src' });
156
+ await allowed(['npm test']);
157
+ await manifest({ test: 'node --test', pretest: 'rm marker' });
158
+ await asked(['npm test']);
159
+ await manifest({ test: 'node --test', posttest: 'curl https://example.com' });
160
+ await asked(['npm test']);
161
+ await manifest({ test: 'node --test', posttest: false });
162
+ await asked(['npm test']);
163
+ await manifest({ test: 'node --test', pretest: '' });
164
+ await asked(['npm test']);
165
+ await manifest({ test: 'node --test', unrelated: 'rm marker', prepare: 'curl https://example.com' });
166
+ await allowed(['npm test']);
167
+ });
168
+
169
+ test('npm scripts are not opaque, cached, implicit, or cycle-permissive', async () => {
170
+ await manifest({ test: 'node src/example.ts', typecheck: 'tsc --noEmit' });
171
+ await asked(['npm test']);
172
+ await allowed(['npm run typecheck']);
173
+ await manifest({ test: 'node --test' });
174
+ await allowed(['npm test']);
175
+ await manifest({ test: 'rm marker' });
176
+ await asked(['npm test']);
177
+ await manifest({ test: 'npm test' });
178
+ await asked(['npm test']);
179
+ await manifest({ test: 'npm run check', check: 'npm run test' });
180
+ await asked(['npm test']);
181
+ await manifest({ test: 'node --test', pretest: 'npm test' });
182
+ await asked(['npm test']);
183
+ await manifest({ test: 'npm run check && npm run check', check: 'tsc --noEmit' });
184
+ await allowed(['npm test']);
185
+ await manifest({ test: true });
186
+ await asked(['npm test']);
187
+ await manifest({});
188
+ await asked(['npm test']);
189
+ });
190
+
191
+ test('bounded manifest reads reject missing, nonregular, symlinked, malformed, and large files', async (t) => {
192
+ const file = path.join(cwd, 'package.json');
193
+ const resolvedFile = await fs.realpath(file);
194
+ const originalOpen = fs.open;
195
+ let sawFlags = false;
196
+ t.mock.method(fs, 'open', async (name: Parameters<typeof fs.open>[0], flags: Parameters<typeof fs.open>[1]) => {
197
+ assert.equal(name, resolvedFile);
198
+ assert.equal(typeof flags, 'number');
199
+ assert.ok((Number(flags) & constants.O_NOFOLLOW) !== 0);
200
+ assert.ok((Number(flags) & constants.O_NONBLOCK) !== 0);
201
+ sawFlags = true;
202
+ return originalOpen(name, flags);
203
+ });
204
+ await allowed(['npm test']);
205
+ assert.equal(sawFlags, true);
206
+ t.mock.restoreAll();
207
+ await fs.unlink(file);
208
+ await asked(['npm test']);
209
+ await fs.mkdir(file);
210
+ await asked(['npm test']);
211
+ await fs.rmdir(file);
212
+ await fs.writeFile(path.join(base, 'manifest.json'), JSON.stringify({ scripts: { test: 'node --test' } }));
213
+ await fs.symlink(path.join(base, 'manifest.json'), file);
214
+ await asked(['npm test']);
215
+ await fs.unlink(file);
216
+ for (const text of ['not json', 'null', '[]', '{"scripts":[]}', '{"scripts":"node --test"}', ' '.repeat(65537)]) {
217
+ await fs.writeFile(file, text);
218
+ await asked(['npm test']);
219
+ }
220
+ await fs.writeFile(file, Buffer.from([0xff, 0xfe]));
221
+ await asked(['npm test']);
222
+ });
223
+
224
+ test('recursive validation is bounded by segment count, total work, and script text', async () => {
225
+ await asked([Array(17).fill('pwd').join(' && ')]);
226
+ await manifest({ test: Array(16).fill('npm run check').join(' && '), check: Array(16).fill('pwd').join(' && ') });
227
+ await asked(['npm test']);
228
+ await manifest({ test: Array(8).fill('npm run check').join(' && '), check: `pwd${' '.repeat(8000)}` });
229
+ await asked(['npm test']);
230
+ await manifest({ test: `pwd${' '.repeat(8192)}` });
231
+ await asked(['npm test']);
232
+ });
233
+
234
+ test('result reasons are safe fixed text and do not promise a sandbox', async () => {
235
+ await manifest({ test: 'unknown-sensitive-marker' });
236
+ const denied = await commandPolicy('npm test', cwd);
237
+ assert.equal(denied.auto, false);
238
+ assert.ok(!denied.reason.includes('sensitive-marker'));
239
+ const result = await commandPolicy('node --test', cwd);
240
+ assert.match(result.reason, /trusted project code/);
241
+ assert.match(result.reason, /not sandboxed/);
242
+ const inspection = await commandPolicy('pwd', cwd);
243
+ assert.match(inspection.reason, /trusted tool configuration/i);
244
+ assert.match(inspection.reason, /not a sandbox/);
245
+ // Callers cannot mutate shared result constants to change future decisions.
246
+ result.auto = false;
247
+ denied.auto = true;
248
+ await allowed(['node --test']);
249
+ await asked(['unknown-sensitive-marker']);
250
+ assert.equal((await commandPolicy('pwd', path.join(base, 'missing'))).auto, false);
251
+ assert.equal((await commandPolicy('pwd', path.join(cwd, 'package.json'))).auto, false);
252
+ });
@@ -0,0 +1,220 @@
1
+ import assert from 'node:assert/strict';
2
+ import fs from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { afterEach, beforeEach, describe, it } from 'node:test';
6
+ import { executeTool, MAX_READ_BYTES, MAX_TREE_BYTES, MAX_TREE_ENTRIES, RECOVERY_DIR } from '../src/tools.ts';
7
+
8
+ let base: string, cwd: string, outside: string;
9
+ beforeEach(async () => {
10
+ base = await fs.mkdtemp(path.join(os.tmpdir(), 'sub-disk-'));
11
+ cwd = path.join(base, 'project');
12
+ outside = path.join(base, 'outside');
13
+ await fs.mkdir(cwd);
14
+ await fs.mkdir(outside);
15
+ await fs.writeFile(path.join(cwd, 'hello.txt'), 'hello');
16
+ await fs.writeFile(path.join(outside, 'secret'), 'outside');
17
+ });
18
+ afterEach(async () => { await fs.rm(base, { recursive: true, force: true }); });
19
+
20
+ function run(name: string, args: Record<string, unknown> = {}) {
21
+ return executeTool({ id: 'test', name, arguments: args, argumentsJson: JSON.stringify(args) }, cwd);
22
+ }
23
+ async function good(name: string, args: Record<string, unknown> = {}) {
24
+ const result = await run(name, args);
25
+ assert.equal(result.isError, undefined, result.content);
26
+ assert.equal(result.toolCallId, 'test');
27
+ return result.content;
28
+ }
29
+ async function bad(name: string, args: Record<string, unknown>, pattern?: RegExp) {
30
+ const result = await run(name, args);
31
+ assert.equal(result.isError, true, result.content);
32
+ if (pattern) assert.match(result.content, pattern);
33
+ }
34
+ async function exists(relative: string) { return !!await fs.lstat(path.join(cwd, relative)).catch(() => null); }
35
+
36
+ it('creates nested directories without changing existing content', async () => {
37
+ await good('make_dir', { path: 'new/project/src' });
38
+ await good('make_dir', { path: 'new/project/src' });
39
+ assert.ok((await fs.stat(path.join(cwd, 'new/project/src'))).isDirectory());
40
+ await bad('make_dir', { path: 'hello.txt' });
41
+ });
42
+
43
+ it('batch reads retain per-path errors and enforce count / total byte bounds', async () => {
44
+ await fs.writeFile(path.join(cwd, 'large.txt'), 'x'.repeat(MAX_READ_BYTES));
45
+ const results = JSON.parse(await good('read_files', { paths: ['hello.txt', 'missing', '../outside/secret', 'large.txt'] }));
46
+ assert.equal(results[0].content, 'hello');
47
+ assert.ok(results.slice(1).every((r: { error?: string }) => r.error));
48
+ const exact = JSON.parse(await good('read_files', { paths: ['large.txt', 'hello.txt'] }));
49
+ assert.equal(exact[0].content.length, MAX_READ_BYTES);
50
+ assert.match(exact[1].error, /limit 0/);
51
+ for (const paths of [[], ['hello.txt', 42], Array(21).fill('hello.txt'), 'hello.txt']) await bad('read_files', { paths });
52
+ });
53
+
54
+ it('reports metadata without binary content and rejects binary text reads', async () => {
55
+ await fs.writeFile(path.join(cwd, 'image.bin'), Buffer.from([0, 1, 255]));
56
+ const info = JSON.parse(await good('file_info', { path: 'image.bin' }));
57
+ assert.equal(info.type, 'file');
58
+ assert.equal(info.size_bytes, 3);
59
+ assert.match(info.permissions, /^[0-7]{3}$/);
60
+ assert.ok(Number.isFinite(Date.parse(info.modified_at)));
61
+ await fs.symlink('hello.txt', path.join(cwd, 'local-link'));
62
+ assert.equal(JSON.parse(await good('file_info', { path: 'local-link' })).type, 'symlink');
63
+ await bad('read_file', { path: 'image.bin' }, /binary/);
64
+ await fs.writeFile(path.join(cwd, 'binary-match'), Buffer.from('needle\0secret'));
65
+ assert.equal(await good('grep', { pattern: 'needle' }), '(no matches)');
66
+ });
67
+
68
+ it('finds nested paths with literal filters, skips links and generated directories', async () => {
69
+ for (const dir of ['src/nested', 'node_modules/pkg', '.git', RECOVERY_DIR]) await fs.mkdir(path.join(cwd, dir), { recursive: true });
70
+ for (const file of ['src/nested/main.ts', 'node_modules/pkg/no.ts', '.git/no.ts', `${RECOVERY_DIR}/no.ts`]) await fs.writeFile(path.join(cwd, file), 'x');
71
+ await fs.symlink(outside, path.join(cwd, 'outside-link'));
72
+ assert.equal(await good('find_files', { pattern: '.ts', type: 'file' }), 'src/nested/main.ts');
73
+ assert.equal(await good('find_files', { path: 'src', type: 'directory' }), 'nested/');
74
+ assert.equal(await good('find_files', { pattern: '*.ts' }), '(no matches)');
75
+ await bad('find_files', { type: 'invalid' });
76
+ await bad('find_files', { path: 'hello.txt' });
77
+ for (let i = 0; i < 510; i++) await fs.writeFile(path.join(cwd, `file-${i}`), '');
78
+ assert.match(await good('find_files'), /capped/);
79
+ });
80
+
81
+ it('copies text, binary assets and trees without overwriting', async () => {
82
+ await fs.mkdir(path.join(cwd, 'assets/empty'), { recursive: true });
83
+ const bytes = Buffer.from([0, 2, 3, 255]);
84
+ await fs.writeFile(path.join(cwd, 'assets/photo.bin'), bytes);
85
+ await fs.writeFile(path.join(cwd, 'assets/tool'), 'script', { mode: 0o755 });
86
+ await good('copy_path', { source: 'assets', destination: 'new/assets' });
87
+ assert.deepEqual(await fs.readFile(path.join(cwd, 'new/assets/photo.bin')), bytes);
88
+ assert.equal((await fs.stat(path.join(cwd, 'new/assets/tool'))).mode & 0o777, 0o755);
89
+ assert.ok(await exists('new/assets/empty'));
90
+ await good('copy_path', { source: 'hello.txt', destination: 'copied.txt' });
91
+ await bad('copy_path', { source: 'hello.txt', destination: 'copied.txt' }, /exists/);
92
+ await bad('copy_path', { source: 'assets', destination: 'assets/child' }, /source tree/);
93
+ assert.equal(await fs.readFile(path.join(cwd, 'copied.txt'), 'utf8'), 'hello');
94
+ });
95
+
96
+ it('moves and renames files / directories, including empty directories', async () => {
97
+ await good('move_path', { source: 'hello.txt', destination: 'new/renamed.txt' });
98
+ assert.ok(!await exists('hello.txt'));
99
+ await fs.mkdir(path.join(cwd, 'empty'));
100
+ await bad('move_path', { source: 'new', destination: 'empty' }, /exists/);
101
+ await good('move_path', { source: 'new', destination: 'renamed' });
102
+ assert.equal(await fs.readFile(path.join(cwd, 'renamed/renamed.txt'), 'utf8'), 'hello');
103
+ await good('move_path', { source: 'empty', destination: 'empty2' });
104
+ });
105
+
106
+ it('preflights size / entry caps before copying, moving or removing', async () => {
107
+ const file = await fs.open(path.join(cwd, 'too-large'), 'w');
108
+ await file.truncate(MAX_TREE_BYTES + 1); await file.close();
109
+ for (const name of ['copy_path', 'move_path']) await bad(name, { source: 'too-large', destination: 'not-created/deep/file' }, /100 MiB/);
110
+ await bad('remove_path', { path: 'too-large' }, /100 MiB/);
111
+ assert.ok(!await exists('not-created'));
112
+ assert.ok(!await exists(RECOVERY_DIR));
113
+ await fs.mkdir(path.join(cwd, 'many'));
114
+ for (let i = 0; i < MAX_TREE_ENTRIES; i++) await fs.writeFile(path.join(cwd, 'many', String(i)), '');
115
+ await bad('copy_path', { source: 'many', destination: 'many-copy' }, /entries/);
116
+ assert.ok(!await exists('many-copy'));
117
+ });
118
+
119
+ it('serializes conflicting mutations so only one destination is created', async () => {
120
+ const results = await Promise.all([
121
+ run('move_path', { source: 'hello.txt', destination: 'first' }),
122
+ run('move_path', { source: 'hello.txt', destination: 'second' }),
123
+ ]);
124
+ assert.equal(results.filter((r) => !r.isError).length, 1);
125
+ assert.notEqual(await exists('first'), await exists('second'));
126
+ await good('write_file', { path: 'after-failure.txt', content: 'queue still works' });
127
+ });
128
+
129
+ it('removes recoverably, lists IDs, refuses occupied restore paths, and restores trees', async () => {
130
+ await fs.mkdir(path.join(cwd, 'work/empty'), { recursive: true });
131
+ await fs.writeFile(path.join(cwd, 'work/data'), 'keep');
132
+ assert.deepEqual(JSON.parse(await good('list_removed')).items, []);
133
+ const removed = JSON.parse(await good('remove_path', { path: 'work' }));
134
+ assert.equal(await fs.readFile(path.join(cwd, RECOVERY_DIR, '.gitignore'), 'utf8'), '*\n');
135
+ assert.ok(!await exists('work'));
136
+ const listing = JSON.parse(await good('list_removed'));
137
+ assert.equal(listing.items[0].recovery_id, removed.recovery_id);
138
+ assert.equal(listing.items[0].path, 'work');
139
+ assert.equal((await fs.stat(path.join(cwd, RECOVERY_DIR))).mode & 0o777, 0o700);
140
+ await fs.mkdir(path.join(cwd, 'work'));
141
+ await bad('restore_file', { recovery_id: removed.recovery_id }, /exists/);
142
+ await good('restore_file', { recovery_id: removed.recovery_id, destination: 'restored/work' });
143
+ assert.equal(await fs.readFile(path.join(cwd, 'restored/work/data'), 'utf8'), 'keep');
144
+ assert.ok(await exists('restored/work/empty'));
145
+ assert.deepEqual(JSON.parse(await good('list_removed')).items, []);
146
+ await bad('restore_file', { recovery_id: removed.recovery_id });
147
+ const file = JSON.parse(await good('remove_path', { path: 'hello.txt' }));
148
+ await good('restore_file', { recovery_id: file.recovery_id });
149
+ assert.equal(await fs.readFile(path.join(cwd, 'hello.txt'), 'utf8'), 'hello');
150
+ });
151
+
152
+ it('confines all new paths and protects project root, git internals and symlink trees', async () => {
153
+ await fs.symlink(outside, path.join(cwd, 'escape'));
154
+ await fs.symlink(path.join(outside, 'new'), path.join(cwd, 'dangling'));
155
+ await fs.mkdir(path.join(cwd, 'tree'));
156
+ await fs.symlink('hello.txt', path.join(cwd, 'local-link'));
157
+ await fs.symlink(outside, path.join(cwd, 'tree/nested-link'));
158
+ for (const raw of ['../outside', outside, '~/.ssh', 'escape', 'dangling']) {
159
+ for (const name of ['make_dir', 'remove_path', 'file_info']) await bad(name, { path: raw });
160
+ for (const name of ['copy_path', 'move_path']) {
161
+ await bad(name, { source: raw, destination: 'new' });
162
+ await bad(name, { source: 'hello.txt', destination: raw });
163
+ }
164
+ }
165
+ for (const raw of ['.', '.git/config', 'local-link']) {
166
+ for (const name of ['remove_path', 'write_file', 'edit_file', 'make_dir']) await bad(name, { path: raw, content: 'no', old_string: 'hello', new_string: 'no' });
167
+ }
168
+ for (const name of ['copy_path', 'move_path']) await bad(name, { source: 'tree', destination: 'new' }, /symlink/);
169
+ await bad('remove_path', { path: 'tree' }, /symlink/);
170
+ assert.equal(await fs.readFile(path.join(outside, 'secret'), 'utf8'), 'outside');
171
+ assert.ok(!await fs.lstat(path.join(outside, 'new')).catch(() => null));
172
+ });
173
+
174
+ describe('recovery confinement', () => {
175
+ it('refuses direct and aliased access to recovery storage', async () => {
176
+ const removed = JSON.parse(await good('remove_path', { path: 'hello.txt' }));
177
+ await fs.symlink(RECOVERY_DIR, path.join(cwd, 'recovery-alias'));
178
+ for (const raw of [RECOVERY_DIR, `x/../${RECOVERY_DIR}`, 'recovery-alias']) {
179
+ for (const name of ['read_file', 'list_dir', 'find_files', 'write_file', 'remove_path']) await bad(name, { path: raw, content: 'no' }, /recovery/);
180
+ }
181
+ await good('restore_file', { recovery_id: removed.recovery_id });
182
+ });
183
+ it('rejects non-private recovery storage without moving the source', async () => {
184
+ await fs.mkdir(path.join(cwd, RECOVERY_DIR), { mode: 0o755 });
185
+ await bad('remove_path', { path: 'hello.txt' }, /private/);
186
+ assert.ok(await exists('hello.txt'));
187
+ });
188
+ it('rejects a symlink recovery root without moving the source', async () => {
189
+ await fs.symlink(outside, path.join(cwd, RECOVERY_DIR));
190
+ await bad('remove_path', { path: 'hello.txt' }, /real directory/);
191
+ await bad('list_removed', {}, /real directory/);
192
+ assert.ok(await exists('hello.txt'));
193
+ });
194
+ it('rejects malicious IDs, records, payload links and entry links', async () => {
195
+ const removed = JSON.parse(await good('remove_path', { path: 'hello.txt' }));
196
+ const entry = path.join(cwd, RECOVERY_DIR, removed.recovery_id);
197
+ for (const id of ['../outside', 'no', null]) await bad('restore_file', { recovery_id: id });
198
+ await fs.writeFile(path.join(entry, 'record.json'), JSON.stringify({ path: '../outside/secret', removed_at: 'now' }));
199
+ await bad('restore_file', { recovery_id: removed.recovery_id }, /escapes/);
200
+ await fs.writeFile(path.join(entry, 'record.json'), JSON.stringify({ path: 'hello.txt', removed_at: 'now' }));
201
+ await fs.unlink(path.join(entry, 'item'));
202
+ await fs.symlink(path.join(outside, 'secret'), path.join(entry, 'item'));
203
+ await bad('restore_file', { recovery_id: removed.recovery_id }, /symlink/);
204
+ await fs.unlink(path.join(entry, 'record.json'));
205
+ await fs.symlink(path.join(outside, 'secret'), path.join(entry, 'record.json'));
206
+ await bad('restore_file', { recovery_id: removed.recovery_id });
207
+ await fs.rm(entry, { recursive: true });
208
+ await fs.symlink(outside, entry);
209
+ await bad('restore_file', { recovery_id: removed.recovery_id }, /invalid recovery entry/);
210
+ assert.equal(await fs.readFile(path.join(outside, 'secret'), 'utf8'), 'outside');
211
+ });
212
+ });
213
+
214
+ it('rejects missing write/edit content, empty edit matches, and oversized edits without data loss', async () => {
215
+ await bad('write_file', { path: 'hello.txt' }, /content/);
216
+ await bad('edit_file', { path: 'hello.txt', old_string: 'hello' }, /new_string/);
217
+ await bad('edit_file', { path: 'hello.txt', old_string: '', new_string: 'bad' }, /old_string/);
218
+ await bad('edit_file', { path: 'hello.txt', old_string: 'hello', new_string: 'x'.repeat(1024 * 1024 + 1) }, /exceeds/);
219
+ assert.equal(await fs.readFile(path.join(cwd, 'hello.txt'), 'utf8'), 'hello');
220
+ });