Ctrl K
ComunidadGuías…

tests/test_hook_bash_read.py

Rama v8 · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""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))