@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/CHANGELOG.md +100 -70
- package/LICENSE +21 -21
- package/README.md +102 -102
- package/agents.ts +881 -878
- package/index.ts +19 -19
- package/package.json +63 -63
- package/prompts/delegate.md +6 -6
- package/prompts/explore.md +4 -4
- package/prompts/review.md +4 -4
- package/render.ts +87 -87
- package/sandbox-bash.ts +312 -148
- package/sandbox-python.ts +169 -0
- package/tui.ts +73 -73
- package/ui-bridge.ts +198 -198
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
|
|
7
|
-
* Read-only is enforced at the capability layer — see DESIGN.md.
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
"
|
|
43
|
-
"
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
+
});
|