Skip to content

Commit 535aa56

Browse files
committed
feat: bridge hook-guard policy into native OMP hooks
1 parent eaaec1a commit 535aa56

9 files changed

Lines changed: 437 additions & 3 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu
5454

5555
## 0.9.58 (2026-09-10)
5656

57+
- Feature: ship a native Oh My Pi guard package with `graphify omp install`; reuse the installed hook-guard policy for bounded tool-call denials and per-run context guidance without building indexes.
5758
- Fix: a call to a Python function defined nested inside another function now resolves to that inner definition per lexical scope, instead of leaking to a same-named function elsewhere; direct recursion is preserved as a self-loop (#3410, thanks @hopstreax).
5859
- Fix: submodule imports inside a PEP 420 namespace package (a directory with no `__init__.py`) now resolve to the target module instead of being dropped (#3429, thanks @flaukowski).
5960
- Fix: a bare-name import of a module sitting next to the importing file (a flat script dir with no package) now resolves to that sibling — matching CPython's `sys.path[0]` behavior — without over-resolving a genuine third-party name (#3430, thanks @hopstreax).

‎README.md‎

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,7 @@ for example `graphify claude install --project` or `graphify codex install --pro
230230
| Agent Skills (cross-framework) | `graphify install --platform agents` (alias `--platform skills`) |
231231
| Kiro IDE/CLI | `graphify kiro install` |
232232
| Pi coding agent | `graphify install --platform pi` |
233+
| Oh My Pi (native guard) | `graphify omp install` |
233234
| Cursor | `graphify cursor install` |
234235
| Devin CLI | `graphify devin install` |
235236
| Google Antigravity | `graphify antigravity install` |
@@ -303,6 +304,7 @@ Run this once in your project after building a graph:
303304
| Agent Skills (cross-framework) | `graphify agents install` (alias `graphify skills install`) |
304305
| Kiro IDE/CLI | `graphify kiro install` |
305306
| Pi coding agent | `graphify pi install` |
307+
| Oh My Pi (native guard) | `graphify omp install` |
306308
| Devin CLI | `graphify devin install` |
307309
| Google Antigravity | `graphify antigravity install` |
308310

@@ -321,7 +323,53 @@ This writes a small config file that tells your assistant to consult the knowled
321323

322324
**Cursor** writes `.cursor/rules/graphify.mdc` with `alwaysApply: true`, so Cursor includes it in every conversation automatically, no hook needed.
323325

324-
To remove graphify from all platforms at once: `graphify uninstall` (add `--purge` to also delete `graphify-out/`). Or use the per-platform command (e.g. `graphify claude uninstall`).
326+
### Oh My Pi (OMP) native guard
327+
328+
Install Graphify into a persistent environment with `uv tool install graphifyy` or
329+
`pipx install graphifyy`, and install OMP separately. Then run:
330+
331+
```bash
332+
graphify omp install
333+
```
334+
335+
This delegates to OMP's supported `omp plugin install <local-package-directory>`
336+
route. The Python wheel and source distribution contain `graphify/omp/package.json`
337+
with explicit `omp.extensions` and its TypeScript entry point; OMP links that
338+
directory and discovers the extension. No npm adapter package, shell hook, Pi API
339+
shim, or hand-edited OMP settings are required. `graphify omp path` prints the same
340+
directory for manual `omp plugin install` or one-session `omp -e` use. Restart OMP
341+
after installing/upgrading, and rerun the installer if the Python environment
342+
moves. Do not link from an ephemeral `uvx` environment.
343+
344+
Before native `read`, `glob`, `grep`, and search-style `bash` calls, the extension
345+
runs the installed `graphify hook-guard read|search` CLI. The existing Python
346+
policy owns fresh/stale graph decisions and strict-mode denials: start OMP with
347+
`GRAPHIFY_HOOK_STRICT=1` to enable its once-per-session indexed-read block.
348+
Denials become OMP `block`/`reason`; guidance becomes one deduplicated context
349+
message, cleared for each new user run and session navigation. In-flight hooks
350+
are cancelled on these boundaries. No graph is created or updated automatically.
351+
The guard package intentionally declares no skills; the existing cross-framework
352+
skill remains available separately through `graphify agents install`.
353+
354+
Only local filesystem targets are inspected, using OMP's selector/path helpers.
355+
URLs and internal resources are excluded. Hooks require an installed `graphify`
356+
on an absolute PATH entry outside the project (including outside a project-local
357+
virtualenv); there is no project Python or command fallback. Each native tool call
358+
has a 256 KiB JSON-input cap, a 64 KiB output cap and a two-second subprocess budget
359+
with forced termination. Missing commands, invalid output and failures fail open.
360+
361+
The extension checks `ctx.isProjectTrusted()` before execution and context
362+
injection, but **current OMP exposes this compatibility method as always true**:
363+
it is not an enforced trust sandbox. Enable this integration only in projects you
364+
trust. The lifecycle contract is based on OMP integration commit
365+
`6aef0e8ad51b3bc5ea7a5f2a255c3d48e4c5af72`; the `18.1.17` version string alone
366+
does not establish those lifecycle fixes.
367+
368+
Remove this host-managed link with `omp plugin uninstall graphify-omp` **before**
369+
uninstalling the Python package. `graphify uninstall` handles Graphify-managed
370+
platform files, not OMP's plugin registry.
371+
372+
To remove Graphify-managed platform files at once: `graphify uninstall` (add `--purge` to also delete `graphify-out/`). Or use the per-platform command (e.g. `graphify claude uninstall`). OMP links are removed separately as described above.
325373

326374
---
327375

@@ -762,6 +810,9 @@ graphify kiro install # .kiro/skills/ + .kiro/steering/graphify.md
762810
graphify kiro uninstall
763811
graphify pi install # skill file (Pi coding agent)
764812
graphify pi uninstall
813+
graphify omp install # native guard package, registered by OMP
814+
graphify omp path # shipped package directory for manual linking
815+
omp plugin uninstall graphify-omp # remove the host-managed link
765816
graphify devin install # skill file + .windsurf/rules/graphify.md (Devin CLI)
766817
graphify devin uninstall
767818
graphify antigravity install # .agents/rules + .agents/workflows (Google Antigravity)

‎graphify/__main__.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -517,7 +517,7 @@ def _run_cli() -> None:
517517
# Skip during install/uninstall (hook writes trigger a fresh check anyway).
518518
# Skip during hook-check — it runs on every editor tool use and must be silent.
519519
# Deduplicate paths so platforms sharing the same install dir don't warn twice.
520-
_silent_cmds = {"install", "uninstall", "hook-check", "hook-guard"}
520+
_silent_cmds = {"install", "uninstall", "hook-check", "hook-guard", "omp"}
521521
if not any(arg in _silent_cmds for arg in sys.argv):
522522
# Resolve each platform's real user-scope destination so per-platform
523523
# overrides (gemini, opencode, devin, antigravity, amp) check the dir
@@ -536,6 +536,7 @@ def _run_cli() -> None:
536536
print(" install [--platform P] copy skill to platform config dir (claude|windows|codebuddy|codex|opencode|aider|amp|agents|claw|droid|trae|trae-cn|gemini|cursor|antigravity|hermes|kiro|pi|devin)")
537537
print(" uninstall remove graphify from all detected platforms in one shot")
538538
print(" --purge also delete graphify-out/ directory")
539+
print(" omp [install|path] install the native Oh My Pi guard, or print its package path")
539540
print(" path \"A\" \"B\" shortest path between two nodes in graph.json")
540541
print(" --graph <path> path to graph.json (default graphify-out/graph.json)")
541542
print(" explain \"X\" plain-language explanation of a node and its neighbors")

‎graphify/install.py‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2088,6 +2088,7 @@ def codebuddy_uninstall(project_dir: Path | None = None, *, project: bool = Fals
20882088
"kilo",
20892089
"kiro",
20902090
"opencode",
2091+
"omp",
20912092
"pi",
20922093
"skills",
20932094
"trae",
@@ -2105,6 +2106,25 @@ def dispatch_install_cli(cmd: str) -> bool:
21052106
"""
21062107
if cmd not in _CLI_INSTALL_COMMANDS:
21072108
return False
2109+
if cmd == "omp":
2110+
# OMP owns package registration; do not duplicate its config/paths here.
2111+
args = sys.argv[2:]
2112+
if args not in (["path"], ["install"]):
2113+
print("Usage: graphify omp [path|install]", file=sys.stderr)
2114+
sys.exit(1)
2115+
package_path = Path(__file__).resolve().parent / "omp"
2116+
if args == ["path"]:
2117+
print(package_path)
2118+
return True
2119+
omp = shutil.which("omp")
2120+
if not omp:
2121+
print("error: install Oh My Pi (omp) and add it to PATH first", file=sys.stderr)
2122+
sys.exit(1)
2123+
import subprocess
2124+
result = subprocess.run([omp, "plugin", "install", str(package_path)], check=False)
2125+
if result.returncode:
2126+
sys.exit(result.returncode)
2127+
return True
21082128
if cmd == "install":
21092129
# Default to windows platform on Windows, claude elsewhere
21102130
default_platform = "windows" if platform.system() == "Windows" else "claude"

‎graphify/omp/index.ts‎

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { execFile } from "node:child_process";
2+
import { realpathSync } from "node:fs";
3+
import { delimiter, isAbsolute, relative, sep } from "node:path";
4+
import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
5+
import {
6+
expandDelimitedPathEntries,
7+
isInternalUrlPath,
8+
isReadableUrlPath,
9+
normalizePathLikeInput,
10+
parseSearchPath,
11+
resolveReadPath,
12+
splitPathAndSelPreferringLiteral,
13+
} from "@oh-my-pi/pi-coding-agent/tools/path-utils";
14+
15+
const INPUT_LIMIT = 256 * 1024;
16+
const OUTPUT_LIMIT = 64 * 1024;
17+
const TIMEOUT_MS = 2000;
18+
const CONTEXT_TYPE = "graphify-guard";
19+
const TOOL_NAMES = { bash: "Bash", grep: "Grep", read: "Read", glob: "Glob" } as const;
20+
21+
function isRemote(path: string): boolean {
22+
return isInternalUrlPath(path) || isReadableUrlPath(path) || path.includes("://");
23+
}
24+
25+
function installedCommand(cwd: string): string | undefined {
26+
// Never resolve a bare/relative PATH entry against the project or use a
27+
// project-supplied Python module as a fallback for an absent installation.
28+
const path = (process.env.PATH ?? "").split(delimiter).filter(isAbsolute).join(delimiter);
29+
const found = path ? Bun.which("graphify", { PATH: path }) : null;
30+
if (!found) return;
31+
const command = realpathSync(found);
32+
const within = relative(realpathSync(cwd), command);
33+
if (!within || (!within.startsWith(`..${sep}`) && !isAbsolute(within))) return;
34+
return command;
35+
}
36+
37+
function runGuard(command: string, kind: string, payload: string, cwd: string, signal: AbortSignal, timeout: number, maxBuffer: number): Promise<string | undefined> {
38+
if (Buffer.byteLength(payload) > INPUT_LIMIT || signal.aborted) return Promise.resolve(undefined);
39+
const { promise, resolve } = Promise.withResolvers<string | undefined>();
40+
const child = execFile(command, ["hook-guard", kind], {
41+
cwd, encoding: "utf8", timeout, killSignal: "SIGKILL", maxBuffer, signal,
42+
env: { ...process.env, CLAUDE_PROJECT_DIR: cwd },
43+
}, (error, stdout) => resolve(error ? undefined : stdout.trim() || undefined));
44+
child.stdin?.on("error", () => {});
45+
child.stdin?.end(payload);
46+
return promise;
47+
}
48+
49+
export default function graphify(api: ExtensionAPI): void {
50+
const guidance = new Set<string>();
51+
let guidanceBytes = 0;
52+
let generation = 0;
53+
let controller = new AbortController();
54+
const reset = () => {
55+
generation++;
56+
controller.abort();
57+
controller = new AbortController();
58+
guidance.clear();
59+
guidanceBytes = 0;
60+
};
61+
api.on("session_start", reset);
62+
api.on("session_switch", reset);
63+
api.on("session_branch", reset);
64+
api.on("session_tree", reset);
65+
api.on("session_shutdown", reset);
66+
api.on("before_agent_start", reset);
67+
68+
api.on("tool_call", async (event, ctx) => {
69+
if (!ctx.isProjectTrusted()) { reset(); return; }
70+
if (!Object.hasOwn(TOOL_NAMES, event.toolName)) return;
71+
const current = generation;
72+
const signal = controller.signal;
73+
const deadline = performance.now() + TIMEOUT_MS;
74+
try {
75+
if (Buffer.byteLength(JSON.stringify(event.input)) > INPUT_LIMIT) return;
76+
const command = installedCommand(ctx.cwd);
77+
if (!command) return;
78+
const toolName = TOOL_NAMES[event.toolName as keyof typeof TOOL_NAMES];
79+
const rawPath = typeof event.input.path === "string" ? normalizePathLikeInput(event.input.path) : ".";
80+
if (isRemote(rawPath)) return;
81+
const paths = toolName === "Read" || toolName === "Bash" ? [rawPath] :
82+
await expandDelimitedPathEntries([rawPath], ctx.cwd, { splitter: parseSearchPath });
83+
let remainingOutput = OUTPUT_LIMIT;
84+
for (const path of paths) {
85+
if (isRemote(path)) continue;
86+
const input: Record<string, unknown> = { ...event.input };
87+
if (toolName !== "Bash") {
88+
const target = toolName === "Glob" ? path : (await splitPathAndSelPreferringLiteral(path, ctx.cwd)).path;
89+
const resolved = resolveReadPath(target, ctx.cwd);
90+
delete input.path;
91+
if (toolName === "Read") input.file_path = resolved;
92+
else if (toolName === "Glob") input.pattern = resolved;
93+
else input.path = resolved;
94+
}
95+
const timeout = Math.floor(deadline - performance.now());
96+
if (current !== generation || !ctx.isProjectTrusted() || timeout <= 0 || remainingOutput <= 0) return;
97+
const output = await runGuard(command, toolName === "Bash" || toolName === "Grep" ? "search" : "read", JSON.stringify({
98+
session_id: ctx.sessionManager.getSessionId(), cwd: ctx.cwd, tool_name: toolName, tool_input: input,
99+
}), ctx.cwd, signal, timeout, remainingOutput);
100+
if (current !== generation || !ctx.isProjectTrusted()) return;
101+
if (!output) continue;
102+
remainingOutput -= Buffer.byteLength(output);
103+
const hook = JSON.parse(output)?.hookSpecificOutput;
104+
if (hook?.hookEventName !== "PreToolUse") continue;
105+
if (hook.permissionDecision === "deny" && typeof hook.permissionDecisionReason === "string" && hook.permissionDecisionReason.trim()) {
106+
return { block: true, reason: hook.permissionDecisionReason };
107+
}
108+
if (typeof hook.additionalContext === "string" && hook.additionalContext.trim() && !guidance.has(hook.additionalContext)) {
109+
const bytes = Buffer.byteLength(hook.additionalContext) + 2;
110+
if (guidanceBytes + bytes <= OUTPUT_LIMIT) {
111+
guidance.add(hook.additionalContext);
112+
guidanceBytes += bytes;
113+
}
114+
}
115+
// The search CLI does not inspect individual targets; one call suffices.
116+
if (toolName === "Grep") break;
117+
}
118+
} catch {
119+
// Optional guidance must not break native tools on missing executables,
120+
// invalid paths, malformed hook output, timeout, or cancellation.
121+
}
122+
});
123+
124+
api.on("context", (event, ctx) => {
125+
if (!ctx.isProjectTrusted()) reset();
126+
// Context transforms are not persisted. Keep one current-run message across
127+
// provider requests, and discard any prior generation's injected message.
128+
const messages = event.messages.filter(message => message.role !== "custom" || message.customType !== CONTEXT_TYPE);
129+
if (guidance.size) messages.push({
130+
role: "custom", customType: CONTEXT_TYPE, content: [...guidance].join("\n\n"), display: false, timestamp: Date.now(),
131+
});
132+
if (guidance.size || messages.length !== event.messages.length) return { messages };
133+
});
134+
}

‎graphify/omp/package.json‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"name": "graphify-omp",
3+
"version": "0.9.63",
4+
"private": true,
5+
"description": "Native Oh My Pi bridge to Graphify's installed hook-guard CLI",
6+
"license": "Apache-2.0",
7+
"type": "module",
8+
"files": ["index.ts"],
9+
"omp": {
10+
"extensions": ["./index.ts"],
11+
"skills": []
12+
}
13+
}

‎pyproject.toml‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,8 @@ include-package-data = false
147147
# under graphify/skills/<host>/references/, and the always-on injection blocks
148148
# under graphify/always_on/. There is no graphify/skills/<host>/SKILL.md in the
149149
# repo, so no SKILL.md glob is needed here.
150-
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"]
150+
# Native OMP package: linked by OMP itself from the Python installation.
151+
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", "omp/package.json", "omp/index.ts"]
151152

152153
[tool.pytest.ini_options]
153154
testpaths = ["tests"]

0 commit comments

Comments
 (0)