feat(hook): opt-in strict PreToolUse guard + stop crying wolf (#1840)

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) <noreply@anthropic.com>
This commit is contained in:
safishamsi
2026-07-17 17:10:41 +01:00
co-authored by Claude Opus 4.8
parent 8cee776cee
commit 689dd6ccfd
5 changed files with 459 additions and 27 deletions
+5
View File
@@ -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.
+216 -13
View File
@@ -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 "<question>"` 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 "<your question>"` (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:
+35 -13
View File
@@ -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 <search|read>` 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("."))
+1 -1
View File
@@ -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" }
+202
View File
@@ -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")