import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { getAgentDir } from "@earendil-works/pi-coding-agent"; export type PermissionAction = "allow" | "elevate" | "deny"; export type Capability = "read" | "write"; export type ZoneName = "workspace" | "external" | "sensitive"; export interface PermissionProfile { read: PermissionAction; write: PermissionAction; } export interface ZoneProfiles { workspace: string; external: string; sensitive: string; } export interface PermissionConfig { enabled: boolean; profiles: Record; activeMode: string; modes: Record; zones: { workspace: { profile: string; extraRoots: string[] }; external: { profile: string }; sensitive: { profile: string; paths: string[] }; }; runtime: { sandboxBash: boolean; requireLandstrip: boolean; network: boolean; }; judge: { enabled: boolean; provider?: string; model?: string; timeoutMs: number; minimumConfidence: number; }; audit: { enabled: boolean; path: string; }; } export const DEFAULT_SENSITIVE_PATHS = [ "~/.ssh", "~/.gnupg", "~/.aws", "~/.kube", "~/.docker", "~/.config/gh", "~/.config/gcloud", "~/.netrc", "~/.npmrc", "~/.pi/agent/auth.json", "~/.pi/agent/trust.json", "~/.pi/agent/permission-control.json", "~/.pi/agent/permission-control-audit.jsonl", "~/.pi/agent/extensions/permission-control", "**/.env", "**/.env.*", "**/*.pem", "**/*.key", "**/*.p12", "**/*.pfx", "**/id_rsa", "**/id_ed25519", ] as const; const DEFAULT_CONFIG: PermissionConfig = { enabled: true, profiles: { full: { read: "allow", write: "allow" }, "guarded-read": { read: "elevate", write: "elevate" }, confirm: { read: "elevate", write: "elevate" }, }, 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: true, requireLandstrip: true, network: true, }, judge: { enabled: true, provider: "mvp-openai", model: "codex-auto-review", timeoutMs: 30_000, minimumConfidence: 0.75, }, audit: { enabled: true, path: join(getAgentDir(), "permission-control-audit.jsonl"), }, }; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function isAction(value: unknown): value is PermissionAction { return value === "allow" || value === "elevate" || value === "deny"; } function stringArray(value: unknown): string[] { return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []; } function mergeProfile(base: PermissionProfile, value: unknown): PermissionProfile { if (!isRecord(value)) return { ...base }; return { read: isAction(value.read) ? value.read : base.read, write: isAction(value.write) ? value.write : base.write, }; } function mergeMode(base: ZoneProfiles, value: unknown): ZoneProfiles { if (!isRecord(value)) return { ...base }; return { workspace: typeof value.workspace === "string" ? value.workspace : base.workspace, external: typeof value.external === "string" ? value.external : base.external, sensitive: typeof value.sensitive === "string" ? value.sensitive : base.sensitive, }; } function mergeConfig(value: unknown): PermissionConfig { if (!isRecord(value)) return structuredClone(DEFAULT_CONFIG); const profiles = { ...DEFAULT_CONFIG.profiles }; if (isRecord(value.profiles)) { for (const [name, profile] of Object.entries(value.profiles)) { profiles[name] = mergeProfile(profiles[name] ?? DEFAULT_CONFIG.profiles["guarded-read"], profile); } } const modes = structuredClone(DEFAULT_CONFIG.modes); if (isRecord(value.modes)) { for (const [name, mode] of Object.entries(value.modes)) { modes[name] = mergeMode(modes[name] ?? DEFAULT_CONFIG.modes.standard, mode); } } const activeMode = typeof value.activeMode === "string" ? value.activeMode : DEFAULT_CONFIG.activeMode; const activeZoneProfiles = modes[activeMode]; if (!activeZoneProfiles) throw new Error(`Unknown permission mode: ${activeMode}`); const zones = isRecord(value.zones) ? value.zones : {}; const workspace = isRecord(zones.workspace) ? zones.workspace : {}; const external = isRecord(zones.external) ? zones.external : {}; const sensitive = isRecord(zones.sensitive) ? zones.sensitive : {}; const runtime = isRecord(value.runtime) ? value.runtime : {}; const judge = isRecord(value.judge) ? value.judge : {}; const audit = isRecord(value.audit) ? value.audit : {}; const config: PermissionConfig = { enabled: typeof value.enabled === "boolean" ? value.enabled : DEFAULT_CONFIG.enabled, profiles, activeMode, modes, zones: { workspace: { profile: activeZoneProfiles.workspace, extraRoots: stringArray(workspace.extraRoots), }, external: { profile: activeZoneProfiles.external, }, sensitive: { profile: activeZoneProfiles.sensitive, paths: stringArray(sensitive.paths), }, }, runtime: { sandboxBash: typeof runtime.sandboxBash === "boolean" ? runtime.sandboxBash : DEFAULT_CONFIG.runtime.sandboxBash, requireLandstrip: typeof runtime.requireLandstrip === "boolean" ? runtime.requireLandstrip : DEFAULT_CONFIG.runtime.requireLandstrip, network: typeof runtime.network === "boolean" ? runtime.network : DEFAULT_CONFIG.runtime.network, }, judge: { enabled: typeof judge.enabled === "boolean" ? judge.enabled : DEFAULT_CONFIG.judge.enabled, provider: typeof judge.provider === "string" ? judge.provider : DEFAULT_CONFIG.judge.provider, model: typeof judge.model === "string" ? judge.model : DEFAULT_CONFIG.judge.model, timeoutMs: typeof judge.timeoutMs === "number" && judge.timeoutMs > 0 ? judge.timeoutMs : DEFAULT_CONFIG.judge.timeoutMs, minimumConfidence: typeof judge.minimumConfidence === "number" ? Math.max(0, Math.min(1, judge.minimumConfidence)) : DEFAULT_CONFIG.judge.minimumConfidence, }, audit: { enabled: typeof audit.enabled === "boolean" ? audit.enabled : DEFAULT_CONFIG.audit.enabled, path: typeof audit.path === "string" ? audit.path : DEFAULT_CONFIG.audit.path, }, }; for (const profileName of [ config.zones.workspace.profile, config.zones.external.profile, config.zones.sensitive.profile, ...Object.values(config.modes).flatMap((mode) => [ mode.workspace, mode.external, mode.sensitive, ]), ]) { if (!config.profiles[profileName]) { throw new Error(`Unknown permission profile: ${profileName}`); } } return config; } export function configPath(): string { return join(getAgentDir(), "permission-control.json"); } export async function loadConfig(): Promise { try { const parsed = JSON.parse(await readFile(configPath(), "utf8")) as unknown; return mergeConfig(parsed); } catch (error) { if (error instanceof SyntaxError) throw new Error(`Invalid ${configPath()}: ${error.message}`); return structuredClone(DEFAULT_CONFIG); } } async function readRawConfig(): Promise> { try { const value = JSON.parse(await readFile(configPath(), "utf8")) as unknown; return isRecord(value) ? value : {}; } catch (error) { if (error instanceof SyntaxError) throw new Error(`Invalid ${configPath()}: ${error.message}`); return {}; } } async function writeRawConfig(value: Record): Promise { const path = configPath(); await mkdir(dirname(path), { recursive: true }); const temporary = `${path}.${process.pid}.${Date.now()}.tmp`; await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8", mode: 0o600, }); await rename(temporary, path); } export async function setActiveMode(mode: string): Promise { const config = await loadConfig(); if (!config.modes[mode]) throw new Error(`Unknown permission mode: ${mode}`); const raw = await readRawConfig(); raw.activeMode = mode; await writeRawConfig(raw); } export async function setModeZoneProfile( mode: string, zone: ZoneName, profile: string, ): Promise { const config = await loadConfig(); if (!config.modes[mode]) throw new Error(`Unknown permission mode: ${mode}`); if (!config.profiles[profile]) throw new Error(`Unknown permission profile: ${profile}`); const raw = await readRawConfig(); const rawModes = isRecord(raw.modes) ? raw.modes : {}; rawModes[mode] = { ...config.modes[mode], [zone]: profile, }; raw.modes = rawModes; raw.activeMode = mode; await writeRawConfig(raw); } export function allSensitivePatterns(config: PermissionConfig): string[] { return [...new Set([...DEFAULT_SENSITIVE_PATHS, ...config.zones.sensitive.paths])]; } export function expandHome(path: string): string { if (path === "~") return homedir(); if (path.startsWith("~/") || path.startsWith("~\\")) return join(homedir(), path.slice(2)); return path; }