/** * PI subagent readonly scope guard. * * Deny-by-default: only read/grep/find/ls. Builtin schema uses a single `path` * field (read requires it; grep/find/ls default to "."). Reject unknown * path-carrier fields. Enforce snapshot realpath + selected-file allowlist; * directory targets must contain only allowlisted regular files (bounded walk). */ import fs from "node:fs"; import path from "node:path"; const ALLOWED_TOOLS = new Set(["read", "grep", "find", "ls"]); const UNKNOWN_PATH_KEYS = new Set([ "file", "filename", "directory", "dir", "root", "cwd", "paths", ]); const MAX_WALK_ENTRIES = 256; function snapshotRoot(): string | null { const raw = process.env.PI_SUBAGENT_SNAPSHOT_ROOT; if (!raw) return null; try { return fs.realpathSync(raw); } catch { return null; } } function allowedFiles(): Set | null { const raw = process.env.PI_SUBAGENT_ALLOWED_FILES; if (!raw) return null; try { const parsed = JSON.parse(raw); if (!Array.isArray(parsed)) return new Set(); return new Set(parsed.map((p: unknown) => String(p))); } catch { return new Set(); } } /** Reject PI resolveToCwd / home-expansion inputs before any join. */ function forbiddenPrefix(raw: string): string | null { const cleaned = raw .replace(/^\uFEFF/, "") .replace(/[\u200B-\u200D\u2060\uFEFF]/g, ""); const trimmed = cleaned.trim(); if (!trimmed) return "empty path"; if (trimmed.startsWith("@") || trimmed.startsWith("~")) { return "@ or ~ prefix refused"; } if (/^[\uFF20\u0040]/.test(trimmed) || /^[\uFF5E\u007E]/.test(trimmed)) { return "unicode @/~ lookalike refused"; } if (trimmed.includes("\0") || trimmed.includes("\n") || trimmed.includes("\r")) { return "illegal characters"; } return null; } function contained(root: string, candidate: string): { ok: boolean; resolved?: string; reason?: string } { const prefixErr = forbiddenPrefix(candidate); if (prefixErr) return { ok: false, reason: prefixErr }; const abs = path.isAbsolute(candidate) ? path.normalize(candidate) : path.resolve(root, candidate); let cur = abs; for (;;) { try { if (fs.lstatSync(cur).isSymbolicLink()) { return { ok: false, reason: "symlink segment refused" }; } } catch { /* missing leaf checked below */ } const parent = path.dirname(cur); if (parent === cur) break; cur = parent; } let resolved: string; try { if (!fs.existsSync(abs)) { return { ok: false, reason: "path does not exist" }; } const st = fs.lstatSync(abs); if (st.isSymbolicLink()) { return { ok: false, reason: "symlink target refused" }; } resolved = fs.realpathSync(abs); } catch { return { ok: false, reason: "realpath failed" }; } try { if (fs.realpathSync(resolved) !== resolved) { return { ok: false, reason: "realpath unstable" }; } } catch { return { ok: false, reason: "realpath recheck failed" }; } const normRoot = root.endsWith(path.sep) ? root : root + path.sep; if (!(resolved === root || resolved.startsWith(normRoot))) { return { ok: false, reason: "escapes snapshot root" }; } return { ok: true, resolved }; } function relUnder(root: string, resolved: string): string | null { const rel = path.relative(root, resolved); if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) return null; return rel; } function fileAllowlisted(root: string, resolved: string, allow: Set | null): boolean { if (!allow) return true; const rel = relUnder(root, resolved); return !!rel && allow.has(rel); } /** Directory listing/search: every regular file under path must be allowlisted. */ function directoryAllowOk(root: string, resolved: string, allow: Set | null): { ok: boolean; reason?: string } { if (!allow) return { ok: true }; let count = 0; const stack = [resolved]; while (stack.length) { const dir = stack.pop()!; let entries: fs.Dirent[]; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return { ok: false, reason: "directory unreadable" }; } for (const ent of entries) { count += 1; if (count > MAX_WALK_ENTRIES) { return { ok: false, reason: "directory walk exceeded bound" }; } const child = path.join(dir, ent.name); let st: fs.Stats; try { st = fs.lstatSync(child); } catch { return { ok: false, reason: "lstat failed" }; } if (st.isSymbolicLink()) { return { ok: false, reason: "symlink descendant refused" }; } if (st.isDirectory()) { stack.push(child); continue; } if (!st.isFile()) { return { ok: false, reason: "special file refused" }; } if (!fileAllowlisted(root, fs.realpathSync(child), allow)) { return { ok: false, reason: "directory contains non-allowlisted file" }; } } } // Exact snapshot root: path.relative(root,root) is "" so relUnder is null. // Inventory above already enforced allowlist; permit root listing/search // (including empty allowlisted snapshot). fileAllowlisted(root,root) stays false. if (resolved === root) { return { ok: true }; } // Empty dir is ok only if it is an ancestor of some allowlisted file const rel = relUnder(root, resolved); if (rel === null) return { ok: false, reason: "directory outside root" }; if (count === 0) { for (const f of allow) { if (f === rel || f.startsWith(rel + path.sep)) return { ok: true }; } return { ok: false, reason: "empty directory not covering allowlist" }; } return { ok: true }; } export default function piSubagentReadonly(pi: { on: (event: string, handler: (event: any, ctx: any) => any) => void; }) { pi.on("tool_call", async (event: { toolName: string; input?: Record }) => { const name = event.toolName; if (!ALLOWED_TOOLS.has(name)) { return { block: true, reason: `Tool "${name}" is denied by pi-subagent-readonly (allowlist: read,grep,find,ls)`, }; } const root = snapshotRoot(); if (!root) { return { block: true, reason: "PI_SUBAGENT_SNAPSHOT_ROOT is unset; refusing all file tools", }; } const allow = allowedFiles(); const input = event.input || {}; for (const key of Object.keys(input)) { if (UNKNOWN_PATH_KEYS.has(key)) { return { block: true, reason: `Unsupported path carrier field "${key}" (builtin tools use path only)`, }; } } // read requires path; grep/find/ls default to "." let rawPath: string; if (typeof input.path === "string" && input.path.trim()) { rawPath = input.path; } else if (name === "read") { return { block: true, reason: 'read requires input.path' }; } else { rawPath = "."; } const base = path.basename(rawPath.replace(/^[@~]+/, "")); if ( base === ".env" || base.startsWith(".env.") || base === "auth.json" || rawPath.split(path.sep).includes(".git") || rawPath.split(path.sep).includes(".secrets") || rawPath.split(path.sep).includes("data") ) { return { block: true, reason: `Path "${rawPath}" is denied by policy` }; } const check = contained(root, rawPath); if (!check.ok || !check.resolved) { return { block: true, reason: `Path "${rawPath}" refused (${check.reason || "policy"})`, }; } let st: fs.Stats; try { st = fs.lstatSync(check.resolved); } catch { return { block: true, reason: "stat failed" }; } if (st.isDirectory()) { const dirOk = directoryAllowOk(root, check.resolved, allow); if (!dirOk.ok) { return { block: true, reason: dirOk.reason || "directory allowlist failed" }; } } else if (st.isFile()) { if (!fileAllowlisted(root, check.resolved, allow)) { return { block: true, reason: `Path "${rawPath}" not in selected-file allowlist` }; } } else { return { block: true, reason: "special file refused" }; } // Canonicalize the only supported field input.path = check.resolved; return undefined; }); }