forked from retoor/devplacepy
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>
155 lines
5.3 KiB
Python
155 lines
5.3 KiB
Python
#!/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()
|