Skip to content

Commit c0c96ca

Browse files
vpetreskiclaude
andcommitted
qmd: enforce QMD-first — PreToolUse hook blocks knowledge-dir searches, forces mcp__qmd__query
- .claude/hooks/qmd-grep-guard.py: guard (protected dirs as args; command-position detection; fails open; logs to ~/.qmd-grep-audit.log) - .claude/settings.json: PreToolUse hook (Grep|Bash matcher) routes to the guard - mirrors the life repo so QMD-first is harness-enforced, not discretionary Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 2ee1d85 commit c0c96ca

2 files changed

Lines changed: 101 additions & 1 deletion

File tree

.claude/hooks/qmd-grep-guard.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
#!/usr/bin/env python3
2+
"""QMD-first guard (PreToolUse hook).
3+
4+
Blocks grep/rg/etc. over this repo's QMD-indexed knowledge dirs so searches go
5+
through `mcp__qmd__query` instead of grep. The protected dir names are passed as
6+
CLI args (derived per-repo from ~/.config/qmd/index.yml), e.g.:
7+
8+
qmd-grep-guard.py content data # life / altius / cinc
9+
qmd-grep-guard.py docs # cp-deep-dive
10+
qmd-grep-guard.py content ideas common # indie-hacking
11+
12+
Enforcement:
13+
- Grep tool: deny if path is unset/repo-root/"." (repo-wide includes knowledge
14+
dirs) or resolves under a protected dir. Allow when an explicit non-knowledge
15+
path (tools/, src/, ...) is given.
16+
- Bash: deny grep-family commands that target a protected dir ("... content/"),
17+
recurse over "." / "*", or use `git grep` (repo-wide). Allow single-file/other.
18+
19+
FAILS OPEN on any error — a bug here must never brick Grep/Bash. Every block is
20+
appended to ~/.qmd-grep-audit.log (timestamp, repo, tool, detail) for audit.
21+
"""
22+
import sys, json, os, re, datetime
23+
24+
_GREP = r"(?:grep|egrep|fgrep|rg|ripgrep|ag|ack)"
25+
# grep-family invoked AS A COMMAND (line start, or after a pipe / ; / && / || / subshell) —
26+
# not when "grep" merely appears as text inside a quoted argument (e.g. git commit -m "...grep...").
27+
CMD_GREP = r"(?:^|[\n|;&(]|&&|\|\|)\s*(?:sudo\s+|command\s+|time\s+)?" + _GREP + r"\b"
28+
GIT_GREP = r"(?:^|[\n|;&(]|&&|\|\|)\s*git\s+grep\b"
29+
30+
31+
def main():
32+
roots = [r for r in sys.argv[1:] if r] or ["content", "data"]
33+
data = json.loads(sys.stdin.read())
34+
tool = data.get("tool_name", "")
35+
if tool not in ("Grep", "Bash"):
36+
return
37+
ti = data.get("tool_input", {}) or {}
38+
alt = "|".join(re.escape(r) for r in roots)
39+
40+
def deny(detail):
41+
try:
42+
with open(os.path.expanduser("~/.qmd-grep-audit.log"), "a") as f:
43+
ts = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
44+
f.write(f"{ts}\t{os.path.basename(os.getcwd())}\t{tool}\t{detail}\n")
45+
except Exception:
46+
pass
47+
reason = (
48+
"Knowledge-base search blocked — use mcp__qmd__query (lex/vec/hyde) instead. "
49+
f"grep over [{', '.join(roots)}] is disabled to enforce QMD-first. "
50+
"Known single file? use Read. Structural code search? target tools/ (or another "
51+
"non-knowledge dir) explicitly so it isn't repo-wide."
52+
)
53+
print(json.dumps({"hookSpecificOutput": {
54+
"hookEventName": "PreToolUse",
55+
"permissionDecision": "deny",
56+
"permissionDecisionReason": reason,
57+
}}))
58+
sys.exit(0)
59+
60+
if tool == "Grep":
61+
path = (ti.get("path") or "").strip()
62+
if path in ("", ".", "./"):
63+
deny("Grep repo-wide (no/'.' path includes knowledge dirs)")
64+
norm = path.lstrip("./").rstrip("/")
65+
if re.search(rf"(^|/)(?:{alt})(/|$)", norm):
66+
deny(f"Grep path={path}")
67+
return
68+
69+
# Bash
70+
cmd = ti.get("command", "") or ""
71+
if re.search(GIT_GREP, cmd):
72+
deny(f"Bash git grep (repo-wide): {cmd[:160]}")
73+
if not re.search(CMD_GREP, cmd):
74+
return
75+
# grep-family touching "<root>/..."
76+
if re.search(rf"(^|[\s=/'\"(])(?:{alt})/", cmd):
77+
deny(f"Bash grep over knowledge dir: {cmd[:160]}")
78+
# recursive grep over cwd / "." / "*" / bare root dir
79+
if re.search(r"(?:\s-[A-Za-z]*[rR]|--recursive)", cmd) and re.search(rf"(\s)(?:{alt}|\.|\*)(\s|$|[|;&])", cmd + " "):
80+
deny(f"Bash recursive grep over cwd/knowledge: {cmd[:160]}")
81+
return
82+
83+
84+
try:
85+
main()
86+
except Exception:
87+
sys.exit(0) # fail open — never brick Grep/Bash

.claude/settings.json

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,16 @@
11
{
2-
"enabledPlugins": {}
2+
"enabledPlugins": {},
3+
"hooks": {
4+
"PreToolUse": [
5+
{
6+
"matcher": "Grep|Bash",
7+
"hooks": [
8+
{
9+
"type": "command",
10+
"command": "python3 \"${CLAUDE_PROJECT_DIR:-/Users/cocoloco/Projects/cp-deep-dive}/.claude/hooks/qmd-grep-guard.py\" docs"
11+
}
12+
]
13+
}
14+
]
15+
}
316
}

0 commit comments

Comments
 (0)