Files
Agent-System/scripts/pi-subagent-readonly.ts
T

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;
});
}