Refuse to touch the production database without stated confirmation
data/devplace.db is the live database and make dev, make prod and the Docker stack all share it, so an agent-initiated command that reaches it is a production incident waiting for a typo. The hazard is invisible in the command text: the script that prompted this named no path at all, it imported devplacepy and therefore resolved config.DATA_DIR to the real file. A path-pattern rule would have sailed straight past it. The PreToolUse hook reads the script and judges it on content, so one that points DEVPLACE_DATABASE_URL at a scratch file passes and an unguarded one does not. It also refuses commands naming the database or a production data directory, the management CLI, and python -m devplacepy. The suite, the server targets and the mandated import gate stay free. permissions.deny additionally refuses Write and Edit anywhere under data, which the Bash hook cannot see. The escape hatch is two-factor and cannot be self-served: without confirmation the command is denied outright rather than prompted, and the token that downgrades it to a prompt may only be added after the user has confirmed in their own words. Verified against thirty-five commands, and the heuristic is narrower than it looks because the repository path itself contains the package name, so it matches an import statement rather than the bare word. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7e37122f9f
commit
2bdcf6528f
154
.claude/hooks/guard_production_db.py
Normal file
154
.claude/hooks/guard_production_db.py
Normal file
@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env python3
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
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<quote>[\"'])(?P<code>.*?)(?P=quote)", re.DOTALL)
|
||||
SCRIPT_PATH = re.compile(r"(?:^|\s)(?P<path>[\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()
|
||||
25
.claude/settings.json
Normal file
25
.claude/settings.json
Normal file
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
10
CLAUDE.md
10
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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user