Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand All @@ -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 <package directory>`) 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 `<system-reminder source="graphify">` 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 <old path>`.

> Codex uses `$graphify` instead of `/graphify`.

</details>
Expand Down
3 changes: 3 additions & 0 deletions graphify/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
17 changes: 16 additions & 1 deletion graphify/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""
Expand Down
190 changes: 190 additions & 0 deletions graphify/pi/index.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> };
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/<in-project path>` 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<string | undefined> {
if (Buffer.byteLength(payload) > INPUT_LIMIT || signal.aborted) return Promise.resolve(undefined);
const { promise, resolve: done } = Promise.withResolvers<string | undefined>();
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<string, unknown> = { ...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 = `<system-reminder source="graphify">\n${contexts.join("\n\n")}\n</system-reminder>`;
if (passive) return { additionalContext: reminder };
api.sendMessage?.({ customType: "graphify", content: reminder, display: true }, { deliverAs: "steer" });
});
}
10 changes: 10 additions & 0 deletions graphify/pi/package.json
Original file line number Diff line number Diff line change
@@ -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"]
}
}
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,8 @@ include-package-data = false
# under graphify/skills/<host>/references/, and the always-on injection blocks
# under graphify/always_on/. There is no graphify/skills/<host>/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"]
Expand Down
Loading
Loading