@jerryan/pi-subagent-tools 0.3.0 → 0.4.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/sandbox-bash.ts CHANGED
@@ -1,148 +1,312 @@
1
- /**
2
- * Sandboxed read-only bash for review/explore child sessions.
3
- *
4
- * One tool replaces the former read/grep/find/ls allowlist plus the git
5
- * tool's policy table: a just-bash interpreter over a composed filesystem
6
- * (see createSandboxFs), with just-git providing git inside the sandbox.
7
- * Read-only is enforced at the capability layer — see DESIGN.md.
8
- *
9
- * The `disabled` git list below is UX only (clean "not available" errors
10
- * for pure mutators); enforcement is the read-only filesystem. Dual-purpose
11
- * verbs (branch, tag, stash, config, remote, worktree) stay enabled so
12
- * their read modes work; their write modes fail at the filesystem.
13
- *
14
- * Each call runs in a freshly constructed interpreter: per-call
15
- * construction is the statelessness guarantee, and it avoids any
16
- * shared-state questions between parallel children.
17
- */
18
-
19
- import * as path from "node:path";
20
- import { Type } from "@sinclair/typebox";
21
- import { defineTool } from "@earendil-works/pi-coding-agent";
22
- import { Bash, InMemoryFs, MountableFs, OverlayFs, type ExecResult } from "just-bash";
23
- import { createGit, type GitCommandName } from "just-git";
24
-
25
- /** Pure-mutator git verbs, disabled for clean UX errors. FS enforces the rest. */
26
- const DISABLED_GIT: GitCommandName[] = [
27
- "init",
28
- "add",
29
- "commit",
30
- "checkout",
31
- "switch",
32
- "restore",
33
- "reset",
34
- "merge",
35
- "cherry-pick",
36
- "revert",
37
- "rebase",
38
- "mv",
39
- "rm",
40
- "clean",
41
- "bisect",
42
- "gc",
43
- "repack",
44
- ];
45
-
46
- /** Mount point of the project root inside the sandbox. */
47
- const MOUNT_POINT = "/repo";
48
-
49
- /**
50
- * Compose the sandbox filesystem: the project root mounted read-only at
51
- * /repo over a writable in-memory base. The base provides working /dev/null
52
- * (stderr silencing is a core shell idiom; on a real read-only mount the
53
- * device still works) and per-call in-memory scratch (/tmp, ...) that
54
- * evaporates with the interpreter. Note MountableFs strips the mount
55
- * prefix before delegating, so the inner OverlayFs mounts at "/".
56
- */
57
- function createSandboxFs(projectRoot: string): MountableFs {
58
- const fs = new MountableFs({ base: new InMemoryFs() });
59
- fs.mount(
60
- MOUNT_POINT,
61
- new OverlayFs({ root: projectRoot, mountPoint: "/", readOnly: true }),
62
- );
63
- return fs;
64
- }
65
-
66
- const MAX_OUTPUT_CHARS = 50_000;
67
- const MAX_OUTPUT_LINES = 2_000;
68
-
69
- /**
70
- * Filesystem-style error codes the sandbox can raise. just-bash reports
71
- * command-level failures (touch, rm) as exit codes, but interpreter-level
72
- * failures — output redirections write through the interpreter's own FS
73
- * path — REJECT the exec promise with these. Both shapes mean the same
74
- * thing to the caller: the command failed. Anything outside this taxonomy
75
- * is a genuine interpreter bug and is rethrown, loudly.
76
- */
77
- const FS_ERROR_PATTERN =
78
- /^(EROFS|EACCES|EPERM|ENOENT|EFBIG|ENOSPC|EISDIR|ENOTDIR|ELOOP|ENOTEMPTY|EEXIST|EINVAL|EBUSY|EXDEV|EIO)\b/;
79
-
80
- function truncateOutput(output: string): string {
81
- const lines = output.split("\n");
82
- if (lines.length <= MAX_OUTPUT_LINES && output.length <= MAX_OUTPUT_CHARS) {
83
- return output;
84
- }
85
- const kept = lines.slice(0, MAX_OUTPUT_LINES).join("\n").slice(0, MAX_OUTPUT_CHARS);
86
- const keptLines = kept.split("\n").length;
87
- return `${kept}\n\n[output truncated: showing ${keptLines} lines, ${kept.length} chars]`;
88
- }
89
-
90
- export const sandboxBashTool = defineTool({
91
- name: "bash",
92
- label: "bash (read-only)",
93
- description:
94
- "Run a command in a sandboxed, read-only bash environment. " +
95
- "The working directory is the project root (mounted at /repo); use relative paths with forward slashes. " +
96
- "Standard utilities are available (grep, find, sed, awk, head, tail, sort, uniq, wc, cat, ls, ...) " +
97
- "plus git for repository inspection (log, diff, show, blame, grep, ls-files, branch/tag listing; " +
98
- "note: git status is slow in large repos — prefer the targeted commands). " +
99
- "The environment has no network access and the project directory is read-only by design: all writes to it fail. " +
100
- "Paths outside /repo (like /tmp) are in-memory scratch, discarded after each call. " +
101
- "Each call runs in a fresh shell — cd and environment variables do not persist between calls; " +
102
- "use compound commands (cd dir && command) for multi-step work.",
103
- promptSnippet: "Run a command in the read-only sandboxed shell",
104
- promptGuidelines: [
105
- "Use bash for searching, file inspection, and git history — never attempt to modify files; all writes fail by design.",
106
- ],
107
- parameters: Type.Object({
108
- command: Type.String({
109
- description:
110
- "The command line to execute (e.g. 'grep -rn \"pattern\" src/', 'git log --oneline -10', 'find . -name \"*.ts\" | head').",
111
- }),
112
- }),
113
- async execute(_toolCallId, params, signal, _onUpdate, ctx) {
114
- const bash = new Bash({
115
- fs: createSandboxFs(path.resolve(ctx.cwd)),
116
- cwd: MOUNT_POINT,
117
- customCommands: [createGit({ network: false, disabled: DISABLED_GIT })],
118
- });
119
- const result = await execSafely(bash, params.command, signal);
120
- const stdout = result.stdout;
121
- const stderr = result.stderr;
122
- const combined =
123
- stdout && stderr ? `${stdout}\n${stderr}` : stdout || stderr || "(no output)";
124
- const text =
125
- result.exitCode === 0
126
- ? truncateOutput(combined)
127
- : `Exit code ${result.exitCode}\n${truncateOutput(combined)}`;
128
- return {
129
- content: [{ type: "text" as const, text }],
130
- details: { exitCode: result.exitCode },
131
- };
132
- },
133
- });
134
-
135
- async function execSafely(
136
- bash: Bash,
137
- command: string,
138
- signal?: AbortSignal,
139
- ): Promise<ExecResult> {
140
- try {
141
- return await bash.exec(command, { signal });
142
- } catch (err: any) {
143
- if (FS_ERROR_PATTERN.test(err?.message ?? "")) {
144
- return { stdout: "", stderr: err.message, exitCode: 1 };
145
- }
146
- throw err;
147
- }
148
- }
1
+ /**
2
+ * Sandboxed read-only bash for review/explore child sessions.
3
+ *
4
+ * One tool replaces the former read/grep/find/ls allowlist plus the git
5
+ * tool's policy table: a just-bash interpreter over a composed filesystem
6
+ * (see computeTopology), with just-git providing git inside the sandbox.
7
+ * Read-only is enforced at the capability layer — see DESIGN.md.
8
+ *
9
+ * Mounts are REAL-LAYOUT: on posix, $HOME at its own path (plus the
10
+ * project root when the cwd is outside home — read-only agents may read
11
+ * other projects); on win32, every existing drive at MSYS form
12
+ * ("C:\" -> "/c"). Sandbox paths therefore match host paths one-to-one,
13
+ * so a path printed by bash works verbatim with the read tool and vice
14
+ * versa. Everything outside the mounts is per-call in-memory scratch
15
+ * (/dev/null, /tmp) that evaporates with the interpreter.
16
+ *
17
+ * The `disabled` git list below is UX only (clean "not available" errors
18
+ * for pure mutators); enforcement is the read-only filesystem. Dual-purpose
19
+ * verbs (branch, tag, stash, config, remote, worktree) stay enabled so
20
+ * their read modes work; their write modes fail at the filesystem.
21
+ *
22
+ * Each call runs in a freshly constructed interpreter: per-call
23
+ * construction is the statelessness guarantee, and it avoids any
24
+ * shared-state questions between parallel children.
25
+ */
26
+
27
+ import { existsSync, realpathSync } from "node:fs";
28
+ import * as os from "node:os";
29
+ import * as path from "node:path";
30
+ import { Type } from "@sinclair/typebox";
31
+ import { defineTool } from "@earendil-works/pi-coding-agent";
32
+ import { Bash, InMemoryFs, MountableFs, OverlayFs, type ExecResult } from "@jerryan/just-bash";
33
+ import { createGit, type GitCommandName } from "just-git";
34
+
35
+ /** Pure-mutator git verbs, disabled for clean UX errors. FS enforces the rest. */
36
+ const DISABLED_GIT: GitCommandName[] = [
37
+ "init",
38
+ "add",
39
+ "commit",
40
+ "checkout",
41
+ "switch",
42
+ "restore",
43
+ "reset",
44
+ "merge",
45
+ "cherry-pick",
46
+ "revert",
47
+ "rebase",
48
+ "mv",
49
+ "rm",
50
+ "clean",
51
+ "bisect",
52
+ "gc",
53
+ "repack",
54
+ ];
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Real-layout mount topology
58
+ // ---------------------------------------------------------------------------
59
+
60
+ type Platform = "win32" | "posix";
61
+ const PLATFORM: Platform = process.platform === "win32" ? "win32" : "posix";
62
+
63
+ function toSlashes(p: string): string {
64
+ return p.replace(/\\/g, "/");
65
+ }
66
+
67
+ /** Canonical on-disk spelling (symlinks, casing); falls back to the input. */
68
+ function canonicalize(p: string): string {
69
+ try {
70
+ const real = realpathSync.native(p);
71
+ // Strip Windows extended-length prefixes so paths stay comparable.
72
+ if (real.startsWith("\\\\?\\UNC\\")) return `\\${real.slice(8)}`;
73
+ if (real.startsWith("\\\\?\\")) return real.slice(4);
74
+ return real;
75
+ } catch {
76
+ return p;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Virtual mount point for a host root: posix roots map to themselves;
82
+ * win32 roots map to MSYS form ("C:\Users\jerry" -> "/c/Users/jerry").
83
+ * One path form is therefore understood by both the sandbox and pi's
84
+ * native tools (pi's native shell on Windows is an MSYS-family bash).
85
+ */
86
+ export function virtualMountPointFor(hostRoot: string, platform: Platform): string {
87
+ const normalized = toSlashes(hostRoot);
88
+ if (platform !== "win32") return normalized;
89
+ const drive = /^([A-Za-z]):\/(.*)$/.exec(normalized);
90
+ if (!drive) return normalized; // UNC
91
+ const rest = drive[2]!.replace(/\/+$/, "");
92
+ return `/${drive[1]!.toLowerCase()}${rest ? `/${rest}` : ""}`;
93
+ }
94
+
95
+ /** Boundary-aware prefix check; slash-normalized, case-insensitive on win32. */
96
+ function isWithin(root: string, child: string, platform: Platform): boolean {
97
+ const form = (p: string) => {
98
+ let out = toSlashes(p);
99
+ while (out.length > 1 && out.endsWith("/")) out = out.slice(0, -1);
100
+ return platform === "win32" ? out.toLowerCase() : out;
101
+ };
102
+ const r = form(root);
103
+ const c = form(child);
104
+ return c === r || c.startsWith(`${r}/`);
105
+ }
106
+
107
+ /**
108
+ * Resolve candidate host roots to a minimal, non-overlapping mount set
109
+ * (overlapping mounts are rejected by MountableFs). "home" and "cwd" are
110
+ * just candidates — neither is special. For each candidate, in order:
111
+ * (1) "/" is dropped (a read-only overlay at the fs root would shadow
112
+ * the scratch /dev/null and /tmp; MountableFs rejects it anyway),
113
+ * (2) a candidate within an already-kept root is dropped,
114
+ * (3) a candidate that CONTAINS kept roots replaces them.
115
+ */
116
+ export function resolveMountRoots(candidates: string[], platform: Platform = PLATFORM): string[] {
117
+ const roots: string[] = [];
118
+ for (const candidate of candidates) {
119
+ if (toSlashes(candidate) === "/") continue; // (1)
120
+ if (roots.some((root) => isWithin(root, candidate, platform))) continue; // (2)
121
+ for (let i = roots.length - 1; i >= 0; i--) {
122
+ if (isWithin(candidate, roots[i]!, platform)) roots.splice(i, 1); // (3)
123
+ }
124
+ roots.push(candidate);
125
+ }
126
+ return roots;
127
+ }
128
+
129
+ /** Existing drive roots ("C:\", "D:\", ...); A/B skipped (floppy probes hang). */
130
+ function probeWindowsDrives(): string[] {
131
+ const drives: string[] = [];
132
+ for (let code = 67; code <= 90; code++) {
133
+ const root = `${String.fromCharCode(code)}:\\`;
134
+ if (existsSync(root)) drives.push(root);
135
+ }
136
+ return drives;
137
+ }
138
+
139
+ // Probing drives touches every letter (a disconnected mapped drive can stall
140
+ // for seconds), so it happens once per process, not per tool call. Node has
141
+ // no drive-list API; the npm "list-drives" alternatives all spawn
142
+ // wmic/powershell, which is worse per call than this probe.
143
+ let cachedWindowsDrives: string[] | undefined;
144
+ function windowsDrives(): string[] {
145
+ return (cachedWindowsDrives ??= probeWindowsDrives());
146
+ }
147
+
148
+ export interface SandboxTopology {
149
+ mounts: { at: string; root: string }[];
150
+ virtualCwd: string;
151
+ virtualHome: string;
152
+ /** Host absolute path -> virtual vfs path, or null when under no mount. */
153
+ hostToVirtual(hostPath: string): string | null;
154
+ }
155
+
156
+ export function computeTopology(
157
+ cwdInput: string,
158
+ homeInput: string,
159
+ options?: { platform?: Platform; drives?: string[]; canonicalize?: (p: string) => string },
160
+ ): SandboxTopology {
161
+ const platform = options?.platform ?? PLATFORM;
162
+ const canon = options?.canonicalize ?? canonicalize;
163
+ const cwd = canon(cwdInput);
164
+ const home = canon(homeInput);
165
+ // win32: home is of course on one of the drives, so only cwd is worth
166
+ // adding — and only matters for a UNC working directory, which no drive
167
+ // letter covers.
168
+ const spelled =
169
+ platform === "win32" ? [...(options?.drives ?? windowsDrives()), cwdInput] : [homeInput, cwdInput];
170
+ // Mounts are string-matched, so a symlinked path must be mounted under
171
+ // BOTH its spelled and canonical forms (macOS /var -> /private/var
172
+ // firmlinks) — otherwise a path typed in the spelled form misses the
173
+ // mount while pi's read tool (host-resolved) sees the file fine.
174
+ const candidates = [...spelled, ...spelled.map(canon)];
175
+ const mounts = resolveMountRoots(candidates, platform).map((root) => ({
176
+ at: virtualMountPointFor(root, platform),
177
+ root,
178
+ }));
179
+ const hostToVirtual = (hostPath: string): string | null => {
180
+ const canonical = canon(hostPath);
181
+ let best: { at: string; root: string } | null = null;
182
+ for (const mount of mounts) {
183
+ if (isWithin(mount.root, canonical, platform) && (!best || mount.root.length > best.root.length)) {
184
+ best = mount;
185
+ }
186
+ }
187
+ if (!best) return null;
188
+ const rel = toSlashes(
189
+ (platform === "win32" ? path.win32 : path.posix).relative(best.root, canonical),
190
+ );
191
+ return rel ? path.posix.join(best.at, rel) : best.at;
192
+ };
193
+ return {
194
+ mounts,
195
+ virtualCwd: hostToVirtual(cwd) ?? "/",
196
+ virtualHome: hostToVirtual(home) ?? "/",
197
+ hostToVirtual,
198
+ };
199
+ }
200
+
201
+ /**
202
+ * Compose the sandbox filesystem: real-layout read-only overlays over a
203
+ * writable in-memory base. The base provides working /dev/null (stderr
204
+ * silencing is a core shell idiom; on a real read-only mount the device
205
+ * still works) and per-call in-memory scratch (/tmp, ...) that evaporates
206
+ * with the interpreter. Note MountableFs strips the mount prefix before
207
+ * delegating, so the inner OverlayFs mounts at "/".
208
+ *
209
+ * allowSymlinks: with the default (false) any real-FS path traversing a
210
+ * symlink is rejected — fine for a project-only mount, but home is full
211
+ * of intentional symlinks (stow/chezmoi dotfiles, ~/.config, pnpm
212
+ * node_modules). Read-only enforcement is unaffected (writes fail with
213
+ * EROFS either way) and there is no confidentiality boundary to protect:
214
+ * the read tool is unrestricted.
215
+ */
216
+ export function createSandboxFs(mounts: { at: string; root: string }[]): MountableFs {
217
+ const fs = new MountableFs({ base: new InMemoryFs() });
218
+ for (const mount of mounts) {
219
+ try {
220
+ fs.mount(
221
+ mount.at,
222
+ new OverlayFs({ root: mount.root, mountPoint: "/", readOnly: true, allowSymlinks: true }),
223
+ );
224
+ } catch {
225
+ // A cached drive that vanished since the probe (USB pulled, mapped
226
+ // drive dropped) must not brick every call for the process lifetime.
227
+ }
228
+ }
229
+ return fs;
230
+ }
231
+
232
+ const MAX_OUTPUT_CHARS = 50_000;
233
+ const MAX_OUTPUT_LINES = 2_000;
234
+
235
+ /**
236
+ * Filesystem-style error codes the sandbox can raise. just-bash reports
237
+ * command-level failures (touch, rm) as exit codes, but interpreter-level
238
+ * failures — output redirections write through the interpreter's own FS
239
+ * path — REJECT the exec promise with these. Both shapes mean the same
240
+ * thing to the caller: the command failed. Anything outside this taxonomy
241
+ * is a genuine interpreter bug and is rethrown, loudly.
242
+ */
243
+ const FS_ERROR_PATTERN =
244
+ /^(EROFS|EACCES|EPERM|ENOENT|EFBIG|ENOSPC|EISDIR|ENOTDIR|ELOOP|ENOTEMPTY|EEXIST|EINVAL|EBUSY|EXDEV|EIO|ENAMETOOLONG|EMFILE|ENFILE|EPIPE)\b/;
245
+
246
+ export function truncateOutput(output: string): string {
247
+ const lines = output.split("\n");
248
+ if (lines.length <= MAX_OUTPUT_LINES && output.length <= MAX_OUTPUT_CHARS) {
249
+ return output;
250
+ }
251
+ const kept = lines.slice(0, MAX_OUTPUT_LINES).join("\n").slice(0, MAX_OUTPUT_CHARS);
252
+ const keptLines = kept.split("\n").length;
253
+ return `${kept}\n\n[output truncated: showing ${keptLines} lines, ${kept.length} chars]`;
254
+ }
255
+
256
+ export const sandboxBashTool = defineTool({
257
+ name: "bash",
258
+ label: "bash (read-only)",
259
+ description:
260
+ "Execute a bash command in a sandboxed, read-only filesystem (standard utilities plus git; no network; writes to real paths fail by design; absolute host paths work verbatim). " +
261
+ "Returns stdout and stderr. Output is truncated to 2000 lines or 50KB (whichever is hit first). " +
262
+ "Each call runs in a fresh shell — cd and environment variables do not persist between calls.",
263
+ promptSnippet: "Run a command in the read-only sandboxed shell",
264
+ promptGuidelines: ["Use bash for searching, file inspection, and git history."],
265
+ parameters: Type.Object({
266
+ command: Type.String({
267
+ description:
268
+ "The command line to execute (e.g. 'grep -rn \"pattern\" src/', 'git log --oneline -10', 'find . -name \"*.ts\" | head').",
269
+ }),
270
+ }),
271
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
272
+ const topology = computeTopology(path.resolve(ctx.cwd), os.homedir());
273
+ const bash = new Bash({
274
+ fs: createSandboxFs(topology.mounts),
275
+ cwd: topology.virtualCwd,
276
+ env: { HOME: topology.virtualHome },
277
+ // Deliberately NO python here: a bare `python3` on PATH implies the
278
+ // native interpreter (project env, pip). Sandboxed stdlib-only CPython
279
+ // is a separate tool with that contract made explicit — see
280
+ // sandbox-python.ts.
281
+ customCommands: [createGit({ network: false, disabled: DISABLED_GIT })],
282
+ });
283
+ const result = await execSafely(bash, params.command, signal);
284
+ const stdout = result.stdout;
285
+ const stderr = result.stderr;
286
+ const combined =
287
+ stdout && stderr ? `${stdout}\n${stderr}` : stdout || stderr || "(no output)";
288
+ const text =
289
+ result.exitCode === 0
290
+ ? truncateOutput(combined)
291
+ : `Exit code ${result.exitCode}\n${truncateOutput(combined)}`;
292
+ return {
293
+ content: [{ type: "text" as const, text }],
294
+ details: { exitCode: result.exitCode },
295
+ };
296
+ },
297
+ });
298
+
299
+ export async function execSafely(
300
+ bash: Bash,
301
+ command: string,
302
+ signal?: AbortSignal,
303
+ ): Promise<ExecResult> {
304
+ try {
305
+ return await bash.exec(command, { signal });
306
+ } catch (err: any) {
307
+ if (FS_ERROR_PATTERN.test(err?.message ?? "")) {
308
+ return { stdout: "", stderr: err.message, exitCode: 1 };
309
+ }
310
+ throw err;
311
+ }
312
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Sandboxed "python" tool for review/explore child sessions: stdlib-only
3
+ * CPython (just-bash's WASM build) for dependency-free scripting and data
4
+ * processing, over the same read-only real-layout filesystem as the
5
+ * sandboxed bash (see sandbox-bash.ts).
6
+ *
7
+ * This is a SEPARATE tool on purpose, mirroring pi-overlayfs's design: a
8
+ * bare `python3` on PATH inside bash implies the native interpreter
9
+ * (project environment, pip, third-party packages) and the WASM CPython
10
+ * is none of that — exposing it as a shell command sells a capability
11
+ * that isn't there. The dedicated tool makes the stdlib-only contract
12
+ * explicit in its description. The bash sandbox therefore has no
13
+ * `python3`; scripts that need the real environment cannot run in a
14
+ * read-only child at all (fail-closed, same as everything else).
15
+ *
16
+ * Per call a fresh interpreter and filesystem are constructed — the same
17
+ * statelessness guarantee as the sandboxed bash. Inline code is staged to
18
+ * a /tmp scratch file (in-memory, gone with the call).
19
+ */
20
+
21
+ import * as os from "node:os";
22
+ import * as path from "node:path";
23
+ import { Type } from "@sinclair/typebox";
24
+ import { defineTool } from "@earendil-works/pi-coding-agent";
25
+ import { Bash, type ExecResult } from "@jerryan/just-bash";
26
+ import {
27
+ computeTopology,
28
+ createSandboxFs,
29
+ execSafely,
30
+ truncateOutput,
31
+ } from "./sandbox-bash.ts";
32
+
33
+ const DEFAULT_TIMEOUT_SECONDS = 300;
34
+
35
+ /** Scratch path inline code is staged at (per-call fs — no collision risk). */
36
+ const STAGED_SCRIPT = "/tmp/.pi-py-script.py";
37
+
38
+ const WINDOWS_ABSOLUTE = /^[a-zA-Z]:[\\/]/;
39
+ const UNC_PATH = /^[\\/]{2}/;
40
+
41
+ /** Single-quote escape for embedding an argument in a shell command line. */
42
+ function shellQuote(value: string): string {
43
+ return `'${value.replace(/'/g, `'\\''`)}'`;
44
+ }
45
+
46
+ /**
47
+ * Resolve a model-typed path to a virtual vfs path. Real-layout mounts
48
+ * make host and virtual forms coincide, so: relative paths join the
49
+ * project root; absolute paths map through the mounts when covered and
50
+ * pass through unchanged otherwise (scratch like /tmp, or simply
51
+ * invisible to the sandbox).
52
+ */
53
+ function toVirtual(
54
+ input: string,
55
+ virtualCwd: string,
56
+ hostToVirtual: (hostPath: string) => string | null,
57
+ ): string {
58
+ const trimmed = input.trim();
59
+ if (trimmed.startsWith("/") || WINDOWS_ABSOLUTE.test(trimmed) || UNC_PATH.test(trimmed)) {
60
+ return hostToVirtual(trimmed) ?? trimmed.replace(/\\/g, "/");
61
+ }
62
+ return path.posix.normalize(path.posix.join(virtualCwd, trimmed));
63
+ }
64
+
65
+ export const sandboxPythonTool = defineTool({
66
+ name: "python",
67
+ label: "python (stdlib, read-only)",
68
+ description:
69
+ "Run Python 3 (standard library only) for scripting and data processing, in the same read-only sandbox as bash. " +
70
+ "Provide exactly one of code (inline source) or path (a script). " +
71
+ "This is not the project's Python environment: third-party packages and pip are unavailable. " +
72
+ "Output is truncated to 2000 lines or 50KB (whichever is hit first).",
73
+ promptSnippet: "Run Python 3 scripts (standard library only)",
74
+ promptGuidelines: ["Use python for dependency-free scripting and data analysis."],
75
+ parameters: Type.Object({
76
+ code: Type.Optional(Type.String({ description: "Inline Python 3 source to execute" })),
77
+ path: Type.Optional(Type.String({ description: "Path to a Python script (absolute or relative to the project)" })),
78
+ args: Type.Optional(Type.Array(Type.String(), { description: "Arguments passed to the script" })),
79
+ timeout: Type.Optional(
80
+ Type.Number({ description: `Timeout in seconds (optional, defaults to ${DEFAULT_TIMEOUT_SECONDS})` }),
81
+ ),
82
+ }),
83
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
84
+ const hasCode = typeof params.code === "string" && params.code.length > 0;
85
+ const hasPath = typeof params.path === "string" && params.path.trim().length > 0;
86
+ if (hasCode === hasPath) {
87
+ throw new Error("python: exactly one of 'code' or 'path' is required");
88
+ }
89
+ const requested = params.timeout;
90
+ // Clamp to [1, 3600]: NaN/0/negative would misbehave in setTimeout.
91
+ const timeoutSeconds =
92
+ typeof requested === "number" && Number.isFinite(requested)
93
+ ? Math.min(Math.max(Math.floor(requested), 1), 3600)
94
+ : DEFAULT_TIMEOUT_SECONDS;
95
+
96
+ const topology = computeTopology(path.resolve(ctx.cwd), os.homedir());
97
+ const fs = createSandboxFs(topology.mounts);
98
+ const bash = new Bash({
99
+ fs,
100
+ cwd: topology.virtualCwd,
101
+ env: { HOME: topology.virtualHome },
102
+ python: true,
103
+ // The interpreter's own caps (maxPythonTimeoutMs defaults to 30s,
104
+ // maxExecutionTimeMs to 1h) must sit ABOVE this tool's timer so a
105
+ // timeout surfaces as this tool's `timeout:N` error (via the abort
106
+ // below) rather than a raw exit-124 the agent can't distinguish from
107
+ // a script failure.
108
+ executionLimits: {
109
+ maxPythonTimeoutMs: timeoutSeconds * 1000 + 10_000,
110
+ maxExecutionTimeMs: timeoutSeconds * 1000 + 15_000,
111
+ },
112
+ });
113
+
114
+ // The CPython worker expects /tmp to exist in the vfs.
115
+ await fs.mkdir("/tmp", { recursive: true });
116
+
117
+ let scriptPath: string;
118
+ if (hasCode) {
119
+ scriptPath = STAGED_SCRIPT;
120
+ await fs.writeFile(scriptPath, params.code as string, { encoding: "utf8" });
121
+ } else {
122
+ scriptPath = toVirtual(params.path as string, topology.virtualCwd, topology.hostToVirtual);
123
+ if (!(await fs.exists(scriptPath))) {
124
+ throw new Error(`python: script not found: ${params.path}`);
125
+ }
126
+ }
127
+
128
+ const args = (params.args ?? []).map(shellQuote);
129
+ const command = [`python3 ${shellQuote(scriptPath)}`, ...args].join(" ");
130
+
131
+ const controller = new AbortController();
132
+ const onAbort = () => controller.abort();
133
+ // A listener on an already-aborted signal never fires — check first.
134
+ if (signal?.aborted) controller.abort();
135
+ signal?.addEventListener("abort", onAbort, { once: true });
136
+ let timedOut = false;
137
+ const timer = setTimeout(() => {
138
+ timedOut = true;
139
+ controller.abort();
140
+ }, timeoutSeconds * 1000);
141
+
142
+ let result: ExecResult;
143
+ try {
144
+ result = await execSafely(bash, command, controller.signal);
145
+ } finally {
146
+ clearTimeout(timer);
147
+ signal?.removeEventListener("abort", onAbort);
148
+ }
149
+ if (timedOut) {
150
+ let detail = "";
151
+ if (result!.stdout) detail += `\n${truncateOutput(result!.stdout)}`;
152
+ if (result!.stderr) detail += `\n--- stderr ---\n${truncateOutput(result!.stderr)}`;
153
+ throw new Error(`timeout:${timeoutSeconds}${detail}`);
154
+ }
155
+
156
+ const stdout = result!.stdout;
157
+ const stderr = result!.stderr;
158
+ const combined =
159
+ stdout && stderr ? `${stdout}\n${stderr}` : stdout || stderr || "(no output)";
160
+ const text =
161
+ result!.exitCode === 0
162
+ ? truncateOutput(combined)
163
+ : `Exit code ${result!.exitCode}\n${truncateOutput(combined)}`;
164
+ return {
165
+ content: [{ type: "text" as const, text }],
166
+ details: { exitCode: result!.exitCode },
167
+ };
168
+ },
169
+ });