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/config.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Load policy.yaml (environment, per-operation rules, blast-radius limits)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
from write_gate.paths import POLICY_PATH
|
|
12
|
+
|
|
13
|
+
VALID_RULES = {"allow", "block", "approval"}
|
|
14
|
+
VALID_OPS = ("select", "insert", "update", "delete", "ddl")
|
|
15
|
+
|
|
16
|
+
PRODUCTION_DEFAULTS: dict[str, Any] = {
|
|
17
|
+
"environment": "production",
|
|
18
|
+
"rules": {
|
|
19
|
+
"select": "allow",
|
|
20
|
+
"insert": "approval",
|
|
21
|
+
"update": "approval",
|
|
22
|
+
"delete": "block",
|
|
23
|
+
"ddl": "block",
|
|
24
|
+
},
|
|
25
|
+
"limits": {
|
|
26
|
+
"update_rows": 100,
|
|
27
|
+
"delete_rows": 50,
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
DEMO_DEFAULTS: dict[str, Any] = {
|
|
32
|
+
"environment": "demo",
|
|
33
|
+
"rules": {
|
|
34
|
+
"select": "allow",
|
|
35
|
+
"insert": "allow",
|
|
36
|
+
"update": "allow",
|
|
37
|
+
"delete": "allow",
|
|
38
|
+
"ddl": "block",
|
|
39
|
+
},
|
|
40
|
+
"limits": {
|
|
41
|
+
"update_rows": 10000,
|
|
42
|
+
"delete_rows": 10000,
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class Policy:
|
|
49
|
+
environment: str
|
|
50
|
+
rules: dict[str, str] = field(default_factory=dict)
|
|
51
|
+
update_rows: int = 100
|
|
52
|
+
delete_rows: int = 50
|
|
53
|
+
|
|
54
|
+
def rule_for(self, operation: str) -> str:
|
|
55
|
+
op = (operation or "ddl").lower()
|
|
56
|
+
return self.rules.get(op, "block")
|
|
57
|
+
|
|
58
|
+
def row_limit(self, operation: str) -> int | None:
|
|
59
|
+
if operation == "update":
|
|
60
|
+
return self.update_rows
|
|
61
|
+
if operation == "delete":
|
|
62
|
+
return self.delete_rows
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
def with_env_approvals_cleared(self) -> "Policy":
|
|
66
|
+
"""Human approve clears environment 'approval' rules only (block stays)."""
|
|
67
|
+
rules = {
|
|
68
|
+
op: ("allow" if rule == "approval" else rule)
|
|
69
|
+
for op, rule in self.rules.items()
|
|
70
|
+
}
|
|
71
|
+
return Policy(
|
|
72
|
+
environment=self.environment,
|
|
73
|
+
rules=rules,
|
|
74
|
+
update_rows=self.update_rows,
|
|
75
|
+
delete_rows=self.delete_rows,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _normalize_rules(raw: Any) -> dict[str, str]:
|
|
80
|
+
src = dict(PRODUCTION_DEFAULTS["rules"])
|
|
81
|
+
if isinstance(raw, dict):
|
|
82
|
+
for key, value in raw.items():
|
|
83
|
+
k = str(key).lower()
|
|
84
|
+
v = str(value).lower()
|
|
85
|
+
if k in VALID_OPS and v in VALID_RULES:
|
|
86
|
+
src[k] = v
|
|
87
|
+
return {k: src[k] for k in VALID_OPS}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def policy_from_dict(raw: dict[str, Any] | None = None) -> Policy:
|
|
91
|
+
data = raw or {}
|
|
92
|
+
limits = data.get("limits") or {}
|
|
93
|
+
return Policy(
|
|
94
|
+
environment=str(data.get("environment") or PRODUCTION_DEFAULTS["environment"]),
|
|
95
|
+
rules=_normalize_rules(data.get("rules")),
|
|
96
|
+
update_rows=int(limits.get("update_rows", PRODUCTION_DEFAULTS["limits"]["update_rows"])),
|
|
97
|
+
delete_rows=int(limits.get("delete_rows", PRODUCTION_DEFAULTS["limits"]["delete_rows"])),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def load_policy(path: Path | str | None = None) -> Policy:
|
|
102
|
+
policy_path = Path(path) if path else POLICY_PATH
|
|
103
|
+
if not policy_path.exists():
|
|
104
|
+
return policy_from_dict(PRODUCTION_DEFAULTS)
|
|
105
|
+
with policy_path.open(encoding="utf-8") as fh:
|
|
106
|
+
raw = yaml.safe_load(fh) or {}
|
|
107
|
+
if not isinstance(raw, dict):
|
|
108
|
+
return policy_from_dict(PRODUCTION_DEFAULTS)
|
|
109
|
+
return policy_from_dict(raw)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def production_policy() -> Policy:
|
|
113
|
+
return policy_from_dict(PRODUCTION_DEFAULTS)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def demo_policy() -> Policy:
|
|
117
|
+
return policy_from_dict(DEMO_DEFAULTS)
|
write_gate/db.py
ADDED
write_gate/decision.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Unified decision model for the write gate."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
ACTION_ALLOW = "ALLOW"
|
|
9
|
+
ACTION_BLOCK = "BLOCK"
|
|
10
|
+
ACTION_APPROVAL = "REQUIRE_APPROVAL"
|
|
11
|
+
|
|
12
|
+
RISK_LOW = "low"
|
|
13
|
+
RISK_MEDIUM = "medium"
|
|
14
|
+
RISK_CRITICAL = "critical"
|
|
15
|
+
|
|
16
|
+
VERDICT_PASS = "PASS"
|
|
17
|
+
VERDICT_WARN = "WARN"
|
|
18
|
+
VERDICT_APPROVAL = "APPROVAL"
|
|
19
|
+
VERDICT_BLOCK = "BLOCK"
|
|
20
|
+
|
|
21
|
+
RULE_OK = "ok"
|
|
22
|
+
RULE_PII = "pii_column"
|
|
23
|
+
RULE_RESTRICTED = "restricted_column"
|
|
24
|
+
RULE_EXPIRED = "expired_partition"
|
|
25
|
+
RULE_SCHEMA = "schema_mismatch"
|
|
26
|
+
RULE_DELETE_NO_WHERE = "delete_without_where"
|
|
27
|
+
RULE_UPDATE_NO_WHERE = "update_without_where"
|
|
28
|
+
RULE_DROP_TABLE = "drop_table"
|
|
29
|
+
RULE_TRUNCATE = "truncate_table"
|
|
30
|
+
RULE_ALTER_TABLE = "alter_table"
|
|
31
|
+
RULE_BLAST = "blast_radius_exceeded"
|
|
32
|
+
RULE_ENV = "environment_policy"
|
|
33
|
+
RULE_RAW_DB_CLI = "raw_db_cli"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class GuardResult:
|
|
38
|
+
"""One guard's verdict. Engine reduces a list of these to a Decision."""
|
|
39
|
+
|
|
40
|
+
name: str
|
|
41
|
+
verdict: str = VERDICT_PASS
|
|
42
|
+
rule_id: str | None = None
|
|
43
|
+
reason: str = ""
|
|
44
|
+
risk: str = RISK_LOW
|
|
45
|
+
evidence: dict[str, Any] = field(default_factory=dict)
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def pass_(cls, name: str, **kwargs: Any) -> "GuardResult":
|
|
49
|
+
return cls(name=name, verdict=VERDICT_PASS, **kwargs)
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def warn(cls, name: str, rule_id: str, reason: str, **kwargs: Any) -> "GuardResult":
|
|
53
|
+
return cls(
|
|
54
|
+
name=name,
|
|
55
|
+
verdict=VERDICT_WARN,
|
|
56
|
+
rule_id=rule_id,
|
|
57
|
+
reason=reason,
|
|
58
|
+
risk=kwargs.pop("risk", RISK_MEDIUM),
|
|
59
|
+
**kwargs,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def approval(
|
|
64
|
+
cls,
|
|
65
|
+
name: str,
|
|
66
|
+
rule_id: str,
|
|
67
|
+
reason: str,
|
|
68
|
+
*,
|
|
69
|
+
risk: str = RISK_MEDIUM,
|
|
70
|
+
evidence: dict[str, Any] | None = None,
|
|
71
|
+
) -> "GuardResult":
|
|
72
|
+
return cls(
|
|
73
|
+
name=name,
|
|
74
|
+
verdict=VERDICT_APPROVAL,
|
|
75
|
+
rule_id=rule_id,
|
|
76
|
+
reason=reason,
|
|
77
|
+
risk=risk,
|
|
78
|
+
evidence=evidence or {},
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def block(
|
|
83
|
+
cls,
|
|
84
|
+
name: str,
|
|
85
|
+
rule_id: str,
|
|
86
|
+
reason: str,
|
|
87
|
+
*,
|
|
88
|
+
risk: str = RISK_CRITICAL,
|
|
89
|
+
evidence: dict[str, Any] | None = None,
|
|
90
|
+
) -> "GuardResult":
|
|
91
|
+
return cls(
|
|
92
|
+
name=name,
|
|
93
|
+
verdict=VERDICT_BLOCK,
|
|
94
|
+
rule_id=rule_id,
|
|
95
|
+
reason=reason,
|
|
96
|
+
risk=risk,
|
|
97
|
+
evidence=evidence or {},
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass
|
|
102
|
+
class Decision:
|
|
103
|
+
"""Final engine decision. `allowed` stays True only for ALLOW (execute path)."""
|
|
104
|
+
|
|
105
|
+
action: str
|
|
106
|
+
risk: str
|
|
107
|
+
rule_id: str
|
|
108
|
+
reason: str
|
|
109
|
+
evidence: dict[str, Any] = field(default_factory=dict)
|
|
110
|
+
sql: str = ""
|
|
111
|
+
operation: str | None = None
|
|
112
|
+
table: str | None = None
|
|
113
|
+
estimated_rows: int | None = None
|
|
114
|
+
approval_id: str | None = None
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def allowed(self) -> bool:
|
|
118
|
+
return self.action == ACTION_ALLOW
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def message(self) -> str:
|
|
122
|
+
return self.reason
|
|
123
|
+
|
|
124
|
+
def to_dict(self) -> dict[str, Any]:
|
|
125
|
+
payload = {
|
|
126
|
+
"allowed": self.allowed,
|
|
127
|
+
"rule_id": self.rule_id,
|
|
128
|
+
"message": self.reason,
|
|
129
|
+
"sql": self.sql,
|
|
130
|
+
"action": self.action,
|
|
131
|
+
"risk": self.risk,
|
|
132
|
+
"reason": self.reason,
|
|
133
|
+
"evidence": self.evidence,
|
|
134
|
+
"operation": self.operation,
|
|
135
|
+
"table": self.table,
|
|
136
|
+
"estimated_rows": self.estimated_rows,
|
|
137
|
+
"approval_id": self.approval_id,
|
|
138
|
+
}
|
|
139
|
+
return payload
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# Backward-compatible alias used by existing tests / demo.
|
|
143
|
+
Evidence = Decision
|
write_gate/engine.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Policy engine: run guards, reduce to a single Decision.
|
|
2
|
+
|
|
3
|
+
any BLOCK → BLOCK; else any APPROVAL → REQUIRE_APPROVAL; else ALLOW.
|
|
4
|
+
Guard order prefers specific dangerous-SQL rules over environment policy.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any, Callable
|
|
11
|
+
|
|
12
|
+
from write_gate.catalog import Catalog
|
|
13
|
+
from write_gate.config import Policy, production_policy
|
|
14
|
+
from write_gate.decision import (
|
|
15
|
+
ACTION_ALLOW,
|
|
16
|
+
ACTION_APPROVAL,
|
|
17
|
+
ACTION_BLOCK,
|
|
18
|
+
RISK_LOW,
|
|
19
|
+
RULE_OK,
|
|
20
|
+
VERDICT_APPROVAL,
|
|
21
|
+
VERDICT_BLOCK,
|
|
22
|
+
Decision,
|
|
23
|
+
GuardResult,
|
|
24
|
+
)
|
|
25
|
+
from write_gate.guards import (
|
|
26
|
+
check_blast_radius,
|
|
27
|
+
check_destructive,
|
|
28
|
+
check_environment,
|
|
29
|
+
check_freshness,
|
|
30
|
+
check_pii,
|
|
31
|
+
check_schema,
|
|
32
|
+
)
|
|
33
|
+
from write_gate.adapters.base import BACKEND_DUCKDB, sqlglot_dialect
|
|
34
|
+
from write_gate.parser import ParsedSQL, parse
|
|
35
|
+
|
|
36
|
+
GuardFn = Callable[["Context"], GuardResult]
|
|
37
|
+
|
|
38
|
+
# Destructive first so DELETE without WHERE reports delete_without_where
|
|
39
|
+
# even when environment also blocks DELETE.
|
|
40
|
+
GUARDS: list[GuardFn] = [
|
|
41
|
+
check_destructive,
|
|
42
|
+
check_schema,
|
|
43
|
+
check_pii,
|
|
44
|
+
check_freshness,
|
|
45
|
+
check_blast_radius,
|
|
46
|
+
check_environment,
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class Context:
|
|
52
|
+
sql: str
|
|
53
|
+
parsed: ParsedSQL
|
|
54
|
+
catalog: Catalog
|
|
55
|
+
policy: Policy
|
|
56
|
+
conn: Any | None = None
|
|
57
|
+
dialect: str = BACKEND_DUCKDB
|
|
58
|
+
guard_results: list[GuardResult] = field(default_factory=list)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def evaluate(
|
|
62
|
+
sql: str,
|
|
63
|
+
catalog: Catalog,
|
|
64
|
+
policy: Policy | None = None,
|
|
65
|
+
conn: Any | None = None,
|
|
66
|
+
dialect: str = BACKEND_DUCKDB,
|
|
67
|
+
) -> Decision:
|
|
68
|
+
parsed = parse(sql, dialect=sqlglot_dialect(dialect))
|
|
69
|
+
ctx = Context(
|
|
70
|
+
sql=sql,
|
|
71
|
+
parsed=parsed,
|
|
72
|
+
catalog=catalog,
|
|
73
|
+
policy=policy or production_policy(),
|
|
74
|
+
conn=conn,
|
|
75
|
+
dialect=dialect,
|
|
76
|
+
)
|
|
77
|
+
results = [guard(ctx) for guard in GUARDS]
|
|
78
|
+
ctx.guard_results = results
|
|
79
|
+
return reduce(ctx, results)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def reduce(ctx: Context, results: list[GuardResult]) -> Decision:
|
|
83
|
+
parsed = ctx.parsed
|
|
84
|
+
estimated = _first_estimated(results)
|
|
85
|
+
evidence_acc: dict[str, Any] = {}
|
|
86
|
+
for result in results:
|
|
87
|
+
if result.evidence:
|
|
88
|
+
evidence_acc[result.name] = result.evidence
|
|
89
|
+
|
|
90
|
+
blocks = [r for r in results if r.verdict == VERDICT_BLOCK]
|
|
91
|
+
approvals = [r for r in results if r.verdict == VERDICT_APPROVAL]
|
|
92
|
+
|
|
93
|
+
if blocks:
|
|
94
|
+
chosen = blocks[0]
|
|
95
|
+
return Decision(
|
|
96
|
+
action=ACTION_BLOCK,
|
|
97
|
+
risk=chosen.risk,
|
|
98
|
+
rule_id=chosen.rule_id or RULE_OK,
|
|
99
|
+
reason=chosen.reason,
|
|
100
|
+
evidence={**evidence_acc, **chosen.evidence},
|
|
101
|
+
sql=ctx.sql,
|
|
102
|
+
operation=parsed.operation,
|
|
103
|
+
table=parsed.table,
|
|
104
|
+
estimated_rows=estimated if estimated is not None else chosen.evidence.get("estimated_rows"),
|
|
105
|
+
)
|
|
106
|
+
if approvals:
|
|
107
|
+
chosen = approvals[0]
|
|
108
|
+
return Decision(
|
|
109
|
+
action=ACTION_APPROVAL,
|
|
110
|
+
risk=chosen.risk,
|
|
111
|
+
rule_id=chosen.rule_id or RULE_OK,
|
|
112
|
+
reason=chosen.reason,
|
|
113
|
+
evidence={**evidence_acc, **chosen.evidence},
|
|
114
|
+
sql=ctx.sql,
|
|
115
|
+
operation=parsed.operation,
|
|
116
|
+
table=parsed.table,
|
|
117
|
+
estimated_rows=estimated if estimated is not None else chosen.evidence.get("estimated_rows"),
|
|
118
|
+
)
|
|
119
|
+
return Decision(
|
|
120
|
+
action=ACTION_ALLOW,
|
|
121
|
+
risk=RISK_LOW,
|
|
122
|
+
rule_id=RULE_OK,
|
|
123
|
+
reason=_allow_reason(parsed),
|
|
124
|
+
evidence=evidence_acc,
|
|
125
|
+
sql=ctx.sql,
|
|
126
|
+
operation=parsed.operation,
|
|
127
|
+
table=parsed.table,
|
|
128
|
+
estimated_rows=estimated,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _first_estimated(results: list[GuardResult]) -> int | None:
|
|
133
|
+
for result in results:
|
|
134
|
+
value = result.evidence.get("estimated_rows") if result.evidence else None
|
|
135
|
+
if isinstance(value, int):
|
|
136
|
+
return value
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _allow_reason(parsed: ParsedSQL) -> str:
|
|
141
|
+
if parsed.operation == "select":
|
|
142
|
+
return "只读 SELECT,绕过写库门禁"
|
|
143
|
+
table = parsed.table or "?"
|
|
144
|
+
if parsed.operation == "insert":
|
|
145
|
+
return f"写入通过门禁: 表 {table},列 {parsed.write_columns},分区未过期且不含 PII"
|
|
146
|
+
if parsed.operation == "update":
|
|
147
|
+
return f"UPDATE 通过门禁: 表 {table},列 {parsed.write_columns}"
|
|
148
|
+
if parsed.operation == "delete":
|
|
149
|
+
return f"DELETE 通过门禁: 表 {table}"
|
|
150
|
+
return f"{parsed.operation.upper()} 通过门禁: 表 {table}"
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Guard functions. Each returns PASS | WARN | APPROVAL | BLOCK."""
|
|
2
|
+
|
|
3
|
+
from write_gate.guards.blast_radius import check_blast_radius
|
|
4
|
+
from write_gate.guards.destructive import check_destructive
|
|
5
|
+
from write_gate.guards.environment import check_environment
|
|
6
|
+
from write_gate.guards.freshness import check_freshness
|
|
7
|
+
from write_gate.guards.pii import check_pii
|
|
8
|
+
from write_gate.guards.schema import check_schema
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"check_blast_radius",
|
|
12
|
+
"check_destructive",
|
|
13
|
+
"check_environment",
|
|
14
|
+
"check_freshness",
|
|
15
|
+
"check_pii",
|
|
16
|
+
"check_schema",
|
|
17
|
+
]
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Blast-radius guard: estimate affected rows for UPDATE/DELETE before execute."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from write_gate.adapters.base import BACKEND_DUCKDB, count_sql, sqlglot_dialect
|
|
6
|
+
from write_gate.decision import RULE_BLAST, RISK_CRITICAL, GuardResult
|
|
7
|
+
from write_gate.parser import where_sql
|
|
8
|
+
|
|
9
|
+
NAME = "blast_radius"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def check_blast_radius(ctx) -> GuardResult:
|
|
13
|
+
parsed = ctx.parsed
|
|
14
|
+
if parsed.statement is None or parsed.error:
|
|
15
|
+
return GuardResult.pass_(NAME)
|
|
16
|
+
if parsed.operation not in {"update", "delete"}:
|
|
17
|
+
return GuardResult.pass_(NAME)
|
|
18
|
+
|
|
19
|
+
table = parsed.table
|
|
20
|
+
if not table:
|
|
21
|
+
return GuardResult.pass_(NAME)
|
|
22
|
+
|
|
23
|
+
limit = ctx.policy.row_limit(parsed.operation)
|
|
24
|
+
if limit is None:
|
|
25
|
+
return GuardResult.pass_(NAME)
|
|
26
|
+
|
|
27
|
+
conn = ctx.conn
|
|
28
|
+
if conn is None:
|
|
29
|
+
return GuardResult.pass_(
|
|
30
|
+
NAME,
|
|
31
|
+
evidence={"skipped": True, "reason": "no connection to estimate rows"},
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
dialect = getattr(ctx, "dialect", BACKEND_DUCKDB)
|
|
35
|
+
estimated = _estimate_rows(conn, table, parsed.where, dialect=dialect)
|
|
36
|
+
evidence = {
|
|
37
|
+
"estimated_rows": estimated,
|
|
38
|
+
"max_rows": limit,
|
|
39
|
+
"operation": parsed.operation,
|
|
40
|
+
"table": table,
|
|
41
|
+
}
|
|
42
|
+
if estimated is None:
|
|
43
|
+
return GuardResult.pass_(NAME, evidence={**evidence, "skipped": True})
|
|
44
|
+
if estimated > limit:
|
|
45
|
+
return GuardResult.block(
|
|
46
|
+
NAME,
|
|
47
|
+
RULE_BLAST,
|
|
48
|
+
(
|
|
49
|
+
f"{parsed.operation.upper()} on {table} would affect {estimated} rows, "
|
|
50
|
+
f"exceeding max {limit}"
|
|
51
|
+
),
|
|
52
|
+
risk=RISK_CRITICAL,
|
|
53
|
+
evidence=evidence,
|
|
54
|
+
)
|
|
55
|
+
return GuardResult.pass_(NAME, evidence=evidence)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _estimate_rows(conn, table: str, where, dialect: str = BACKEND_DUCKDB) -> int | None:
|
|
59
|
+
# Table name comes from the parser identifier, not raw user interpolation of extra SQL.
|
|
60
|
+
if not table.isidentifier():
|
|
61
|
+
return None
|
|
62
|
+
predicate = where_sql(where, dialect=sqlglot_dialect(dialect))
|
|
63
|
+
sql = count_sql(table, predicate, backend=dialect)
|
|
64
|
+
try:
|
|
65
|
+
row = conn.execute(sql).fetchone()
|
|
66
|
+
except Exception:
|
|
67
|
+
return None
|
|
68
|
+
if not row:
|
|
69
|
+
return 0
|
|
70
|
+
try:
|
|
71
|
+
return int(row[0])
|
|
72
|
+
except (TypeError, ValueError):
|
|
73
|
+
return None
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Dangerous SQL guard: unbounded DELETE/UPDATE, DROP, TRUNCATE, ALTER."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from sqlglot import exp
|
|
6
|
+
|
|
7
|
+
from write_gate.decision import (
|
|
8
|
+
RULE_ALTER_TABLE,
|
|
9
|
+
RULE_DELETE_NO_WHERE,
|
|
10
|
+
RULE_DROP_TABLE,
|
|
11
|
+
RULE_TRUNCATE,
|
|
12
|
+
RULE_UPDATE_NO_WHERE,
|
|
13
|
+
GuardResult,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
NAME = "destructive"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def check_destructive(ctx) -> GuardResult:
|
|
20
|
+
parsed = ctx.parsed
|
|
21
|
+
stmt = parsed.statement
|
|
22
|
+
if stmt is None:
|
|
23
|
+
return GuardResult.pass_(NAME)
|
|
24
|
+
|
|
25
|
+
if isinstance(stmt, exp.Delete) and not parsed.has_where:
|
|
26
|
+
table = parsed.table or "?"
|
|
27
|
+
return GuardResult.block(
|
|
28
|
+
NAME,
|
|
29
|
+
RULE_DELETE_NO_WHERE,
|
|
30
|
+
f"DELETE without a WHERE clause is forbidden (full-table delete on {table})",
|
|
31
|
+
evidence={"table": table, "has_where": False},
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
if isinstance(stmt, exp.Update) and not parsed.has_where:
|
|
35
|
+
table = parsed.table or "?"
|
|
36
|
+
return GuardResult.block(
|
|
37
|
+
NAME,
|
|
38
|
+
RULE_UPDATE_NO_WHERE,
|
|
39
|
+
f"UPDATE without a WHERE clause is forbidden (full-table update on {table})",
|
|
40
|
+
evidence={"table": table, "has_where": False},
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
if isinstance(stmt, exp.Drop):
|
|
44
|
+
kind = (stmt.args.get("kind") or "TABLE")
|
|
45
|
+
kind_s = str(kind).upper()
|
|
46
|
+
table = parsed.table or ident_fallback(stmt)
|
|
47
|
+
if kind_s in {"TABLE", "VIEW", ""} or kind is None:
|
|
48
|
+
return GuardResult.block(
|
|
49
|
+
NAME,
|
|
50
|
+
RULE_DROP_TABLE,
|
|
51
|
+
f"DROP {kind_s or 'TABLE'} {table} is forbidden",
|
|
52
|
+
evidence={"table": table, "kind": kind_s},
|
|
53
|
+
)
|
|
54
|
+
return GuardResult.block(
|
|
55
|
+
NAME,
|
|
56
|
+
RULE_DROP_TABLE,
|
|
57
|
+
f"DROP {kind_s} is forbidden",
|
|
58
|
+
evidence={"kind": kind_s},
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
if isinstance(stmt, exp.TruncateTable) or type(stmt).__name__ == "TruncateTable":
|
|
62
|
+
table = parsed.table or "?"
|
|
63
|
+
return GuardResult.block(
|
|
64
|
+
NAME,
|
|
65
|
+
RULE_TRUNCATE,
|
|
66
|
+
f"TRUNCATE TABLE {table} is forbidden",
|
|
67
|
+
evidence={"table": table},
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
if isinstance(stmt, exp.Alter) or type(stmt).__name__ in {"Alter", "AlterTable"}:
|
|
71
|
+
table = parsed.table or "?"
|
|
72
|
+
return GuardResult.block(
|
|
73
|
+
NAME,
|
|
74
|
+
RULE_ALTER_TABLE,
|
|
75
|
+
f"ALTER TABLE {table} is forbidden",
|
|
76
|
+
evidence={"table": table},
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
return GuardResult.pass_(NAME)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def ident_fallback(stmt) -> str:
|
|
83
|
+
this = getattr(stmt, "this", None)
|
|
84
|
+
name = getattr(this, "name", None) if this is not None else None
|
|
85
|
+
return str(name).lower() if name else "?"
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Environment policy: per-operation allow | block | approval."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from write_gate.decision import RULE_ENV, RISK_CRITICAL, RISK_MEDIUM, GuardResult
|
|
6
|
+
|
|
7
|
+
NAME = "environment"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def check_environment(ctx) -> GuardResult:
|
|
11
|
+
parsed = ctx.parsed
|
|
12
|
+
if parsed.statement is None or parsed.error:
|
|
13
|
+
return GuardResult.pass_(NAME)
|
|
14
|
+
|
|
15
|
+
operation = parsed.operation if parsed.operation != "unknown" else "ddl"
|
|
16
|
+
rule = ctx.policy.rule_for(operation)
|
|
17
|
+
env = ctx.policy.environment
|
|
18
|
+
evidence = {
|
|
19
|
+
"environment": env,
|
|
20
|
+
"operation": operation,
|
|
21
|
+
"policy_rule": rule,
|
|
22
|
+
}
|
|
23
|
+
if rule == "allow":
|
|
24
|
+
return GuardResult.pass_(NAME, evidence=evidence)
|
|
25
|
+
if rule == "approval":
|
|
26
|
+
return GuardResult.approval(
|
|
27
|
+
NAME,
|
|
28
|
+
RULE_ENV,
|
|
29
|
+
f"{operation.upper()} requires approval in {env}",
|
|
30
|
+
risk=RISK_MEDIUM,
|
|
31
|
+
evidence=evidence,
|
|
32
|
+
)
|
|
33
|
+
# block
|
|
34
|
+
risk = RISK_CRITICAL if operation in {"delete", "ddl", "update"} else RISK_MEDIUM
|
|
35
|
+
return GuardResult.block(
|
|
36
|
+
NAME,
|
|
37
|
+
RULE_ENV,
|
|
38
|
+
f"{operation.upper()} is blocked by policy in {env}",
|
|
39
|
+
risk=risk,
|
|
40
|
+
evidence=evidence,
|
|
41
|
+
)
|