|
#!/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()
|