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/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
@@ -0,0 +1,5 @@
1
+ """Compatibility shim. Prefer write_gate.adapters.duckdb."""
2
+
3
+ from write_gate.adapters.duckdb import ORDERS_DDL, connect, execute_user_sql
4
+
5
+ __all__ = ["ORDERS_DDL", "connect", "execute_user_sql"]
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
+ )