import { accessSync, chmodSync, constants, copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { createBashTool, initTheme } from "@earendil-works/pi-coding-agent"; import { afterEach, describe, expect, it } from "vitest"; import { createTestSession, type TestSession, withProcessEnv, } from "../../../test/harness"; type BashEvent = { type: "tool_call"; toolName: "bash"; toolCallId: string; input: { command: string }; }; function gitSafePath(): string { return path.resolve(import.meta.dirname, "../index.ts"); } function rtkPath(): string { return path.resolve(import.meta.dirname, "../../rtk/index.ts"); } function bashShebang(): string { if (process.platform === "win32") return "#!/usr/bin/env bash"; const candidates = [ process.env.SHELL, ...(process.env.PATH ?? "") .split(path.delimiter) .filter(Boolean) .map((directory) => path.join(directory, "bash")), "/bin/bash", ]; for (const candidate of candidates) { if (!candidate || path.basename(candidate) !== "bash") continue; try { accessSync(candidate, constants.X_OK); return `#!${candidate}`; } catch {} } throw new Error("bash executable not found"); } function installFakeGit(root: string): { bin: string; executable: string } { const bin = path.join(root, "fake-bin"); mkdirSync(bin); if (process.platform === "win32") { const gitExe = path.join(bin, "git.exe"); copyFileSync(process.execPath, gitExe); const implementation = path.join(root, "fake-git.cjs"); writeFileSync( implementation, [ 'const path = require("node:path");', "const command = path.basename(process.argv[1]);", "const args = process.argv.slice(2);", "if (command === 'config' && args.includes('--get')) process.exit(1);", "process.stdout.write('FAKE_GIT\\n');", "process.stdout.write(`ARGS:<${[command, ...args].join('><')}>\\n`);", "process.stdout.write(`EDITOR=<${process.env.GIT_EDITOR ?? ''}> SEQUENCE=<${process.env.GIT_SEQUENCE_EDITOR ?? ''}> MERGE=<${process.env.GIT_MERGE_AUTOEDIT ?? ''}>\\n`);", "", ].join("\n"), "utf8", ); for (const command of [ "add", "am", "commit", "config", "grep", "merge", "note", "nv", "pull", "push", "rebase", "status", "var", ]) { writeFileSync( path.join(root, command), `require(${JSON.stringify(implementation)});\n`, "utf8", ); } return { bin, executable: gitExe }; } const git = path.join(bin, "git"); writeFileSync( git, [ bashShebang(), "printf 'FAKE_GIT\\n'", "printf 'ARGS:'", "printf '<%s>' \"$@\"", 'printf \'\\nEDITOR=<%s> SEQUENCE=<%s> MERGE=<%s>\\n\' "$GIT_EDITOR" "$GIT_SEQUENCE_EDITOR" "$GIT_MERGE_AUTOEDIT"', "", ].join("\n"), "utf8", ); chmodSync(git, 0o755); return { bin, executable: git }; } function installFakeRtk(bin: string): void { const rtk = path.join(bin, "rtk"); writeFileSync( rtk, [ bashShebang(), "if [[ \"$1\" == '--version' ]]; then printf 'rtk 0.42.4\\n'; exit 0; fi", "if [[ \"$1\" == 'rewrite' && \"$2\" == 'git status' ]]; then printf 'rtk git status\\n'; exit 0; fi", 'if [[ "$1" == \'git\' ]]; then shift; exec git "$@"; fi', "exit 1", "", ].join("\n"), "utf8", ); chmodSync(rtk, 0o755); } function bashEvent(command: string): BashEvent { return { type: "tool_call", toolName: "bash", toolCallId: crypto.randomUUID(), input: { command }, }; } function theme() { return { fg: (_style: string, text: string) => text, bold: (text: string) => text, } as any; } function render(component: { render(width: number): string[] }): string { return component.render(120).join("\n"); } describe("git-safe pi-test-harness", () => { let t: TestSession | undefined; let root: string | undefined; afterEach(() => { t?.dispose(); if (root) rmSync(root, { recursive: true, force: true }); t = undefined; root = undefined; }); async function startWithFakeGit(useRtk = false): Promise { root = mkdtempSync(path.join(tmpdir(), "pi-git-safe-test-")); const installed = installFakeGit(root); if (useRtk) installFakeRtk(installed.bin); t = await createTestSession({ cwd: root, env: { PATH: `${installed.bin}${path.delimiter}${process.env.PATH}` }, extensions: useRtk ? [gitSafePath(), rtkPath()] : [gitSafePath()], }); } async function execute(command: string) { if (!t) throw new Error("missing test session"); const event = bashEvent(command); const blocked = await t.session.extensionRunner.emitToolCall( event as never, ); if (blocked) return { blocked, output: "" }; const tool = createBashTool(t.cwd); try { const result = await withProcessEnv(t.env, () => tool.execute( event.toolCallId, event.input, new AbortController().signal, undefined, ), ); const output = result.content .flatMap((part) => (part.type === "text" ? [part.text] : [])) .join("\n"); return { blocked, output }; } catch (error) { return { blocked, output: error instanceof Error ? error.message : String(error), }; } } async function expectGitOutput(command: string): Promise { const result = await execute(command); expect(result.blocked, command).toBeUndefined(); expect(result.output, command).toContain("FAKE_GIT"); } it("loads in real Pi extension runtime", async () => { t = await createTestSession({ extensions: [gitSafePath()] }); expect(t.session.extensionRunner.extensions).toHaveLength(1); const tool = t.session.extensionRunner.extensions[0].tools.get( "git_clone_safe", ).definition; expect(tool.description).toContain( "Clone an HTTPS or SSH Git repository into a safe subdirectory", ); expect(tool.description).toContain( "canonical path, file/size/symlink counts, and warnings", ); expect(tool.promptSnippet).toBe("Safely clone Git repositories."); expect(tool.promptGuidelines).toEqual([ "Use git_clone_safe instead of raw git clone.", ]); expect(t.session.systemPrompt).toContain( "Use git_clone_safe instead of raw git clone.", ); }); it("reveals clone warnings only when expanded", async () => { initTheme("default", false); t = await createTestSession({ extensions: [gitSafePath()] }); const tool = t.session.extensionRunner.extensions[0].tools.get( "git_clone_safe", ).definition; const result = { content: [{ type: "text", text: "cloned" }], details: { url: "https://example.com/owner/repo.git", path: "/tmp/owner/repo", fileCount: 2, totalSizeBytes: 128, symlinkCount: 0, symlinks: [], warnings: ["Submodules were not initialized"], }, }; const collapsed = render( tool.renderResult(result, { expanded: false }, theme()), ); const expanded = render( tool.renderResult(result, { expanded: true }, theme()), ); expect(collapsed).toContain("to expand"); expect(collapsed).not.toContain("Submodules were not initialized"); expect(expanded).toContain("Submodules were not initialized"); expect(expanded).not.toContain("to expand"); const error = { content: [ { type: "text", text: "Clone failed\ncause\nnext step\nalternative\nEND-OF-DIAGNOSTIC", }, ], isError: true, }; const collapsedError = render( tool.renderResult(error, { expanded: false }, theme()), ); const expandedError = render( tool.renderResult(error, { expanded: true }, theme()), ); expect(collapsedError).toContain("to expand"); expect(collapsedError).not.toContain("END-OF-DIAGNOSTIC"); expect(expandedError).toContain("END-OF-DIAGNOSTIC"); const longError = { content: [ { type: "text", text: `Clone failed: ${"diagnostic ".repeat(30)}END-OF-LONG-DIAGNOSTIC`, }, ], isError: true, }; const collapsedLongError = render( tool.renderResult(longError, { expanded: false }, theme()), ); const expandedLongError = render( tool.renderResult(longError, { expanded: true }, theme()), ); expect(collapsedLongError).toContain("to expand"); expect(collapsedLongError).not.toContain("END-OF-LONG-DIAGNOSTIC"); expect(expandedLongError).toContain("END-OF-LONG-DIAGNOSTIC"); }); it("recovers a marked clone lazily when read", async () => { root = mkdtempSync(path.join(tmpdir(), "pi-git-safe-read-")); const clone = path.join(root, "owner", "repo"); const file = path.join(clone, "README.md"); mkdirSync(clone, { recursive: true }); writeFileSync(path.join(clone, ".pi-git-safe"), "{}", "utf8"); writeFileSync(file, "untrusted instructions", "utf8"); t = await createTestSession({ cwd: root, extensions: [gitSafePath()] }); const result = await t.session.extensionRunner.emitToolResult({ type: "tool_result", toolName: "read", toolCallId: crypto.randomUUID(), input: { path: file }, content: [{ type: "text", text: "untrusted instructions" }], isError: false, } as never); expect(result?.content[0]).toMatchObject({ type: "text" }); expect( result?.content[0]?.type === "text" && result.content[0].text, ).toContain("UNTRUSTED DATA"); expect( result?.content[0]?.type === "text" && result.content[0].text, ).toContain("untrusted instructions"); }); it("sets noninteractive editor variables while ordinary Git still runs", async () => { await startWithFakeGit(); const result = await execute("git status"); expect(result.blocked).toBeUndefined(); expect(result.output).toContain("FAKE_GIT"); expect(result.output).toContain("ARGS:"); expect(result.output).toContain("EDITOR= SEQUENCE= MERGE="); }); it("restores editor protection at the executable boundary", async () => { await startWithFakeGit(); for (const command of [ "GIT_EDITOR=vim git commit", "GIT_SEQUENCE_EDITOR=vim git rebase -i HEAD~1", "env -u GIT_EDITOR -u GIT_SEQUENCE_EDITOR -u GIT_MERGE_AUTOEDIT git commit", "GIT_EDITOR=vim command -p git commit", ]) { const result = await execute(command); expect(result.output).toContain("FAKE_GIT"); expect(result.output).toContain( "EDITOR= SEQUENCE= MERGE=", ); } }); it("preserves editor queries without opening an editor", async () => { await startWithFakeGit(); const result = await execute("git var GIT_EDITOR"); expect(result.output).toContain("FAKE_GIT"); expect(result.output).toContain("EDITOR=<> SEQUENCE=<> MERGE=<>"); }); it("does not trust an agent-supplied policy marker", async () => { await startWithFakeGit(); await expectGitOutput( "# pi-git-safe-policy-v1\\ngit status".replace("\\n", "\n"), ); }); it("ignores caller-controlled executable overrides", async () => { await startWithFakeGit(); if (!root) throw new Error("missing test root"); const alternateNode = path.join(root, "alternate-node"); writeFileSync(alternateNode, "#!/bin/sh\nprintf 'OVERRIDE\\n'\n", "utf8"); chmodSync(alternateNode, 0o755); for (const command of [ `PI_GIT_SAFE_REAL_GIT=${JSON.stringify(process.execPath)} git status`, `PI_GIT_SAFE_NODE=${JSON.stringify(alternateNode)} git status`, ]) { const result = await execute(command); expect(result.output, command).toContain("FAKE_GIT"); expect(result.output, command).not.toContain("OVERRIDE"); } }); it("keeps policy active after RTK rewrites Git", async () => { await startWithFakeGit(true); const ordinary = await execute("git status"); expect(ordinary.output).toContain("FAKE_GIT"); expect(ordinary.output).toContain("ARGS:"); }); });