import { homedir } from "node:os"; import { dirname } from "node:path"; import { StringEnum } from "@earendil-works/pi-ai"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; import { AuditLog } from "./audit.ts"; import { childChannel, forwardApproval, isChildProcess, mainChannel, startApprovalServer, type ApprovalServer, type ForwardedReply, type ForwardedRequest, } from "./channel.ts"; import { allSensitivePatterns, type Capability, loadConfig, type PermissionConfig, setActiveMode, setModeZoneProfile, type ZoneName, } from "./config.ts"; import { type Grant, type GrantKind, type GrantScope, GrantStore } from "./grants.ts"; import { judgeExternalAccess } from "./judge.ts"; import { classifyPath, realpathSafe, toAbsolute, type PathContext } from "./paths.ts"; import { decide, type AccessRequest } from "./policy.ts"; import { prepareSandboxCommand, sandboxStatus, type PreparedSandboxCommand, } from "./sandbox.ts"; interface ApprovalOutcome { allowed: boolean; scope?: GrantScope; abort?: boolean; reason?: string; source?: "profile" | "grant" | "judge" | "user"; usedGrants?: Grant[]; } interface State { config: PermissionConfig; workspaceRoots: string[]; pathContext: PathContext; grants: GrantStore; audit: AuditLog; onceByToolCall: Map; sandboxByToolCall: Map; server?: ApprovalServer; } function emptyState(): State { const fallback = { enabled: false, profiles: { full: { read: "allow" as const, write: "allow" as const }, "guarded-read": { read: "elevate" as const, write: "elevate" as const }, confirm: { read: "elevate" as const, write: "elevate" as const }, }, activeMode: "standard", modes: { standard: { workspace: "full", external: "guarded-read", sensitive: "confirm", }, trusted: { workspace: "full", external: "full", sensitive: "full", }, }, zones: { workspace: { profile: "full", extraRoots: [] }, external: { profile: "guarded-read" }, sensitive: { profile: "confirm", paths: [] }, }, runtime: { sandboxBash: false, requireLandstrip: false, network: true }, judge: { enabled: false, timeoutMs: 30_000, minimumConfidence: 0.75 }, audit: { enabled: false, path: "" }, } satisfies PermissionConfig; return { config: fallback, workspaceRoots: [], pathContext: { workspaceRoots: [], sensitivePatterns: [] }, grants: new GrantStore(), audit: new AuditLog(() => fallback), onceByToolCall: new Map(), sandboxByToolCall: new Map(), }; } function shortenPath(path: string): string { const home = homedir(); return path === home ? "~" : path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path; } function agentName(): string { return process.env.PI_SUBAGENT_AGENT?.trim() || (isChildProcess() ? "subagent" : "main"); } function zonePermissionSummary(config: PermissionConfig, zone: ZoneName): string { const profileName = config.zones[zone].profile; const profile = config.profiles[profileName]; return `${profileName} (read=${profile.read}, write=${profile.write})`; } function userPromptText(request: AccessRequest): string { const action = request.capability === "read" ? "Read" : "Write"; const lines = [ `${action} permission requested`, "", `Agent: ${request.agent ?? "main"}`, `Zone: ${request.zone}`, `Target: ${shortenPath(request.resource)}`, `Tool: ${request.tool}`, ]; if (request.command) lines.push(`Command: ${request.command}`); if (request.justification) lines.push(`Reason: ${request.justification}`); return lines.join("\n"); } async function promptUser( ctx: ExtensionContext, request: AccessRequest, ): Promise { if (!ctx.hasUI) return { allowed: false, reason: "no interactive UI is available" }; const options = request.zone === "sensitive" ? ["Allow once", "Deny", "Abort turn"] : ["Allow once", "Allow for session", "Deny", "Abort turn"]; const choice = await ctx.ui.select(userPromptText(request), options); if (choice === "Allow once") return { allowed: true, scope: "once", source: "user" }; if (choice === "Allow for session") return { allowed: true, scope: "session", source: "user" }; if (choice === "Abort turn") return { allowed: false, abort: true, reason: "aborted by user" }; return { allowed: false, reason: "denied by user" }; } function addGrant( state: State, request: AccessRequest, scope: GrantScope, source: "judge" | "user", ): Grant { const effectiveScope = request.zone === "sensitive" ? "once" : scope; return state.grants.add({ capability: request.capability, pathPrefix: request.resource, scope: effectiveScope, kind: request.kind, command: effectiveScope === "once" && request.kind === "bash" ? request.command : undefined, source, }); } async function askMainFromChild(request: AccessRequest): Promise { const channel = childChannel(); if (!channel) return { allowed: false, reason: "subagent approval channel is unavailable" }; const reply = await forwardApproval( channel, { tool: request.tool, capability: request.capability, resource: request.resource, zone: request.zone, kind: request.kind, command: request.command, justification: request.justification, }, { timeoutMs: 120_000 }, ); return { allowed: reply.allowed, scope: reply.scope, abort: reply.abort, reason: reply.reason, source: reply.allowed ? "user" : undefined, }; } async function resolveElevation( state: State, request: AccessRequest, ctx: ExtensionContext, options: { proactive: boolean; toolCallId?: string }, ): Promise { const decision = decide(state.config, request); if (decision.action === "allow") { await state.audit.record(request, "allow", "profile", decision.reason); return { allowed: true, source: "profile" }; } if (decision.action === "deny") { await state.audit.record(request, "deny", "profile", decision.reason); return { allowed: false, reason: decision.reason }; } const matching = state.grants .matching(request.capability, request.resource, request.kind, request.command) .filter((grant) => request.zone !== "sensitive" || grant.scope === "once"); if (matching.length > 0) { if (options.toolCallId) state.onceByToolCall.set(options.toolCallId, matching); await state.audit.record(request, "allow", "grant", matching.map((grant) => grant.scope).join(",")); return { allowed: true, source: "grant", usedGrants: matching }; } // Every non-sensitive external elevation goes through the judge first. // Proactive Bash requests must include the exact command so a silently // approved one-shot grant cannot be reused for a different shell action. if (request.zone === "external" && (request.kind === "file" || Boolean(request.command))) { const judgment = await judgeExternalAccess(request, ctx, state.config, ctx.signal); if (judgment.allowOnce) { if (options.proactive) addGrant(state, request, "once", "judge"); await state.audit.record(request, "allow", "judge", judgment.reason); return { allowed: true, scope: "once", source: "judge" }; } } const outcome = isChildProcess() ? await askMainFromChild(request) : await promptUser(ctx, request); if (outcome.allowed && outcome.scope) { if (options.proactive || outcome.scope === "session") { const grant = addGrant(state, request, outcome.scope, "user"); if (options.toolCallId && outcome.scope === "once") { state.onceByToolCall.set(options.toolCallId, [grant]); } } await state.audit.record(request, "allow", "user", outcome.scope); return outcome; } await state.audit.record(request, "deny", "user", outcome.reason); return outcome; } function pathFromInput(input: unknown): string | undefined { if (typeof input !== "object" || input === null) return undefined; const path = (input as { path?: unknown }).path; return typeof path === "string" ? path : undefined; } function patchText(input: unknown): string { if (typeof input === "string") return input; if (typeof input !== "object" || input === null) return ""; const value = input as { patch?: unknown; input?: unknown }; return typeof value.patch === "string" ? value.patch : typeof value.input === "string" ? value.input : ""; } function patchPaths(input: unknown): string[] { const paths: string[] = []; for (const line of patchText(input).split("\n")) { const match = line.match(/^\*\*\* (?:Add File|Update File|Delete File|Move to): (.+)$/); if (match?.[1]) paths.push(match[1].trim()); } return [...new Set(paths)]; } async function fileRequests( state: State, toolName: string, input: unknown, cwd: string, ): Promise { let capability: Capability | undefined; let paths: string[] = []; if (["read", "grep", "find", "ls"].includes(toolName)) { capability = "read"; paths = [pathFromInput(input) ?? "."]; } else if (["write", "edit"].includes(toolName)) { capability = "write"; const path = pathFromInput(input); if (path) paths = [path]; } else if (toolName === "apply_patch") { capability = "write"; paths = patchPaths(input); } if (!capability) return []; const requests: AccessRequest[] = []; for (const inputPath of paths) { const classified = await classifyPath(inputPath, cwd, state.pathContext); requests.push({ tool: toolName, capability, resource: classified.path, zone: classified.zone, kind: "file", agent: agentName(), }); } return requests; } async function initializeState(state: State, ctx: ExtensionContext): Promise { state.config = await loadConfig(); const roots = [ctx.cwd, ...state.config.zones.workspace.extraRoots]; state.workspaceRoots = await Promise.all( roots.map((root) => realpathSafe(toAbsolute(root, ctx.cwd))), ); state.pathContext = { workspaceRoots: state.workspaceRoots, sensitivePatterns: allSensitivePatterns(state.config), }; state.grants.clear(); state.audit = new AuditLog(() => state.config); state.onceByToolCall.clear(); if (!isChildProcess()) { await state.server?.dispose(); const sessionId = ctx.sessionManager.getSessionId(); state.server = await startApprovalServer(mainChannel(sessionId), async (forwarded) => { const request: AccessRequest = { tool: forwarded.tool, capability: forwarded.capability, resource: forwarded.resource, zone: forwarded.zone, kind: forwarded.kind, command: forwarded.command, justification: forwarded.justification, agent: forwarded.agent, }; const outcome = await promptUser(ctx, request); return { id: forwarded.id, allowed: outcome.allowed, scope: outcome.scope, reason: outcome.reason, abort: outcome.abort, createdAt: Date.now(), }; }); } } export default function permissionControl(pi: ExtensionAPI) { const state = emptyState(); pi.on("session_start", async (_event, ctx) => { try { await initializeState(state, ctx); if (state.workspaceRoots.some((root) => root === homedir())) { ctx.ui.notify( "permission-control: the workspace root is your home directory; the full profile covers all non-sensitive paths under it.", "warning", ); } } catch (error) { state.config.enabled = false; ctx.ui.notify( `permission-control failed to initialize: ${error instanceof Error ? error.message : String(error)}`, "error", ); } }); pi.on("session_shutdown", async () => { for (const prepared of state.sandboxByToolCall.values()) await prepared.dispose(); state.sandboxByToolCall.clear(); state.onceByToolCall.clear(); await state.server?.dispose(); state.server = undefined; }); pi.on("tool_call", async (event, ctx) => { if (!state.config.enabled || event.toolName === "request_permission") return undefined; if (event.toolName === "bash") { const input = event.input as { command?: string }; const command = input.command ?? ""; try { const matching = state.grants .all() .filter( (grant) => grant.kind === "bash" && (grant.command === undefined || grant.command === command), ); const prepared = await prepareSandboxCommand( command, state.config, state.workspaceRoots, state.pathContext.sensitivePatterns, matching, ); if (prepared) { input.command = prepared.command; state.sandboxByToolCall.set(event.toolCallId, prepared); } const once = matching.filter((grant) => grant.scope === "once"); if (once.length > 0) state.onceByToolCall.set(event.toolCallId, once); } catch (error) { const reason = `Bash sandbox unavailable: ${error instanceof Error ? error.message : String(error)}`; return { block: true, reason }; } return undefined; } const requests = await fileRequests(state, event.toolName, event.input, ctx.cwd); for (const request of requests) { const outcome = await resolveElevation(state, request, ctx, { proactive: false, toolCallId: event.toolCallId, }); if (!outcome.allowed) { return { block: true, reason: outcome.reason ?? `Permission denied for ${request.resource}`, terminate: outcome.abort, }; } } return undefined; }); pi.on("tool_execution_end", async (event) => { const grants = state.onceByToolCall.get(event.toolCallId); if (grants) { state.grants.consume(grants); state.onceByToolCall.delete(event.toolCallId); } const prepared = state.sandboxByToolCall.get(event.toolCallId); if (prepared) { await prepared.dispose(); state.sandboxByToolCall.delete(event.toolCallId); } }); pi.registerTool({ name: "request_permission", label: "Request Permission", description: "Request a narrow temporary read or write permission for a non-sensitive path outside the workspace. Every external request is reviewed by the configured judge first and may be silently approved once; requests the judge does not approve go to the user. Include command when the permission is needed by Bash.", promptSnippet: "Request a narrow temporary external path permission before retrying a blocked operation", promptGuidelines: [ "Use request_permission when a task genuinely needs an external path; request the narrowest exact path and explain how it serves the user's current objective.", "When Bash needs an external path, include the exact command so a one-shot grant is bound to that command.", ], parameters: Type.Object({ capability: StringEnum(["read", "write"] as const, { description: "Filesystem capability needed", }), resource: Type.String({ description: "Exact file or directory path needed" }), justification: Type.String({ description: "Why this exact access is necessary" }), command: Type.Optional( Type.String({ description: "Exact Bash command that will consume the permission" }), ), }), async execute(_id, params, _signal, _onUpdate, ctx) { if (!state.config.enabled) { return { content: [{ type: "text", text: "permission-control is disabled." }], details: { allowed: true, disabled: true }, }; } const classified = await classifyPath(params.resource, ctx.cwd, state.pathContext); const request: AccessRequest = { tool: "request_permission", capability: params.capability, resource: classified.path, zone: classified.zone, kind: params.command ? "bash" : "file", command: params.command, justification: params.justification, agent: agentName(), }; const outcome = await resolveElevation(state, request, ctx, { proactive: true }); const text = outcome.allowed ? `Permission granted${outcome.scope ? ` (${outcome.scope})` : ""}: ${params.capability} ${shortenPath(classified.path)}` : `Permission denied: ${outcome.reason ?? "not approved"}`; return { content: [{ type: "text", text }], details: { allowed: outcome.allowed, scope: outcome.scope, zone: classified.zone, resource: classified.path, abort: outcome.abort, }, terminate: Boolean(outcome.abort), }; }, }); pi.on("before_agent_start", (event) => { if (!state.config.enabled) return undefined; const prompt = [ "## Permission environment", `- Active permission mode: \`${state.config.activeMode}\`.`, `- Workspace roots use ${zonePermissionSummary(state.config, "workspace")}: ${state.workspaceRoots.map(shortenPath).join(", ")}.`, `- External paths use ${zonePermissionSummary(state.config, "external")}; elevated accesses go to the judge before the user.`, `- Sensitive paths use ${zonePermissionSummary(state.config, "sensitive")}; when elevated they always require explicit user approval and never receive session grants.`, `- Bash is ${state.config.runtime.sandboxBash ? "OS-sandboxed with Landstrip" : "not sandboxed"}.`, `- Outbound network is ${state.config.runtime.network ? "available" : "blocked"}.`, "- Non-sensitive external operations configured as elevate are reviewed by the judge first and may receive a silent one-shot approval; only rejected or uncertain requests go to the user.", ].join("\n"); return { systemPrompt: `${event.systemPrompt}\n\n${prompt}` }; }); pi.registerCommand("perm", { description: "Show permission profiles, workspace roots, grants, and sandbox status", handler: async (_args, ctx) => { const sandbox = await sandboxStatus(); const lines = [ `enabled: ${state.config.enabled}`, `active mode: ${state.config.activeMode}`, `workspace: ${zonePermissionSummary(state.config, "workspace")}`, `external: ${zonePermissionSummary(state.config, "external")}`, `sensitive: ${zonePermissionSummary(state.config, "sensitive")}`, `workspace roots: ${state.workspaceRoots.map(shortenPath).join(", ")}`, `network: ${state.config.runtime.network ? "allowed" : "blocked"}`, `judge: ${state.config.judge.enabled ? `${state.config.judge.provider}/${state.config.judge.model}` : "disabled"}`, `grants: ${state.grants.all().length}`, `sandbox: ${sandbox.available ? `available (${sandbox.binaryPath})` : `unavailable (${sandbox.error})`}`, ]; ctx.ui.notify(lines.join("\n"), sandbox.available ? "info" : "warning"); }, }); pi.registerCommand("perm-mode", { description: "Interactively switch the active permission mode", handler: async (_args, ctx) => { if (!ctx.hasUI) { ctx.ui.notify("Interactive permission settings require a UI", "error"); return; } const choices = Object.entries(state.config.modes).map(([name, zones]) => ({ name, label: `${name === state.config.activeMode ? "●" : "○"} ${name} — workspace=${zones.workspace}, external=${zones.external}, sensitive=${zones.sensitive}`, })); const selected = await ctx.ui.select( "Permission mode", choices.map((choice) => choice.label), ); const mode = choices.find((choice) => choice.label === selected)?.name; if (!mode || mode === state.config.activeMode) return; try { await setActiveMode(mode); await initializeState(state, ctx); ctx.ui.notify(`Permission mode switched to ${mode}`, "info"); } catch (error) { ctx.ui.notify(error instanceof Error ? error.message : String(error), "error"); } }, }); pi.registerCommand("perm-zone", { description: "Interactively change a zone profile in the active mode", handler: async (_args, ctx) => { if (!ctx.hasUI) { ctx.ui.notify("Interactive permission settings require a UI", "error"); return; } const zones: ZoneName[] = ["workspace", "external", "sensitive"]; const zoneChoices = zones.map((zone) => ({ zone, label: `${zone} — ${zonePermissionSummary(state.config, zone)}`, })); const selectedZone = await ctx.ui.select( `Permission zones (${state.config.activeMode})`, zoneChoices.map((choice) => choice.label), ); const zone = zoneChoices.find((choice) => choice.label === selectedZone)?.zone; if (!zone) return; const profileChoices = Object.entries(state.config.profiles).map( ([name, permissions]) => ({ name, label: `${name === state.config.zones[zone].profile ? "●" : "○"} ${name} — read=${permissions.read}, write=${permissions.write}`, }), ); const selectedProfile = await ctx.ui.select( `${zone} profile`, profileChoices.map((choice) => choice.label), ); const profile = profileChoices.find( (choice) => choice.label === selectedProfile, )?.name; if (!profile || profile === state.config.zones[zone].profile) return; try { await setModeZoneProfile(state.config.activeMode, zone, profile); await initializeState(state, ctx); ctx.ui.notify( `${zone} now uses ${profile} in mode ${state.config.activeMode}`, "info", ); } catch (error) { ctx.ui.notify(error instanceof Error ? error.message : String(error), "error"); } }, }); pi.registerCommand("perm-grants", { description: "List or clear temporary permission grants", handler: async (args, ctx) => { if (String(args).trim() === "clear") { state.grants.clear(); ctx.ui.notify("Permission grants cleared", "info"); return; } const grants = state.grants.all(); ctx.ui.notify( grants.length ? grants .map( (grant) => `${grant.scope} ${grant.capability} ${shortenPath(grant.pathPrefix)} [${grant.kind}/${grant.source}]`, ) .join("\n") : "No active permission grants", "info", ); }, }); pi.registerCommand("perm-log", { description: "Show recent permission audit decisions", handler: async (_args, ctx) => { const entries = await state.audit.recent(20); ctx.ui.notify( entries.length ? entries .map( (entry) => `${entry.timestamp} ${entry.decision} ${entry.capability} ${shortenPath(entry.resource)} (${entry.source})`, ) .join("\n") : "No permission audit entries", "info", ); }, }); }