265 lines
8.1 KiB
TypeScript
265 lines
8.1 KiB
TypeScript
/**
|
|
* 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<string> | 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<string> | 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<string> | 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<string, unknown> }) => {
|
|
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;
|
|
});
|
|
}
|