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,20 @@
|
|
|
1
|
+
# sql-write-gate — getting started
|
|
2
|
+
|
|
3
|
+
```bash
|
|
4
|
+
pip install sql-write-gate # or from clone: pip install -e .
|
|
5
|
+
sql-write-gate init # already done if you see this file
|
|
6
|
+
sql-write-gate check "DELETE FROM orders"
|
|
7
|
+
# → BLOCKED rule=delete_without_where
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Optional warehouse:
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
# DuckDB file from a clone’s seed/ (or your own .duckdb):
|
|
14
|
+
sql-write-gate check --db path/to/warehouse.duckdb --catalog catalog.json "DELETE FROM orders"
|
|
15
|
+
|
|
16
|
+
# or URL (Postgres / MySQL / SQLite):
|
|
17
|
+
sql-write-gate check --database "$DATABASE_URL" "DELETE FROM orders"
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Edit `policy.yaml` and `catalog.json` here. Pass `--policy` / `--catalog` if needed.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"as_of_date": "2026-09-02",
|
|
3
|
+
"freshness_days": 7,
|
|
4
|
+
"tables": {
|
|
5
|
+
"orders": {
|
|
6
|
+
"writable": true,
|
|
7
|
+
"stale": false,
|
|
8
|
+
"partition_column": "dt",
|
|
9
|
+
"columns": {
|
|
10
|
+
"order_id": "INTEGER",
|
|
11
|
+
"user_id": "INTEGER",
|
|
12
|
+
"amount": "DOUBLE",
|
|
13
|
+
"dt": "DATE",
|
|
14
|
+
"email": "VARCHAR",
|
|
15
|
+
"phone": "VARCHAR",
|
|
16
|
+
"status": "VARCHAR"
|
|
17
|
+
},
|
|
18
|
+
"allowed_write_columns": [
|
|
19
|
+
"order_id",
|
|
20
|
+
"user_id",
|
|
21
|
+
"amount",
|
|
22
|
+
"dt",
|
|
23
|
+
"status"
|
|
24
|
+
],
|
|
25
|
+
"pii_columns": [
|
|
26
|
+
"email",
|
|
27
|
+
"phone"
|
|
28
|
+
],
|
|
29
|
+
"restricted_columns": []
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Production default. Screenshots and `sql-write-gate check` use this file.
|
|
2
|
+
# `make demo` three write cases pass examples/policy.demo.yaml so INSERT can ALLOW.
|
|
3
|
+
environment: production
|
|
4
|
+
rules:
|
|
5
|
+
select: allow
|
|
6
|
+
insert: approval
|
|
7
|
+
update: approval
|
|
8
|
+
delete: block
|
|
9
|
+
ddl: block
|
|
10
|
+
limits:
|
|
11
|
+
update_rows: 100
|
|
12
|
+
delete_rows: 50
|
write_gate/wrapper.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""The only SQL write tool. All INSERT/UPDATE/DELETE go through WriteGate.execute."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from write_gate.adapters.base import (
|
|
9
|
+
BACKEND_DUCKDB,
|
|
10
|
+
BACKEND_MYSQL,
|
|
11
|
+
BACKEND_POSTGRES,
|
|
12
|
+
BACKEND_SQLITE,
|
|
13
|
+
resolve_target,
|
|
14
|
+
)
|
|
15
|
+
from write_gate.approvals import (
|
|
16
|
+
ApprovalError,
|
|
17
|
+
enqueue_approval,
|
|
18
|
+
get_approval,
|
|
19
|
+
mark_approved,
|
|
20
|
+
mark_rejected,
|
|
21
|
+
)
|
|
22
|
+
from write_gate.audit import append_audit
|
|
23
|
+
from write_gate.catalog import Catalog, load_catalog
|
|
24
|
+
from write_gate.config import Policy, load_policy
|
|
25
|
+
from write_gate.decision import ACTION_ALLOW, ACTION_APPROVAL, Decision, Evidence
|
|
26
|
+
from write_gate.engine import evaluate
|
|
27
|
+
from write_gate.paths import APPROVALS_PATH, AUDIT_PATH, CATALOG_PATH, DB_PATH, POLICY_PATH
|
|
28
|
+
|
|
29
|
+
__all__ = ["WriteGate", "Evidence", "Decision"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class WriteGate:
|
|
33
|
+
"""Deterministic pre-write gate wrapping DuckDB, PostgreSQL, MySQL, or SQLite."""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
db_path: Path | str | None = None,
|
|
38
|
+
catalog_path: Path | None = None,
|
|
39
|
+
catalog: Catalog | None = None,
|
|
40
|
+
conn: Any | None = None,
|
|
41
|
+
policy_path: Path | None = None,
|
|
42
|
+
policy: Policy | None = None,
|
|
43
|
+
audit_path: Path | None = None,
|
|
44
|
+
approvals_path: Path | str | None = None,
|
|
45
|
+
agent: str = "cli",
|
|
46
|
+
database: str | None = None,
|
|
47
|
+
database_url: str | None = None,
|
|
48
|
+
) -> None:
|
|
49
|
+
backend, target = resolve_target(
|
|
50
|
+
database=database,
|
|
51
|
+
database_url=database_url,
|
|
52
|
+
db_path=db_path,
|
|
53
|
+
)
|
|
54
|
+
self.backend = backend
|
|
55
|
+
self.database = target
|
|
56
|
+
self.db_path = Path(target) if backend == BACKEND_DUCKDB else DB_PATH
|
|
57
|
+
self.catalog_path = Path(catalog_path) if catalog_path else CATALOG_PATH
|
|
58
|
+
self.catalog = catalog or load_catalog(self.catalog_path)
|
|
59
|
+
self.policy_path = Path(policy_path) if policy_path else POLICY_PATH
|
|
60
|
+
self.policy = policy or load_policy(self.policy_path)
|
|
61
|
+
self.audit_path = Path(audit_path) if audit_path else AUDIT_PATH
|
|
62
|
+
self.approvals_path = Path(approvals_path) if approvals_path else APPROVALS_PATH
|
|
63
|
+
self.agent = agent
|
|
64
|
+
self._conn = conn
|
|
65
|
+
self._owns_conn = conn is None
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def conn(self) -> Any:
|
|
69
|
+
if self._conn is None:
|
|
70
|
+
self._conn = self._connect()
|
|
71
|
+
return self._conn
|
|
72
|
+
|
|
73
|
+
def _connect(self) -> Any:
|
|
74
|
+
if self.backend == BACKEND_POSTGRES:
|
|
75
|
+
from write_gate.adapters.postgres import connect as pg_connect
|
|
76
|
+
|
|
77
|
+
return pg_connect(self.database)
|
|
78
|
+
if self.backend == BACKEND_MYSQL:
|
|
79
|
+
from write_gate.adapters.mysql import connect as mysql_connect
|
|
80
|
+
|
|
81
|
+
return mysql_connect(self.database)
|
|
82
|
+
if self.backend == BACKEND_SQLITE:
|
|
83
|
+
from write_gate.adapters.sqlite import connect as sqlite_connect
|
|
84
|
+
|
|
85
|
+
return sqlite_connect(self.database)
|
|
86
|
+
from write_gate.adapters.duckdb import connect as duck_connect
|
|
87
|
+
|
|
88
|
+
return duck_connect(self.db_path)
|
|
89
|
+
|
|
90
|
+
def _conn_if_available(self) -> Any | None:
|
|
91
|
+
if self._conn is not None:
|
|
92
|
+
return self._conn
|
|
93
|
+
if self.backend == BACKEND_DUCKDB and self.db_path.exists():
|
|
94
|
+
return self.conn
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
def _conn_for_execute(self) -> Any | None:
|
|
98
|
+
"""Prefer a live connection for blast-radius; AST guards still run if connect fails."""
|
|
99
|
+
if self._conn is not None:
|
|
100
|
+
return self._conn
|
|
101
|
+
try:
|
|
102
|
+
return self.conn
|
|
103
|
+
except Exception:
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
def _evaluate(self, sql: str, *, use_conn: bool) -> Decision:
|
|
107
|
+
if use_conn:
|
|
108
|
+
conn = self._conn_for_execute()
|
|
109
|
+
else:
|
|
110
|
+
conn = self._conn_if_available()
|
|
111
|
+
return evaluate(
|
|
112
|
+
sql,
|
|
113
|
+
self.catalog,
|
|
114
|
+
policy=self.policy,
|
|
115
|
+
conn=conn,
|
|
116
|
+
dialect=self.backend,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
def _audit(self, decision: Decision) -> None:
|
|
120
|
+
append_audit(
|
|
121
|
+
decision,
|
|
122
|
+
agent=self.agent,
|
|
123
|
+
environment=self.policy.environment,
|
|
124
|
+
path=self.audit_path,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def check(self, sql: str) -> Decision:
|
|
128
|
+
decision = self._evaluate(sql, use_conn=False)
|
|
129
|
+
self._audit(decision)
|
|
130
|
+
return decision
|
|
131
|
+
|
|
132
|
+
def execute(self, sql: str) -> tuple[Decision, Any]:
|
|
133
|
+
"""Gate then (only if ALLOW) run SQL via the single adapter write path.
|
|
134
|
+
|
|
135
|
+
REQUIRE_APPROVAL is enqueued and not executed. BLOCK is not queued
|
|
136
|
+
and not executed. check() stays evaluate-only (no enqueue).
|
|
137
|
+
"""
|
|
138
|
+
decision = self._evaluate(sql, use_conn=True)
|
|
139
|
+
if decision.action == ACTION_APPROVAL:
|
|
140
|
+
rec = enqueue_approval(
|
|
141
|
+
sql=sql,
|
|
142
|
+
decision=decision,
|
|
143
|
+
database=self.database,
|
|
144
|
+
db_path=str(self.db_path) if self.backend == BACKEND_DUCKDB else None,
|
|
145
|
+
policy_path=str(self.policy_path) if self.policy_path else None,
|
|
146
|
+
catalog_path=str(self.catalog_path) if self.catalog_path else None,
|
|
147
|
+
path=self.approvals_path,
|
|
148
|
+
backend=self.backend,
|
|
149
|
+
agent=self.agent,
|
|
150
|
+
)
|
|
151
|
+
decision.approval_id = rec.id
|
|
152
|
+
rec.decision = decision.to_dict()
|
|
153
|
+
self._audit(decision)
|
|
154
|
+
return decision, None
|
|
155
|
+
self._audit(decision)
|
|
156
|
+
if decision.action != ACTION_ALLOW:
|
|
157
|
+
return decision, None
|
|
158
|
+
result = self._execute_user_sql(sql)
|
|
159
|
+
return decision, result
|
|
160
|
+
|
|
161
|
+
def approve(self, approval_id: str) -> tuple[Decision, Any]:
|
|
162
|
+
"""Load a pending id, re-run guards, execute if ALLOW after clearing env approval."""
|
|
163
|
+
rec = get_approval(approval_id, path=self.approvals_path)
|
|
164
|
+
if rec is None or rec.status != "pending":
|
|
165
|
+
raise ApprovalError(f"approval not found or not pending: {approval_id}")
|
|
166
|
+
saved_policy = self.policy
|
|
167
|
+
try:
|
|
168
|
+
self.policy = saved_policy.with_env_approvals_cleared()
|
|
169
|
+
decision = self._evaluate(rec.sql, use_conn=True)
|
|
170
|
+
finally:
|
|
171
|
+
self.policy = saved_policy
|
|
172
|
+
decision.approval_id = rec.id
|
|
173
|
+
if decision.action != ACTION_ALLOW:
|
|
174
|
+
self._audit(decision)
|
|
175
|
+
return decision, None
|
|
176
|
+
result = self._execute_user_sql(rec.sql)
|
|
177
|
+
mark_approved(rec.id, path=self.approvals_path)
|
|
178
|
+
self._audit(decision)
|
|
179
|
+
return decision, result
|
|
180
|
+
|
|
181
|
+
def reject(self, approval_id: str) -> None:
|
|
182
|
+
"""Mark pending id rejected. Does not write."""
|
|
183
|
+
mark_rejected(approval_id, path=self.approvals_path)
|
|
184
|
+
|
|
185
|
+
def _execute_user_sql(self, sql: str):
|
|
186
|
+
if self.backend == BACKEND_POSTGRES:
|
|
187
|
+
from write_gate.adapters.postgres import execute_user_sql as exec_sql
|
|
188
|
+
elif self.backend == BACKEND_MYSQL:
|
|
189
|
+
from write_gate.adapters.mysql import execute_user_sql as exec_sql
|
|
190
|
+
elif self.backend == BACKEND_SQLITE:
|
|
191
|
+
from write_gate.adapters.sqlite import execute_user_sql as exec_sql
|
|
192
|
+
else:
|
|
193
|
+
from write_gate.adapters.duckdb import execute_user_sql as exec_sql
|
|
194
|
+
return exec_sql(self.conn, sql)
|
|
195
|
+
|
|
196
|
+
def close(self) -> None:
|
|
197
|
+
if self._owns_conn and self._conn is not None:
|
|
198
|
+
close = getattr(self._conn, "close", None)
|
|
199
|
+
if callable(close):
|
|
200
|
+
close()
|
|
201
|
+
self._conn = None
|
|
202
|
+
|
|
203
|
+
def __enter__(self) -> "WriteGate":
|
|
204
|
+
return self
|
|
205
|
+
|
|
206
|
+
def __exit__(self, *exc: object) -> None:
|
|
207
|
+
self.close()
|