diff --git a/AGENTS.md b/AGENTS.md index 0de55cb..d373b12 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -181,6 +181,14 @@ effect without restarting pi. skips re-linking if found. So re-running `./install.sh` (e.g. to pick up a newly added extension) does not silently re-enable a previously `/ext`-disabled extension. +- **Per-extension disable guards.** A `DISABLE_GUARDS` map keyed by bare + extension name lets specific extensions refuse a `/ext` disable when + toggling would silently break in-flight session state. Currently used + by `ssh-controlmaster`: refuses to disable while `--ssh` is in + `process.argv`, because disabling tears down the ControlMaster (if we + own it) and reverts read/write/edit/bash to the local filesystem while + the system prompt still says we're on the remote. Add new entries here + as similar foot-guns are discovered. - **Subdir extensions are read-only in v1.** `name/index.ts` shapes show up in the listing with a `[dir]` tag but cannot be toggled — the cleanest disable for a directory would need a hidden-prefix or move-aside dance that adds diff --git a/README.md b/README.md index 9b0ec5e..fa99525 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,7 @@ Registers `/ext` — a slash command that lists extensions in `~/.pi/agent/exten - Subdirectory-style extensions (`name/index.ts`) are listed read-only — v1 doesn't toggle them. Move the directory aside manually if needed. - `install.sh --uninstall` cleans up both `.ts` and `.ts.off` symlinks pointing into this repo, so a disabled extension won't be left behind. - Re-running `./install.sh` respects a prior `/ext` disable: if `.ts.off` already exists, the installer leaves it alone instead of silently re-enabling. +- `ssh-controlmaster` cannot be disabled via `/ext` while pi was launched with `--ssh` — disabling mid-session would silently revert tool calls to the local filesystem. Exit pi and relaunch without `--ssh` instead. ## Adding a new extension diff --git a/extensions/ext-toggle.ts b/extensions/ext-toggle.ts index 27322b7..080b58a 100644 --- a/extensions/ext-toggle.ts +++ b/extensions/ext-toggle.ts @@ -21,6 +21,36 @@ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; const EXT_DIR = path.join(os.homedir(), ".pi", "agent", "extensions"); +/** + * Per-extension guards that refuse a disable when toggling would silently + * break in-flight session state. Returns a string explaining why the + * disable should be blocked, or null to allow it. + * + * Keyed by bare extension name (matches the file basename without `.ts`). + */ +const DISABLE_GUARDS: Record string | null> = { + "ssh-controlmaster": () => { + // If pi was launched with --ssh, disabling will (a) tear down the + // ControlMaster (if we own it) and (b) silently revert read/write/ + // edit/bash to the LOCAL filesystem while the system prompt still + // claims we're on the remote. Refuse, point at the safe path. + const hasSshFlag = process.argv.some( + (a) => a === "--ssh" || a.startsWith("--ssh="), + ); + if (!hasSshFlag) return null; + return [ + "Refusing to disable ssh-controlmaster during an active --ssh session.", + "", + "Disabling now would:", + " \u2022 tear down the SSH ControlMaster (if this extension started it)", + " \u2022 silently redirect read/write/edit/bash back to the LOCAL", + " machine while the system prompt still says you're on the remote", + "", + "Exit pi and relaunch without --ssh instead.", + ].join("\n"); + }, +}; + type Entry = { name: string; // bare name without extension (e.g. "notify") fullPath: string; // absolute path on disk @@ -142,6 +172,16 @@ export default function extToggleExtension(pi: ExtensionAPI) { return; } + // Run guard before prompting — only blocks disables, not re-enables. + if (entry.enabled) { + const guard = DISABLE_GUARDS[entry.name]; + const reason = guard?.(); + if (reason) { + await ctx.ui.confirm(`Cannot disable "${entry.name}"`, reason); + return; + } + } + const verb = entry.enabled ? "Disable" : "Enable"; const ok = await ctx.ui.confirm( `${verb} "${entry.name}"?`,