diff --git a/dot_pi/agent/extensions/guardrails.json b/dot_pi/agent/extensions/guardrails.json new file mode 100644 index 00000000..fbca63c2 --- /dev/null +++ b/dot_pi/agent/extensions/guardrails.json @@ -0,0 +1,241 @@ +{ + "$schema": "https://unpkg.com/@aliou/pi-guardrails@0.13.2/schema.json", + "enabled": true, + "applyBuiltinDefaults": true, + "onboarding": { + "completed": true + }, + "features": { + "policies": true, + "permissionGate": true, + "pathAccess": false + }, + "policies": { + "rules": [ + { + "id": "joe-secrets", + "name": "Credentials and secrets", + "description": "Migrated from path-policy.json blockedPaths", + "protection": "noAccess", + "onlyIfExists": false, + "blockMessage": "Accessing {file} is not allowed: it may contain secrets or credentials. Explain why you need it and ask the user to act if changes are required.", + "patterns": [ + { + "pattern": "**/*.key" + }, + { + "pattern": "**/*.p12" + }, + { + "pattern": "**/*.pem" + }, + { + "pattern": "**/*.pfx" + }, + { + "pattern": "**/.aws/**" + }, + { + "pattern": "**/.azure/**" + }, + { + "pattern": "**/.config/gcloud/**" + }, + { + "pattern": "**/.config/gh/hosts.yml" + }, + { + "pattern": "**/.env*" + }, + { + "pattern": "**/.gem/credentials" + }, + { + "pattern": "**/.git-credentials" + }, + { + "pattern": "**/.kube/**" + }, + { + "pattern": "**/.netrc" + }, + { + "pattern": "**/.npmrc" + }, + { + "pattern": "**/.pypirc" + }, + { + "pattern": "**/.secret*" + }, + { + "pattern": "**/.terraform.d/credentials.tfrc.json" + }, + { + "pattern": "**/.yarnrc.yml" + }, + { + "pattern": "**/Obsidian/journal/**" + }, + { + "pattern": "**/credentials/**" + }, + { + "pattern": "**/id_ed25519" + }, + { + "pattern": "**/id_rsa" + }, + { + "pattern": "**/pip.conf" + }, + { + "pattern": "**/secrets/**" + }, + { + "pattern": "~/.aws/**" + }, + { + "pattern": "~/.azure/**" + }, + { + "pattern": "~/.codex/auth.json" + }, + { + "pattern": "~/.config/exercism/user.json" + }, + { + "pattern": "~/.config/gcloud/**" + }, + { + "pattern": "~/.config/gcx/config.yaml" + }, + { + "pattern": "~/.config/gh/hosts.yml" + }, + { + "pattern": "~/.config/github-copilot/**" + }, + { + "pattern": "~/.config/gws/**" + }, + { + "pattern": "~/.config/pip/pip.conf" + }, + { + "pattern": "~/.config/save-to-spotify/**" + }, + { + "pattern": "~/.fly/**" + }, + { + "pattern": "~/.gem/credentials" + }, + { + "pattern": "~/.git-credentials" + }, + { + "pattern": "~/.gnupg/**" + }, + { + "pattern": "~/.kube/**" + }, + { + "pattern": "~/.netrc" + }, + { + "pattern": "~/.npmrc" + }, + { + "pattern": "~/.pi/auth.json" + }, + { + "pattern": "~/.pypirc" + }, + { + "pattern": "~/.ssh/**" + }, + { + "pattern": "~/.terraform.d/credentials.tfrc.json" + }, + { + "pattern": "~/.config/op/**" + }, + { + "pattern": "~/.config/sops/**" + }, + { + "pattern": "~/*.gpg" + }, + { + "pattern": "~/.gpg-agent.conf" + } + ], + "allowedPatterns": [ + { + "pattern": "~/.ssh/*.pub" + } + ] + } + ] + }, + "permissionGate": { + "requireConfirmation": true, + "patterns": [ + { + "pattern": "chmod\\b.*o\\+w", + "description": "world-writable permission (o+w)", + "regex": true + }, + { + "pattern": "(psql|pgcli|mysql|mariadb|sqlite3|duckdb|sqlcmd|clickhouse-client)\\b[\\s\\S]*\\b(DROP\\s+(TABLE|DATABASE|SCHEMA|VIEW|INDEX|ROLE|USER|EXTENSION)|TRUNCATE(\\s+TABLE)?|DELETE\\s+FROM)\\b", + "description": "destructive SQL statement", + "regex": true + }, + { + "pattern": "git\\s+clean\\b.*-[a-z]*f", + "description": "git clean (removes untracked files)", + "regex": true + }, + { + "pattern": "git\\s+reset\\b.*--hard", + "description": "git reset --hard (loses uncommitted changes)", + "regex": true + }, + { + "pattern": "find\\b.*-delete\\b", + "description": "file deletion (find -delete)", + "regex": true + }, + { + "pattern": "find\\b.*-exec\\s+rm\\b", + "description": "file deletion (find -exec rm)", + "regex": true + }, + { + "pattern": "(curl|wget)\\b[^|]*\\|\\s*(ba)?sh\\b", + "description": "remote code execution (pipe to shell)", + "regex": true + }, + { + "pattern": ">\\s*/dev/sd[a-z]", + "description": "write to raw block device", + "regex": true + } + ], + "autoDenyPatterns": [ + { + "pattern": "rm\\s+(-[a-zA-Z]+\\s+)*(/|~|\\$HOME|\\$\\{HOME\\})(\\s|$)", + "description": "catastrophic rm target (/ or home)", + "regex": true + }, + { + "pattern": "rm\\s+(-[a-zA-Z]+\\s+)*(/|~|\\$HOME|\\$\\{HOME\\}|\\.|\\.\\.)/\\*", + "description": "catastrophic rm wildcard target", + "regex": true + } + ], + "allowedPatterns": [] + }, + "version": "0.9.0-20260327" +} diff --git a/dot_pi/agent/extensions/path-policy/index.ts b/dot_pi/agent/extensions/path-policy/index.ts deleted file mode 100644 index b943cec4..00000000 --- a/dot_pi/agent/extensions/path-policy/index.ts +++ /dev/null @@ -1,631 +0,0 @@ -/** - * Path Policy Extension - * - * Blocks read/edit/write access for selected path patterns without enabling - * full OS-level sandboxing. - * - * Config files (merged, project takes precedence): - * - ~/.pi/agent/path-policy.json (global) - * - /.pi/path-policy.json (project-local) - * - * Example .pi/path-policy.json: - * { - * "enabled": true, - * "blockedPaths": [".env", ".env.*", "**\/.env", "secrets/\*\*", "config/private.json"], - * "blockedTools": ["read", "edit", "write"], - * "guardBash": true, - * "audit": true - * } - * - * Glob notes: - * - `*` matches within one path segment (no `/`) - * - `**` matches across directories - * - `?` matches one character in a segment - */ - -import { - appendFileSync, - existsSync, - mkdirSync, - readFileSync, -} from "node:fs"; -import { homedir } from "node:os"; -import { dirname, join, posix, relative, resolve } from "node:path"; -import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; - -type BlockedTool = "read" | "edit" | "write"; -type PolicyDecision = { - blocked: boolean; - rule?: string; - normalizedPath: string; -}; - -interface PathPolicyConfig { - enabled: boolean; - blockedPaths: string[]; - blockedTools: BlockedTool[]; - guardBash: boolean; - audit: boolean; -} - -interface CompiledRule { - raw: string; - normalized: string; - kind: "exact" | "prefix" | "glob"; - regex?: RegExp; -} - -const BLOCKED_TOOL_VALUES: readonly BlockedTool[] = ["read", "edit", "write"]; -const LOG_FILE = join(homedir(), ".pi", "agent", "path-policy.log"); - -const DEFAULT_CONFIG: PathPolicyConfig = { - enabled: true, - blockedPaths: [".env", ".env.*", "secrets/", ".git/", ".ssh/", "**/*id_rsa*", ".pi/"], - blockedTools: ["read", "edit", "write"], - guardBash: false, - audit: false, -}; - -const BASH_FILE_OP_HINT = - /\b(cat|less|more|head|tail|grep|rg|sed|awk|tee|cp|mv|rm|touch|truncate|nano|vim|vi|chmod|chown|chgrp|ln|mkdir|rmdir|find)\b|>>?|< 1 && normalized.endsWith("/")) { - normalized = normalized.slice(0, -1); - } - return normalized; -} - -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -function globToRegexSource(globPattern: string): string { - let source = escapeRegExp(globPattern); - - // `**/foo` should match both `foo` and `a/b/foo`. - source = source.replace(/^\\\*\\\*\//, "(?:.*/)?"); - - // `foo/**` should match both `foo` and descendants. - source = source.replace(/\/\\\*\\\*$/, "(?:/.*)?"); - - // `**` crosses directories, `*` stays within a segment. - source = source.replace(/\\\*\\\*/g, ".*"); - source = source.replace(/\\\*/g, "[^/]*"); - - // `?` matches a single character in a segment. - source = source.replace(/\\\?/g, "[^/]"); - - return source; -} - -function compileRule(pattern: string): CompiledRule | null { - const raw = pattern.trim(); - if (!raw) return null; - - const normalized = normalizePath(raw); - if (!normalized) return null; - - const hasGlob = normalized.includes("*") || normalized.includes("?"); - if (hasGlob) { - const regexSource = globToRegexSource(normalized); - return { - raw, - normalized, - kind: "glob", - regex: new RegExp(`^${regexSource}$`), - }; - } - - if (raw.endsWith("/")) { - return { raw, normalized, kind: "prefix" }; - } - - return { raw, normalized, kind: "exact" }; -} - -function matchesRule(rule: CompiledRule, candidate: string): boolean { - switch (rule.kind) { - case "exact": - return candidate === rule.normalized; - case "prefix": - return ( - candidate === rule.normalized || - candidate.startsWith(`${rule.normalized}/`) - ); - case "glob": - return rule.regex?.test(candidate) ?? false; - } -} - -function isBlockedTool(value: string): value is BlockedTool { - return BLOCKED_TOOL_VALUES.includes(value as BlockedTool); -} - -function parseStringArray(value: unknown): string[] | undefined { - if (!Array.isArray(value)) return undefined; - const result = value - .filter((item): item is string => typeof item === "string") - .map((item) => item.trim()); - return result.length > 0 ? result : []; -} - -function parseBlockedTools(value: unknown): BlockedTool[] | undefined { - const values = parseStringArray(value); - if (values === undefined) return undefined; - const tools = values.filter((tool): tool is BlockedTool => - isBlockedTool(tool), - ); - return tools.length > 0 ? tools : []; -} - -function parseConfig(raw: unknown): Partial { - if (!raw || typeof raw !== "object") return {}; - - const obj = raw as Record; - const blockedPaths = parseStringArray(obj.blockedPaths); - const blockedTools = parseBlockedTools(obj.blockedTools); - - return { - enabled: typeof obj.enabled === "boolean" ? obj.enabled : undefined, - blockedPaths, - blockedTools, - guardBash: typeof obj.guardBash === "boolean" ? obj.guardBash : undefined, - audit: typeof obj.audit === "boolean" ? obj.audit : undefined, - }; -} - -function mergeConfig( - base: PathPolicyConfig, - overrides: Partial, -): PathPolicyConfig { - return { - enabled: overrides.enabled ?? base.enabled, - blockedPaths: overrides.blockedPaths ?? base.blockedPaths, - blockedTools: overrides.blockedTools ?? base.blockedTools, - guardBash: overrides.guardBash ?? base.guardBash, - audit: overrides.audit ?? base.audit, - }; -} - -function loadConfig(cwd: string): PathPolicyConfig { - const globalPath = join(homedir(), ".pi", "agent", "path-policy.json"); - const projectPath = join(cwd, ".pi", "path-policy.json"); - - let globalConfig: Partial = {}; - let projectConfig: Partial = {}; - - if (existsSync(globalPath)) { - try { - globalConfig = parseConfig(JSON.parse(readFileSync(globalPath, "utf-8"))); - } catch (error) { - console.error(`Warning: failed to parse ${globalPath}: ${error}`); - } - } - - if (existsSync(projectPath)) { - try { - projectConfig = parseConfig( - JSON.parse(readFileSync(projectPath, "utf-8")), - ); - } catch (error) { - console.error(`Warning: failed to parse ${projectPath}: ${error}`); - } - } - - return mergeConfig(mergeConfig(DEFAULT_CONFIG, globalConfig), projectConfig); -} - -function resolveCandidates(cwd: string, inputPath: string): string[] { - const cleanedInput = stripPathPrefix(inputPath); - const rawCandidate = normalizePath(cleanedInput); - - const absolute = normalizePath(resolve(cwd, cleanedInput)); - const rel = normalizePath(relative(cwd, absolute)); - - const candidates = new Set(); - if (rawCandidate) candidates.add(rawCandidate); - if (absolute) candidates.add(absolute); - - const isOutsideWorkspace = - rel === ".." || rel.startsWith("../") || rel.startsWith("..\\") || rel === "."; - if (rel && !isOutsideWorkspace) { - candidates.add(rel); - } - - return Array.from(candidates); -} - -function evaluatePath( - pathValue: string, - cwd: string, - rules: CompiledRule[], -): PolicyDecision { - const normalizedPath = normalizePath(pathValue); - if (!normalizedPath) { - return { blocked: false, normalizedPath }; - } - - const candidates = resolveCandidates(cwd, pathValue); - - for (const rule of rules) { - for (const candidate of candidates) { - if (matchesRule(rule, candidate)) { - return { - blocked: true, - rule: rule.raw, - normalizedPath: candidate, - }; - } - } - } - - return { blocked: false, normalizedPath }; -} - -function logDecision( - toolName: string, - pathValue: string, - allowed: boolean, - reason?: string, -) { - const timestamp = new Date().toISOString(); - const status = allowed ? "ALLOWED" : "BLOCKED"; - const reasonSuffix = reason ? ` (${reason})` : ""; - const line = `[${timestamp}] ${status} ${toolName}: ${pathValue}${reasonSuffix}\n`; - - try { - mkdirSync(dirname(LOG_FILE), { recursive: true }); - appendFileSync(LOG_FILE, line); - } catch { - // Best-effort logging only. - } -} - -function extractPathFromInput(input: unknown): string | undefined { - if (!input || typeof input !== "object") return undefined; - const maybePath = (input as Record).path; - return typeof maybePath === "string" ? stripPathPrefix(maybePath) : undefined; -} - -function extractCommandFromInput(input: unknown): string | undefined { - if (!input || typeof input !== "object") return undefined; - const maybeCommand = (input as Record).command; - return typeof maybeCommand === "string" ? maybeCommand : undefined; -} - -function splitShellSubcommands(command: string): string[] { - const commands: string[] = []; - let current = ""; - - let inSingle = false; - let inDouble = false; - let escaped = false; - - for (let i = 0; i < command.length; i++) { - const ch = command[i]; - const next = command[i + 1]; - - if (escaped) { - current += ch; - escaped = false; - continue; - } - - if (ch === "\\" && !inSingle) { - current += ch; - escaped = true; - continue; - } - - if (ch === "'" && !inDouble) { - inSingle = !inSingle; - current += ch; - continue; - } - - if (ch === '"' && !inSingle) { - inDouble = !inDouble; - current += ch; - continue; - } - - if (!inSingle && !inDouble) { - const isSeparator = - ch === ";" || ch === "\n" || ch === "|" || ch === "&"; - - if (isSeparator) { - const trimmed = current.trim(); - if (trimmed.length > 0) commands.push(trimmed); - current = ""; - - const isDoubleSep = - (ch === "&" && next === "&") || (ch === "|" && next === "|"); - if (isDoubleSep) i += 1; - continue; - } - } - - current += ch; - } - - const tail = current.trim(); - if (tail.length > 0) commands.push(tail); - - return commands.length > 0 ? commands : [command.trim()].filter(Boolean); -} - -function tokenizeShellCommand(command: string): string[] { - const tokens: string[] = []; - let current = ""; - - let inSingle = false; - let inDouble = false; - let escaped = false; - - const pushCurrent = () => { - if (!current) return; - tokens.push(current); - current = ""; - }; - - for (let i = 0; i < command.length; i++) { - const ch = command[i]; - const next = command[i + 1]; - - if (escaped) { - current += ch; - escaped = false; - continue; - } - - if (ch === "\\" && !inSingle) { - escaped = true; - continue; - } - - if (ch === "'" && !inDouble) { - inSingle = !inSingle; - continue; - } - - if (ch === '"' && !inSingle) { - inDouble = !inDouble; - continue; - } - - if (!inSingle && !inDouble) { - if (/\s/.test(ch)) { - pushCurrent(); - continue; - } - - const twoChar = `${ch}${next ?? ""}`; - if (["&&", "||", ">>", "<<"].includes(twoChar)) { - pushCurrent(); - tokens.push(twoChar); - i += 1; - continue; - } - - if (["|", ";", "&", "(", ")", ">", "<"].includes(ch)) { - pushCurrent(); - tokens.push(ch); - continue; - } - } - - current += ch; - } - - pushCurrent(); - return tokens; -} - -function isEnvAssignment(token: string): boolean { - return /^[A-Za-z_][A-Za-z0-9_]*=.*/.test(token); -} - -function isRedirectionToken(token: string): boolean { - if ([">", ">>", "<", "<<"].includes(token)) return true; - return /^[0-9]*>>?$/.test(token) || /^[0-9]*< file and >file - if (isRedirectionToken(token)) { - const next = tokens[i + 1]; - if (next && looksLikePathToken(next, cwd)) { - paths.push(stripPathPrefix(next)); - i += 1; - } - continue; - } - - if (token.startsWith(">") || token.startsWith("<")) { - const redirected = stripPathPrefix(token.slice(1)); - if (redirected && looksLikePathToken(redirected, cwd)) { - paths.push(redirected); - } - continue; - } - - if (token.startsWith("-")) continue; - if (isEnvAssignment(token)) { - const equalsIndex = token.indexOf("="); - const rhs = stripPathPrefix(token.slice(equalsIndex + 1)); - if (rhs && looksLikePathToken(rhs, cwd)) paths.push(rhs); - continue; - } - - if (looksLikePathToken(token, cwd)) { - paths.push(stripPathPrefix(token)); - } - } - } - - return [...new Set(paths)]; -} - -export default function pathPolicyExtension(pi: ExtensionAPI) { - let config: PathPolicyConfig = DEFAULT_CONFIG; - let rules: CompiledRule[] = DEFAULT_CONFIG.blockedPaths - .map(compileRule) - .filter((rule): rule is CompiledRule => rule !== null); - - const refreshConfig = (cwd: string) => { - config = loadConfig(cwd); - rules = config.blockedPaths - .map(compileRule) - .filter((rule): rule is CompiledRule => rule !== null); - }; - - pi.on("session_start", async (_event, ctx) => { - refreshConfig(ctx.cwd); - }); - - pi.on("tool_call", async (event, ctx) => { - // Keep config live without requiring /reload or session restart. - refreshConfig(ctx.cwd); - - if (!config.enabled) return undefined; - - if ( - isBlockedTool(event.toolName) && - config.blockedTools.includes(event.toolName) - ) { - const pathValue = extractPathFromInput(event.input); - if (!pathValue) return undefined; - - const decision = evaluatePath(pathValue, ctx.cwd, rules); - if (!decision.blocked) { - if (config.audit) - logDecision(event.toolName, decision.normalizedPath || pathValue, true); - return undefined; - } - - const reason = `Path "${pathValue}" matches blocked rule "${decision.rule}"`; - if (config.audit) - logDecision( - event.toolName, - decision.normalizedPath || pathValue, - false, - reason, - ); - if (ctx.hasUI) { - ctx.ui.notify(`Blocked ${event.toolName}: ${pathValue}`, "warning"); - } - return { block: true, reason }; - } - - if (config.guardBash && event.toolName === "bash") { - const command = extractCommandFromInput(event.input); - if (!command || !BASH_FILE_OP_HINT.test(command)) return undefined; - - const candidatePaths = extractPossiblePathsFromCommand(command, ctx.cwd); - for (const candidatePath of candidatePaths) { - const decision = evaluatePath(candidatePath, ctx.cwd, rules); - if (!decision.blocked) continue; - - const reason = `bash command touches blocked path "${candidatePath}" (rule "${decision.rule}")`; - if (config.audit) logDecision("bash", candidatePath, false, reason); - if (ctx.hasUI) { - ctx.ui.notify( - `Blocked bash path access: ${candidatePath}`, - "warning", - ); - } - return { block: true, reason }; - } - - if (config.audit) logDecision("bash", "(no blocked paths)", true); - } - - return undefined; - }); - - pi.registerCommand("path-policy", { - description: "Show active path-policy configuration", - handler: async (_args, ctx) => { - refreshConfig(ctx.cwd); - - const tools = config.blockedTools.join(", ") || "(none)"; - const sortedPaths = [...config.blockedPaths].sort((a, b) => - a.localeCompare(b), - ); - const pathLines = - sortedPaths.length > 0 - ? sortedPaths.map((pathValue) => ` - ${pathValue}`) - : [" (none)"]; - - const lines = [ - "Path Policy", - `enabled: ${config.enabled}`, - `blockedTools: ${tools}`, - "blockedPaths:", - ...pathLines, - `guardBash: ${config.guardBash}`, - `audit: ${config.audit}`, - ]; - ctx.ui.notify(lines.join("\n"), "info"); - }, - }); -} - diff --git a/dot_pi/agent/extensions/permission-gate/index.ts b/dot_pi/agent/extensions/permission-gate/index.ts deleted file mode 100644 index dcfa27d4..00000000 --- a/dot_pi/agent/extensions/permission-gate/index.ts +++ /dev/null @@ -1,493 +0,0 @@ -/** - * Permission Gate Extension - * - * Prompts for confirmation before running potentially dangerous bash commands. - * - * Improvements inspired by Zed's approach: - * - Splits shell input into subcommands and evaluates each one. - * - Prevents chain bypasses like: `safe && dangerous`. - * - Adds hardcoded, non-overridable blocks for catastrophic rm targets. - */ - -import path from "node:path"; -import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; - -type Rule = { - pattern: RegExp; - label: string; -}; - -const CATASTROPHIC_RM_REASON = - "Blocked by built-in security rule: catastrophic rm target (/, ~, $HOME, ., ..)."; - -const HOME_DIR = process.env.HOME ? path.resolve(process.env.HOME) : null; - -const SAFE_GENERATED_DELETE_BASENAMES = new Set([ - "node_modules", - ".bin", - "dist", - "build", - "coverage", - ".cache", - "session-transcripts", -]); - -const DESTRUCTIVE_SQL_PATTERN = - /\b(?:DROP\s+(?:TABLE|DATABASE|SCHEMA|VIEW|INDEX|ROLE|USER|EXTENSION)\b|TRUNCATE(?:\s+TABLE)?\b|DELETE\s+FROM\b)/i; - -const SQL_CLIENT_PATTERN = /\b(psql|pgcli|mysql|mariadb|sqlite3|duckdb|sqlcmd|clickhouse-client)\b/i; - -function shellSplitWords(input: string): string[] { - const words: string[] = []; - let current = ""; - let inSingle = false; - let inDouble = false; - let escaped = false; - - for (const ch of input) { - if (escaped) { - current += ch; - escaped = false; - continue; - } - - if (ch === "\\" && !inSingle) { - escaped = true; - continue; - } - - if (ch === "'" && !inDouble) { - inSingle = !inSingle; - continue; - } - - if (ch === '"' && !inSingle) { - inDouble = !inDouble; - continue; - } - - if (!inSingle && !inDouble && /\s/.test(ch)) { - if (current.length > 0) { - words.push(current); - current = ""; - } - continue; - } - - current += ch; - } - - if (escaped) current += "\\"; - if (current.length > 0) words.push(current); - return words; -} - -function splitShellSubcommands(command: string): string[] { - const commands: string[] = []; - let current = ""; - - let inSingle = false; - let inDouble = false; - let escaped = false; - let subExprDepth = 0; - - for (let i = 0; i < command.length; i++) { - const ch = command[i]; - const next = command[i + 1]; - const prev = command[i - 1]; - - if (escaped) { - current += ch; - escaped = false; - continue; - } - - if (ch === "\\" && !inSingle) { - current += ch; - escaped = true; - continue; - } - - if (ch === "'" && !inDouble) { - inSingle = !inSingle; - current += ch; - continue; - } - - if (ch === '"' && !inSingle) { - inDouble = !inDouble; - current += ch; - continue; - } - - if (!inSingle && !inDouble) { - // Track command/process substitutions so separators inside them don't split. - if (ch === "(" && (prev === "$" || prev === "<" || prev === ">")) { - subExprDepth += 1; - current += ch; - continue; - } - - if (ch === ")" && subExprDepth > 0) { - subExprDepth -= 1; - current += ch; - continue; - } - - if (subExprDepth === 0) { - const isSingleSep = ch === ";" || ch === "\n"; - const isDoubleSep = (ch === "&" && next === "&") || (ch === "|" && next === "|"); - const isPipeSep = ch === "|"; - const isBackgroundSep = ch === "&"; - - if (isSingleSep || isDoubleSep || isPipeSep || isBackgroundSep) { - const trimmed = current.trim(); - if (trimmed.length > 0) commands.push(trimmed); - current = ""; - - if (isDoubleSep) i += 1; - continue; - } - } - } - - current += ch; - } - - const tail = current.trim(); - if (tail.length > 0) commands.push(tail); - - return commands.length > 0 ? commands : [command.trim()].filter(Boolean); -} - -const HOME_PREFIXES = ["${HOME}", "$HOME", "~"] as const; - -function matchesHomePrefix(raw: string, prefix: string): boolean { - if (prefix === "~") return raw === "~" || raw.startsWith("~/"); - return raw.startsWith(prefix); -} - -function trimTrailingSlash(value: string): string { - return value === "/" ? value : value.replace(/\/+$/, ""); -} - -function normalizePathLikeToken(token: string): string { - const raw = token.trim(); - - for (const prefix of HOME_PREFIXES) { - if (!matchesHomePrefix(raw, prefix)) continue; - const normalized = path.posix.normalize(`/__HOME__${raw.slice(prefix.length)}`); - const mapped = normalized.replace(/^\/__HOME__/, prefix); - return trimTrailingSlash(mapped || prefix); - } - - return trimTrailingSlash(path.posix.normalize(raw) || "."); -} - -function isCatastrophicPathTarget(token: string): boolean { - const trimmed = token.trim(); - const normalized = normalizePathLikeToken(trimmed); - - if (/^(\/|~|\$HOME|\$\{HOME\}|\.|\.\.)\/\*$/.test(trimmed)) { - return true; - } - - const catastrophicTargets = new Set(["/", "~", "$HOME", "${HOME}", ".", ".."]); - return catastrophicTargets.has(normalized); -} - -function isCatastrophicRm(command: string): boolean { - const parsed = parseRmCommand(command); - if (!parsed) return false; - - const { pathTokens } = parsed; - if (pathTokens.length === 0) return false; - return pathTokens.some(isCatastrophicPathTarget); -} - -function parseRmCommand(command: string): { optionTokens: string[]; pathTokens: string[] } | null { - const words = shellSplitWords(command); - if (words.length === 0) return null; - - if (words[0].toLowerCase() !== "rm") return null; - - const optionTokens: string[] = []; - const pathTokens: string[] = []; - let sawDoubleDash = false; - - for (const token of words.slice(1)) { - if (!sawDoubleDash && token === "--") { - sawDoubleDash = true; - continue; - } - - if (!sawDoubleDash && token.startsWith("-")) { - optionTokens.push(token); - continue; - } - - pathTokens.push(token); - } - - return { optionTokens, pathTokens }; -} - -function hasShellGlob(token: string): boolean { - return /[*?[\]]/.test(token); -} - -function resolvePathLikeTokenAbsolute(token: string, cwd: string): string | null { - const raw = token.trim(); - if (raw.length === 0) return null; - - if (hasShellGlob(raw)) return null; - - if (/\$\{(?!HOME\})[^}]+\}/.test(raw)) return null; - if (/\$(?!HOME\b)[A-Za-z_][A-Za-z0-9_]*/.test(raw)) return null; - - for (const prefix of HOME_PREFIXES) { - if (!matchesHomePrefix(raw, prefix)) continue; - if (!HOME_DIR) return null; - return path.resolve(HOME_DIR, `.${raw.slice(prefix.length)}`); - } - - if (path.isAbsolute(raw)) { - return path.resolve(raw); - } - - return path.resolve(cwd, raw); -} - -function isWithinPath(parent: string, candidate: string): boolean { - const normalizedParent = path.resolve(parent); - const normalizedCandidate = path.resolve(candidate); - - if (normalizedParent === normalizedCandidate) return true; - - const relative = path.relative(normalizedParent, normalizedCandidate); - return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative); -} - -function isManagedDeletePath(absPath: string): boolean { - if (!HOME_DIR) return false; - - const managedRoots = [ - path.join(HOME_DIR, ".pi", "agent", "extensions"), - path.join(HOME_DIR, ".pi", "agent", "git"), - path.join(HOME_DIR, ".pi", "agent", "skills"), - path.join(HOME_DIR, ".agents", "skills"), - path.join(HOME_DIR, ".claude", "commands"), - path.join(HOME_DIR, ".claude", "skills"), - path.join(HOME_DIR, ".local", "share", "chezmoi"), - ]; - - return managedRoots.some((root) => absPath !== root && isWithinPath(root, absPath)); -} - -function isClearlyGeneratedDeletePath(absPath: string): boolean { - const normalized = path.resolve(absPath); - - if ( - isWithinPath("/tmp", normalized) - || isWithinPath("/private/tmp", normalized) - || isWithinPath("/var/folders", normalized) - ) { - return true; - } - - const segments = normalized.split(path.sep).filter(Boolean); - return segments.some((segment) => SAFE_GENERATED_DELETE_BASENAMES.has(segment)); -} - -function isRmRecursiveFlag(token: string): boolean { - if (token === "--recursive") return true; - return /^-[^-]+$/.test(token) && /[rR]/.test(token); -} - -function isRmForceFlag(token: string): boolean { - if (token === "--force") return true; - return /^-[^-]+$/.test(token) && token.slice(1).includes("f"); -} - -function deletesTopLevelProjectPath(absPath: string, cwd: string): boolean { - if (!isWithinPath(cwd, absPath)) return false; - - const relative = path.relative(path.resolve(cwd), absPath); - const segments = relative.split(path.sep).filter(Boolean); - return segments.length <= 1; -} - -function isRiskyRmSubcommand(command: string, cwd: string): boolean { - const parsed = parseRmCommand(command); - if (!parsed) return false; - - const { optionTokens, pathTokens } = parsed; - if (pathTokens.length === 0) return false; - - if (pathTokens.some(isCatastrophicPathTarget)) return true; - if (pathTokens.some(hasShellGlob)) return true; - - const resolvedTargets = pathTokens.map((token) => resolvePathLikeTokenAbsolute(token, cwd)); - if (resolvedTargets.some((target) => !target)) return true; - - const targets = resolvedTargets as string[]; - const allSafeTargets = targets.every( - (target) => isWithinPath(cwd, target) || isManagedDeletePath(target) || isClearlyGeneratedDeletePath(target), - ); - - if (!allSafeTargets) return true; - if (pathTokens.length > 8) return true; - - const hasRecursive = optionTokens.some(isRmRecursiveFlag); - if ( - hasRecursive - && targets.some((target) => deletesTopLevelProjectPath(target, cwd) && !isClearlyGeneratedDeletePath(target)) - ) { - return true; - } - - const hasForce = optionTokens.some(isRmForceFlag); - return hasForce && pathTokens.length > 5; -} - -function isDestructiveSqlSubcommand(command: string): boolean { - return SQL_CLIENT_PATTERN.test(command) && DESTRUCTIVE_SQL_PATTERN.test(command); -} - -function resolveCdTarget(command: string, cwd: string): string | null { - const words = shellSplitWords(command); - if (words.length === 0 || words[0].toLowerCase() !== "cd") return null; - - const rawTarget = words[1] ?? "~"; - if (rawTarget === "-") return null; - - return resolvePathLikeTokenAbsolute(rawTarget, cwd); -} - -function collectMatches(subcommands: string[], rules: Rule[]): Array<{ label: string; command: string }> { - const matches: Array<{ label: string; command: string }> = []; - - for (const sub of subcommands) { - for (const rule of rules) { - if (rule.pattern.test(sub)) { - matches.push({ label: rule.label, command: sub }); - } - } - } - - return matches; -} - -function collectCustomMatches( - subcommands: string[], - cwd: string, -): Array<{ label: string; command: string }> { - const matches: Array<{ label: string; command: string }> = []; - let effectiveCwd = cwd; - - for (const sub of subcommands) { - if (isRiskyRmSubcommand(sub, effectiveCwd)) { - matches.push({ label: "file deletion (rm)", command: sub }); - } - - if (isDestructiveSqlSubcommand(sub)) { - matches.push({ label: "destructive SQL statement", command: sub }); - } - - const nextCwd = resolveCdTarget(sub, effectiveCwd); - if (nextCwd) { - effectiveCwd = nextCwd; - } - } - - return matches; -} - -export default function (pi: ExtensionAPI) { - const subcommandDangerRules: Rule[] = [ - // Privilege escalation - { pattern: /\bsudo\b/i, label: "privilege escalation (sudo)" }, - - // Dangerous permission changes (777 or world-writable equivalents) - { pattern: /\b(chmod|chown)\b.*777/i, label: "dangerous permissions (777)" }, - { pattern: /\bchmod\b.*o\+w/i, label: "world-writable permission (o+w)" }, - - // Disk / filesystem operations - { pattern: /\bdd\b.*\bof=/i, label: "disk write (dd)" }, - { pattern: /\b(mkfs|fdisk|parted)\b/i, label: "disk/filesystem operation" }, - { pattern: /\bdiskutil\b.*(erase|format|partition)/i, label: "disk operation (diskutil)" }, - - // find-based deletion - { pattern: /\bfind\b.*-delete\b/i, label: "file deletion (find -delete)" }, - { pattern: /\bfind\b.*-exec\s+rm\b/i, label: "file deletion (find -exec rm)" }, - - // Secure / irreversible file wiping - { pattern: /\bshred\b/i, label: "secure file deletion (shred)" }, - - // Git destructive operations - { pattern: /\bgit\s+clean\b.*-[a-z]*f/i, label: "git clean (removes untracked files)" }, - { - pattern: /\bgit\s+reset\b.*--hard\b/i, - label: "git reset --hard (loses uncommitted changes)", - }, - - // Raw block device writes - { pattern: />\s*\/dev\/sd[a-z]/i, label: "write to raw block device (/dev/sd*)" }, - ]; - - const fullCommandDangerRules: Rule[] = [ - // Remote code execution — piping curl/wget into a shell - { - pattern: /\b(curl|wget)\b[^|]*\|\s*(ba)?sh\b/i, - label: "remote code execution (pipe to shell)", - }, - ]; - - pi.on("tool_call", async (event, ctx) => { - if (event.toolName !== "bash") return undefined; - - const command = String(event.input.command ?? ""); - const subcommands = splitShellSubcommands(command); - - const hardBlockedSubcommand = subcommands.find(isCatastrophicRm); - if (hardBlockedSubcommand) { - return { - block: true, - reason: `${CATASTROPHIC_RM_REASON} Matched: ${hardBlockedSubcommand}`, - }; - } - - const subMatches = collectMatches(subcommands, subcommandDangerRules); - const customMatches = collectCustomMatches(subcommands, ctx.cwd); - const fullMatches = fullCommandDangerRules - .filter(({ pattern }) => pattern.test(command)) - .map(({ label }) => ({ label, command })); - - const allMatches = [...subMatches, ...customMatches, ...fullMatches]; - if (allMatches.length === 0) return undefined; - - const labels = [...new Set(allMatches.map((m) => m.label))]; - const matchedCommands = [...new Set(allMatches.map((m) => m.command))]; - - if (!ctx.hasUI) { - return { - block: true, - reason: `Dangerous command blocked (no UI for confirmation): ${labels.join(", ")}`, - }; - } - - const matchedPreview = matchedCommands.slice(0, 3).map((cmd) => ` - ${cmd}`).join("\n"); - const extra = matchedCommands.length > 3 ? `\n - ...and ${matchedCommands.length - 3} more` : ""; - - const choice = await ctx.ui.select( - `⚠️ Potentially dangerous command\n\n ${command}\n\nReasons:\n - ${labels.join("\n - ")}\n\nMatched subcommands:\n${matchedPreview}${extra}\n\nAllow?`, - ["Yes", "No"], - ); - - if (choice !== "Yes") { - return { block: true, reason: "Blocked by user" }; - } - - return undefined; - }); -} diff --git a/dot_pi/agent/modify_private_settings.json b/dot_pi/agent/modify_private_settings.json index b99cb3c4..857183d3 100755 --- a/dot_pi/agent/modify_private_settings.json +++ b/dot_pi/agent/modify_private_settings.json @@ -21,6 +21,8 @@ MANAGED_CONFIG='{ "quietStartup": true, "extensions": [], "packages": [ + "git:github.com/aliou/pi-guardrails", + "git:github.com/aliou/pi-processes", "/Users/joelazar/Code/ai/pi/pi-review", "/Users/joelazar/Code/ai/pi/pi-nvim", "/Users/joelazar/Code/ai/pi/pi-packages/packages/pi-claude-code-use", diff --git a/dot_pi/agent/path-policy.json b/dot_pi/agent/path-policy.json deleted file mode 100644 index 3bdc9ff4..00000000 --- a/dot_pi/agent/path-policy.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "enabled": true, - "blockedPaths": [ - "**/*.key", - "**/*.p12", - "**/*.pem", - "**/*.pfx", - "**/.aws/**", - "**/.azure/**", - "**/.config/gcloud/**", - "**/.config/gh/hosts.yml", - "**/.env*", - "**/.gem/credentials", - "**/.git-credentials", - "**/.kube/**", - "**/.netrc", - "**/.npmrc", - "**/.pypirc", - "**/.secret*", - "**/.terraform.d/credentials.tfrc.json", - "**/.yarnrc.yml", - "**/Obsidian/journal/**", - "**/credentials/**", - "**/id_ed25519", - "**/id_rsa", - "**/pip.conf", - "**/secrets/**", - "~/.aws", - "~/.azure/**", - "~/.codex/auth.json", - "~/.config/exercism/user.json", - "~/.config/gcloud/**", - "~/.config/gcx/config.yaml", - "~/.config/gh/hosts.yml", - "~/.config/github-copilot/**", - "~/.config/gws/**", - "~/.config/pip/pip.conf", - "~/.config/save-to-spotify/**", - "~/.fly/**", - "~/.gem/credentials", - "~/.git-credentials", - "~/.gnupg", - "~/.kube/**", - "~/.netrc", - "~/.npmrc", - "~/.pi/auth.json", - "~/.pypirc", - "~/.ssh/**", - "~/.terraform.d/credentials.tfrc.json" - ], - "blockedTools": ["read", "edit", "write"], - "guardBash": true, - "audit": false -}