Skip to content
Open
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
65 changes: 65 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,71 @@ jobs:
uv run --frozen graphify --help
uv run --frozen graphify install

omp-bridge:
# tests/omp.test.ts drives the real OMP extension: bun resolves the
# path-utils helpers it imports from the pinned @oh-my-pi/pi-coding-agent
# devDependency in graphify/omp/ (the only package the host provides;
# graphify/omp/index.ts vendors isReadableUrlPath locally instead of
# importing it from @oh-my-pi/pi-tui, which the host never resolves), and
# the policy assertions run against the real installed Python CLI, so
# this job needs both toolchains.
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

- name: Install dependencies
run: uv sync --frozen

- name: Install OMP extension dependencies
run: bun install --frozen-lockfile
working-directory: graphify/omp

- name: Run OMP bridge tests
run: GRAPHIFY_TEST_CLI=$PWD/.venv/bin/graphify bun test tests/omp.test.ts

- name: Check for upstream symbol moves (advisory, no execution)
# The frozen run above only proves the bridge works against the
# exact pinned version. Upstream OMP has moved exported symbols
# between minor releases before (isReadableUrlPath moved from
# pi-coding-agent to pi-tui) without the frozen lockfile ever seeing
# it; index.ts now vendors that predicate locally instead of
# tracking whichever package currently exports it. This step used
# to install @latest AND execute the whole suite against it --
# unpinned, mutable third-party code running on every CI invocation.
# It now only downloads the published package (--ignore-scripts:
# no install hooks run) and statically compares index.ts's imported
# path-utils symbols against that file's declared exports as plain
# text -- the downloaded code is never imported or executed, but a
# future symbol move still surfaces here.
# continue-on-error: an unrelated upstream release must not block a
# merge; the frozen run above stays the blocking gate.
continue-on-error: true
run: |
bun add --cwd graphify/omp -d @oh-my-pi/pi-coding-agent@latest --ignore-scripts
bun -e '
const fs = require("node:fs");
const index = fs.readFileSync("graphify/omp/index.ts", "utf8");
const block = index.match(/import\s*{([^}]+)}\s*from\s*"@oh-my-pi\/pi-coding-agent\/tools\/path-utils"/);
if (!block) throw new Error("no path-utils import block found in index.ts");
const imported = block[1].split(",").map((s) => s.trim()).filter(Boolean);
const source = fs.readFileSync("graphify/omp/node_modules/@oh-my-pi/pi-coding-agent/src/tools/path-utils.ts", "utf8");
const exported = new Set([...source.matchAll(/^export\s+(?:async\s+function|function)\s+(\w+)/gm)].map((m) => m[1]));
const missing = imported.filter((name) => !exported.has(name));
if (missing.length) {
console.error(`@oh-my-pi/pi-coding-agent@latest no longer exports: ${missing.join(", ")}`);
process.exit(1);
}
console.log(`ok: every imported symbol is exported by @latest (${imported.join(", ")})`);
'

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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,9 @@ paper/

# macOS Finder metadata
.DS_Store

# OMP extension test dependency tree (graphify/omp)
graphify/omp/node_modules/

# Local symlink for the root-level bun tests (temporary, per AGENTS.md)
/node_modules
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu

## 0.9.58 (2026-09-10)

- 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-call guidance appended to each tool result without building indexes.
- 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).
- 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).
- 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).
Expand Down
55 changes: 54 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,7 @@ for example `graphify claude install --project` or `graphify codex install --pro
| Agent Skills (cross-framework) | `graphify install --platform agents` (alias `--platform skills`) |
| Kiro IDE/CLI | `graphify kiro install` |
| Pi coding agent | `graphify install --platform pi` |
| Oh My Pi (native guard) | `graphify omp install` |
| Cursor | `graphify cursor install` |
| Devin CLI | `graphify devin install` |
| Google Antigravity | `graphify antigravity install` |
Expand Down Expand Up @@ -303,6 +304,7 @@ Run this once in your project after building a graph:
| Agent Skills (cross-framework) | `graphify agents install` (alias `graphify skills install`) |
| Kiro IDE/CLI | `graphify kiro install` |
| Pi coding agent | `graphify pi install` |
| Oh My Pi (native guard) | `graphify omp install` |
| Devin CLI | `graphify devin install` |
| Google Antigravity | `graphify antigravity install` |

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

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

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`).
### Oh My Pi (OMP) native guard

