diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b6f5e373f..53b06488a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,6 +78,29 @@ jobs: uv run --frozen graphify --help uv run --frozen graphify install + pi-guard: + # tests/pi.test.ts loads the real extension (Node built-ins only, no + # node_modules, as when installed) against the real installed Python CLI. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v8.1.0 + with: + python-version: "3.12" + + - name: Install bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.4.0 + + - name: Install dependencies + run: uv sync --frozen + + - name: Run Pi guard tests + run: GRAPHIFY_TEST_CLI=$PWD/.venv/bin/graphify bun test tests/pi.test.ts + security-scan: # The dev deps include bandit and pip-audit. Run them in CI so a new # HIGH-severity finding or vulnerable dependency is caught on the PR that diff --git a/README.md b/README.md index 3f8f9f5e0..ed4b09f4d 100644 --- a/README.md +++ b/README.md @@ -250,7 +250,7 @@ for example `graphify claude install --project` or `graphify codex install --pro | Amp | `graphify amp install` | | Agent Skills (cross-framework) | `graphify install --platform agents` (alias `--platform skills`) | | Kiro IDE/CLI | `graphify kiro install` | -| Pi coding agent | `graphify install --platform pi` | +| Pi coding agent | `graphify install --platform pi`, plus `graphify pi guard install` for the native guard | | Cursor | `graphify cursor install` | | Devin CLI | `graphify devin install` | | Google Antigravity | `graphify antigravity install` | @@ -259,6 +259,8 @@ Codex users also need `multi_agent = true` under `[features]` in `~/.codex/confi `--platform agents` (alias `--platform skills`) targets the generic cross-framework [Agent-Skills](https://github.com/anthropics/skills) locations: the spec's user-global `~/.agents/skills/` (read by `npx skills` and spec-compliant frameworks) for a global install, and `./.agents/skills/` for a project (`--project`) install. The bare `graphify install` stays single-platform (Claude Code) by design — use the named `agents` platform when you want the skill discoverable by any framework that reads `.agents/skills`. +Pi has no `PreToolUse` hook, so `graphify pi guard install` registers a native Pi extension (via Pi's own `pi install `) that runs the installed `graphify hook-guard` before Pi's `read`, `grep`, `find` and search-style `bash` calls. Fresh/stale graph decisions and the `GRAPHIFY_HOOK_STRICT=1` once-per-session indexed-read denial come from that same policy, so Pi matches Claude Code's hook behavior; guidance arrives as a `` steering message. It needs Pi 0.58.4 or newer: earlier versions let a steering message skip the rest of a parallel tool-call batch. The package ships inside the Graphify installation, so install Graphify persistently (`uv tool install graphifyy` or `pipx install graphifyy`, not `uvx`), restart Pi after installing or upgrading, and remove it with `graphify pi guard uninstall`. Only local file targets are inspected; the hook needs `graphify` on an absolute `PATH` entry outside the project, is bounded to a two-second, 256 KiB-in/64 KiB-out call, and fails open. The same package also loads in [Oh My Pi](https://github.com/can1357/oh-my-pi): `omp plugin install "$(graphify pi guard path)"`. The registered path contains the Python version and venv, so re-run `graphify pi guard install` after changing either, and remove the old entry with `pi remove `. + > Codex uses `$graphify` instead of `/graphify`. diff --git a/graphify/__main__.py b/graphify/__main__.py index b65e3041a..579087662 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -1059,6 +1059,9 @@ def _run_cli() -> None: print(" kiro uninstall remove skill + steering file") print(" pi install write skill to ~/.pi/agent/skills/graphify/ (Pi coding agent)") print(" pi uninstall remove skill from ~/.pi/agent/skills/graphify/") + print(" pi guard install register the native Pi guard extension via `pi install`") + print(" pi guard uninstall unregister it via `pi remove`") + print(" pi guard path print the guard package path (e.g. for `omp plugin install`)") print(" devin install write skill to ~/.config/devin/skills/graphify/ (Devin CLI)") print(" devin uninstall remove skill from ~/.config/devin/skills/graphify/") print() diff --git a/graphify/install.py b/graphify/install.py index 66fe4ad8a..5291056c7 100644 --- a/graphify/install.py +++ b/graphify/install.py @@ -2696,8 +2696,23 @@ def dispatch_install_cli(cmd: str) -> bool: _project_uninstall("pi", Path(".")) else: _remove_skill_file("pi") + elif subcmd == "guard" and sys.argv[3:] in (["install"], ["uninstall"], ["path"]): + # The native guard package ships inside the wheel; Pi owns its registration. + package_path = Path(__file__).resolve().parent / "pi" + if sys.argv[3] == "path": + print(package_path) + return True + pi = shutil.which("pi") + if not pi: + print("error: install Pi (pi) and add it to PATH first", file=sys.stderr) + sys.exit(1) + import subprocess + verb = "install" if sys.argv[3] == "install" else "remove" + result = subprocess.run([pi, verb, str(package_path)], check=False) + if result.returncode: + sys.exit(result.returncode) else: - print("Usage: graphify pi [install|uninstall]", file=sys.stderr) + print("Usage: graphify pi [install|uninstall] | graphify pi guard [install|uninstall|path]", file=sys.stderr) sys.exit(1) elif cmd == "amp": subcmd = sys.argv[2] if len(sys.argv) > 2 else "" diff --git a/graphify/pi/index.ts b/graphify/pi/index.ts new file mode 100644 index 000000000..8df7746a8 --- /dev/null +++ b/graphify/pi/index.ts @@ -0,0 +1,190 @@ +import { execFile } from "node:child_process"; +import { accessSync, constants, existsSync, realpathSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { delimiter, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; + +// One extension for both Pi and Oh My Pi (OMP). It imports only Node built-ins: +// the wheel ships no node_modules, and the two hosts provide different packages +// (@earendil-works/pi-coding-agent vs @oh-my-pi/pi-coding-agent), so path and +// URL handling are implemented locally below. + +const INPUT_LIMIT = 256 * 1024; +const OUTPUT_LIMIT = 64 * 1024; +const TIMEOUT_MS = 2000; +// Host tool name -> the Claude Code tool name hook-guard expects. OMP has `glob` +// (its `path` is the pattern); Pi has `find` (`pattern` relative to `path`). OMP's +// own `find` is a semantic search without `pattern`, so it is left alone. +const TOOL_NAMES = { bash: "Bash", grep: "Grep", read: "Read", glob: "Glob", find: "Glob" } as const; +const FILE_URL_RE = /^file:\/\//i; +// A URL or host-internal resource (https://, skill://, local:/, artifact://): +// a scheme of 2+ characters followed by a slash. Two characters keep a Windows +// drive (C:/) out; the slash keeps a read selector (source.py:12) out. +const SCHEME_RE = /^[A-Za-z][A-Za-z0-9+.-]+:\//; +// OMP read selectors after a path: `:50`, `:50-80`, `:5-16,960-973`, `:raw`, `:-60`. +const SELECTOR_RE = /^(.+?)((?::[^:\\/]+)+)$/; + +type HostEvent = { toolName: string; input: Record }; +type HostContext = { + cwd: string; + signal?: AbortSignal; + isProjectTrusted?: () => boolean; + sessionManager: { getSessionId(): string }; +}; +type HostAPI = { + on(event: string, handler: (event: never, ctx: HostContext) => unknown): void; + sendMessage?(message: { customType: string; content: string; display: boolean }, options: { deliverAs: "steer" }): void; + // Present only on OMP, which hands extensions its own module. + pi?: unknown; +}; + +// RFC 8089 / Node's url.fileURLToPath: a file:// URL is local only when its +// authority is empty or `localhost`. Any other authority names a remote host, +// so `file://evil.com/` cannot alias a real local file. +function isRemote(path: string): boolean { + if (FILE_URL_RE.test(path)) { + try { + const { hostname } = new URL(path); + return !(hostname === "" || hostname.toLowerCase() === "localhost"); + } catch { + return true; + } + } + return SCHEME_RE.test(path) || /^www\./i.test(path) || path.includes("://"); +} + +// A local target as an absolute path, normalized the way Pi's tools do (leading +// `@` dropped, `~` expanded). A literal file wins over a selector reading, so a +// file really named `source.py:12` is not truncated. +function localPath(value: string, cwd: string, selectors: boolean): string { + if (value.startsWith("@")) value = value.slice(1); + if (value === "~" || value.startsWith("~/")) value = join(homedir(), value.slice(2)); + if (FILE_URL_RE.test(value)) return fileURLToPath(value.replace(/^file:\/\/localhost/i, "file://")); + const literal = resolve(cwd, value); + if (!selectors || existsSync(literal)) return literal; + const match = SELECTOR_RE.exec(value); + return match ? resolve(cwd, match[1]) : literal; +} + +function which(name: string, path: string): string | undefined { + const exts = process.platform === "win32" ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";") : [""]; + for (const dir of path.split(delimiter)) { + for (const ext of exts) { + const candidate = join(dir, name + ext); + try { + if (!statSync(candidate).isFile()) continue; + accessSync(candidate, constants.X_OK); + return candidate; + } catch { + // not here, or not executable + } + } + } +} + +// The first absolute PATH entry whose `graphify` lives outside the project. The +// PATH entry itself is returned, not its realpath: a shim may dispatch on argv[0]. +function installedCommand(cwd: string): string | undefined { + const project = realpathSync(cwd); + for (const dir of (process.env.PATH ?? "").split(delimiter).filter(isAbsolute)) { + const found = which("graphify", dir); + if (!found) continue; + const within = relative(project, realpathSync(found)); + if (within.startsWith(`..${sep}`) || isAbsolute(within)) return found; + } +} + +function runGuard(command: string, kind: string, payload: string, cwd: string, signal: AbortSignal, timeout: number, maxBuffer: number): Promise { + if (Buffer.byteLength(payload) > INPUT_LIMIT || signal.aborted) return Promise.resolve(undefined); + const { promise, resolve: done } = Promise.withResolvers(); + const child = execFile(command, ["hook-guard", kind], { + cwd, encoding: "utf8", timeout, killSignal: "SIGKILL", maxBuffer, signal, + env: { ...process.env, CLAUDE_PROJECT_DIR: cwd }, + }, (error, stdout) => done(error ? undefined : stdout.trim() || undefined)); + child.stdin?.on("error", () => {}); + child.stdin?.end(payload); + return promise; +} + +export default function graphify(api: HostAPI): void { + // OMP delivers `additionalContext` from tool_call as a separate passive + // developer message after the batch: deduplicated, dropped for denied or + // failed calls, and shown on the tool card. Pi has no such channel, so it + // gets the same text as a steering message, delivered after the tool batch + // and before the next model call. Either way raw tool output stays untouched, + // so a file or command cannot forge this instruction channel. Both hosts send + // it as unlabelled user text, so it is wrapped the way Claude Code labels + // PreToolUse context; bare text reads as noise and agents ignore it. + const passive = typeof api.pi === "object" && api.pi !== null; + let controller = new AbortController(); + const reset = () => { + controller.abort(); + controller = new AbortController(); + }; + // session_switch and session_branch exist only on OMP; Pi reloads extensions + // and emits session_shutdown + session_start instead. + for (const event of ["session_start", "session_switch", "session_branch", "session_tree", "session_shutdown", "before_agent_start"]) { + api.on(event, reset); + } + + api.on("tool_call", async (event: HostEvent, ctx: HostContext) => { + const trusted = () => ctx.isProjectTrusted?.() ?? true; + const signal = ctx.signal ? AbortSignal.any([controller.signal, ctx.signal]) : controller.signal; + const deadline = performance.now() + TIMEOUT_MS; + const contexts: string[] = []; + try { + if (!trusted()) { reset(); return; } + if (!Object.hasOwn(TOOL_NAMES, event.toolName)) return; + if (event.toolName === "find" && typeof event.input.pattern !== "string") return; + const command = installedCommand(ctx.cwd); + if (!command) return; + const toolName = TOOL_NAMES[event.toolName as keyof typeof TOOL_NAMES]; + const kind = toolName === "Bash" || toolName === "Grep" ? "search" : "read"; + // The search CLI reads only `command`/`pattern`, so its input goes through unchanged. + let inputs = [event.input]; + if (kind === "read") { + const rawPath = typeof event.input.path === "string" ? event.input.path.trim() : "."; + // OMP's glob accepts `;`-separated targets; Pi paths never contain one. + const paths = event.toolName === "glob" ? + rawPath.split(";").map(entry => entry.trim()).filter(Boolean) : [rawPath]; + inputs = []; + for (const path of paths) { + if (isRemote(path)) continue; + const input: Record = { ...event.input }; + const resolved = localPath(path, ctx.cwd, toolName === "Read"); + delete input.path; + if (toolName === "Read") input.file_path = resolved; + // OMP's glob target is a pattern; the CLI containment-checks only `path`. + else if (event.toolName === "glob") { input.pattern = resolved; input.path = resolved; } + else input.path = resolved; + inputs.push(input); + } + } + for (const input of inputs) { + const timeout = Math.floor(deadline - performance.now()); + if (signal.aborted || !trusted() || timeout <= 0) return; + const output = await runGuard(command, kind, JSON.stringify({ + session_id: ctx.sessionManager.getSessionId(), cwd: ctx.cwd, tool_name: toolName, tool_input: input, + }), ctx.cwd, signal, timeout, OUTPUT_LIMIT); + if (signal.aborted || !trusted()) return; + if (!output) continue; + let hook; + try { hook = JSON.parse(output)?.hookSpecificOutput; } catch { continue; } + if (hook?.hookEventName !== "PreToolUse") continue; + if (hook.permissionDecision === "deny" && typeof hook.permissionDecisionReason === "string" && hook.permissionDecisionReason.trim()) { + return { block: true, reason: hook.permissionDecisionReason }; + } + if (typeof hook.additionalContext === "string" && hook.additionalContext.trim()) { + contexts.push(hook.additionalContext); + } + } + } catch { + // Optional guidance must not break native tools on missing executables, + // invalid paths, malformed hook output, timeout, or cancellation. + } + if (contexts.length === 0) return; + const reminder = `\n${contexts.join("\n\n")}\n`; + if (passive) return { additionalContext: reminder }; + api.sendMessage?.({ customType: "graphify", content: reminder, display: true }, { deliverAs: "steer" }); + }); +} diff --git a/graphify/pi/package.json b/graphify/pi/package.json new file mode 100644 index 000000000..e045de589 --- /dev/null +++ b/graphify/pi/package.json @@ -0,0 +1,10 @@ +{ + "name": "graphify-pi", + "private": true, + "description": "Native Pi (and Oh My Pi) bridge to Graphify's installed hook-guard CLI", + "license": "Apache-2.0", + "type": "module", + "pi": { + "extensions": ["./index.ts"] + } +} diff --git a/pyproject.toml b/pyproject.toml index 18e62ca1e..16f38b981 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -162,7 +162,8 @@ include-package-data = false # under graphify/skills//references/, and the always-on injection blocks # under graphify/always_on/. There is no graphify/skills//SKILL.md in the # repo, so no SKILL.md glob is needed here. -graphify = ["skill.md", "skill-codex.md", "skill-opencode.md", "skill-kilo.md", "command-kilo.md", "skill-aider.md", "skill-amp.md", "skill-agents.md", "skill-copilot.md", "skill-claw.md", "skill-windows.md", "skill-droid.md", "skill-trae.md", "skill-kiro.md", "skill-vscode.md", "skill-pi.md", "skill-devin.md", "skills/*/references/*.md", "always_on/*.md"] +# graphify/pi/ is the native Pi guard package; Pi registers it in place from the installation. +graphify = ["skill.md", "skill-codex.md", "skill-opencode.md", "skill-kilo.md", "command-kilo.md", "skill-aider.md", "skill-amp.md", "skill-agents.md", "skill-copilot.md", "skill-claw.md", "skill-windows.md", "skill-droid.md", "skill-trae.md", "skill-kiro.md", "skill-vscode.md", "skill-pi.md", "skill-devin.md", "skills/*/references/*.md", "always_on/*.md", "pi/package.json", "pi/index.ts"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/tests/pi.test.ts b/tests/pi.test.ts new file mode 100644 index 000000000..33e0f74d3 --- /dev/null +++ b/tests/pi.test.ts @@ -0,0 +1,347 @@ +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([]); +}); diff --git a/tests/test_pi_guard_install.py b/tests/test_pi_guard_install.py new file mode 100644 index 000000000..520b496c5 --- /dev/null +++ b/tests/test_pi_guard_install.py @@ -0,0 +1,56 @@ +"""Pi owns registration; Graphify supplies a wheel-contained native package.""" +import json +from pathlib import Path +import sys +from types import SimpleNamespace + +import pytest + +from graphify.install import dispatch_install_cli + +try: + import tomllib +except ModuleNotFoundError: # Python 3.10 + import tomli as tomllib # type: ignore[no-redef] + + +def test_pi_guard_path_is_a_complete_packaged_extension(monkeypatch, capsys): + monkeypatch.setattr(sys, "argv", ["graphify", "pi", "guard", "path"]) + assert dispatch_install_cli("pi") + package = Path(capsys.readouterr().out.strip()) + manifest = json.loads((package / "package.json").read_text(encoding="utf-8")) + entries = manifest["pi"]["extensions"] + assert entries + pyproject = tomllib.loads((package.parents[1] / "pyproject.toml").read_text(encoding="utf-8")) + package_data = pyproject["tool"]["setuptools"]["package-data"]["graphify"] + assert "pi/package.json" in package_data + for entry in entries: + assert (package / entry).is_file() + assert (package / entry).resolve().is_relative_to(package) + assert f"pi/{Path(entry).name}" in package_data + + +@pytest.mark.parametrize(("action", "verb"), [("install", "install"), ("uninstall", "remove")]) +def test_pi_guard_delegates_to_host_and_propagates_failure(monkeypatch, action, verb): + monkeypatch.setattr(sys, "argv", ["graphify", "pi", "guard", action]) + monkeypatch.setattr("graphify.install.shutil.which", lambda name: "/usr/bin/pi") + + def host(argv, *, check): + assert argv[:2] == ["/usr/bin/pi", verb] + assert (Path(argv[2]) / "package.json").is_file() + return SimpleNamespace(returncode=23) + + monkeypatch.setattr("subprocess.run", host) + with pytest.raises(SystemExit) as error: + dispatch_install_cli("pi") + assert error.value.code == 23 + + +def test_pi_guard_without_host_does_not_create_configuration(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(sys, "argv", ["graphify", "pi", "guard", "install"]) + monkeypatch.setattr("graphify.install.shutil.which", lambda name: None) + with pytest.raises(SystemExit) as error: + dispatch_install_cli("pi") + assert error.value.code == 1 + assert not list(tmp_path.iterdir())