sql-write-gate 0.16.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sql_write_gate-0.16.0.dist-info/METADATA +482 -0
- sql_write_gate-0.16.0.dist-info/RECORD +42 -0
- sql_write_gate-0.16.0.dist-info/WHEEL +5 -0
- sql_write_gate-0.16.0.dist-info/entry_points.txt +2 -0
- sql_write_gate-0.16.0.dist-info/licenses/LICENSE +21 -0
- sql_write_gate-0.16.0.dist-info/top_level.txt +1 -0
- write_gate/__init__.py +7 -0
- write_gate/__main__.py +6 -0
- write_gate/adapters/__init__.py +33 -0
- write_gate/adapters/base.py +101 -0
- write_gate/adapters/duckdb.py +41 -0
- write_gate/adapters/mysql.py +115 -0
- write_gate/adapters/postgres.py +96 -0
- write_gate/adapters/sqlite.py +116 -0
- write_gate/approvals.py +205 -0
- write_gate/audit.py +120 -0
- write_gate/cases.py +30 -0
- write_gate/catalog.py +79 -0
- write_gate/cli.py +397 -0
- write_gate/config.py +117 -0
- write_gate/db.py +5 -0
- write_gate/decision.py +143 -0
- write_gate/engine.py +150 -0
- write_gate/guards/__init__.py +17 -0
- write_gate/guards/blast_radius.py +73 -0
- write_gate/guards/destructive.py +85 -0
- write_gate/guards/environment.py +41 -0
- write_gate/guards/freshness.py +102 -0
- write_gate/guards/pii.py +84 -0
- write_gate/guards/schema.py +147 -0
- write_gate/hooks.py +360 -0
- write_gate/init.py +84 -0
- write_gate/mcp_server.py +71 -0
- write_gate/mcp_tools.py +197 -0
- write_gate/parser.py +367 -0
- write_gate/paths.py +20 -0
- write_gate/policy.py +49 -0
- write_gate/proxy.py +273 -0
- write_gate/templates/GETTING_STARTED.md +20 -0
- write_gate/templates/catalog.json +32 -0
- write_gate/templates/policy.yaml +12 -0
- write_gate/wrapper.py +207 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Adapter routing: postgres:// / mysql:// / sqlite:// URLs vs DuckDB file paths."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Mapping
|
|
8
|
+
|
|
9
|
+
from write_gate.paths import DB_PATH
|
|
10
|
+
|
|
11
|
+
BACKEND_DUCKDB = "duckdb"
|
|
12
|
+
BACKEND_POSTGRES = "postgres"
|
|
13
|
+
BACKEND_MYSQL = "mysql"
|
|
14
|
+
BACKEND_SQLITE = "sqlite"
|
|
15
|
+
|
|
16
|
+
_PG_PREFIXES = ("postgres://", "postgresql://")
|
|
17
|
+
_MYSQL_PREFIXES = ("mysql://", "mysql+pymysql://")
|
|
18
|
+
_SQLITE_PREFIXES = ("sqlite:///", "sqlite+aiosqlite://")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def is_postgres_url(value: str | None) -> bool:
|
|
22
|
+
if not value:
|
|
23
|
+
return False
|
|
24
|
+
lowered = value.strip().lower()
|
|
25
|
+
return lowered.startswith(_PG_PREFIXES)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def is_mysql_url(value: str | None) -> bool:
|
|
29
|
+
if not value:
|
|
30
|
+
return False
|
|
31
|
+
lowered = value.strip().lower()
|
|
32
|
+
return lowered.startswith(_MYSQL_PREFIXES)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def is_sqlite_url(value: str | None) -> bool:
|
|
36
|
+
if not value:
|
|
37
|
+
return False
|
|
38
|
+
lowered = value.strip().lower()
|
|
39
|
+
return lowered.startswith(_SQLITE_PREFIXES)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def detect_backend(target: str | None) -> str:
|
|
43
|
+
if is_sqlite_url(target):
|
|
44
|
+
return BACKEND_SQLITE
|
|
45
|
+
if is_mysql_url(target):
|
|
46
|
+
return BACKEND_MYSQL
|
|
47
|
+
if is_postgres_url(target):
|
|
48
|
+
return BACKEND_POSTGRES
|
|
49
|
+
return BACKEND_DUCKDB
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def sqlglot_dialect(backend: str) -> str:
|
|
53
|
+
if backend in {BACKEND_POSTGRES, "postgresql", "pg"}:
|
|
54
|
+
return "postgres"
|
|
55
|
+
if backend == BACKEND_MYSQL:
|
|
56
|
+
return "mysql"
|
|
57
|
+
if backend == BACKEND_SQLITE:
|
|
58
|
+
return "sqlite"
|
|
59
|
+
return "duckdb"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def count_sql(table: str, predicate: str | None, *, backend: str = BACKEND_DUCKDB) -> str:
|
|
63
|
+
"""SELECT COUNT(*) of rows matching the write predicate.
|
|
64
|
+
|
|
65
|
+
Same COUNT form for DuckDB, Postgres, MySQL, and SQLite (EXPLAIN is optional elsewhere).
|
|
66
|
+
`backend` is accepted so callers can be explicit; quoting is ANSI identifiers.
|
|
67
|
+
"""
|
|
68
|
+
del backend # COUNT SQL is shared; dialect only affects WHERE rendering.
|
|
69
|
+
sql = f'SELECT COUNT(*) FROM "{table}"'
|
|
70
|
+
if predicate:
|
|
71
|
+
sql = f"{sql} WHERE {predicate}"
|
|
72
|
+
return sql
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def resolve_target(
|
|
76
|
+
*,
|
|
77
|
+
database: str | None = None,
|
|
78
|
+
database_url: str | None = None,
|
|
79
|
+
db_path: str | Path | None = None,
|
|
80
|
+
environ: Mapping[str, str] | None = None,
|
|
81
|
+
) -> tuple[str, str]:
|
|
82
|
+
"""Return (backend, target).
|
|
83
|
+
|
|
84
|
+
Priority:
|
|
85
|
+
1. ``database=``
|
|
86
|
+
2. ``database_url=``
|
|
87
|
+
3. ``db_path=`` (explicit path wins over env so tests/demo stay DuckDB)
|
|
88
|
+
4. ``DATABASE_URL`` env
|
|
89
|
+
5. default DuckDB warehouse
|
|
90
|
+
"""
|
|
91
|
+
env = os.environ if environ is None else environ
|
|
92
|
+
for candidate in (database, database_url):
|
|
93
|
+
if candidate:
|
|
94
|
+
return detect_backend(candidate), candidate
|
|
95
|
+
if db_path is not None:
|
|
96
|
+
target = str(db_path)
|
|
97
|
+
return detect_backend(target), target
|
|
98
|
+
env_url = env.get("DATABASE_URL")
|
|
99
|
+
if env_url:
|
|
100
|
+
return detect_backend(env_url), env_url
|
|
101
|
+
return BACKEND_DUCKDB, str(DB_PATH)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""DuckDB warehouse helpers. User SQL must not be executed from here; use WriteGate."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import duckdb
|
|
8
|
+
|
|
9
|
+
from write_gate.adapters.base import BACKEND_DUCKDB, count_sql as _count_sql
|
|
10
|
+
from write_gate.paths import DB_PATH
|
|
11
|
+
|
|
12
|
+
DIALECT = "duckdb"
|
|
13
|
+
BACKEND = BACKEND_DUCKDB
|
|
14
|
+
|
|
15
|
+
ORDERS_DDL = """
|
|
16
|
+
CREATE TABLE orders (
|
|
17
|
+
order_id INTEGER PRIMARY KEY,
|
|
18
|
+
user_id INTEGER NOT NULL,
|
|
19
|
+
amount DOUBLE NOT NULL,
|
|
20
|
+
dt DATE NOT NULL,
|
|
21
|
+
email VARCHAR,
|
|
22
|
+
phone VARCHAR,
|
|
23
|
+
status VARCHAR NOT NULL
|
|
24
|
+
)
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def count_sql(table: str, predicate: str | None) -> str:
|
|
29
|
+
"""Blast-radius estimate: COUNT(*) of the matching predicate."""
|
|
30
|
+
return _count_sql(table, predicate, backend=BACKEND_DUCKDB)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def connect(db_path: Path | None = None, *, read_only: bool = False) -> duckdb.DuckDBPyConnection:
|
|
34
|
+
path = Path(db_path) if db_path else DB_PATH
|
|
35
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
return duckdb.connect(str(path), read_only=read_only)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def execute_user_sql(conn: duckdb.DuckDBPyConnection, sql: str):
|
|
40
|
+
"""Run already-gated SQL. Called only by write_gate.wrapper.WriteGate."""
|
|
41
|
+
return conn.execute(sql)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""MySQL adapter. User SQL must not be executed from here; use WriteGate."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
from urllib.parse import unquote, urlparse
|
|
7
|
+
|
|
8
|
+
from write_gate.adapters.base import BACKEND_MYSQL, count_sql as _count_sql
|
|
9
|
+
|
|
10
|
+
DIALECT = "mysql"
|
|
11
|
+
BACKEND = BACKEND_MYSQL
|
|
12
|
+
|
|
13
|
+
ORDERS_DDL = """
|
|
14
|
+
CREATE TABLE orders (
|
|
15
|
+
order_id INTEGER PRIMARY KEY,
|
|
16
|
+
user_id INTEGER NOT NULL,
|
|
17
|
+
amount DOUBLE NOT NULL,
|
|
18
|
+
dt DATE NOT NULL,
|
|
19
|
+
email VARCHAR(255),
|
|
20
|
+
phone VARCHAR(64),
|
|
21
|
+
status VARCHAR(64) NOT NULL
|
|
22
|
+
)
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def count_sql(table: str, predicate: str | None) -> str:
|
|
27
|
+
"""Blast-radius estimate: COUNT(*) of the matching predicate (not EXPLAIN)."""
|
|
28
|
+
return _count_sql(table, predicate, backend=BACKEND_MYSQL)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def explain_sql(table: str, predicate: str | None) -> str:
|
|
32
|
+
"""Optional EXPLAIN form. Blast-radius uses count_sql() instead."""
|
|
33
|
+
return f"EXPLAIN {count_sql(table, predicate)}"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _parse_mysql_dsn(dsn: str) -> dict[str, Any]:
|
|
37
|
+
"""Parse mysql:// or mysql+pymysql:// into driver connect kwargs."""
|
|
38
|
+
parsed = urlparse(dsn.strip())
|
|
39
|
+
database = unquote(parsed.path.lstrip("/")) if parsed.path else ""
|
|
40
|
+
kwargs: dict[str, Any] = {
|
|
41
|
+
"host": parsed.hostname or "localhost",
|
|
42
|
+
"port": parsed.port or 3306,
|
|
43
|
+
"user": unquote(parsed.username) if parsed.username else None,
|
|
44
|
+
"password": unquote(parsed.password) if parsed.password is not None else "",
|
|
45
|
+
"database": database or None,
|
|
46
|
+
}
|
|
47
|
+
return {k: v for k, v in kwargs.items() if v is not None}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _connect_raw(dsn: str, **kwargs: Any) -> Any:
|
|
51
|
+
"""Open a driver connection. Tests may monkeypatch this."""
|
|
52
|
+
timeout = kwargs.pop("connect_timeout", 3)
|
|
53
|
+
params = _parse_mysql_dsn(dsn)
|
|
54
|
+
params.update(kwargs)
|
|
55
|
+
params.setdefault("connect_timeout", timeout)
|
|
56
|
+
try:
|
|
57
|
+
import pymysql
|
|
58
|
+
|
|
59
|
+
return pymysql.connect(**params)
|
|
60
|
+
except ImportError:
|
|
61
|
+
pass
|
|
62
|
+
try:
|
|
63
|
+
import mysql.connector
|
|
64
|
+
|
|
65
|
+
# mysql.connector uses connection_timeout, not connect_timeout.
|
|
66
|
+
if "connect_timeout" in params:
|
|
67
|
+
params["connection_timeout"] = params.pop("connect_timeout")
|
|
68
|
+
return mysql.connector.connect(**params)
|
|
69
|
+
except ImportError as exc:
|
|
70
|
+
raise ImportError(
|
|
71
|
+
"MySQL support requires pymysql. "
|
|
72
|
+
"Install with: pip install 'sql-write-gate[mysql]'"
|
|
73
|
+
) from exc
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class MySQLConnection:
|
|
77
|
+
"""DuckDB-like execute/fetchone surface over pymysql or mysql.connector."""
|
|
78
|
+
|
|
79
|
+
def __init__(self, raw: Any) -> None:
|
|
80
|
+
self._raw = raw
|
|
81
|
+
try:
|
|
82
|
+
self._raw.autocommit = True
|
|
83
|
+
except Exception:
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
def execute(self, sql: str):
|
|
87
|
+
cursor_fn = getattr(self._raw, "cursor", None)
|
|
88
|
+
if callable(cursor_fn):
|
|
89
|
+
cur = cursor_fn()
|
|
90
|
+
cur.execute(sql)
|
|
91
|
+
return cur
|
|
92
|
+
execute = getattr(self._raw, "execute", None)
|
|
93
|
+
if callable(execute):
|
|
94
|
+
return execute(sql)
|
|
95
|
+
raise RuntimeError("MySQL connection does not support execute/cursor")
|
|
96
|
+
|
|
97
|
+
def close(self) -> None:
|
|
98
|
+
close = getattr(self._raw, "close", None)
|
|
99
|
+
if callable(close):
|
|
100
|
+
close()
|
|
101
|
+
|
|
102
|
+
def commit(self) -> None:
|
|
103
|
+
commit = getattr(self._raw, "commit", None)
|
|
104
|
+
if callable(commit):
|
|
105
|
+
commit()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def connect(dsn: str, *, read_only: bool = False) -> MySQLConnection:
|
|
109
|
+
del read_only
|
|
110
|
+
return MySQLConnection(_connect_raw(dsn))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def execute_user_sql(conn: Any, sql: str):
|
|
114
|
+
"""Run already-gated SQL. Called only by write_gate.wrapper.WriteGate."""
|
|
115
|
+
return conn.execute(sql)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""PostgreSQL adapter. User SQL must not be executed from here; use WriteGate."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from write_gate.adapters.base import BACKEND_POSTGRES, count_sql as _count_sql
|
|
8
|
+
|
|
9
|
+
DIALECT = "postgres"
|
|
10
|
+
BACKEND = BACKEND_POSTGRES
|
|
11
|
+
|
|
12
|
+
ORDERS_DDL = """
|
|
13
|
+
CREATE TABLE orders (
|
|
14
|
+
order_id INTEGER PRIMARY KEY,
|
|
15
|
+
user_id INTEGER NOT NULL,
|
|
16
|
+
amount DOUBLE PRECISION NOT NULL,
|
|
17
|
+
dt DATE NOT NULL,
|
|
18
|
+
email VARCHAR,
|
|
19
|
+
phone VARCHAR,
|
|
20
|
+
status VARCHAR NOT NULL
|
|
21
|
+
)
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def count_sql(table: str, predicate: str | None) -> str:
|
|
26
|
+
"""Blast-radius estimate: COUNT(*) of the matching predicate (not EXPLAIN)."""
|
|
27
|
+
return _count_sql(table, predicate, backend=BACKEND_POSTGRES)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def explain_sql(table: str, predicate: str | None) -> str:
|
|
31
|
+
"""Optional EXPLAIN form. Blast-radius uses count_sql() instead."""
|
|
32
|
+
return f"EXPLAIN {count_sql(table, predicate)}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _connect_raw(dsn: str, **kwargs: Any) -> Any:
|
|
36
|
+
"""Open a driver connection. Tests may monkeypatch this."""
|
|
37
|
+
timeout = kwargs.pop("connect_timeout", 3)
|
|
38
|
+
try:
|
|
39
|
+
import psycopg
|
|
40
|
+
|
|
41
|
+
return psycopg.connect(dsn, autocommit=True, connect_timeout=timeout, **kwargs)
|
|
42
|
+
except ImportError:
|
|
43
|
+
pass
|
|
44
|
+
try:
|
|
45
|
+
import psycopg2
|
|
46
|
+
|
|
47
|
+
conn = psycopg2.connect(dsn, connect_timeout=timeout, **kwargs)
|
|
48
|
+
conn.autocommit = True
|
|
49
|
+
return conn
|
|
50
|
+
except ImportError as exc:
|
|
51
|
+
raise ImportError(
|
|
52
|
+
"PostgreSQL support requires psycopg. "
|
|
53
|
+
"Install with: pip install 'sql-write-gate[postgres]'"
|
|
54
|
+
) from exc
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class PostgresConnection:
|
|
58
|
+
"""DuckDB-like execute/fetchone surface over psycopg or psycopg2."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, raw: Any) -> None:
|
|
61
|
+
self._raw = raw
|
|
62
|
+
try:
|
|
63
|
+
self._raw.autocommit = True
|
|
64
|
+
except Exception:
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
def execute(self, sql: str):
|
|
68
|
+
cursor_fn = getattr(self._raw, "cursor", None)
|
|
69
|
+
if callable(cursor_fn):
|
|
70
|
+
cur = cursor_fn()
|
|
71
|
+
cur.execute(sql)
|
|
72
|
+
return cur
|
|
73
|
+
execute = getattr(self._raw, "execute", None)
|
|
74
|
+
if callable(execute):
|
|
75
|
+
return execute(sql)
|
|
76
|
+
raise RuntimeError("PostgreSQL connection does not support execute/cursor")
|
|
77
|
+
|
|
78
|
+
def close(self) -> None:
|
|
79
|
+
close = getattr(self._raw, "close", None)
|
|
80
|
+
if callable(close):
|
|
81
|
+
close()
|
|
82
|
+
|
|
83
|
+
def commit(self) -> None:
|
|
84
|
+
commit = getattr(self._raw, "commit", None)
|
|
85
|
+
if callable(commit):
|
|
86
|
+
commit()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def connect(dsn: str, *, read_only: bool = False) -> PostgresConnection:
|
|
90
|
+
del read_only
|
|
91
|
+
return PostgresConnection(_connect_raw(dsn))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def execute_user_sql(conn: Any, sql: str):
|
|
95
|
+
"""Run already-gated SQL. Called only by write_gate.wrapper.WriteGate."""
|
|
96
|
+
return conn.execute(sql)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""SQLite adapter. User SQL must not be executed from here; use WriteGate."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sqlite3
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
from urllib.parse import unquote
|
|
9
|
+
|
|
10
|
+
from write_gate.adapters.base import BACKEND_SQLITE, count_sql as _count_sql
|
|
11
|
+
|
|
12
|
+
DIALECT = "sqlite"
|
|
13
|
+
BACKEND = BACKEND_SQLITE
|
|
14
|
+
|
|
15
|
+
ORDERS_DDL = """
|
|
16
|
+
CREATE TABLE orders (
|
|
17
|
+
order_id INTEGER PRIMARY KEY,
|
|
18
|
+
user_id INTEGER NOT NULL,
|
|
19
|
+
amount REAL NOT NULL,
|
|
20
|
+
dt DATE NOT NULL,
|
|
21
|
+
email TEXT,
|
|
22
|
+
phone TEXT,
|
|
23
|
+
status TEXT NOT NULL
|
|
24
|
+
)
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def count_sql(table: str, predicate: str | None) -> str:
|
|
29
|
+
"""Blast-radius estimate: COUNT(*) of the matching predicate (not EXPLAIN)."""
|
|
30
|
+
return _count_sql(table, predicate, backend=BACKEND_SQLITE)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def explain_sql(table: str, predicate: str | None) -> str:
|
|
34
|
+
"""Optional EXPLAIN form. Blast-radius uses count_sql() instead."""
|
|
35
|
+
return f"EXPLAIN {count_sql(table, predicate)}"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _parse_sqlite_path(dsn: str) -> str:
|
|
39
|
+
"""Extract a filesystem path (or :memory:) from sqlite:/// / sqlite+aiosqlite://."""
|
|
40
|
+
raw = dsn.strip()
|
|
41
|
+
lower = raw.lower()
|
|
42
|
+
if lower.startswith("sqlite+aiosqlite://"):
|
|
43
|
+
rest = raw[len("sqlite+aiosqlite://") :]
|
|
44
|
+
elif lower.startswith("sqlite://"):
|
|
45
|
+
rest = raw[len("sqlite://") :]
|
|
46
|
+
else:
|
|
47
|
+
raise ValueError(f"not a sqlite URL: {dsn}")
|
|
48
|
+
|
|
49
|
+
if "?" in rest:
|
|
50
|
+
rest = rest.split("?", 1)[0]
|
|
51
|
+
rest = unquote(rest)
|
|
52
|
+
|
|
53
|
+
if rest in (":memory:", "/:memory:"):
|
|
54
|
+
return ":memory:"
|
|
55
|
+
# Four-slash absolute: //tmp/x.db -> /tmp/x.db
|
|
56
|
+
if rest.startswith("//"):
|
|
57
|
+
return rest[1:]
|
|
58
|
+
# Three-slash form: /tmp/x.db or /relative.db
|
|
59
|
+
if rest.startswith("/"):
|
|
60
|
+
return rest
|
|
61
|
+
return rest or ":memory:"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _connect_raw(dsn: str, **kwargs: Any) -> Any:
|
|
65
|
+
"""Open a stdlib sqlite3 connection. Tests may monkeypatch this."""
|
|
66
|
+
path = _parse_sqlite_path(dsn)
|
|
67
|
+
timeout = kwargs.pop("timeout", 3.0)
|
|
68
|
+
if path != ":memory:":
|
|
69
|
+
parent = Path(path).parent
|
|
70
|
+
if str(parent) not in ("", "."):
|
|
71
|
+
parent.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
try:
|
|
73
|
+
conn = sqlite3.connect(path, timeout=timeout, **kwargs)
|
|
74
|
+
except TypeError:
|
|
75
|
+
# Unexpected kwargs from callers; retry with path only.
|
|
76
|
+
conn = sqlite3.connect(path, timeout=timeout)
|
|
77
|
+
conn.isolation_level = None # autocommit-like
|
|
78
|
+
return conn
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class SQLiteConnection:
|
|
82
|
+
"""DuckDB-like execute/fetchone surface over stdlib sqlite3."""
|
|
83
|
+
|
|
84
|
+
def __init__(self, raw: Any) -> None:
|
|
85
|
+
self._raw = raw
|
|
86
|
+
|
|
87
|
+
def execute(self, sql: str):
|
|
88
|
+
cursor_fn = getattr(self._raw, "cursor", None)
|
|
89
|
+
if callable(cursor_fn):
|
|
90
|
+
cur = cursor_fn()
|
|
91
|
+
cur.execute(sql)
|
|
92
|
+
return cur
|
|
93
|
+
execute = getattr(self._raw, "execute", None)
|
|
94
|
+
if callable(execute):
|
|
95
|
+
return execute(sql)
|
|
96
|
+
raise RuntimeError("SQLite connection does not support execute/cursor")
|
|
97
|
+
|
|
98
|
+
def close(self) -> None:
|
|
99
|
+
close = getattr(self._raw, "close", None)
|
|
100
|
+
if callable(close):
|
|
101
|
+
close()
|
|
102
|
+
|
|
103
|
+
def commit(self) -> None:
|
|
104
|
+
commit = getattr(self._raw, "commit", None)
|
|
105
|
+
if callable(commit):
|
|
106
|
+
commit()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def connect(dsn: str, *, read_only: bool = False) -> SQLiteConnection:
|
|
110
|
+
del read_only
|
|
111
|
+
return SQLiteConnection(_connect_raw(dsn))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def execute_user_sql(conn: Any, sql: str):
|
|
115
|
+
"""Run already-gated SQL. Called only by write_gate.wrapper.WriteGate."""
|
|
116
|
+
return conn.execute(sql)
|
write_gate/approvals.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""JSONL approval queue. REQUIRE_APPROVAL SQL is recorded and not executed."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import uuid
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from write_gate.decision import Decision
|
|
13
|
+
from write_gate.paths import APPROVALS_PATH, LOG_DIR
|
|
14
|
+
|
|
15
|
+
STATUS_PENDING = "pending"
|
|
16
|
+
STATUS_APPROVED = "approved"
|
|
17
|
+
STATUS_REJECTED = "rejected"
|
|
18
|
+
|
|
19
|
+
_ID_LEN = 12
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ApprovalError(Exception):
|
|
23
|
+
"""Missing, not pending, or invalid approval record."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class ApprovalRecord:
|
|
28
|
+
id: str
|
|
29
|
+
status: str
|
|
30
|
+
sql: str
|
|
31
|
+
database: str | None = None
|
|
32
|
+
db_path: str | None = None
|
|
33
|
+
policy_path: str | None = None
|
|
34
|
+
catalog_path: str | None = None
|
|
35
|
+
created_at: str = ""
|
|
36
|
+
decision: dict[str, Any] = field(default_factory=dict)
|
|
37
|
+
backend: str | None = None
|
|
38
|
+
agent: str | None = None
|
|
39
|
+
|
|
40
|
+
def to_dict(self) -> dict[str, Any]:
|
|
41
|
+
return {
|
|
42
|
+
"id": self.id,
|
|
43
|
+
"status": self.status,
|
|
44
|
+
"sql": self.sql,
|
|
45
|
+
"database": self.database,
|
|
46
|
+
"db_path": self.db_path,
|
|
47
|
+
"policy_path": self.policy_path,
|
|
48
|
+
"catalog_path": self.catalog_path,
|
|
49
|
+
"created_at": self.created_at,
|
|
50
|
+
"decision": self.decision,
|
|
51
|
+
"backend": self.backend,
|
|
52
|
+
"agent": self.agent,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def from_dict(cls, raw: dict[str, Any]) -> "ApprovalRecord":
|
|
57
|
+
decision = raw.get("decision")
|
|
58
|
+
if not isinstance(decision, dict):
|
|
59
|
+
decision = {}
|
|
60
|
+
return cls(
|
|
61
|
+
id=str(raw.get("id") or ""),
|
|
62
|
+
status=str(raw.get("status") or STATUS_PENDING),
|
|
63
|
+
sql=str(raw.get("sql") or ""),
|
|
64
|
+
database=raw.get("database"),
|
|
65
|
+
db_path=raw.get("db_path"),
|
|
66
|
+
policy_path=raw.get("policy_path"),
|
|
67
|
+
catalog_path=raw.get("catalog_path"),
|
|
68
|
+
created_at=str(raw.get("created_at") or ""),
|
|
69
|
+
decision=decision,
|
|
70
|
+
backend=raw.get("backend"),
|
|
71
|
+
agent=raw.get("agent"),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _now() -> str:
|
|
76
|
+
return datetime.now(timezone.utc).isoformat()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _new_id() -> str:
|
|
80
|
+
return uuid.uuid4().hex[:_ID_LEN]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _path(path: Path | str | None = None) -> Path:
|
|
84
|
+
return Path(path) if path else APPROVALS_PATH
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _load(path: Path) -> dict[str, dict[str, Any]]:
|
|
88
|
+
if not path.exists():
|
|
89
|
+
return {}
|
|
90
|
+
records: dict[str, dict[str, Any]] = {}
|
|
91
|
+
try:
|
|
92
|
+
text = path.read_text(encoding="utf-8")
|
|
93
|
+
except OSError:
|
|
94
|
+
return {}
|
|
95
|
+
for line in text.splitlines():
|
|
96
|
+
line = line.strip()
|
|
97
|
+
if not line:
|
|
98
|
+
continue
|
|
99
|
+
try:
|
|
100
|
+
raw = json.loads(line)
|
|
101
|
+
except json.JSONDecodeError:
|
|
102
|
+
continue
|
|
103
|
+
if not isinstance(raw, dict):
|
|
104
|
+
continue
|
|
105
|
+
rec_id = str(raw.get("id") or "")
|
|
106
|
+
if rec_id:
|
|
107
|
+
records[rec_id] = raw
|
|
108
|
+
return records
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _save(path: Path, records: dict[str, dict[str, Any]]) -> None:
|
|
112
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
113
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
114
|
+
body = "".join(json.dumps(rec, ensure_ascii=False) + "\n" for rec in records.values())
|
|
115
|
+
tmp.write_text(body, encoding="utf-8")
|
|
116
|
+
tmp.replace(path)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def get_approval(approval_id: str, path: Path | str | None = None) -> ApprovalRecord | None:
|
|
120
|
+
dest = _path(path)
|
|
121
|
+
raw = _load(dest).get(str(approval_id))
|
|
122
|
+
if not raw:
|
|
123
|
+
return None
|
|
124
|
+
return ApprovalRecord.from_dict(raw)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def list_pending(path: Path | str | None = None) -> list[ApprovalRecord]:
|
|
128
|
+
dest = _path(path)
|
|
129
|
+
pending: list[ApprovalRecord] = []
|
|
130
|
+
for raw in _load(dest).values():
|
|
131
|
+
rec = ApprovalRecord.from_dict(raw)
|
|
132
|
+
if rec.status == STATUS_PENDING and rec.id:
|
|
133
|
+
pending.append(rec)
|
|
134
|
+
pending.sort(key=lambda r: r.created_at)
|
|
135
|
+
return pending
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def enqueue_approval(
|
|
139
|
+
*,
|
|
140
|
+
sql: str,
|
|
141
|
+
decision: Decision,
|
|
142
|
+
database: str | None = None,
|
|
143
|
+
db_path: str | None = None,
|
|
144
|
+
policy_path: str | None = None,
|
|
145
|
+
catalog_path: str | None = None,
|
|
146
|
+
path: Path | str | None = None,
|
|
147
|
+
backend: str | None = None,
|
|
148
|
+
agent: str | None = None,
|
|
149
|
+
) -> ApprovalRecord:
|
|
150
|
+
dest = _path(path)
|
|
151
|
+
records = _load(dest)
|
|
152
|
+
approval_id = _new_id()
|
|
153
|
+
while approval_id in records:
|
|
154
|
+
approval_id = _new_id()
|
|
155
|
+
rec = ApprovalRecord(
|
|
156
|
+
id=approval_id,
|
|
157
|
+
status=STATUS_PENDING,
|
|
158
|
+
sql=sql,
|
|
159
|
+
database=database,
|
|
160
|
+
db_path=db_path,
|
|
161
|
+
policy_path=str(policy_path) if policy_path else None,
|
|
162
|
+
catalog_path=str(catalog_path) if catalog_path else None,
|
|
163
|
+
created_at=_now(),
|
|
164
|
+
decision=decision.to_dict(),
|
|
165
|
+
backend=backend,
|
|
166
|
+
agent=agent,
|
|
167
|
+
)
|
|
168
|
+
# Snapshot includes approval_id once attached on the live decision.
|
|
169
|
+
rec.decision = {**rec.decision, "approval_id": approval_id}
|
|
170
|
+
records[approval_id] = rec.to_dict()
|
|
171
|
+
_save(dest, records)
|
|
172
|
+
return rec
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def set_status(
|
|
176
|
+
approval_id: str,
|
|
177
|
+
status: str,
|
|
178
|
+
path: Path | str | None = None,
|
|
179
|
+
) -> ApprovalRecord:
|
|
180
|
+
if status not in {STATUS_PENDING, STATUS_APPROVED, STATUS_REJECTED}:
|
|
181
|
+
raise ApprovalError(f"invalid approval status: {status}")
|
|
182
|
+
dest = _path(path)
|
|
183
|
+
records = _load(dest)
|
|
184
|
+
raw = records.get(str(approval_id))
|
|
185
|
+
if not raw:
|
|
186
|
+
raise ApprovalError(f"approval not found: {approval_id}")
|
|
187
|
+
rec = ApprovalRecord.from_dict(raw)
|
|
188
|
+
if rec.status != STATUS_PENDING:
|
|
189
|
+
raise ApprovalError(f"approval not pending: {approval_id}")
|
|
190
|
+
rec.status = status
|
|
191
|
+
records[rec.id] = rec.to_dict()
|
|
192
|
+
_save(dest, records)
|
|
193
|
+
return rec
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def mark_approved(approval_id: str, path: Path | str | None = None) -> ApprovalRecord:
|
|
197
|
+
return set_status(approval_id, STATUS_APPROVED, path=path)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def mark_rejected(approval_id: str, path: Path | str | None = None) -> ApprovalRecord:
|
|
201
|
+
return set_status(approval_id, STATUS_REJECTED, path=path)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def ensure_log_dir() -> None:
|
|
205
|
+
LOG_DIR.mkdir(parents=True, exist_ok=True)
|