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:
co-authored by
Claude Opus 4.8
parent
8cee776cee
commit
689dd6ccfd
@@ -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
@@ -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
@@ -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
@@ -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" }
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user