Install Graphify into a persistent environment with `uv tool install graphifyy` or
`pipx install graphifyy`, and install OMP separately. Then run:

```bash
graphify omp install
```

This delegates to OMP's supported `omp plugin install <local-package-directory>`
route. The Python wheel and source distribution contain `graphify/omp/package.json`
with explicit `omp.extensions` and its TypeScript entry point; OMP links that
directory and discovers the extension. No npm adapter package, shell hook, Pi API
shim, or hand-edited OMP settings are required. `graphify omp path` prints the same
directory for manual `omp plugin install` or one-session `omp -e` use. Restart OMP
after installing/upgrading, and rerun the installer if the Python environment
moves. Do not link from an ephemeral `uvx` environment.

Before native `read`, `glob`, `grep`, and search-style `bash` calls, the extension
runs the installed `graphify hook-guard read|search` CLI. The existing Python
policy owns fresh/stale graph decisions and strict-mode denials: start OMP with
`GRAPHIFY_HOOK_STRICT=1` to enable its once-per-session indexed-read block.
Denials become OMP `block`/`reason`; each qualifying call carries its own
guidance, appended to that call's persisted tool result (Claude
`PreToolUse` additionalContext parity), and pending deliveries are cleared for
each new user run and session navigation. In-flight hooks
are cancelled on these boundaries. No graph is created or updated automatically.
The guard package intentionally declares no skills; the existing cross-framework
skill remains available separately through `graphify agents install`.

Only local filesystem targets are inspected, using OMP's selector/path helpers.
URLs and internal resources are excluded. Hooks require an installed `graphify`
on an absolute PATH entry outside the project (including outside a project-local
virtualenv); there is no project Python or command fallback. Each native tool call
has a 256 KiB JSON-input cap, a 64 KiB output cap and a two-second subprocess budget
with forced termination. Missing commands, invalid output and failures fail open.

The extension checks `ctx.isProjectTrusted()` before execution and context
injection, but **current OMP exposes this compatibility method as always true**:
it is not an enforced trust sandbox. Enable this integration only in projects you
trust. The lifecycle contract is based on OMP integration commit
`6aef0e8ad51b3bc5ea7a5f2a255c3d48e4c5af72`; the `18.1.17` version string alone
does not establish those lifecycle fixes.

Remove this host-managed link with `omp plugin uninstall graphify-omp` **before**
uninstalling the Python package. `graphify uninstall` handles Graphify-managed
platform files, not OMP's plugin registry.

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.

---

Expand Down Expand Up @@ -762,6 +812,9 @@ graphify kiro install # .kiro/skills/ + .kiro/steering/graphify.md
graphify kiro uninstall
graphify pi install # skill file (Pi coding agent)
graphify pi uninstall
graphify omp install # native guard package, registered by OMP
graphify omp path # shipped package directory for manual linking
omp plugin uninstall graphify-omp # remove the host-managed link
graphify devin install # skill file + .windsurf/rules/graphify.md (Devin CLI)
graphify devin uninstall
graphify antigravity install # .agents/rules + .agents/workflows (Google Antigravity)
Expand Down
3 changes: 2 additions & 1 deletion graphify/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -517,7 +517,7 @@ def _run_cli() -> None:
# Skip during install/uninstall (hook writes trigger a fresh check anyway).
# Skip during hook-check — it runs on every editor tool use and must be silent.
# Deduplicate paths so platforms sharing the same install dir don't warn twice.
_silent_cmds = {"install", "uninstall", "hook-check", "hook-guard"}
_silent_cmds = {"install", "uninstall", "hook-check", "hook-guard", "omp"}
if not any(arg in _silent_cmds for arg in sys.argv):
# Resolve each platform's real user-scope destination so per-platform
# overrides (gemini, opencode, devin, antigravity, amp) check the dir
Expand All @@ -536,6 +536,7 @@ def _run_cli() -> None:
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)")
print(" uninstall remove graphify from all detected platforms in one shot")
print(" --purge also delete graphify-out/ directory")
print(" omp [install|path] install the native Oh My Pi guard, or print its package path")
print(" path \"A\" \"B\" shortest path between two nodes in graph.json")
print(" --graph <path> path to graph.json (default graphify-out/graph.json)")
print(" explain \"X\" plain-language explanation of a node and its neighbors")
Expand Down
136 changes: 136 additions & 0 deletions graphify/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import time
from graphify.paths import GRAPHIFY_OUT as _GRAPHIFY_OUT
from pathlib import Path, PurePosixPath, PureWindowsPath
from urllib.parse import unquote, urlsplit


