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/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"
|
write_gate/mcp_server.py
ADDED
|
@@ -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()
|
write_gate/mcp_tools.py
ADDED
|
@@ -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
|
+
)
|