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
write_gate/parser.py
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
"""Parse a single SQL statement into a structured form (sqlglot AST, no LLM)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import date, datetime
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import sqlglot
|
|
11
|
+
from sqlglot import exp
|
|
12
|
+
|
|
13
|
+
from write_gate.decision import RULE_SCHEMA
|
|
14
|
+
|
|
15
|
+
_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
16
|
+
|
|
17
|
+
WRITE_TYPE_NAMES = (
|
|
18
|
+
"Insert",
|
|
19
|
+
"Update",
|
|
20
|
+
"Delete",
|
|
21
|
+
"Merge",
|
|
22
|
+
"Create",
|
|
23
|
+
"Drop",
|
|
24
|
+
"Alter",
|
|
25
|
+
"AlterTable",
|
|
26
|
+
"Command",
|
|
27
|
+
"Copy",
|
|
28
|
+
"TruncateTable",
|
|
29
|
+
"Replace",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
DDL_TYPE_NAMES = (
|
|
33
|
+
"Create",
|
|
34
|
+
"Drop",
|
|
35
|
+
"Alter",
|
|
36
|
+
"AlterTable",
|
|
37
|
+
"TruncateTable",
|
|
38
|
+
"Command",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class ParsedSQL:
|
|
44
|
+
sql: str
|
|
45
|
+
statement: exp.Expression | None = None
|
|
46
|
+
operation: str = "unknown" # select | insert | update | delete | ddl | unknown
|
|
47
|
+
table: str | None = None
|
|
48
|
+
columns: list[str] = field(default_factory=list)
|
|
49
|
+
write_columns: list[str] = field(default_factory=list)
|
|
50
|
+
select_columns: list[str] = field(default_factory=list)
|
|
51
|
+
star: bool = False
|
|
52
|
+
where: exp.Expression | None = None
|
|
53
|
+
has_where: bool = False
|
|
54
|
+
assignments: dict[str, exp.Expression | None] = field(default_factory=dict)
|
|
55
|
+
insert_rows: list[list[exp.Expression]] | None = None
|
|
56
|
+
error: str | None = None
|
|
57
|
+
error_rule: str = RULE_SCHEMA
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def ident(node: exp.Expression | None) -> str | None:
|
|
61
|
+
if node is None:
|
|
62
|
+
return None
|
|
63
|
+
if isinstance(node, exp.Table):
|
|
64
|
+
return node.name.lower() if node.name else None
|
|
65
|
+
if isinstance(node, exp.Schema):
|
|
66
|
+
return ident(node.this)
|
|
67
|
+
if isinstance(node, exp.Identifier):
|
|
68
|
+
return node.name.lower()
|
|
69
|
+
if isinstance(node, exp.Column):
|
|
70
|
+
return node.name.lower() if node.name else None
|
|
71
|
+
name = getattr(node, "name", None)
|
|
72
|
+
return str(name).lower() if name else None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def literal_value(node: exp.Expression | None) -> Any:
|
|
76
|
+
if node is None:
|
|
77
|
+
return None
|
|
78
|
+
if isinstance(node, exp.Null):
|
|
79
|
+
return None
|
|
80
|
+
if isinstance(node, exp.Cast):
|
|
81
|
+
return literal_value(node.this)
|
|
82
|
+
if isinstance(node, (exp.TsOrDsToDate, exp.Date)):
|
|
83
|
+
return literal_value(node.this) if node.this else node.sql()
|
|
84
|
+
if isinstance(node, exp.Literal):
|
|
85
|
+
raw = node.this
|
|
86
|
+
if node.is_int:
|
|
87
|
+
try:
|
|
88
|
+
return int(raw)
|
|
89
|
+
except (TypeError, ValueError):
|
|
90
|
+
return raw
|
|
91
|
+
if node.is_number:
|
|
92
|
+
try:
|
|
93
|
+
return float(raw)
|
|
94
|
+
except (TypeError, ValueError):
|
|
95
|
+
return raw
|
|
96
|
+
return str(raw)
|
|
97
|
+
sql = node.sql(dialect="duckdb").strip().strip("'\"")
|
|
98
|
+
return sql
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def expected_kind(col_type: str) -> str:
|
|
102
|
+
t = col_type.upper()
|
|
103
|
+
if t in {"INTEGER", "INT", "BIGINT", "SMALLINT", "TINYINT"}:
|
|
104
|
+
return "INTEGER"
|
|
105
|
+
if t in {"DOUBLE", "FLOAT", "REAL", "DECIMAL", "NUMERIC"}:
|
|
106
|
+
return "DOUBLE"
|
|
107
|
+
if t in {"DATE", "TIMESTAMP"}:
|
|
108
|
+
return "DATE"
|
|
109
|
+
return "VARCHAR"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def type_ok(expected: str, node: exp.Expression | None) -> bool:
|
|
113
|
+
if node is None or isinstance(node, exp.Null):
|
|
114
|
+
return True
|
|
115
|
+
kind = expected_kind(expected)
|
|
116
|
+
value = literal_value(node)
|
|
117
|
+
if kind == "INTEGER":
|
|
118
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
119
|
+
if kind == "DOUBLE":
|
|
120
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
121
|
+
if kind == "DATE":
|
|
122
|
+
text = str(value)
|
|
123
|
+
if not _DATE_RE.match(text):
|
|
124
|
+
return False
|
|
125
|
+
try:
|
|
126
|
+
date.fromisoformat(text)
|
|
127
|
+
return True
|
|
128
|
+
except ValueError:
|
|
129
|
+
return False
|
|
130
|
+
return True
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def parse_date(value: Any) -> date | None:
|
|
134
|
+
if value is None:
|
|
135
|
+
return None
|
|
136
|
+
if isinstance(value, date) and not isinstance(value, datetime):
|
|
137
|
+
return value
|
|
138
|
+
text = str(value)
|
|
139
|
+
if not _DATE_RE.match(text):
|
|
140
|
+
return None
|
|
141
|
+
try:
|
|
142
|
+
return date.fromisoformat(text)
|
|
143
|
+
except ValueError:
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _write_types() -> tuple[type, ...]:
|
|
148
|
+
return tuple(getattr(exp, n) for n in WRITE_TYPE_NAMES if hasattr(exp, n))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _ddl_types() -> tuple[type, ...]:
|
|
152
|
+
return tuple(getattr(exp, n) for n in DDL_TYPE_NAMES if hasattr(exp, n))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def is_read_only(stmt: exp.Expression) -> bool:
|
|
156
|
+
write_types = _write_types()
|
|
157
|
+
if write_types and isinstance(stmt, write_types):
|
|
158
|
+
return False
|
|
159
|
+
if isinstance(stmt, (exp.Select, exp.Union, exp.Except, exp.Intersect)):
|
|
160
|
+
return True
|
|
161
|
+
return isinstance(stmt, exp.Query) and not isinstance(stmt, write_types)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def classify_operation(stmt: exp.Expression) -> str:
|
|
165
|
+
if isinstance(stmt, exp.Insert):
|
|
166
|
+
return "insert"
|
|
167
|
+
if isinstance(stmt, exp.Update):
|
|
168
|
+
return "update"
|
|
169
|
+
if isinstance(stmt, exp.Delete):
|
|
170
|
+
return "delete"
|
|
171
|
+
ddl_types = _ddl_types()
|
|
172
|
+
if ddl_types and isinstance(stmt, ddl_types):
|
|
173
|
+
return "ddl"
|
|
174
|
+
if isinstance(stmt, exp.Merge):
|
|
175
|
+
return "ddl"
|
|
176
|
+
if is_read_only(stmt):
|
|
177
|
+
return "select"
|
|
178
|
+
return "ddl"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _table_from_select(stmt: exp.Expression) -> str | None:
|
|
182
|
+
from_ = stmt.args.get("from_") or stmt.args.get("from")
|
|
183
|
+
if from_ is None:
|
|
184
|
+
return None
|
|
185
|
+
this = from_.this if isinstance(from_, exp.From) else from_
|
|
186
|
+
return ident(this)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _table_from_truncate(stmt: exp.Expression) -> str | None:
|
|
190
|
+
for item in stmt.expressions or []:
|
|
191
|
+
name = ident(item)
|
|
192
|
+
if name:
|
|
193
|
+
return name
|
|
194
|
+
return ident(stmt.this)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def extract_table(stmt: exp.Expression) -> str | None:
|
|
198
|
+
if isinstance(stmt, exp.TruncateTable) or type(stmt).__name__ == "TruncateTable":
|
|
199
|
+
return _table_from_truncate(stmt)
|
|
200
|
+
if isinstance(stmt, (exp.Select, exp.Union, exp.Except, exp.Intersect)):
|
|
201
|
+
return _table_from_select(stmt)
|
|
202
|
+
if isinstance(stmt, exp.Query):
|
|
203
|
+
return _table_from_select(stmt)
|
|
204
|
+
return ident(stmt.this)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def extract_where(stmt: exp.Expression) -> exp.Expression | None:
|
|
208
|
+
where = stmt.args.get("where")
|
|
209
|
+
if where is None:
|
|
210
|
+
return None
|
|
211
|
+
if isinstance(where, exp.Where):
|
|
212
|
+
return where.this
|
|
213
|
+
return where
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def where_sql(where: exp.Expression | None, dialect: str = "duckdb") -> str | None:
|
|
217
|
+
if where is None:
|
|
218
|
+
return None
|
|
219
|
+
if isinstance(where, exp.Where):
|
|
220
|
+
return where.this.sql(dialect=dialect) if where.this else None
|
|
221
|
+
return where.sql(dialect=dialect)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _select_columns(stmt: exp.Expression) -> tuple[list[str], bool]:
|
|
225
|
+
cols: list[str] = []
|
|
226
|
+
star = False
|
|
227
|
+
expressions = stmt.expressions or []
|
|
228
|
+
for item in expressions:
|
|
229
|
+
if isinstance(item, exp.Star):
|
|
230
|
+
star = True
|
|
231
|
+
continue
|
|
232
|
+
if isinstance(item, exp.Alias):
|
|
233
|
+
name = ident(item.this) or ident(item)
|
|
234
|
+
else:
|
|
235
|
+
name = ident(item)
|
|
236
|
+
if name and name != "*":
|
|
237
|
+
cols.append(name)
|
|
238
|
+
elif isinstance(item, exp.Star) or (name == "*"):
|
|
239
|
+
star = True
|
|
240
|
+
return cols, star
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def insert_columns(stmt: exp.Insert, fallback: list[str] | None = None) -> list[str]:
|
|
244
|
+
target = stmt.this
|
|
245
|
+
if isinstance(target, exp.Schema) and target.expressions:
|
|
246
|
+
return [ident(c) or "" for c in target.expressions]
|
|
247
|
+
return list(fallback or [])
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def insert_rows(stmt: exp.Insert) -> list[list[exp.Expression]] | None:
|
|
251
|
+
values = stmt.expression
|
|
252
|
+
if isinstance(values, exp.Values):
|
|
253
|
+
rows: list[list[exp.Expression]] = []
|
|
254
|
+
for tup in values.expressions:
|
|
255
|
+
if isinstance(tup, exp.Tuple):
|
|
256
|
+
rows.append(list(tup.expressions))
|
|
257
|
+
else:
|
|
258
|
+
rows.append([tup])
|
|
259
|
+
return rows
|
|
260
|
+
return None
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def update_assignments(stmt: exp.Update) -> dict[str, exp.Expression | None]:
|
|
264
|
+
out: dict[str, exp.Expression | None] = {}
|
|
265
|
+
for item in stmt.expressions:
|
|
266
|
+
if isinstance(item, exp.EQ):
|
|
267
|
+
name = ident(item.this)
|
|
268
|
+
if name:
|
|
269
|
+
out[name] = item.expression
|
|
270
|
+
return out
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def partition_dates_from_where(
|
|
274
|
+
where: exp.Expression | None, part: str | None
|
|
275
|
+
) -> list[date | None]:
|
|
276
|
+
if where is None or not part:
|
|
277
|
+
return []
|
|
278
|
+
dates: list[date | None] = []
|
|
279
|
+
|
|
280
|
+
def visit(node: exp.Expression) -> None:
|
|
281
|
+
if isinstance(node, exp.In) and ident(node.this) == part:
|
|
282
|
+
for item in node.expressions:
|
|
283
|
+
dates.append(parse_date(literal_value(item)))
|
|
284
|
+
return
|
|
285
|
+
if isinstance(node, exp.EQ):
|
|
286
|
+
left, right = node.this, node.expression
|
|
287
|
+
if ident(left) == part:
|
|
288
|
+
dates.append(parse_date(literal_value(right)))
|
|
289
|
+
elif ident(right) == part:
|
|
290
|
+
dates.append(parse_date(literal_value(left)))
|
|
291
|
+
return
|
|
292
|
+
for child in node.iter_expressions():
|
|
293
|
+
visit(child)
|
|
294
|
+
|
|
295
|
+
visit(where)
|
|
296
|
+
return dates
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def partition_dates_from_insert(
|
|
300
|
+
cols: list[str], rows: list[list[exp.Expression]], part: str | None
|
|
301
|
+
) -> list[date | None]:
|
|
302
|
+
if not part:
|
|
303
|
+
return []
|
|
304
|
+
dates: list[date | None] = []
|
|
305
|
+
for row in rows:
|
|
306
|
+
assignments = dict(zip(cols, row))
|
|
307
|
+
if part not in assignments:
|
|
308
|
+
dates.append(None)
|
|
309
|
+
else:
|
|
310
|
+
dates.append(parse_date(literal_value(assignments[part])))
|
|
311
|
+
return dates
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def parse(sql: str, dialect: str = "duckdb") -> ParsedSQL:
|
|
315
|
+
original = sql
|
|
316
|
+
stripped = sql.strip().rstrip(";").strip()
|
|
317
|
+
parsed = ParsedSQL(sql=original)
|
|
318
|
+
if not stripped:
|
|
319
|
+
parsed.error = "SQL 为空,无法执行"
|
|
320
|
+
return parsed
|
|
321
|
+
|
|
322
|
+
read_dialect = dialect or "duckdb"
|
|
323
|
+
try:
|
|
324
|
+
statements = sqlglot.parse(stripped, read=read_dialect)
|
|
325
|
+
except sqlglot.errors.ParseError as exc:
|
|
326
|
+
if read_dialect != "duckdb":
|
|
327
|
+
try:
|
|
328
|
+
statements = sqlglot.parse(stripped, read="duckdb")
|
|
329
|
+
except sqlglot.errors.ParseError as exc2:
|
|
330
|
+
parsed.error = f"SQL 无法解析: {exc2}"
|
|
331
|
+
return parsed
|
|
332
|
+
else:
|
|
333
|
+
parsed.error = f"SQL 无法解析: {exc}"
|
|
334
|
+
return parsed
|
|
335
|
+
|
|
336
|
+
statements = [s for s in statements if s is not None]
|
|
337
|
+
if not statements:
|
|
338
|
+
parsed.error = "SQL 无法解析为空语句"
|
|
339
|
+
return parsed
|
|
340
|
+
if len(statements) != 1:
|
|
341
|
+
parsed.error = f"一次只允许一条语句,收到 {len(statements)} 条"
|
|
342
|
+
return parsed
|
|
343
|
+
|
|
344
|
+
stmt = statements[0]
|
|
345
|
+
parsed.statement = stmt
|
|
346
|
+
parsed.operation = classify_operation(stmt)
|
|
347
|
+
parsed.table = extract_table(stmt)
|
|
348
|
+
parsed.where = extract_where(stmt)
|
|
349
|
+
parsed.has_where = parsed.where is not None
|
|
350
|
+
|
|
351
|
+
if isinstance(stmt, exp.Insert):
|
|
352
|
+
cols = insert_columns(stmt)
|
|
353
|
+
parsed.write_columns = cols
|
|
354
|
+
parsed.columns = list(cols)
|
|
355
|
+
parsed.insert_rows = insert_rows(stmt)
|
|
356
|
+
parsed.assignments = {}
|
|
357
|
+
elif isinstance(stmt, exp.Update):
|
|
358
|
+
assignments = update_assignments(stmt)
|
|
359
|
+
parsed.assignments = assignments
|
|
360
|
+
parsed.write_columns = list(assignments.keys())
|
|
361
|
+
parsed.columns = list(assignments.keys())
|
|
362
|
+
elif is_read_only(stmt):
|
|
363
|
+
cols, star = _select_columns(stmt)
|
|
364
|
+
parsed.select_columns = cols
|
|
365
|
+
parsed.columns = cols
|
|
366
|
+
parsed.star = star
|
|
367
|
+
return parsed
|
write_gate/paths.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Project paths. Warehouse and catalog live under seed/."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
PACKAGE_DIR = Path(__file__).resolve().parent
|
|
6
|
+
PROJECT_ROOT = PACKAGE_DIR.parents[1]
|
|
7
|
+
SEED_DIR = PROJECT_ROOT / "seed"
|
|
8
|
+
EXAMPLES_DIR = PROJECT_ROOT / "examples"
|
|
9
|
+
LOG_DIR = PROJECT_ROOT / ".logs"
|
|
10
|
+
|
|
11
|
+
DB_PATH = SEED_DIR / "warehouse.duckdb"
|
|
12
|
+
CATALOG_PATH = SEED_DIR / "catalog.json"
|
|
13
|
+
ORDERS_CSV = SEED_DIR / "orders.csv"
|
|
14
|
+
|
|
15
|
+
POLICY_PATH = PROJECT_ROOT / "policy.yaml"
|
|
16
|
+
EXAMPLES_POLICY_PATH = EXAMPLES_DIR / "policy.yaml"
|
|
17
|
+
DEMO_POLICY_PATH = EXAMPLES_DIR / "policy.demo.yaml"
|
|
18
|
+
EXAMPLES_CATALOG_PATH = EXAMPLES_DIR / "catalog.json"
|
|
19
|
+
AUDIT_PATH = LOG_DIR / "audit.jsonl"
|
|
20
|
+
APPROVALS_PATH = LOG_DIR / "approvals.jsonl"
|
write_gate/policy.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Backward-compatible evaluate() entry point. Engine + guards do the real work."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from write_gate.catalog import Catalog
|
|
8
|
+
from write_gate.config import Policy, demo_policy
|
|
9
|
+
from write_gate.decision import (
|
|
10
|
+
RULE_EXPIRED,
|
|
11
|
+
RULE_OK,
|
|
12
|
+
RULE_PII,
|
|
13
|
+
RULE_SCHEMA,
|
|
14
|
+
Decision,
|
|
15
|
+
Evidence,
|
|
16
|
+
)
|
|
17
|
+
from write_gate.engine import evaluate as engine_evaluate
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"Evidence",
|
|
21
|
+
"Decision",
|
|
22
|
+
"evaluate",
|
|
23
|
+
"RULE_OK",
|
|
24
|
+
"RULE_PII",
|
|
25
|
+
"RULE_EXPIRED",
|
|
26
|
+
"RULE_SCHEMA",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def evaluate(
|
|
31
|
+
sql: str,
|
|
32
|
+
catalog: Catalog,
|
|
33
|
+
policy: Policy | None = None,
|
|
34
|
+
conn: Any | None = None,
|
|
35
|
+
dialect: str = "duckdb",
|
|
36
|
+
) -> Decision:
|
|
37
|
+
"""Return a gate verdict for a single SQL string.
|
|
38
|
+
|
|
39
|
+
Library callers (tests / demo helpers) default to the demo policy so
|
|
40
|
+
existing legal-INSERT cases stay ALLOW. The CLI loads production
|
|
41
|
+
policy.yaml instead.
|
|
42
|
+
"""
|
|
43
|
+
return engine_evaluate(
|
|
44
|
+
sql,
|
|
45
|
+
catalog,
|
|
46
|
+
policy=policy or demo_policy(),
|
|
47
|
+
conn=conn,
|
|
48
|
+
dialect=dialect,
|
|
49
|
+
)
|
write_gate/proxy.py
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""SQL proxy in front of a real DB. Incoming SQL goes through WriteGate first.
|
|
2
|
+
|
|
3
|
+
ALLOW then execute. BLOCK and REQUIRE_APPROVAL do not write. Uses
|
|
4
|
+
WriteGate.execute only (never raw DuckDB). DuckDB is fully testable without
|
|
5
|
+
a Postgres server; postgres:// / postgresql:// URLs take the same path.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import socket
|
|
12
|
+
import sys
|
|
13
|
+
from typing import Any, Callable, TextIO
|
|
14
|
+
|
|
15
|
+
from write_gate.decision import ACTION_ALLOW, ACTION_APPROVAL, ACTION_BLOCK, Decision
|
|
16
|
+
from write_gate.wrapper import WriteGate
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"handle_sql",
|
|
20
|
+
"format_proxy",
|
|
21
|
+
"exit_code",
|
|
22
|
+
"split_statements",
|
|
23
|
+
"parse_listen_addr",
|
|
24
|
+
"serve_listen",
|
|
25
|
+
"handle_connection",
|
|
26
|
+
"run_cli",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def handle_sql(gate: WriteGate, sql: str) -> tuple[Decision, Any]:
|
|
31
|
+
"""Gate one statement via WriteGate.execute (never raw DuckDB).
|
|
32
|
+
|
|
33
|
+
ALLOW executes. BLOCK and REQUIRE_APPROVAL return ``result=None``.
|
|
34
|
+
"""
|
|
35
|
+
return gate.execute(sql)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def exit_code(decision: Decision) -> int:
|
|
39
|
+
"""Same as ``check``: 0 ALLOW, 1 REQUIRE_APPROVAL, 2 BLOCK."""
|
|
40
|
+
if decision.action == ACTION_ALLOW:
|
|
41
|
+
return 0
|
|
42
|
+
if decision.action == ACTION_APPROVAL:
|
|
43
|
+
return 1
|
|
44
|
+
return 2
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _headline(action: str) -> str:
|
|
48
|
+
if action == ACTION_BLOCK:
|
|
49
|
+
return "BLOCKED"
|
|
50
|
+
if action == ACTION_APPROVAL:
|
|
51
|
+
return "APPROVAL REQUIRED"
|
|
52
|
+
return "ALLOWED"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _safe(value: object) -> str:
|
|
56
|
+
if value is None or value == "":
|
|
57
|
+
return "-"
|
|
58
|
+
return str(value)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _executed(decision: Decision, result: Any) -> bool:
|
|
62
|
+
return decision.action == ACTION_ALLOW and result is not None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def format_proxy(decision: Decision, result: Any = None, *, as_json: bool = False) -> str:
|
|
66
|
+
"""Human or JSON text for one proxy decision. Always includes executed."""
|
|
67
|
+
executed = _executed(decision, result)
|
|
68
|
+
if as_json:
|
|
69
|
+
payload = decision.to_dict()
|
|
70
|
+
payload["executed"] = executed
|
|
71
|
+
if executed:
|
|
72
|
+
try:
|
|
73
|
+
rows = result.fetchall()
|
|
74
|
+
payload["rows"] = [list(r) for r in rows]
|
|
75
|
+
payload["rowcount"] = len(payload["rows"])
|
|
76
|
+
except Exception:
|
|
77
|
+
payload["rowcount"] = getattr(result, "rowcount", None)
|
|
78
|
+
return json.dumps(payload, ensure_ascii=False, indent=2)
|
|
79
|
+
lines = [
|
|
80
|
+
_headline(decision.action),
|
|
81
|
+
f"Risk: {_safe(decision.risk)}",
|
|
82
|
+
f"Operation: {_safe(decision.operation).upper()}",
|
|
83
|
+
f"Table: {_safe(decision.table)}",
|
|
84
|
+
f"Rule: {_safe(decision.rule_id)}",
|
|
85
|
+
f"Reason: {_safe(decision.reason)}",
|
|
86
|
+
f"executed: {'yes' if executed else 'no'}",
|
|
87
|
+
]
|
|
88
|
+
if decision.approval_id:
|
|
89
|
+
lines.append(f"Approval id: {decision.approval_id}")
|
|
90
|
+
return "\n".join(lines)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def split_statements(text: str, *, once: bool = False) -> list[str]:
|
|
94
|
+
"""Split stdin into SQL statements: one SQL, or one per non-empty line."""
|
|
95
|
+
raw = (text or "").strip()
|
|
96
|
+
if not raw:
|
|
97
|
+
return []
|
|
98
|
+
if once:
|
|
99
|
+
return [_strip_sql(raw)]
|
|
100
|
+
lines = [_strip_sql(ln) for ln in raw.splitlines()]
|
|
101
|
+
return [ln for ln in lines if ln]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _strip_sql(sql: str) -> str:
|
|
105
|
+
return sql.strip().rstrip(";").strip()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def parse_listen_addr(spec: str) -> tuple[str, int]:
|
|
109
|
+
"""Parse ``HOST:PORT`` (port 0 allowed for tests). Bare PORT binds 127.0.0.1."""
|
|
110
|
+
text = (spec or "").strip()
|
|
111
|
+
if not text:
|
|
112
|
+
raise ValueError("listen address is empty")
|
|
113
|
+
if ":" not in text:
|
|
114
|
+
return "127.0.0.1", int(text)
|
|
115
|
+
host, _, port_s = text.rpartition(":")
|
|
116
|
+
return (host or "127.0.0.1"), int(port_s)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def read_sql_from_socket(sock: socket.socket, *, limit: int = 1_000_000) -> str:
|
|
120
|
+
"""Read one SQL until newline or semicolon (or EOF / size limit)."""
|
|
121
|
+
buf = bytearray()
|
|
122
|
+
while len(buf) < limit:
|
|
123
|
+
chunk = sock.recv(4096)
|
|
124
|
+
if not chunk:
|
|
125
|
+
break
|
|
126
|
+
buf.extend(chunk)
|
|
127
|
+
if b"\n" in chunk or b";" in chunk:
|
|
128
|
+
break
|
|
129
|
+
text = bytes(buf).decode("utf-8", errors="replace")
|
|
130
|
+
for sep in ("\n", ";"):
|
|
131
|
+
if sep in text:
|
|
132
|
+
text = text.split(sep, 1)[0]
|
|
133
|
+
break
|
|
134
|
+
return _strip_sql(text)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def handle_connection(
|
|
138
|
+
conn: socket.socket,
|
|
139
|
+
gate: WriteGate,
|
|
140
|
+
*,
|
|
141
|
+
as_json: bool = False,
|
|
142
|
+
) -> Decision:
|
|
143
|
+
"""One connection: read one SQL, gate, write BLOCKED/ALLOWED, close is caller."""
|
|
144
|
+
sql = read_sql_from_socket(conn)
|
|
145
|
+
if not sql:
|
|
146
|
+
body = "BLOCKED\nRule: empty_sql\nReason: empty SQL\nexecuted: no\n"
|
|
147
|
+
conn.sendall(body.encode("utf-8"))
|
|
148
|
+
return Decision(
|
|
149
|
+
action=ACTION_BLOCK,
|
|
150
|
+
risk="low",
|
|
151
|
+
rule_id="empty_sql",
|
|
152
|
+
reason="empty SQL",
|
|
153
|
+
)
|
|
154
|
+
decision, result = handle_sql(gate, sql)
|
|
155
|
+
body = format_proxy(decision, result, as_json=as_json) + "\n"
|
|
156
|
+
conn.sendall(body.encode("utf-8"))
|
|
157
|
+
return decision
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def serve_listen(
|
|
161
|
+
host: str,
|
|
162
|
+
port: int,
|
|
163
|
+
gate: WriteGate,
|
|
164
|
+
*,
|
|
165
|
+
as_json: bool = False,
|
|
166
|
+
stop: Callable[[], bool] | None = None,
|
|
167
|
+
on_bound: Callable[[str, int], None] | None = None,
|
|
168
|
+
err: TextIO | None = None,
|
|
169
|
+
) -> int:
|
|
170
|
+
"""Accept connections on HOST:PORT. Each connection is one SQL then close.
|
|
171
|
+
|
|
172
|
+
``port=0`` lets the OS pick an ephemeral port (tests bind 127.0.0.1:0).
|
|
173
|
+
``stop`` is polled between accepts so tests can shut the server down.
|
|
174
|
+
"""
|
|
175
|
+
err = err if err is not None else sys.stderr
|
|
176
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
177
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
178
|
+
sock.bind((host, port))
|
|
179
|
+
sock.listen(8)
|
|
180
|
+
sock.settimeout(0.3)
|
|
181
|
+
bound_host, bound_port = sock.getsockname()[:2]
|
|
182
|
+
err.write(f"listening on {bound_host}:{bound_port}\n")
|
|
183
|
+
err.flush()
|
|
184
|
+
if on_bound is not None:
|
|
185
|
+
on_bound(bound_host, bound_port)
|
|
186
|
+
try:
|
|
187
|
+
while True:
|
|
188
|
+
if stop is not None and stop():
|
|
189
|
+
return 0
|
|
190
|
+
try:
|
|
191
|
+
conn, _addr = sock.accept()
|
|
192
|
+
except TimeoutError:
|
|
193
|
+
continue
|
|
194
|
+
except OSError:
|
|
195
|
+
if stop is not None and stop():
|
|
196
|
+
return 0
|
|
197
|
+
raise
|
|
198
|
+
with conn:
|
|
199
|
+
handle_connection(conn, gate, as_json=as_json)
|
|
200
|
+
except KeyboardInterrupt:
|
|
201
|
+
err.write("proxy: listen stopped\n")
|
|
202
|
+
return 0
|
|
203
|
+
finally:
|
|
204
|
+
sock.close()
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _worst_code(codes: list[int]) -> int:
|
|
208
|
+
if not codes:
|
|
209
|
+
return 2
|
|
210
|
+
return max(codes)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def run_statements(
|
|
214
|
+
gate: WriteGate,
|
|
215
|
+
statements: list[str],
|
|
216
|
+
*,
|
|
217
|
+
as_json: bool = False,
|
|
218
|
+
out: TextIO | None = None,
|
|
219
|
+
) -> int:
|
|
220
|
+
"""Run each SQL through handle_sql. Exit code is the most severe result."""
|
|
221
|
+
out = out if out is not None else sys.stdout
|
|
222
|
+
codes: list[int] = []
|
|
223
|
+
for sql in statements:
|
|
224
|
+
decision, result = handle_sql(gate, sql)
|
|
225
|
+
out.write(format_proxy(decision, result, as_json=as_json) + "\n")
|
|
226
|
+
out.flush()
|
|
227
|
+
codes.append(exit_code(decision))
|
|
228
|
+
return _worst_code(codes)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def run_cli(
|
|
232
|
+
gate: WriteGate,
|
|
233
|
+
*,
|
|
234
|
+
sql: str | None = None,
|
|
235
|
+
listen: str | None = None,
|
|
236
|
+
once: bool = False,
|
|
237
|
+
as_json: bool = False,
|
|
238
|
+
stdin: TextIO | None = None,
|
|
239
|
+
out: TextIO | None = None,
|
|
240
|
+
err: TextIO | None = None,
|
|
241
|
+
stop: Callable[[], bool] | None = None,
|
|
242
|
+
on_bound: Callable[[str, int], None] | None = None,
|
|
243
|
+
) -> int:
|
|
244
|
+
"""CLI body for ``sql-write-gate proxy``. One-shot --sql / stdin EOF / listen."""
|
|
245
|
+
err = err if err is not None else sys.stderr
|
|
246
|
+
stdin = stdin if stdin is not None else sys.stdin
|
|
247
|
+
if listen:
|
|
248
|
+
host, port = parse_listen_addr(listen)
|
|
249
|
+
return serve_listen(
|
|
250
|
+
host,
|
|
251
|
+
port,
|
|
252
|
+
gate,
|
|
253
|
+
as_json=as_json,
|
|
254
|
+
stop=stop,
|
|
255
|
+
on_bound=on_bound,
|
|
256
|
+
err=err,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
once = bool(once) or bool(sql and str(sql).strip())
|
|
260
|
+
statements: list[str] = []
|
|
261
|
+
if sql and str(sql).strip():
|
|
262
|
+
statements = [_strip_sql(str(sql))]
|
|
263
|
+
else:
|
|
264
|
+
isatty = getattr(stdin, "isatty", lambda: False)
|
|
265
|
+
if callable(isatty) and isatty():
|
|
266
|
+
err.write("proxy: provide --sql, pipe SQL on stdin, or --listen HOST:PORT\n")
|
|
267
|
+
return 2
|
|
268
|
+
statements = split_statements(stdin.read(), once=once)
|
|
269
|
+
|
|
270
|
+
if not statements:
|
|
271
|
+
err.write("proxy: empty SQL\n")
|
|
272
|
+
return 2
|
|
273
|
+
return run_statements(gate, statements, as_json=as_json, out=out)
|