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/init.py ADDED
@@ -0,0 +1,84 @@
1
+ """Scaffold a starter sql-write-gate project (policy, catalog, getting started)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ TEMPLATES_DIR = Path(__file__).resolve().parent / "templates"
10
+
11
+ # (template filename, destination relative path)
12
+ STARTER_FILES: tuple[tuple[str, str], ...] = (
13
+ ("policy.yaml", "policy.yaml"),
14
+ ("catalog.json", "catalog.json"),
15
+ ("GETTING_STARTED.md", "GETTING_STARTED.md"),
16
+ )
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class InitResult:
21
+ created: list[str]
22
+ skipped: list[str]
23
+ overwritten: list[str]
24
+
25
+ @property
26
+ def all_paths(self) -> list[str]:
27
+ return [*self.created, *self.skipped, *self.overwritten]
28
+
29
+
30
+ def templates_dir() -> Path:
31
+ """Resolve packaged templates (editable install or wheel)."""
32
+ path = TEMPLATES_DIR
33
+ if not path.is_dir():
34
+ raise FileNotFoundError(f"sql-write-gate templates missing: {path}")
35
+ return path
36
+
37
+
38
+ def init_project(target_dir: Path | str = ".", *, force: bool = False) -> InitResult:
39
+ """Create starter files under *target_dir*.
40
+
41
+ Without *force*, existing files are left unchanged and listed as skipped.
42
+ With *force*, existing files are overwritten.
43
+ """
44
+ dest_root = Path(target_dir).expanduser().resolve()
45
+ dest_root.mkdir(parents=True, exist_ok=True)
46
+ src_root = templates_dir()
47
+
48
+ created: list[str] = []
49
+ skipped: list[str] = []
50
+ overwritten: list[str] = []
51
+
52
+ for template_name, rel in STARTER_FILES:
53
+ src = src_root / template_name
54
+ if not src.is_file():
55
+ raise FileNotFoundError(f"template not found: {src}")
56
+ dest = dest_root / rel
57
+ dest.parent.mkdir(parents=True, exist_ok=True)
58
+ label = str(dest.relative_to(dest_root)) if dest.is_relative_to(dest_root) else str(dest)
59
+
60
+ if dest.exists() and not force:
61
+ skipped.append(label)
62
+ continue
63
+ if dest.exists() and force:
64
+ shutil.copyfile(src, dest)
65
+ overwritten.append(label)
66
+ else:
67
+ shutil.copyfile(src, dest)
68
+ created.append(label)
69
+
70
+ return InitResult(created=created, skipped=skipped, overwritten=overwritten)
71
+
72
+
73
+ def format_init_report(result: InitResult, target_dir: Path | str) -> str:
74
+ root = Path(target_dir).expanduser().resolve()
75
+ lines = [f"sql-write-gate init → {root}"]
76
+ for path in result.created:
77
+ lines.append(f" created: {path}")
78
+ for path in result.overwritten:
79
+ lines.append(f" overwritten: {path}")
80
+ for path in result.skipped:
81
+ lines.append(f" skipped (exists): {path}")
82
+ if not result.created and not result.overwritten and result.skipped:
83
+ lines.append(" (nothing written; use --force to overwrite)")
84
+ return "\n".join(lines) + "\n"
@@ -0,0 +1,71 @@
1
+ """MCP stdio server. FastMCP is lazy-imported so mcp_tools tests need no SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ def _fastmcp():
9
+ try:
10
+ from mcp.server.fastmcp import FastMCP
11
+ except ImportError as exc: # optional extra
12
+ raise ImportError('pip install -e ".[mcp]"') from exc
13
+ return FastMCP
14
+
15
+
16
+ def create_server(
17
+ *,
18
+ database: str | None = None,
19
+ db: str | None = None,
20
+ catalog: str | None = None,
21
+ policy: str | None = None,
22
+ agent: str = "mcp",
23
+ ):
24
+ """Build a FastMCP server exposing query_sql and write_sql (ALLOW executes)."""
25
+ from write_gate.mcp_tools import query_sql as check_query
26
+ from write_gate.mcp_tools import write_sql as check_write
27
+
28
+ FastMCP = _fastmcp()
29
+ mcp = FastMCP("sql-write-gate")
30
+ gate_kwargs = {
31
+ "database": database,
32
+ "db_path": db,
33
+ "catalog_path": catalog,
34
+ "policy_path": policy,
35
+ "agent": agent or "mcp",
36
+ }
37
+
38
+ @mcp.tool()
39
+ def query_sql(sql: str) -> dict[str, Any]:
40
+ """Evaluate a SELECT (or any SQL) through sql-write-gate. ALLOW executes."""
41
+ return check_query(sql, **gate_kwargs)
42
+
43
+ @mcp.tool()
44
+ def write_sql(sql: str) -> dict[str, Any]:
45
+ """Evaluate INSERT/UPDATE/DELETE/DDL through sql-write-gate. ALLOW executes."""
46
+ return check_write(sql, **gate_kwargs)
47
+
48
+ return mcp
49
+
50
+
51
+ def run_server(
52
+ *,
53
+ database: str | None = None,
54
+ db: str | None = None,
55
+ catalog: str | None = None,
56
+ policy: str | None = None,
57
+ agent: str = "mcp",
58
+ ) -> None:
59
+ """Start the MCP server on stdio (mcp.run())."""
60
+ mcp = create_server(
61
+ database=database,
62
+ db=db,
63
+ catalog=catalog,
64
+ policy=policy,
65
+ agent=agent,
66
+ )
67
+ mcp.run()
68
+
69
+
70
+ if __name__ == "__main__":
71
+ run_server()
@@ -0,0 +1,197 @@
1
+ """MCP tool functions: execute ALLOW SQL via WriteGate.
2
+
3
+ query_sql / write_sql call WriteGate.execute (not raw DuckDB). BLOCK and
4
+ REQUIRE_APPROVAL already return None and do not write. Honor database= /
5
+ DATABASE_URL the same way as the CLI.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from write_gate.decision import ACTION_ALLOW
14
+ from write_gate.wrapper import WriteGate
15
+
16
+ QUERY_ROW_CAP = 50
17
+
18
+
19
+ def _as_path(value: str | Path | None) -> Path | None:
20
+ if value is None:
21
+ return None
22
+ return Path(value)
23
+
24
+
25
+ def _gate(
26
+ *,
27
+ database: str | None = None,
28
+ db_path: str | Path | None = None,
29
+ catalog_path: str | Path | None = None,
30
+ policy_path: str | Path | None = None,
31
+ approvals_path: str | Path | None = None,
32
+ agent: str = "mcp",
33
+ ) -> WriteGate:
34
+ return WriteGate(
35
+ database=database,
36
+ db_path=_as_path(db_path),
37
+ catalog_path=_as_path(catalog_path),
38
+ policy_path=_as_path(policy_path),
39
+ approvals_path=_as_path(approvals_path),
40
+ agent=agent or "mcp",
41
+ )
42
+
43
+
44
+ def _json_cell(value: Any) -> Any:
45
+ if value is None or isinstance(value, (bool, int, float, str)):
46
+ return value
47
+ if isinstance(value, bytes):
48
+ return value.decode("utf-8", errors="replace")
49
+ iso = getattr(value, "isoformat", None)
50
+ if callable(iso):
51
+ return iso()
52
+ return str(value)
53
+
54
+
55
+ def _fetch_rows(result: Any, *, cap: int) -> list[list[Any]]:
56
+ if result is None:
57
+ return []
58
+ fetched = None
59
+ fetchmany = getattr(result, "fetchmany", None)
60
+ if callable(fetchmany):
61
+ try:
62
+ fetched = fetchmany(cap)
63
+ except Exception:
64
+ fetched = None
65
+ if fetched is None:
66
+ fetchall = getattr(result, "fetchall", None)
67
+ if not callable(fetchall):
68
+ return []
69
+ try:
70
+ fetched = fetchall()[:cap]
71
+ except Exception:
72
+ return []
73
+ rows: list[list[Any]] = []
74
+ for row in fetched:
75
+ rows.append([_json_cell(c) for c in row])
76
+ return rows
77
+
78
+
79
+ def _rowcount(result: Any) -> int | None:
80
+ if result is None:
81
+ return None
82
+ rc = getattr(result, "rowcount", None)
83
+ if isinstance(rc, int) and rc >= 0:
84
+ return rc
85
+ return None
86
+
87
+
88
+ def decision_payload(
89
+ decision: Any,
90
+ *,
91
+ executed: bool = False,
92
+ rowcount: int | None = None,
93
+ rows: list[list[Any]] | None = None,
94
+ ) -> dict[str, Any]:
95
+ """JSON-serializable gate result for MCP tools / python -c demos."""
96
+ payload: dict[str, Any] = {
97
+ "action": decision.action,
98
+ "rule_id": decision.rule_id,
99
+ "reason": decision.reason,
100
+ "operation": decision.operation,
101
+ "table": decision.table,
102
+ "risk": decision.risk,
103
+ "executed": bool(executed),
104
+ }
105
+ if getattr(decision, "approval_id", None):
106
+ payload["approval_id"] = decision.approval_id
107
+ if rowcount is not None:
108
+ payload["rowcount"] = rowcount
109
+ if rows is not None:
110
+ payload["rows"] = rows
111
+ return payload
112
+
113
+
114
+ def _execute(
115
+ sql: str,
116
+ *,
117
+ include_rows: bool,
118
+ database: str | None = None,
119
+ db_path: str | Path | None = None,
120
+ catalog_path: str | Path | None = None,
121
+ policy_path: str | Path | None = None,
122
+ approvals_path: str | Path | None = None,
123
+ agent: str = "mcp",
124
+ ) -> dict[str, Any]:
125
+ with _gate(
126
+ database=database,
127
+ db_path=db_path,
128
+ catalog_path=catalog_path,
129
+ policy_path=policy_path,
130
+ approvals_path=approvals_path,
131
+ agent=agent,
132
+ ) as gate:
133
+ decision, result = gate.execute(sql)
134
+ executed = decision.action == ACTION_ALLOW and result is not None
135
+ rows = None
136
+ rowcount = None
137
+ if executed:
138
+ rowcount = _rowcount(result)
139
+ if include_rows:
140
+ rows = _fetch_rows(result, cap=QUERY_ROW_CAP)
141
+ if rowcount is None:
142
+ rowcount = len(rows)
143
+ return decision_payload(decision, executed=executed, rowcount=rowcount, rows=rows)
144
+
145
+
146
+ def query_sql(
147
+ sql: str,
148
+ *,
149
+ database: str | None = None,
150
+ db_path: str | Path | None = None,
151
+ catalog_path: str | Path | None = None,
152
+ policy_path: str | Path | None = None,
153
+ approvals_path: str | Path | None = None,
154
+ agent: str = "mcp",
155
+ ) -> dict[str, Any]:
156
+ """Gate then (on ALLOW) execute SQL via WriteGate.execute.
157
+
158
+ SELECT results include a capped ``rows`` list. BLOCK / REQUIRE_APPROVAL
159
+ do not run user SQL. REQUIRE_APPROVAL is queued (approval_id).
160
+ """
161
+ return _execute(
162
+ sql,
163
+ include_rows=True,
164
+ database=database,
165
+ db_path=db_path,
166
+ catalog_path=catalog_path,
167
+ policy_path=policy_path,
168
+ approvals_path=approvals_path,
169
+ agent=agent,
170
+ )
171
+
172
+
173
+ def write_sql(
174
+ sql: str,
175
+ *,
176
+ database: str | None = None,
177
+ db_path: str | Path | None = None,
178
+ catalog_path: str | Path | None = None,
179
+ policy_path: str | Path | None = None,
180
+ approvals_path: str | Path | None = None,
181
+ agent: str = "mcp",
182
+ ) -> dict[str, Any]:
183
+ """Gate then (on ALLOW) execute INSERT/UPDATE/DELETE/DDL via WriteGate.execute.
184
+
185
+ BLOCK / REQUIRE_APPROVAL return executed=false and do not write.
186
+ REQUIRE_APPROVAL is queued (approval_id); approve <id> later to write.
187
+ """
188
+ return _execute(
189
+ sql,
190
+ include_rows=False,
191
+ database=database,
192
+ db_path=db_path,
193
+ catalog_path=catalog_path,
194
+ policy_path=policy_path,
195
+ approvals_path=approvals_path,
196
+ agent=agent,
197
+ )