Last active
January 22, 2026 15:14
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * 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