yoyo-pi 0.1.4

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.
@@ -0,0 +1,332 @@
1
+ /**
2
+ * Sandbox extension for the child plan-agent process.
3
+ *
4
+ * Enforces:
5
+ * - read/search/list/bash path access only inside the current repository
6
+ * - write/edit only inside .plan/
7
+ * - deletion only through plan_delete, also inside .plan/
8
+ * - web search through plan_web_search (no API key required)
9
+ */
10
+
11
+ import * as fs from "node:fs";
12
+ import * as fsp from "node:fs/promises";
13
+ import * as path from "node:path";
14
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
15
+ import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
16
+ import { Type } from "typebox";
17
+
18
+ const PLAN_DIR = ".plan";
19
+ const MAX_WEB_SEARCH_CHARS = 30_000;
20
+
21
+ function isInside(child: string, root: string): boolean {
22
+ const relative = path.relative(root, child);
23
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
24
+ }
25
+
26
+ function stripAtPrefix(value: string): string {
27
+ return value.startsWith("@") ? value.slice(1) : value;
28
+ }
29
+
30
+ function resolveInputPath(cwd: string, value: string | undefined): string {
31
+ const cleaned = stripAtPrefix((value || ".").trim());
32
+ return path.isAbsolute(cleaned) ? path.resolve(cleaned) : path.resolve(cwd, cleaned);
33
+ }
34
+
35
+ function realpathIfExists(value: string): string {
36
+ try {
37
+ return fs.realpathSync(value);
38
+ } catch {
39
+ return path.resolve(value);
40
+ }
41
+ }
42
+
43
+ function nearestExistingAncestor(value: string): string {
44
+ let current = path.resolve(value);
45
+ while (!fs.existsSync(current)) {
46
+ const parent = path.dirname(current);
47
+ if (parent === current) return current;
48
+ current = parent;
49
+ }
50
+ return current;
51
+ }
52
+
53
+ function isPathWithinPolicy(target: string, root: string): boolean {
54
+ const absoluteTarget = path.resolve(target);
55
+ const absoluteRoot = path.resolve(root);
56
+ if (!isInside(absoluteTarget, absoluteRoot)) return false;
57
+
58
+ // If the policy root does not exist yet (for example, .plan/ before the
59
+ // first plan is written), allow creation as long as the target and the
60
+ // would-be root share the same safe existing ancestor.
61
+ if (!fs.existsSync(absoluteRoot)) {
62
+ const rootAncestor = nearestExistingAncestor(absoluteRoot);
63
+ const targetAncestor = nearestExistingAncestor(absoluteTarget);
64
+ return isInside(realpathIfExists(targetAncestor), realpathIfExists(rootAncestor));
65
+ }
66
+
67
+ const realRoot = realpathIfExists(absoluteRoot);
68
+ const ancestor = nearestExistingAncestor(absoluteTarget);
69
+ const realAncestor = realpathIfExists(ancestor);
70
+ if (!isInside(realAncestor, realRoot)) return false;
71
+
72
+ if (fs.existsSync(absoluteTarget)) {
73
+ const realTarget = realpathIfExists(absoluteTarget);
74
+ if (!isInside(realTarget, realRoot)) return false;
75
+ }
76
+
77
+ return true;
78
+ }
79
+
80
+ function ensurePlanRootSafe(cwd: string): { ok: boolean; reason?: string } {
81
+ const repoRoot = path.resolve(cwd);
82
+ const planRoot = path.resolve(cwd, PLAN_DIR);
83
+ if (!fs.existsSync(planRoot)) return { ok: true };
84
+ const realRepo = realpathIfExists(repoRoot);
85
+ const realPlan = realpathIfExists(planRoot);
86
+ if (!isInside(realPlan, realRepo)) {
87
+ return { ok: false, reason: `${PLAN_DIR}/ resolves outside the current repo` };
88
+ }
89
+ return { ok: true };
90
+ }
91
+
92
+ function ensureRepoReadPath(cwd: string, rawPath: string | undefined, toolName: string): { ok: boolean; reason?: string } {
93
+ const target = resolveInputPath(cwd, rawPath);
94
+ const repoRoot = path.resolve(cwd);
95
+ if (!isPathWithinPolicy(target, repoRoot)) {
96
+ return { ok: false, reason: `${toolName} path must stay inside current repo: ${rawPath || "."}` };
97
+ }
98
+ return { ok: true };
99
+ }
100
+
101
+ function ensurePlanWritePath(cwd: string, rawPath: string | undefined, toolName: string): { ok: boolean; reason?: string } {
102
+ if (!rawPath?.trim()) return { ok: false, reason: `${toolName} requires a path under ${PLAN_DIR}/` };
103
+ const rootCheck = ensurePlanRootSafe(cwd);
104
+ if (!rootCheck.ok) return rootCheck;
105
+ const target = resolveInputPath(cwd, rawPath);
106
+ const planRoot = path.resolve(cwd, PLAN_DIR);
107
+ if (path.resolve(target) === planRoot) return { ok: false, reason: `Refusing to modify the ${PLAN_DIR}/ directory itself` };
108
+ if (!isPathWithinPolicy(target, planRoot)) {
109
+ return { ok: false, reason: `${toolName} path must stay inside ${PLAN_DIR}/: ${rawPath}` };
110
+ }
111
+ return { ok: true };
112
+ }
113
+
114
+ function shellTokens(command: string): string[] {
115
+ const tokens: string[] = [];
116
+ const regex = /"((?:\\.|[^"])*)"|'([^']*)'|(\S+)/g;
117
+ let match: RegExpExecArray | null;
118
+ while ((match = regex.exec(command))) tokens.push(match[1] ?? match[2] ?? match[3]);
119
+ return tokens;
120
+ }
121
+
122
+ function looksLikePath(token: string): boolean {
123
+ if (!token || token.startsWith("-")) return false;
124
+ if (/^(https?|ftp):\/\//i.test(token)) return false;
125
+ return token.startsWith("/") || token.startsWith("./") || token.startsWith("../") || token.includes("/");
126
+ }
127
+
128
+ function commandReferencesOutsideRepo(command: string, cwd: string): boolean {
129
+ const repoRoot = path.resolve(cwd);
130
+ for (const token of shellTokens(command)) {
131
+ if (!looksLikePath(token)) continue;
132
+ const cleaned = stripAtPrefix(token.replace(/[,:;]+$/g, ""));
133
+ const target = path.isAbsolute(cleaned) ? path.resolve(cleaned) : path.resolve(cwd, cleaned);
134
+ if (!isPathWithinPolicy(target, repoRoot)) return true;
135
+ }
136
+ return false;
137
+ }
138
+
139
+ const MUTATING_OR_DANGEROUS_BASH = [
140
+ /\brm\b/i,
141
+ /\brmdir\b/i,
142
+ /\bmv\b/i,
143
+ /\bcp\b/i,
144
+ /\bmkdir\b/i,
145
+ /\btouch\b/i,
146
+ /\bchmod\b/i,
147
+ /\bchown\b/i,
148
+ /\bchgrp\b/i,
149
+ /\bln\b/i,
150
+ /\btee\b/i,
151
+ /\btruncate\b/i,
152
+ /\bdd\b/i,
153
+ /\bshred\b/i,
154
+ /(^|[^<])>(?!>)/,
155
+ />>/,
156
+ /<<\s*\w+/, // heredoc
157
+ /\bnpm\s+(install|uninstall|update|ci|link|publish|pack)/i,
158
+ /\byarn\s+(add|remove|install|publish)/i,
159
+ /\bpnpm\s+(add|remove|install|publish)/i,
160
+ /\bbun\s+(add|remove|install)/i,
161
+ /\bpip\s+(install|uninstall)/i,
162
+ /\bapt(-get)?\s+(install|remove|purge|update|upgrade)/i,
163
+ /\bbrew\s+(install|uninstall|upgrade)/i,
164
+ /\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout|stash|cherry-pick|revert|tag|init|clone|apply|am|bisect)\b/i,
165
+ /\bsudo\b/i,
166
+ /\bsu\b/i,
167
+ /\bkill\b/i,
168
+ /\bpkill\b/i,
169
+ /\bkillall\b/i,
170
+ /\breboot\b/i,
171
+ /\bshutdown\b/i,
172
+ /\bsystemctl\s+(start|stop|restart|enable|disable)/i,
173
+ /\bservice\s+\S+\s+(start|stop|restart)/i,
174
+ /\b(vim?|nano|emacs|code|subl)\b/i,
175
+ ];
176
+
177
+ const READ_ONLY_SEGMENTS = [
178
+ /^\s*(cat|head|tail|less|more|grep|find|ls|pwd|echo|printf|wc|sort|uniq|diff|file|stat|du|df|tree|which|whereis|type|env|printenv|uname|whoami|id|date|ps|rg|fd|bat|eza)\b/i,
179
+ /^\s*git\s+(status|log|diff|show|branch|remote|config\s+--get|ls-files|grep)\b/i,
180
+ /^\s*npm\s+(list|ls|view|info|search|outdated|audit)\b/i,
181
+ /^\s*yarn\s+(list|info|why|audit)\b/i,
182
+ /^\s*pnpm\s+(list|view|info|why|audit)\b/i,
183
+ /^\s*bun\s+(--version|pm\s+ls)\b/i,
184
+ /^\s*node\s+--version\b/i,
185
+ /^\s*python3?\s+--version\b/i,
186
+ /^\s*jq\b/i,
187
+ /^\s*sed\s+-n\b/i,
188
+ /^\s*awk\b/i,
189
+ /^\s*curl\b/i,
190
+ /^\s*wget\b/i,
191
+ ];
192
+
193
+ function isReadOnlyNetworkCommand(segment: string): { ok: boolean; reason?: string } {
194
+ if (/^\s*curl\b/i.test(segment)) {
195
+ if (/\bfile:\/\//i.test(segment)) return { ok: false, reason: "curl file:// URLs are not allowed" };
196
+ if (/\s(-o|-O|-T|--output|--remote-name|--upload-file)\b/i.test(segment)) {
197
+ return { ok: false, reason: "curl output/upload flags are not allowed" };
198
+ }
199
+ if (/\s(-d|--data|--data-raw|--data-binary|-F|--form)\b/i.test(segment)) {
200
+ return { ok: false, reason: "curl request body flags are not allowed" };
201
+ }
202
+ if (/\s-X\s*(POST|PUT|PATCH|DELETE)\b/i.test(segment)) {
203
+ return { ok: false, reason: "mutating curl methods are not allowed" };
204
+ }
205
+ }
206
+
207
+ if (/^\s*wget\b/i.test(segment)) {
208
+ if (/\bfile:\/\//i.test(segment)) return { ok: false, reason: "wget file:// URLs are not allowed" };
209
+ if (!/(\s-O\s*-\b|\s--output-document=-\b)/i.test(segment)) {
210
+ return { ok: false, reason: "wget must write to stdout with -O -" };
211
+ }
212
+ if (/\s(-P|--directory-prefix|--mirror|-m|--recursive|-r)\b/i.test(segment)) {
213
+ return { ok: false, reason: "wget download-to-disk flags are not allowed" };
214
+ }
215
+ }
216
+
217
+ return { ok: true };
218
+ }
219
+
220
+ function isReadOnlyBash(command: string, cwd: string): { ok: boolean; reason?: string } {
221
+ if (!command.trim()) return { ok: false, reason: "empty command" };
222
+ if (MUTATING_OR_DANGEROUS_BASH.some((pattern) => pattern.test(command))) {
223
+ return { ok: false, reason: "mutating or dangerous command" };
224
+ }
225
+ if (/[`$]\(/.test(command)) return { ok: false, reason: "command substitution is not allowed" };
226
+ if (commandReferencesOutsideRepo(command, cwd)) return { ok: false, reason: "command references a path outside the repo" };
227
+
228
+ const segments = command
229
+ .split(/\s*(?:&&|\|\||;|\n|\|)\s*/g)
230
+ .map((segment) => segment.trim())
231
+ .filter(Boolean);
232
+
233
+ for (const segment of segments) {
234
+ if (!READ_ONLY_SEGMENTS.some((pattern) => pattern.test(segment))) {
235
+ return { ok: false, reason: `not an allowlisted read-only command: ${segment}` };
236
+ }
237
+ const networkCheck = isReadOnlyNetworkCommand(segment);
238
+ if (!networkCheck.ok) return networkCheck;
239
+ }
240
+
241
+ return { ok: true };
242
+ }
243
+
244
+ function truncateText(text: string, maxChars: number): string {
245
+ if (text.length <= maxChars) return text;
246
+ return `${text.slice(0, maxChars)}\n\n[Truncated ${text.length - maxChars} characters. Refine the query for narrower results.]`;
247
+ }
248
+
249
+ export default function planAgentSandbox(pi: ExtensionAPI): void {
250
+ pi.on("session_start", async (_event, ctx) => {
251
+ const rootCheck = ensurePlanRootSafe(ctx.cwd);
252
+ if (rootCheck.ok) await fsp.mkdir(path.resolve(ctx.cwd, PLAN_DIR), { recursive: true });
253
+ });
254
+
255
+ pi.registerTool({
256
+ name: "plan_web_search",
257
+ label: "Plan Web Search",
258
+ description: "Search the web for planning/research context. Returns text results; does not write files.",
259
+ promptSnippet: "Search the web for external documentation and research context.",
260
+ parameters: Type.Object({
261
+ query: Type.String({ description: "Search query" }),
262
+ }),
263
+ async execute(_toolCallId, params, signal) {
264
+ const url = `https://s.jina.ai/?q=${encodeURIComponent(params.query)}`;
265
+ const response = await fetch(url, {
266
+ signal,
267
+ headers: {
268
+ Accept: "text/plain, text/markdown;q=0.9, */*;q=0.8",
269
+ "User-Agent": "pi-plan-agent/1.0",
270
+ },
271
+ });
272
+ const text = await response.text();
273
+ if (!response.ok) throw new Error(`plan_web_search failed (${response.status}): ${truncateText(text, 2000)}`);
274
+ return {
275
+ content: [{ type: "text", text: truncateText(`Web search: ${params.query}\n\n${text}`, MAX_WEB_SEARCH_CHARS) }],
276
+ details: { query: params.query, url, status: response.status },
277
+ };
278
+ },
279
+ });
280
+
281
+ pi.registerTool({
282
+ name: "plan_delete",
283
+ label: "Plan Delete",
284
+ description: "Delete a file or directory under .plan/. Cannot delete outside .plan/ or the .plan directory itself.",
285
+ parameters: Type.Object({
286
+ path: Type.String({ description: "Path under .plan/ to delete" }),
287
+ recursive: Type.Optional(Type.Boolean({ description: "Delete directories recursively", default: false })),
288
+ }),
289
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
290
+ const check = ensurePlanWritePath(ctx.cwd, params.path, "plan_delete");
291
+ if (!check.ok) throw new Error(check.reason);
292
+ const target = resolveInputPath(ctx.cwd, params.path);
293
+ await withFileMutationQueue(target, async () => {
294
+ await fsp.rm(target, { recursive: params.recursive ?? false, force: true });
295
+ });
296
+ return {
297
+ content: [{ type: "text", text: `Deleted ${path.relative(ctx.cwd, target)}` }],
298
+ details: { path: path.relative(ctx.cwd, target), recursive: params.recursive ?? false },
299
+ };
300
+ },
301
+ });
302
+
303
+ pi.on("tool_call", async (event, ctx) => {
304
+ if (event.toolName === "read") {
305
+ const check = ensureRepoReadPath(ctx.cwd, (event.input as any).path, "read");
306
+ if (!check.ok) return { block: true, reason: check.reason };
307
+ }
308
+
309
+ if (event.toolName === "grep" || event.toolName === "find" || event.toolName === "ls") {
310
+ const check = ensureRepoReadPath(ctx.cwd, (event.input as any).path, event.toolName);
311
+ if (!check.ok) return { block: true, reason: check.reason };
312
+ }
313
+
314
+ if (event.toolName === "write" || event.toolName === "edit") {
315
+ const check = ensurePlanWritePath(ctx.cwd, (event.input as any).path, event.toolName);
316
+ if (!check.ok) return { block: true, reason: check.reason };
317
+ }
318
+
319
+ if (event.toolName === "bash") {
320
+ const command = String((event.input as any).command ?? "");
321
+ const check = isReadOnlyBash(command, ctx.cwd);
322
+ if (!check.ok) {
323
+ return {
324
+ block: true,
325
+ reason: `plan-agent bash blocked: ${check.reason}. Use read/grep/find/ls, plan_web_search, write/edit under .plan/, or plan_delete.`,
326
+ };
327
+ }
328
+ }
329
+
330
+ return undefined;
331
+ });
332
+ }