Files
graphify/tests/test_hook_strict.py
T
shazebandsafishamsi 0224bcaea4 fix(install): fire the search nudge on Claude Code's Grep tool, not just Bash (#1986)
ee1df22 narrowed the Claude Code search-guard matcher from "Glob|Grep" to
"Bash" on the premise that dedicated search tools were removed and searches
go through Bash. Current Claude Code routes content search through its
first-class Grep tool (its Bash tool description actively steers away from
shell grep), so the graphify-first nudge never fired on the agent's primary
exploration path and the graph was silently bypassed.

Three-part fix, per the issue's analysis:

- Matcher: "Bash" -> "Bash|Grep" in _claude_pretooluse_hooks. Glob already
  fires the read nudge via "Read|Glob", so Grep was the only orphaned tool.
- Guard body: the hook-guard search branch only inspected tool_input.command,
  which a Grep call doesn't carry (it has pattern/path/glob). A Grep-shaped
  input (pattern present, no command) is now treated as a search — it IS one
  by definition — and nudges whenever a fresh graph exists. The Bash
  token-matching path is unchanged, and a command-carrying input never
  triggers the Grep shape, so non-search Bash calls stay silent.
- Idempotency: "Bash|Grep" added to the four install/uninstall dedup filters
  (claude + codebuddy), so upgrading replaces the stale "Bash" hook in place
  instead of appending a duplicate — verified against a pre-fix settings.json.

Tests: new regression tests feed Grep-shaped tool_input through
hook-guard search and assert the nudge (with graph), silence (without),
valid PreToolUse JSON, and no blocking; plus a guard that a non-search Bash
command with a stray pattern key does not nudge. Existing matcher assertions
updated across test_search_hook/test_install/test_claude_md/test_codebuddy/
test_hook_strict. Hook+install suites: 397 passed. Full suite: 3224 passed;
the 13 failures are pre-existing on clean v8 in this environment.

Fixes #1986
2026-07-18 19:07:16 +01:00

203 lines
8.0 KiB
Python

