import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { getAgentDir, isToolCallEventType } from "@earendil-works/pi-coding-agent"; import { spawn, spawnSync } from "node:child_process"; import { existsSync, readFileSync, realpathSync } from "node:fs"; import os from "node:os"; import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { minimatch } from "minimatch"; type PolicyAction = "allow" | "confirm" | "deny" | "refine"; type AlertSoundKind = "confirm" | "block" | "refine"; type ToolName = "bash" | "read" | "write" | "edit"; interface RuleOverride { id?: string; tool?: ToolName; commandRegex?: string; pathGlob?: string; action: PolicyAction; reason?: string; suggest?: string; } interface PolicyGateConfig { workspaceRoots?: string[]; requireAbsolutePathForRecursiveDelete?: boolean; requireAbsolutePathForFindDelete?: boolean; confirmSensitiveReads?: boolean; confirmSensitiveWrites?: boolean; confirmWritesOutsideWorkspace?: boolean; sensitivePathGlobs?: string[]; soundEnabled?: boolean; soundConfirmEnabled?: boolean; soundBlockEnabled?: boolean; soundRefineEnabled?: boolean; soundPlayer?: string; soundDirectory?: string; overrides?: RuleOverride[]; } interface ResolvedConfig { workspaceRoots: string[]; requireAbsolutePathForRecursiveDelete: boolean; requireAbsolutePathForFindDelete: boolean; confirmSensitiveReads: boolean; confirmSensitiveWrites: boolean; confirmWritesOutsideWorkspace: boolean; sensitivePathGlobs: string[]; soundEnabled: boolean; soundConfirmEnabled: boolean; soundBlockEnabled: boolean; soundRefineEnabled: boolean; soundPlayer: string; soundDirectory: string; overrides: RuleOverride[]; configSources: string[]; } interface Decision { action: PolicyAction; title: string; reason: string; suggest?: string; details?: string[]; overrideId?: string; } const HOME = os.homedir(); const AGENT_DIR = getAgentDir(); const PACKAGE_DIR = dirname(fileURLToPath(import.meta.url)); const GLOBAL_CONFIG = resolve(AGENT_DIR, "policy-gate.json"); const PROJECT_CONFIG_NAME = ".pi/policy-gate.json"; const DEFAULT_SOUND_DIR = resolve(PACKAGE_DIR, "assets"); const AUDIO_PLAYER_CANDIDATES = ["paplay", "pw-play", "aplay", "ffplay", "play"]; const DEFAULT_SENSITIVE_PATH_GLOBS = [ "~/.ssh/**", "~/.aws/**", "~/.config/gcloud/**", "~/.azure/**", "~/.gnupg/**", "~/.docker/**", "~/.kube/**", "~/.config/gh/**", "~/.pi/**", "**/.pi/**", "**/.env", "**/.env.*", "**/*.pem", "**/*.key", "**/.netrc", "**/.git-credentials", "~/.gitconfig", "~/.wgetrc", "~/.curlrc", "~/.npmrc", "~/.pypirc", "~/.bashrc", "~/.zshrc", "~/.profile", "~/.bash_profile", "~/.local/bin/**", "~/.config/systemd/**", "**/.git/config", "**/.git/hooks/**", "**/AGENTS.md", "**/CLAUDE.md", "**/.cursorrules", "**/copilot-instructions.md", ]; const SHELL_WRAPPERS = new Set(["bash", "sh", "zsh", "fish"]); const REMOTE_COMMANDS = new Set(["ssh", "scp", "sftp", "rsync", "mosh"]); const NETWORK_COMMANDS = new Set(["curl", "wget", "nc", "ncat", "netcat", "socat", "telnet"]); const PACKAGE_MANAGER_COMMANDS = new Set(["dnf", "yum", "apt", "apt-get", "zypper", "pacman"]); const PLATFORM_CONTROL_COMMANDS = new Set(["docker", "podman", "kubectl", "helm", "mount", "umount", "dd", "crontab", "at"]); const PATH_AWARE_WRITE_COMMANDS = new Set(["cp", "mv", "install", "ln", "touch", "mkdir", "tee", "truncate"]); const PATH_AWARE_READ_COMMANDS = new Set(["cat", "less", "more", "head", "tail", "file", "stat"]); const SENSITIVE_BASENAMES = new Set([ "AGENTS.md", "CLAUDE.md", ".cursorrules", "copilot-instructions.md", ".env", ".netrc", ".git-credentials", ".bashrc", ".zshrc", ".profile", ".bash_profile", "config", ]); const DEFAULT_POLICY_FEATURES = [ "confirm sudo, ssh/scp/rsync/sftp, service changes, package-manager mutations, network clients, and destructive git", "deny only objectively unsafe forms such as wildcard recursive deletes or safety-wrapper bypass attempts", "refine recursive deletes and find -delete into explicit absolute paths", "confirm reads/writes against sensitive files and writes outside the workspace", "confirm bash-based file mutations targeting sensitive paths such as AGENTS.md, .env, shell dotfiles, SSH keys, and local policy files", "play distinct generated sounds for confirmation, deny, and refine outcomes", ]; const DEFAULT_CONFIG: ResolvedConfig = { workspaceRoots: [], requireAbsolutePathForRecursiveDelete: true, requireAbsolutePathForFindDelete: true, confirmSensitiveReads: true, confirmSensitiveWrites: true, confirmWritesOutsideWorkspace: true, sensitivePathGlobs: DEFAULT_SENSITIVE_PATH_GLOBS, soundEnabled: true, soundConfirmEnabled: true, soundBlockEnabled: true, soundRefineEnabled: true, soundPlayer: "auto", soundDirectory: DEFAULT_SOUND_DIR, overrides: [], configSources: [], }; function stripOuterQuotes(value: string): string { if (value.length >= 2) { const first = value[0]; const last = value[value.length - 1]; if ((first === '"' && last === '"') || (first === "'" && last === "'")) { return value.slice(1, -1); } } return value; } function tokenizeShell(command: string): string[] { const matches = command.match(/"(?:\\.|[^"])*"|'(?:\\.|[^'])*'|\S+/g) ?? []; return matches.map((token) => stripOuterQuotes(token)); } function expandHome(pathLike: string): string { return pathLike.startsWith("~/") ? resolve(HOME, pathLike.slice(2)) : pathLike; } function canonicalizePath(absPath: string): string { const resolved = resolve(expandHome(absPath)); try { return realpathSync.native(resolved); } catch { let cursor = resolved; const suffix: string[] = []; while (true) { if (existsSync(cursor)) { try { const real = realpathSync.native(cursor); return resolve(real, ...suffix.reverse()); } catch { return resolve(cursor, ...suffix.reverse()); } } const parent = dirname(cursor); if (parent === cursor) { return resolved; } suffix.push(basename(cursor)); cursor = parent; } } } function resolveConfigPath(pathLike: string, cwd: string): string { const expanded = expandHome(pathLike); return canonicalizePath(isAbsolute(expanded) ? expanded : resolve(cwd, expanded)); } function normalizeForMatch(value: string): string { return value.replace(/\\/g, "/"); } function matchesPathGlob(absPath: string, cwd: string, pattern: string): boolean { const normalizedAbs = normalizeForMatch(absPath); const relativeToCwd = normalizeForMatch(relative(cwd, absPath)); const expanded = expandHome(pattern); const normalizedPattern = normalizeForMatch( isAbsolute(expanded) ? canonicalizePath(expanded) : expanded, ); return ( minimatch(normalizedAbs, normalizedPattern, { dot: true, nocase: false }) || minimatch(relativeToCwd, normalizedPattern, { dot: true, nocase: false }) || minimatch(basename(absPath), normalizedPattern, { dot: true, nocase: false }) ); } function isWithinRoot(absPath: string, root: string): boolean { const rel = relative(root, absPath); return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); } function firstNonOption(tokens: string[], start = 0): number { for (let i = start; i < tokens.length; i += 1) { if (!tokens[i].startsWith("-")) return i; } return -1; } function uniqueStrings(values: string[]): string[] { return Array.from(new Set(values.filter(Boolean))); } function extractShellCodeArg(args: string[]): string | null { const flagIndex = args.findIndex((token) => token === "-c"); if (flagIndex >= 0 && args[flagIndex + 1]) { return args[flagIndex + 1]; } return null; } function isLikelyUrl(token: string): boolean { return /^[a-z][a-z0-9+.-]*:\/\//i.test(token); } function looksLikePathToken(token: string): boolean { if (!token || token === "-" || isLikelyUrl(token)) return false; return ( token.startsWith("/") || token.startsWith("./") || token.startsWith("../") || token.startsWith("~/") || token === "." || token === ".." || token.includes("/") || SENSITIVE_BASENAMES.has(token) ); } function extractRedirectionTargets(command: string): string[] { const targets: string[] = []; const regex = /(?:^|\s)(?:\d*>>?|< !token.startsWith("-") && looksLikePathToken(token)); if (commandName === "tee") { return uniqueStrings([...candidates, ...extractRedirectionTargets(rawCommand)]); } return uniqueStrings([...candidates, ...extractRedirectionTargets(rawCommand)]); } function extractPathAssessment(tool: ToolName, rawPath: string, cwd: string, config: ResolvedConfig) { const absPath = canonicalizePath(isAbsolute(expandHome(rawPath)) ? expandHome(rawPath) : resolve(cwd, rawPath)); const inWorkspace = config.workspaceRoots.some((root) => isWithinRoot(absPath, root)); const sensitive = isSensitivePath(absPath, cwd, config); const details = [ `Resolved path: ${summarizePath(absPath, cwd)}`, `Workspace roots: ${summarizeRoots(config, cwd).join(", ")}`, ]; return { absPath, inWorkspace, sensitive, details }; } function classifyPathAccessFromBash( mode: "read" | "write", commandName: string, pathTokens: string[], cwd: string, config: ResolvedConfig, ): Decision | null { if (pathTokens.length === 0) return null; const assessments = pathTokens.map((token) => ({ token, ...extractPathAssessment(mode, token, cwd, config) })); if (mode === "read") { const sensitive = assessments.filter((item) => item.sensitive); if (sensitive.length > 0 && config.confirmSensitiveReads) { return makeDecision( "confirm", `Confirm ${commandName} sensitive read`, `${commandName} reads a sensitive path.`, sensitive.flatMap((item) => [`Argument: ${item.token}`, ...item.details]), ); } return null; } const sensitiveWrites = assessments.filter((item) => item.sensitive); if (sensitiveWrites.length > 0 && config.confirmSensitiveWrites) { return makeDecision( "confirm", `Confirm ${commandName} sensitive write`, `${commandName} may modify a sensitive path.`, sensitiveWrites.flatMap((item) => [`Argument: ${item.token}`, ...item.details]), ); } const outsideWorkspace = assessments.filter((item) => !item.inWorkspace); if (outsideWorkspace.length > 0 && config.confirmWritesOutsideWorkspace) { return makeDecision( "confirm", `Confirm ${commandName} write outside workspace`, `${commandName} may modify a path outside the configured workspace roots.`, outsideWorkspace.flatMap((item) => [`Argument: ${item.token}`, ...item.details]), ); } return null; } function extractPrimaryCommand(tokens: string[]): { command: string | null; args: string[]; usesSudo: boolean; } { let index = 0; while (index < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[index])) { index += 1; } let usesSudo = false; if (tokens[index] === "sudo") { usesSudo = true; index += 1; while (index < tokens.length) { const token = tokens[index]; if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) { index += 1; continue; } if (token.startsWith("-")) { index += 1; continue; } break; } } if (index >= tokens.length) { return { command: null, args: [], usesSudo }; } return { command: tokens[index], args: tokens.slice(index + 1), usesSudo, }; } function readJsonFile(path: string): T | null { try { return JSON.parse(readFileSync(path, "utf-8")) as T; } catch { return null; } } let detectedAudioPlayer: string | null | undefined; function resolveAudioPlayer(preferred: string): string | null { if (preferred && preferred !== "auto") { return spawnSync(preferred, ["--help"], { stdio: "ignore" }).error ? null : preferred; } if (detectedAudioPlayer !== undefined) return detectedAudioPlayer; for (const candidate of AUDIO_PLAYER_CANDIDATES) { const result = spawnSync(candidate, ["--help"], { stdio: "ignore" }); if (!result.error) { detectedAudioPlayer = candidate; return candidate; } } detectedAudioPlayer = null; return null; } function soundPath(kind: AlertSoundKind, config: ResolvedConfig): string { return join(config.soundDirectory, `${kind}.wav`); } function playAlert(kind: AlertSoundKind, config: ResolvedConfig) { if (!config.soundEnabled) return; if (kind === "confirm" && !config.soundConfirmEnabled) return; if (kind === "block" && !config.soundBlockEnabled) return; if (kind === "refine" && !config.soundRefineEnabled) return; const player = resolveAudioPlayer(config.soundPlayer); if (!player) return; try { const file = soundPath(kind, config); if (!existsSync(file)) return; const args = player === "ffplay" ? ["-nodisp", "-autoexit", "-loglevel", "quiet", file] : player === "play" ? ["-q", file] : [file]; const child = spawn(player, args, { stdio: "ignore", detached: true }); child.unref(); } catch { // best-effort notification only } } function mergeConfig(base: ResolvedConfig, patch: PolicyGateConfig | null, cwd: string, source?: string): ResolvedConfig { if (!patch) return base; return { workspaceRoots: patch.workspaceRoots !== undefined ? patch.workspaceRoots.map((item) => resolveConfigPath(item, cwd)) : base.workspaceRoots, requireAbsolutePathForRecursiveDelete: patch.requireAbsolutePathForRecursiveDelete ?? base.requireAbsolutePathForRecursiveDelete, requireAbsolutePathForFindDelete: patch.requireAbsolutePathForFindDelete ?? base.requireAbsolutePathForFindDelete, confirmSensitiveReads: patch.confirmSensitiveReads ?? base.confirmSensitiveReads, confirmSensitiveWrites: patch.confirmSensitiveWrites ?? base.confirmSensitiveWrites, confirmWritesOutsideWorkspace: patch.confirmWritesOutsideWorkspace ?? base.confirmWritesOutsideWorkspace, sensitivePathGlobs: patch.sensitivePathGlobs ?? base.sensitivePathGlobs, soundEnabled: patch.soundEnabled ?? base.soundEnabled, soundConfirmEnabled: patch.soundConfirmEnabled ?? base.soundConfirmEnabled, soundBlockEnabled: patch.soundBlockEnabled ?? base.soundBlockEnabled, soundRefineEnabled: patch.soundRefineEnabled ?? base.soundRefineEnabled, soundPlayer: patch.soundPlayer ?? base.soundPlayer, soundDirectory: patch.soundDirectory ? resolveConfigPath(patch.soundDirectory, cwd) : base.soundDirectory, overrides: patch.overrides ? [...base.overrides, ...patch.overrides] : base.overrides, configSources: source ? [...base.configSources, source] : base.configSources, }; } function loadConfig(cwd: string, extraConfigPath?: string): ResolvedConfig { let config: ResolvedConfig = { ...DEFAULT_CONFIG, workspaceRoots: [canonicalizePath(cwd)], sensitivePathGlobs: [...DEFAULT_CONFIG.sensitivePathGlobs], overrides: [], configSources: [], }; if (existsSync(GLOBAL_CONFIG)) { config = mergeConfig(config, readJsonFile(GLOBAL_CONFIG), cwd, GLOBAL_CONFIG); } if (extraConfigPath && existsSync(extraConfigPath)) { config = mergeConfig(config, readJsonFile(extraConfigPath), cwd, extraConfigPath); } const projectConfig = resolve(cwd, PROJECT_CONFIG_NAME); if (existsSync(projectConfig)) { config = mergeConfig(config, readJsonFile(projectConfig), cwd, projectConfig); } if (config.workspaceRoots.length === 0) { config.workspaceRoots = [canonicalizePath(cwd)]; } config.workspaceRoots = Array.from(new Set(config.workspaceRoots.map((item) => canonicalizePath(item)))); return config; } function isSensitivePath(absPath: string, cwd: string, config: ResolvedConfig): boolean { return config.sensitivePathGlobs.some((pattern) => matchesPathGlob(absPath, cwd, pattern)); } function summarizePath(absPath: string, cwd: string): string { const rel = relative(cwd, absPath); if (rel === "") return `${absPath} (cwd)`; if (!rel.startsWith("..") && !isAbsolute(rel)) return `${absPath} (./${rel})`; return absPath; } function summarizeRoots(config: ResolvedConfig, cwd: string): string[] { return config.workspaceRoots.map((root) => summarizePath(root, cwd)); } function decisionFromOverride( tool: ToolName, command: string | undefined, path: string | undefined, cwd: string, config: ResolvedConfig, ): Decision | null { let decision: Decision | null = null; for (const rule of config.overrides) { if (rule.tool && rule.tool !== tool) continue; if (rule.commandRegex) { if (!command) continue; let regex: RegExp; try { regex = new RegExp(rule.commandRegex); } catch { continue; } if (!regex.test(command)) continue; } if (rule.pathGlob) { if (!path) continue; if (!matchesPathGlob(path, cwd, rule.pathGlob)) continue; } decision = { action: rule.action, title: `Override${rule.id ? `: ${rule.id}` : ""}`, reason: rule.reason ?? "Matched explicit policy override.", suggest: rule.suggest, overrideId: rule.id, }; } return decision; } function makeDecision(action: PolicyAction, title: string, reason: string, details?: string[], suggest?: string): Decision { return { action, title, reason, details, suggest }; } function hasShellMetacharacters(target: string): boolean { return /[*?[\]{}$`]/.test(target); } function isAmbiguousDeleteTarget(target: string): boolean { const normalized = target.trim(); return ( normalized === "" || normalized === "." || normalized === ".." || normalized === "*" || normalized === ".*" || normalized === "./*" || normalized === "./.*" || normalized === "../*" || normalized === "../.*" || normalized === "~" || normalized === "~/" || normalized === "~/*" || normalized === "/" || normalized === "/*" || hasShellMetacharacters(normalized) ); } function formatDecision(decision: Decision): string { const lines = [decision.reason]; if (decision.details?.length) lines.push(...decision.details); if (decision.suggest) lines.push(`Safer alternative: ${decision.suggest}`); if (decision.overrideId) lines.push(`Matched override: ${decision.overrideId}`); return lines.join("\n"); } function classifyRm(args: string[], cwd: string, config: ResolvedConfig): Decision { const options = args.filter((token) => token.startsWith("-")); const targets = args.filter((token) => !token.startsWith("-")); const recursive = options.some((token) => token.includes("r") || token.includes("R") || token === "--recursive"); const force = options.some((token) => token.includes("f") || token === "--force"); const destructive = recursive || force; if (targets.length === 0) { return makeDecision( "refine", "Refine rm command", "Deletion command has no explicit target.", undefined, "Specify the exact path you want to remove.", ); } if (targets.some(isAmbiguousDeleteTarget)) { return makeDecision( "deny", "Blocked ambiguous delete", "Wildcard or broad delete target detected.", ["Recursive or forceful deletes must not use *, ., .., ~, /, or shell expansions as targets."], "Replace it with an explicit absolute path and preview the target first if needed.", ); } if (destructive && config.requireAbsolutePathForRecursiveDelete && targets.some((target) => !isAbsolute(expandHome(target)))) { return makeDecision( "refine", "Refine recursive delete", "Recursive or forceful delete requires an explicit absolute path.", targets.map((target) => `Provided target: ${target}`), "Use an absolute path such as /full/path/to/target.", ); } const resolvedTargets = targets.map((target) => canonicalizePath(isAbsolute(expandHome(target)) ? expandHome(target) : resolve(cwd, target))); if (resolvedTargets.some((target) => target === "/" || target === HOME)) { return makeDecision( "deny", "Blocked dangerous delete", "Deletion target resolves to the filesystem root or your home directory.", resolvedTargets.map((target) => `Resolved target: ${target}`), "Choose a narrower explicit path.", ); } if (destructive) { return makeDecision( "confirm", "Confirm recursive delete", "Recursive or forceful delete requested.", resolvedTargets.map((target) => `Resolved target: ${summarizePath(target, cwd)}`), ); } return makeDecision( "allow", "Allow rm", "Non-recursive delete with explicit target inside normal shell execution.", ); } function classifyFind(args: string[], cwd: string, config: ResolvedConfig): Decision { if (!args.includes("-delete")) { return makeDecision("allow", "Allow find", "Read-only find usage."); } const pathIndex = firstNonOption(args, 0); const base = pathIndex >= 0 ? args[pathIndex] : null; if (!base || base === "." || base === "..") { return makeDecision( "refine", "Refine find -delete", "find -delete requires an explicit absolute base path.", undefined, "Use find /absolute/path -name 'pattern' -delete after previewing with -print.", ); } if (config.requireAbsolutePathForFindDelete && !isAbsolute(expandHome(base))) { return makeDecision( "refine", "Refine find -delete", "find -delete requires an explicit absolute base path.", [`Provided base path: ${base}`], "Use find /absolute/path ... -delete.", ); } const resolved = canonicalizePath(expandHome(base)); if (resolved === "/" || resolved === HOME) { return makeDecision( "deny", "Blocked dangerous find -delete", "find -delete targets the filesystem root or home directory.", [`Resolved base path: ${resolved}`], "Restrict it to a narrower absolute path.", ); } return makeDecision( "confirm", "Confirm find -delete", "find -delete can remove many files and should be reviewed.", [`Resolved base path: ${summarizePath(resolved, cwd)}`], "Prefer running the same find command with -print first.", ); } function classifyGit(args: string[]): Decision { const raw = args.join(" "); const sub = args[0] ?? ""; if (sub === "push") { const forced = /(^|\s)(--force|-f|--force-with-lease)(\s|$)/.test(raw); return makeDecision( "confirm", forced ? "Confirm force push" : "Confirm git push", forced ? "git push with force semantics requested." : "git push requested.", ); } if (sub === "reset" && args.includes("--hard")) { return makeDecision("confirm", "Confirm git reset --hard", "Destructive git reset requested."); } if (sub === "clean" && args.some((token) => /^-[^-]*f/.test(token) || token === "--force")) { return makeDecision("confirm", "Confirm git clean", "Potentially destructive git clean requested."); } if (sub === "restore" || sub === "checkout") { return makeDecision("confirm", `Confirm git ${sub}`, `Potentially destructive git ${sub} requested.`); } if (sub === "stash" && ["drop", "clear", "pop"].includes(args[1] ?? "")) { return makeDecision("confirm", `Confirm git stash ${args[1]}`, `Potentially destructive git stash ${args[1]} requested.`); } return makeDecision("allow", "Allow git", "Non-destructive git command."); } function classifySystemCtl(args: string[]): Decision { const sub = args[0] ?? ""; if (["start", "stop", "restart", "reload", "enable", "disable", "mask", "unmask"].includes(sub)) { return makeDecision("confirm", `Confirm systemctl ${sub}`, `systemctl ${sub} can affect system services.`); } return makeDecision("allow", "Allow systemctl", "Read-only or low-risk systemctl usage."); } function classifyPackageManager(command: string, args: string[]): Decision { const sub = args[0] ?? ""; if (["install", "remove", "uninstall", "upgrade", "update", "downgrade", "autoremove"].includes(sub)) { return makeDecision("confirm", `Confirm ${command} ${sub}`, `${command} ${sub} can change system or environment state.`); } return makeDecision("allow", `Allow ${command}`, `Low-risk ${command} usage.`); } function classifyChmodLike(command: string, args: string[]): Decision { if (args.some((token) => token.startsWith("-R") || token === "--recursive")) { return makeDecision("confirm", `Confirm ${command} -R`, `Recursive ${command} can affect many files.`); } return makeDecision("confirm", `Confirm ${command}`, `${command} changes file ownership or permissions.`); } function classifyBash(command: string, cwd: string, config: ResolvedConfig): Decision { if (/SAFEEXEC_DISABLED\s*=\s*1/.test(command) || /\bsafeexec\s+-off\b/.test(command) || /\.safeexec\.real\b/.test(command)) { return makeDecision( "deny", "Blocked policy bypass", "Command appears to disable or bypass a host safety wrapper.", undefined, "Ask the human before changing safety controls.", ); } const tokens = tokenizeShell(command); const primary = extractPrimaryCommand(tokens); const cmd = primary.command; if (!cmd) { return makeDecision("allow", "Allow bash", "Empty or non-standard shell command."); } if (primary.usesSudo) { const nestedDecision = classifyBash(primary.args.join(" "), cwd, config); if (nestedDecision.action === "deny" || nestedDecision.action === "refine") { return nestedDecision; } return makeDecision( "confirm", "Confirm sudo command", "sudo requires explicit human review.", [`Nested command: ${primary.command} ${primary.args.join(" ")}`.trim()], nestedDecision.action === "confirm" ? nestedDecision.suggest : undefined, ); } if (SHELL_WRAPPERS.has(cmd)) { const nestedCode = extractShellCodeArg(primary.args); if (nestedCode) { return classifyBash(nestedCode, cwd, config); } } if (cmd === "rm") return classifyRm(primary.args, cwd, config); if (cmd === "find") return classifyFind(primary.args, cwd, config); if (cmd === "git") return classifyGit(primary.args); if (cmd === "systemctl") return classifySystemCtl(primary.args); if (PACKAGE_MANAGER_COMMANDS.has(cmd)) { return classifyPackageManager(cmd, primary.args); } if (["chmod", "chown"].includes(cmd)) { return classifyChmodLike(cmd, primary.args); } if (REMOTE_COMMANDS.has(cmd)) { return makeDecision("confirm", `Confirm ${cmd}`, `${cmd} can affect remote systems or transfer data.`); } if (NETWORK_COMMANDS.has(cmd)) { return makeDecision("confirm", `Confirm ${cmd}`, `${cmd} can reach external systems or open raw network channels.`); } if (["env", "printenv"].includes(cmd)) { return makeDecision("confirm", `Confirm ${cmd}`, `${cmd} can reveal credentials or sensitive environment data.`); } if (PLATFORM_CONTROL_COMMANDS.has(cmd)) { return makeDecision("confirm", `Confirm ${cmd}`, `${cmd} can affect containers, clusters, scheduled tasks, devices, or mounts.`); } if (cmd === "sed" && primary.args.some((token) => token === "-i" || token.startsWith("-i"))) { const pathDecision = classifyPathAccessFromBash("write", cmd, extractCandidatePathTokens(cmd, primary.args, command), cwd, config); if (pathDecision) return pathDecision; return makeDecision("confirm", "Confirm sed -i", "In-place sed edit requested."); } if (cmd === "perl" && primary.args.some((token) => token === "-pi" || token.startsWith("-pi"))) { const pathDecision = classifyPathAccessFromBash("write", cmd, extractCandidatePathTokens(cmd, primary.args, command), cwd, config); if (pathDecision) return pathDecision; return makeDecision("confirm", "Confirm perl -pi", "In-place perl edit requested."); } if (PATH_AWARE_WRITE_COMMANDS.has(cmd)) { const pathDecision = classifyPathAccessFromBash("write", cmd, extractCandidatePathTokens(cmd, primary.args, command), cwd, config); if (pathDecision) return pathDecision; } if (PATH_AWARE_READ_COMMANDS.has(cmd)) { const pathDecision = classifyPathAccessFromBash("read", cmd, extractCandidatePathTokens(cmd, primary.args, command), cwd, config); if (pathDecision) return pathDecision; } return makeDecision("allow", "Allow bash", "No risky pattern matched by the default policy."); } function classifyPathTool(tool: ToolName, rawPath: string, cwd: string, config: ResolvedConfig): Decision { const { inWorkspace, sensitive, details } = extractPathAssessment(tool, rawPath, cwd, config); if (tool === "read") { if (sensitive && config.confirmSensitiveReads) { return makeDecision("confirm", "Confirm sensitive read", "Read targets a sensitive path.", details); } return makeDecision("allow", "Allow read", "Read path does not match a sensitive pattern."); } if (sensitive && config.confirmSensitiveWrites) { return makeDecision("confirm", "Confirm sensitive write", `${tool} targets a sensitive path.`, details); } if (!inWorkspace && config.confirmWritesOutsideWorkspace) { return makeDecision("confirm", "Confirm write outside workspace", `${tool} targets a path outside the configured workspace roots.`, details); } return makeDecision("allow", `Allow ${tool}`, `${tool} stays within the configured workspace roots.`); } function buildConfirmationMessage(tool: ToolName, payload: string, decision: Decision, cwd: string): string { const lines = [ decision.reason, `Tool: ${tool}`, `CWD: ${cwd}`, `Payload: ${payload}`, ...(decision.details ?? []), ]; if (decision.suggest) lines.push(`Safer alternative: ${decision.suggest}`); return lines.join("\n"); } export default function policyGate(pi: ExtensionAPI) { pi.registerFlag("policy-gate-config", { description: "Path to an extra JSON config file for @enne2/pi-policy-gate", type: "string", }); let activeConfig: ResolvedConfig = loadConfig(process.cwd()); pi.on("session_start", async (_event, ctx) => { const extraConfig = pi.getFlag("policy-gate-config") as string | undefined; activeConfig = loadConfig(ctx.cwd, extraConfig ? resolveConfigPath(extraConfig, ctx.cwd) : undefined); if (ctx.hasUI) { ctx.ui.setStatus("policy-gate", ctx.ui.theme.fg("accent", "policy-gate active")); } }); pi.on("before_agent_start", async (event) => { const roots = activeConfig.workspaceRoots.join(", "); return { systemPrompt: event.systemPrompt + "\n\n[policy-gate]\n" + `- Workspace roots currently protected: ${roots}.\n` + "- For recursive or forceful deletes, use an explicit absolute path, never wildcards or broad targets.\n" + "- If a command is blocked or requires refinement, propose a safer reformulation instead of trying to bypass the gate.\n" + "- Treat sudo, ssh, rsync, scp, service changes, destructive git operations, raw network clients, and environment dumps as review-worthy actions.\n" + "- Be especially careful with AGENTS.md, CLAUDE.md, .env files, shell dotfiles, SSH keys, and local policy files.\n", }; }); pi.registerCommand("policy-gate", { description: "Show active @enne2/pi-policy-gate settings", handler: async (_args, ctx) => { const summary = [ "@enne2/pi-policy-gate", `Workspace roots: ${activeConfig.workspaceRoots.join(", ")}`, `Config sources: ${activeConfig.configSources.join(", ") || "defaults only"}`, `Overrides: ${activeConfig.overrides.length}`, `Sensitive reads require confirm: ${activeConfig.confirmSensitiveReads}`, `Sensitive writes require confirm: ${activeConfig.confirmSensitiveWrites}`, `Writes outside workspace require confirm: ${activeConfig.confirmWritesOutsideWorkspace}`, `Recursive delete requires absolute path: ${activeConfig.requireAbsolutePathForRecursiveDelete}`, `Sound alerts enabled: ${activeConfig.soundEnabled}`, `Confirm sound enabled: ${activeConfig.soundConfirmEnabled}`, `Block sound enabled: ${activeConfig.soundBlockEnabled}`, `Refine sound enabled: ${activeConfig.soundRefineEnabled}`, `Sound player: ${resolveAudioPlayer(activeConfig.soundPlayer) ?? "none"}`, `Sound directory: ${activeConfig.soundDirectory}`, "Built-in default policy:", ...DEFAULT_POLICY_FEATURES.map((feature) => `- ${feature}`), ].join("\n"); if (ctx.hasUI) ctx.ui.notify(summary, "info"); else console.log(summary); }, }); pi.registerCommand("policy-gate-sound", { description: "Play the confirmation, block, or refine alert sound (usage: /policy-gate-sound [confirm|block|refine])", handler: async (args, ctx) => { const normalized = args?.trim(); const kind: AlertSoundKind = normalized === "block" || normalized === "refine" ? normalized : "confirm"; playAlert(kind, activeConfig); const message = `Played ${kind} alert with ${resolveAudioPlayer(activeConfig.soundPlayer) ?? "no player"}.`; if (ctx.hasUI) ctx.ui.notify(message, "info"); else console.log(message); }, }); pi.on("tool_call", async (event, ctx) => { const cwd = ctx.cwd; let decision: Decision | null = null; let payload = ""; let tool: ToolName | null = null; let override: Decision | null = null; if (isToolCallEventType("bash", event)) { tool = "bash"; payload = String(event.input.command ?? ""); decision = classifyBash(payload, cwd, activeConfig); override = decisionFromOverride(tool, payload, undefined, cwd, activeConfig); } else if (isToolCallEventType("read", event)) { tool = "read"; const rawPath = String(event.input.path ?? ""); const absPath = canonicalizePath(isAbsolute(expandHome(rawPath)) ? expandHome(rawPath) : resolve(cwd, rawPath)); payload = rawPath; decision = classifyPathTool(tool, rawPath, cwd, activeConfig); override = decisionFromOverride(tool, undefined, absPath, cwd, activeConfig); } else if (isToolCallEventType("write", event)) { tool = "write"; const rawPath = String(event.input.path ?? ""); const absPath = canonicalizePath(isAbsolute(expandHome(rawPath)) ? expandHome(rawPath) : resolve(cwd, rawPath)); payload = rawPath; decision = classifyPathTool(tool, rawPath, cwd, activeConfig); override = decisionFromOverride(tool, undefined, absPath, cwd, activeConfig); } else if (isToolCallEventType("edit", event)) { tool = "edit"; const rawPath = String(event.input.path ?? ""); const absPath = canonicalizePath(isAbsolute(expandHome(rawPath)) ? expandHome(rawPath) : resolve(cwd, rawPath)); payload = rawPath; decision = classifyPathTool(tool, rawPath, cwd, activeConfig); override = decisionFromOverride(tool, undefined, absPath, cwd, activeConfig); } if (!tool || !decision) return undefined; if (override) decision = override; if (decision.action === "allow") return undefined; if (decision.action === "deny") { playAlert("block", activeConfig); return { block: true, reason: formatDecision(decision) }; } if (decision.action === "refine") { playAlert("refine", activeConfig); return { block: true, reason: formatDecision(decision) }; } playAlert("confirm", activeConfig); if (!ctx.hasUI) { return { block: true, reason: `Confirmation required but no interactive UI is available.\n${formatDecision(decision)}`, }; } const choice = await ctx.ui.select( `${decision.title}\n\n${buildConfirmationMessage(tool, payload, decision, cwd)}`, ["Allow once", "Block"], ); if (choice !== "Allow once") { return { block: true, reason: formatDecision(decision) }; } return undefined; }); }