Fix: make Claude Code / Codebuddy PreToolUse graph-nudge hooks work on Windows (#522)
The hooks were inline POSIX bash (case/esac, [ -f ], single-quoted echo), which Windows cmd.exe/PowerShell cannot parse. On Windows the hook failed silently, so the "run `graphify query` before grepping/reading raw files" nudge was never injected and users fell back to manual /graphify. The detection logic (grep-command match; source/doc extension match; skip if the target is under the output dir; require graph.json to exist) moved into a shell-agnostic `graphify hook-guard <search|read>` subcommand, invoked via the absolute exe path resolved by _resolve_graphify_exe() — the exact pattern the codex hook already uses. A single console-script invocation has no shell syntax, so it parses identically under sh, cmd.exe and PowerShell. Behavior on macOS/Linux is unchanged: the nudge payload is byte-identical (compact JSON, same additionalContext text), matchers stay "Bash"/"Read|Glob" so install/uninstall still find and replace old hooks, and the command still contains "graphify". The graph-exists check now honors GRAPHIFY_OUT instead of the hardcoded graphify-out/ path. Codex stays a no-op there (hook-check) because Codex Desktop rejects additionalContext. Detection is fully unit-tested (ported test_read_hook.py + new test_search_hook.py, byte-identical output on POSIX); Windows execution itself is not testable in CI here, but the mechanism is now shell-independent by construction. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
5d0137388e
commit
f7911fd1b3
@@ -4,6 +4,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Fix: the Claude Code / Codebuddy `PreToolUse` graph-nudge hooks now work on Windows (#522). The hooks were inline POSIX bash (`case/esac`, `[ -f ]`, single-quoted `echo`), which Windows cmd.exe/PowerShell cannot parse — so on Windows the hook failed silently, no "run `graphify query` before grepping/reading raw files" context was injected, and users had to invoke `/graphify` by hand. The detection logic (grep-command match, source-file extension match, skip-if-under-output-dir, graph-exists check) moved into a shell-agnostic `graphify hook-guard <search|read>` subcommand invoked via the absolute exe path (the same pattern the codex hook already uses), so the hook parses and runs identically on Windows, macOS, and Linux. Behavior on macOS/Linux is unchanged (byte-identical nudge payload); the graph path now also honors `GRAPHIFY_OUT`. Codex stays a no-op there because Codex Desktop rejects `additionalContext`.
|
||||
- Fix: `--update`-style section writes to `CLAUDE.md`/`AGENTS.md` no longer corrupt or drop content (#1688, thanks @bdfinst). `_replace_or_append_section` located its managed block by substring (`marker in content`) and `next(... if marker in line)`, so a heading that appeared as a substring of another line (or duplicate headings) matched the wrong offset and the rewrite could truncate the file. It now matches the section heading exactly (`line.strip() == marker`), appends when absent, and prefers the last exact match when several exist, so unrelated content is preserved.
|
||||
- Fix: token estimation no longer crashes on files containing tiktoken special-token text like `<|endoftext|>` (#1685, thanks @Kyzcreig). `_TOKENIZER.encode(content)` raises `ValueError` by default when the text contains a special token, which aborted packing on docs/corpora that merely mention these strings. Both `encode` sites now pass `disallowed_special=()` so such text is tokenized as ordinary bytes.
|
||||
- Fix: the Ollama backend no longer multiplies a hang by the retry count (#1686, thanks @Kyzcreig). A stalled local model would wedge for `timeout * (max_retries + 1)`, which with the default 6 retries turned one long stall into a very long one. Ollama now defaults to zero client-side retries (a local model that stalls will not un-stall on retry); set `GRAPHIFY_MAX_RETRIES` to opt back in. Other backends are unchanged. Note: the underlying stall is non-deterministic and driven by the model server, so this bounds the wait rather than eliminating the hang.
|
||||
|
||||
+117
-60
@@ -434,62 +434,70 @@ def _print_project_git_add_hint(paths: list[Path]) -> None:
|
||||
print("Project-scoped install. Add to version control:")
|
||||
print(f" git add {' '.join(unique)}")
|
||||
|
||||
_SETTINGS_HOOK = {
|
||||
# Claude Code v2.1.117+ removed dedicated Grep/Glob tools; searches now go through Bash.
|
||||
# We match on Bash and inspect the command string to avoid firing on every shell call.
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": (
|
||||
"CMD=$(python3 -c \""
|
||||
"import json,sys; d=json.load(sys.stdin); "
|
||||
"print(d.get('tool_input',d).get('command',''))\" 2>/dev/null || true); "
|
||||
"case \"$CMD\" in "
|
||||
r"*grep*|*rg\ *|*ripgrep*|*find\ *|*fd\ *|*ack\ *|*ag\ *) "
|
||||
" [ -f graphify-out/graph.json ] && "
|
||||
r""" echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":"MANDATORY: graphify-out/graph.json exists. You MUST run `graphify query \"<question>\"` before grepping raw files. Only grep after graphify has oriented you, or to modify/debug specific lines."}}' """
|
||||
" || true ;; "
|
||||
"esac"
|
||||
),
|
||||
}
|
||||
],
|
||||
}
|
||||
# PreToolUse nudge payloads, emitted verbatim by the shell-agnostic
|
||||
# `graphify hook-guard` subcommand (see _run_hook_guard). The previous hooks
|
||||
# inlined POSIX bash (case/esac, [ -f ], single-quoted echo) which Windows
|
||||
# cmd.exe/PowerShell cannot parse, so on Windows the hook failed and the nudge
|
||||
# silently vanished — users had to invoke /graphify by hand (#522). Moving the
|
||||
# logic into a Python subcommand invoked via an absolute exe path makes the hook
|
||||
# parse identically under sh, cmd.exe and PowerShell. Claude Code accepts
|
||||
# additionalContext on PreToolUse (Codex Desktop does not — that path stays a
|
||||
# no-op via `hook-check`). Compact separators keep the payload byte-for-byte the
|
||||
# same JSON the old `echo` emitted.
|
||||
_SEARCH_NUDGE = json.dumps({
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PreToolUse",
|
||||
"additionalContext": (
|
||||
'MANDATORY: graphify-out/graph.json exists. You MUST run '
|
||||
'`graphify query "<question>"` before grepping raw files. Only grep '
|
||||
'after graphify has oriented you, or to modify/debug specific lines.'
|
||||
),
|
||||
}
|
||||
}, ensure_ascii=False, separators=(",", ":")) + "\n"
|
||||
|
||||
_READ_SETTINGS_HOOK = {
|
||||
# The Bash hook above never sees a file read through the native Read tool or a
|
||||
# Glob, which is the most common way an agent skips the graph: answering a
|
||||
# codebase question by Read-ing many source files one by one (issue #1114).
|
||||
# Match Read|Glob, inspect the target path, and nudge (never block) only for a
|
||||
# source/doc file outside graphify-out/ when a graph exists. The parser is
|
||||
# python3 (already a graphify dependency), the shell is POSIX, and every branch
|
||||
# fails open, so a legitimate read always goes through. Reading the graph's own
|
||||
# report under graphify-out/ is suppressed so it never starts a feedback loop.
|
||||
# The extension test compares each value's real trailing extension (segment
|
||||
# after the last '/' then after the last '.') against exts -- not a substring
|
||||
# scan, which both missed framework files like .astro and false-matched .json
|
||||
# against .js (the substring '.js' is inside '.json').
|
||||
"matcher": "Read|Glob",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": (
|
||||
"HIT=$(python3 -c \""
|
||||
"import json,sys;"
|
||||
"d=json.load(sys.stdin);"
|
||||
"t=d.get('tool_input',d);"
|
||||
"exts=('.py','.js','.ts','.tsx','.jsx','.astro','.vue','.svelte','.go','.rs','.java','.rb','.c','.h','.cpp','.hpp','.cc','.cs','.kt','.swift','.php','.scala','.lua','.sh','.md','.rst','.txt','.mdx');"
|
||||
"vals=[str(t.get('file_path') or ''),str(t.get('pattern') or ''),str(t.get('path') or '')];"
|
||||
"j=' '.join(vals).lower().replace(chr(92),'/');"
|
||||
"tails=[('.'+x.rsplit('.',1)[-1]) for v in vals if v for x in [v.lower().replace(chr(92),'/').rsplit('/',1)[-1]] if '.' in x];"
|
||||
"sys.stdout.write('1' if 'graphify-out/' not in j and any(tl in exts for tl in tails) else '')\" 2>/dev/null || true); "
|
||||
"if [ \"$HIT\" = 1 ] && [ -f graphify-out/graph.json ]; then "
|
||||
r"""echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":"MANDATORY: graphify-out/graph.json exists. You MUST run graphify before reading source files. Use: `graphify query \"<question>\"` (scoped subgraph), `graphify explain \"<concept>\"`, or `graphify path \"<A>\" \"<B>\"`. Only read raw files after graphify has oriented you, or to modify/debug specific lines. This rule applies to subagents too — include it in every subagent prompt involving code exploration."}}'; """
|
||||
"fi || true"
|
||||
),
|
||||
}
|
||||
],
|
||||
}
|
||||
_READ_NUDGE = json.dumps({
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PreToolUse",
|
||||
"additionalContext": (
|
||||
'MANDATORY: graphify-out/graph.json exists. You MUST run graphify '
|
||||
'before reading source files. Use: `graphify query "<question>"` '
|
||||
'(scoped subgraph), `graphify explain "<concept>"`, or '
|
||||
'`graphify path "<A>" "<B>"`. Only read raw files after graphify has '
|
||||
'oriented you, or to modify/debug specific lines. This rule applies to '
|
||||
'subagents too — include it in every subagent prompt involving code '
|
||||
'exploration.'
|
||||
),
|
||||
}
|
||||
}, ensure_ascii=False, separators=(",", ":")) + "\n"
|
||||
|
||||
# Source/doc extensions the Read|Glob guard nudges on (verbatim from the old hook).
|
||||
# The trailing-extension test (real final path segment, then its last '.') means
|
||||
# '.json' never false-matches '.js', and framework files like '.astro' are kept.
|
||||
_HOOK_SOURCE_EXTS = (
|
||||
'.py', '.js', '.ts', '.tsx', '.jsx', '.astro', '.vue', '.svelte', '.go',
|
||||
'.rs', '.java', '.rb', '.c', '.h', '.cpp', '.hpp', '.cc', '.cs', '.kt',
|
||||
'.swift', '.php', '.scala', '.lua', '.sh', '.md', '.rst', '.txt', '.mdx',
|
||||
)
|
||||
|
||||
|
||||
def _claude_pretooluse_hooks() -> "list[dict]":
|
||||
"""graphify's Claude/Codebuddy PreToolUse hooks, resolved at install time.
|
||||
|
||||
The command invokes `graphify hook-guard <search|read>` via the absolute exe
|
||||
path (`_resolve_graphify_exe`), so it parses under sh, cmd.exe and PowerShell
|
||||
alike — this is the #522 fix, and mirrors the codex hook. Matchers stay "Bash"
|
||||
and "Read|Glob" and the command always contains "graphify", so the existing
|
||||
install/uninstall filters find and replace both old bash hooks and these.
|
||||
"""
|
||||
exe = _resolve_graphify_exe()
|
||||
if " " in exe and not exe.startswith('"'):
|
||||
exe = f'"{exe}"'
|
||||
return [
|
||||
{"matcher": "Bash",
|
||||
"hooks": [{"type": "command", "command": f"{exe} hook-guard search"}]},
|
||||
{"matcher": "Read|Glob",
|
||||
"hooks": [{"type": "command", "command": f"{exe} hook-guard read"}]},
|
||||
]
|
||||
|
||||
def _skill_registration(skill_path: str = "~/.claude/skills/graphify/SKILL.md") -> str:
|
||||
return (
|
||||
@@ -1618,6 +1626,51 @@ def _resolve_graphify_exe() -> str:
|
||||
return "graphify"
|
||||
|
||||
|
||||
def _run_hook_guard(kind: str) -> None:
|
||||
"""Shell-agnostic PreToolUse guard (#522).
|
||||
|
||||
Reads the tool-call JSON from stdin and, when a knowledge graph exists in the
|
||||
current output dir, prints a nudge (`additionalContext`) telling the agent to
|
||||
use graphify instead of grepping/reading raw files. Replaces the old inline
|
||||
bash hooks that failed to parse on Windows. Always fails open: any error, or a
|
||||
non-matching tool call, prints nothing and the caller exits 0, so a legitimate
|
||||
tool call is never blocked. Detection mirrors the previous hooks exactly.
|
||||
"""
|
||||
from graphify.paths import out_path, GRAPHIFY_OUT_NAME
|
||||
try:
|
||||
d = json.loads(sys.stdin.buffer.read().decode("utf-8", "replace"))
|
||||
except Exception:
|
||||
return
|
||||
if not isinstance(d, dict):
|
||||
return
|
||||
t = d.get("tool_input", d)
|
||||
if not isinstance(t, dict):
|
||||
return
|
||||
try:
|
||||
if kind == "search":
|
||||
cmd_str = str(t.get("command", "") or "")
|
||||
# Same set the old `case` matched: *grep*, *ripgrep*, and rg/find/fd/
|
||||
# ack/ag as a token (name followed by a space).
|
||||
if any(tok in cmd_str for tok in ("grep", "ripgrep", "rg ", "find ", "fd ", "ack ", "ag ")) \
|
||||
and out_path("graph.json").is_file():
|
||||
sys.stdout.write(_SEARCH_NUDGE)
|
||||
elif kind == "read":
|
||||
vals = [str(t.get("file_path") or ""), str(t.get("pattern") or ""), str(t.get("path") or "")]
|
||||
j = " ".join(vals).lower().replace("\\", "/")
|
||||
tails = [
|
||||
"." + seg.rsplit(".", 1)[-1]
|
||||
for v in vals if v
|
||||
for seg in [v.lower().replace("\\", "/").rsplit("/", 1)[-1]]
|
||||
if "." in seg
|
||||
]
|
||||
under_out = "graphify-out/" in j or (GRAPHIFY_OUT_NAME.lower() + "/") in j
|
||||
if not under_out and any(tl in _HOOK_SOURCE_EXTS for tl in tails) \
|
||||
and out_path("graph.json").is_file():
|
||||
sys.stdout.write(_READ_NUDGE)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _install_codex_hook(project_dir: Path) -> None:
|
||||
"""Add graphify PreToolUse hook to .codex/hooks.json."""
|
||||
hooks_path = project_dir / ".codex" / "hooks.json"
|
||||
@@ -1975,8 +2028,7 @@ def _install_claude_hook(project_dir: Path) -> None:
|
||||
pre_tool = hooks.setdefault("PreToolUse", [])
|
||||
|
||||
hooks["PreToolUse"] = [h for h in pre_tool if not (h.get("matcher") in ("Glob|Grep", "Bash", "Read|Glob") and "graphify" in str(h))]
|
||||
hooks["PreToolUse"].append(_SETTINGS_HOOK)
|
||||
hooks["PreToolUse"].append(_READ_SETTINGS_HOOK)
|
||||
hooks["PreToolUse"].extend(_claude_pretooluse_hooks())
|
||||
settings_path.write_text(json.dumps(settings, indent=2), encoding="utf-8")
|
||||
print(f" .claude/settings.json -> PreToolUse hooks registered (Bash search + Read/Glob)")
|
||||
|
||||
@@ -2125,8 +2177,7 @@ def _install_codebuddy_hook(project_dir: Path) -> None:
|
||||
pre_tool = hooks.setdefault("PreToolUse", [])
|
||||
|
||||
hooks["PreToolUse"] = [h for h in pre_tool if not (h.get("matcher") in ("Glob|Grep", "Bash", "Read|Glob") and "graphify" in str(h))]
|
||||
hooks["PreToolUse"].append(_SETTINGS_HOOK)
|
||||
hooks["PreToolUse"].append(_READ_SETTINGS_HOOK)
|
||||
hooks["PreToolUse"].extend(_claude_pretooluse_hooks())
|
||||
settings_path.write_text(json.dumps(settings, indent=2), encoding="utf-8")
|
||||
print(f" .codebuddy/settings.json -> PreToolUse hooks registered")
|
||||
|
||||
@@ -2250,7 +2301,7 @@ def main() -> 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"}
|
||||
_silent_cmds = {"install", "uninstall", "hook-check", "hook-guard"}
|
||||
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
|
||||
@@ -3777,6 +3828,12 @@ def main() -> None:
|
||||
# Keep this as a cross-platform no-op so installed hooks never break Bash
|
||||
# tool calls. Graph guidance reaches the agent via AGENTS.md / skill instead.
|
||||
sys.exit(0)
|
||||
elif cmd == "hook-guard":
|
||||
# Shell-agnostic Claude/Codebuddy PreToolUse guard (#522). Replaces the old
|
||||
# inline-bash hooks that failed on Windows. Prints an additionalContext nudge
|
||||
# toward graphify when a graph exists; always exits 0 (never blocks a tool).
|
||||
_run_hook_guard(sys.argv[2] if len(sys.argv) > 2 else "")
|
||||
sys.exit(0)
|
||||
elif cmd == "check-update":
|
||||
if len(sys.argv) < 3:
|
||||
print("Usage: graphify check-update <path>", file=sys.stderr)
|
||||
|
||||
@@ -359,6 +359,41 @@ def test_codebuddy_install_writes_hook(tmp_path):
|
||||
assert any("graphify" in str(h) for h in hooks)
|
||||
|
||||
|
||||
def test_claude_hook_is_shell_agnostic(tmp_path):
|
||||
# #522: the installed PreToolUse hooks must be plain exe invocations, not
|
||||
# POSIX bash (which fails on Windows cmd.exe/PowerShell).
|
||||
import json as _json
|
||||
from graphify.__main__ import _install_claude_hook
|
||||
_install_claude_hook(tmp_path)
|
||||
hooks = _json.loads((tmp_path / ".claude" / "settings.json").read_text())["hooks"]["PreToolUse"]
|
||||
matchers = {h["matcher"] for h in hooks}
|
||||
assert {"Bash", "Read|Glob"} <= matchers
|
||||
for h in hooks:
|
||||
cmd = h["hooks"][0]["command"]
|
||||
for token in ("$(", "case ", "[ -f", "&&", "||", ";;", "echo '"):
|
||||
assert token not in cmd, f"shell syntax {token!r} in {cmd!r}"
|
||||
assert "graphify" in cmd and "hook-guard" in cmd
|
||||
|
||||
|
||||
def test_claude_hook_install_idempotent_and_replaces_old_bash_hook(tmp_path):
|
||||
import json as _json
|
||||
from graphify.__main__ import _install_claude_hook
|
||||
settings_path = tmp_path / ".claude" / "settings.json"
|
||||
settings_path.parent.mkdir(parents=True)
|
||||
# Pre-seed a legacy bash-style graphify hook (the thing #522 shipped before).
|
||||
settings_path.write_text(_json.dumps({"hooks": {"PreToolUse": [
|
||||
{"matcher": "Bash", "hooks": [{"type": "command",
|
||||
"command": "[ -f graphify-out/graph.json ] && echo '{...}' || true"}]},
|
||||
]}}), encoding="utf-8")
|
||||
_install_claude_hook(tmp_path)
|
||||
_install_claude_hook(tmp_path) # second install must not duplicate
|
||||
hooks = _json.loads(settings_path.read_text())["hooks"]["PreToolUse"]
|
||||
graphify_hooks = [h for h in hooks if "graphify" in str(h)]
|
||||
assert len(graphify_hooks) == 2, "exactly the Bash + Read|Glob guards, no dupes"
|
||||
# the legacy bash payload must be gone
|
||||
assert not any("[ -f graphify-out" in h["hooks"][0]["command"] for h in graphify_hooks)
|
||||
|
||||
|
||||
def test_codebuddy_install_idempotent(tmp_path):
|
||||
from graphify.__main__ import codebuddy_install
|
||||
codebuddy_install(tmp_path)
|
||||
|
||||
@@ -12,8 +12,8 @@ from __future__ import annotations
|
||||
import json
|
||||
|
||||
from graphify.__main__ import (
|
||||
_SETTINGS_HOOK,
|
||||
_READ_SETTINGS_HOOK,
|
||||
_SEARCH_NUDGE,
|
||||
_READ_NUDGE,
|
||||
_skill_registration,
|
||||
_CLAUDE_MD_SECTION,
|
||||
_AGENTS_MD_SECTION,
|
||||
@@ -32,8 +32,8 @@ from graphify.__main__ import (
|
||||
# Hook constants are dicts/JSON; serialize them so we can do substring checks
|
||||
# against the actual payload text the assistant will receive.
|
||||
_INSTALL_TEXTS: dict[str, str] = {
|
||||
"_SETTINGS_HOOK": json.dumps(_SETTINGS_HOOK),
|
||||
"_READ_SETTINGS_HOOK": json.dumps(_READ_SETTINGS_HOOK),
|
||||
"_SEARCH_NUDGE": _SEARCH_NUDGE,
|
||||
"_READ_NUDGE": _READ_NUDGE,
|
||||
"_CLAUDE_MD_SECTION": _CLAUDE_MD_SECTION,
|
||||
"_AGENTS_MD_SECTION": _AGENTS_MD_SECTION,
|
||||
"_GEMINI_MD_SECTION": _GEMINI_MD_SECTION,
|
||||
|
||||
@@ -145,9 +145,14 @@ def test_claude_install_upgrades_stale_hook_payload(tmp_path, monkeypatch):
|
||||
assert _OLD_HOOK_PAYLOAD_SNIPPET not in new_settings_text, (
|
||||
"stale hook payload survived upgrade"
|
||||
)
|
||||
assert "graphify query" in new_settings_text, (
|
||||
"new hook payload should route to `graphify query`"
|
||||
# Since #522 the nudge text lives in the `graphify hook-guard` subcommand, not
|
||||
# inline in settings.json (so the command parses on Windows). The upgraded hook
|
||||
# must therefore route to that shell-agnostic subcommand, and the old bash
|
||||
# pipeline must be gone.
|
||||
assert "hook-guard" in new_settings_text, (
|
||||
"new hook payload should route to the `graphify hook-guard` subcommand"
|
||||
)
|
||||
assert "case x in" not in new_settings_text, "stale bash pipeline survived upgrade"
|
||||
|
||||
|
||||
def test_agents_install_upgrades_stale_section(tmp_path, monkeypatch):
|
||||
|
||||
+35
-11
@@ -1,17 +1,31 @@
|
||||
"""The Read|Glob PreToolUse hook nudges toward the graph instead of raw reads.
|
||||
"""The Read|Glob PreToolUse guard nudges toward the graph instead of raw reads.
|
||||
|
||||
Closes the issue #1114 gap: the Bash search hook never sees a file read through
|
||||
the native Read tool or a Glob. These tests run the hook command the way Claude
|
||||
Code does - via `sh -c` with crafted stdin JSON - and assert it nudges only for
|
||||
a source/doc file outside graphify-out/ when a graph exists, and otherwise stays
|
||||
silent and fails open.
|
||||
the native Read tool or a Glob. Since #522 the guard runs as the shell-agnostic
|
||||
`graphify hook-guard read` subcommand (not inline bash), so it works on Windows
|
||||
too. These tests invoke that subcommand with crafted stdin JSON and assert it
|
||||
nudges only for a source/doc file outside graphify-out/ when a graph exists, and
|
||||
otherwise stays silent and fails open.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from graphify.__main__ import _READ_SETTINGS_HOOK
|
||||
from graphify.__main__ import _claude_pretooluse_hooks
|
||||
|
||||
CMD = _READ_SETTINGS_HOOK["hooks"][0]["command"]
|
||||
|
||||
def _read_matcher():
|
||||
hooks = _claude_pretooluse_hooks()
|
||||
return next(h for h in hooks if h["matcher"] == "Read|Glob")
|
||||
|
||||
|
||||
def _env():
|
||||
# The guard resolves the graph via GRAPHIFY_OUT (default "graphify-out",
|
||||
# relative to cwd). Drop any inherited override so the tmp_path graph is found.
|
||||
e = dict(os.environ)
|
||||
e.pop("GRAPHIFY_OUT", None)
|
||||
return e
|
||||
|
||||
|
||||
def _run(tool_input, cwd, *, graph: bool):
|
||||
@@ -20,12 +34,21 @@ def _run(tool_input, cwd, *, graph: bool):
|
||||
(cwd / "graphify-out" / "graph.json").write_text("{}", encoding="utf-8")
|
||||
stdin = json.dumps({"tool_input": tool_input})
|
||||
return subprocess.run(
|
||||
["sh", "-c", CMD], input=stdin, capture_output=True, text=True, cwd=cwd
|
||||
[sys.executable, "-m", "graphify", "hook-guard", "read"],
|
||||
input=stdin, capture_output=True, text=True, cwd=cwd, env=_env(),
|
||||
)
|
||||
|
||||
|
||||
def test_matcher_targets_read_and_glob():
|
||||
assert _READ_SETTINGS_HOOK["matcher"] == "Read|Glob"
|
||||
assert _read_matcher()["matcher"] == "Read|Glob"
|
||||
|
||||
|
||||
def test_command_has_no_shell_syntax():
|
||||
# #522: the command must be a plain exe invocation, not POSIX bash.
|
||||
cmd = _read_matcher()["hooks"][0]["command"]
|
||||
for token in ("$(", "case ", "[ -f", "&&", "||", ";;", "echo '"):
|
||||
assert token not in cmd, f"shell syntax {token!r} leaked into the hook"
|
||||
assert "graphify" in cmd and "hook-guard read" in cmd
|
||||
|
||||
|
||||
def test_silent_without_graph(tmp_path):
|
||||
@@ -106,14 +129,15 @@ def test_fails_open_on_malformed_stdin(tmp_path):
|
||||
(tmp_path / "graphify-out").mkdir()
|
||||
(tmp_path / "graphify-out" / "graph.json").write_text("{}", encoding="utf-8")
|
||||
r = subprocess.run(
|
||||
["sh", "-c", CMD], input="this is not json", capture_output=True, text=True, cwd=tmp_path
|
||||
[sys.executable, "-m", "graphify", "hook-guard", "read"],
|
||||
input="this is not json", capture_output=True, text=True, cwd=tmp_path, env=_env(),
|
||||
)
|
||||
assert r.returncode == 0
|
||||
assert r.stdout.strip() == ""
|
||||
|
||||
|
||||
def test_never_blocks(tmp_path):
|
||||
"""A nudge is additionalContext only - the hook must exit 0, never deny."""
|
||||
"""A nudge is additionalContext only - the guard must exit 0, never deny."""
|
||||
r = _run({"file_path": "src/app.py"}, tmp_path, graph=True)
|
||||
assert r.returncode == 0
|
||||
assert '"permissionDecision"' not in r.stdout
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"""The Bash PreToolUse guard nudges toward the graph before grep/find searches.
|
||||
|
||||
Since #522 it runs as the shell-agnostic `graphify hook-guard search` subcommand
|
||||
(not inline bash), so it works on Windows too. These tests invoke the subcommand
|
||||
with crafted stdin JSON and assert it nudges only for a search command when a
|
||||
graph exists, and otherwise stays silent and fails open.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from graphify.__main__ import _claude_pretooluse_hooks
|
||||
|
||||
|
||||
def _search_matcher():
|
||||
hooks = _claude_pretooluse_hooks()
|
||||
return next(h for h in hooks if h["matcher"] == "Bash")
|
||||
|
||||
|
||||
def _env():
|
||||
e = dict(os.environ)
|
||||
e.pop("GRAPHIFY_OUT", None)
|
||||
return e
|
||||
|
||||
|
||||
def _run(command, cwd, *, graph: bool):
|
||||
if graph:
|
||||
(cwd / "graphify-out").mkdir(parents=True, exist_ok=True)
|
||||
(cwd / "graphify-out" / "graph.json").write_text("{}", encoding="utf-8")
|
||||
stdin = json.dumps({"tool_input": {"command": command}})
|
||||
return subprocess.run(
|
||||
[sys.executable, "-m", "graphify", "hook-guard", "search"],
|
||||
input=stdin, capture_output=True, text=True, cwd=cwd, env=_env(),
|
||||
)
|
||||
|
||||
|
||||
def test_matcher_targets_bash():
|
||||
assert _search_matcher()["matcher"] == "Bash"
|
||||
|
||||
|
||||
def test_command_has_no_shell_syntax():
|
||||
# #522: no POSIX bash that Windows cmd.exe/PowerShell can't parse.
|
||||
cmd = _search_matcher()["hooks"][0]["command"]
|
||||
for token in ("$(", "case ", "[ -f", "&&", "||", ";;", "echo '"):
|
||||
assert token not in cmd, f"shell syntax {token!r} leaked into the hook"
|
||||
assert "graphify" in cmd and "hook-guard search" in cmd
|
||||
|
||||
|
||||
def test_nudges_on_search_commands_with_graph(tmp_path):
|
||||
for command in (
|
||||
"grep -rn foo .",
|
||||
"rg pattern src/",
|
||||
"ripgrep thing",
|
||||
"find . -name '*.py'",
|
||||
"fd bar",
|
||||
"ack needle",
|
||||
"ag needle",
|
||||
):
|
||||
out = _run(command, tmp_path, graph=True).stdout
|
||||
assert "graphify query" in out, f"{command!r} should nudge"
|
||||
|
||||
|
||||
def test_silent_without_graph(tmp_path):
|
||||
out = _run("grep -rn foo .", tmp_path, graph=False).stdout
|
||||
assert out.strip() == ""
|
||||
|
||||
|
||||
def test_silent_on_non_search_commands(tmp_path):
|
||||
for command in ("ls -la", "git status", "cat README.md", "python app.py"):
|
||||
out = _run(command, tmp_path, graph=True).stdout
|
||||
assert out.strip() == "", f"{command!r} should not nudge"
|
||||
|
||||
|
||||
def test_nudge_payload_is_valid_pretooluse_json(tmp_path):
|
||||
out = _run("grep -rn foo .", tmp_path, graph=True).stdout
|
||||
payload = json.loads(out)
|
||||
assert payload["hookSpecificOutput"]["hookEventName"] == "PreToolUse"
|
||||
assert "graphify query" in payload["hookSpecificOutput"]["additionalContext"]
|
||||
|
||||
|
||||
def test_fails_open_on_malformed_stdin(tmp_path):
|
||||
(tmp_path / "graphify-out").mkdir()
|
||||
(tmp_path / "graphify-out" / "graph.json").write_text("{}", encoding="utf-8")
|
||||
r = subprocess.run(
|
||||
[sys.executable, "-m", "graphify", "hook-guard", "search"],
|
||||
input="not json", capture_output=True, text=True, cwd=tmp_path, env=_env(),
|
||||
)
|
||||
assert r.returncode == 0
|
||||
assert r.stdout.strip() == ""
|
||||
|
||||
|
||||
def test_never_blocks(tmp_path):
|
||||
r = _run("grep -rn foo .", tmp_path, graph=True)
|
||||
assert r.returncode == 0
|
||||
assert '"permissionDecision"' not in r.stdout
|
||||
assert '"deny"' not in r.stdout
|
||||
|
||||
|
||||
def test_honors_graphify_out_override(tmp_path):
|
||||
"""The guard resolves the graph via GRAPHIFY_OUT, not a hardcoded path."""
|
||||
custom = tmp_path / "custom-out"
|
||||
custom.mkdir()
|
||||
(custom / "graph.json").write_text("{}", encoding="utf-8")
|
||||
env = dict(os.environ, GRAPHIFY_OUT=str(custom))
|
||||
stdin = json.dumps({"tool_input": {"command": "grep -rn foo ."}})
|
||||
r = subprocess.run(
|
||||
[sys.executable, "-m", "graphify", "hook-guard", "search"],
|
||||
input=stdin, capture_output=True, text=True, cwd=tmp_path, env=env,
|
||||
)
|
||||
assert "graphify query" in r.stdout
|
||||
Reference in New Issue
Block a user