"""Strict-mode hook-guard: opt-in block-then-nudge + #1840 gating.
The strict guard (Claude Code Read only) denies the FIRST raw read of an indexed,
in-project, fresh source file per session, then downgrades to the soft nudge — so
it can never strand an agent. #1840: out-of-project reads are ignored and a graph
that is stale for the target softens to a non-mandatory nudge. Everything defaults
to the historical soft nudge unless strict is explicitly enabled.
"""
import io
import json
import os
import sys
import time
import pytest
import graphify.cli as cli
def _fixture(tmp_path, *, indexed=True, fresh=True):
"""A project with graphify-out/graph.json + manifest and one source file.
``fresh`` makes the graph newer than the source (not stale)."""
src = tmp_path / "src"
src.mkdir()
f = src / "mod.py"
f.write_text("def x():\n return 1\n", encoding="utf-8")
out = tmp_path / "graphify-out"
out.mkdir()
(out / "manifest.json").write_text(
json.dumps({"src/mod.py": {"mtime": 1}} if indexed else {"other/z.py": {"mtime": 1}}),
encoding="utf-8",
)
time.sleep(0.02)
(out / "graph.json").write_text('{"nodes":[],"links":[]}', encoding="utf-8")
if not fresh:
time.sleep(0.02)
f.write_text("def x():\n return 2\n", encoding="utf-8") # source now newer -> stale
return f
def _invoke(kind, payload, tmp_path, monkeypatch, *, strict=False, env=None):
monkeypatch.chdir(tmp_path)
for k, v in (env or {}).items():
monkeypatch.setenv(k, v)
data = json.dumps(payload).encode() if not isinstance(payload, (bytes, bytearray)) else bytes(payload)
class _Stdin:
buffer = io.BytesIO(data)
monkeypatch.setattr(sys, "stdin", _Stdin())
buf = io.StringIO()
monkeypatch.setattr(sys, "stdout", buf)
cli._run_hook_guard(kind, strict=strict)
return buf.getvalue()
def _read(fpath, sid="s1"):
return {"session_id": sid, "tool_name": "Read", "tool_input": {"file_path": str(fpath)}}
def _is_deny(out):
return out.strip() != "" and json.loads(out).get("hookSpecificOutput", {}).get("permissionDecision") == "deny"
def test_strict_first_read_denies_then_nudges(tmp_path, monkeypatch):
f = _fixture(tmp_path)
out1 = _invoke("read", _read(f), tmp_path, monkeypatch, strict=True)
assert _is_deny(out1)
assert "graphify query" in json.loads(out1)["hookSpecificOutput"]["permissionDecisionReason"]
# marker created
assert (tmp_path / "graphify-out" / "cache" / "hook_sessions" / "s1.denied").exists()
# same session again -> soft nudge, not a second deny
out2 = _invoke("read", _read(f), tmp_path, monkeypatch, strict=True)
assert not _is_deny(out2) and "MANDATORY" in out2
def test_strict_new_session_denies_again(tmp_path, monkeypatch):
f = _fixture(tmp_path)
_invoke("read", _read(f, "sA"), tmp_path, monkeypatch, strict=True)
out = _invoke("read", _read(f, "sB"), tmp_path, monkeypatch, strict=True)
assert _is_deny(out)
def test_fresh_query_stamp_suppresses_deny(tmp_path, monkeypatch):
f = _fixture(tmp_path)
stamp = tmp_path / "graphify-out" / "cache" / "last_query_stamp"
stamp.parent.mkdir(parents=True, exist_ok=True)
stamp.write_text(str(time.time()), encoding="utf-8")
out = _invoke("read", _read(f), tmp_path, monkeypatch, strict=True)
assert not _is_deny(out) and "MANDATORY" in out
def test_expired_query_stamp_still_denies(tmp_path, monkeypatch):
f = _fixture(tmp_path)
stamp = tmp_path / "graphify-out" / "cache" / "last_query_stamp"
stamp.parent.mkdir(parents=True, exist_ok=True)
stamp.write_text("old", encoding="utf-8")
old = time.time() - 10_000
os.utime(stamp, (old, old))
out = _invoke("read", _read(f), tmp_path, monkeypatch, strict=True, env={"GRAPHIFY_HOOK_STRICT_TTL": "1800"})
assert _is_deny(out)
def test_soft_mode_never_denies(tmp_path, monkeypatch):
f = _fixture(tmp_path)
out = _invoke("read", _read(f), tmp_path, monkeypatch, strict=False)
assert not _is_deny(out) and "MANDATORY" in out
def test_env_forces_strict_on(tmp_path, monkeypatch):
f = _fixture(tmp_path)
out = _invoke("read", _read(f), tmp_path, monkeypatch, strict=False, env={"GRAPHIFY_HOOK_STRICT": "1"})
assert _is_deny(out)
def test_env_kills_strict(tmp_path, monkeypatch):
f = _fixture(tmp_path)
out = _invoke("read", _read(f), tmp_path, monkeypatch, strict=True, env={"GRAPHIFY_HOOK_STRICT": "0"})
assert not _is_deny(out)
def test_out_of_project_read_silenced(tmp_path, monkeypatch):
_fixture(tmp_path)
payload = {"session_id": "s1", "tool_name": "Read", "tool_input": {"file_path": "/somewhere/else/x.py"}}
assert _invoke("read", payload, tmp_path, monkeypatch, strict=True).strip() == ""
# soft mode too
assert _invoke("read", payload, tmp_path, monkeypatch, strict=False).strip() == ""
def test_stale_graph_softens_never_denies(tmp_path, monkeypatch):
f = _fixture(tmp_path, fresh=False) # source newer than graph
out = _invoke("read", _read(f), tmp_path, monkeypatch, strict=True)
assert not _is_deny(out)
assert "stale" in out.lower() and "MANDATORY" not in out
def test_needs_update_flag_softens(tmp_path, monkeypatch):
f = _fixture(tmp_path)
(tmp_path / "graphify-out" / "needs_update").write_text("1", encoding="utf-8")
out = _invoke("read", _read(f), tmp_path, monkeypatch, strict=True)
assert not _is_deny(out) and "stale" in out.lower()
def test_glob_never_denies(tmp_path, monkeypatch):
_fixture(tmp_path)
payload = {"session_id": "s1", "tool_name": "Glob",
"tool_input": {"pattern": "**/*.py", "path": str(tmp_path)}}
assert not _is_deny(_invoke("read", payload, tmp_path, monkeypatch, strict=True))
def test_search_never_denies(tmp_path, monkeypatch):
_fixture(tmp_path)
out = _invoke("search", {"session_id": "s1", "tool_input": {"command": "grep -rn foo ."}},
tmp_path, monkeypatch, strict=True)
assert not _is_deny(out) # search stays a nudge even in strict mode
def test_no_session_id_never_denies(tmp_path, monkeypatch):
f = _fixture(tmp_path)
payload = {"tool_name": "Read", "tool_input": {"file_path": str(f)}} # no session_id
assert not _is_deny(_invoke("read", payload, tmp_path, monkeypatch, strict=True))
def test_not_indexed_file_not_denied(tmp_path, monkeypatch):
f = _fixture(tmp_path, indexed=False) # manifest doesn't list src/mod.py
out = _invoke("read", _read(f), tmp_path, monkeypatch, strict=True)
assert not _is_deny(out)
def test_fail_open_on_malformed_stdin(tmp_path, monkeypatch):
_fixture(tmp_path)
assert _invoke("read", b"{not json", tmp_path, monkeypatch, strict=True) == ""
def test_strict_enabled_env_precedence():
import os as _os
saved = _os.environ.get("GRAPHIFY_HOOK_STRICT")
try:
_os.environ["GRAPHIFY_HOOK_STRICT"] = "1"
assert cli._hook_strict_enabled(False) is True
_os.environ["GRAPHIFY_HOOK_STRICT"] = "0"
assert cli._hook_strict_enabled(True) is False
_os.environ.pop("GRAPHIFY_HOOK_STRICT", None)
assert cli._hook_strict_enabled(True) is True
assert cli._hook_strict_enabled(False) is False
finally:
if saved is None:
_os.environ.pop("GRAPHIFY_HOOK_STRICT", None)
else:
_os.environ["GRAPHIFY_HOOK_STRICT"] = saved
def test_install_hook_carries_strict_flag():
from graphify.install import _claude_pretooluse_hooks
soft = _claude_pretooluse_hooks(strict=False)
strict = _claude_pretooluse_hooks(strict=True)
read_soft = next(h for h in soft if h["matcher"] == "Read|Glob")["hooks"][0]["command"]
read_strict = next(h for h in strict if h["matcher"] == "Read|Glob")["hooks"][0]["command"]
assert read_soft.endswith("hook-guard read")
assert read_strict.endswith("hook-guard read --strict")
# search hook is unchanged either way
for hooks in (soft, strict):
assert next(h for h in hooks if h["matcher"] == "Bash|Grep")["hooks"][0]["command"].endswith("hook-guard search")