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/audit.py ADDED
@@ -0,0 +1,120 @@
1
+ """JSONL audit log for every check/execute."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+ from typing import Any, Iterable
9
+ from zoneinfo import ZoneInfo
10
+
11
+ from write_gate.decision import ACTION_APPROVAL, Decision
12
+ from write_gate.paths import AUDIT_PATH, LOG_DIR
13
+
14
+ # Glanceable table times: UTC ISO on disk, Asia/Shanghai in `sql-write-gate audit`.
15
+ AUDIT_DISPLAY_TZ = ZoneInfo("Asia/Shanghai")
16
+ EMPTY_AUDIT_MESSAGE = "(no audit records)"
17
+ VERDICT_DISPLAY = {
18
+ ACTION_APPROVAL: "APPROVAL",
19
+ "REQUIRE_APPROVAL": "APPROVAL",
20
+ }
21
+
22
+
23
+ def append_audit(
24
+ decision: Decision,
25
+ *,
26
+ agent: str = "cli",
27
+ environment: str = "production",
28
+ path: Path | None = None,
29
+ ) -> None:
30
+ record = {
31
+ "timestamp": datetime.now(timezone.utc).isoformat(),
32
+ "agent": agent,
33
+ "environment": environment,
34
+ "sql": decision.sql,
35
+ "operation": decision.operation,
36
+ "table": decision.table,
37
+ "estimated_rows": decision.estimated_rows,
38
+ "decision": decision.action,
39
+ "rule_id": decision.rule_id,
40
+ }
41
+ dest = Path(path) if path else AUDIT_PATH
42
+ dest.parent.mkdir(parents=True, exist_ok=True)
43
+ with dest.open("a", encoding="utf-8") as fh:
44
+ fh.write(json.dumps(record, ensure_ascii=False) + "\n")
45
+
46
+
47
+ def read_audit(path: Path | None = None, limit: int = 20) -> list[dict[str, Any]]:
48
+ dest = Path(path) if path else AUDIT_PATH
49
+ if not dest.exists():
50
+ return []
51
+ lines = dest.read_text(encoding="utf-8").splitlines()
52
+ rows: list[dict[str, Any]] = []
53
+ for line in lines[-limit:]:
54
+ line = line.strip()
55
+ if not line:
56
+ continue
57
+ try:
58
+ rows.append(json.loads(line))
59
+ except json.JSONDecodeError:
60
+ continue
61
+ return rows
62
+
63
+
64
+ def format_audit_time(value: object) -> str:
65
+ """UTC ISO (with or without microseconds) → glanceable local `YYYY-MM-DD HH:MM`."""
66
+ raw = str(value or "").strip()
67
+ if not raw:
68
+ return "-"
69
+ try:
70
+ ts = datetime.fromisoformat(raw.replace("Z", "+00:00"))
71
+ except ValueError:
72
+ return raw[:16].replace("T", " ")
73
+ if ts.tzinfo is None:
74
+ ts = ts.replace(tzinfo=timezone.utc)
75
+ return ts.astimezone(AUDIT_DISPLAY_TZ).strftime("%Y-%m-%d %H:%M")
76
+
77
+
78
+ def format_verdict(value: object) -> str:
79
+ """Map JSONL `decision` to the printed VERDICT column only."""
80
+ raw = str(value or "").strip()
81
+ if not raw:
82
+ return "-"
83
+ return VERDICT_DISPLAY.get(raw, raw)
84
+
85
+
86
+ def format_audit_table(rows: Iterable[dict[str, Any]]) -> str:
87
+ records = list(rows)
88
+ if not records:
89
+ return EMPTY_AUDIT_MESSAGE
90
+ headers = ("TIME", "SOURCE", "OP", "TABLE", "VERDICT", "RULE")
91
+ extracted: list[tuple[str, ...]] = []
92
+ for rec in records:
93
+ extracted.append(
94
+ (
95
+ format_audit_time(rec.get("timestamp")),
96
+ str(rec.get("agent") or "-"),
97
+ str(rec.get("operation") or "-"),
98
+ str(rec.get("table") or "-"),
99
+ format_verdict(rec.get("decision")),
100
+ str(rec.get("rule_id") or "-"),
101
+ )
102
+ )
103
+ widths = [len(h) for h in headers]
104
+ for row in extracted:
105
+ for i, cell in enumerate(row):
106
+ widths[i] = max(widths[i], len(cell))
107
+
108
+ def fmt(row: tuple[str, ...]) -> str:
109
+ return " ".join(cell.ljust(widths[i]) for i, cell in enumerate(row))
110
+
111
+ lines = [fmt(headers), " ".join("-" * w for w in widths)]
112
+ lines.extend(fmt(r) for r in extracted)
113
+ return "\n".join(lines)
114
+
115
+
116
+ def ensure_log_dir() -> None:
117
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
118
+ gitkeep = LOG_DIR / ".gitkeep"
119
+ if not gitkeep.exists():
120
+ gitkeep.write_text("", encoding="utf-8")
write_gate/cases.py ADDED
@@ -0,0 +1,30 @@
1
+ """Canonical demo / test SQL. Kept in one place so Makefile demo and pytest stay aligned."""
2
+
3
+ LEGAL_WRITE_SQL = (
4
+ "INSERT INTO orders (order_id, user_id, amount, dt, status) "
5
+ "VALUES (900001, 42, 18.50, '2026-09-01', 'paid')"
6
+ )
7
+
8
+ EXPIRED_WRITE_SQL = (
9
+ "INSERT INTO orders (order_id, user_id, amount, dt, status) "
10
+ "VALUES (900002, 42, 18.50, '2026-08-01', 'paid')"
11
+ )
12
+
13
+ PII_WRITE_SQL = (
14
+ "INSERT INTO orders (order_id, user_id, amount, dt, status, email) "
15
+ "VALUES (900003, 42, 18.50, '2026-09-01', 'paid', 'eve@example.com')"
16
+ )
17
+
18
+ SCHEMA_MISMATCH_SQL = (
19
+ "INSERT INTO orders (order_id, user_id, amount, dt, status, not_a_column) "
20
+ "VALUES (900004, 42, 18.50, '2026-09-01', 'paid', 1)"
21
+ )
22
+
23
+ TYPE_MISMATCH_SQL = (
24
+ "INSERT INTO orders (order_id, user_id, amount, dt, status) "
25
+ "VALUES ('not-an-int', 42, 18.50, '2026-09-01', 'paid')"
26
+ )
27
+
28
+ READ_ONLY_SQL = (
29
+ "SELECT user_id, amount, dt, status FROM orders WHERE dt >= '2026-08-26' LIMIT 5"
30
+ )
write_gate/catalog.py ADDED
@@ -0,0 +1,79 @@
1
+ """Load the static warehouse catalog (tables, PII columns, freshness cutoff)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass
7
+ from datetime import date, timedelta
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from write_gate.paths import CATALOG_PATH
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class TableSpec:
16
+ name: str
17
+ writable: bool
18
+ stale: bool
19
+ partition_column: str | None
20
+ columns: dict[str, str]
21
+ allowed_write_columns: frozenset[str]
22
+ pii_columns: frozenset[str]
23
+ restricted_columns: frozenset[str]
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Catalog:
28
+ as_of_date: date
29
+ freshness_days: int
30
+ tables: dict[str, TableSpec]
31
+
32
+ @property
33
+ def cutoff_date(self) -> date:
34
+ """Oldest partition still considered fresh (inclusive).
35
+
36
+ as_of=2026-09-02, freshness_days=7 → cutoff=2026-08-26.
37
+ dt < cutoff is expired ("older than 7 days").
38
+ """
39
+ return self.as_of_date - timedelta(days=self.freshness_days)
40
+
41
+ def table(self, name: str) -> TableSpec | None:
42
+ return self.tables.get(name.lower())
43
+
44
+
45
+ def _table_spec(name: str, raw: dict[str, Any]) -> TableSpec:
46
+ columns = {str(k).lower(): str(v).upper() for k, v in raw.get("columns", {}).items()}
47
+ allowed = frozenset(c.lower() for c in raw.get("allowed_write_columns", []))
48
+ pii = frozenset(c.lower() for c in raw.get("pii_columns", []))
49
+ restricted = frozenset(c.lower() for c in raw.get("restricted_columns", []))
50
+ # Conventional restricted names if the catalog lists those columns.
51
+ for extra in ("id_card", "card_number"):
52
+ if extra in columns:
53
+ restricted = restricted | {extra}
54
+ part = raw.get("partition_column")
55
+ return TableSpec(
56
+ name=name.lower(),
57
+ writable=bool(raw.get("writable", True)),
58
+ stale=bool(raw.get("stale", False)),
59
+ partition_column=str(part).lower() if part else None,
60
+ columns=columns,
61
+ allowed_write_columns=allowed,
62
+ pii_columns=pii,
63
+ restricted_columns=restricted,
64
+ )
65
+
66
+
67
+ def load_catalog(path: Path | None = None) -> Catalog:
68
+ catalog_path = Path(path) if path else CATALOG_PATH
69
+ with catalog_path.open(encoding="utf-8") as fh:
70
+ raw = json.load(fh)
71
+ tables = {
72
+ name.lower(): _table_spec(name, spec)
73
+ for name, spec in raw.get("tables", {}).items()
74
+ }
75
+ return Catalog(
76
+ as_of_date=date.fromisoformat(raw["as_of_date"]),
77
+ freshness_days=int(raw["freshness_days"]),
78
+ tables=tables,
79
+ )
write_gate/cli.py ADDED
@@ -0,0 +1,397 @@
1
+ """CLI: sql-write-gate check|exec|audit|hook|mcp|proxy|approve|reject|pending|init."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from write_gate.approvals import (
11
+ ApprovalError,
12
+ get_approval,
13
+ list_pending,
14
+ mark_rejected,
15
+ )
16
+ from write_gate.audit import format_audit_table, read_audit
17
+ from write_gate.decision import ACTION_ALLOW, ACTION_APPROVAL, ACTION_BLOCK, Decision
18
+ from write_gate.paths import APPROVALS_PATH, AUDIT_PATH
19
+ from write_gate.wrapper import WriteGate
20
+
21
+
22
+ def _headline(action: str) -> str:
23
+ if action == ACTION_BLOCK:
24
+ return "BLOCKED"
25
+ if action == ACTION_APPROVAL:
26
+ return "APPROVAL REQUIRED"
27
+ return "ALLOWED"
28
+
29
+
30
+ def format_decision(decision: Decision) -> str:
31
+ lines = [
32
+ _headline(decision.action),
33
+ f"Risk: {_safe(decision.risk)}",
34
+ f"Operation: {_safe(decision.operation).upper()}",
35
+ f"Table: {_safe(decision.table)}",
36
+ f"Rule: {_safe(decision.rule_id)}",
37
+ f"Reason: {_safe(decision.reason)}",
38
+ ]
39
+ if decision.estimated_rows is not None:
40
+ lines.append(f"Estimated rows: {decision.estimated_rows}")
41
+ if decision.approval_id:
42
+ lines.append(f"Approval id: {decision.approval_id}")
43
+ return "\n".join(lines)
44
+
45
+
46
+ def _safe(value: object) -> str:
47
+ if value is None or value == "":
48
+ return "-"
49
+ return str(value)
50
+
51
+
52
+ def _approvals_path(args: argparse.Namespace) -> Path:
53
+ raw = getattr(args, "approvals", None)
54
+ return Path(raw) if raw else APPROVALS_PATH
55
+
56
+
57
+ def _gate_from_args(args: argparse.Namespace) -> WriteGate:
58
+ return WriteGate(
59
+ db_path=Path(args.db) if getattr(args, "db", None) else None,
60
+ database=getattr(args, "database", None),
61
+ catalog_path=Path(args.catalog) if getattr(args, "catalog", None) else None,
62
+ policy_path=Path(args.policy) if getattr(args, "policy", None) else None,
63
+ approvals_path=_approvals_path(args),
64
+ agent=getattr(args, "agent", None) or "cli",
65
+ )
66
+
67
+
68
+ def _gate_from_record(rec, *, approvals_path: Path, agent: str = "approve") -> WriteGate:
69
+ return WriteGate(
70
+ database=rec.database,
71
+ db_path=Path(rec.db_path) if rec.db_path else None,
72
+ catalog_path=Path(rec.catalog_path) if rec.catalog_path else None,
73
+ policy_path=Path(rec.policy_path) if rec.policy_path else None,
74
+ approvals_path=approvals_path,
75
+ agent=agent,
76
+ )
77
+
78
+
79
+ def _print_decision(decision: Decision, *, as_json: bool, result=None) -> int:
80
+ if as_json:
81
+ payload = decision.to_dict()
82
+ if result is not None and decision.allowed:
83
+ try:
84
+ rows = result.fetchall()
85
+ payload["rows"] = [list(r) for r in rows]
86
+ payload["rowcount"] = len(payload["rows"])
87
+ except Exception:
88
+ payload["rowcount"] = getattr(result, "rowcount", None)
89
+ json.dump(payload, sys.stdout, ensure_ascii=False, indent=2)
90
+ sys.stdout.write("\n")
91
+ else:
92
+ sys.stdout.write(format_decision(decision) + "\n")
93
+ if decision.action == ACTION_ALLOW:
94
+ return 0
95
+ if decision.action == ACTION_APPROVAL:
96
+ return 1
97
+ return 2
98
+
99
+
100
+ def _add_shared(parser: argparse.ArgumentParser) -> None:
101
+ parser.add_argument("--policy", help="Path to policy.yaml (default: ./policy.yaml)")
102
+ parser.add_argument("--catalog", help="Path to catalog.json")
103
+ parser.add_argument("--db", help="Path to DuckDB warehouse")
104
+ parser.add_argument(
105
+ "--database",
106
+ help=(
107
+ "DuckDB file path or postgres:// / mysql:// / mysql+pymysql:// / "
108
+ "sqlite:/// / sqlite+aiosqlite:// URL "
109
+ "(default: DATABASE_URL, then local DuckDB)"
110
+ ),
111
+ )
112
+ parser.add_argument("--agent", default="cli", help="Audit agent name")
113
+ parser.add_argument("--json", action="store_true", help="Print machine-readable JSON")
114
+ parser.add_argument(
115
+ "--approvals",
116
+ help="Path to approvals jsonl (default: .logs/approvals.jsonl)",
117
+ )
118
+
119
+
120
+ def build_parser() -> argparse.ArgumentParser:
121
+ shared = argparse.ArgumentParser(add_help=False)
122
+ _add_shared(shared)
123
+ parser = argparse.ArgumentParser(
124
+ prog="sql-write-gate",
125
+ description="Policy firewall for AI agents writing to databases (no LLM, no API key)",
126
+ )
127
+ sub = parser.add_subparsers(dest="command", required=True)
128
+
129
+ check_p = sub.add_parser("check", help="Evaluate SQL without executing", parents=[shared])
130
+ check_p.add_argument("sql", help="One SQL statement")
131
+
132
+ exec_p = sub.add_parser("exec", help="Evaluate then execute if ALLOW", parents=[shared])
133
+ exec_p.add_argument("sql", help="One SQL statement")
134
+
135
+ audit_p = sub.add_parser("audit", help="Print recent audit log rows")
136
+ audit_p.add_argument("--limit", type=int, default=20, help="How many recent rows")
137
+ audit_p.add_argument("--audit-path", default=None, help="Override audit jsonl path")
138
+
139
+ hook_p = sub.add_parser(
140
+ "hook",
141
+ help="PreToolUse: block raw DB CLIs; never execute SQL",
142
+ parents=[shared],
143
+ )
144
+ hook_p.add_argument(
145
+ "--command",
146
+ dest="hook_command",
147
+ default=None,
148
+ help="Bash command (tests / override stdin JSON)",
149
+ )
150
+ hook_p.add_argument(
151
+ "--sql",
152
+ dest="hook_sql",
153
+ default=None,
154
+ help="Raw SQL to evaluate (tests; still never executed)",
155
+ )
156
+
157
+ sub.add_parser(
158
+ "mcp",
159
+ help="Start MCP stdio server (query_sql / write_sql; ALLOW executes)",
160
+ parents=[shared],
161
+ )
162
+
163
+ proxy_p = sub.add_parser(
164
+ "proxy",
165
+ help="Front a real DB: gate SQL then execute if ALLOW",
166
+ parents=[shared],
167
+ )
168
+ proxy_p.add_argument(
169
+ "--sql",
170
+ dest="proxy_sql",
171
+ default=None,
172
+ help="One SQL statement then exit",
173
+ )
174
+ proxy_p.add_argument(
175
+ "--once",
176
+ action="store_true",
177
+ help="Exit after one statement (default when --sql is set; stdin also exits on EOF)",
178
+ )
179
+ proxy_p.add_argument(
180
+ "--listen",
181
+ default=None,
182
+ metavar="HOST:PORT",
183
+ help="Text protocol: one SQL per connection then close (tests: 127.0.0.1:0)",
184
+ )
185
+
186
+ queue = argparse.ArgumentParser(add_help=False)
187
+ queue.add_argument(
188
+ "--approvals",
189
+ help="Path to approvals jsonl (default: .logs/approvals.jsonl)",
190
+ )
191
+ queue.add_argument("--json", action="store_true", help="Print machine-readable JSON")
192
+
193
+ approve_p = sub.add_parser(
194
+ "approve",
195
+ help="Execute a pending approval id (re-runs guards; env approval only is cleared)",
196
+ parents=[queue],
197
+ )
198
+ approve_p.add_argument("approval_id", help="Pending approval id")
199
+
200
+ reject_p = sub.add_parser(
201
+ "reject",
202
+ help="Reject a pending approval id without writing",
203
+ parents=[queue],
204
+ )
205
+ reject_p.add_argument("approval_id", help="Pending approval id")
206
+
207
+ sub.add_parser(
208
+ "pending",
209
+ help="List pending approval ids",
210
+ parents=[queue],
211
+ )
212
+
213
+ init_p = sub.add_parser(
214
+ "init",
215
+ help="Scaffold policy.yaml, catalog.json, GETTING_STARTED.md",
216
+ )
217
+ init_p.add_argument(
218
+ "--dir",
219
+ default=".",
220
+ help="Target directory (default: current directory)",
221
+ )
222
+ init_p.add_argument(
223
+ "--force",
224
+ action="store_true",
225
+ help="Overwrite existing starter files",
226
+ )
227
+ return parser
228
+
229
+
230
+ def _read_hook_stdin() -> str:
231
+ if sys.stdin.isatty():
232
+ return ""
233
+ return sys.stdin.read()
234
+
235
+
236
+ def _cmd_hook(args: argparse.Namespace) -> int:
237
+ from write_gate.hooks import run_hook
238
+
239
+ agent = getattr(args, "agent", None)
240
+ if not agent or agent == "cli":
241
+ agent = "hook"
242
+ stdin_text = None
243
+ if not args.hook_command and not args.hook_sql:
244
+ stdin_text = _read_hook_stdin()
245
+ return run_hook(
246
+ bash_command=args.hook_command,
247
+ sql=args.hook_sql,
248
+ stdin_text=stdin_text,
249
+ database=getattr(args, "database", None),
250
+ db=getattr(args, "db", None),
251
+ catalog=getattr(args, "catalog", None),
252
+ policy=getattr(args, "policy", None),
253
+ agent=agent,
254
+ )
255
+
256
+
257
+ def _cmd_mcp(args: argparse.Namespace) -> int:
258
+ try:
259
+ from write_gate.mcp_server import run_server
260
+ except ImportError:
261
+ sys.stderr.write('pip install -e ".[mcp]"\n')
262
+ return 1
263
+ agent = getattr(args, "agent", None)
264
+ if not agent or agent == "cli":
265
+ agent = "mcp"
266
+ try:
267
+ run_server(
268
+ database=getattr(args, "database", None),
269
+ db=getattr(args, "db", None),
270
+ catalog=getattr(args, "catalog", None),
271
+ policy=getattr(args, "policy", None),
272
+ agent=agent,
273
+ )
274
+ except ImportError:
275
+ sys.stderr.write('pip install -e ".[mcp]"\n')
276
+ return 1
277
+ return 0
278
+
279
+
280
+ def _cmd_proxy(args: argparse.Namespace) -> int:
281
+ from write_gate.proxy import run_cli
282
+
283
+ agent = getattr(args, "agent", None)
284
+ if not agent or agent == "cli":
285
+ args.agent = "proxy"
286
+ with _gate_from_args(args) as gate:
287
+ return run_cli(
288
+ gate,
289
+ sql=getattr(args, "proxy_sql", None),
290
+ listen=getattr(args, "listen", None),
291
+ once=bool(getattr(args, "once", False)),
292
+ as_json=bool(getattr(args, "json", False)),
293
+ )
294
+
295
+
296
+ def _cmd_approve(args: argparse.Namespace) -> int:
297
+ path = _approvals_path(args)
298
+ rec = get_approval(args.approval_id, path=path)
299
+ if rec is None or rec.status != "pending":
300
+ sys.stderr.write(f"approval not found or not pending: {args.approval_id}\n")
301
+ return 1
302
+ with _gate_from_record(rec, approvals_path=path, agent="approve") as gate:
303
+ try:
304
+ decision, result = gate.approve(rec.id)
305
+ except ApprovalError as exc:
306
+ sys.stderr.write(str(exc) + "\n")
307
+ return 1
308
+ return _print_decision(decision, as_json=bool(args.json), result=result)
309
+
310
+
311
+ def _cmd_reject(args: argparse.Namespace) -> int:
312
+ path = _approvals_path(args)
313
+ rec = get_approval(args.approval_id, path=path)
314
+ if rec is None or rec.status != "pending":
315
+ sys.stderr.write(f"approval not found or not pending: {args.approval_id}\n")
316
+ return 1
317
+ try:
318
+ rec = mark_rejected(rec.id, path=path)
319
+ except ApprovalError as exc:
320
+ sys.stderr.write(str(exc) + "\n")
321
+ return 1
322
+ if args.json:
323
+ json.dump(rec.to_dict(), sys.stdout, ensure_ascii=False, indent=2)
324
+ sys.stdout.write("\n")
325
+ else:
326
+ sys.stdout.write(f"REJECTED\nApproval id: {rec.id}\n")
327
+ return 0
328
+
329
+
330
+ def _cmd_pending(args: argparse.Namespace) -> int:
331
+ path = _approvals_path(args)
332
+ rows = list_pending(path=path)
333
+ if args.json:
334
+ json.dump([r.to_dict() for r in rows], sys.stdout, ensure_ascii=False, indent=2)
335
+ sys.stdout.write("\n")
336
+ return 0
337
+ if not rows:
338
+ sys.stdout.write("(no pending approvals)\n")
339
+ return 0
340
+ for rec in rows:
341
+ decision = rec.decision if isinstance(rec.decision, dict) else {}
342
+ op = decision.get("operation") or "-"
343
+ table = decision.get("table") or "-"
344
+ sys.stdout.write(f"{rec.id} {rec.status} {op} {table}\n")
345
+ return 0
346
+
347
+
348
+ def _cmd_init(args: argparse.Namespace) -> int:
349
+ from write_gate.init import format_init_report, init_project
350
+
351
+ result = init_project(args.dir, force=bool(args.force))
352
+ sys.stdout.write(format_init_report(result, args.dir))
353
+ return 0
354
+
355
+
356
+ def main(argv: list[str] | None = None) -> int:
357
+ parser = build_parser()
358
+ args = parser.parse_args(argv)
359
+
360
+ if args.command == "audit":
361
+ path = Path(args.audit_path) if args.audit_path else AUDIT_PATH
362
+ rows = read_audit(path, limit=args.limit)
363
+ sys.stdout.write(format_audit_table(rows) + "\n")
364
+ return 0
365
+
366
+ if args.command == "hook":
367
+ return _cmd_hook(args)
368
+
369
+ if args.command == "mcp":
370
+ return _cmd_mcp(args)
371
+
372
+ if args.command == "proxy":
373
+ return _cmd_proxy(args)
374
+
375
+ if args.command == "approve":
376
+ return _cmd_approve(args)
377
+
378
+ if args.command == "reject":
379
+ return _cmd_reject(args)
380
+
381
+ if args.command == "pending":
382
+ return _cmd_pending(args)
383
+
384
+ if args.command == "init":
385
+ return _cmd_init(args)
386
+
387
+ with _gate_from_args(args) as gate:
388
+ if args.command == "check":
389
+ decision = gate.check(args.sql)
390
+ result = None
391
+ else:
392
+ decision, result = gate.execute(args.sql)
393
+ return _print_decision(decision, as_json=bool(args.json), result=result)
394
+
395
+
396
+ if __name__ == "__main__":
397
+ raise SystemExit(main())