diff --git a/.claude/hooks/guard_production_db.py b/.claude/hooks/guard_production_db.py new file mode 100644 index 00000000..cf8e8621 --- /dev/null +++ b/.claude/hooks/guard_production_db.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +# retoor + +import json +import re +import sys +from pathlib import Path + +CONFIRMATION_TOKEN = "I-HAVE-CONFIRMED-PRODUCTION-DB-ACCESS" + +PRODUCTION_PATHS = re.compile( + r"data/(devplace\.db|devii_tasks\.db|devii_lessons\.db|keys|uploads" + r"|attachments|project_files|backups)\b" +) +PRODUCTION_DB_FILE = re.compile(r"\bdevplace\.db\b") +MANAGEMENT_CLI = re.compile(r"(?:^|[;&|(]\s*|\s)(?:[\w./-]*/)?devplace\s+(?!-)") +PYTHON_INVOCATION = re.compile(r"(?:^|[;&|(\s])(?:[\w./-]*/)?python[0-9.]*(?:\s|$)") +DATABASE_OVERRIDE = re.compile(r"DEVPLACE_DATABASE_URL\s*=\s*[\"']?(\S+?)[\"']?(?:\s|$)") +DATABASE_ASSIGNMENT = re.compile(r"DEVPLACE_DATABASE_URL[\"'\]\s]*[=,]") +MODULE_INVOCATION = re.compile(r"-m\s+devplacepy") +INLINE_CODE = re.compile(r"-c\s+(?P[\"'])(?P.*?)(?P=quote)", re.DOTALL) +SCRIPT_PATH = re.compile(r"(?:^|\s)(?P[\w./~-]+\.py)(?:\s|$)") +IMPORT_GATE = re.compile( + r"^from devplacepy\.main import app\s*;?\s*(?:print\([^)]*\)\s*;?\s*)?$" +) +TEST_RUNNER = re.compile(r"\bpytest\b|\bmake\s+(test|test-[\w-]+)\b") +SERVER_TARGET = re.compile(r"\bmake\s+(dev|prod|docker-[\w-]+|ppy)\b") + + +APPLICATION_IMPORT = re.compile( + r"(?:^|[\s;])(?:from|import)\s+devplacepy\b" + r"|import_module\s*\(\s*[\"']devplacepy", + re.MULTILINE, +) + + +def reaches_application(text: str) -> bool: + return bool(APPLICATION_IMPORT.search(text)) + + +def overrides_the_database(text: str) -> bool: + match = DATABASE_OVERRIDE.search(text) + if not match: + return False + return "data/devplace.db" not in match.group(1) + + +def source_targets_a_scratch_database(source: str) -> bool: + if PRODUCTION_DB_FILE.search(source): + return False + return bool(DATABASE_ASSIGNMENT.search(source)) + + +def script_is_safe(command: str) -> bool | None: + match = SCRIPT_PATH.search(command) + if not match: + return None + path = Path(match.group("path")).expanduser() + try: + body = path.read_text(errors="replace") + except OSError: + return None + if not reaches_application(body): + return True + return source_targets_a_scratch_database(body) + + +def hazard_in(command: str) -> str: + if CONFIRMATION_TOKEN in command: + return "" + if TEST_RUNNER.search(command) or SERVER_TARGET.search(command): + return "" + if PRODUCTION_DB_FILE.search(command) or PRODUCTION_PATHS.search(command): + return "it names the production database or a production data directory" + if MANAGEMENT_CLI.search(command): + return "the devplace management CLI operates on the production database" + if not PYTHON_INVOCATION.search(command): + return "" + if overrides_the_database(command): + return "" + if MODULE_INVOCATION.search(command): + return "it runs a devplacepy module with no DEVPLACE_DATABASE_URL override" + inline = INLINE_CODE.search(command) + if inline: + code = inline.group("code").strip() + if not reaches_application(code): + return "" + if IMPORT_GATE.match(code): + return "" + return "it imports devplacepy inline with no DEVPLACE_DATABASE_URL override" + safe = script_is_safe(command) + if safe is None: + return "" + if safe: + return "" + return "the script imports devplacepy with no DEVPLACE_DATABASE_URL override" + + +def refuse(reason: str) -> dict: + return { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": ( + f"Blocked: this command reaches the production database because {reason}. " + "The production database is never touched without the user's explicit, " + "stated confirmation. Stop, tell the user exactly what the command would " + "read or write, and ask them to confirm in their own words. Only after " + f"they have done so may the command carry the literal token " + f"{CONFIRMATION_TOKEN}, which still raises a permission prompt they must " + "approve. Never add that token on your own initiative. Alternatives that " + "need no confirmation: set DEVPLACE_DATABASE_URL to a scratch database, " + "or run the test suite." + ), + } + } + + +def confirm(reason: str) -> dict: + return { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "ask", + "permissionDecisionReason": ( + "This command carries the production-database confirmation token and " + f"reaches the production database because {reason}. Approve only if you " + "asked for this." + ), + } + } + + +def main() -> None: + try: + payload = json.load(sys.stdin) + except (json.JSONDecodeError, ValueError): + sys.exit(0) + command = (payload.get("tool_input") or {}).get("command") or "" + if not command: + sys.exit(0) + if CONFIRMATION_TOKEN in command: + stripped = command.replace(CONFIRMATION_TOKEN, "") + reason = hazard_in(stripped) + if reason: + print(json.dumps(confirm(reason))) + sys.exit(0) + reason = hazard_in(command) + if reason: + print(json.dumps(refuse(reason))) + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..2d2de04b --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-settings.json", + "permissions": { + "deny": [ + "Bash(devplace *)", + "Write(data/**)", + "Edit(data/**)" + ] + }, + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/guard_production_db.py\"", + "timeout": 10, + "statusMessage": "Checking for production database access" + } + ] + } + ] + } +} diff --git a/CLAUDE.md b/CLAUDE.md index c8f222de..f419ffdc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -266,6 +266,16 @@ Devii is also reachable over **Telegram** (one supervised long-poller subprocess `devplacepy/seo.py` generates JSON-LD schemas (WebSite, BreadcrumbList, DiscussionForumPosting, ProfilePage, SoftwareApplication). Every router builds context via `base_seo_context(request, ...)`. Auth/messages/notifications are `noindex,nofollow`; profiles with fewer than 2 posts are `noindex,follow`. `/robots.txt` and `/sitemap.xml` are served by `routers/seo.py`. Full implementation map (template layer, heading hierarchy, slugs, related posts, performance, default OG image, SEO tests) is in `devplacepy/routers/CLAUDE.md`. +## The production database is never touched without explicit confirmation (hard rule) + +`data/devplace.db` is the live production database, and `make dev`, `make prod` and the Docker stack all share it (see "Production deployment"). No agent-initiated command may read or write it, or anything else under `data/`, without the user's explicit, stated confirmation - not a one-click approval, a confirmation they wrote themselves after being told exactly what the command would do. + +This is enforced, not remembered. `.claude/hooks/guard_production_db.py` runs as a `PreToolUse` hook on every Bash call and **denies** the command outright when it reaches production, naming the reason. The interesting case is the one that motivated the rule: a script that never mentions a path at all but imports `devplacepy` and therefore resolves `config.DATA_DIR` to the real database. The hook reads the script and decides on its content, so a scratch-database script passes and an unguarded one does not. + +What the guard blocks: any command naming `data/devplace.db` or a production data directory, the `devplace` management CLI, `python -m devplacepy...`, and any inline `-c` or script file that imports `devplacepy` without a `DEVPLACE_DATABASE_URL` override. What stays free: `make test` and `pytest` (the suite runs on its own temp database), `make dev`/`make prod`/`make docker-*`, the mandated import gate `python -c "from devplacepy.main import app"`, and anything that sets `DEVPLACE_DATABASE_URL` to a scratch file. `permissions.deny` in `.claude/settings.json` additionally refuses `Write`/`Edit` anywhere under `data/`, which the Bash hook cannot see. + +The escape hatch is deliberately two-factor and must never be self-served: after the user has confirmed in their own words, the command may carry the literal token `I-HAVE-CONFIRMED-PRODUCTION-DB-ACCESS`, which downgrades the denial to a permission prompt the user still has to approve. **Never add that token on your own initiative.** Write disposable scripts against a temp database via `DEVPLACE_DATABASE_URL`/`DEVPLACE_DATA_DIR` instead, exactly as "Rigorous correctness verification" already requires. + ## Conventions (project-specific) - **No comments, no docstrings in source.** Code is self-documenting.