From 689dd6ccfd92e9cb106bf446153041e912b22c0f Mon Sep 17 00:00:00 2001 From: safishamsi Date: Fri, 17 Jul 2026 17:10:41 +0100 Subject: [PATCH] feat(hook): opt-in strict PreToolUse guard + stop crying wolf (#1840) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agents routinely ignore the advisory "run graphify query first" nudge and read raw files anyway. `graphify install --project --strict` (or `graphify claude install --strict`) now installs a hook that BLOCKS the first raw source read of a session via permissionDecision:"deny" with a redirect to graphify query, then downgrades to the soft nudge — it fires at most once per session (atomic per-session marker) so it can never strand the agent, and a recent query/explain/path refreshes a stamp that suppresses it. Claude Code only; Bash-grep and Glob stay nudge-only; Gemini/Codex/OpenCode are unchanged. GRAPHIFY_HOOK_STRICT=1/0 toggles at runtime without a reinstall; default installs are byte-identical (soft nudge). Also fixes #1840 for the default soft nudge: the guard no longer fires for reads of out-of-project files, and softens to a non-mandatory nudge when the graph is stale for the target file. Gating is ~3 stat calls (no corpus walk) and fails open. Begins the 0.9.19 cycle. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 5 + graphify/cli.py | 229 +++++++++++++++++++++++++++++++++++--- graphify/install.py | 48 +++++--- pyproject.toml | 2 +- tests/test_hook_strict.py | 202 +++++++++++++++++++++++++++++++++ 5 files changed, 459 insertions(+), 27 deletions(-) create mode 100644 tests/test_hook_strict.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 87fe4b2..09040cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases) +## 0.9.19 (unreleased) + +- Feat: opt-in strict PreToolUse hook that actually makes agents use the graph. The installed Claude Code hook has always *nudged* the agent to run `graphify query` before reading raw files, but a nudge is advisory `additionalContext` the model routinely walks past mid-task. `graphify install --project --strict` (or `graphify claude install --strict`) now installs a hook that *blocks* the first raw source read of a session (`permissionDecision: "deny"`) with a redirect to `graphify query`, then downgrades to the soft nudge — so it fires at most once per session and can never strand the agent (the next read proceeds even if no query ran, or if `graphify query` itself failed). Running any `graphify query`/`explain`/`path` refreshes a short-lived "recently oriented" stamp that suppresses the block. Strict mode is Claude Code only (Bash-grep and Glob stay nudge-only; Gemini/Codex/OpenCode can't hard-block and are unchanged); `GRAPHIFY_HOOK_STRICT=1`/`0` toggles it at runtime without a reinstall. Default installs are unchanged (soft nudge). +- Fix: the PreToolUse hook stops crying wolf (#1840), which applies to the default soft nudge too. It no longer fires for reads of files **outside** the indexed project (a common false trigger, e.g. a `~/.claude/.../SKILL.md` read), and when the graph is **stale for the target file** (the file changed after the last build, or `graphify watch` flagged the tree) it softens to a non-mandatory nudge that suggests `graphify update` instead of demanding the query. Gating is ~3 `stat` calls — no corpus walk — so it stays fast on large monorepos, and fails open on any error. + ## 0.9.18 (2026-07-17) - Fix: an incomplete extraction no longer force-writes a partial graph over a complete one (#1951, thanks @TPAteeq). A crashed AST/semantic pass, a some-chunks-failed run, or a walk that couldn't fully enumerate the corpus (permission-denied subtree) produced a smaller graph that the `to_json(force=True)` path wrote anyway, bypassing the #479 shrink guard; the `--no-cluster` raw dump had no guard at all. Both paths now refuse to overwrite a larger existing graph when the run was incomplete (exit 1, nothing written) unless `--allow-partial` is passed, and a present-but-unparseable existing graph fails closed (a corrupt/mid-write file could be hiding a complete graph). `detect()`'s `walk_errors` now count as incomplete too. diff --git a/graphify/cli.py b/graphify/cli.py index 27b3fb2..8bca710 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -8,7 +8,9 @@ import of main to avoid a cli<->__main__ import cycle. from __future__ import annotations import json import os +import re import sys +import time from graphify.paths import GRAPHIFY_OUT as _GRAPHIFY_OUT from pathlib import Path @@ -37,6 +39,35 @@ _READ_NUDGE = json.dumps({ ), } }, ensure_ascii=False, separators=(",", ":")) + "\n" +_READ_NUDGE_STALE = json.dumps({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "additionalContext": ( + 'graphify-out/graph.json exists but may be STALE for this file (the file ' + 'changed after the last build). Prefer `graphify query ""` for ' + 'orientation, and run `graphify update` to refresh the graph. Reading the ' + 'file directly is fine.' + ), + } +}, ensure_ascii=False, separators=(",", ":")) + "\n" +# Strict-mode block (opt-in). Claude Code PreToolUse honors +# hookSpecificOutput.permissionDecision == "deny" and shows permissionDecisionReason +# to the model. Fires at most once per session (see _mark_session_denied) so it can +# never strand an agent: the very next read proceeds with the soft nudge. +_READ_DENY = json.dumps({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": ( + 'graphify strict mode: this project has a fresh knowledge graph that covers ' + 'this file. Run `graphify query ""` (or `graphify explain` / ' + '`graphify path`) FIRST to orient yourself, then re-issue this Read — it ' + 'will be allowed. This block fires at most once per session; reading raw ' + 'files to modify or debug specific lines is fine after one query. Apply the ' + 'same rule in any subagent prompt that explores code.' + ), + } +}, ensure_ascii=False, separators=(",", ":")) + "\n" _HOOK_SOURCE_EXTS = ( '.py', '.js', '.cjs', '.ts', '.tsx', '.jsx', '.astro', '.vue', '.svelte', '.go', '.rs', '.java', '.rb', '.c', '.h', '.cpp', '.hpp', '.cc', '.cs', '.kt', @@ -303,15 +334,90 @@ def _enforce_graph_size_cap_or_exit(gp: Path) -> None: except ValueError as exc: print(f"error: {exc}", file=sys.stderr) sys.exit(1) -def _run_hook_guard(kind: str) -> None: +def _hook_strict_enabled(flag: bool) -> bool: + """Resolve strict mode: GRAPHIFY_HOOK_STRICT env overrides the baked-in flag + (truthy forces on without a reinstall, falsy is the kill switch); unset defers + to the flag the installed hook command carried.""" + v = os.environ.get("GRAPHIFY_HOOK_STRICT", "").strip().lower() + if v in ("1", "true", "yes", "on"): + return True + if v in ("0", "false", "no", "off"): + return False + return flag + + +def _touch_query_stamp(graph_path: "Path") -> None: + """Record that graphify oriented the agent recently, next to the queried graph. + The strict guard suppresses its block while this stamp is fresh. Fail-silent.""" + try: + from graphify.paths import write_text_atomic + stamp = Path(graph_path).parent / "cache" / "last_query_stamp" + stamp.parent.mkdir(parents=True, exist_ok=True) + write_text_atomic(stamp, str(time.time())) + except Exception: + pass + + +def _query_stamp_fresh() -> bool: + """True if a query/explain/path ran within GRAPHIFY_HOOK_STRICT_TTL (default + 1800s) — recent orientation, so strict mode does not block this read.""" + from graphify.paths import out_path + try: + ttl = float(os.environ.get("GRAPHIFY_HOOK_STRICT_TTL", "1800")) + return (time.time() - out_path("cache", "last_query_stamp").stat().st_mtime) < ttl + except Exception: + return False + + +def _mark_session_denied(session_id: str) -> bool: + """Atomically claim a one-time strict block for this session. Returns True only + on the FIRST call for a given session id (O_EXCL create wins once); every later + call — or any error — returns False, so a session is blocked at most once and an + agent can never be stranded. Best-effort GC of markers older than 24h.""" + from graphify.paths import out_path + sid = re.sub(r"[^A-Za-z0-9_-]", "_", str(session_id))[:64] + if not sid: + return False + try: + d = out_path("cache", "hook_sessions") + d.mkdir(parents=True, exist_ok=True) + fd = os.open(str(d / f"{sid}.denied"), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644) + os.close(fd) + try: + cutoff = time.time() - 86400 + for entry in os.scandir(d): + try: + if entry.stat().st_mtime < cutoff: + os.unlink(entry.path) + except OSError: + pass + except OSError: + pass + return True + except FileExistsError: + return False + except Exception: + return False + + +def _run_hook_guard(kind: str, strict: bool = False) -> 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. + Reads the tool-call JSON from stdin and, when a fresh in-project knowledge graph + exists, nudges the agent to use graphify instead of grepping/reading raw files. + Replaces the old inline bash hooks that failed to parse on Windows. + + Fails open everywhere: any error, or a non-matching tool call, prints nothing + and the caller exits 0, so a legitimate tool call is never blocked by a bug. + + In strict mode (opt-in, Claude Code Read only) the FIRST raw read of indexed, + in-project, fresh code per session is DENIED with a redirect to `graphify query` + (permissionDecision), then downgrades to the soft nudge — it fires at most once + per session and can never strand the agent. Search (Bash) and Glob stay + nudge-only: a compound shell command has no single parseable target and blocking + file listing would strand navigation. #1840: reads of out-of-project files are + ignored, and a graph that is stale for the target file softens to a non-mandatory + nudge instead of blocking or demanding. """ from graphify.paths import out_path, GRAPHIFY_OUT_NAME # Gemini's BeforeTool hook takes no stdin and must ALWAYS return a decision so @@ -339,7 +445,8 @@ def _run_hook_guard(kind: str) -> None: 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). + # ack/ag as a token (name followed by a space). Nudge-only, even in + # strict mode — see the docstring. 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) @@ -353,11 +460,98 @@ def _run_hook_guard(kind: str) -> None: 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) + if under_out or not any(tl in _HOOK_SOURCE_EXTS for tl in tails): + return + # #1840 (a): skip files outside the graph's project. cwd (or + # CLAUDE_PROJECT_DIR, which Claude Code sets) is the project root, since + # the guard only triggers when graph.json exists relative to cwd. A path + # candidate that resolves outside that root is out-of-project. + root = Path(os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()) + try: + root = root.resolve() + except (OSError, RuntimeError): + pass + path_vals = [str(t.get("file_path") or ""), str(t.get("path") or "")] + explicit = [v for v in path_vals if v] + if explicit: + in_project = False + for v in explicit: + p = Path(v) + if not p.is_absolute(): + in_project = True # relative -> anchored at cwd == in project + break + try: + p.resolve().relative_to(root) + in_project = True + break + except (ValueError, OSError, RuntimeError): + continue + if not in_project: + return + # One stat for existence + mtime of the graph. + try: + gmtime = os.stat(str(out_path("graph.json"))).st_mtime + except OSError: + return + # #1840 (b): stale-for-target -> soften, never block. The target file + # changed after the last build, or watch flagged the tree. + stale = False + fp = str(t.get("file_path") or "") + if fp: + try: + stale = os.stat(fp).st_mtime > gmtime + except OSError: + stale = False + try: + if out_path("needs_update").exists(): + stale = True + except Exception: + pass + if stale: + sys.stdout.write(_READ_NUDGE_STALE) + return + # Strict block: Read tool only, first time per session, not recently + # oriented, and the file is demonstrably indexed. + tool_name = d.get("tool_name") + if _hook_strict_enabled(strict) and tool_name in (None, "Read") \ + and not _query_stamp_fresh() \ + and _target_is_indexed(fp, root) \ + and _mark_session_denied(str(d.get("session_id") or "")): + sys.stdout.write(_READ_DENY) + return + sys.stdout.write(_READ_NUDGE) except Exception: pass + + +def _target_is_indexed(file_path: str, root: "Path") -> bool: + """Guard the strict deny: only block a read of a file the graph actually indexes. + Reads manifest.json (cheap, capped); on any doubt (missing/corrupt/oversized + manifest, unresolvable path) returns True so the once-per-session deny still + applies — that block is self-limiting, so erring toward it is safe.""" + from graphify.paths import out_path + if not file_path: + return True + try: + mp = out_path("manifest.json") + st = mp.stat() + if st.st_size > 2_000_000: + return True + manifest = json.loads(mp.read_text(encoding="utf-8")) + if not isinstance(manifest, dict) or not manifest: + return True + p = Path(file_path) + rels = set() + try: + rels.add(p.resolve().relative_to(root).as_posix()) + except (ValueError, OSError, RuntimeError): + pass + rels.add(p.name) + keys = {str(k).replace("\\", "/") for k in manifest} + abskey = str(p).replace("\\", "/") + return abskey in keys or any(r and (r in keys or any(k.endswith("/" + r) or k == r for k in keys)) for r in rels) + except Exception: + return True def _clone_repo( url: str, branch: str | None = None, out_dir: Path | None = None ) -> Path: @@ -656,6 +850,7 @@ def dispatch_command(cmd: str) -> None: token_budget=budget, duration_ms=(_time.perf_counter() - _t0) * 1000, ) + _touch_query_stamp(gp) print(_result) elif cmd == "affected": if len(sys.argv) < 3: @@ -906,6 +1101,7 @@ def dispatch_command(cmd: str) -> None: corpus=str(gp), nodes_returned=hops, ) + _touch_query_stamp(gp) elif cmd == "explain": if len(sys.argv) < 3: @@ -995,6 +1191,7 @@ def dispatch_command(cmd: str) -> None: corpus=str(gp), nodes_returned=len(connections), ) + _touch_query_stamp(gp) elif cmd == "diagnose": subcmd = sys.argv[2] if len(sys.argv) > 2 else "" @@ -1509,8 +1706,14 @@ def dispatch_command(cmd: str) -> None: 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 "") + # toward graphify when a fresh in-project graph exists; always exits 0. In + # strict mode (opt-in, `hook-guard read --strict`) it blocks the first raw + # read per session via the JSON permissionDecision payload — never via exit + # code — and downgrades to the nudge thereafter. + _run_hook_guard( + sys.argv[2] if len(sys.argv) > 2 else "", + strict="--strict" in sys.argv[3:], + ) sys.exit(0) elif cmd == "check-update": if len(sys.argv) < 3: diff --git a/graphify/install.py b/graphify/install.py index b34290e..0ad0750 100644 --- a/graphify/install.py +++ b/graphify/install.py @@ -285,7 +285,7 @@ def _print_project_git_add_hint(paths: list[Path]) -> None: print() print("Project-scoped install. Add to version control:") print(f" git add {' '.join(unique)}") -def _claude_pretooluse_hooks() -> "list[dict]": +def _claude_pretooluse_hooks(strict: bool = False) -> "list[dict]": """graphify's Claude/Codebuddy PreToolUse hooks, resolved at install time. The command invokes `graphify hook-guard ` via the absolute exe @@ -293,15 +293,20 @@ def _claude_pretooluse_hooks() -> "list[dict]": 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. + + When ``strict`` is set, the read hook carries ``--strict`` so it blocks the + first raw read per session (Claude Code only). The ``GRAPHIFY_HOOK_STRICT`` env + var can force it on or off at runtime without a reinstall. """ exe = _resolve_graphify_exe() if " " in exe and not exe.startswith('"'): exe = f'"{exe}"' + read_cmd = f"{exe} hook-guard read" + (" --strict" if strict else "") return [ {"matcher": "Bash", "hooks": [{"type": "command", "command": f"{exe} hook-guard search"}]}, {"matcher": "Read|Glob", - "hooks": [{"type": "command", "command": f"{exe} hook-guard read"}]}, + "hooks": [{"type": "command", "command": read_cmd}]}, ] def _skill_registration(skill_path: str = "~/.claude/skills/graphify/SKILL.md") -> str: return ( @@ -622,8 +627,10 @@ def install(platform: str = "claude", *, project: bool = False, project_dir: Pat print() def _print_install_usage() -> None: platforms = ", ".join([*_PLATFORM_CONFIG, "gemini", "cursor"]) - print("Usage: graphify install [--project] [--platform P|P]") + print("Usage: graphify install [--project] [--strict] [--platform P|P]") print(f"Platforms: {platforms}") + print(" --strict block the first raw file read per session until one " + "`graphify query` runs (Claude Code project hook only; needs --project)") _CLAUDE_MD_MARKER = "## graphify" _CODEBUDDY_MD_MARKER = "## graphify" _AGENTS_MD_MARKER = "## graphify" @@ -1424,13 +1431,13 @@ def _agents_platform_uninstall(project_dir: Path | None = None) -> None: if removed: print("skill removed") _agents_uninstall(project_dir or Path("."), platform="agents") -def _project_install(platform_name: str, project_dir: Path | None = None) -> None: +def _project_install(platform_name: str, project_dir: Path | None = None, strict: bool = False) -> None: """Install platform skill/config files in the current project.""" project_dir = project_dir or Path(".") platform_name = _canonical_platform(platform_name) if platform_name in ("claude", "windows"): install(platform=platform_name, project=True, project_dir=project_dir) - claude_install(project_dir) + claude_install(project_dir, strict=strict) _print_project_git_add_hint([project_dir / ".claude", project_dir / "CLAUDE.md"]) elif platform_name == "gemini": gemini_install(project_dir, project=True) @@ -1586,7 +1593,7 @@ def _kilo_uninstall(project_dir: Path) -> None: _agents_uninstall(project_dir or Path("."), platform="kilo") removed = _kilo_uninstall_global() print("; ".join(removed) if removed else "nothing to remove") -def claude_install(project_dir: Path | None = None) -> None: +def claude_install(project_dir: Path | None = None, strict: bool = False) -> None: """Write the graphify section to the local CLAUDE.md.""" target = (project_dir or Path(".")) / "CLAUDE.md" @@ -1606,12 +1613,15 @@ def claude_install(project_dir: Path | None = None) -> None: # Always re-install the Claude Code PreToolUse hook so an old hook # payload (e.g. pre-issue-#580 wording) is replaced on upgrade. - _install_claude_hook(project_dir or Path(".")) + _install_claude_hook(project_dir or Path("."), strict=strict) print() print("Claude Code will now check the knowledge graph before answering") print("codebase questions and rebuild it after code changes.") -def _install_claude_hook(project_dir: Path) -> None: + if strict: + print("Strict mode: the first raw file read per session is blocked until") + print("one `graphify query` runs (toggle with GRAPHIFY_HOOK_STRICT=0).") +def _install_claude_hook(project_dir: Path, strict: bool = False) -> None: """Add graphify PreToolUse hook to .claude/settings.json.""" settings_path = project_dir / ".claude" / "settings.json" settings_path.parent.mkdir(parents=True, exist_ok=True) @@ -1628,9 +1638,10 @@ 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"].extend(_claude_pretooluse_hooks()) + hooks["PreToolUse"].extend(_claude_pretooluse_hooks(strict=strict)) settings_path.write_text(json.dumps(settings, indent=2), encoding="utf-8") - print(f" .claude/settings.json -> PreToolUse hooks registered (Bash search + Read/Glob)") + _mode = " (strict)" if strict else "" + print(f" .claude/settings.json -> PreToolUse hooks registered (Bash search + Read/Glob){_mode}") def _uninstall_claude_hook(project_dir: Path) -> None: """Remove the graphify PreToolUse hook from .claude/settings.json and its local-only sibling .claude/settings.local.json. @@ -1894,6 +1905,7 @@ def dispatch_install_cli(cmd: str) -> bool: default_platform = "windows" if platform.system() == "Windows" else "claude" selected_platform: str | None = None project_scope = False + strict = False args = sys.argv[2:] i = 0 while i < len(args): @@ -1904,6 +1916,9 @@ def dispatch_install_cli(cmd: str) -> bool: if arg == "--project": project_scope = True i += 1 + elif arg == "--strict": + strict = True + i += 1 elif arg.startswith("--platform="): candidate = arg.split("=", 1)[1] if selected_platform and selected_platform != candidate: @@ -1932,8 +1947,14 @@ def dispatch_install_cli(cmd: str) -> bool: i += 1 chosen_platform = selected_platform or default_platform if project_scope: - _project_install(chosen_platform, Path(".")) + _project_install(chosen_platform, Path("."), strict=strict) else: + if strict: + print( + "note: --strict applies to the project PreToolUse hook; run " + "`graphify install --project --strict` or `graphify claude install --strict`.", + file=sys.stderr, + ) install(platform=chosen_platform) elif cmd == "uninstall": args = sys.argv[2:] @@ -1970,10 +1991,11 @@ def dispatch_install_cli(cmd: str) -> bool: elif cmd == "claude": subcmd = sys.argv[2] if len(sys.argv) > 2 else "" if subcmd == "install": + _strict = "--strict" in sys.argv[3:] if "--project" in sys.argv[3:]: - _project_install("claude", Path(".")) + _project_install("claude", Path("."), strict=_strict) else: - claude_install() + claude_install(strict=_strict) elif subcmd == "uninstall": if "--project" in sys.argv[3:]: _project_uninstall("claude", Path(".")) diff --git a/pyproject.toml b/pyproject.toml index e63453e..b8ea4ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "graphifyy" -version = "0.9.18" +version = "0.9.19" description = "AI coding assistant skill (Claude Code, CodeBuddy, Codex, OpenCode, Kilo Code, Cursor, Gemini CLI, Aider, OpenClaw, Factory Droid, Trae, Hermes, Kiro, Pi, Devin CLI, Google Antigravity) - turn any folder of code, docs, papers, images, or videos into a queryable knowledge graph" readme = "README.md" license = { file = "LICENSE" } diff --git a/tests/test_hook_strict.py b/tests/test_hook_strict.py new file mode 100644 index 0000000..1d18dd8 --- /dev/null +++ b/tests/test_hook_strict.py @@ -0,0 +1,202 @@ +"""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")["hooks"][0]["command"].endswith("hook-guard search")