Files
ai-agent/runner/extensions/folder-permissions.ts
Gabriel Vidal f752389a45 feat(ai-agent): pi.dev harness — runner wrapping pi --mode json + sidecar routing
New services/ai-agent/runner/: a Node CLI the sidecar launches exactly like
`claude` (-p/--session-id/--resume/--model). It runs pi 0.80.6 headless on
Qwen 3.6 (OpenRouter via vendor-prefixed ids, EVOX2 LM Studio via bare ids,
with WoL wake), persists pi sessions keyed by the viewer's session UUID, and
mirrors the event stream as a Claude-Code-schema transcript under
~/.pi-runner/transcripts so the viewer parses it with zero new code. Ships a
folder-permissions extension (tool_call gate: writes only inside
PI_PERM_SCOPE, read roots, bash path scan, secrets deny-list) — pi itself has
no permission system.

sidecar.py: harness field on /spawn + /resume routes pi sessions to
RUNNER_BIN (claude untouched); pi model ids get their own validation regex.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 04:40:12 +02:00

88 lines
3.3 KiB
TypeScript

/**
* folder-permissions — scope-based read/execute/write gating for pi sessions.
*
* pi has no built-in permission system; extensions can block any tool call via
* the `tool_call` event. Policy is derived from the session's worktree path:
*
* PI_PERM_SCOPE the worktree/project root (default: cwd). write/edit are
* allowed ONLY inside it.
* PI_PERM_READ colon-separated extra readable roots (default: $HOME).
* The scope itself and system prefixes are always readable.
* bash may only reference absolute paths inside the readable roots
* (naive token scan — defense in depth, not a sandbox).
*
* A deny-always list protects secrets from write/edit regardless of scope.
* Headless runs have no UI, so every violation is a hard block with a reason
* the model can read and report.
*/
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { realpathSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, resolve } from "node:path";
const SYSTEM_PREFIXES = ["/usr", "/bin", "/sbin", "/etc", "/proc", "/sys", "/opt",
"/var", "/run", "/tmp", "/dev/null", "/dev/stdin", "/dev/stdout", "/dev/stderr"];
const WRITE_DENY = [/\/\.env(\.|$)/, /\.credentials/, /\/\.ssh\//, /\/\.gnupg\//];
/** Resolve symlinks on the deepest existing ancestor so `a/../b` and links
* can't escape the scope check. */
function canonical(p: string): string {
let cur = resolve(p);
let suffix = "";
for (;;) {
try {
return resolve(realpathSync(cur), suffix);
} catch {
suffix = suffix ? `${cur.split("/").pop()}/${suffix}` : (cur.split("/").pop() ?? "");
const parent = dirname(cur);
if (parent === cur) return resolve(p);
cur = parent;
}
}
}
export default function (pi: ExtensionAPI) {
const scope = canonical(process.env.PI_PERM_SCOPE || process.cwd());
const readRoots = (process.env.PI_PERM_READ || homedir())
.split(":")
.filter(Boolean)
.map(canonical)
.concat([scope]);
const inside = (p: string, root: string) => p === root || p.startsWith(root + "/");
const readable = (p: string) =>
readRoots.some((r) => inside(p, r)) || SYSTEM_PREFIXES.some((r) => inside(p, r));
pi.on("tool_call", async (event) => {
const input = event.input as Record<string, unknown>;
const t = event.toolName;
if (t === "write" || t === "edit") {
const p = canonical(String(input.path ?? ""));
if (WRITE_DENY.some((re) => re.test(p)))
return { block: true, reason: `permission denied: ${p} is protected` };
if (!inside(p, scope))
return { block: true, reason: `permission denied: write outside scope ${scope}: ${p}` };
}
if (t === "read" || t === "list" || t === "grep" || t === "glob" || t === "find") {
const raw = String(input.path ?? input.file ?? "");
if (raw && !readable(canonical(raw)))
return { block: true, reason: `permission denied: read outside permitted roots: ${raw}` };
}
if (t === "bash") {
const cmd = String(input.command ?? "");
const paths = cmd.match(/(?<=^|[\s='"`(])\/(?:[\w.@+~-]+\/)*[\w.@+*~-]+/g) ?? [];
const offending = [...new Set(paths.map(canonical).filter((p) => !readable(p)))];
if (offending.length)
return {
block: true,
reason: `permission denied: command references paths outside the permitted roots: ${offending.join(", ")}`,
};
}
return undefined;
});
}