#!/usr/bin/env python3
"""
DashClaw database containment (Neon) — the staging medium for the
`db_branch` containment basis.
See docs/rfcs/2026-09-04-database-containment.md. The file basis stages a
mutation in a git worktree; this module stages a Postgres mutation on an
ephemeral Neon branch of the same database, so the operator reviews the
statement, the schema diff and the output once and Promote replays the
ORIGINAL command against production.
Imported by dashclaw_pretool.py (detect + branch + rewrite) and
dashclaw_posttool.py (schema diff + credential scrub). Stdlib only, like
every hook script here.
SECRET DISCIPLINE: this module is the only place a production connection URL
is ever read. It is never logged, never sent to DashClaw, and never written
to session state — only the branch HOST and the Neon ids are persisted, and
every string that leaves the machine goes through scrub_db_credentials()
first. Callers must keep that property: log the host, never the URL.
"""
import json
import os
import re
import tempfile
import urllib.parse
import urllib.request
from datetime import datetime, timedelta, timezone
# Quoting-aware segmentation, shared with the classifier so the hook's notion
# of "this segment runs psql" matches the one the server grades on.
from dashclaw_agent_intel.command_parser import parse_command, split_chain_texts
# ---------------------------------------------------------------------------
# Environment
# ---------------------------------------------------------------------------
# Neon's control-plane API. Overridable ONLY so the hook test suite can point
# the client at a local mock: these requests carry the operator's NEON_API_KEY
# as a Bearer token, so a production installation must never set this.
NEON_API_BASE_DEFAULT = "https://console.neon.tech/api/v2"
_DEFAULT_TTL_HOURS = 72
_HTTP_TIMEOUT = 10
# A Neon account with many projects must not stall PreToolUse: the endpoint
# probe walks at most this many pages of 100 before giving up (set
# NEON_PROJECT_ID to skip the walk entirely).
_MAX_PROJECT_PAGES = 10
def db_containment_enabled():
"""DB containment kill switch, independent of DASHCLAW_CONTAINMENT (which
covers both media). Read per call, not at import: the hook processes one
tool call per process, and tests drive this through the environment."""
return (os.environ.get("DASHCLAW_DB_CONTAINMENT") or "1") != "0"
def neon_api_key():
return os.environ.get("NEON_API_KEY") or ""
def neon_api_base():
return (os.environ.get("DASHCLAW_NEON_API_BASE") or NEON_API_BASE_DEFAULT).rstrip("/")
def ttl_hours():
raw = os.environ.get("DASHCLAW_DB_CONTAINMENT_TTL_HOURS")
try:
return max(1, int(raw)) if raw else _DEFAULT_TTL_HOURS
except (TypeError, ValueError):
return _DEFAULT_TTL_HOURS
def utc_now_iso():
"""RFC3339 timestamp, the format Neon's API speaks."""
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def expires_at_iso(now=None):
"""RFC3339 expiry Neon self-deletes the branch at. Expiry IS the cleanup
story (RFC): a session that never finishes leaves nothing behind."""
base = now or datetime.now(timezone.utc)
return (base + timedelta(hours=ttl_hours())).strftime("%Y-%m-%dT%H:%M:%SZ")
# ---------------------------------------------------------------------------
# Detection: is this command talking to a Postgres database, and which one?
# ---------------------------------------------------------------------------
_PG_URL_RE = re.compile(r"postgres(?:ql)?://[^\s'\"<>`]+", re.IGNORECASE)
# Postgres clients whose mere presence in the command slot makes the segment a
# database act (RFC "Evidence classifier"). Mirrors the server's classifyShell
# list — a hook classifier that diverges from the server's is worse than a
# shared over-tag, since the server does the real grading.
_DB_BARE_CLIENTS = frozenset({"psql", "pg_restore"})
_PRISMA_DB_SUBCOMMANDS = frozenset({
("db", "push"), ("db", "execute"),
("migrate", "deploy"), ("migrate", "dev"), ("migrate", "reset"),
})
_DRIZZLE_DB_SUBCOMMANDS = frozenset({"push", "migrate", "drop"})
# `npx prisma …` / `pnpm dlx drizzle-kit …` are the way these tools are
# actually invoked; the runner is not the act.
_RUNNERS = frozenset({"npx", "pnpm", "yarn", "bunx", "bun", "npm"})
_RUNNER_PASSTHROUGH = frozenset({"dlx", "exec", "run"})
def _basename(token):
token = (token or "").replace("\\", "/").rsplit("/", 1)[-1]
if token.lower().endswith(".exe"):
token = token[:-4]
return token.lower()
def _segment_words(parsed):
"""Non-flag words of one parsed segment, in order, with runner prefixes
dropped.
parse_command already strips leading KEY=VALUE assignments, wrappers
(sudo/env/timeout/…) and redirections, and it tokenizes quote-aware — so a
`git commit -m "fix the psql thing"` never surfaces `psql` in the command
slot."""
base = parsed.get("base_command") or ""
if not base:
return []
words = [base]
if parsed.get("subcommand"):
words.append(parsed["subcommand"])
words.extend(parsed.get("targets") or [])
words = [_basename(w) for w in words]
while words and words[0] in _RUNNERS:
words.pop(0)
while words and words[0] in _RUNNER_PASSTHROUGH:
words.pop(0)
return words
def _segments(command):
"""Every sequential AND piped segment of the command, parsed. `psql` in
the second stage of a pipe is still a database act."""
segments = []
for chain_text in split_chain_texts(command or ""):
parsed = parse_command(chain_text)
pipes = parsed.get("pipes") or []
segments.extend(pipes if pipes else [parsed])
return segments
def is_db_command(command):
"""True when this Bash command targets a Postgres database.
Used ONLY to decide whether to advertise `allow_contained:db` and, later,
to rewrite the command — the risk grading stays server-side. Over-tagging
is t