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>
88 lines
3.3 KiB
TypeScript
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;
|
|
});
|
|
}
|