@sisu-ai/tool-terminal 1.1.0 → 1.2.1
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 +45 -10
- package/dist/index.d.ts +3 -21
- package/dist/index.js +390 -95
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# @sisu-ai/tool-terminal
|
|
2
2
|
|
|
3
|
+
A secure terminal execution tool for Sisu agents. Provides sandboxed command execution with session support, a strict allow list, realpath-based path scoping, timeouts and basic file helpers. Commands run without a shell and reject control operators by default; optional shell-free pipelines (`|`) and sequences (`;`, `&&`, `||`) can be enabled via config.
|
|
4
|
+
|
|
3
5
|
[](https://github.com/finger-gun/sisu/actions/workflows/tests.yml)
|
|
4
6
|
[](https://github.com/finger-gun/sisu/blob/main/LICENSE)
|
|
5
7
|
[](https://www.npmjs.com/package/@sisu-ai/tool-terminal)
|
|
6
8
|
[](https://github.com/finger-gun/sisu/blob/main/CONTRIBUTING.md)
|
|
7
9
|
|
|
8
|
-
A secure terminal execution tool for Sisu agents. Provides sandboxed shell command execution with session support, command allow/deny lists, path scoping, timeouts and basic file helpers.
|
|
9
|
-
|
|
10
10
|
## API
|
|
11
11
|
|
|
12
12
|
- `createTerminalTool(config?)` → returns an instance with:
|
|
@@ -16,8 +16,7 @@ A secure terminal execution tool for Sisu agents. Provides sandboxed shell comma
|
|
|
16
16
|
### Defaults & Reuse
|
|
17
17
|
- Importable defaults to help you build policies/UI:
|
|
18
18
|
- `DEFAULT_CONFIG` — full default config object
|
|
19
|
-
- `TERMINAL_COMMANDS_ALLOW` — default
|
|
20
|
-
- `TERMINAL_COMMANDS_DENY` — default denylist array
|
|
19
|
+
- `TERMINAL_COMMANDS_ALLOW` — default allow list array
|
|
21
20
|
- `defaultTerminalConfig(partial)` — helper to merge your overrides with sensible defaults
|
|
22
21
|
|
|
23
22
|
## Quick Start
|
|
@@ -73,13 +72,23 @@ type TerminalToolConfig = {
|
|
|
73
72
|
roots: string[]; // allowed path roots (required)
|
|
74
73
|
readOnlyRoots?: string[];
|
|
75
74
|
capabilities: { read: boolean; write: boolean; delete: boolean; exec: boolean };
|
|
76
|
-
commands: { allow: string[]
|
|
77
|
-
execution: { timeoutMs: number; maxStdoutBytes: number; maxStderrBytes: number;
|
|
75
|
+
commands: { allow: string[] };
|
|
76
|
+
execution: { timeoutMs: number; maxStdoutBytes: number; maxStderrBytes: number; pathDirs: string[] };
|
|
77
|
+
allowPipe?: boolean; // enable '|'
|
|
78
|
+
allowSequence?: boolean; // enable ';', '&&', '||'
|
|
78
79
|
sessions: { enabled: boolean; ttlMs: number; maxPerAgent: number };
|
|
79
80
|
}
|
|
80
81
|
```
|
|
81
82
|
|
|
82
|
-
Sensible defaults: `read: true`, `exec: true`, `write/delete: false`, timeout 10s, `roots: [process.cwd()]`, and a conservative allow
|
|
83
|
+
Sensible defaults: `read: true`, `exec: true`, `write/delete: false`, timeout 10s, `roots: [process.cwd()]`, `execution.pathDirs` includes common system bins (`/usr/bin:/bin:/usr/local/bin` and `/opt/homebrew/bin` on macOS), and a conservative allow-only command policy. Shell operators are denied by default. You can opt-in to pipelines (`|`) which are executed without a shell, validating each segment.
|
|
84
|
+
|
|
85
|
+
### PATH Policy
|
|
86
|
+
- Fixed PATH: The tool constructs `PATH` from `execution.pathDirs` and ignores any provided `PATH` to prevent PATH hijack (malicious binaries earlier in the search path).
|
|
87
|
+
- Recommended dirs:
|
|
88
|
+
- Linux: `/usr/bin`, `/bin`, `/usr/local/bin`.
|
|
89
|
+
- macOS: add `/opt/homebrew/bin` if using Homebrew on Apple Silicon.
|
|
90
|
+
- Customize per app: Extend `execution.pathDirs` if your allowed commands live elsewhere (e.g., custom install prefixes). Prefer adding exact directories over inheriting the ambient PATH.
|
|
91
|
+
- Environment hygiene: Only `PATH`, `HOME`, `LANG`, and `TERM` are passed through (sanitized). Consider adding absolute paths (e.g., `/usr/bin/grep`) in policies if you want even stronger guarantees.
|
|
83
92
|
|
|
84
93
|
## Tool Schemas
|
|
85
94
|
|
|
@@ -89,6 +98,26 @@ Sensible defaults: `read: true`, `exec: true`, `write/delete: false`, timeout 10
|
|
|
89
98
|
|
|
90
99
|
Each tool is validated with zod and registered through the instance’s `tools` array. `start_session` is available as a method for advanced use but is not exposed as a tool by default.
|
|
91
100
|
|
|
101
|
+
### Allowing Operators (Optional)
|
|
102
|
+
By default, shell operators are denied. If you need simple operators, enable them explicitly:
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
const terminal = createTerminalTool({
|
|
106
|
+
roots: [process.cwd()],
|
|
107
|
+
allowPipe: true, // allow shell-free pipelines
|
|
108
|
+
allowSequence: true, // allow ;, &&, || sequencing
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Now these work securely without a shell:
|
|
112
|
+
await terminal.run_command({ command: "cat README.md | wc -l" });
|
|
113
|
+
await terminal.run_command({ command: "ls missing && echo will-not-run; ls || echo ran-on-error" });
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Notes:
|
|
117
|
+
- Each segment must be an allowed verb and passes path checks.
|
|
118
|
+
- Redirection (`>`, `<`), command substitution (`$()`/backticks), and backgrounding (`&`) remain blocked.
|
|
119
|
+
- Pipelines are executed by wiring processes directly; sequences run segments sequentially with correct semantics.
|
|
120
|
+
|
|
92
121
|
### When To Use `start_session`
|
|
93
122
|
- Persistent cwd across multiple calls: when you plan a sequence like “cd → run → read → run” and want a stable working directory without passing `cwd` every time.
|
|
94
123
|
- Pre-seeding env: when a short‑lived, limited env should apply to multiple runs (e.g., `PATH` tweak, `FOO_MODE=1`) without repeating it on each call.
|
|
@@ -107,11 +136,17 @@ const file = await term.read_file({ sessionId, path: 'README.md' });
|
|
|
107
136
|
## Notes
|
|
108
137
|
|
|
109
138
|
- Non-interactive commands only.
|
|
110
|
-
- Network-accessing commands are
|
|
111
|
-
-
|
|
112
|
-
-
|
|
139
|
+
- Network-accessing commands are not in the allow list by default (e.g., `curl`, `wget`).
|
|
140
|
+
- Default allowlist includes read-only tools: `pwd`, `ls`, `stat`, `wc`, `head`, `tail`, `cat`, `cut`, `sort`, `uniq`, `grep`.
|
|
141
|
+
- All paths are resolved via `realpath` and constrained to configured `roots`; write/delete under read-only roots are denied.
|
|
142
|
+
- Absolute or relative path arguments outside `roots` are denied (e.g., `grep -r /`). Prefer setting `cwd` (via `terminalCd`) and using relative paths.
|
|
143
|
+
- Commands run without an intermediate shell; tokens like `&&`, `|`, `;`, `$()` and redirections are rejected.
|
|
113
144
|
|
|
114
145
|
# Community & Support
|
|
146
|
+
|
|
147
|
+
Discover what you can do through examples or documentation. Check it out at https://github.com/finger-gun/sisu. Example projects live under [`examples/`](https://github.com/finger-gun/sisu/tree/main/examples) in the repo.
|
|
148
|
+
|
|
149
|
+
|
|
115
150
|
- [Code of Conduct](https://github.com/finger-gun/sisu/blob/main/CODE_OF_CONDUCT.md)
|
|
116
151
|
- [Contributing Guide](https://github.com/finger-gun/sisu/blob/main/CONTRIBUTING.md)
|
|
117
152
|
- [License](https://github.com/finger-gun/sisu/blob/main/LICENSE)
|
package/dist/index.d.ts
CHANGED
|
@@ -10,14 +10,15 @@ export interface TerminalToolConfig {
|
|
|
10
10
|
};
|
|
11
11
|
commands: {
|
|
12
12
|
allow: string[];
|
|
13
|
-
deny: string[];
|
|
14
13
|
};
|
|
15
14
|
execution: {
|
|
16
15
|
timeoutMs: number;
|
|
17
16
|
maxStdoutBytes: number;
|
|
18
17
|
maxStderrBytes: number;
|
|
19
|
-
|
|
18
|
+
pathDirs: string[];
|
|
20
19
|
};
|
|
20
|
+
allowPipe?: boolean;
|
|
21
|
+
allowSequence?: boolean;
|
|
21
22
|
sessions: {
|
|
22
23
|
enabled: boolean;
|
|
23
24
|
ttlMs: number;
|
|
@@ -26,7 +27,6 @@ export interface TerminalToolConfig {
|
|
|
26
27
|
}
|
|
27
28
|
export declare const DEFAULT_CONFIG: TerminalToolConfig;
|
|
28
29
|
export declare const TERMINAL_COMMANDS_ALLOW: ReadonlyArray<string>;
|
|
29
|
-
export declare const TERMINAL_COMMANDS_DENY: ReadonlyArray<string>;
|
|
30
30
|
export declare function defaultTerminalConfig(overrides?: Partial<TerminalToolConfig>): TerminalToolConfig;
|
|
31
31
|
export declare function createTerminalTool(config?: Partial<TerminalToolConfig>): {
|
|
32
32
|
start_session: (args?: {
|
|
@@ -52,24 +52,6 @@ export declare function createTerminalTool(config?: Partial<TerminalToolConfig>)
|
|
|
52
52
|
reason?: string;
|
|
53
53
|
};
|
|
54
54
|
cwd: string;
|
|
55
|
-
} | {
|
|
56
|
-
exitCode: number;
|
|
57
|
-
stdout: Buffer<ArrayBufferLike>;
|
|
58
|
-
stderr: Buffer<ArrayBufferLike>;
|
|
59
|
-
durationMs: number;
|
|
60
|
-
policy: {
|
|
61
|
-
allowed: boolean;
|
|
62
|
-
};
|
|
63
|
-
cwd: string;
|
|
64
|
-
} | {
|
|
65
|
-
exitCode: any;
|
|
66
|
-
stdout: string;
|
|
67
|
-
stderr: string;
|
|
68
|
-
durationMs: number;
|
|
69
|
-
policy: {
|
|
70
|
-
allowed: boolean;
|
|
71
|
-
};
|
|
72
|
-
cwd: string;
|
|
73
55
|
}>;
|
|
74
56
|
cd: (args: {
|
|
75
57
|
path: string;
|
package/dist/index.js
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { promises as fs } from 'node:fs';
|
|
2
|
+
import { promises as fs, realpathSync } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import {
|
|
5
|
-
import { promisify } from 'node:util';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
6
5
|
import { minimatch } from 'minimatch';
|
|
7
6
|
import { z } from 'zod';
|
|
8
|
-
const
|
|
7
|
+
const DEFAULT_PATH_DIRS = (() => {
|
|
8
|
+
const base = ['/usr/bin', '/bin', '/usr/local/bin'];
|
|
9
|
+
if (process.platform === 'darwin')
|
|
10
|
+
base.push('/opt/homebrew/bin');
|
|
11
|
+
return base;
|
|
12
|
+
})();
|
|
9
13
|
export const DEFAULT_CONFIG = {
|
|
10
14
|
roots: [process.cwd()],
|
|
11
15
|
capabilities: { read: true, write: false, delete: false, exec: true },
|
|
@@ -13,54 +17,29 @@ export const DEFAULT_CONFIG = {
|
|
|
13
17
|
allow: [
|
|
14
18
|
'pwd',
|
|
15
19
|
'ls',
|
|
16
|
-
'cat',
|
|
17
|
-
'head',
|
|
18
|
-
'tail',
|
|
19
20
|
'stat',
|
|
20
21
|
'wc',
|
|
21
|
-
'
|
|
22
|
-
'
|
|
23
|
-
'
|
|
24
|
-
'sed',
|
|
25
|
-
'awk',
|
|
22
|
+
'head',
|
|
23
|
+
'tail',
|
|
24
|
+
'cat',
|
|
26
25
|
'cut',
|
|
27
26
|
'sort',
|
|
28
27
|
'uniq',
|
|
29
|
-
'
|
|
30
|
-
'node',
|
|
31
|
-
'npm',
|
|
32
|
-
'pnpm',
|
|
33
|
-
'yarn'
|
|
28
|
+
'grep'
|
|
34
29
|
],
|
|
35
|
-
deny: [
|
|
36
|
-
'sudo',
|
|
37
|
-
'chmod',
|
|
38
|
-
'chown',
|
|
39
|
-
'mount',
|
|
40
|
-
'umount',
|
|
41
|
-
'shutdown',
|
|
42
|
-
'reboot',
|
|
43
|
-
'dd',
|
|
44
|
-
'mkfs*',
|
|
45
|
-
'service',
|
|
46
|
-
'systemctl',
|
|
47
|
-
'iptables',
|
|
48
|
-
'firewall*',
|
|
49
|
-
'curl *',
|
|
50
|
-
'wget *'
|
|
51
|
-
]
|
|
52
30
|
},
|
|
53
31
|
execution: {
|
|
54
32
|
timeoutMs: 10_000,
|
|
55
33
|
maxStdoutBytes: 1_000_000,
|
|
56
34
|
maxStderrBytes: 250_000,
|
|
57
|
-
|
|
35
|
+
pathDirs: DEFAULT_PATH_DIRS,
|
|
58
36
|
},
|
|
37
|
+
allowPipe: false,
|
|
38
|
+
allowSequence: false,
|
|
59
39
|
sessions: { enabled: true, ttlMs: 120_000, maxPerAgent: 4 }
|
|
60
40
|
};
|
|
61
41
|
// Reusable exports for consumers who want to surface or extend policy
|
|
62
42
|
export const TERMINAL_COMMANDS_ALLOW = Object.freeze([...DEFAULT_CONFIG.commands.allow]);
|
|
63
|
-
export const TERMINAL_COMMANDS_DENY = Object.freeze([...DEFAULT_CONFIG.commands.deny]);
|
|
64
43
|
export function defaultTerminalConfig(overrides) {
|
|
65
44
|
return {
|
|
66
45
|
...DEFAULT_CONFIG,
|
|
@@ -68,53 +47,255 @@ export function defaultTerminalConfig(overrides) {
|
|
|
68
47
|
capabilities: { ...DEFAULT_CONFIG.capabilities, ...(overrides?.capabilities ?? {}) },
|
|
69
48
|
commands: {
|
|
70
49
|
allow: overrides?.commands?.allow ?? DEFAULT_CONFIG.commands.allow,
|
|
71
|
-
deny: overrides?.commands?.deny ?? DEFAULT_CONFIG.commands.deny,
|
|
72
50
|
},
|
|
73
51
|
execution: { ...DEFAULT_CONFIG.execution, ...(overrides?.execution ?? {}) },
|
|
52
|
+
allowPipe: overrides?.allowPipe ?? DEFAULT_CONFIG.allowPipe,
|
|
53
|
+
allowSequence: overrides?.allowSequence ?? DEFAULT_CONFIG.allowSequence,
|
|
74
54
|
sessions: { ...DEFAULT_CONFIG.sessions, ...(overrides?.sessions ?? {}) },
|
|
75
55
|
};
|
|
76
56
|
}
|
|
77
|
-
function isCommandAllowed(
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
57
|
+
function isCommandAllowed(verb, policy) {
|
|
58
|
+
const opts = { nocase: true };
|
|
59
|
+
return policy.allow.some(p => minimatch(verb, p, opts));
|
|
60
|
+
}
|
|
61
|
+
function canonicalize(p) {
|
|
62
|
+
try {
|
|
63
|
+
return realpathSync(p);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
const dir = realpathSync(path.dirname(p));
|
|
67
|
+
return path.join(dir, path.basename(p));
|
|
68
|
+
}
|
|
86
69
|
}
|
|
87
70
|
function isPathAllowed(absPath, cfg, mode) {
|
|
88
|
-
const real =
|
|
89
|
-
const roots = cfg.roots.map(r =>
|
|
71
|
+
const real = canonicalize(absPath);
|
|
72
|
+
const roots = cfg.roots.map(r => canonicalize(r));
|
|
90
73
|
const inside = roots.some(r => real === r || real.startsWith(r + path.sep));
|
|
91
74
|
if (!inside)
|
|
92
75
|
return false;
|
|
93
76
|
if (mode !== 'read' && cfg.readOnlyRoots) {
|
|
94
|
-
const ro = cfg.readOnlyRoots.map(r =>
|
|
77
|
+
const ro = cfg.readOnlyRoots.map(r => canonicalize(r));
|
|
95
78
|
const inRo = ro.some(r => real === r || real.startsWith(r + path.sep));
|
|
96
79
|
if (inRo)
|
|
97
80
|
return false;
|
|
98
81
|
}
|
|
99
82
|
return true;
|
|
100
83
|
}
|
|
101
|
-
function
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
84
|
+
function looksLikePath(arg) {
|
|
85
|
+
return arg.startsWith('.') || arg.includes('/') || /^(?:[A-Za-z]:[\\/]|\\\\)/.test(arg);
|
|
86
|
+
}
|
|
87
|
+
function parseArgs(cmd) {
|
|
88
|
+
const out = [];
|
|
89
|
+
let current = '';
|
|
90
|
+
let single = false;
|
|
91
|
+
let double = false;
|
|
92
|
+
for (let i = 0; i < cmd.length; i++) {
|
|
93
|
+
const ch = cmd[i];
|
|
94
|
+
if (ch === "'" && !double) {
|
|
95
|
+
single = !single;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (ch === '"' && !single) {
|
|
99
|
+
double = !double;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (!single && !double && /\s/.test(ch)) {
|
|
103
|
+
if (current) {
|
|
104
|
+
out.push(current);
|
|
105
|
+
current = '';
|
|
106
|
+
}
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
current += ch;
|
|
110
|
+
}
|
|
111
|
+
if (single || double)
|
|
112
|
+
throw new Error('unbalanced quotes');
|
|
113
|
+
if (current)
|
|
114
|
+
out.push(current);
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
function splitPipeline(cmd) {
|
|
118
|
+
// Split on '|' outside quotes
|
|
119
|
+
const out = [];
|
|
120
|
+
let current = '';
|
|
121
|
+
let single = false;
|
|
122
|
+
let double = false;
|
|
123
|
+
for (let i = 0; i < cmd.length; i++) {
|
|
124
|
+
const ch = cmd[i];
|
|
125
|
+
if (ch === "'" && !double) {
|
|
126
|
+
single = !single;
|
|
127
|
+
current += ch;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (ch === '"' && !single) {
|
|
131
|
+
double = !double;
|
|
132
|
+
current += ch;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (ch === '|' && !single && !double) {
|
|
136
|
+
const seg = current.trim();
|
|
137
|
+
if (seg)
|
|
138
|
+
out.push(seg);
|
|
139
|
+
current = '';
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
current += ch;
|
|
143
|
+
}
|
|
144
|
+
const seg = current.trim();
|
|
145
|
+
if (seg)
|
|
146
|
+
out.push(seg);
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
function splitSequence(cmd) {
|
|
150
|
+
const out = [];
|
|
151
|
+
let current = '';
|
|
152
|
+
let single = false;
|
|
153
|
+
let double = false;
|
|
154
|
+
let nextOp = null;
|
|
155
|
+
for (let i = 0; i < cmd.length; i++) {
|
|
156
|
+
const ch = cmd[i];
|
|
157
|
+
const nxt = cmd[i + 1];
|
|
158
|
+
if (ch === "'" && !double) {
|
|
159
|
+
single = !single;
|
|
160
|
+
current += ch;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (ch === '"' && !single) {
|
|
164
|
+
double = !double;
|
|
165
|
+
current += ch;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (!single && !double) {
|
|
169
|
+
if (ch === ';') {
|
|
170
|
+
const seg = current.trim();
|
|
171
|
+
if (seg)
|
|
172
|
+
out.push({ cmd: seg, op: nextOp });
|
|
173
|
+
current = '';
|
|
174
|
+
nextOp = ';';
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (ch === '&' && nxt === '&') {
|
|
178
|
+
const seg = current.trim();
|
|
179
|
+
if (seg)
|
|
180
|
+
out.push({ cmd: seg, op: nextOp });
|
|
181
|
+
current = '';
|
|
182
|
+
nextOp = '&&';
|
|
183
|
+
i++;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (ch === '|' && nxt === '|') {
|
|
187
|
+
const seg = current.trim();
|
|
188
|
+
if (seg)
|
|
189
|
+
out.push({ cmd: seg, op: nextOp });
|
|
190
|
+
current = '';
|
|
191
|
+
nextOp = '||';
|
|
192
|
+
i++;
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
current += ch;
|
|
197
|
+
}
|
|
198
|
+
const tail = current.trim();
|
|
199
|
+
if (tail)
|
|
200
|
+
out.push({ cmd: tail, op: nextOp });
|
|
201
|
+
return out;
|
|
105
202
|
}
|
|
106
203
|
function commandPolicyCheck(args, cfg) {
|
|
107
204
|
if (!cfg.capabilities.exec)
|
|
108
205
|
return { allowed: false, reason: 'exec disabled' };
|
|
109
206
|
if (!isPathAllowed(args.cwd, cfg, 'exec'))
|
|
110
207
|
return { allowed: false, reason: 'cwd outside roots' };
|
|
111
|
-
|
|
208
|
+
let parsed;
|
|
209
|
+
try {
|
|
210
|
+
parsed = parseArgs(args.command);
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
return { allowed: false, reason: 'invalid quoting' };
|
|
214
|
+
}
|
|
215
|
+
if (parsed.length === 0)
|
|
216
|
+
return { allowed: false, reason: 'empty command' };
|
|
217
|
+
// Detect shell/control operators; allow only configured ones
|
|
218
|
+
const found = [];
|
|
219
|
+
const cmdStr = args.command;
|
|
220
|
+
if (/&&/.test(cmdStr))
|
|
221
|
+
found.push('&&');
|
|
222
|
+
const hasOrOr = /\|\|/.test(cmdStr);
|
|
223
|
+
if (hasOrOr)
|
|
224
|
+
found.push('||');
|
|
225
|
+
// Consider single '|' only after removing '||'
|
|
226
|
+
if (/\|/.test(cmdStr.replace(/\|\|/g, '')))
|
|
227
|
+
found.push('|');
|
|
228
|
+
if (/;/.test(cmdStr))
|
|
229
|
+
found.push(';');
|
|
230
|
+
if (/\$\(/.test(cmdStr))
|
|
231
|
+
found.push('$(...)');
|
|
232
|
+
if (/`/.test(cmdStr))
|
|
233
|
+
found.push('`...`');
|
|
234
|
+
if (/>/.test(cmdStr))
|
|
235
|
+
found.push('>');
|
|
236
|
+
if (/<\<?/.test(cmdStr))
|
|
237
|
+
found.push('<');
|
|
238
|
+
if (/(^|\s)&(\s|$)/.test(cmdStr))
|
|
239
|
+
found.push('&');
|
|
240
|
+
const allowPipe = cfg.allowPipe ?? false;
|
|
241
|
+
const allowSequence = cfg.allowSequence ?? false;
|
|
242
|
+
const unallowed = found.filter(op => {
|
|
243
|
+
if (op === '|' && allowPipe)
|
|
244
|
+
return false;
|
|
245
|
+
if ((op === '&&' || op === '||' || op === ';') && allowSequence)
|
|
246
|
+
return false;
|
|
247
|
+
return true;
|
|
248
|
+
});
|
|
249
|
+
if (unallowed.length > 0) {
|
|
250
|
+
const unique = Array.from(new Set(unallowed)).join(', ');
|
|
251
|
+
return { allowed: false, reason: `shell operators not allowed (${unique}). Enable allowPipe and/or allowSequence in config to opt in.` };
|
|
252
|
+
}
|
|
253
|
+
const [verb, ...rest] = parsed;
|
|
254
|
+
if (!isCommandAllowed(verb, cfg.commands))
|
|
112
255
|
return { allowed: false, reason: 'command denied' };
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
256
|
+
for (const a of rest) {
|
|
257
|
+
if (looksLikePath(a)) {
|
|
258
|
+
const abs = path.isAbsolute(a) || /^(?:[A-Za-z]:\\|\\)/.test(a) ? a : path.join(args.cwd, a);
|
|
259
|
+
if (!isPathAllowed(abs, cfg, 'read')) {
|
|
260
|
+
return { allowed: false, reason: `path outside roots: ${a}` };
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
// If a pipeline is present and allowed, validate each segment
|
|
265
|
+
if (allowPipe && /\|/.test(args.command)) {
|
|
266
|
+
const segments = splitPipeline(args.command);
|
|
267
|
+
if (segments.length < 2)
|
|
268
|
+
return { allowed: false, reason: 'invalid pipeline' };
|
|
269
|
+
for (const seg of segments) {
|
|
270
|
+
let segArgs;
|
|
271
|
+
try {
|
|
272
|
+
segArgs = parseArgs(seg);
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
return { allowed: false, reason: 'invalid quoting in pipeline segment' };
|
|
276
|
+
}
|
|
277
|
+
if (segArgs.length === 0)
|
|
278
|
+
return { allowed: false, reason: 'empty pipeline segment' };
|
|
279
|
+
const [v, ...r] = segArgs;
|
|
280
|
+
if (!isCommandAllowed(v, cfg.commands))
|
|
281
|
+
return { allowed: false, reason: `command denied in pipeline: ${v}` };
|
|
282
|
+
for (const a of r) {
|
|
283
|
+
if (looksLikePath(a)) {
|
|
284
|
+
const abs = path.isAbsolute(a) || /^(?:[A-Za-z]:\\|\\)/.test(a) ? a : path.join(args.cwd, a);
|
|
285
|
+
if (!isPathAllowed(abs, cfg, 'read'))
|
|
286
|
+
return { allowed: false, reason: `path outside roots in pipeline: ${a}` };
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (allowSequence && /(?:&&|\|\||;)/.test(args.command)) {
|
|
292
|
+
const seq = splitSequence(args.command);
|
|
293
|
+
if (seq.length === 0)
|
|
294
|
+
return { allowed: false, reason: 'invalid sequence' };
|
|
295
|
+
for (const part of seq) {
|
|
296
|
+
const res = commandPolicyCheck({ command: part.cmd, cwd: args.cwd }, cfg);
|
|
297
|
+
if (!res.allowed)
|
|
298
|
+
return res;
|
|
118
299
|
}
|
|
119
300
|
}
|
|
120
301
|
return { allowed: true };
|
|
@@ -134,8 +315,26 @@ export function createTerminalTool(config) {
|
|
|
134
315
|
}
|
|
135
316
|
return s;
|
|
136
317
|
}
|
|
318
|
+
function buildEnv(extra) {
|
|
319
|
+
const allowed = new Set(['PATH', 'HOME', 'LANG', 'TERM']);
|
|
320
|
+
const env = {};
|
|
321
|
+
for (const key of allowed) {
|
|
322
|
+
const v = process.env[key];
|
|
323
|
+
if (v !== undefined)
|
|
324
|
+
env[key] = v;
|
|
325
|
+
}
|
|
326
|
+
for (const [k, v] of Object.entries(extra)) {
|
|
327
|
+
if (allowed.has(k))
|
|
328
|
+
env[k] = v;
|
|
329
|
+
}
|
|
330
|
+
// Enforce a controlled PATH from config (ignores provided PATH to avoid hijack)
|
|
331
|
+
env.PATH = cfg.execution.pathDirs.join(':');
|
|
332
|
+
return env;
|
|
333
|
+
}
|
|
137
334
|
function start_session(args) {
|
|
138
|
-
|
|
335
|
+
if (!cfg.sessions.enabled)
|
|
336
|
+
throw new Error('sessions disabled');
|
|
337
|
+
const cwd = canonicalize(args?.cwd ? path.resolve(args.cwd) : cfg.roots[0]);
|
|
139
338
|
if (!isPathAllowed(cwd, cfg, 'exec')) {
|
|
140
339
|
throw new Error('cwd outside allowed roots');
|
|
141
340
|
}
|
|
@@ -146,54 +345,149 @@ export function createTerminalTool(config) {
|
|
|
146
345
|
}
|
|
147
346
|
async function run_command(args) {
|
|
148
347
|
const session = getSession(args.sessionId);
|
|
149
|
-
const cwd = path.resolve(args.cwd ?? session?.cwd ?? cfg.roots[0]);
|
|
150
|
-
const
|
|
151
|
-
const pre = commandPolicyCheck({ command: commandStr, cwd }, cfg);
|
|
348
|
+
const cwd = canonicalize(path.resolve(args.cwd ?? session?.cwd ?? cfg.roots[0]));
|
|
349
|
+
const pre = commandPolicyCheck({ command: args.command, cwd }, cfg);
|
|
152
350
|
if (!pre.allowed) {
|
|
153
351
|
return { exitCode: -1, stdout: '', stderr: '', durationMs: 0, policy: pre, cwd };
|
|
154
352
|
}
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
let
|
|
165
|
-
let
|
|
166
|
-
|
|
167
|
-
|
|
353
|
+
const pipelinesAllowed = cfg.allowPipe ?? false;
|
|
354
|
+
const sequencesAllowed = cfg.allowSequence ?? false;
|
|
355
|
+
const hasPipe = pipelinesAllowed && /\|/.test(args.command);
|
|
356
|
+
const hasSeq = sequencesAllowed && /(?:&&|\|\||;)/.test(args.command);
|
|
357
|
+
// Execute sequences (if enabled) without a shell by running segments serially
|
|
358
|
+
if (hasSeq) {
|
|
359
|
+
const seq = splitSequence(args.command);
|
|
360
|
+
let lastExit = 0;
|
|
361
|
+
let out = '';
|
|
362
|
+
let err = '';
|
|
363
|
+
let durTotal = 0;
|
|
364
|
+
for (let i = 0; i < seq.length; i++) {
|
|
365
|
+
const { cmd: subCmd, op } = seq[i];
|
|
366
|
+
const shouldRun = i === 0 ? true : (op === ';' ? true : (op === '&&' ? lastExit === 0 : lastExit !== 0));
|
|
367
|
+
if (!shouldRun)
|
|
368
|
+
continue;
|
|
369
|
+
const res = await run_command({ ...args, command: subCmd, cwd });
|
|
370
|
+
out += res.stdout || '';
|
|
371
|
+
err += res.stderr || '';
|
|
372
|
+
durTotal += res.durationMs || 0;
|
|
373
|
+
lastExit = res.exitCode;
|
|
168
374
|
}
|
|
169
|
-
if (
|
|
170
|
-
stderr = stderr.slice(-cfg.execution.maxStderrBytes);
|
|
171
|
-
}
|
|
172
|
-
if (session)
|
|
375
|
+
if (session) {
|
|
173
376
|
session.cwd = cwd;
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
const dur = Date.now() - start;
|
|
178
|
-
const stdout = String(err.stdout ?? '');
|
|
179
|
-
const stderr = String(err.stderr ?? err.message ?? '');
|
|
180
|
-
const code = typeof err.code === 'number' ? err.code : -1;
|
|
181
|
-
return { exitCode: code, stdout, stderr, durationMs: dur, policy: { allowed: true }, cwd };
|
|
377
|
+
session.expiresAt = Date.now() + cfg.sessions.ttlMs;
|
|
378
|
+
}
|
|
379
|
+
return { exitCode: lastExit, stdout: out, stderr: err, durationMs: durTotal, policy: { allowed: true }, cwd };
|
|
182
380
|
}
|
|
381
|
+
const argv = parseArgs(args.command);
|
|
382
|
+
const [cmd, ...cmdArgs] = argv;
|
|
383
|
+
const env = buildEnv({ ...(session?.env ?? {}), ...(args.env ?? {}) });
|
|
384
|
+
const start = Date.now();
|
|
385
|
+
return await new Promise((resolve) => {
|
|
386
|
+
let stdout = '', stderr = '';
|
|
387
|
+
let outBytes = 0, errBytes = 0;
|
|
388
|
+
const children = [];
|
|
389
|
+
const killAll = () => { for (const c of children) {
|
|
390
|
+
try {
|
|
391
|
+
c.kill('SIGKILL');
|
|
392
|
+
}
|
|
393
|
+
catch { }
|
|
394
|
+
} };
|
|
395
|
+
const onStdout = (d) => { outBytes += d.length; if (outBytes <= cfg.execution.maxStdoutBytes)
|
|
396
|
+
stdout += d.toString();
|
|
397
|
+
else
|
|
398
|
+
killAll(); };
|
|
399
|
+
const onStderr = (d) => { errBytes += d.length; if (errBytes <= cfg.execution.maxStderrBytes)
|
|
400
|
+
stderr += d.toString();
|
|
401
|
+
else
|
|
402
|
+
killAll(); };
|
|
403
|
+
const timeout = setTimeout(() => killAll(), cfg.execution.timeoutMs);
|
|
404
|
+
if (hasPipe) {
|
|
405
|
+
const segments = splitPipeline(args.command);
|
|
406
|
+
const argvList = segments.map(seg => parseArgs(seg));
|
|
407
|
+
let prev;
|
|
408
|
+
let finished = false;
|
|
409
|
+
const finish = (exitCode, errMsg) => {
|
|
410
|
+
if (finished)
|
|
411
|
+
return;
|
|
412
|
+
finished = true;
|
|
413
|
+
clearTimeout(timeout);
|
|
414
|
+
const dur = Date.now() - start;
|
|
415
|
+
if (session) {
|
|
416
|
+
session.cwd = cwd;
|
|
417
|
+
session.expiresAt = Date.now() + cfg.sessions.ttlMs;
|
|
418
|
+
}
|
|
419
|
+
if (errMsg) {
|
|
420
|
+
stderr += (stderr ? "\n" : "") + errMsg;
|
|
421
|
+
}
|
|
422
|
+
resolve({ exitCode, stdout, stderr, durationMs: dur, policy: { allowed: true }, cwd });
|
|
423
|
+
};
|
|
424
|
+
for (let i = 0; i < argvList.length; i++) {
|
|
425
|
+
const [pcmd, ...pargs] = argvList[i];
|
|
426
|
+
const proc = spawn(pcmd, pargs, { cwd, env, shell: false });
|
|
427
|
+
children.push(proc);
|
|
428
|
+
proc.on('error', (err) => {
|
|
429
|
+
killAll();
|
|
430
|
+
finish(-1, String(err?.message ?? err));
|
|
431
|
+
});
|
|
432
|
+
if (i === 0) {
|
|
433
|
+
if (args.stdin)
|
|
434
|
+
proc.stdin.write(args.stdin);
|
|
435
|
+
}
|
|
436
|
+
if (prev && prev.stdout) {
|
|
437
|
+
prev.stdout.pipe(proc.stdin);
|
|
438
|
+
}
|
|
439
|
+
if (i === argvList.length - 1 && proc.stdout) {
|
|
440
|
+
proc.stdout.on('data', onStdout);
|
|
441
|
+
}
|
|
442
|
+
if (proc.stderr)
|
|
443
|
+
proc.stderr.on('data', onStderr);
|
|
444
|
+
// Close stdin of previous once piped
|
|
445
|
+
if (prev && prev.stdin) {
|
|
446
|
+
prev.stdin.end();
|
|
447
|
+
}
|
|
448
|
+
prev = proc;
|
|
449
|
+
}
|
|
450
|
+
const last = children[children.length - 1];
|
|
451
|
+
last.on('close', (code) => finish(code ?? -1));
|
|
452
|
+
last.on('error', (err) => finish(-1, String(err?.message ?? err)));
|
|
453
|
+
}
|
|
454
|
+
else {
|
|
455
|
+
const child = spawn(cmd, cmdArgs, { cwd, env, shell: false });
|
|
456
|
+
children.push(child);
|
|
457
|
+
if (args.stdin)
|
|
458
|
+
child.stdin.write(args.stdin);
|
|
459
|
+
child.stdin.end();
|
|
460
|
+
child.stdout.on('data', onStdout);
|
|
461
|
+
child.stderr.on('data', onStderr);
|
|
462
|
+
child.on('close', (code) => {
|
|
463
|
+
clearTimeout(timeout);
|
|
464
|
+
const dur = Date.now() - start;
|
|
465
|
+
if (session) {
|
|
466
|
+
session.cwd = cwd;
|
|
467
|
+
session.expiresAt = Date.now() + cfg.sessions.ttlMs;
|
|
468
|
+
}
|
|
469
|
+
resolve({ exitCode: code ?? -1, stdout, stderr, durationMs: dur, policy: { allowed: true }, cwd });
|
|
470
|
+
});
|
|
471
|
+
child.on('error', (err) => {
|
|
472
|
+
clearTimeout(timeout);
|
|
473
|
+
const dur = Date.now() - start;
|
|
474
|
+
resolve({ exitCode: -1, stdout, stderr: String(err.message), durationMs: dur, policy: { allowed: true }, cwd });
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
});
|
|
183
478
|
}
|
|
184
479
|
function cd(args) {
|
|
185
480
|
let session = getSession(args.sessionId);
|
|
186
481
|
// If no valid session is provided, create one anchored at the first root
|
|
187
482
|
if (!session) {
|
|
188
|
-
const cwd = cfg.roots[0];
|
|
483
|
+
const cwd = canonicalize(cfg.roots[0]);
|
|
189
484
|
const sessionId = randomUUID();
|
|
190
485
|
const expiresAt = Date.now() + cfg.sessions.ttlMs;
|
|
191
486
|
session = { cwd, env: {}, expiresAt };
|
|
192
487
|
sessions.set(sessionId, session);
|
|
193
|
-
// attach generated id on args for return below
|
|
194
488
|
args._createdSessionId = sessionId;
|
|
195
489
|
}
|
|
196
|
-
const newPath = path.resolve(session.cwd, args.path);
|
|
490
|
+
const newPath = canonicalize(path.resolve(session.cwd, args.path));
|
|
197
491
|
if (!isPathAllowed(newPath, cfg, 'exec')) {
|
|
198
492
|
throw new Error('path outside allowed roots');
|
|
199
493
|
}
|
|
@@ -206,7 +500,7 @@ export function createTerminalTool(config) {
|
|
|
206
500
|
throw new Error('read disabled');
|
|
207
501
|
const session = getSession(args.sessionId);
|
|
208
502
|
const cwd = session?.cwd ?? cfg.roots[0];
|
|
209
|
-
const abs = path.resolve(cwd, args.path);
|
|
503
|
+
const abs = canonicalize(path.resolve(cwd, args.path));
|
|
210
504
|
if (!isPathAllowed(abs, cfg, 'read'))
|
|
211
505
|
throw new Error('path outside allowed roots');
|
|
212
506
|
const buf = await fs.readFile(abs);
|
|
@@ -217,9 +511,10 @@ export function createTerminalTool(config) {
|
|
|
217
511
|
const runCommandTool = {
|
|
218
512
|
name: 'terminalRun',
|
|
219
513
|
description: [
|
|
220
|
-
`Run a non-interactive
|
|
221
|
-
`Use for listing files (ls), printing files (cat), simple text processing etc. Allowed commands are ${cfg.commands.allow.join(', ')}
|
|
222
|
-
'
|
|
514
|
+
`Run a non-interactive command within allowed roots (${cfg.roots}).`,
|
|
515
|
+
`Use for listing files (ls), printing files (cat), simple text processing etc. Allowed commands are ${cfg.commands.allow.join(', ')}.`,
|
|
516
|
+
'Shell operators are rejected and the environment is sanitized before execution.',
|
|
517
|
+
'Always prefer passing a safe single command.',
|
|
223
518
|
'Tips: pass cwd to run in a specific folder; use terminalCd first to set a working directory for subsequent calls; prefer terminalReadFile when you only need file contents.'
|
|
224
519
|
].join(' '),
|
|
225
520
|
schema: z.object({
|