_SEARCH_NUDGE = json.dumps({
Expand Down Expand Up @@ -894,10 +895,26 @@ def _run_hook_guard(kind: str, strict: bool = False) -> None:
if explicit:
in_project = False
for v in explicit:
v = _normalize_hook_path(v)
# A bare www.host/... value is remote only when nothing
# identically named exists locally, mirroring OMP's own
# "an existing local path wins over URL" precedence
# (resolveToolSearchScope). Decided here, not inside
# _is_cwd_relative, which has no cwd/root of its own.
if _is_external_www_target(v, root):
continue
p = Path(v)
if _is_cwd_relative(v):
in_project = True # relative -> anchored at cwd == in project
break
# _is_cwd_relative already rejects a *whole-value*
# scheme://... prefix above (a rootless URL never reaches
# here). This catches the same URL after some upstream
# host has already glued it onto an absolute prefix
# (`<root>/local:/x`), which looks exactly like a real
# file to the containment check below.
if _has_embedded_url_scheme_segment(v):
continue
try:
p.resolve().relative_to(root)
in_project = True
Expand Down Expand Up @@ -942,6 +959,73 @@ def _run_hook_guard(kind: str, strict: bool = False) -> None:
pass


_URL_SCHEME_PREFIX_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]*://")
_URL_SCHEME_SEGMENT_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]*:$")
_FILE_URL_RE = re.compile(r"^file://", re.IGNORECASE)
_WWW_HOST_RE = re.compile(r"^www\.", re.IGNORECASE)


def _normalize_hook_path(value: str) -> str:
r"""Trim padding whitespace and a matching pair of outer double quotes,
then strip a *local* leading ``file://`` scheme down to the local path
it names.

Mirrors what OMP's own path pipeline already does to a raw tool argument
before a hook would ever see it: ``normalizePathLikeInput`` (trim +
de-quote) and ``file://`` -> ``url.fileURLToPath`` (its own
``strictExternalUrlRe`` deliberately omits ``file``, routing it through
the ordinary local-file path instead of the external-URL one). The guard
must classify identical text identically whether or not a given host
bothered to normalize it first -- a classifier that only agrees with its
own host after trimming/de-quoting is exactly the kind of gap a prior
review flagged.

Per RFC 8089, a ``file://`` URL is local only when its authority is
empty (``file:///path``) or ``localhost``; Node's own
``url.fileURLToPath`` enforces exactly this, throwing
``ERR_INVALID_FILE_URL_HOST`` for any other host. Any other authority
names a *remote* host, not a local path -- reducing it here would
discard the host and let ``file://evil.com/<in-project path>`` alias a
real local file, so it is left untouched for ``_is_foreign_url_scheme``
to classify instead.
"""
value = value.strip()
if len(value) > 1 and value[0] == value[-1] == '"':
value = value[1:-1]
if _FILE_URL_RE.match(value):
split = urlsplit(value)
if split.hostname and split.hostname.lower() != "localhost":
return value
path = unquote(split.path) or "/"
# file:///C:/proj/a.py -> C:/proj/a.py: drop the URL's extra root
# slash in front of a Windows drive letter.
if os.name == "nt" and re.match(r"^/[A-Za-z]:", path):
path = path[1:]
return path
return value


