@sisu-ai/tool-terminal 1.1.0 → 1.2.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.
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  [![Downloads](https://img.shields.io/npm/dm/%40sisu-ai%2Ftool-terminal)](https://www.npmjs.com/package/@sisu-ai/tool-terminal)
6
6
  [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/finger-gun/sisu/blob/main/CONTRIBUTING.md)
7
7
 
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.
8
+ 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.
9
9
 
10
10
  ## API
11
11
 
@@ -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 allowlist array
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[]; deny: string[] };
77
- execution: { timeoutMs: number; maxStdoutBytes: number; maxStderrBytes: number; shell: 'direct'|'sh'|'bash'|'powershell'|'cmd' };
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/deny command policy. See `DEFAULT_CONFIG` in `src/index.ts` for full details.
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,9 +136,11 @@ 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 denied by default via patterns (e.g., `curl *`, `wget *`).
111
- - All paths are resolved and constrained to configured `roots`; write/delete under read-only roots are denied.
112
- - Absolute path arguments outside `roots` are denied (e.g., `grep -r /`). Prefer setting `cwd` (via `terminalCd`) and using relative paths.
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
115
146
  - [Code of Conduct](https://github.com/finger-gun/sisu/blob/main/CODE_OF_CONDUCT.md)
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
- shell: 'sh' | 'bash' | 'powershell' | 'cmd' | 'direct';
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 { exec as cpExec } from 'node:child_process';
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 exec = promisify(cpExec);
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
- 'grep',
22
- 'find',
23
- 'echo',
24
- 'sed',
25
- 'awk',
22
+ 'head',
23
+ 'tail',
24
+ 'cat',
26
25
  'cut',
27
26
  'sort',
28
27
  'uniq',
29
- 'xargs',
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
- shell: 'direct'
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(cmd, policy) {
78
- const normalized = cmd.trim().replace(/\s+/g, ' ');
79
- const verb = normalized.split(' ')[0] ?? '';
80
- const candidates = [normalized, verb];
81
- const opts = { nocase: true, matchBase: true };
82
- const denyHit = policy.deny.some(p => candidates.some(c => minimatch(c, p, opts)));
83
- if (denyHit)
84
- return false;
85
- return policy.allow.some(p => candidates.some(c => minimatch(c, p, opts)));
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 = path.resolve(absPath);
89
- const roots = cfg.roots.map(r => path.resolve(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 => path.resolve(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 extractAbsolutePaths(cmd) {
102
- // Very simple token scan: find whitespace-delimited tokens that start with '/'
103
- const matches = cmd.match(/(?:^|\s)(\/[\w\-./]+)(?=\s|$)/g) || [];
104
- return matches.map(m => m.trim());
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
- if (!isCommandAllowed(args.command, cfg.commands))
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
- // Guard against absolute path escapes: deny any absolute path outside roots
114
- const abs = extractAbsolutePaths(args.command);
115
- for (const p of abs) {
116
- if (!isPathAllowed(p, cfg, 'read')) {
117
- return { allowed: false, reason: `absolute path outside roots: ${p}` };
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
- const cwd = args?.cwd ? path.resolve(args.cwd) : cfg.roots[0];
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 commandStr = args.command;
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 start = Date.now();
156
- try {
157
- const { stdout: s, stderr: e } = await exec(commandStr, {
158
- cwd,
159
- env: { ...(session?.env ?? {}), ...(args.env ?? {}) },
160
- timeout: cfg.execution.timeoutMs,
161
- input: args.stdin
162
- });
163
- const dur = Date.now() - start;
164
- let stdout = s ?? '';
165
- let stderr = e ?? '';
166
- if (Buffer.byteLength(stdout) > cfg.execution.maxStdoutBytes) {
167
- stdout = stdout.slice(-cfg.execution.maxStdoutBytes);
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 (Buffer.byteLength(stderr) > cfg.execution.maxStderrBytes) {
170
- stderr = stderr.slice(-cfg.execution.maxStderrBytes);
171
- }
172
- if (session)
375
+ if (session) {
173
376
  session.cwd = cwd;
174
- return { exitCode: 0, stdout, stderr, durationMs: dur, policy: { allowed: true }, cwd };
175
- }
176
- catch (err) {
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, sandboxed terminal command within allowed roots (${cfg.roots}).`,
221
- `Use for listing files (ls), printing files (cat), simple text processing etc. Allowed commands are ${cfg.commands.allow.join(', ')}).`,
222
- 'Always prefer passing a safe single command. Network and destructive commands are denied by policy.',
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({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sisu-ai/tool-terminal",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",