tests/test_hook_bash_read.py
Rama v8 · réplica de lectura del repositorio original.
"""Reading code with cat/sed/head/tail/... in Bash gets the Read nudge.
The Read|Glob matcher gives the read nudge, but Bash goes to `hook-guard
search`, which only reacted to search commands (_bash_invokes_search, #3121).
`cat src/app.py`, `head -50 src/app.py` or `nl src/app.py` read indexed code
with no nudge at all, and `sed -n 1,80p src/app.py` got the generic search
nudge without the file's symbols. Now a Bash command that prints an in-project
code file gets the same Read nudge (with the file hint) through the same
per-agent budget, and is never denied, strict mode included.
Driven the way Claude Code drives it: `graphify hook-guard search` in a
subprocess with the PreToolUse JSON on stdin.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import pytest
SID = "3e1f0c2a-7b6d-4e5f-9a8b-1c2d3e4f5a6b"
GRAPH = {
"nodes": [
{"id": "app", "label": "app.py", "source_file": "src/app.py", "source_location": "L1"},
{"id": "app_run", "label": "run()", "source_file": "src/app.py", "source_location": "L3"},
],
"links": [],
}
def _project(tmp_path):
proj = tmp_path / "proj"
(proj / "src").mkdir(parents=True)
(proj / "src" / "app.py").write_text("import os\n\ndef run():\n return os.getcwd()\n", encoding="utf-8")
(proj / "README.md").write_text("# demo\n", encoding="utf-8")
(proj / "package.json").write_text("{}\n", encoding="utf-8")
(proj / "logo.png").write_bytes(b"\x89PNG")
out = proj / "graphify-out"
out.mkdir()
(out / "GRAPH_REPORT.md").write_text("# report\n", encoding="utf-8")
(out / "graph.json").write_text(json.dumps(GRAPH), encoding="utf-8")
outside = tmp_path / "elsewhere"
outside.mkdir()
(outside / "other.py").write_text("x = 1\n", encoding="utf-8")
return proj
def _env(proj, **extra):
e = dict(os.environ)
for k in ("GRAPHIFY_OUT", "GRAPHIFY_HOOK_STRICT", "GRAPHIFY_HOOK_NUDGE_CAP", "GRAPHIFY_HOOK_STRICT_TTL"):
e.pop(k, None)
e["CLAUDE_PROJECT_DIR"] = str(proj)
e.update(extra)
return e
def _bash(command, proj, *, sid=SID, agent=None, strict=False, tool_name="Bash", env=None):
payload = {
"session_id": sid,
"transcript_path": f"/home/u/.claude/projects/p/{sid}.jsonl",
"cwd": str(proj),
"permission_mode": "auto",
"hook_event_name": "PreToolUse",
"tool_name": tool_name,
"tool_input": {"command": command, "description": "Read the file"},
"tool_use_id": "toolu_01",
}
if tool_name is None:
del payload["tool_name"]
if agent:
payload["agent_id"] = agent
args = [sys.executable, "-m", "graphify", "hook-guard", "search"] + (["--strict"] if strict else [])
r = subprocess.run(args, input=json.dumps(payload), capture_output=True, text=True,
cwd=proj, env=env or _env(proj))
assert r.returncode == 0, r.stderr
return r.stdout
def _read_tool(path, proj, *, sid=SID):
payload = {"session_id": sid, "hook_event_name": "PreToolUse", "tool_name": "Read",
"cwd": str(proj), "tool_input": {"file_path": str(path)}}
r = subprocess.run([sys.executable, "-m", "graphify", "hook-guard", "read"], input=json.dumps(payload),
capture_output=True, text=True, cwd=proj, env=_env(proj))
return r.stdout
def _is_read_nudge(out):
hso = json.loads(out)["hookSpecificOutput"]
ctx = hso.get("additionalContext", "")
return (hso["hookEventName"] == "PreToolUse" and "permissionDecision" not in hso
and "reading source files one by one" in ctx)
def _nudges(proj):
d = proj / "graphify-out" / "cache" / "hook_sessions"
return {p.name: p.read_text() for p in d.glob("*.nudges")} if d.is_dir() else {}
READERS = [
"cat src/app.py",
"cat -n src/app.py",
"sed -n 1,80p src/app.py",
"sed -n '/def run/,/^$/p' src/app.py",
"head -50 src/app.py",
"head -n 20 src/app.py",
"tail src/app.py",
"tail -n +3 src/app.py",
"nl -ba src/app.py",
"less src/app.py",
"more src/app.py",
"bat --paging=never src/app.py",
"cat < src/app.py",
"cat src/app.py | grep run",
"sudo cat ./src/app.py",
"git status && cat src/app.py",
"cat src/app.py 2>/dev/null",
"head -50 src/app.py 2>&1",
"cat {proj}/src/app.py",
]
@pytest.mark.parametrize("command", READERS)
def test_bash_reader_gets_the_read_nudge_with_the_file_hint(command, tmp_path):
proj = _project(tmp_path)
out = _bash(command.format(proj=proj), proj)
assert out.count("\n") == 1, "exactly one nudge"
assert _is_read_nudge(out), out
ctx = json.loads(out)["hookSpecificOutput"]["additionalContext"]
assert 'Start with: `graphify explain "app.py"`' in ctx
assert "- run() (src/app.py L3)" in ctx
assert _nudges(proj) == {f"{SID}.nudges": "1"}
SILENT = [
"cat README.md", # docs: orientation, not a code walk
"cat package.json", # not a graphed extension
"cat logo.png",
"cat src/missing.py", # no such file
"cat > src/new.py", # writes
"cat >> src/app.py",
"cat src/app.py > /tmp/copy.py",
"cat <<'EOF' > src/new.py\nimport os\nEOF",
"sed -i 's/a/b/' src/app.py", # edits in place
"sed -ni 's/a/b/p' src/app.py",
"echo x | cat", # stdin, no file
"git diff | head -50",
"cat -",
'echo "cat src/app.py"', # prose, not an executed reader
"# cat src/app.py",
"cat ../elsewhere/other.py", # outside the project (#1840)
"cat {tmp}/elsewhere/other.py",
"cat graphify-out/GRAPH_REPORT.md", # graph output (#4961)
"head graphify-out/graph.json",
"cat $HOME/src/app.py", # shell expansion: target unknown
"cat src/*.py",
"python src/app.py", # runs code, does not print it
]
@pytest.mark.parametrize("command", SILENT)
def test_non_reads_stay_silent(command, tmp_path):
proj = _project(tmp_path)
assert _bash(command.format(tmp=tmp_path), proj) == ""
assert _nudges(proj) == {}, "a silent call spends no budget"
# Control in the same project and session: a real read nudges.
assert _is_read_nudge(_bash("cat src/app.py", proj))
def test_bash_reads_share_the_read_budget(tmp_path):
proj = _project(tmp_path)
outs = [_bash("cat src/app.py", proj), _read_tool(proj / "src" / "app.py", proj),
_bash("head -50 src/app.py", proj), _bash("sed -n 1,9p src/app.py", proj),
_read_tool(proj / "src" / "app.py", proj)]
assert all(outs) and _is_read_nudge(outs[0])
assert _bash("tail src/app.py", proj) == "", "cap of 5 shared with the Read tool"
sub = _bash("cat src/app.py", proj, agent="agent-7")
assert _is_read_nudge(sub), "a subagent has its own budget (#3435)"
@pytest.mark.parametrize("tool_name", ["Bash", None])
def test_strict_mode_never_blocks_a_bash_read(tool_name, tmp_path):
proj = _project(tmp_path)
(proj / "graphify-out" / "manifest.json").write_text(json.dumps({"src/app.py": {"mtime": 1}}),
encoding="utf-8")
os.utime(proj / "src" / "app.py", (1, 1)) # older than the graph: fresh, indexed
for env in (None, _env(proj, GRAPHIFY_HOOK_STRICT="1")):
out = _bash("cat src/app.py", proj, sid=f"strict-{env is None}", strict=True,
tool_name=tool_name, env=env)
assert _is_read_nudge(out), out
assert not list((proj / "graphify-out" / "cache" / "hook_sessions").glob("*.denied"))
# The Read tool on the same file is still denied once.
out = _read_tool(proj / "src" / "app.py", proj)
assert out # soft mode here (no --strict): the nudge
r = subprocess.run([sys.executable, "-m", "graphify", "hook-guard", "read", "--strict"],
input=json.dumps({"session_id": "r", "tool_name": "Read",
"tool_input": {"file_path": str(proj / "src" / "app.py")}}),
capture_output=True, text=True, cwd=proj, env=_env(proj))
assert json.loads(r.stdout)["hookSpecificOutput"]["permissionDecision"] == "deny"
def test_relative_path_resolves_against_the_bash_cwd(tmp_path):
proj = _project(tmp_path)
payload = {"session_id": SID, "tool_name": "Bash", "cwd": str(proj / "src"),
"tool_input": {"command": "cat app.py"}}
r = subprocess.run([sys.executable, "-m", "graphify", "hook-guard", "search"], input=json.dumps(payload),
capture_output=True, text=True, cwd=proj, env=_env(proj))
assert _is_read_nudge(r.stdout)
assert "- run() (src/app.py L3)" in r.stdout
def test_stale_file_gets_the_stale_read_nudge(tmp_path):
proj = _project(tmp_path)
(proj / "graphify-out" / "needs_update").write_text("1", encoding="utf-8")
out = _bash("cat src/app.py", proj)
assert "may be STALE" in json.loads(out)["hookSpecificOutput"]["additionalContext"]
@pytest.mark.parametrize("command", [
"grep -rn run src",
"rg run src/app.py",
"sed 's/a/b/' src/app.py", # no -n: still counts as a search
"awk '/run/' src/app.py",
])
def test_searches_keep_the_search_nudge(command, tmp_path):
proj = _project(tmp_path)
out = _bash(command, proj)
ctx = json.loads(out)["hookSpecificOutput"]["additionalContext"]
assert "Finding where something lives is what the graph answers" in ctx
# Control: the same session's read gets the read nudge.
assert _is_read_nudge(_bash("cat src/app.py", proj))
def test_no_graph_no_nudge(tmp_path):
proj = _project(tmp_path)
(proj / "graphify-out" / "graph.json").unlink()
assert _bash("cat src/app.py", proj) == ""
(proj / "graphify-out" / "graph.json").write_text(json.dumps(GRAPH), encoding="utf-8")
assert _is_read_nudge(_bash("cat src/app.py", proj))