def _is_foreign_url_scheme(value: str) -> bool:
"""Whether *value* is itself a ``scheme://...`` value -- everything
except a *local* ``file://`` (empty or ``localhost`` authority, RFC
8089), which ``_normalize_hook_path`` has already reduced to a plain
local path by the time this runs, so it never matches here. A
``file://`` with any other authority is NOT reduced by
``_normalize_hook_path`` and so matches here like any other foreign
scheme, naming a remote host rather than a local file.

Deliberately does not enumerate OMP's (or any other harness's) internal
scheme allow-list (``local://``, ``artifact://``, ...): this guard is
embedded by multiple hosts (#522), and hard-coding one of them would
silently stop matching the day that host adds a scheme. Any scheme this
function doesn't specifically know to be local -- only a local
``file://`` is -- is treated as not-a-local-source-file: a missed nudge
on a remote URL is a far safer wrong answer than a wrong nudge on an
unrelated file.
"""
return bool(_URL_SCHEME_PREFIX_RE.match(value))


def _is_cwd_relative(value: str) -> bool:
r"""Whether *value* is anchored at the current working directory.

Expand All @@ -964,11 +1048,63 @@ def _is_cwd_relative(value: str) -> bool:
so ``paths.is_absolute_any_platform`` (for stored, portable paths) is
deliberately not used. On POSIX ``root`` is set exactly when the path is
absolute and ``drive`` is always empty, so this is unchanged there.

A URL is rootless and driveless by that exact same test -- ``https://x``
and ``myscheme://x`` have no ``root`` and no ``drive`` in either flavour
-- so without checking the scheme first, this rule alone short-circuits
the caller straight to "in project" for a value that names no filesystem
path at all (the bug this guards against, #522 follow-up).
``_normalize_hook_path`` and ``_is_foreign_url_scheme`` reject that
shape, and strip a ``file://`` wrapper down to its local path, before
the root/drive test below ever runs.
"""
value = _normalize_hook_path(value)
if _is_foreign_url_scheme(value):
return False
pure = PureWindowsPath(value) if os.name == "nt" else PurePosixPath(value)
return not pure.root and not pure.drive


def _has_embedded_url_scheme_segment(value: str) -> bool:
"""Whether an already-absolute *value* still carries a collapsed
``scheme://`` marker as one of its OWN path segments, e.g.
``<root>/local:/x`` -- what a naive host-side path-join leaves behind
when it glues a rootless ``scheme://...`` value onto a root/cwd prefix
instead of routing it through a URL handler, defeating the whole-value
check in ``_is_cwd_relative`` above.

Only meaningful once a value is already known to have a root or drive
(``_is_cwd_relative`` returned ``False``): the leading component IS that
root/drive marker, so it is skipped here on purpose -- a genuinely
relative, colon-bearing POSIX filename like ``C:/proj/a.py`` never
reaches this function at all (it takes the cwd-relative shortcut above
and stays in-project, matching POSIX semantics where a colon has no
special meaning).
"""
pure = PureWindowsPath(value) if os.name == "nt" else PurePosixPath(value)
parts = pure.parts[1:] if (pure.root or pure.drive) else pure.parts
return any(_URL_SCHEME_SEGMENT_RE.match(part) for part in parts)


def _is_external_www_target(value: str, root: "Path") -> bool:
"""Whether *value* is a bare ``www.host/...`` external-read shape with no
identically named local file to override it.

Mirrors OMP's own ``isReadableUrlPath``/``resolveToolSearchScope``
precedence: a ``www.`` value carries no ``://`` at all, so no scheme rule
catches it, and it is remote only when nothing local shares its exact
name ("an existing local path wins over URL"). A project that genuinely
has a top-level path named e.g. ``www.example.com`` stays classified as
local; only the common case -- no such path -- is treated as external.
"""
if not _WWW_HOST_RE.match(value):
return False
try:
return not (root / value).exists()
except OSError:
return False


def _target_is_indexed(file_path: str, root: "Path") -> bool:
"""Guard the strict deny: only block a read of a file the graph actually indexes.
Reads manifest.json (cheap, capped); on any doubt (missing/corrupt/oversized
Expand Down
Loading
Loading