ext-toggle: refuse to disable ssh-controlmaster during --ssh session
Disabling ssh-controlmaster mid --ssh session would tear down the ControlMaster (if we own it) and silently redirect read/write/edit/bash back to the local filesystem while the system prompt still claims we're on the remote. Now blocked with an explanatory dialog. Implementation: a DISABLE_GUARDS map keyed by bare extension name lets specific extensions register a refusal predicate. ssh-controlmaster's guard checks process.argv for --ssh and refuses if present. Easy to extend with similar foot-guns later.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 `<name>.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
|
||||
|
||||
|
||||
@@ -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, () => 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}"?`,
|
||||
|
||||
Reference in New Issue
Block a user