convention_guard.py

Claude / Hooks / convention_guard

Type: ask (prompts for confirmation) · Events: PreToolUse · Registrations: 1

convention_guard.py is the one hook that asks rather than denies: it stops a command or edit that contradicts a standing platform convention and lets Mohamed confirm with one keypress. It runs before Bash, Write, Edit and MultiEdit from the orchestrata plugin registration.

For Write, Edit and MultiEdit it checks whether the path starts with /opt/ and asks, because a deployed tree is replaced by the next install (install.sh does a git reset --hard). For Bash it masks quoted text and heredoc bodies using the helper from route_pytest_to_threads, then tests a rule table: pip install (use uv add or uv sync --frozen), stdlib venv or virtualenv (use uv venv --python python3.14t), system python3 x.py (use uv run or the project venv), a symlink from a system path into a live checkout (deploy-in-place), a systemd ExecStart pointing into a checkout, docker commands (infrastructure is Incus), and a literal localhost graph endpoint (matched on the raw text, not the masked text). Every match is collected and returned as permissionDecision: "ask" with the rule, the fix and a pointer to the workflow document; the bypass is # convention-ok in the command.

Diagram claude-hooks-convention_guard-mb916610dd8

Any internal error exits 0 so the guard never breaks a tool call. Known defects: the plugin registration runs the separately deployed copy under $HOME/.claude/hooks (through a sh -c wrapper that prefers python3.14t), so the plugin alone does not carry this hook; and if the import of the masking helper fails the guard falls back to unmasked text, which would make it fire on quoted prose. Related: RUL-004 (Incus, not Docker), RUL-021 (a worktree owns its environment), RUL-027 (prod runs its own checkout), RUL-003, platform_conventions_prompt, install_secret_guard.

Registration

# Event Matcher Timeout Registered in Live in a $HOME session
48 PreToolUse Bash|Write|Edit|MultiEdit default orchestrata plugin 1.5.0 hooks.json:340 yes

Commands (as registered, abridged):

Location

Source

convention_guard.py (deployed copy)

205 lines.

#!/home/mohamed/.claude/.venv/bin/python
"""PreToolUse hook: stop and remind when an action would negate a platform convention.

User instruction (2026-08-13): "add reminder hooks if commands are about to be
executed that negate this" — 'this' being the standing conventions: free-threaded
python3.14t, per-project uv venvs, git worktrees, no deploy-in-place, Incus rather
than Docker, endpoints from profiles rather than literals.

Decision model: every match returns permissionDecision "ask". That is deliberately
a reminder rather than a denial — it surfaces the rule with the reason at the exact
moment it is about to be broken, and one keypress proceeds when the user genuinely
means it (installing inside a container, reproducing a bug on system python).
Silent allow-with-warning was the alternative and it is worthless: warnings that
never interrupt get scrolled past, which is how the conventions got broken in the
first place.

Bypass token: `# convention-ok` anywhere in the command, mirroring the `# seq-ok`
token the pytest routing hook uses.

Detection runs on EXECUTABLE text only, reusing the masking helper from
route_pytest_to_threads.py: heredoc bodies and quoted strings are DATA. That hook
learned this the hard way — a commit message containing the word "pytest" was
matched and the whole command rewritten. A git commit describing "removed the pip
install" must not trip this guard.

Never breaks a tool call: any internal error exits 0 (allow, unmodified).
"""

from __future__ import annotations

import json
import re
import sys
from pathlib import Path
from typing import NoReturn

HOOKS_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(HOOKS_DIR))

try:  # reuse the proven masking rather than re-deriving it
    from route_pytest_to_threads import _executable_text  # type: ignore[attr-defined]
except Exception:  # noqa: BLE001 - degrade to raw text rather than failing the call

    def _executable_text(s: str) -> str:  # type: ignore[misc]
        return s


WORKFLOW = Path.home() / ".claude" / "workflow"

