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.
Files changed (42) hide show
  1. sql_write_gate-0.16.0.dist-info/METADATA +482 -0
  2. sql_write_gate-0.16.0.dist-info/RECORD +42 -0
  3. sql_write_gate-0.16.0.dist-info/WHEEL +5 -0
  4. sql_write_gate-0.16.0.dist-info/entry_points.txt +2 -0
  5. sql_write_gate-0.16.0.dist-info/licenses/LICENSE +21 -0
  6. sql_write_gate-0.16.0.dist-info/top_level.txt +1 -0
  7. write_gate/__init__.py +7 -0
  8. write_gate/__main__.py +6 -0
  9. write_gate/adapters/__init__.py +33 -0
  10. write_gate/adapters/base.py +101 -0
  11. write_gate/adapters/duckdb.py +41 -0
  12. write_gate/adapters/mysql.py +115 -0
  13. write_gate/adapters/postgres.py +96 -0
  14. write_gate/adapters/sqlite.py +116 -0
  15. write_gate/approvals.py +205 -0
  16. write_gate/audit.py +120 -0
  17. write_gate/cases.py +30 -0
  18. write_gate/catalog.py +79 -0
  19. write_gate/cli.py +397 -0
  20. write_gate/config.py +117 -0
  21. write_gate/db.py +5 -0
  22. write_gate/decision.py +143 -0
  23. write_gate/engine.py +150 -0
  24. write_gate/guards/__init__.py +17 -0
  25. write_gate/guards/blast_radius.py +73 -0
  26. write_gate/guards/destructive.py +85 -0
  27. write_gate/guards/environment.py +41 -0
  28. write_gate/guards/freshness.py +102 -0
  29. write_gate/guards/pii.py +84 -0
  30. write_gate/guards/schema.py +147 -0
  31. write_gate/hooks.py +360 -0
  32. write_gate/init.py +84 -0
  33. write_gate/mcp_server.py +71 -0
  34. write_gate/mcp_tools.py +197 -0
  35. write_gate/parser.py +367 -0
  36. write_gate/paths.py +20 -0
  37. write_gate/policy.py +49 -0
  38. write_gate/proxy.py +273 -0
  39. write_gate/templates/GETTING_STARTED.md +20 -0
  40. write_gate/templates/catalog.json +32 -0
  41. write_gate/templates/policy.yaml +12 -0
  42. 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()