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:
safishamsi
2026-07-06 16:38:42 +01:00
co-authored by Claude Opus 4.8
parent 5d0137388e
commit f7911fd1b3
7 changed files with 310 additions and 77 deletions
+1
View File
@@ -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
View File
@@ -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)
+35
View File
@@ -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)
+4 -4
View File
@@ -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,
+7 -2
View File
@@ -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
View File
@@ -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
+111
View File
@@ -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