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
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)