import { existsSync, realpathSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; import { basename, dirname, isAbsolute, relative, resolve } from "node:path"; import { StringEnum } from "@earendil-works/pi-ai"; import { createBashTool, createLocalBashOperations, type BashOperations, type ExtensionAPI, type ExtensionContext, } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; import { mutatesGit } from "./git-policy.ts"; type PermissionMode = "default" | "auto" | "yolo"; interface BashParams { command: string; timeout?: number; sandbox_permissions?: "use_sandbox" | "require_escalated"; escalation_reason?: string; } interface PermissionState { mode?: PermissionMode; } const MODE_LABELS: Record = { default: "Default", auto: "Auto", yolo: "YOLO", }; function shellQuote(value: string): string { return `'${value.replaceAll("'", `'"'"'`)}'`; } function canonicalPath(input: string, cwd: string): string { const value = input.startsWith("@") ? input.slice(1) : input; const expanded = value === "~" || value.startsWith("~/") ? resolve(homedir(), value.slice(2)) : value; const target = isAbsolute(expanded) ? resolve(expanded) : resolve(cwd, expanded); let current = target; const missing: string[] = []; while (!existsSync(current)) { const parent = dirname(current); if (parent === current) break; missing.unshift(basename(current)); current = parent; } const base = existsSync(current) ? realpathSync(current) : current; return resolve(base, ...missing); } function isWithin(root: string, target: string): boolean { const path = relative(root, target); return path === "" || (!path.startsWith("..") && !isAbsolute(path)); } function isAutoWritePath(path: string, cwd: string): boolean { const target = canonicalPath(path, cwd); const project = canonicalPath(cwd, cwd); const temp = canonicalPath(tmpdir(), cwd); return isWithin(project, target) || isWithin(temp, target); } function sandboxCommand(command: string, cwd: string): string { const project = canonicalPath(cwd, cwd); const temp = canonicalPath(tmpdir(), cwd); const writable = [...new Set([project, temp])]; const args = [ "bwrap", "--die-with-parent", "--unshare-all", "--share-net", "--new-session", "--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc", "--chdir", cwd, ]; for (const root of writable) { args.push("--bind", root, root); } args.push("--", "sh", "-lc", command); return args.map(shellQuote).join(" "); } function createSandboxOperations(): BashOperations { const local = createLocalBashOperations(); return { exec(command, cwd, options) { return local.exec(sandboxCommand(command, cwd), cwd, options); }, }; } export function prepareBashArguments(input: unknown): unknown { if (!input || typeof input !== "object" || Array.isArray(input)) return input; const args = { ...(input as Record) }; if ( args.sandbox_permissions === undefined || args.sandbox_permissions === null || args.sandbox_permissions === "" || args.sandbox_permissions === "null" ) { args.sandbox_permissions = "use_sandbox"; } if ( args.escalation_reason === null || args.escalation_reason === "" || args.escalation_reason === "null" ) { delete args.escalation_reason; } if (args.timeout === null || args.timeout === "" || args.timeout === "null") { delete args.timeout; } return args; } async function approve( ctx: ExtensionContext, title: string, detail: string, signal?: AbortSignal, ): Promise { if (!ctx.hasUI || signal?.aborted) return false; const options = signal ? { signal } : undefined; const choice = await ctx.ui.select(`${title}\n\n${detail}`, ["Allow once", "Deny"], options); return choice === "Allow once"; } export default function simplePermissions(pi: ExtensionAPI): void { let mode: PermissionMode = "auto"; function updateStatus(ctx: ExtensionContext): void { let color: "accent" | "muted" | "warning" = "muted"; if (mode === "auto") color = "accent"; if (mode === "yolo") color = "warning"; ctx.ui.setStatus( "simple-permissions", ctx.ui.theme.fg(color, `Permission: ${MODE_LABELS[mode]}`), ); } function setMode(next: PermissionMode, ctx: ExtensionContext): void { mode = next; pi.appendEntry("simple-permissions", { mode }); updateStatus(ctx); ctx.ui.notify(`Permission mode: ${MODE_LABELS[mode]}`, mode === "yolo" ? "warning" : "info"); } async function chooseMode(ctx: ExtensionContext): Promise { if (!ctx.hasUI) return; const choice = await ctx.ui.select("Permission mode", ["Default", "Auto", "YOLO"]); if (choice) setMode(choice.toLowerCase() as PermissionMode, ctx); } pi.registerCommand("permission", { description: "Show or switch permission mode: /permission [default|auto|yolo]", handler: async (args, ctx) => { const value = args.trim().toLowerCase(); if (!value) { await chooseMode(ctx); return; } if (value !== "default" && value !== "auto" && value !== "yolo") { ctx.ui.notify("Usage: /permission [default|auto|yolo]", "warning"); return; } setMode(value as PermissionMode, ctx); }, }); pi.registerShortcut("alt+m", { description: "Cycle Permission mode (Default -> Auto -> YOLO)", handler: async (ctx) => { const nextMap: Record = { default: "auto", auto: "yolo", yolo: "default", }; setMode(nextMap[mode], ctx); }, }); pi.on("session_start", (_event, ctx) => { const entry = ctx.sessionManager.getEntries() .filter((item) => item.type === "custom" && item.customType === "simple-permissions") .pop() as { data?: PermissionState } | undefined; const savedMode = entry?.data?.mode; mode = (savedMode === "default" || savedMode === "auto" || savedMode === "yolo") ? savedMode : "auto"; updateStatus(ctx); }); pi.on("before_agent_start", (event) => { let boundaryText = ""; if (mode === "yolo") { boundaryText = "\n\n## Permission boundary\n\nThe active permission mode is YOLO. There are no restrictions. You have full system access without manual approval."; } else { boundaryText = `\n\n## Permission boundary\n\nThe active permission mode is ${MODE_LABELS[mode]}. Network access and network-search tools are allowed in every mode. In Auto mode, bash is sandboxed so only the current working directory and /tmp are writable. Use sandbox_permissions=\"use_sandbox\" for normal bash calls. For a necessary write outside those roots, use sandbox_permissions=\"require_escalated\" and a concise escalation_reason. Git: read-only inspection (status, log, diff, show, branch --show-current, ls-files, ls-remote, blame, ...) runs without approval; everything else, including add, commit, push, fetch, clone, init, reset, merge, rebase, checkout, tag -d, branch -D, stash drop, and config or remote writes, requires user approval.`; } return { systemPrompt: `${event.systemPrompt}${boundaryText}` }; }); pi.on("tool_call", async (event, ctx) => { const callMode = mode; if (callMode === "yolo") return; if (event.toolName !== "write" && event.toolName !== "edit") return; const path = String(event.input.path ?? ""); const needsApproval = callMode === "default" || !isAutoWritePath(path, ctx.cwd); if (!needsApproval) return; const allowed = await approve( ctx, `${MODE_LABELS[callMode]} permission request`, `${event.toolName}: ${path}`, ctx.signal, ); if (!allowed) { return { block: true, reason: ctx.signal?.aborted ? "Operation aborted" : ctx.hasUI ? "Blocked by user" : "Permission required, but no interactive UI is available", }; } }); const template = createBashTool(process.cwd()); const bashParameters = Type.Object({ command: Type.String({ description: "Bash command to execute" }), timeout: Type.Optional(Type.Number({ description: "Timeout in seconds" })), sandbox_permissions: Type.Optional(StringEnum(["use_sandbox", "require_escalated"] as const, { description: "Use the Auto filesystem sandbox, or request execution outside it", })), escalation_reason: Type.Optional(Type.String({ maxLength: 100, description: "Concise reason why execution outside the filesystem sandbox is required", })), }); pi.registerTool({ ...template, name: "bash", label: "bash (permissioned)", executionMode: "sequential", parameters: bashParameters, prepareArguments: prepareBashArguments, description: `${template.description}\n\nIn Auto mode, use sandbox_permissions=\"use_sandbox\" for normal commands; bash can then write only inside the current working directory and /tmp. Use sandbox_permissions=\"require_escalated\" only when a necessary operation must write elsewhere. Network access is unrestricted. Read-only Git inspection (status, log, diff, show, branch --show-current, ...) runs without approval; any state-changing Git command (add, commit, push, fetch, clone, reset, merge, rebase, checkout, ...) requires approval.`, async execute(toolCallId, params: BashParams, signal, onUpdate, ctx) { const callMode = mode; const command = params.command.trim(); const escalated = params.sandbox_permissions === "require_escalated"; const gitMutation = mutatesGit(command); const isYolo = callMode === "yolo"; const needsApproval = !isYolo && (callMode === "default" || escalated || gitMutation); if (needsApproval) { const reasons = [ callMode === "default" ? "Default mode" : undefined, escalated ? `filesystem escalation${params.escalation_reason ? `: ${params.escalation_reason}` : ""}` : undefined, gitMutation ? "mutating Git command" : undefined, ].filter(Boolean).join("; "); const allowed = await approve( ctx, `${MODE_LABELS[callMode]} bash permission request`, `${command}\n\nReason: ${reasons}`, signal, ); if (!allowed) { return { content: [{ type: "text", text: signal?.aborted ? "Operation aborted" : ctx.hasUI ? "Permission denied by user" : "Permission required, but no interactive UI is available", }], details: undefined, }; } } const useSandbox = callMode === "auto" && !escalated && !isYolo; const tool = useSandbox ? createBashTool(ctx.cwd, { operations: createSandboxOperations() }) : createBashTool(ctx.cwd); return tool.execute( toolCallId, { command, timeout: params.timeout }, signal, onUpdate, ); }, }); }