# (regex, short rule name, what to do instead, which workflow doc explains it, scan_raw)
#
# scan_raw=True scans the ORIGINAL command instead of the masked executable text.
# Reserved for rules whose target is a literal rather than a command word. Measured:
# `python3 -c "...GraphDatabase.driver('bolt://localhost:7687')"` was allowed through,
# because the masking keeps a -c payload as code but still blanks the nested single-quoted
# string inside it — which is exactly where the endpoint literal lives. A command word like
# `pip install` must stay masked (prose in a commit message must not fire); an endpoint
# literal is data by nature, so matching it in data is the entire point.
RULES: list[tuple[str, str, str, str, bool]] = [
    (
        r"(?:^|[;&|]\s*|\s)pip3?\s+install\b",
        "pip install — every project owns a uv venv",
        "Use `uv add <pkg>` (declares it) or `uv sync --frozen` (installs the lock). "
        "`pip install` writes into whatever interpreter happens to be active and leaves "
        "nothing recorded.",
        "development.md",
        False,
    ),
    (
        r"(?:^|[;&|]\s*|\s)(?:python3?(?:\.\d+)?\s+-m\s+venv|virtualenv)\b",
        "stdlib venv/virtualenv — venvs are made with uv",
        "Use `uv venv --python python3.14t`. A stdlib venv defaults to the system "
        "interpreter, which is the wrong interpreter here.",
        "development.md",
        False,
    ),
    (
        r"(?:^|[;&|]\s*|\s)(?:/usr/bin/python3?|python3(?:\.\d+)?)\s+\S*\.py\b",
        "system python — the interpreter is free-threaded python3.14t",
        "Run project code with `uv run <script>` or `.venv/bin/python <script>`. "
        "System python is not the interpreter anything here is built, deployed or "
        "benchmarked on. (Standalone one-off scripts outside any project are fine — "
        "proceed if that is what this is.)",
        "development.md",
        False,
    ),
    (
        r"ln\s+-[a-zA-Z]*s[a-zA-Z]*\s+\S*(?:/home/\w+/workspace|~/workspace)\S*\s+"
        r"\S*(?:/usr/local/bin|/opt|/etc/systemd|/usr/bin)\S*",
        "symlink from a system path into a live checkout — that IS deploy-in-place",
        "Deploy real copies under /opt/<name> with their own uv venv, the source commit "
        "recorded, and an installer that resets to a pinned git ref. A symlink into a "
        "working tree mutates whenever anyone edits, checks out or rebases.",
        "deployment.md",
        False,
    ),
    (
        r"ExecStart\s*=\s*\S*(?:/home/\w+/workspace|~/workspace)",
        "systemd unit executing out of a live checkout — deploy-in-place",
        "Point ExecStart at /opt/<name>/.venv/bin/... and install the unit from the "
        "repo's version-controlled copy. An uncommitted edit must never be what serves "
        "requests.",
        "deployment.md",
        False,
    ),
    (
        r"(?:^|[;&|]\s*|\s)docker(?:-compose)?\s+(?:run|compose|up|build|exec|ps)\b",
        "docker — infrastructure here is Incus",
        "Use `incus` (projects: default = prod 10.53.1.x, orchestrata-test = "
        "10.53.2.x, orchestrata-dev = 10.53.3.x). There is no docker-compose stack.",
        "environments.md",
        False,
    ),
    (
        r"(?:bolt|neo4j)://(?:localhost|127\.0\.0\.1)|(?:localhost|127\.0\.0\.1):(?:7687|7474)",
        "localhost graph endpoint — endpoints come from the profile",
        "Source the profile (`set -a; source env/<profile>.env; set +a`) and read the "
        "endpoint from the environment. The literal localhost default outlived the "
        "database it named and left sync jobs failing silently for weeks.",
        "environments.md",
        True,
    ),
]

_COMPILED = [
    (re.compile(rx, re.IGNORECASE | re.MULTILINE), name, fix, doc, raw)
    for rx, name, fix, doc, raw in RULES
]


def _allow() -> NoReturn:
    sys.exit(0)


def _ask(reasons: list[str]) -> NoReturn:
    body = "\n\n".join(reasons)
    print(
        json.dumps(
            {
                "hookSpecificOutput": {
                    "hookEventName": "PreToolUse",
                    "permissionDecision": "ask",
                    "permissionDecisionReason": (
                        "This command contradicts a standing platform convention.\n\n"
                        + body
                        + "\n\nProceed only if you mean it. Bypass token for a genuine "
                        "exception: add `# convention-ok` to the command."
                    ),
                }
            }
        )
    )
    sys.exit(0)


def main() -> None:
    try:
        data = json.load(sys.stdin)
    except Exception:  # noqa: BLE001
        _allow()

    tool = data.get("tool_name") or ""
    tool_input = data.get("tool_input") or {}

    # Hand-editing the deployed tree: the next install resets it, so the change is
    # discarded rather than promoted — silently, which is the dangerous part.
    if tool in ("Write", "Edit", "MultiEdit"):
        path = str(tool_input.get("file_path") or tool_input.get("path") or "")
        if path.startswith("/opt/"):
            _ask(
                [
                    "RULE: never hand-edit a deployed tree under /opt.\n"
                    f"  path: {path}\n"
                    "  INSTEAD: edit the source in ~/workspace/<project>, commit, then run "
                    "the project's installer. `install.sh` does `git reset --hard <ref>`, so "
                    "an edit made here is discarded on the next deploy, not promoted.\n"
                    f"  See: {WORKFLOW / 'deployment.md'}"
                ]
            )
        _allow()

    if tool != "Bash":
        _allow()

    cmd = tool_input.get("command") or ""
    if not cmd or "# convention-ok" in cmd:
        _allow()

    masked = _executable_text(cmd)

    reasons = [
        f"RULE: {name}\n  INSTEAD: {fix}\n  See: {WORKFLOW / doc}"
        for rx, name, fix, doc, raw in _COMPILED
        if rx.search(cmd if raw else masked)
    ]
    if reasons:
        _ask(reasons)
    _allow()


if __name__ == "__main__":
    try:
        main()
    except Exception:  # noqa: BLE001 - a guard must never break the tool call
        sys.exit(0)