mirror of
https://github.com/safishamsi/graphify.git
synced 2026-07-22 23:31:39 +00:00
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
203 lines
8.0 KiB
Python
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")
|