import { dirname } from "node:path"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { checkAction } from "../../src/core"; import { type AllowedPath, normalizeForDisplay, type PathAccessState, } from "../../src/core/paths"; import { configLoader } from "../../src/shared/config"; import { createFeatureRegisterPayload, emitActionBlocked, emitActionPrompted, GUARDRAILS_CONFIG_RELOAD_EVENT, GUARDRAILS_FEATURE_REGISTER_EVENT, GUARDRAILS_FEATURE_REQUEST_EVENT, GUARDRAILS_WORKSPACE_ROOTS_CHANGED_EVENT, GUARDRAILS_YOLO_CHANGED_EVENT, type GuardrailsWorkspaceRootsChangedPayload, type GuardrailsYoloChangedPayload, } from "../../src/shared/events"; import { piDocumentationPaths } from "./dynamic-resources"; import { createPendingGrant, isGrantTooBroad, type PendingPathGrant, pendingAllowedPaths, persistGrant, resolveAllowedPaths, } from "./grants"; import { createPathAccessPromptComponent, type PromptResult } from "./prompt"; import { createPathAccessRule } from "./rules"; import { targetsForTool } from "./targets"; export default async function pathAccess(pi: ExtensionAPI) { await configLoader.load(); // Pi docs paths depend only on `PI_PACKAGE_DIR` / the package root and are // fixed for the process lifetime, so resolve once at setup. const piDocsPaths = piDocumentationPaths(); let currentSkillAllowedPaths: AllowedPath[] = []; let workspaceRootPaths: AllowedPath[] = []; let yoloEnabled = false; pi.events.on(GUARDRAILS_YOLO_CHANGED_EVENT, (data: unknown) => { const payload = data as GuardrailsYoloChangedPayload | undefined; if (typeof payload?.enabled === "boolean") yoloEnabled = payload.enabled; }); // Another guardrails extension edited the shared config file (e.g. // /remove-dir revoking a grant); re-read it so this loader instance agrees. // load() resets the ephemeral memory scope, so session grants stored there // are captured and restored around it. pi.events.on(GUARDRAILS_CONFIG_RELOAD_EVENT, () => { void (async () => { const memory = configLoader.getRawConfig("memory"); await configLoader.load(); if (memory) await configLoader.save("memory", memory); })(); }); // The workspace-roots extension announces its root directories; each is // allowed like a directory grant so roots the agent is told about are also // accessible. pi.events.on(GUARDRAILS_WORKSPACE_ROOTS_CHANGED_EVENT, (data: unknown) => { const payload = data as GuardrailsWorkspaceRootsChangedPayload | undefined; if (!payload || !Array.isArray(payload.roots)) return; workspaceRootPaths = payload.roots .filter((root): root is string => typeof root === "string") .map((root) => ({ kind: "directory", path: root })); }); pi.on("before_agent_start", (event) => { const skills = event.systemPromptOptions.skills; if (!skills || skills.length === 0) return; currentSkillAllowedPaths = skills.flatMap((skill) => [ { kind: "file", path: skill.filePath }, { kind: "directory", path: skill.baseDir }, ]); }); pi.events.on(GUARDRAILS_FEATURE_REQUEST_EVENT, () => { pi.events.emit( GUARDRAILS_FEATURE_REGISTER_EVENT, createFeatureRegisterPayload("pathAccess"), ); }); pi.on("tool_call", async (event, ctx) => { if (yoloEnabled) return; const config = configLoader.getConfig(); if ( !config.enabled || !config.features.pathAccess || config.pathAccess.mode === "allow" ) { return; } const input = event.input as Record; const targets = [ ...new Set(await targetsForTool(event.toolName, input, ctx.cwd)), ]; const acceptedGrants: PendingPathGrant[] = []; for (const absolutePath of targets) { const action = { kind: "file" as const, path: absolutePath, origin: event.toolName, }; const state: PathAccessState = { cwd: ctx.cwd, mode: config.pathAccess.mode, allowedPaths: [ ...resolveAllowedPaths(config.pathAccess.allowedPaths, ctx.cwd), ...piDocsPaths, ...currentSkillAllowedPaths, ...workspaceRootPaths, ...pendingAllowedPaths(acceptedGrants), ], hasUI: ctx.hasUI, }; const safety = await checkAction(action, [createPathAccessRule(state)]); if (safety.kind === "safe") continue; if (config.pathAccess.mode === "block" || !ctx.hasUI) { emitActionBlocked(pi, { feature: "pathAccess", action: safety.action, reason: safety.reason, block: { source: ctx.hasUI ? "policy" : "nonInteractive", metadata: safety.metadata, }, context: { toolName: event.toolName, input }, }); return { block: true, reason: safety.reason }; } const parentDir = dirname(absolutePath); const showFileOptions = event.toolName !== "ls" && event.toolName !== "find"; emitActionPrompted(pi, { feature: "pathAccess", action: safety.action, reason: safety.reason, prompt: { kind: "confirmation", metadata: safety.metadata, }, context: { toolName: event.toolName, input }, }); const result = await ctx.ui.custom( createPathAccessPromptComponent( event.toolName, safety.metadata.displayPath, normalizeForDisplay(parentDir, ctx.cwd), ctx.cwd, showFileOptions, ), ); if (result === "allow-file-once" || result === "allow-dir-once") { continue; } if (result === "allow-file-session" || result === "allow-file-always") { const grant = createPendingGrant( absolutePath, false, result === "allow-file-session" ? "memory" : "local", ); acceptedGrants.push(grant); await persistGrant(grant); continue; } if (result === "allow-dir-session" || result === "allow-dir-always") { const dirPath = showFileOptions ? parentDir : absolutePath; if (isGrantTooBroad(dirPath)) { ctx.ui.notify( `Cannot grant access to ${normalizeForDisplay(dirPath, ctx.cwd)}/ — too broad. Treating as allow once.`, "warning", ); continue; } const grant = createPendingGrant( dirPath, true, result === "allow-dir-session" ? "memory" : "local", ); acceptedGrants.push(grant); await persistGrant(grant); continue; } const reason = "User denied access outside working directory"; emitActionBlocked(pi, { feature: "pathAccess", action: safety.action, reason, block: { source: "user", metadata: safety.metadata }, context: { toolName: event.toolName, input }, }); return { block: true, reason }; } }); }