|
| 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 |
0 commit comments