Skip to content

Instantly share code, notes, and snippets.

@nicobailon
Last active January 22, 2026 15:14
Show Gist options
  • Select an option

  • Save nicobailon/f5885f79a6b2f3136418935c0da201ba to your computer and use it in GitHub Desktop.

Select an option

Save nicobailon/f5885f79a6b2f3136418935c0da201ba to your computer and use it in GitHub Desktop.
Pi extension: Git commit/push guard with confirmation prompts and destructive command protection
/**
* PATH Wrapper Extension
*
* Prepends a directory to PATH for all bash commands.
* Requires config in settings.json — does nothing without it:
*
* { "pathWrapper": { "prependPath": "/path/to/wrappers" } }
*/
import { spawn } from "node:child_process";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
interface PathWrapperConfig {
prependPath?: string;
timeout?: number;
debug?: boolean;
}
const SETTINGS_PATH = path.join(os.homedir(), ".pi", "agent", "settings.json");
const DEFAULT_TIMEOUT = 300_000;
const MAX_OUTPUT_BYTES = 51200;
const MAX_OUTPUT_LINES = 2000;
function loadConfig(): PathWrapperConfig {
const envPath = process.env.PATH_WRAPPER_PREPEND;
if (envPath) {
return {
prependPath: envPath,
timeout: Number(process.env.PATH_WRAPPER_TIMEOUT) || DEFAULT_TIMEOUT,
debug: process.env.PATH_WRAPPER_DEBUG === "1",
};
}
try {
const settings = JSON.parse(fs.readFileSync(SETTINGS_PATH, "utf-8"));
return settings.pathWrapper ?? {};
} catch {
return {};
}
}
function truncateOutput(output: string): string {
let result = output;
const lines = result.split("\n");
if (lines.length > MAX_OUTPUT_LINES) {
const truncated = lines.length - MAX_OUTPUT_LINES;
result = `[...${truncated} lines truncated...]\n${lines.slice(-MAX_OUTPUT_LINES).join("\n")}`;
}
if (result.length > MAX_OUTPUT_BYTES) {
result = `[...truncated to ${MAX_OUTPUT_BYTES / 1024}KB...]\n${result.slice(-MAX_OUTPUT_BYTES)}`;
}
return result;
}
function runWithModifiedPath(
command: string,
cwd: string,
prependPath: string,
timeout: number,
debug: boolean
): Promise<string> {
return new Promise((resolve) => {
const env = { ...process.env, PATH: `${prependPath}:${process.env.PATH}` };
if (debug) {
console.error(`[path-wrapper] PATH=${prependPath}:$PATH`);
console.error(`[path-wrapper] Command: ${command}`);
}
const proc = spawn("/bin/bash", ["-c", command], { cwd, env });
let killed = false;
const chunks: Buffer[] = [];
proc.stdout.on("data", (d) => chunks.push(d));
proc.stderr.on("data", (d) => chunks.push(d));
const timer = setTimeout(() => {
killed = true;
proc.kill("SIGTERM");
setTimeout(() => {
try {
proc.kill("SIGKILL");
} catch {
// Already dead
}
}, 5000);
}, timeout);
proc.on("close", (code) => {
clearTimeout(timer);
let output = Buffer.concat(chunks).toString("utf-8");
output = truncateOutput(output);
if (killed) {
output += `\n[timeout after ${timeout / 1000}s]`;
} else if (code !== 0 && code !== null) {
output += `\n[exit code: ${code}]`;
}
if (debug) {
console.error(`[path-wrapper] Exit: ${code}, killed: ${killed}`);
}
resolve(output || "(no output)");
});
proc.on("error", (err) => {
clearTimeout(timer);
resolve(`Error: ${err.message}`);
});
});
}
export default function (pi: ExtensionAPI) {
const config = loadConfig();
if (!config.prependPath) {
return;
}
const { prependPath, timeout = DEFAULT_TIMEOUT, debug = false } = config;
if (debug) {
console.error(`[path-wrapper] Loaded: ${prependPath}`);
}
// User !bash — return custom operations with modified PATH (preserves streaming)
pi.on("user_bash", (event) => {
if (debug) {
console.error(`[path-wrapper] user_bash: ${event.command}`);
}
const modifiedEnv = { ...process.env, PATH: `${prependPath}:${process.env.PATH}` };
return {
operations: {
exec: (command: string, cwd: string, options: { onData: (data: Buffer) => void; signal?: AbortSignal; timeout?: number }) => {
return new Promise((resolve) => {
const proc = spawn("/bin/bash", ["-c", command], { cwd, env: modifiedEnv });
if (options.signal) {
options.signal.addEventListener("abort", () => proc.kill("SIGTERM"));
}
proc.stdout.on("data", options.onData);
proc.stderr.on("data", options.onData);
const timer = options.timeout
? setTimeout(() => proc.kill("SIGTERM"), options.timeout)
: null;
proc.on("close", (code) => {
if (timer) clearTimeout(timer);
resolve({ exitCode: code });
});
proc.on("error", () => {
if (timer) clearTimeout(timer);
resolve({ exitCode: 1 });
});
});
},
},
};
});
// Agent bash — intercept and execute with modified env
pi.on("tool_call", async (event, ctx) => {
if (event.toolName !== "bash") return;
const command = event.input?.command;
if (typeof command !== "string" || !command.trim()) return;
const output = await runWithModifiedPath(command, ctx.cwd, prependPath, timeout, debug);
return { block: true, reason: output };
});
// Notify on session start
pi.on("session_start", (_event, ctx) => {
if (ctx.hasUI) {
ctx.ui.notify(`PATH wrapper: ${prependPath}`, "info");
}
});
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment