import { type ExtensionAPI, getAgentDir, getPackageDir } from "@earendil-works/pi-coding-agent"; import { claimPermissionExtensionRegistration, currentSessionId, getPermissionRuntime, isSubagentProcess, parentSessionId, permissionRequestId, releasePermissionExtensionRegistration, } from "./authority.ts"; import { buildPermissionPathPolicy, extractSubmittedTempFiles, extractTerminalPasteFiles, } from "./policy.ts"; import { evaluatePermissionPolicy, requirementAccess, type PermissionRequest, } from "./core.ts"; import { loadParentGrantView, permissionForwardingRoot, PermissionForwardingServer, PermissionSnapshotStore, requestParentPermission, } from "./forwarding.ts"; import { getOutstandingRequirements, type PermissionPromptDecision } from "./presentation.ts"; import { requestPermissionDecision } from "./ui.ts"; export default function permissionExtension(pi: ExtensionAPI): void { if (!claimPermissionExtensionRegistration()) return; const authority = getPermissionRuntime().authority; const forwardingRoot = permissionForwardingRoot(getAgentDir()); const forwardingServer = new PermissionForwardingServer( forwardingRoot, (sessionId) => authority.refreshSnapshot(sessionId), ); const childProcess = isSubagentProcess(); const policyByCwd = new Map>(); let unsubscribeTerminalFileTrust: (() => void) | undefined; if (!childProcess) authority.configureSnapshotStore(new PermissionSnapshotStore(forwardingRoot)); pi.on("session_start", (_event, ctx) => { if (childProcess) return; const sessionId = currentSessionId(ctx); authority.activateSession(sessionId); forwardingServer.start(sessionId, async (forwarded) => { const result = await authority.authorize({ sessionId, requestId: `forwarded:${forwarded.id}`, request: forwarded.request, isAborted: () => ctx.signal?.aborted ?? false, hasUI: ctx.hasUI, decide: (request) => requestPermissionDecision(ctx, request), }); return result.decision ?? { kind: "once" }; }); unsubscribeTerminalFileTrust?.(); unsubscribeTerminalFileTrust = undefined; if (ctx.mode === "tui" && typeof ctx.ui.onTerminalInput === "function") { // Pi reassembles each bracketed paste before terminal listeners run. unsubscribeTerminalFileTrust = ctx.ui.onTerminalInput((data) => { const files = extractTerminalPasteFiles(data); if (files.length > 0) authority.registerTrustedFiles(sessionId, files); return undefined; }); } }); pi.on("tool_call", async (event, ctx) => { const localSessionId = currentSessionId(ctx); const agentName = subagentName(); const toolCall = { toolName: event.toolName, input: event.input as Record }; const policyKey = process.platform === "win32" ? ctx.cwd.toLowerCase() : ctx.cwd; let basePolicy = policyByCwd.get(policyKey); if (!basePolicy) { basePolicy = buildPermissionPathPolicy(ctx.cwd, { agentDir: getAgentDir(), packageDir: getPackageDir(), }); policyByCwd.set(policyKey, basePolicy); } const sessionTrustedFiles = authority.trustedFiles(localSessionId); const localPolicy = sessionTrustedFiles.length === 0 ? basePolicy : { ...basePolicy, approvedReadFiles: [...(basePolicy.approvedReadFiles ?? []), ...sessionTrustedFiles], }; const policyDecision = evaluatePermissionPolicy(toolCall, ctx.cwd, localPolicy, agentName); if (policyDecision.effect === "deny") { return { block: true, reason: `Permission policy denied: ${policyDecision.reason}` }; } if (policyDecision.effect === "allow") return undefined; const request = policyDecision.request; let decision: PermissionPromptDecision | undefined; if (childProcess) { const parentId = parentSessionId(); const firstView = parentId ? loadParentGrantView(forwardingRoot, parentId) : undefined; const isCovered = (view: NonNullable): boolean => { const inheritedRequest = evaluatePermissionPolicy(toolCall, ctx.cwd, { ...localPolicy, approvedReadFiles: [ ...(localPolicy.approvedReadFiles ?? []), ...view.approvedReadFiles, ], }, agentName); return inheritedRequest.effect === "allow" || (inheritedRequest.effect === "ask" && getOutstandingRequirements(inheritedRequest.request, view.grants).length === 0); }; if (firstView && isCovered(firstView)) { const confirmedView = parentId ? loadParentGrantView(forwardingRoot, parentId) : undefined; if (confirmedView?.revision === firstView.revision && isCovered(confirmedView)) return undefined; } decision = parentId ? await requestParentPermission({ forwardingRoot, parentSessionId: parentId, requesterSessionId: localSessionId, requesterAgentName: agentName, request, isAborted: () => ctx.signal?.aborted ?? false, }) : { kind: "reject" }; } else { const result = await authority.authorize({ sessionId: localSessionId, requestId: permissionRequestId(event.toolCallId), request, isAborted: () => ctx.signal?.aborted ?? false, hasUI: ctx.hasUI, decide: (restrictedRequest) => requestPermissionDecision(ctx, restrictedRequest), }); decision = result.decision; } if (!decision || decision.kind === "once" || decision.kind === "always") return undefined; return permissionRejection(decision, childProcess, ctx.hasUI); }); pi.on("tool_result", (event, ctx) => { const details = event.details as { fullOutputPath?: unknown } | undefined; if (event.toolName === "bash" && typeof details?.fullOutputPath === "string") { authority.registerTrustedFile(currentSessionId(ctx), details.fullOutputPath); } }); pi.on("input", (event, ctx) => { if (!childProcess && event.source !== "extension") { registerSubmittedFiles(authority, currentSessionId(ctx), event.text); } return { action: "continue" }; }); pi.registerCommand("permissions", { description: "Manage session permission grants", handler: async (_args, ctx) => { if (!ctx.hasUI || childProcess) return; const grants = authority.grantsFor(currentSessionId(ctx)); const rules = grants.list(); const choices = [ ...rules.map(formatRule), ...(rules.length > 0 ? ["Clear all session grants"] : []), "Cancel", ]; const selected = await ctx.ui.select("Session permission grants", choices); if (!selected || selected === "Cancel") return; if (selected === "Clear all session grants") { grants.clear(); return; } const rule = rules.find((item) => formatRule(item) === selected); if (rule) grants.remove(rule.permission, rule.alwaysPattern, requirementAccess(rule)); }, }); pi.on("session_shutdown", (_event, ctx) => { unsubscribeTerminalFileTrust?.(); unsubscribeTerminalFileTrust = undefined; forwardingServer.stop(); authority.clearSession(currentSessionId(ctx)); policyByCwd.clear(); releasePermissionExtensionRegistration(); }); } function permissionRejection( decision: Extract, childProcess: boolean, hasUI: boolean, ): { block: true; reason: string } { if (decision.feedback) { return { block: true, reason: `Permission rejected by the user. User feedback: ${decision.feedback}` }; } if (childProcess) { return { block: true, reason: "Permission denied by the parent conversation or permission forwarding is unavailable." }; } return { block: true, reason: hasUI ? "Permission rejected by the user." : "Permission denied because no interactive UI is available.", }; } function registerSubmittedFiles( authority: ReturnType["authority"], sessionId: string, text: string, ): void { authority.registerTrustedFiles(sessionId, extractSubmittedTempFiles(text)); } function subagentName(): string | undefined { if (!isSubagentProcess()) return undefined; return process.env.PI_SUBAGENT_NAME?.trim() || "Subagent"; } function formatRule(rule: PermissionRequest["requirements"][number]): string { return `${rule.permission}:${requirementAccess(rule)}: ${rule.alwaysPattern}`; }