/** * Path Guard Extension — protects against accidental deletes / overwrites / edits * * Version history lives in CHANGELOG.md (aligned with package.json); the most * recent release/tag is 1.6.0. */ import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext, BashToolInput, EditToolInput, WriteToolInput, ToolCallEventResult, ExtensionUIContext, } from "@earendil-works/pi-coding-agent"; import { resolve, normalize, relative as relativePath, join, dirname, basename, sep, } from "node:path"; import { homedir } from "node:os"; import { Input, matchesKey, truncateToWidth, visibleWidth, type Component, type Focusable, } from "@earendil-works/pi-tui"; import { realpathSync, existsSync, statSync, readFileSync, writeFileSync, } from "node:fs"; // ─── Configuration ────────────────────────────────────────────────────── /** Protected path fragments — matching paths block writes/edits */ const PROTECTED_PATH_PATTERNS = [ ".env", ".envrc", // direnv config (can load arbitrary commands / credentials) ".git/", ".ssh/", // SSH config & keys // HOME-level credentials/config (intercepted for bash redirects, overwrites, and the write tool) ".aws/", // AWS credentials ".kube/", // Kubernetes admin config ".docker/", // Docker login credentials ".gnupg/", // GPG keys ".git-credentials", // plaintext git credentials ".npmrc", // npm tokens ".pypirc", // PyPI tokens ".netrc", // generic login credentials ".bashrc", // shell config (persistence/backdoor vector) ".zshrc", ".profile", ".bash_profile", ".secrets/", // secrets dir (also covers a bare `.secrets` file) "credentials", // in-project credential files "id_rsa", // private keys that may live outside .ssh/ "id_ed25519", "id_ecdsa", "id_dsa", "*.pem", // private keys (suffix match) "*.key", // private keys (suffix match) "*.p12", // PKCS#12 keystores "*.pfx", // PKCS#12 keystores (Windows) "node_modules/", ".next/", ".nuxt/", ".cache/", "dist/", "build/", "coverage/", "__pycache__/", ".pytest_cache/", "target/", "vendor/", // Go vendor / PHP composer ]; /** * File patterns that must match the last path segment exactly and never a * `.` variant — `id_rsa.pub` is the public key, not the private one. */ const EXACT_ONLY_PATTERNS = new Set([ "id_rsa", "id_ed25519", "id_ecdsa", "id_dsa", ]); /** * `credentials.` extensions that clearly mark a non-secret template/example. * The bare `credentials` pattern blocks the exact name and sensitive variants * (`credentials.json`), but must not fire on these (issue: false positives). */ const SAFE_CREDENTIAL_SUFFIXES = new Set([ ".example", ".sample", ".template", ".tmpl", ".dist", ".md", ".txt", ]); /** Block group — system-destructive; blocked in every mode (no confirmation opportunity) */ const BLOCK_DANGEROUS_PATTERNS: RegExp[] = [ /\bmkfs\./, /\bmkswap\b/, /\bpoweroff\b/, /\breboot\b/, /\bshutdown\b/, /\binit\s+0\b/, /\binit\s+6\b/, /\bdd\b[^;|&]*\bof=\s*\/dev\/(sda|sdb|sdc|nvme|mmcblk)/, // dd writing directly to a block device (ordinary files handled by judgeDd) /(>|>>)\s*\/dev\/(sda|sdb|sdc|nvme|mmcblk)/, // direct write to a block device (note: no \b — > is often preceded by a space) /\bfind\b[^;|&]*-delete\b/, // find ... -delete bulk delete /\bfind\b[^;|&]*-exec(dir)?\b[^;|&]*\brm\b/, // find ... -exec rm bulk delete /\bxargs\b[^;|&]*\brm\b/, // xargs rm bulk delete (backup beyond judgeGit) ]; /** Confirm group — privilege escalation / remote / risky permissions: blocked in strict, confirmed otherwise */ const CONFIRM_DANGEROUS_PATTERNS: RegExp[] = [ /\bsudo\b/, /\b(doas|pkexec)\b/, /\b(chmod|chown)\b.*777/, /(? = { strict: "Strict: confirm in-project writes, block dangerous commands / 全防护:项目内写也询问,危险命令直接阻止", normal: "Normal: block system-destructive commands, confirm sudo/ssh / 默认:系统级破坏直接阻止,提权/远程询问", loose: "Loose: pass new-file writes & deletes, confirm overwrites / 放宽:新建/删除免问,覆盖需确认", trusted: "Trusted: pass overwrites & ordinary-file deletes / 最宽松:覆盖/删除普通文件也免问", naked: "Naked: pass everything except system-destructive cmds (confirmed) / 裸奔:除系统级破坏命令外全部放行(破坏命令弹窗询问)", }; // ─── Tunable rules & user-configured protected paths ────────────────── /** Decision level a rule can produce. */ type RuleLevel = "block" | "confirm" | "pass"; /** * Tunable rule IDs — each is a single decision point in the judgement logic. * A rule's effective value for the current mode = settings override ?? default. */ type RuleId = | "blockGroup" // system-destructive mkfs/reboot/dev-write/bulk-delete | "confirmGroup" // privilege/remote sudo/ssh/chmod777 | "writeOutside" // write/edit targeting a path outside the project | "writeHome" // write/edit under HOME | "writeInProject" // write/edit creating/overwriting in the project | "deleteOutside" // rm outside the project | "deleteInProject" // rm in the project | "overwriteOutsideExisting" // mv/cp over an existing target outside | "overwriteOutsideNew" // mv/cp creating a target outside | "overwriteInProject" // mv/cp overwrite in the project | "truncateInProject" // `> existing in-project file` / truncate in project | "truncateOutside" // `> existing outside file` / truncate outside | "gitDestructive" // git clean -f / reset --hard / checkout . / push --force … | "pipeToShellInProject" // curl/wget/interpreter output piped into a shell (in-workspace) | "pipeToShellOutside" // … with a remote/outside-workspace source | "runScriptInProject" // source/./bash script.sh inside the project | "runScriptOutside" // … outside the project / under HOME | "runScriptProtected" // … targeting a built-in protected path | "scriptUnresolved"; // … a `$VAR`/glob target that cannot be resolved statically const RULE_IDS: readonly RuleId[] = [ "blockGroup", "confirmGroup", "writeOutside", "writeHome", "writeInProject", "deleteOutside", "deleteInProject", "overwriteOutsideExisting", "overwriteOutsideNew", "overwriteInProject", "truncateInProject", "truncateOutside", "gitDestructive", "pipeToShellInProject", "pipeToShellOutside", "runScriptInProject", "runScriptOutside", "runScriptProtected", "scriptUnresolved", ]; /** Bilingual short labels for each tunable rule (used in the rule-editor menu). */ const RULE_DESCRIPTIONS: Record = { blockGroup: "system-destructive mkfs/reboot (系统级破坏)", confirmGroup: "privilege/remote sudo/ssh/chmod777 (权限/远程)", writeOutside: "write/edit outside project (项目外写)", writeHome: "write/edit under HOME (HOME 下写)", writeInProject: "write/edit in project (项目内写)", deleteOutside: "delete outside project (项目外删)", deleteInProject: "delete in project (项目内删)", overwriteOutsideExisting: "overwrite existing outside (项目外覆盖已存在)", overwriteOutsideNew: "create target outside (项目外新建)", overwriteInProject: "overwrite in project (项目内覆盖)", truncateInProject: "truncate existing in-project file (截断项目内已存在文件)", truncateOutside: "truncate existing outside file (截断项目外已存在文件)", gitDestructive: "git destructive reset --hard (Git 破坏性)", pipeToShellInProject: "pipe to shell, in-project (管道进 shell·项目内)", pipeToShellOutside: "pipe to shell, remote/outside (管道进 shell·远程/外)", runScriptInProject: "source/./bash script, in-project (运行脚本·项目内)", runScriptOutside: "source/./bash script, outside/HOME (运行脚本·项目外/HOME)", runScriptProtected: "source/./bash script of a built-in protected path (运行脚本·内置保护)", scriptUnresolved: "source/./script with a $VAR/glob path that cannot be resolved (脚本路径不可静态解析)", }; const RULE_LEVELS: readonly RuleLevel[] = ["block", "confirm", "pass"]; const RULE_LEVEL_LABELS: Record = { block: "block — Block (阻止)", confirm: "confirm — Confirm (确认)", pass: "pass — Pass (放行)", }; function isRuleLevel(v: string | undefined): v is RuleLevel { return v === "block" || v === "confirm" || v === "pass"; } /** Default rules per built-in mode — reproduces the pre-config (v1.0.0) hardcoded behaviour exactly. */ const DEFAULT_MODES: Record> = { strict: { blockGroup: "block", confirmGroup: "block", writeOutside: "confirm", writeHome: "confirm", writeInProject: "confirm", deleteOutside: "block", deleteInProject: "confirm", overwriteOutsideExisting: "block", overwriteOutsideNew: "confirm", overwriteInProject: "confirm", truncateInProject: "confirm", truncateOutside: "block", gitDestructive: "confirm", pipeToShellInProject: "confirm", pipeToShellOutside: "confirm", runScriptInProject: "confirm", runScriptOutside: "block", runScriptProtected: "block", scriptUnresolved: "block", }, normal: { blockGroup: "block", confirmGroup: "confirm", writeOutside: "confirm", writeHome: "confirm", writeInProject: "pass", deleteOutside: "block", deleteInProject: "confirm", overwriteOutsideExisting: "block", overwriteOutsideNew: "confirm", overwriteInProject: "confirm", truncateInProject: "confirm", truncateOutside: "confirm", gitDestructive: "confirm", pipeToShellInProject: "pass", pipeToShellOutside: "confirm", runScriptInProject: "confirm", runScriptOutside: "confirm", runScriptProtected: "confirm", scriptUnresolved: "confirm", }, loose: { blockGroup: "block", confirmGroup: "confirm", writeOutside: "pass", writeHome: "pass", writeInProject: "pass", deleteOutside: "confirm", deleteInProject: "pass", overwriteOutsideExisting: "confirm", overwriteOutsideNew: "pass", overwriteInProject: "confirm", truncateInProject: "pass", truncateOutside: "confirm", gitDestructive: "confirm", pipeToShellInProject: "pass", pipeToShellOutside: "pass", runScriptInProject: "pass", runScriptOutside: "confirm", runScriptProtected: "confirm", scriptUnresolved: "confirm", }, trusted: { blockGroup: "block", confirmGroup: "confirm", writeOutside: "pass", writeHome: "pass", writeInProject: "pass", deleteOutside: "pass", deleteInProject: "pass", overwriteOutsideExisting: "pass", overwriteOutsideNew: "pass", overwriteInProject: "pass", truncateInProject: "pass", truncateOutside: "pass", gitDestructive: "confirm", pipeToShellInProject: "pass", pipeToShellOutside: "pass", runScriptInProject: "pass", runScriptOutside: "pass", runScriptProtected: "pass", scriptUnresolved: "pass", }, naked: { blockGroup: "confirm", confirmGroup: "pass", writeOutside: "pass", writeHome: "pass", writeInProject: "pass", deleteOutside: "pass", deleteInProject: "pass", overwriteOutsideExisting: "pass", overwriteOutsideNew: "pass", overwriteInProject: "pass", truncateInProject: "pass", truncateOutside: "pass", gitDestructive: "pass", pipeToShellInProject: "pass", pipeToShellOutside: "pass", runScriptInProject: "pass", runScriptOutside: "pass", runScriptProtected: "pass", scriptUnresolved: "pass", }, }; /** Effective rule level for the current mode (settings override ?? built-in default). */ function rl(rule: RuleId): RuleLevel { // A confirm dialog's "Allow & set … = pass (session)" outranks config/defaults // for the rest of this session (never persisted). if (isSessionPassed(currentMode, rule)) return "pass"; return config.rules[currentMode]?.[rule] ?? DEFAULT_MODES[currentMode][rule]; } /** Effective rule level for a specific mode (override ?? built-in default). */ function rlFor(mode: GuardMode, rule: RuleId): RuleLevel { return config.rules[mode]?.[rule] ?? DEFAULT_MODES[mode][rule]; } /** Map a rule to a segment verdict: block (with reason) / confirm / pass. */ function ruleVerdict(rule: RuleId, blockReason: string): SegmentVerdict { const lvl = rl(rule); if (lvl === "block") return { kind: "block", reason: blockReason }; if (lvl === "confirm") return { kind: "confirm", rule }; return { kind: "pass" }; } /** Whether the current mode is naked (many conservative confirms become pass). */ const inNaked = () => currentMode === "naked"; /** * User-configured protected paths (pathGuard.extraProtected). Unlike built-in * protected paths, these are enforced in EVERY mode — including naked. */ let extraProtected: string[] = []; /** Match a resolved absolute path against a user-configured protected entry. */ function isUserProtectedPath(absolutePath: string): boolean { for (const entry of extraProtected) { const e = normalize(resolveReal(entry)); if (absolutePath === e) return true; if (absolutePath.startsWith(e + sep)) return true; } return false; } /** Expand ~ and resolve relative entries against cwd into a canonical absolute path. * Deliberately does NOT resolve symlinks: protection/trust is anchored to the literal * path the user configured, so it keeps guarding that location even when a symlink in * it is created/removed/retargeted later (across sessions or during builds). At match * time (isUserProtectedPath / isTrustedPath) the entry's CURRENT real path is resolved, * so writes through a symlink to the same real target are still caught — but a write to * the literal path is never missed because the stored entry drifted to an old target. */ function normalizeProtectedEntry( entry: string, cwd: string | undefined, ): string { const expanded = expandHome(entry.trim()); // resolve() normalizes (absolute, dot-segment-free) but does NOT follow symlinks. return resolve(cwd ?? HOME, expanded); } /** * User-configured trusted paths (pathGuard.trustedPaths). Operations whose target * lies inside a trusted path are always allowed — path-guard treats them as if the * active mode were "trusted" for just that path, regardless of the current mode: * writes/edits/deletes/overwrites/truncates/in-place edits inside it pass without * prompting. Protection always outranks trust: a trusted path can never be a * protected path (built-in system path or user-protected path), so those stay blocked. */ let trustedPaths: string[] = []; /** Path category selector used by both the CLI and interactive /guard paths UIs. */ type PathKind = "protected" | "trusted"; /** The live entry list for a path category. */ function pathList(kind: PathKind): string[] { return kind === "trusted" ? trustedPaths : extraProtected; } /** Whether a path is a user-configured trusted entry (or under one). */ function isTrustedPath(absolutePath: string): boolean { for (const entry of trustedPaths) { const e = normalize(resolveReal(entry)); if (absolutePath === e) return true; if (absolutePath.startsWith(e + sep)) return true; } return false; } /** * Why a path cannot be added as a trusted entry, or null if it can be trusted. * Trusting never overrides protection, so built-in system paths and user-protected * paths (or anything under them) are refused. */ function untrustableReason(absolutePath: string): string | null { // Compare on the REAL path so a literal entry that goes through a symlink into a // protected location is still refused as trusted. const real = resolveReal(absolutePath); if (isUserProtectedPath(real)) { return "it is a user-protected path — remove it from protected paths first"; } if (matchesProtectedPath(real)) { return "it is a system-important protected path (.env/.ssh/keys/credentials/node_modules/build-output) and cannot be trusted"; } return null; } /** Guard verdict: { block, reason } to block / undefined to allow (askConfirm returns a Promise) */ type GuardVerdict = | ToolCallEventResult | undefined | Promise; // ─── Entry ──────────────────────────────────────────────────────────── /** Set the current guard mode and mirror it into the TUI footer status bar. */ function setMode(mode: GuardMode, ui: ExtensionUIContext) { currentMode = mode; refreshModeStatus(ui); } /** * Show the active guard mode in the footer status bar (persists across renders). * naked is highlighted in warning color so the "bare" state is unmissable. */ function refreshModeStatus(ui: ExtensionUIContext) { const t = ui.theme; const color = currentMode === "naked" ? "warning" : "accent"; const label = currentMode === "naked" ? "🛡 NAKED" : `🛡 ${currentMode}`; ui.setStatus("path-guard", t.fg(color, label)); } // ─── Settings persistence (mode survives across sessions) ───────────── /** Global settings.json path (~/.pi/agent/settings.json; PI_PATH_GUARD_SETTINGS overrides, for tests). */ function globalSettingsPath(): string { return process.env.PI_PATH_GUARD_SETTINGS ?? GLOBAL_SETTINGS_PATH; } /** Whether cwd is the user's HOME (never treated as a project for settings). */ function isHomeCwd(cwd: string | undefined): boolean { if (!cwd) return false; return resolveReal(cwd) === resolveReal(HOME); } /** Project settings.json path (cwd/.pi/settings.json), or undefined when no cwd. */ function projectSettingsPath(cwd: string | undefined): string | undefined { return cwd ? join(cwd, CONFIG_DIR, "settings.json") : undefined; } /** * Loaded path-guard config: active mode, user-configured protected paths, and * per-mode rule overrides. Repopulated from settings.json on every session_start. */ interface PathGuardConfig { mode: GuardMode; extraProtected: string[]; trustedPaths: string[]; rules: Partial>>>; } let config: PathGuardConfig = { mode: "normal", extraProtected: [], trustedPaths: [], rules: {}, }; /** * Session-only rule passes set from a confirm dialog's "Allow & set … = pass * (session)" option. Kept separate from `config.rules` so that a later * persistConfig (e.g. /guard rules) can never write them to disk — a new session * reverts to the configured / built-in levels. */ const sessionPass: Partial>> = {}; /** Whether a rule was session-passed from a confirm dialog. */ function isSessionPassed(mode: GuardMode, rule: RuleId): boolean { return sessionPass[mode]?.has(rule) ?? false; } /** Session-pass one or more rules for a mode (in-memory only, never persisted). */ function sessionPassRule(mode: GuardMode, rule: RuleId): void { (sessionPass[mode] ??= new Set()).add(rule); } /** Drop session-pass overrides (one mode, or all) — used when rules are edited. */ function clearSessionPass(mode?: GuardMode): void { if (mode) { delete sessionPass[mode]; return; } for (const m of GUARD_MODES) delete sessionPass[m]; } /** Read and validate the raw pathGuard block from a settings.json file, or undefined. */ function readSettingsGuard( filePath: string | undefined, ): Partial | undefined { if (!filePath) return undefined; try { if (!existsSync(filePath)) return undefined; const data = JSON.parse(readFileSync(filePath, "utf8")) as { pathGuard?: { mode?: string; extraProtected?: string[]; trustedPaths?: string[]; rules?: Record>; }; }; const g = data?.pathGuard; if (!g) return undefined; const out: Partial = {}; if (typeof g.mode === "string" && isGuardMode(g.mode)) out.mode = g.mode; if (Array.isArray(g.extraProtected)) { out.extraProtected = g.extraProtected.filter( (p): p is string => typeof p === "string", ); } if (Array.isArray(g.trustedPaths)) { out.trustedPaths = g.trustedPaths.filter( (p): p is string => typeof p === "string", ); } if (g.rules && typeof g.rules === "object") { const rules: PathGuardConfig["rules"] = {}; for (const [m, overrides] of Object.entries(g.rules)) { if (!isGuardMode(m) || !overrides || typeof overrides !== "object") continue; const clean: Partial> = {}; for (const [r, lvl] of Object.entries(overrides)) { if ((RULE_IDS as readonly string[]).includes(r) && isRuleLevel(lvl)) { clean[r as RuleId] = lvl; } } if (Object.keys(clean).length > 0) rules[m] = clean; } if (Object.keys(rules).length > 0) out.rules = rules; } return out; } catch { return undefined; } } /** * Effective config at session start. The active mode/extraProtected/rules are * persisted to the GLOBAL settings file only (~/.pi/agent/settings.json) and * restored from there — see persistConfig for why project-scoped writes are * avoided. A trusted project's .pi/settings.json may still OPT-IN override the * global mode (read-side only, for hand-authored project config); since path-guard * itself never writes that file, using /guard can no longer turn a plain project * into a "trust-requiring" one (which is what made pi start asking for trust and * silently drop a saved mode on untrusted launches). */ function readSavedConfig( cwd: string | undefined, trusted: boolean, ): PathGuardConfig { const global = readSettingsGuard(globalSettingsPath()) ?? {}; const project = trusted && !isHomeCwd(cwd) ? (readSettingsGuard(projectSettingsPath(cwd)) ?? {}) : {}; const mode = project.mode ?? global.mode ?? "normal"; const extraProtected = [ ...(global.extraProtected ?? []), ...(project.extraProtected ?? []), ].map((e) => normalizeProtectedEntry(e, cwd)); const trustedPaths = [ ...(global.trustedPaths ?? []), ...(project.trustedPaths ?? []), ].map((e) => normalizeProtectedEntry(e, cwd)); const rules = { ...global.rules, ...project.rules }; return { mode, extraProtected, trustedPaths, rules }; } /** * Persist the whole config to the GLOBAL settings file (~/.pi/agent/settings.json), * regardless of cwd or project trust. Project-scoped writes are deliberately avoided: * writing cwd/.pi/settings.json would make that project "trust-requiring", so pi would * begin asking for trust on the next launch (defaultProjectTrust=ask) and a declined/ * untrusted launch would silently ignore the saved mode — the flapping that made a * saved mode revert to normal. Global settings are never trust-gated, so the mode the * user sets always survives. Returns "global" on success, "none" when there is no cwd. */ /** Reason the last persistConfig call fell back to session-only (empty on success). */ let lastPersistError = ""; function persistConfig(cwd: string | undefined): string { if (!cwd) return "none"; const target = globalSettingsPath(); try { let data: Record = {}; if (existsSync(target)) { data = JSON.parse(readFileSync(target, "utf8")) as Record; } const guard = (data.pathGuard as Record) ?? {}; guard.mode = config.mode; if (extraProtected.length > 0) { guard.extraProtected = extraProtected; } else { delete guard.extraProtected; } if (trustedPaths.length > 0) { guard.trustedPaths = trustedPaths; } else { delete guard.trustedPaths; } if (Object.keys(config.rules).length > 0) { guard.rules = config.rules; } else { delete guard.rules; } data.pathGuard = guard; writeFileSync(target, JSON.stringify(data, null, 2) + "\n", "utf8"); lastPersistError = ""; return "global"; } catch (e) { lastPersistError = e instanceof Error ? e.message : String(e); return "none"; } } /** Human-readable persistence note for notify messages. */ function persistNote(where: string): string { if (where === "global") return "saved to global settings"; return lastPersistError ? `session-only — could not write global settings: ${lastPersistError}` : "session-only (not persisted)"; } /** Path Guard paths usage message. */ const PATHS_USAGE = "Path Guard paths usage:\n" + " /guard paths list | add | rm | clear\n" + " /guard paths protected … (same, explicit — this is the default)\n" + " /guard paths trusted … manage trusted (always-allowed) paths\n\n" + "Protected paths are guarded in EVERY mode (including naked).\n" + "Trusted paths are ALWAYS allowed (trusted-mode protection for that path);\n" + "system-important protected paths (.env/.ssh/keys/…) cannot be trusted."; /** * /guard paths … subcommand handler. The optional leading category token * (protected | trusted) picks the list; it defaults to "protected" for backward * compatibility. list / add / rm / clear then operate on that category. */ async function handlePathsCommand(raw: string, ctx: ExtensionCommandContext) { let rest = raw.replace(/^paths\s*/i, "").trim(); let kind: PathKind = "protected"; const cat = rest.match(/^(protected|trusted)\b/i); if (cat) { kind = cat[1].toLowerCase() as PathKind; rest = rest.slice(cat[0].length).trim(); } const spaceIdx = rest.indexOf(" "); const sub = (spaceIdx === -1 ? rest : rest.slice(0, spaceIdx)).toLowerCase(); const arg = spaceIdx === -1 ? "" : rest.slice(spaceIdx + 1).trim(); const show = (msg: string) => ctx.ui.notify(msg, "info"); const list = pathList(kind); switch (sub) { case "list": case "show": if (list.length === 0) { return show(`Path Guard: no ${kind} paths configured`); } return show( `Path Guard ${kind} paths (${list.length}):\n` + list.map((p) => `· ${p}`).join("\n"), ); case "add": { if (!arg) return show(`Usage: /guard paths ${kind} add `); const norm = normalizeProtectedEntry(arg, ctx.cwd); if (kind === "trusted") { const denied = untrustableReason(norm); if (denied) { return show(`Path Guard: cannot trust ${norm} — ${denied}`); } if (!(await confirmTrustPath(ctx))) { return show( `Path Guard: not added — trusting ${norm} requires confirmation`, ); } } if (list.includes(norm)) { return show(`Path Guard: already ${kind} — ${norm}`); } return show(actionAddPath(kind, arg, ctx)); } case "rm": case "remove": { if (!arg) return show(`Usage: /guard paths ${kind} rm `); const norm = normalizeProtectedEntry(arg, ctx.cwd); return show(actionRemovePath(kind, norm, ctx)); } case "clear": return show(actionClearPaths(kind, ctx)); default: return show(PATHS_USAGE); } } // ─── Shared /guard actions (single source of truth for the overlay & menus) ── // Both the interactive overlay panel and the legacy chained menus (plus the // scriptable /guard paths handler) mutate state through these helpers, so the // two UI paths can never drift apart. /** Switch the active mode: update config, refresh the footer, persist. Confirm first. */ function actionSwitchMode(mode: GuardMode, ctx: ExtensionCommandContext): string { config.mode = mode; setMode(mode, ctx.ui); const where = persistConfig(ctx.cwd); return `Path Guard switched to: ${mode} (${persistNote(where)})`; } /** Set one rule override for a mode (block/confirm/pass). */ function actionSetRule( mode: GuardMode, rule: RuleId, level: RuleLevel, ctx: ExtensionCommandContext, ): string { (config.rules[mode] ??= {})[rule] = level; clearSessionPass(mode); const where = persistConfig(ctx.cwd); return `Path Guard: set ${mode}.${rule} = ${level} (${persistNote(where)})`; } /** Reset one rule to its built-in default (drops the override). */ function actionResetRule( mode: GuardMode, rule: RuleId, ctx: ExtensionCommandContext, ): string { if (config.rules[mode]) delete config.rules[mode]![rule]; clearSessionPass(mode); const where = persistConfig(ctx.cwd); return `Path Guard: ${mode}.${rule} back to default ${DEFAULT_MODES[mode][rule]} (${persistNote(where)})`; } /** Reset every override of one mode to built-in defaults. */ function actionResetMode(mode: GuardMode, ctx: ExtensionCommandContext): string { delete config.rules[mode]; clearSessionPass(mode); const where = persistConfig(ctx.cwd); return `Path Guard: reset mode ${mode} to defaults (${persistNote(where)})`; } /** Clear all rule overrides for every mode. */ function actionResetAllRules(ctx: ExtensionCommandContext): string { config.rules = {}; clearSessionPass(); const where = persistConfig(ctx.cwd); return `Path Guard: cleared all rule overrides (${persistNote(where)})`; } /** * Add a path entry. Returns the notify/status message. For `trusted`, callers * must show the warning confirmation first — this still re-checks that the path * is trustable (defence in depth) and reports "already" rather than duplicating. */ function actionAddPath( kind: PathKind, input: string, ctx: ExtensionCommandContext, ): string { const norm = normalizeProtectedEntry(input, ctx.cwd); if (kind === "trusted") { const denied = untrustableReason(norm); if (denied) return `Path Guard: cannot trust ${norm} — ${denied}`; } const list = pathList(kind); if (list.includes(norm)) return `Path Guard: already ${kind} — ${norm}`; list.push(norm); const where = persistConfig(ctx.cwd); return `Path Guard: added ${kind} path ${norm} (${persistNote(where)})`; } /** Remove a (normalized) path entry. */ function actionRemovePath( kind: PathKind, target: string, ctx: ExtensionCommandContext, ): string { const list = pathList(kind); const idx = list.indexOf(target); if (idx === -1) return `Path Guard: not a ${kind} path — ${target}`; list.splice(idx, 1); const where = persistConfig(ctx.cwd); return `Path Guard: removed ${kind} path ${target} (${persistNote(where)})`; } /** Clear every entry of a path category. */ function actionClearPaths( kind: PathKind, ctx: ExtensionCommandContext, ): string { const list = pathList(kind); if (list.length === 0) return `Path Guard: no ${kind} paths to clear`; list.length = 0; const where = persistConfig(ctx.cwd); return `Path Guard: cleared all ${kind} paths (${persistNote(where)})`; } /** * Main /guard menu (shown when invoked with no/unknown args and a UI is * available). Extend this array to add future top-level actions. */ const GUARD_MAIN_MENU = [ "switch — Switch mode (切换防护模式)", "rules — Customize per-mode guard rules (定制每模式守护规则)", "paths — Manage protected & trusted paths (管理保护/信任路径)", ]; /** First step inside /guard paths: pick a category (loops until back/cancel). */ const GUARD_PATHS_CATEGORY_MENU = [ "protected — Custom protected paths (自定义受保护路径)", "trusted — Trusted paths, always allowed (信任路径,始终放行)", "back — Back to main menu (返回)", ]; /** Second step: per-category actions (loops until back to the category chooser). */ function pathsActionsMenu(kind: PathKind): string[] { return [ kind === "trusted" ? "add — Add a trusted path (添加信任路径)" : "add — Add a protected path (添加受保护路径)", kind === "trusted" ? "remove — Remove a trusted path (删除信任路径)" : "remove — Remove a protected path (删除受保护路径)", `clear — Clear all ${kind} paths (清空全部${kind === "trusted" ? "信任" : "受保护"}路径)`, "back — Back to path categories (返回分类)", ]; } /** * Interactive mode picker: decision matrix as the title, one of the 5 modes * as the choice. Switches mode (with the trusted/naked warning) and persists. * Returns true if a switch happened, false on cancel/invalid. */ async function runModePicker(ctx: ExtensionCommandContext): Promise { const choices = GUARD_MODES.map( (mo) => `${mo} — ${MODE_DESCRIPTIONS[mo]}${mo === currentMode ? " (current)" : ""}`, ); const chosen = await ctx.ui.select( `${rulesMatrix()}\n\nCurrent mode: ${currentMode} — choose one:`, choices, ); if (!chosen) { ctx.ui.notify("Cancelled, mode unchanged", "info"); return false; } const picked = chosen.split(/\s+/)[0] as GuardMode; if (!isGuardMode(picked)) return false; if (!(await confirmModeSwitch(picked, ctx))) { ctx.ui.notify( `Cancelled: switching to ${picked} requires confirmation`, "info", ); return false; } ctx.ui.notify(actionSwitchMode(picked, ctx), "info"); return true; } /** * Interactive management of paths. First picks a category (protected | trusted), * then loops add / remove / clear for that category until back returns here / exits. */ async function runPathsMenu(ctx: ExtensionCommandContext): Promise { while (true) { const category = await ctx.ui.select( "Path Guard — choose a path category:", GUARD_PATHS_CATEGORY_MENU, ); if (!category) { ctx.ui.notify("Cancelled, paths unchanged", "info"); return; } const kind = category.split(/\s+/)[0]; if (kind === "back") return; if (kind === "protected" || kind === "trusted") { await runPathCategoryMenu(kind, ctx); } } } /** Add/remove/clear loop for one path category; "back" returns to the category chooser. */ async function runPathCategoryMenu( kind: PathKind, ctx: ExtensionCommandContext, ): Promise { const list = pathList(kind); while (true) { if (list.length > 0) { ctx.ui.notify( `Path Guard ${kind} paths (${list.length}):\n` + list.map((p) => `· ${p}`).join("\n"), "info", ); } else { ctx.ui.notify(`Path Guard: no ${kind} paths configured`, "info"); } const action = await ctx.ui.select( "Choose an action:", pathsActionsMenu(kind), ); if (!action) { ctx.ui.notify(`Cancelled, ${kind} paths unchanged`, "info"); return; } const op = action.split(/\s+/)[0]; if (op === "back") return; if (op === "add") { const input = await ctx.ui.input( kind === "trusted" ? "Enter the path to ALWAYS trust (absolute, or relative to cwd):" : "Enter the path to protect (absolute, or relative to cwd):", "", ); if (input == null) { ctx.ui.notify("Cancelled add", "info"); continue; } const norm = normalizeProtectedEntry(input, ctx.cwd); if (kind === "trusted") { const denied = untrustableReason(norm); if (denied) { ctx.ui.notify(`Path Guard: cannot trust ${norm} — ${denied}`, "warning"); continue; } if (!(await confirmTrustPath(ctx))) { ctx.ui.notify( `Path Guard: not added — trusting ${norm} requires confirmation`, "info", ); continue; } } ctx.ui.notify(actionAddPath(kind, input, ctx), "info"); continue; } if (op === "remove") { if (list.length === 0) { ctx.ui.notify(`Path Guard: no ${kind} paths to remove`, "info"); continue; } const target = await ctx.ui.select("Choose a path to remove:", [...list]); if (!target) { ctx.ui.notify("Cancelled remove", "info"); continue; } ctx.ui.notify(actionRemovePath(kind, target, ctx), "info"); continue; } if (op === "clear") { if (list.length === 0) { ctx.ui.notify(`Path Guard: no ${kind} paths to clear`, "info"); continue; } const ok = await ctx.ui.confirm( `Clear all ${kind} paths?`, `Remove these ${list.length} path(s)?\n` + list.map((p) => `· ${p}`).join("\n"), ); if (!ok) { ctx.ui.notify("Cancelled clear", "info"); continue; } ctx.ui.notify(actionClearPaths(kind, ctx), "info"); } } } /** * Sub-menu for customizing per-mode guard rules (loops until back/cancel): * mode → pick a mode → rule editor; overview → read-only matrix; reset → clear ALL overrides. */ const GUARD_RULES_MENU = [ "mode — Pick a mode to customize (选择要定制的模式)", "overview — Show the full mode×rule matrix (查看完整规则矩阵)", "reset — Clear ALL rule overrides (清空全部规则覆盖)", "back — Back to main menu (返回)", ]; /** Widget id used to render the full effective rules matrix above the editor. */ const OVERVIEW_WIDGET = "path-guard-overview"; /** * Show the full rules matrix above the editor. Only used by the legacy chained * fallback menus (the overlay renders the matrix in its own overview screen). */ async function showMatrixViewer( ctx: ExtensionCommandContext, matrix: string, ): Promise { ctx.ui.setWidget(OVERVIEW_WIDGET, matrix.split("\n")); ctx.ui.notify( "Effective rules matrix shown above the editor (返回以收起)", "info", ); } /** The effective (override-aware) rule matrix as a readable table. */ function rulesMatrix(): string { const head = "rule".padEnd(30) + GUARD_MODES.map((mo) => mo.padStart(8)).join(""); const rows = RULE_IDS.map((r) => { const cell = (l: RuleLevel) => l === "block" ? "B" : l === "confirm" ? "?" : "."; return ( r.padEnd(30) + GUARD_MODES.map((mo) => cell(rlFor(mo, r)).padStart(8)).join("") ); }); // Surface confirm-dialog session passes — they are not in `config.rules`. const session: string[] = []; for (const mo of GUARD_MODES) { for (const r of sessionPass[mo] ?? []) session.push(`${mo}.${r}`); } const note = session.length ? `\n\nSession-only pass (not persisted): ${session.sort().join(", ")}` : ""; return `Path Guard effective rules matrix (B=block ?=confirm .=pass):\n${head}\n${rows.join("\n")}${note}`; } /** Human-readable list of the current rule overrides (or a notice if none). */ function rulesSummary(): string { const out: string[] = []; for (const mo of GUARD_MODES) { const ov = config.rules[mo]; if (!ov) continue; for (const r of RULE_IDS) { if (ov[r] !== undefined) out.push(`${mo}.${r} = ${ov[r]}`); } } return out.length ? `Path Guard rule overrides (${out.length}):\n` + out.join("\n") : "Path Guard: no rule overrides — all modes use built-in defaults"; } /** * Level picker for a single rule in a mode. Picks block/confirm/pass, or reset * (delete the override so the built-in default applies). Returns to the caller, * which then re-shows the rule list — that's the "loop" letting the user set * several rules in one mode without re-navigating. */ async function ruleLevelPicker( mode: GuardMode, rule: RuleId, ctx: ExtensionCommandContext, ): Promise { const dflt = DEFAULT_MODES[mode][rule]; const cur = rlFor(mode, rule); const options = [ ...RULE_LEVELS.map( (l) => `${RULE_LEVEL_LABELS[l]}${l === cur ? " (current)" : ""}${l === dflt ? " [default]" : ""}`, ), `reset — back to built-in default (${dflt}) (恢复该条默认)`, "back — Back to rule list (返回)", ]; const picked = await ctx.ui.select( `Mode: ${mode} · Rule: ${rule} — ${RULE_DESCRIPTIONS[rule]}\n` + `Current: ${cur} · Built-in default: ${dflt}`, options, ); if (!picked) return; const op = picked.split(/\s+/)[0]; if (op === "back") return; if (op === "reset") { ctx.ui.notify(actionResetRule(mode, rule, ctx), "info"); return; } if (isRuleLevel(op)) { ctx.ui.notify(actionSetRule(mode, rule, op, ctx), "info"); } } /** * Rule editor for one mode: shows all 17 rules with their current levels, lets * the user set several in a row (each level pick returns here), and offers * reset (this mode) + back. */ async function runModeEditor( mode: GuardMode, ctx: ExtensionCommandContext, ): Promise { while (true) { const title = `Mode: ${mode} — pick a rule to set (current levels shown):\n` + RULE_IDS.map((r) => ` ${r} = ${rlFor(mode, r)}`).join("\n"); const options = [ ...RULE_IDS.map((r) => `${r} — ${RULE_DESCRIPTIONS[r]} (${rlFor(mode, r)})`), "reset — Reset this mode to built-in defaults (恢复该模式默认)", "back — Back to mode list (返回)", ]; const picked = await ctx.ui.select(title, options); if (!picked) { ctx.ui.notify("Cancelled, rules unchanged", "info"); return; } const op = picked.split(/\s+/)[0]; if (op === "back") return; if (op === "reset") { const ok = await ctx.ui.confirm( `Reset mode "${mode}" to built-in defaults?`, "", ); if (!ok) continue; ctx.ui.notify(actionResetMode(mode, ctx), "info"); continue; } if ((RULE_IDS as readonly string[]).includes(op)) { await ruleLevelPicker(mode, op as RuleId, ctx); } } } /** * Mode sub-menu: the 5 modes (each showing override count / current), plus * reset (reset a single mode) and back. */ async function runModeSubmenu(ctx: ExtensionCommandContext): Promise { while (true) { const options = [ ...GUARD_MODES.map((mo) => { const n = Object.keys(config.rules[mo] ?? {}).length; return `${mo} — ${MODE_DESCRIPTIONS[mo]}${n ? ` (${n} overrides)` : ""}${mo === currentMode ? " (current)" : ""}`; }), "reset — Reset a mode to built-in defaults (恢复某模式默认)", "back — Back to rules menu (返回)", ]; const picked = await ctx.ui.select("Pick a mode to customize:", options); if (!picked) { ctx.ui.notify("Cancelled, rules unchanged", "info"); return; } const op = picked.split(/\s+/)[0]; if (op === "back") return; if (op === "reset") { const target = await ctx.ui.select( "Reset which mode to its built-in defaults?", GUARD_MODES.map((mo) => `${mo} — ${MODE_DESCRIPTIONS[mo]}`), ); if (!target) continue; const mo = target.split(/\s+/)[0] as GuardMode; if (!isGuardMode(mo)) continue; const ok = await ctx.ui.confirm( `Reset mode "${mo}" to built-in defaults?`, "", ); if (!ok) continue; ctx.ui.notify(actionResetMode(mo, ctx), "info"); continue; } if (isGuardMode(op)) await runModeEditor(op, ctx); } } /** * Main rules menu (loops until back/cancel). Each iteration shows a summary of * current overrides; overview shows the full matrix on demand. */ async function runRulesMenu(ctx: ExtensionCommandContext): Promise { // The full matrix is far too large for a notify popup, so it is rendered as a // persistent read-only widget above the editor and cleared when leaving the menu. const clearOverview = () => { try { ctx.ui.setWidget(OVERVIEW_WIDGET, undefined); } catch { /* widget API unavailable (e.g. bare mock / print mode) */ } }; while (true) { ctx.ui.notify(rulesSummary(), "info"); const action = await ctx.ui.select("Choose an action:", GUARD_RULES_MENU); if (!action) { clearOverview(); ctx.ui.notify("Cancelled, rules unchanged", "info"); return; } const op = action.split(/\s+/)[0]; if (op === "back") { clearOverview(); return; } if (op === "overview") { await showMatrixViewer(ctx, rulesMatrix()); continue; } if (op === "reset") { const ok = await ctx.ui.confirm( "Clear ALL rule overrides?", "Reset every mode back to its built-in defaults?", ); if (!ok) continue; ctx.ui.notify(actionResetAllRules(ctx), "info"); continue; } if (op === "mode") await runModeSubmenu(ctx); } } // ─── Interactive /guard overlay (single self-contained popup) ────────── // B1: the whole /guard settings flow (mode / rules / paths, including all // confirmations and the path text input) lives inside ONE floating overlay // component. The tool_call interception prompts are unrelated and still use the // host's built-in ctx.ui.select/confirm. /** One screen in the /guard overlay navigation stack (the top of the stack is shown). */ type PanelScreen = | { kind: "main"; idx: number } | { kind: "mode"; idx: number } | { kind: "rules"; idx: number } | { kind: "ruleMode"; idx: number } | { kind: "ruleEditor"; mode: GuardMode; idx: number } | { kind: "ruleLevel"; mode: GuardMode; rule: RuleId; idx: number } | { kind: "overview"; scroll: number } | { kind: "pathsCategory"; idx: number } | { kind: "pathsActions"; pathKind: PathKind; idx: number } | { kind: "resetModePick"; idx: number } | { kind: "pathsRemove"; pathKind: PathKind; idx: number } | { kind: "pathsAdd"; pathKind: PathKind }; /** An in-panel yes/no confirmation (never offered by the direct /guard shortcut). */ interface PendingConfirm { title: string; body: string; onConfirm: () => void; } /** Minimal theme surface the panel uses (kept loose so test mocks are accepted). */ type PanelTheme = { fg: (color: any, text: string) => string }; /** The option labels for a screen; the leading token is the action id. */ function panelOptions(s: PanelScreen): string[] { switch (s.kind) { case "main": return GUARD_MAIN_MENU; case "mode": return GUARD_MODES.map( (mo) => `${mo} — ${MODE_DESCRIPTIONS[mo]}${mo === currentMode ? " (current)" : ""}`, ); case "rules": return GUARD_RULES_MENU; case "ruleMode": return [ ...GUARD_MODES.map((mo) => { const n = Object.keys(config.rules[mo] ?? {}).length; return `${mo} — ${MODE_DESCRIPTIONS[mo]}${n ? ` (${n} overrides)` : ""}${mo === currentMode ? " (current)" : ""}`; }), "reset — Reset a mode to built-in defaults (恢复某模式默认)", "back — Back to rules menu (返回)", ]; case "resetModePick": return [ ...GUARD_MODES.map((mo) => `${mo} — ${MODE_DESCRIPTIONS[mo]}`), "back — Back (返回)", ]; case "ruleEditor": return [ ...RULE_IDS.map( (r) => `${r} — ${RULE_DESCRIPTIONS[r]} (${rlFor(s.mode, r)})`, ), "reset — Reset this mode to built-in defaults (恢复该模式默认)", "back — Back to mode list (返回)", ]; case "ruleLevel": return [ ...RULE_LEVELS.map( (l) => `${RULE_LEVEL_LABELS[l]}${l === rlFor(s.mode, s.rule) ? " (current)" : ""}${l === DEFAULT_MODES[s.mode][s.rule] ? " [default]" : ""}`, ), `reset — back to built-in default (${DEFAULT_MODES[s.mode][s.rule]}) (恢复该条默认)`, "back — Back to rule list (返回)", ]; case "pathsCategory": return GUARD_PATHS_CATEGORY_MENU; case "pathsActions": return pathsActionsMenu(s.pathKind); case "pathsRemove": return [...pathList(s.pathKind), "back — Back to actions (返回)"]; case "pathsAdd": case "overview": return []; } } /** Draw a bordered dialog frame around inner lines, clamped to the render width. */ function framePanel(inner: string[], width: number): string[] { const w = Math.max(2, width); const contentW = Math.max(0, w - 2); const top = "┌" + "─".repeat(contentW) + "┐"; const bottom = "└" + "─".repeat(contentW) + "┘"; const body = inner.map((line) => { const t = truncateToWidth(line, contentW, "…"); const pad = " ".repeat(Math.max(0, contentW - visibleWidth(t))); return "│" + t + pad + "│"; }); return [top, ...body, bottom]; } /** * The self-contained /guard settings popup: a small screen-stack state machine * rendered as one floating overlay. It owns its own confirmations and its own * single-line path input, so nothing else is shown while it is open. */ class GuardPanel implements Component, Focusable { /** Focusable — set by the TUI so the embedded Input can position the cursor. */ focused = false; private stack: PanelScreen[] = [{ kind: "main", idx: 0 }]; private confirmState: PendingConfirm | null = null; private confirmIdx = 0; private status = ""; private addInput: Input | null = null; private ctx: ExtensionCommandContext; private theme: PanelTheme; private requestRender: () => void; private done: (result: void) => void; constructor( ctx: ExtensionCommandContext, theme: PanelTheme, requestRender: () => void, done: (result: void) => void, ) { this.ctx = ctx; this.theme = theme; this.requestRender = requestRender; this.done = done; } private current(): PanelScreen { return this.stack[this.stack.length - 1]; } private push(s: PanelScreen): void { this.stack.push(s); if (s.kind === "pathsAdd") { this.addInput = new Input({ placeholder: "absolute, ~, or path relative to cwd", }); this.addInput.onSubmit = (v) => this.submitAdd(v); this.addInput.onEscape = () => this.pop(); } this.requestRender(); } private pop(): void { if (this.stack.length <= 1) { this.done(); return; } const leaving = this.current(); this.stack.pop(); if (leaving.kind === "pathsAdd") this.addInput = null; this.requestRender(); } private goMain(): void { this.stack = [{ kind: "main", idx: 0 }]; this.addInput = null; this.requestRender(); } private move(delta: number): void { const s = this.current(); const n = panelOptions(s).length; if (n === 0 || !("idx" in s)) return; s.idx = Math.max(0, Math.min(n - 1, s.idx + delta)); } private askConfirm(title: string, body: string, onConfirm: () => void): void { this.confirmState = { title, body, onConfirm }; this.confirmIdx = 0; this.requestRender(); } private resolveConfirm(yes: boolean): void { const c = this.confirmState; this.confirmState = null; this.requestRender(); if (c && yes) c.onConfirm(); } private requestModeSwitch(mode: GuardMode): void { if (mode === "trusted") { this.askConfirm("⚠️ Switch to trusted mode?", TRUSTED_SWITCH_WARNING, () => this.doSwitch(mode), ); } else if (mode === "naked") { this.askConfirm( "⚠️ Switch to NAKED mode?", NAKED_SWITCH_WARNING_1, () => this.askConfirm( "⚠️⚠️ FINAL confirmation — disable ALL protection?", NAKED_SWITCH_WARNING_2, () => this.doSwitch(mode), ), ); } else { this.doSwitch(mode); } } private doSwitch(mode: GuardMode): void { this.status = actionSwitchMode(mode, this.ctx); this.goMain(); } private submitAdd(raw: string): void { const s = this.current(); if (s.kind !== "pathsAdd") return; const kind = s.pathKind; if (!raw.trim()) return; if (kind === "trusted") { const norm = normalizeProtectedEntry(raw, this.ctx.cwd); const denied = untrustableReason(norm); if (denied) { this.status = `Path Guard: cannot trust ${norm} — ${denied}`; this.requestRender(); return; } this.askConfirm("⚠️ Trust this path?", TRUST_PATH_WARNING, () => { this.status = actionAddPath(kind, raw, this.ctx); this.pop(); }); return; } this.status = actionAddPath(kind, raw, this.ctx); this.pop(); } private activate(): void { const s = this.current(); const opts = panelOptions(s); if (opts.length === 0) return; const id = opts[(s as { idx: number }).idx].split(/\s+/)[0]; switch (s.kind) { case "main": if (id === "switch") this.push({ kind: "mode", idx: 0 }); else if (id === "rules") this.push({ kind: "rules", idx: 0 }); else if (id === "paths") this.push({ kind: "pathsCategory", idx: 0 }); return; case "mode": { const mode = GUARD_MODES[s.idx]; if (mode) this.requestModeSwitch(mode); return; } case "rules": if (id === "mode") this.push({ kind: "ruleMode", idx: 0 }); else if (id === "overview") this.push({ kind: "overview", scroll: 0 }); else if (id === "reset") this.askConfirm( "Clear ALL rule overrides?", "Reset every mode back to its built-in defaults?", () => { this.status = actionResetAllRules(this.ctx); this.requestRender(); }, ); else if (id === "back") this.pop(); return; case "ruleMode": if (id === "reset") this.push({ kind: "resetModePick", idx: 0 }); else if (id === "back") this.pop(); else if (isGuardMode(id)) this.push({ kind: "ruleEditor", mode: id, idx: 0 }); return; case "resetModePick": { if (id === "back") { this.pop(); return; } if (!isGuardMode(id)) return; const mode = id; this.askConfirm( `Reset mode "${mode}" to built-in defaults?`, "", () => { this.status = actionResetMode(mode, this.ctx); this.pop(); }, ); return; } case "ruleEditor": { const mode = s.mode; if (id === "reset") this.askConfirm( `Reset mode "${mode}" to built-in defaults?`, "", () => { this.status = actionResetMode(mode, this.ctx); this.requestRender(); }, ); else if (id === "back") this.pop(); else if ((RULE_IDS as readonly string[]).includes(id)) this.push({ kind: "ruleLevel", mode, rule: id as RuleId, idx: 0, }); return; } case "ruleLevel": { const { mode, rule } = s; if (id === "back") this.pop(); else if (id === "reset") { this.status = actionResetRule(mode, rule, this.ctx); this.pop(); } else if (isRuleLevel(id)) { this.status = actionSetRule(mode, rule, id, this.ctx); this.pop(); } return; } case "pathsCategory": if (id === "protected" || id === "trusted") this.push({ kind: "pathsActions", pathKind: id, idx: 0 }); else if (id === "back") this.pop(); return; case "pathsActions": { const kind = s.pathKind; if (id === "add") this.push({ kind: "pathsAdd", pathKind: kind }); else if (id === "remove") { if (pathList(kind).length === 0) { this.status = `Path Guard: no ${kind} paths to remove`; this.requestRender(); } else this.push({ kind: "pathsRemove", pathKind: kind, idx: 0 }); } else if (id === "clear") { if (pathList(kind).length === 0) { this.status = `Path Guard: no ${kind} paths to clear`; this.requestRender(); } else this.askConfirm( `Clear all ${kind} paths?`, `Remove these ${pathList(kind).length} path(s)?\n` + pathList(kind) .map((p) => `· ${p}`) .join("\n"), () => { this.status = actionClearPaths(kind, this.ctx); this.requestRender(); }, ); } else if (id === "back") this.pop(); return; } case "pathsRemove": { const kind = s.pathKind; const list = pathList(kind); if (s.idx >= list.length) { this.pop(); return; } this.status = actionRemovePath(kind, list[s.idx], this.ctx); this.pop(); return; } case "pathsAdd": case "overview": return; } } handleInput(data: string): void { if (this.confirmState) { if ( matchesKey(data, "up") || matchesKey(data, "down") || matchesKey(data, "left") || matchesKey(data, "right") || matchesKey(data, "tab") ) { this.confirmIdx = this.confirmIdx === 0 ? 1 : 0; this.requestRender(); return; } if (matchesKey(data, "return") || matchesKey(data, "enter")) { this.resolveConfirm(this.confirmIdx === 0); return; } if (matchesKey(data, "escape")) this.resolveConfirm(false); return; } const s = this.current(); if (s.kind === "pathsAdd") { this.addInput?.handleInput(data); this.requestRender(); return; } if (s.kind === "overview") { const maxScroll = Math.max(0, rulesMatrix().split("\n").length - 1); if (matchesKey(data, "up")) s.scroll = Math.max(0, s.scroll - 1); else if (matchesKey(data, "down")) s.scroll = Math.min(maxScroll, s.scroll + 1); else if (matchesKey(data, "pageup") || matchesKey(data, "ctrl+u")) s.scroll = Math.max(0, s.scroll - 10); else if (matchesKey(data, "pagedown") || matchesKey(data, "ctrl+d")) s.scroll = Math.min(maxScroll, s.scroll + 10); else if (matchesKey(data, "home")) s.scroll = 0; else if (matchesKey(data, "end")) s.scroll = maxScroll; else if ( matchesKey(data, "escape") || matchesKey(data, "q") || matchesKey(data, "return") || matchesKey(data, "enter") ) { this.pop(); return; } this.requestRender(); return; } if (matchesKey(data, "up")) this.move(-1); else if (matchesKey(data, "down")) this.move(1); else if (matchesKey(data, "return") || matchesKey(data, "enter")) this.activate(); else if (matchesKey(data, "escape")) this.pop(); else if (matchesKey(data, "q") && s.kind === "main") { this.done(); return; } this.requestRender(); } /** Windowed selection list (bounded so the panel never grows unbounded). */ private listLines(items: string[], idx: number, max = 14): string[] { if (items.length === 0) return ["(none)"]; const n = items.length; const start = Math.max(0, Math.min(idx - Math.floor(max / 2), n - max)); const end = Math.min(n, start + max); const lines: string[] = []; if (start > 0) lines.push(` … ${start} above`); for (let i = start; i < end; i++) lines.push(`${i === idx ? "▶ " : " "}${items[i]}`); if (end < n) lines.push(` … ${n - end} below`); return lines; } private overviewLines(lines: string[], scroll: number): string[] { const rows = 14; const maxScroll = Math.max(0, lines.length - rows); const start = Math.min(scroll, maxScroll); const end = Math.min(lines.length, start + rows); const out = lines.slice(start, end); if (start > 0) out.unshift(` ↑ ${start} more`); if (end < lines.length) out.push(` ↓ ${lines.length - end} more`); return out; } private footerHint(): string { if (this.confirmState) return "↑/↓ choose · ⏎ confirm · esc cancel"; const s = this.current(); if (s.kind === "overview") return "↑/↓/PgUp/PgDn scroll · ⏎/esc close"; if (s.kind === "pathsAdd") return "type a path · ⏎ submit · esc back"; return `↑/↓ move · ⏎ select · esc back${s.kind === "main" ? " · q quit" : ""}`; } private renderScreen(s: PanelScreen, width: number): string[] { const out: string[] = []; switch (s.kind) { case "main": out.push("Choose an action:", ""); out.push(...this.listLines(panelOptions(s), s.idx)); break; case "mode": out.push(...rulesMatrix().split("\n"), ""); out.push(`Current mode: ${currentMode} — choose one:`); out.push(...this.listLines(panelOptions(s), s.idx)); break; case "rules": out.push(rulesSummary().split("\n")[0], ""); out.push(...this.listLines(panelOptions(s), s.idx)); break; case "ruleMode": out.push("Pick a mode to customize:", ""); out.push(...this.listLines(panelOptions(s), s.idx)); break; case "resetModePick": out.push("Reset which mode to its built-in defaults?", ""); out.push(...this.listLines(panelOptions(s), s.idx)); break; case "ruleEditor": out.push( `Mode: ${s.mode} — pick a rule to set (current levels shown):`, ); out.push(...this.listLines(panelOptions(s), s.idx, 16)); break; case "ruleLevel": out.push( `Mode: ${s.mode} · Rule: ${s.rule} — ${RULE_DESCRIPTIONS[s.rule]}`, ); out.push( `Current: ${rlFor(s.mode, s.rule)} · Built-in default: ${DEFAULT_MODES[s.mode][s.rule]}`, "", ); out.push(...this.listLines(panelOptions(s), s.idx)); break; case "overview": out.push(...this.overviewLines(rulesMatrix().split("\n"), s.scroll)); break; case "pathsCategory": out.push("Choose a path category:", ""); out.push(...this.listLines(panelOptions(s), s.idx)); break; case "pathsActions": { const list = pathList(s.pathKind); out.push( `${s.pathKind} paths (${list.length}):`, ...(list.length ? list.slice(0, 6).map((p) => `· ${p}`) : ["(none)"]), "", ); out.push(...this.listLines(panelOptions(s), s.idx)); break; } case "pathsRemove": out.push(`Choose a ${s.pathKind} path to remove:`, ""); out.push(...this.listLines(panelOptions(s), s.idx)); break; case "pathsAdd": out.push( s.pathKind === "trusted" ? "Enter the path to ALWAYS trust:" : "Enter the path to protect:", "", ); if (this.addInput) out.push(...this.addInput.render(Math.max(20, width - 6))); break; } return out; } render(width: number): string[] { if (this.addInput) this.addInput.focused = this.focused; const fg = (color: string, text: string) => { try { return this.theme.fg(color, text); } catch { return text; } }; const inner: string[] = [ fg("accent", `Path Guard · mode: ${currentMode}`), "", ]; if (this.confirmState) { inner.push(fg("warning", this.confirmState.title)); for (const line of this.confirmState.body.split("\n")) inner.push(line); inner.push(""); inner.push(`${this.confirmIdx === 0 ? "▶ " : " "}✅ Confirm (确认)`); inner.push(`${this.confirmIdx === 1 ? "▶ " : " "}❌ Cancel (取消)`); } else { inner.push(...this.renderScreen(this.current(), width)); } if (this.status) { inner.push(""); for (const line of this.status.split("\n").slice(0, 4)) inner.push(fg("dim", line)); } inner.push("", fg("dim", this.footerHint())); return framePanel(inner, width); } invalidate(): void { /* no cached render state */ } } /** Open the single /guard settings popup as a floating overlay. */ async function runGuardPanel(ctx: ExtensionCommandContext): Promise { await ctx.ui.custom( (tui, theme, _keybindings, done) => new GuardPanel(ctx, theme, () => tui.requestRender(), done), { overlay: true, overlayOptions: { anchor: "center", width: "80%", maxHeight: "80%", margin: 1, }, }, ); } export default function (pi: ExtensionAPI) { // Restore the persisted config on every new session (startup, /new, /resume all // fire session_start): active mode + user protected paths + rule overrides. pi.on("session_start", (_event, ctx) => { config = readSavedConfig(ctx.cwd, ctx.isProjectTrusted?.() === true); clearSessionPass(); extraProtected = config.extraProtected; trustedPaths = config.trustedPaths; setMode(config.mode, ctx.ui); // A persisted loose mode survives into every new session; surface it so the // user never runs unprotected without noticing (naked in particular). if (config.mode === "naked") { ctx.ui.notify( "⚠️ Path Guard restored in NAKED mode — nearly all protection is OFF " + "(persisted from a previous session). Use /guard normal to re-enable.", "warning", ); } else if (config.mode === "trusted") { ctx.ui.notify( "⚠️ Path Guard restored in trusted mode (persisted from a previous session): " + "in-project deletes and outside overwrites are no longer prompted. " + "Use /guard normal to re-enable prompts.", "warning", ); } }); // /guard slash command: view / switch mode, and manage custom protected paths pi.registerCommand("guard", { description: "Path Guard: /guard shows mode, /guard switches, /guard paths add|rm|list|clear manages paths", handler: async (args, ctx) => { const raw = args?.trim() ?? ""; const m = raw.toLowerCase(); // ── /guard paths … : manage user-configured protected paths (any mode) ── if (m === "paths" || m.startsWith("paths ")) { return handlePathsCommand(raw, ctx); } // Valid argument → switch directly (shortcut, no picker); trusted/naked require a warning confirmation if (isGuardMode(m)) { if (!(await confirmModeSwitch(m, ctx))) { ctx.ui.notify( `Cancelled: switching to ${m} requires confirmation`, "info", ); return; } config.mode = m; setMode(m, ctx.ui); const where = persistConfig(ctx.cwd); ctx.ui.notify( `Path Guard switched to: ${m} (${persistNote(where)})`, "info", ); return; } // No UI → cannot interact; just show the current mode if (!ctx.hasUI) { ctx.ui.notify(`Path Guard current mode: ${currentMode}`, "info"); return; } // Prefer the single self-contained overlay popup when the host supports // custom components; the chained select/confirm menus below remain as a // fallback for hosts/mocks that do not implement ctx.ui.custom. if (typeof ctx.ui.custom === "function") { return runGuardPanel(ctx); } // Interactive main menu loop (fallback for no/invalid arg): switch mode, manage // custom protected paths, or customize per-mode guard rules. Sub-menus return // here on "back"; only cancelling at this top level exits the command. while (true) { const main = await ctx.ui.select( `Path Guard — current mode: ${currentMode} — choose an action:`, GUARD_MAIN_MENU, ); if (!main) { ctx.ui.notify("Cancelled", "info"); return; } const option = main.split(/\s+/)[0]; if (option === "paths") await runPathsMenu(ctx); else if (option === "rules") await runRulesMenu(ctx); else await runModePicker(ctx); } }, }); pi.on("tool_call", (event, ctx) => { // ── write / edit ────────────────────────────────────────── if (event.toolName === "write" || event.toolName === "edit") { return checkWriteEdit(event.input as WriteToolInput | EditToolInput, ctx); } // ── bash ──────────────────────────────────────────────── if (event.toolName === "bash") { return checkBashCommand(event.input as BashToolInput, ctx); } }); } /** write/edit guard: protected → block; outside project / cwd is HOME → confirm */ function checkWriteEdit( input: WriteToolInput | EditToolInput, ctx: ExtensionContext, ): GuardVerdict { const path = input.path; if (!path) return; // Resolve the real cwd first (cwd may itself be a symlink), then the real target path, // preventing symlink escape to protected locations and symlink-cwd false positives const realCwd = resolveReal(ctx.cwd); const real = resolveReal(resolve(realCwd, expandHome(path))); // ① User-configured protected paths are guarded in EVERY mode (incl. naked). if (isUserProtectedPath(real)) { return { block: true, reason: withEscapeHints(`Path "${real}" is user-protected; write blocked.`), }; } // ② naked passes everything else (built-in protected paths & the write/edit tools). if (currentMode === "naked") return; // ③ Built-in protected path (incl. HOME-level credentials/config, inside or outside project) → block if (matchesProtectedPath(real)) { return { block: true, reason: withEscapeHints(`Path "${real}" is protected; write blocked.`), }; } // ③b A trusted path is always allowed — protection outranks trust (a trusted // entry is never a protected path), so skip the outside/HOME/in-project rules. if (isTrustedPath(real)) return; const outside = isOutsideCwd(real, realCwd); // ④ Outside the project dir OR cwd is HOME → per writeOutside / writeHome rule if (outside || realCwd === HOME) { const rule = outside ? "writeOutside" : "writeHome"; const lvl = rl(rule); if (lvl === "block") { return { block: true, reason: withEscapeHints(`Write blocked by rule (${rule}): ${real}`), }; } if (lvl === "confirm") { return askConfirm( ctx, outside ? `⚠️ File path is outside the project directory\n\nPath: ${real}\nProject: ${realCwd}` : `⚠️ Write operation in HOME directory\n\nPath: ${real}\nHOME: ${HOME}\n\nConfirm write?`, [rule], ); } return; // pass } // ⑤ In-project → per writeInProject rule const lvl = rl("writeInProject"); if (lvl === "block") { return { block: true, reason: withEscapeHints(`Write blocked by rule (writeInProject): ${real}`), }; } if (lvl === "confirm") { return askConfirm( ctx, `⚠️ strict mode: in-project write operation\n\nPath: ${real}\n\nConfirm write?`, ["writeInProject"], ); } return; // In-project and safe: allow } /** bash guard: scan segments then decide once (prevents "rm -rf safe && sudo reboot" segment bypass) */ function checkBashCommand( input: BashToolInput, ctx: ExtensionContext, ): GuardVerdict { const command = input.command ?? ""; if (!command.trim()) return; const realCwd = resolveReal(ctx.cwd); // Split by &&, ||, ;, |, newline; check each segment and aggregate results, // then decide once — so an early return from the first guarded segment can't skip later ones const blockReasons: string[] = []; const confirmNeeded: string[] = []; const confirmRules = new Set(); // Dangerous pipe-to-shell (curl … | bash, python -c '…' | sh) — the pipe // crosses segments, so scan the raw command before the per-segment loop. const pipeVerdict = scanPipeToShell(command, realCwd); if (pipeVerdict.kind === "block") { blockReasons.push(pipeVerdict.reason); } else if (pipeVerdict.kind === "confirm") { confirmNeeded.push(command.trim()); if (pipeVerdict.rule) confirmRules.add(pipeVerdict.rule); } for (const seg of splitSegments(command)) { const trimmed = seg.trim(); if (!trimmed) continue; const verdict = classifySegment(trimmed, realCwd, ctx.hasUI); if (verdict.kind === "block") { blockReasons.push(verdict.reason); } else if (verdict.kind === "confirm") { confirmNeeded.push(trimmed); if (verdict.rule) confirmRules.add(verdict.rule); } } // Aggregate: any hard block → block everything (fail-safe) if (blockReasons.length > 0) { return { block: true, reason: withEscapeHints(`Command blocked:\n${blockReasons.join("\n")}`), }; } // Segments needing confirmation → one prompt, confirm together if (confirmNeeded.length > 0) { return askConfirm( ctx, `⚠️ Commands requiring confirmation\n\n${confirmNeeded .map((s) => `· ${s}`) .join("\n")}\n\nConfirm execution?`, [...confirmRules], ); } return; // Safe command: allow } /** Verdict for a single segment */ type SegmentVerdict = | { kind: "block"; reason: string } | { kind: "confirm"; rule?: RuleId } | { kind: "pass" }; /** Per-segment check: protected redirect → block; dangerous commands → confirm/block; the rest to sub-judges / wrapper recursion */ /** Shell interpreters whose ` [flags] script.sh` form executes a script file. */ const SHELL_INTERPRETERS = new Set(["bash", "sh", "zsh", "dash", "ksh"]); /** * The script-file target of a `source`/`.` or ` script` command, * or null if the command is neither. For interpreters, inline-code forms (`-c`) are * excluded and only an argument that resolves to an existing file counts. */ function scriptTargetOf(cmdInfo: CmdInfo, realCwd: string): string | null { const cmd = cmdInfo.command; if (cmd === "source" || cmd === ".") { return cmdInfo.args.find((a) => !a.startsWith("-")) ?? null; } if (SHELL_INTERPRETERS.has(cmd)) { if (hasShortFlag(cmdInfo.args, "c") || hasLongFlag(cmdInfo.args, "command")) { return null; // inline code (bash -c '…') — handled by the shell-wrapper check } for (const a of cmdInfo.args) { if (a.startsWith("-")) continue; if (existsSync(resolveReal(resolve(realCwd, expandHome(a))))) return a; } return null; } return null; } /** * The literal remainder of an unresolvable `source`/`.`/interpreter target: the part * after a leading `$VAR` / `${VAR}` prefix, or the whole glob pattern. It is often * enough to recognise a protected target (`$D/id_rsa`, `$D/.ssh/config`) even though * the variable itself cannot be expanded. */ function unresolvedScriptTail(target: string): string { const m = target.match( /^\$(?:\{[A-Za-z_][A-Za-z0-9_]*\}|[A-Za-z_][A-Za-z0-9_]*)/, ); return m ? target.slice(m[0].length) : target; } /** * A `$VAR`-prefixed path cannot be matched against a user-protected entry (those are * absolute prefixes), so the literal tail is compared segment-wise against the * entries' basenames. Conservative direction: a same-named directory blocks too. */ function tailLooksUserProtected(tail: string): boolean { if (extraProtected.length === 0) return false; const segs = normalize("/x" + tail) .toLowerCase() .split(sep) .filter(Boolean); if (segs.length === 0) return false; const names = new Set(); for (const entry of extraProtected) { const parts = normalize(resolveReal(entry)) .toLowerCase() .split(sep) .filter(Boolean); if (parts.length > 0) names.add(parts[parts.length - 1]); } return segs.some((s) => names.has(s)); } /** * Verdict for a `source`/`.`/interpreter target that cannot be resolved statically * (a `$VAR` prefix or a glob). The literal tail is still inspected: * 1. user-protected tail → hard block, every mode (the user said "never") * 2. built-in protected tail → runScriptProtected (same as a literal target) * 3. no literal information at all (bare `$VAR` / bare glob) → conservative confirm * 4. otherwise → scriptUnresolved (per-mode ladder: strict block, normal/loose * confirm, trusted/naked pass) */ function judgeUnresolvedScriptTarget(target: string): SegmentVerdict { const tail = unresolvedScriptTail(target); // Nothing but separators / glob metacharacters → no information to act on. if (!/[^/*?[\]]/.test(tail)) { return inNaked() ? { kind: "pass" } : { kind: "confirm" }; } if (tailLooksUserProtected(tail)) { return { kind: "block", reason: `Script execution of user-protected path: ${target}`, }; } if (matchesProtectedPath("/__var__" + tail)) { return ruleVerdict( "runScriptProtected", `Script execution blocked by rule (runScriptProtected): ${target}`, ); } return ruleVerdict( "scriptUnresolved", `Script path cannot be resolved statically — confirm (scriptUnresolved): ${target}`, ); } /** * `source`/`.` or shell-interpreter script execution verdict — path-aware + tunable: * - user-protected target → hard block in EVERY mode (incl naked) * - built-in protected target → runScriptProtected rule (strict block / normal,loose confirm / trusted,naked pass) * - trusted target → always pass * - otherwise in-project → runScriptInProject, outside/HOME → runScriptOutside */ function judgeScript( _trimmed: string, cmdInfo: CmdInfo, realCwd: string, ): SegmentVerdict { const cmd = cmdInfo.command; const isSource = cmd === "source" || cmd === "."; if (!isSource && !SHELL_INTERPRETERS.has(cmd)) return { kind: "pass" }; if ( !isSource && (hasShortFlag(cmdInfo.args, "c") || hasLongFlag(cmdInfo.args, "command")) ) { return { kind: "pass" }; // interpreter inline code — not a script file } const target = scriptTargetOf(cmdInfo, realCwd); if (!target) { // `source` with no statically resolvable file → conservative; interpreter with // no existing script file → nothing to run → pass. return isSource && !inNaked() ? { kind: "confirm" } : { kind: "pass" }; } if (target.startsWith("$") || target.includes("*") || target.includes("?")) { return judgeUnresolvedScriptTarget(target); } const real = resolveReal(resolve(realCwd, expandHome(target))); // User-configured protected paths stay a hard block in every mode (incl naked). if (isUserProtectedPath(real)) { return { kind: "block", reason: `Script execution of user-protected path: ${target}`, }; } // Built-in protected paths → per-mode ladder (runScriptProtected). if (matchesProtectedPath(real)) { return ruleVerdict( "runScriptProtected", `Script execution blocked by rule (runScriptProtected): ${target}`, ); } // Trusted path → always allowed. if (isTrustedPath(real)) return { kind: "pass" }; const rule = isOutsideCwd(real, realCwd) || realCwd === HOME ? "runScriptOutside" : "runScriptInProject"; return ruleVerdict( rule, `Script execution blocked by rule (${rule}): ${target}`, ); } /** * Extract command substitutions (`$(...)` and backticks) from a command segment. * Substitutions inside single quotes are literal, so they are skipped. Nested * `$()` bodies are returned whole and handled by the recursive call. */ function extractCommandSubstitutions(input: string): string[] { const results: string[] = []; let i = 0; let inSingle = false; let inDouble = false; while (i < input.length) { const ch = input[i]; if (ch === "\\") { i += 2; continue; } if (ch === "'" && !inDouble) { inSingle = !inSingle; i++; continue; } if (ch === '"' && !inSingle) { inDouble = !inDouble; i++; continue; } if (inSingle) { i++; continue; } // Backtick substitution if (ch === "`") { const end = input.indexOf("`", i + 1); if (end < 0) { results.push(input.slice(i + 1)); break; } results.push(input.slice(i + 1, end)); i = end + 1; continue; } // $( ... ) — balanced, quote-aware; `$((` arithmetic is left to the parser if (ch === "$" && input[i + 1] === "(" && input[i + 2] !== "(") { let depth = 0; let j = i + 1; // points at the opening "(" let sq = false; let dq = false; for (; j < input.length; j++) { const c = input[j]; if (c === "\\") { j++; continue; } if (c === "'" && !dq) { sq = !sq; continue; } if (c === '"' && !sq) { dq = !dq; continue; } if (sq) continue; if (c === "(") depth++; else if (c === ")") { depth--; if (depth === 0) break; } } if (depth !== 0) { results.push(input.slice(i + 2)); break; } results.push(input.slice(i + 2, j)); i = j + 1; continue; } i++; } return results; } /** * Judge the inner command(s) of command substitutions. Each body may itself be a * compound command, so split it and aggregate (block > confirm > pass). */ function classifySubstitutions( substitutions: string[], realCwd: string, hasUI: boolean, depth: number, ): SegmentVerdict { const blockReasons: string[] = []; let confirm = false; for (const body of substitutions) { for (const seg of splitSegments(body)) { const s = seg.trim(); if (!s) continue; const v = classifySegment(s, realCwd, hasUI, depth); if (v.kind === "block") blockReasons.push(v.reason); else if (v.kind === "confirm") confirm = true; } } if (blockReasons.length > 0) { return { kind: "block", reason: blockReasons.join("\n") }; } if (confirm) return { kind: "confirm" }; return { kind: "pass" }; } /** * A command segment: recurse into any `$()` / backtick substitutions, then judge * the outer command. A hard block in a substitution wins; a confirm is deferred * until the outer verdict is known (block > confirm). */ function classifySegment( trimmed: string, realCwd: string, hasUI: boolean, depth = 0, ): SegmentVerdict { // Recursion depth guard (nested bash -c / eval / $() too deep to statically check → conservative confirm) if (depth > 4) return { kind: "confirm" }; // ⓪ Command substitutions run before the outer command, so judge their content // too — `echo "$(rm -rf x)"` must not slip through. const substitutions = extractCommandSubstitutions(trimmed); let subConfirm = false; if (substitutions.length > 0) { const subVerdict = classifySubstitutions( substitutions, realCwd, hasUI, depth + 1, ); if (subVerdict.kind === "block") { return { kind: "block", reason: `Command substitution blocked:\n${subVerdict.reason}`, }; } if (subVerdict.kind === "confirm") subConfirm = true; } const outer = classifySegmentOuter(trimmed, realCwd, hasUI, depth); if (outer.kind === "block") return outer; if (outer.kind === "confirm" || subConfirm) { // Preserve the rule id when the outer verdict is rule-driven (enables the // confirm dialog's session-pass option); a substitution-only confirm has none. return { kind: "confirm", rule: outer.kind === "confirm" ? outer.rule : undefined, }; } return { kind: "pass" }; } /** Judge one command segment (redirect / danger / wrapper / script / writers). */ function classifySegmentOuter( trimmed: string, realCwd: string, hasUI: boolean, depth = 0, ): SegmentVerdict { // ① Redirect check: // - Write to a protected path (echo x > .env etc.) → block in every mode (user paths too) // - "> existing file" (truncate, not >> append, not a device) → per truncate rule const redirect = extractRedirectTarget(trimmed); if (redirect) { // Variable/glob target can't be statically resolved (echo x > $F) → conservative confirm (pass in naked) if (isUnresolvedTarget(redirect.target)) { return inNaked() ? { kind: "pass" } : { kind: "confirm" }; } const real = resolveReal(resolve(realCwd, expandHome(redirect.target))); if (isUserProtectedPath(real)) { return { kind: "block", reason: `Redirect writes to user-protected path: ${trimmed}`, }; } if (!inNaked() && matchesProtectedPath(real)) { return { kind: "block", reason: `Redirect writes to protected path: ${trimmed}`, }; } // Redirect into a trusted path (incl. truncating it) → pass in every mode. if (isTrustedPath(real)) return { kind: "pass" }; if ( isTruncatingOp(redirect.op) && !DEVICE_TARGETS.has(redirect.target) && existsSync(real) ) { const rule = isOutsideCwd(real, realCwd) || realCwd === HOME ? "truncateOutside" : "truncateInProject"; return ruleVerdict(rule, `Truncate blocked by rule: ${trimmed}`); } // New/append target outside the project (or cwd is HOME) → per writeOutside / writeHome if (!DEVICE_TARGETS.has(redirect.target)) { const outside = isOutsideCwd(real, realCwd); if (outside || realCwd === HOME) { const rule = outside ? "writeOutside" : "writeHome"; return ruleVerdict( rule, `Redirect writes outside the project blocked by rule: ${trimmed}`, ); } } } // ② Dangerous commands → per blockGroup / confirmGroup rule const danger = dangerousLevel(trimmed); if (danger === "block") { return ruleVerdict( "blockGroup", `System-destructive command blocked: ${trimmed}`, ); } if (danger === "confirm") { const lvl = rl("confirmGroup"); if (lvl === "block") { return { kind: "block", reason: `Dangerous command blocked by rule: ${trimmed}`, }; } if (lvl === "confirm") { return hasUI ? { kind: "confirm" } : { kind: "block", reason: `Dangerous command blocked (no interactive UI): ${trimmed}`, }; } return { kind: "pass" }; // confirmGroup = pass } const cmdInfo = parseCommand(trimmed); if (!cmdInfo) return { kind: "pass" }; // ③ Shell wrapper (bash -c 'code' / eval 'code') → recursively check the inner code const wrapperVerdict = judgeShellWrapper( trimmed, cmdInfo, realCwd, hasUI, depth, ); if (wrapperVerdict.kind !== "pass") return wrapperVerdict; // ④ source / . / script.sh: runs a script file → path-aware + tunable const scriptVerdict = judgeScript(trimmed, cmdInfo, realCwd); if (scriptVerdict.kind !== "pass") return scriptVerdict; // ⑤-⑪ Pipeline for target-writing commands (git / dd / download / truncate / in-place edit / delete / overwrite / unzip -o) return judgeWriters(trimmed, cmdInfo, realCwd); } /** Shell wrapper verdict: recursively run the same checks on bash -c / eval inner code */ function judgeShellWrapper( _trimmed: string, cmdInfo: CmdInfo, realCwd: string, hasUI: boolean, depth: number, ): SegmentVerdict { const inner = unwrapShellWrapper(cmdInfo); if (!inner) return { kind: "pass" }; // A pipe-to-shell inside the wrapper (bash -c 'curl … | bash') crosses the // inner split segments, so scan it before recursing. const pipeVerdict = scanPipeToShell(inner, realCwd); if (pipeVerdict.kind === "block") { return { kind: "block", reason: `Inner command blocked:\n${pipeVerdict.reason}`, }; } if (pipeVerdict.kind === "confirm") return { kind: "confirm" }; const blockReasons: string[] = []; const confirmNeeded: string[] = []; for (const seg of splitSegments(inner)) { const s = seg.trim(); if (!s) continue; const v = classifySegment(s, realCwd, hasUI, depth + 1); if (v.kind === "block") blockReasons.push(v.reason); else if (v.kind === "confirm") confirmNeeded.push(s); } if (blockReasons.length > 0) { return { kind: "block", reason: `Inner command blocked:\n${blockReasons.join("\n")}`, }; } if (confirmNeeded.length > 0) return { kind: "confirm" }; return { kind: "pass" }; } /** Target-writing pipeline: judge each; return on the first non-pass; allow only when all pass */ function judgeWriters( trimmed: string, cmdInfo: CmdInfo, realCwd: string, ): SegmentVerdict { const pipeline: Array<(t: string, c: CmdInfo, r: string) => SegmentVerdict> = [ judgeGit, judgeDd, judgeDownload, judgeTruncate, judgeInPlace, judgeDelete, judgeOverwrite, ]; for (const judge of pipeline) { const v = judge(trimmed, cmdInfo, realCwd); if (v.kind !== "pass") return v; } // Forced extraction overwrite (unzip -o): archive contents unknowable → conservative confirm (pass in naked) if (cmdInfo.command === "unzip" && hasShortFlag(cmdInfo.args, "o")) { return inNaked() ? { kind: "pass" } : { kind: "confirm" }; } return { kind: "pass" }; } /** git destructive commands: clean -f / reset --hard / checkout -- . / restore . / branch -D / push --force / stash drop */ function judgeGit( _trimmed: string, cmdInfo: CmdInfo, _realCwd: string, ): SegmentVerdict { if (cmdInfo.command !== "git") return { kind: "pass" }; const args = cmdInfo.args; // Skip git global options (-C dir / -c key=val / --git-dir= etc.), find the subcommand let i = 0; while (i < args.length) { const a = args[i]; if (a === "-C" || a === "-c") { i += 2; continue; } if ( a.startsWith("--git-dir=") || a.startsWith("--work-tree=") || a === "--bare" || a === "--no-pager" || a === "--paginate" ) { i++; continue; } break; } const sub = args[i]; // Destructive git ops → per gitDestructive rule (block / confirm / pass) if (sub === "clean" && hasForceFlag(args)) return ruleVerdict("gitDestructive", "git clean --force blocked by rule"); if (sub === "reset" && args.includes("--hard")) return ruleVerdict("gitDestructive", "git reset --hard blocked by rule"); if ( (sub === "checkout" || sub === "switch") && (args.includes("--") || args.includes(".") || hasForceFlag(args)) ) return ruleVerdict( "gitDestructive", "git checkout destructive blocked by rule", ); // `git restore` writes the working tree. Only a whole-tree restore (`.`) counts // as destructive; `--source= -- ` is a routine operation and must // not trigger on its own. `--staged`-only restores touch the index, not files. if (sub === "restore") { const stagedOnly = (args.includes("--staged") || args.includes("-S")) && !args.includes("--worktree") && !args.includes("-W"); if (!stagedOnly && args.includes(".")) return ruleVerdict( "gitDestructive", "git restore destructive blocked by rule", ); } if (sub === "branch" && args.some((a) => a === "-D")) return ruleVerdict("gitDestructive", "git branch -D blocked by rule"); if (sub === "worktree" && args.includes("remove") && hasForceFlag(args)) return ruleVerdict( "gitDestructive", "git worktree remove --force blocked by rule", ); if (sub === "tag" && (args.includes("-d") || args.includes("--delete"))) return ruleVerdict("gitDestructive", "git tag -d blocked by rule"); if ( sub === "push" && args.some((a) => a === "-f" || a === "--force" || a === "--force-with-lease") ) return ruleVerdict("gitDestructive", "git push --force blocked by rule"); if (sub === "stash" && args.includes("drop")) return ruleVerdict("gitDestructive", "git stash drop blocked by rule"); return { kind: "pass" }; } /** Delete-command verdict (rm, rmdir, shred, ...); non-delete commands → pass */ function judgeDelete( trimmed: string, cmdInfo: CmdInfo, realCwd: string, ): SegmentVerdict { if (!isDeleteCommand(cmdInfo.command)) return { kind: "pass" }; // Query forms (command -v rm / rm --version etc., no path args) → pass if ( cmdInfo.args.every((a) => a.startsWith("-")) && /(-v|-V|--version|-h|--help)\b/.test(trimmed) ) { return { kind: "pass" }; } const pathArgs = extractPathArgs(cmdInfo.args, realCwd); // Protected paths first: user paths block in EVERY mode (incl. naked); built-in paths block except in naked for (const p of pathArgs) { if (isUserProtectedPath(p.path)) { return { kind: "block", reason: `Delete command targets user-protected path: ${p.path}`, }; } if (!inNaked() && matchesProtectedPath(p.path)) { return { kind: "block", reason: `Delete command targets protected path: ${p.path}`, }; } } // No concrete path (rm "$HOME/.ssh", rm ./* — variable/wildcard, not statically resolvable) → conservative confirm (pass in naked) if (pathArgs.length === 0) { return inNaked() ? { kind: "pass" } : { kind: "confirm" }; } // All concrete targets are trusted → pass in every mode (trusted-mode protection for those paths) if (pathArgs.every((p) => isTrustedPath(p.path))) { return { kind: "pass" }; } const externalPaths = pathArgs.filter((p) => p.isOutside); if (externalPaths.length > 0) { const list = externalPaths.map((p) => p.path).join(", "); // per deleteOutside rule: strict/normal block, loose confirm, trusted/naked pass return ruleVerdict( "deleteOutside", `Delete command targets paths outside the project directory: ${list}`, ); } // In-project delete → per deleteInProject rule (strict/normal confirm; loose/trusted/naked pass) return ruleVerdict("deleteInProject", "Delete command blocked by rule"); } /** * Overwrite-command verdict (mv/cp/install/tee/ln -f/rsync): * - Target hits a protected path → block * - Target exists (file, or dir with a basename conflict) → confirm in-project / block outside * - Target missing → confirm outside write / pass in-project (pure rename/create) * - -n/--no-clobber (explicit no-overwrite), ln without -f, tee -a (append) → pass */ function judgeOverwrite( _trimmed: string, cmdInfo: CmdInfo, realCwd: string, ): SegmentVerdict { if (!OVERWRITE_COMMANDS.has(cmdInfo.command)) return { kind: "pass" }; // ln only overwrites existing targets with -f/--force if (cmdInfo.command === "ln" && !hasForceFlag(cmdInfo.args)) { return { kind: "pass" }; } // -n/--no-clobber: explicit no-overwrite, safe to pass if (cmdInfo.args.includes("-n") || cmdInfo.args.includes("--no-clobber")) { return { kind: "pass" }; } // tee -a / --append: append, no overwrite if ( cmdInfo.command === "tee" && (cmdInfo.args.includes("-a") || cmdInfo.args.includes("--append")) ) { return { kind: "pass" }; } // rsync --delete: removes extra files in the target dir → conservative confirm (pass in naked) if (cmdInfo.command === "rsync" && cmdInfo.args.includes("--delete")) { return inNaked() ? { kind: "pass" } : { kind: "confirm" }; } // Resolve target: -t dir src... form vs the regular form (last operand is the target) let target: string | null = null; let sources: string[] = []; const tIdx = cmdInfo.args.indexOf("-t"); if (tIdx >= 0 && cmdInfo.args[tIdx + 1]) { target = cmdInfo.args[tIdx + 1]; sources = cmdInfo.args.filter((a) => !a.startsWith("-") && a !== target); } else { const operands = cmdInfo.args.filter((a) => !a.startsWith("-")); if (operands.length >= 2) { target = operands[operands.length - 1]; sources = operands.slice(0, -1); } } if (!target || sources.length === 0) return { kind: "pass" }; // Variable/wildcard not statically resolvable → conservative confirm (pass in naked) if (target.startsWith("$") || target.includes("*") || target.includes("?")) { return inNaked() ? { kind: "pass" } : { kind: "confirm" }; } // rsync/scp remote target (user@host:/path) is not a local path — resolving it // would fake an in-project path. Writing to a remote host → conservative confirm. if (isRemoteTarget(target)) { return inNaked() ? { kind: "pass" } : { kind: "confirm" }; } const real = resolveReal(resolve(realCwd, expandHome(target))); // ① Target hits a protected path → block (user paths in every mode; built-in except naked) if (isUserProtectedPath(real)) { return { kind: "block", reason: `Command may overwrite user-protected path: ${cmdInfo.command} ${target}`, }; } if (!inNaked() && matchesProtectedPath(real)) { return { kind: "block", reason: `Command may overwrite protected path: ${cmdInfo.command} ${target}`, }; } // Overwrite/rename target is inside a trusted path → pass (no prompt in any mode). if (isTrustedPath(real)) return { kind: "pass" }; const outside = isOutsideCwd(real, realCwd); // Outside overwrite of an existing target → per overwriteOutsideExisting rule const outsideOverwriteVerdict = (): SegmentVerdict => ruleVerdict( "overwriteOutsideExisting", `Command will overwrite a target outside the project directory: ${cmdInfo.command} ${target}`, ); // ② Target is an existing directory: check each source basename for conflicts if (existsSync(real) && isDirectory(real)) { const conflict = sources.some((s) => { // Source not statically resolvable → treat as a conflict if (s.startsWith("$") || s.includes("*") || s.includes("?")) return true; const srcReal = resolveReal(resolve(realCwd, expandHome(s))); return existsSync(join(real, basename(srcReal))); }); if (!conflict) return { kind: "pass" }; // Overwriting an existing target: outside per overwriteOutsideExisting; in-project per overwriteInProject return outside ? outsideOverwriteVerdict() : ruleVerdict("overwriteInProject", "Overwrite in project blocked by rule"); } // ③ Target is an existing file: will be overwritten if (existsSync(real)) { return outside ? outsideOverwriteVerdict() : ruleVerdict("overwriteInProject", "Overwrite in project blocked by rule"); } // ④ Target missing: outside → per overwriteOutsideNew; in-project → per writeInProject (strict confirm, others pass) if (outside) { return ruleVerdict( "overwriteOutsideNew", `Write outside the project blocked by rule: ${cmdInfo.command} ${target}`, ); } return ruleVerdict("writeInProject", "Write in project blocked by rule"); } /** Unwrap a shell wrapper: bash/sh/zsh -c 'code', eval 'code' → inner code; else null */ function unwrapShellWrapper(cmdInfo: CmdInfo): string | null { if (SHELL_WRAPPERS.has(cmdInfo.command)) { for (let i = 0; i < cmdInfo.args.length; i++) { const a = cmdInfo.args[i]; if (a === "--") break; // everything after is not a flag // short flag contains c (-c, -ec combos); long flags don't count if (a.startsWith("-") && !a.startsWith("--") && a.includes("c")) { const inner = cmdInfo.args.slice(i + 1).join(" "); return inner.trim() || null; } } return null; } if (cmdInfo.command === "eval") { const inner = cmdInfo.args.join(" "); return inner.trim() || null; } return null; } /** Whether args contain a short flag (supports -i.bak / -pi combos; single-dash only) */ function hasShortFlag(args: string[], ch: string): boolean { return args.some( (a) => a.startsWith("-") && !a.startsWith("--") && a.slice(1).includes(ch), ); } /** Whether args contain a long flag (--name or --name=value) */ function hasLongFlag(args: string[], name: string): boolean { return args.some((a) => a === `--${name}` || a.startsWith(`--${name}=`)); } /** dd verdict: of= pointing at a protected file → block (block-device writes covered by dangerous patterns) */ function judgeDd( trimmed: string, cmdInfo: CmdInfo, realCwd: string, ): SegmentVerdict { if (cmdInfo.command !== "dd") return { kind: "pass" }; for (const a of cmdInfo.args) { if (!a.startsWith("of=")) continue; const target = a.slice(3); if (!target) continue; if (target.startsWith("$") || target.includes("*") || target.includes("?")) { return inNaked() ? { kind: "pass" } : { kind: "confirm" }; } const real = resolveReal(resolve(realCwd, expandHome(target))); if (isUserProtectedPath(real)) { return { kind: "block", reason: `dd writes to user-protected path: ${trimmed}`, }; } if (!inNaked() && matchesProtectedPath(real)) { return { kind: "block", reason: `dd writes to protected path: ${trimmed}` }; } if (isTrustedPath(real)) continue; // Outside target → same outside logic as overwrite commands (existing vs new) if (!DEVICE_TARGETS.has(target) && isOutsideCwd(real, realCwd)) { const rule = existsSync(real) ? "overwriteOutsideExisting" : "overwriteOutsideNew"; return ruleVerdict( rule, `dd writes outside the project blocked by rule: ${trimmed}`, ); } } return { kind: "pass" }; } /** curl/wget verdict: output target hits a protected path → block */ function judgeDownload( _trimmed: string, cmdInfo: CmdInfo, realCwd: string, ): SegmentVerdict { if (cmdInfo.command !== "curl" && cmdInfo.command !== "wget") { return { kind: "pass" }; } const target = downloadTarget(cmdInfo.command, cmdInfo.args); if (!target) return { kind: "pass" }; if (target.startsWith("$") || target.includes("*") || target.includes("?")) { return inNaked() ? { kind: "pass" } : { kind: "confirm" }; } const real = resolveReal(resolve(realCwd, expandHome(target))); if (isUserProtectedPath(real)) { return { kind: "block", reason: `Download writes to user-protected path: ${cmdInfo.command} ${target}`, }; } if (!inNaked() && matchesProtectedPath(real)) { return { kind: "block", reason: `Download writes to protected path: ${cmdInfo.command} ${target}`, }; } if (isTrustedPath(real)) return { kind: "pass" }; // Outside target → same outside logic as overwrite commands (existing vs new) if (!DEVICE_TARGETS.has(target) && isOutsideCwd(real, realCwd)) { const rule = existsSync(real) ? "overwriteOutsideExisting" : "overwriteOutsideNew"; return ruleVerdict( rule, `Download writes outside the project blocked by rule: ${cmdInfo.command} ${target}`, ); } return { kind: "pass" }; } /** Extract the download output target; null if none explicit */ function downloadTarget(command: string, args: string[]): string | null { return command === "wget" ? wgetDownloadTarget(args) : curlDownloadTarget(args); } /** wget output target (-O / --output / --output-document all take an argument) */ function wgetDownloadTarget(args: string[]): string | null { for (let i = 0; i < args.length; i++) { const a = args[i]; if (a === "-O" || a === "--output" || a === "--output-document") { return args[i + 1] ?? null; } if (a.startsWith("--output=") || a.startsWith("--output-document=")) { return a.slice(a.indexOf("=") + 1); } } return null; } /** curl output target (-o / --output take an argument; -O has none, uses the URL basename) */ function curlDownloadTarget(args: string[]): string | null { for (let i = 0; i < args.length; i++) { const a = args[i]; if (a === "-o" || a === "--output" || a === "--output-document") { return args[i + 1] ?? null; } if (a.startsWith("--output=") || a.startsWith("--output-document=")) { return a.slice(a.indexOf("=") + 1); } if (a === "-O") { for (let j = i + 1; j < args.length; j++) { const u = args[j]; if (u.startsWith("-")) continue; const base = u.split("/").pop(); if (base) return base; break; } } } return null; } /** truncate verdict: target hits a protected path → block; existing non-device target → confirm */ function judgeTruncate( _trimmed: string, cmdInfo: CmdInfo, realCwd: string, ): SegmentVerdict { if (cmdInfo.command !== "truncate") return { kind: "pass" }; // Any target not statically resolvable (variable/wildcard) → conservative confirm (pass in naked) if ( cmdInfo.args.some( (a) => !a.startsWith("-") && (a.startsWith("$") || a.includes("*") || a.includes("?")), ) ) { return inNaked() ? { kind: "pass" } : { kind: "confirm" }; } for (const t of extractPathArgs(cmdInfo.args, realCwd)) { if (isUserProtectedPath(t.path)) { return { kind: "block", reason: `truncate truncates user-protected path: ${t.raw}`, }; } if (!inNaked() && matchesProtectedPath(t.path)) { return { kind: "block", reason: `truncate truncates protected path: ${t.raw}`, }; } // Trusted target truncate → pass in every mode. if (isTrustedPath(t.path)) continue; // Existing ordinary file truncated → per truncateInProject / truncateOutside if (!DEVICE_TARGETS.has(t.path) && existsSync(t.path)) { const rule = isOutsideCwd(t.path, realCwd) || realCwd === HOME ? "truncateOutside" : "truncateInProject"; return ruleVerdict(rule, `Truncate blocked by rule: ${t.raw}`); } } return { kind: "pass" }; } /** In-place edit verdict (sed -i / perl -i / ruby -i): target hits a protected path → block */ function judgeInPlace( _trimmed: string, cmdInfo: CmdInfo, realCwd: string, ): SegmentVerdict { if (!INPLACE_EDITORS.has(cmdInfo.command)) return { kind: "pass" }; if ( !hasShortFlag(cmdInfo.args, "i") && !hasLongFlag(cmdInfo.args, "in-place") ) { return { kind: "pass" }; } // sed syntax: sed -i 'script' file — target file is last (multi-file: only the last is checked; conservative enough) const dest = lastDestArg(cmdInfo.args); if (!dest) return { kind: "pass" }; if (dest.startsWith("$") || dest.includes("*") || dest.includes("?")) { return inNaked() ? { kind: "pass" } : { kind: "confirm" }; } const real = resolveReal(resolve(realCwd, expandHome(dest))); if (isUserProtectedPath(real)) { return { kind: "block", reason: `In-place edit of user-protected path: ${cmdInfo.command} ${dest}`, }; } if (!inNaked() && matchesProtectedPath(real)) { return { kind: "block", reason: `In-place edit of protected path: ${cmdInfo.command} ${dest}`, }; } // In-place edit of a trusted path → pass in every mode. if (isTrustedPath(real)) return { kind: "pass" }; return { kind: "pass" }; } // ─── Path Utils ─────────────────────────────────────────────────────── /** Whether an absolute path is outside cwd */ function isOutsideCwd(absolutePath: string, cwd: string): boolean { const normCwd = normalize(cwd); const normPath = normalize(absolutePath); if (normPath === normCwd) return false; const rel = relativePath(normCwd, normPath); return rel.startsWith("..") || rel === normPath; } /** Protected-path match regardless of in/out project (used by bash redirect/overwrite checks and the write guard) */ function matchesProtectedPath(absolutePath: string): boolean { const segments = normalize(absolutePath).toLowerCase().split(sep); for (const pattern of PROTECTED_PATH_PATTERNS) { const pat = pattern.toLowerCase(); const isDir = pat.endsWith("/"); const core = isDir ? pat.slice(0, -1) : pat; // Suffix patterns (*.pem, *.key): match any path segment if (core.startsWith("*.")) { const suffix = core.slice(1); if (segments.some((seg) => seg.endsWith(suffix))) return true; continue; } for (let i = 0; i < segments.length; i++) { const seg = segments[i]; if (seg === core) { // Dir patterns (.git/, node_modules/, etc.) match any directory segment; // file patterns (.env) only match the last segment if (isDir || i === segments.length - 1) return true; } // File-pattern variants (.env.local / .env.production, last segment) if (!isDir && i === segments.length - 1 && seg.startsWith(core + ".")) { // key files: exact name only, never a `.pub`/backup variant if (EXACT_ONLY_PATTERNS.has(core)) continue; // credentials: allow clearly non-secret template/example variants if (core === "credentials") { const ext = seg.slice(core.length); if (SAFE_CREDENTIAL_SUFFIXES.has(ext)) continue; } return true; } } } return false; } /** * Dangerous command classification: * - "block" → system-destructive (format/shutdown/bulk-delete/block-device writes), blocked in every mode * - "confirm" → privilege/remote/risky (sudo/ssh/chmod 777), blocked in strict, confirmed otherwise * - null → not dangerous */ function dangerousLevel(fullCommand: string): "block" | "confirm" | null { for (const pattern of BLOCK_DANGEROUS_PATTERNS) { if (pattern.test(fullCommand)) return "block"; } for (const pattern of CONFIRM_DANGEROUS_PATTERNS) { if (pattern.test(fullCommand)) return "confirm"; } return null; } // ─── Dangerous pipe-to-shell ─────────────────────────────────────────── /** Split on the pipe operator (|), but not the logical || ; quote-aware. */ function pipeGroups(input: string): string[] { const groups: string[] = []; let current = ""; let inSingle = false; let inDouble = false; for (let i = 0; i < input.length; i++) { const ch = input[i]; if (ch === "'" && !inDouble) { inSingle = !inSingle; current += ch; continue; } if (ch === '"' && !inSingle) { inDouble = !inDouble; current += ch; continue; } if (!inSingle && !inDouble && ch === "|") { if (input[i + 1] === "|") { // logical OR — keep the operator token together, not a pipe current += "||"; i++; continue; } if (current.trim()) groups.push(current.trim()); current = ""; continue; } current += ch; } if (current.trim()) groups.push(current.trim()); return groups; } /** * Whether a pipe's source references an external / outside-workspace resource. * Network fetchers (curl/wget) are treated as remote; interpreters are judged by * whether any path arg resolves outside the project. */ function pipeSourceIsExternal(sourceText: string, realCwd: string): boolean { const info = parseCommand(sourceText); if (!info) return false; if (info.command === "curl" || info.command === "wget") return true; for (const arg of info.args) { if (arg.startsWith("-")) continue; if (arg.includes("*") || arg.includes("?")) continue; if (arg.startsWith("$")) continue; if (/^[a-z][a-z0-9+.-]*:\/\//i.test(arg)) return true; // URL scheme const expanded = expandHome(arg); const real = resolveReal(resolve(realCwd, expanded)); if (isOutsideCwd(real, realCwd)) return true; } return false; } /** * Scan a command for a dangerous pipe into a shell (`curl … | bash`, * `python -c '…' | sh`, …). Per the pipeToShell* rules: strict confirms at all * positions, normal passes in-workspace / confirms outside, others pass. */ function scanPipeToShell(text: string, realCwd: string): SegmentVerdict { const groups = pipeGroups(text); const anyBlock: string[] = []; let confirmRule: RuleId | undefined; for (let i = 1; i < groups.length; i++) { const right = parseCommand(groups[i]); if (!right || !SHELL_WRAPPERS.has(right.command)) continue; const left = parseCommand(groups[i - 1]); if (!left || !PIPE_TO_SHELL_SOURCES.has(left.command)) continue; const external = pipeSourceIsExternal(groups[i - 1], realCwd); const rule: RuleId = external ? "pipeToShellOutside" : "pipeToShellInProject"; const reason = `Piping ${left.command} output into ${right.command} (potentially untrusted code): ${groups[i - 1]} | ${groups[i]}`; const lvl = rl(rule); if (lvl === "block") anyBlock.push(reason); else if (lvl === "confirm") confirmRule = rule; } if (anyBlock.length > 0) { return { kind: "block", reason: anyBlock.join("\n") }; } if (confirmRule) return { kind: "confirm", rule: confirmRule }; return { kind: "pass" }; } // ─── Command Parsing ────────────────────────────────────────────────── interface CmdInfo { command: string; // base command name (rm, rmdir, etc.) args: string[]; // non-flag args (potential paths) } /** Parse a shell command into name and args (strips prefix commands first) */ function parseCommand(fullCommand: string): CmdInfo | null { // Strip command-substitution $(...), subshell (...), and group {...} wrappers let cleaned = fullCommand.trim(); cleaned = cleaned.replace(/^\$\(\s*/, "").replace(/\s*\)$/, ""); cleaned = cleaned.replace(/^\(\s*/, "").replace(/\s*\)$/, ""); cleaned = cleaned.replace(/^\{\s*/, "").replace(/\s*;?\s*\}$/, ""); const tokens = splitShellTokens(cleaned); if (tokens.length === 0) return null; // Strip prefix commands (sudo/nohup/timeout/env etc.) along with their flags / numbers / VAR= assignments const stripped = stripPrefixTokens(tokens); if (stripped.length === 0) return null; // Drop the backslash prefix (\rm) and path prefix (/bin/rm) const raw = stripped[0].split("/").pop() ?? stripped[0]; const base = raw.replace(/^\\(?=[A-Za-z])/, ""); return { command: base, args: stripped.slice(1) }; } /** Strip prefix commands (sudo etc.), skipping their flags / numbers / VAR= assignments */ function stripPrefixTokens(tokens: string[]): string[] { const t = [...tokens]; while (t.length > 0 && PREFIX_COMMANDS.has(t[0])) { const prefix = t.shift()!; while ( t.length > 0 && (t[0].startsWith("-") || /^\d+$/.test(t[0]) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(t[0])) ) { const flag = t.shift()!; if (FLAGS_WITH_ARG.has(flag)) t.shift(); } // chroot's first argument is the NEWROOT path; skip it if (prefix === "chroot" && t.length > 0) t.shift(); } return t; } /** Whether the command is a delete command */ function isDeleteCommand(cmd: string): boolean { return DELETE_COMMANDS.has(cmd); } /** Whether args carry a force flag (-f / --force, supports -sf / -fdx combos) */ function hasForceFlag(args: string[]): boolean { return args.some((a) => { if (!a.startsWith("-")) return false; if (a.startsWith("--")) return a === "--force" || a.startsWith("--force="); return a.slice(1).includes("f"); }); } /** Overwrite command "target" — last non-flag arg; null if none */ function lastDestArg(args: string[]): string | null { for (let i = args.length - 1; i >= 0; i--) { const a = args[i]; if (a.startsWith("-")) continue; if (a === ">" || a === ">>" || a === "2>" || a === "2>>") continue; return a; } return null; } /** Extract path-like tokens from args, resolve to absolute, classify in/out */ function extractPathArgs( args: string[], cwd: string, ): Array<{ raw: string; path: string; isOutside: boolean }> { const results: Array<{ raw: string; path: string; isOutside: boolean }> = []; for (const arg of args) { // Skip flags if (arg.startsWith("-")) continue; // Skip wildcards/redirects if ( arg.includes("*") || arg.includes("?") || arg === ">" || arg === ">>" || arg === "2>" || arg === "2>>" ) continue; // Variable refs ("$HOME/.ssh") not statically resolvable → skip; falls into the no-path→confirm branch if (arg.startsWith("$")) continue; // Expand ~ / ~/xxx to HOME, or it'd be treated as an in-project relative path const expanded = expandHome(arg); const resolved = resolve(cwd, expanded); // Resolve symlinks so a delete target can't actually live outside the project const real = resolveReal(resolved); const outside = isOutsideCwd(real, cwd); results.push({ raw: arg, path: real, isOutside: outside }); } return results; } /** Expand ~ / ~/xxx to HOME */ function expandHome(p: string): string { if (p === "~") return HOME; if (p.startsWith("~/")) return join(HOME, p.slice(2)); // Foreign home (~user/...) can't be resolved statically and must never be // mistaken for an in-project relative path. Anchor it at the filesystem root // so the outside-project rules apply (conservative confirm / block instead // of a silent in-project pass). if (p.startsWith("~")) return "/" + p; return p; } /** Whether a path token carries shell variable/glob syntax that can't be statically resolved */ function isUnresolvedTarget(p: string): boolean { return p.includes("$") || p.includes("*") || p.includes("?"); } /** * Whether a command operand is an rsync/scp-style remote target * (`user@host:/path`, `host:/path`, `user@host::module`, `rsync://host/path`). * Remote targets must never be resolved as local (in-project) paths. */ function isRemoteTarget(p: string): boolean { if (p.startsWith("rsync://")) return true; // user@host:path (also git@github.com:owner/repo) if (/^[^/@:\s]+@[^/:\s]+:/.test(p)) return true; // host:path (no user, and not a local path like /foo or ./bar) if (/^[^/@:\s]+:/.test(p)) return true; return false; } /** Redirect target: { op, target }; null if none */ interface RedirectTarget { op: string; // redirect operator (>, 2>, &>, >>, 2>>, ...) target: string; // target path } /** Extract the redirect write target (> file, 2>>file, &> file, ...); null if none */ function extractRedirectTarget(fullCommand: string): RedirectTarget | null { const tokens = splitShellTokens(fullCommand); const REDIR = /^([0-9]*&?>>?\|?)(.*)$/; for (let i = 0; i < tokens.length; i++) { const m = REDIR.exec(tokens[i]); if (!m) continue; // Target glued to the same token (echo hi >.env) if (m[2]) { // fd duplication like 2>&1 → skip if (!m[2].startsWith("&")) return { op: m[1], target: m[2] }; continue; } // Target in the next token (> /dev/sda) const next = tokens[i + 1]; if (next && !next.startsWith("&")) return { op: m[1], target: next }; } return null; } /** Whether the operator is truncating (single >, or the >| noclobber override — not >> append) */ function isTruncatingOp(op: string): boolean { // `>|` / `2>|` explicitly clobber a file even under noclobber → truncating if (op.endsWith("|")) return true; return op.endsWith(">") && !op.endsWith(">>"); } /** Minimal shell tokenizer (handles single/double quotes) */ function splitShellTokens(input: string): string[] { const tokens: string[] = []; let current = ""; let inSingle = false; let inDouble = false; for (const ch of input) { if (ch === "'" && !inDouble) { inSingle = !inSingle; continue; } if (ch === '"' && !inSingle) { inDouble = !inDouble; continue; } if (/\s/.test(ch) && !inSingle && !inDouble) { if (current) { tokens.push(current); current = ""; } continue; } current += ch; } if (current) tokens.push(current); return tokens; } /** Split by shell operators (&&, ||, ;, |, newline); never inside quotes */ function splitSegments(input: string): string[] { const segments: string[] = []; let current = ""; let inSingle = false; let inDouble = false; for (let i = 0; i < input.length; i++) { const ch = input[i]; if (ch === "'" && !inDouble) { inSingle = !inSingle; current += ch; continue; } if (ch === '"' && !inSingle) { inDouble = !inDouble; current += ch; continue; } if (!inSingle && !inDouble) { // `>|` / `>>|` is the noclobber redirect override, not a pipe separator const noclobber = ch === "|" && input[i - 1] === ">"; const isSep = (ch === "|" && !noclobber) || ch === ";" || ch === "\n" || (ch === "&" && input[i + 1] === "&"); if (isSep) { if (current.trim()) segments.push(current.trim()); current = ""; if (ch === "&") i++; // skip the second & continue; } } current += ch; } if (current.trim()) segments.push(current.trim()); return segments; } /** Whether the path is a directory */ function isDirectory(p: string): boolean { try { return statSync(p).isDirectory(); } catch { return false; } } /** * Resolve symlinks to the real path. * For missing paths, walk upward from the nearest existing ancestor, resolve the first * resolvable parent, and append the remainder. Unlike top-down resolution, this correctly * handles mid-path symlinks (e.g. in-project lnk -> external dir), preventing deep missing * paths from being written through a symlink to outside the project; also handles symlink cwd. */ function resolveReal(p: string): string { try { return realpathSync(p); } catch { let cur = p; const tail: string[] = []; for (;;) { const parent = dirname(cur); if (parent === cur) break; // reached root; path doesn't exist at all try { const real = realpathSync(parent); return join(real, basename(cur), ...tail); } catch { tail.unshift(basename(cur)); cur = parent; } } return normalize(p); } } // ─── UI Interaction ─────────────────────────────────────────────────── // ─── Warning copy (shared by the host confirm dialogs and the /guard overlay) ── /** Body of the trusted-path warning (used by both confirm paths). */ const TRUST_PATH_WARNING = "A trusted path is ALWAYS allowed: path-guard will not block or prompt for writes/edits/deletes/overwrites/truncates inside it, in ANY mode (like trusted mode for just that path).\n\nOnly protected paths (which can never be trusted) still apply. Confirm trusting it?"; /** Body of the single trusted-mode switch warning. */ const TRUSTED_SWITCH_WARNING = "trusted is the most permissive mode: in-project deletes and outside overwrites/deletes of\nordinary files are no longer prompted. Only protected paths and system-destructive commands\nremain blocked.\n\npi's behavior boundary is very loose in this mode — please confirm the switch."; /** First of the two naked-mode switch warnings. */ const NAKED_SWITCH_WARNING_1 = "naked passes nearly everything: protected paths (.env/.ssh/keys), write/edit tool checks, git\ndestructive ops, truncation, and outside deletes/overwrites are no longer blocked or prompted.\nOnly system-destructive commands (mkfs/reboot/bulk-delete/block-device writes) are still\nconfirmed — everything else is allowed without a prompt."; /** Second, final naked-mode switch warning. */ const NAKED_SWITCH_WARNING_2 = "This is the final step. After this, path-guard passes nearly every operation with no blocking and\nno confirmation, including writes to protected paths and git destructive / truncate / outside\ndelete operations. Only system-destructive commands (mkfs/reboot/bulk-delete/block-device\nwrites) will still prompt for confirmation.\n\nOnly switch if you are certain you want minimal protection."; /** Warning confirmation before adding a trusted path: that path bypasses all path-guard prompts in every mode */ async function confirmTrustPath( ctx: ExtensionCommandContext, ): Promise { // No UI (headless) cannot confirm → conservatively refuse the trust if (!ctx.hasUI) return false; return ctx.ui.confirm("⚠️ Trust this path?", TRUST_PATH_WARNING); } /** Warning confirmation before switching to trusted: behavior boundary is very loose; requires explicit user confirmation */ async function confirmTrustedSwitch( ctx: ExtensionCommandContext, ): Promise { // No UI (headless) cannot confirm → conservatively refuse the switch if (!ctx.hasUI) return false; return ctx.ui.confirm("⚠️ Switch to trusted mode?", TRUSTED_SWITCH_WARNING); } /** Double confirmation before switching to naked: disables ALL protection (incl. protected paths, destructive commands, and write/edit checks) */ async function confirmNakedSwitch( ctx: ExtensionCommandContext, ): Promise { // No UI (headless) cannot confirm → conservatively refuse the switch if (!ctx.hasUI) return false; const first = await ctx.ui.confirm( "⚠️ Switch to NAKED mode?", NAKED_SWITCH_WARNING_1, ); if (!first) return false; // Second, final confirmation — makes an accidental /guard naked far less likely return ctx.ui.confirm( "⚠️⚠️ FINAL confirmation — disable ALL protection?", NAKED_SWITCH_WARNING_2, ); } /** Mode-switch confirmation: trusted → single warn; naked → double warn; others → no confirmation */ async function confirmModeSwitch( mode: GuardMode, ctx: ExtensionCommandContext, ): Promise { if (mode === "trusted") return confirmTrustedSwitch(ctx); if (mode === "naked") return confirmNakedSwitch(ctx); return true; } /** * Tunable rules never offered the confirm dialog's session-pass shortcut: * `confirmGroup` (sudo/ssh/chmod 777) and `blockGroup` (system-destructive). */ const SESSION_PASS_EXCLUDED = new Set(["confirmGroup", "blockGroup"]); async function askConfirm( ctx: ExtensionContext, message: string, confirmRules: RuleId[] = [], ): Promise { if (!ctx.hasUI) { return { block: true, reason: withEscapeHints("No interactive UI; blocked"), }; } // Third option: allow this once and session-pass the triggering rule(s). Shown // only outside naked (where a confirm already means "very dangerous") and never // for an excluded rule. const eligible = currentMode === "naked" ? [] : [...new Set(confirmRules)].filter((r) => !SESSION_PASS_EXCLUDED.has(r)); const options = ["✅ Allow", "❌ Deny"]; let passChoice: string | null = null; if (eligible.length === 1) { passChoice = `🔓 Allow & set ${eligible[0]} = pass (session)`; } else if (eligible.length > 1) { passChoice = `🔓 Allow & set ${eligible.length} rules = pass (session)`; } if (passChoice) options.push(passChoice); const choice = await ctx.ui.select(message, options); if (passChoice && choice === passChoice) { for (const r of eligible) sessionPassRule(currentMode, r); ctx.ui.notify( `Path Guard: ${eligible.join(", ")} = pass for this session (not persisted)`, "info", ); return undefined; // allow this operation } if (choice !== "✅ Allow") { return { block: true, reason: "User denied the operation" }; } return undefined; // allow } // ─── Block escape hints ──────────────────────────────────────────────── // A block is opaque without guidance on how to actually run the thing. Each // blocked line is classified into one escape category and a short, honest // hint (English + brief Chinese) is appended for every category present, so // the advice always matches why it was blocked. type EscapeCat = | "userPath" // user-configured protected path → blocked in EVERY mode (incl. naked) | "protectedPath" // built-in protected path → only naked bypasses | "systemDestructive" // mkfs/reboot/bulk-delete/block-device → naked still prompts | "noUi" // a confirm-grade op blocked because there is no interactive UI | "rule"; // some rule is set to block at the current level → loosen it const ESCAPE_HINTS: Record = { userPath: { en: "user-configured protected path — blocked in every mode; remove it with /guard paths rm ", zh: "自定义保护路径,所有模式强制拦截;请先用 /guard paths rm 移除", }, protectedPath: { en: "built-in protected path — only /guard naked bypasses it", zh: "内置保护路径,仅 /guard naked 会放行", }, systemDestructive: { en: "system-destructive command — /guard naked still prompts once before running it", zh: "系统级破坏命令,/guard naked 后仍会再向你确认一次", }, noUi: { en: "needs an interactive confirm — run it in the TUI, or loosen this rule to pass", zh: "需要交互确认,请在 TUI 里运行,或把该规则调为 pass", }, rule: { en: "rule-level block — loosen the mode (/guard loose|trusted|naked) or tune just this rule (/guard rules)", zh: "规则级拦截,可切换 /guard loose 或 /guard rules 调整该条规则", }, }; /** Pick the single most specific category for one blocked-reason line. */ function escapeCatOf(line: string): EscapeCat { if (/user-protected/i.test(line)) return "userPath"; if (/system-destructive/i.test(line)) return "systemDestructive"; if (/protected\b/i.test(line)) return "protectedPath"; if (/no interactive ui/i.test(line)) return "noUi"; return "rule"; } /** * Append per-category "how to run this" hints to a block message. * Structural header lines (ending in ':') and blanks are skipped, so nested * multi-line reasons are still classified by their individual detail lines. */ function withEscapeHints(reason: string): string { const cats = new Set(); for (const line of reason.split("\n")) { const t = line.trim(); if (!t) continue; if (t.endsWith(":")) continue; // "Command blocked:" / "Inner command blocked:" cats.add(escapeCatOf(t)); } if (cats.size === 0) return reason; const order: EscapeCat[] = [ "systemDestructive", "protectedPath", "userPath", "noUi", "rule", ]; const hintLines: string[] = []; for (const cat of order) { if (cats.has(cat)) { // English and Chinese on separate lines so long hints stay readable. hintLines.push(`· ${ESCAPE_HINTS[cat].en}\n ${ESCAPE_HINTS[cat].zh}`); } } return `${reason}\n\nTo run anyway / 如需执行:\n${hintLines.join("\n")}`; }