import { afterEach, beforeEach, expect, test } from "bun:test"; import { execFileSync } from "node:child_process"; import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, utimesSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { delimiter, dirname, join, relative } from "node:path"; import graphify from "../graphify/pi/index.ts"; // Use the real installed Python CLI for policy tests. No mock of host path helpers. const realCLI = process.env.GRAPHIFY_TEST_CLI ?? Bun.which("graphify"); if (!realCLI) throw new Error("Install graphifyy or set GRAPHIFY_TEST_CLI to its executable before running this test"); const savedPath = process.env.PATH; const savedStrict = process.env.GRAPHIFY_HOOK_STRICT; let root: string; let cwd: string; let control: string; let started: string; let fixture: string; type Result = { block?: boolean; reason?: string; additionalContext?: string } | undefined; type Handler = (event: Record, ctx: object) => Result | Promise; type Sent = { message: { customType: string; content: string; display: boolean }; options: { deliverAs: string } }; // `omp` hands the extension its own module as `api.pi` and takes passive context // from tool_call; `pi` has no such channel and gets a steering message instead. function harness(host: "omp" | "pi" = "omp") { const handlers = new Map(); const sent: Sent[] = []; let trusted = true; const ctx = { cwd, isProjectTrusted: () => trusted, sessionManager: { getSessionId: () => "omp-test-session" } }; // Only the event registry and message sink are needed; every callback is the real extension's. const api = { on: (event: string, handler: Handler) => handlers.set(event, handler), sendMessage: (message: Sent["message"], options: Sent["options"]) => sent.push({ message, options }), ...(host === "omp" ? { pi: {} } : {}), }; graphify(api); return { sent, trust(value: boolean) { trusted = value; }, async emit(event: string, payload: Record = {}) { return await handlers.get(event)?.({ type: event, ...payload }, ctx); }, }; } beforeEach(() => { root = mkdtempSync(join(tmpdir(), "graphify-omp-")); cwd = join(root, "project"); mkdirSync(cwd); mkdirSync(join(root, "bin")); control = join(root, "control.json"); started = join(root, "started"); fixture = join(root, "bin", "graphify"); writeFileSync(control, JSON.stringify({ mode: "real" })); // A controlled installed executable outside the project exercises the real // subprocess boundary (including cancellation and output limits). // Real subprocess timeouts cannot be advanced by the parent test's fake clock. writeFileSync(fixture, `#!${process.execPath}\nimport {execFileSync} from "node:child_process"; import {readFileSync,writeFileSync,existsSync} from "node:fs"; const input = await Bun.stdin.text(); const config = JSON.parse(readFileSync(${JSON.stringify(control)}, "utf8")); writeFileSync(${JSON.stringify(started)}, input); if (config.mode === "real") process.stdout.write(execFileSync(${JSON.stringify(realCLI)}, process.argv.slice(2), {input})); else if (config.mode === "invalid") process.stdout.write("not json"); else if (config.mode === "overflow") process.stdout.write(JSON.stringify({hookSpecificOutput: {hookEventName: "PreToolUse", additionalContext: "overflow guidance"}}) + " ".repeat(65537)); else if (config.mode === "delayed") { while (!existsSync(${JSON.stringify(join(root, "release"))})) await Bun.sleep(5); process.stdout.write(JSON.stringify({hookSpecificOutput: {hookEventName: "PreToolUse", additionalContext: "stale guidance"}})); } else if (config.mode === "varying") { const counterFile = ${JSON.stringify(join(root, "count"))}; const n = existsSync(counterFile) ? Number(readFileSync(counterFile, "utf8")) || 0 : 0; writeFileSync(counterFile, String(n + 1)); if (config.invalidFirst && n === 0) process.stdout.write("not json"); else process.stdout.write(JSON.stringify({hookSpecificOutput: {hookEventName: "PreToolUse", additionalContext: "guidance " + (n + 1)}})); } else if (config.mode === "hung") { process.on("SIGTERM", () => {}); await Bun.sleep(60000); } `); chmodSync(fixture, 0o755); process.env.PATH = dirname(fixture); delete process.env.GRAPHIFY_HOOK_STRICT; writeFileSync(join(cwd, "source.py"), "def example(): pass\n"); mkdirSync(join(cwd, "graphify-out")); writeFileSync(join(cwd, "graphify-out", "graph.json"), JSON.stringify({ nodes: [], links: [] })); writeFileSync(join(cwd, "graphify-out", "manifest.json"), JSON.stringify({ "source.py": {} })); const fresh = new Date(Date.now() + 1000); utimesSync(join(cwd, "graphify-out", "graph.json"), fresh, fresh); }); afterEach(() => { process.env.PATH = savedPath; if (savedStrict === undefined) delete process.env.GRAPHIFY_HOOK_STRICT; else process.env.GRAPHIFY_HOOK_STRICT = savedStrict; rmSync(root, { recursive: true, force: true }); }); test("real CLI strict denial blocks once; later calls return trusted passive context", async () => { process.env.GRAPHIFY_HOOK_STRICT = "1"; const api = harness(); await api.emit("before_agent_start"); const event = { toolName: "read", input: { path: "source.py:1-5" }, toolCallId: "call-1" }; const result = await api.emit("tool_call", event); expect(result?.block).toBe(true); expect(result?.reason).toContain("graphify"); expect(existsSync(join(cwd, "graphify-out", "cache", "hook_sessions", "omp-test-session.denied"))).toBe(true); // After the one-time deny, every qualifying call carries its own guidance. const second = await api.emit("tool_call", { ...event, toolCallId: "call-2" }); expect(second?.additionalContext).toContain("graphify"); await api.emit("before_agent_start"); const third = await api.emit("tool_call", { ...event, toolCallId: "call-3" }); expect(third?.additionalContext).toContain("graphify"); }); test("native grep, bash search, and glob expose the installed CLI's actual guidance", async () => { for (const [toolName, input, kind, legacyInput] of [ ["grep", { pattern: "example", path: "source.py:1" }, "search", { pattern: "example" }], ["bash", { command: "rg example ." }, "search", { command: "rg example ." }], ["glob", { path: "**/*.py" }, "read", { pattern: "**/*.py" }], ] as const) { const api = harness(); const expected = JSON.parse(execFileSync(realCLI!, ["hook-guard", kind], { cwd, input: JSON.stringify({ tool_input: legacyInput }), encoding: "utf8", })).hookSpecificOutput.additionalContext; const result = await api.emit("tool_call", { toolName, input, toolCallId: `${toolName}-1` }); expect(result?.additionalContext).toBe(`\n${expected}\n`); } }); test("multi-target glob calls surface every target's guidance", async () => { // The real CLI returns identical fresh text per glob target (its stale check // keys on file_path, which glob payloads do not carry), so a varying fixture // mode distinguishes the per-target guard invocations the extension must join. writeFileSync(control, JSON.stringify({ mode: "varying" })); const api = harness(); const result = await api.emit("tool_call", { toolName: "glob", input: { path: "source.py;stale.py" }, toolCallId: "call-1" }); expect(result?.additionalContext).toContain("guidance 1"); expect(result?.additionalContext).toContain("guidance 2"); }); test("malformed output from one glob target does not stop later targets", async () => { writeFileSync(control, JSON.stringify({ mode: "varying", invalidFirst: true })); const api = harness(); const result = await api.emit("tool_call", { toolName: "glob", input: { path: "source.py;stale.py" }, toolCallId: "call-1" }); expect(result?.additionalContext).toContain("guidance 2"); }); test("URLs, internal resources, literal selector-like names and false trust do not run project hooks", async () => { const api = harness(); for (const path of ["https://example.com/source.py", "www.example.com/source.py", "skill://graphify", "local:/source.py", "source.py; ssh://host/source.py"]) { expect(await api.emit("tool_call", { toolName: "read", input: { path }, toolCallId: "call-1" })).toBeUndefined(); expect(existsSync(started)).toBe(false); } api.trust(false); expect(await api.emit("tool_call", { toolName: "read", input: { path: "source.py" }, toolCallId: "call-2" })).toBeUndefined(); expect(existsSync(started)).toBe(false); api.trust(true); writeFileSync(join(cwd, "source.py:12"), "a real filename, not a selector"); expect(await api.emit("tool_call", { toolName: "read", input: { path: "source.py:12" }, toolCallId: "call-3" })).toBeUndefined(); }); test("a file:// read target resolves to the real local path and still reaches the guard", async () => { // file:// is the one scheme isRemote lets through to localPath // (which OMP's own path pipeline also resolves down to a local path, not // an external URL) -- an in-project file:// target must still nudge... const api = harness(); const inProject = await api.emit("tool_call", { toolName: "read", input: { path: `file://${join(cwd, "source.py")}` }, toolCallId: "call-1" }); expect(inProject?.additionalContext).toContain("graphify"); // ...while one outside the project still resolves (the CLI subprocess // runs), but the guard's own containment check stays silent, same as any // other out-of-project absolute path. expect(await api.emit("tool_call", { toolName: "read", input: { path: `file://${join(root, "outside.py")}` }, toolCallId: "call-2" })).toBeUndefined(); }); test("file:// read targets distinguish remote authorities from localhost", async () => { const api = harness(); expect(await api.emit("tool_call", { toolName: "read", input: { path: `file://evil.com${join(cwd, "source.py")}` }, toolCallId: "call-1", })).toBeUndefined(); expect(existsSync(started)).toBe(false); const local = await api.emit("tool_call", { toolName: "read", input: { path: `file://localhost${join(cwd, "source.py")}` }, toolCallId: "call-2", }); expect(local?.additionalContext).toContain("graphify"); }); test("navigation cancels in-flight guidance promptly, for every reset event", async () => { for (const navigation of ["session_start", "session_switch", "session_tree", "session_branch", "session_shutdown", "before_agent_start"]) { rmSync(started, { force: true }); writeFileSync(control, JSON.stringify({ mode: "delayed" })); const api = harness(); const pending = api.emit("tool_call", { toolName: "read", input: { path: "source.py" }, toolCallId: "call-1" }); const deadline = Date.now() + 1500; while (!existsSync(started) && Date.now() < deadline) await Bun.sleep(5); expect(existsSync(started)).toBe(true); const navigated = performance.now(); await api.emit(navigation); expect(await pending).toBeUndefined(); // Without the abort the child would only be killed by the 2s timeout. expect(performance.now() - navigated).toBeLessThan(1000); } }, 15000); test("output that finishes after navigation is discarded", async () => { writeFileSync(control, JSON.stringify({ mode: "delayed" })); const api = harness("pi"); const pending = api.emit("tool_call", { toolName: "read", input: { path: "source.py" }, toolCallId: "call-1" }); const deadline = Date.now() + 1500; while (!existsSync(started) && Date.now() < deadline) await Bun.sleep(5); expect(existsSync(started)).toBe(true); // Release the child and block the event loop until it has exited, so its // output is ready before the session navigates. writeFileSync(join(root, "release"), ""); Bun.sleepSync(300); await api.emit("session_start"); expect(await pending).toBeUndefined(); expect(api.sent).toEqual([]); }); test("oversized input never spawns, invalid/oversized output fails open, and a hung child is killed", async () => { const api = harness(); expect(await api.emit("tool_call", { toolName: "bash", input: { command: "x".repeat(256 * 1024) }, toolCallId: "call-1" })).toBeUndefined(); expect(existsSync(started)).toBe(false); for (const mode of ["invalid", "overflow", "hung"]) { writeFileSync(control, JSON.stringify({ mode })); const start = performance.now(); expect(await api.emit("tool_call", { toolName: "read", input: { path: "source.py" }, toolCallId: "call-2" })).toBeUndefined(); expect(performance.now() - start).toBeLessThan(3500); } }, 10000); test("missing or project-local executables never fall back to project Python code", async () => { process.env.PATH = cwd; const api = harness(); await api.emit("tool_call", { toolName: "read", input: { path: "source.py" }, toolCallId: "call-1" }); expect(existsSync(started)).toBe(false); writeFileSync(join(cwd, "graphify"), readFileSync(fixture)); chmodSync(join(cwd, "graphify"), 0o755); await api.emit("tool_call", { toolName: "read", input: { path: "source.py" }, toolCallId: "call-2" }); expect(existsSync(started)).toBe(false); }); test("a project-local graphify earlier on PATH does not shadow an installed one", async () => { const local = join(cwd, ".venv", "bin"); mkdirSync(local, { recursive: true }); writeFileSync(join(local, "graphify"), `#!/bin/sh\ntouch ${JSON.stringify(join(root, "local-ran"))}\n`); chmodSync(join(local, "graphify"), 0o755); process.env.PATH = [local, dirname(fixture)].join(delimiter); const result = await harness().emit("tool_call", { toolName: "read", input: { path: "source.py" }, toolCallId: "call-1" }); expect(result?.additionalContext).toContain("graphify"); expect(existsSync(join(root, "local-ran"))).toBe(false); }); test("the executable is spawned through its PATH entry, not the symlink's target", async () => { // A shim that dispatches on argv[0] must see the name it was installed under. const multicall = join(root, "multicall"); writeFileSync(multicall, `#!/bin/sh\necho "$0" > ${JSON.stringify(join(root, "argv0"))}\nexec ${JSON.stringify(fixture)} "$@"\n`); chmodSync(multicall, 0o755); const shim = join(root, "shim"); mkdirSync(shim); symlinkSync(multicall, join(shim, "graphify")); process.env.PATH = shim; const result = await harness().emit("tool_call", { toolName: "read", input: { path: "source.py" }, toolCallId: "call-1" }); expect(result?.additionalContext).toContain("graphify"); expect(readFileSync(join(root, "argv0"), "utf8").trim()).toBe(join(shim, "graphify")); }); test("a relative PATH entry is never searched", async () => { process.env.PATH = relative(process.cwd(), dirname(fixture)); await harness().emit("tool_call", { toolName: "read", input: { path: "source.py" }, toolCallId: "call-1" }); expect(existsSync(started)).toBe(false); }); test("tools the guard does not map never spawn it", async () => { process.env.GRAPHIFY_HOOK_STRICT = "1"; const api = harness(); for (const [toolName, input] of [ ["edit", { path: "source.py" }], ["write", { path: "source.py", content: "" }], ["ls", { path: "source.py" }], // OMP's `find` is a semantic search, not Pi's pattern-based `find`. ["find", { query: "example", path: "source.py" }], ] as const) { expect(await api.emit("tool_call", { toolName, input, toolCallId: "call-1" })).toBeUndefined(); expect(existsSync(started)).toBe(false); } }); test("OMP glob targets outside the project stay silent; in-project ones still nudge", async () => { const api = harness(); for (const path of [join(root, "outside", "**", "*.py"), "../outside/*.py"]) { expect(await api.emit("tool_call", { toolName: "glob", input: { path }, toolCallId: "call-1" })).toBeUndefined(); } const inside = await api.emit("tool_call", { toolName: "glob", input: { path: "src/**/*.py" }, toolCallId: "call-2" }); expect(inside?.additionalContext).toContain("graphify"); }); test("`@` and `~` prefixes are normalized like Pi's tools do", async () => { // ~/source.py is outside the project (and would match the manifest by basename // if it were mistaken for /~/source.py); @source.py is the in-project file. const savedHome = process.env.HOME; process.env.HOME = join(root, "home"); mkdirSync(process.env.HOME); writeFileSync(join(process.env.HOME, "source.py"), "def other(): pass\n"); process.env.GRAPHIFY_HOOK_STRICT = "1"; try { const api = harness(); expect(await api.emit("tool_call", { toolName: "read", input: { path: "~/source.py" }, toolCallId: "call-1" })).toBeUndefined(); const result = await api.emit("tool_call", { toolName: "read", input: { path: "@source.py" }, toolCallId: "call-2" }); expect(result?.block).toBe(true); } finally { if (savedHome === undefined) delete process.env.HOME; else process.env.HOME = savedHome; } }); test("on Pi, guidance goes out as one steering message and tool_call returns nothing", async () => { const api = harness("pi"); const expected = JSON.parse(execFileSync(realCLI!, ["hook-guard", "search"], { cwd, input: JSON.stringify({ tool_input: { pattern: "example" } }), encoding: "utf8", })).hookSpecificOutput.additionalContext; expect(await api.emit("tool_call", { toolName: "grep", input: { pattern: "example", path: "." }, toolCallId: "call-1" })).toBeUndefined(); expect(api.sent).toEqual([{ message: { customType: "graphify", content: `\n${expected}\n`, display: true }, options: { deliverAs: "steer" }, }]); }); test("Pi's find tool reaches the read guard with its pattern and a resolved path", async () => { const api = harness("pi"); await api.emit("tool_call", { toolName: "find", input: { pattern: "*.py", path: "." }, toolCallId: "call-1" }); const payload = JSON.parse(readFileSync(started, "utf8")); expect(payload.tool_name).toBe("Glob"); expect(payload.tool_input).toEqual({ pattern: "*.py", path: cwd }); expect(api.sent).toHaveLength(1); }); test("Pi strict denial still blocks through tool_call", async () => { process.env.GRAPHIFY_HOOK_STRICT = "1"; const api = harness("pi"); const result = await api.emit("tool_call", { toolName: "read", input: { path: "source.py" }, toolCallId: "call-1" }); expect(result?.block).toBe(true); expect(api.sent).toEqual([]); });