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
@@ -0,0 +1,102 @@
1
+ """Freshness guard: expired partitions and stale/non-writable tables."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import date
6
+
7
+ from write_gate.decision import RULE_EXPIRED, RULE_SCHEMA, RISK_MEDIUM, GuardResult
8
+ from write_gate.parser import partition_dates_from_insert, partition_dates_from_where
9
+
10
+ NAME = "freshness"
11
+
12
+
13
+ def check_freshness(ctx) -> GuardResult:
14
+ parsed = ctx.parsed
15
+ if parsed.statement is None or parsed.error:
16
+ return GuardResult.pass_(NAME)
17
+ if parsed.operation not in {"insert", "update", "delete"}:
18
+ return GuardResult.pass_(NAME)
19
+
20
+ table_name = parsed.table
21
+ if not table_name:
22
+ return GuardResult.pass_(NAME)
23
+ spec = ctx.catalog.table(table_name)
24
+ if spec is None:
25
+ return GuardResult.pass_(NAME)
26
+
27
+ if spec.stale or not spec.writable:
28
+ return GuardResult.block(
29
+ NAME,
30
+ RULE_EXPIRED,
31
+ f"表 {spec.name} 已标记为 stale/不可写,拒绝全部写入",
32
+ risk=RISK_MEDIUM,
33
+ evidence={"table": spec.name, "stale": spec.stale, "writable": spec.writable},
34
+ )
35
+
36
+ part = spec.partition_column
37
+ if not part:
38
+ return GuardResult.pass_(NAME)
39
+
40
+ if parsed.operation == "insert":
41
+ rows = parsed.insert_rows or []
42
+ cols = parsed.write_columns
43
+ dates = partition_dates_from_insert(cols, rows, part)
44
+ return _evaluate_dates(
45
+ spec,
46
+ ctx.catalog,
47
+ dates,
48
+ missing_ok=False,
49
+ missing_message=f"INSERT 必须显式写出分区列 {part}",
50
+ )
51
+
52
+ dates = partition_dates_from_where(parsed.where, part)
53
+ # UPDATE/DELETE: if WHERE names an expired partition, block.
54
+ # If WHERE exists but does not mention the partition, let blast_radius handle scope.
55
+ return _evaluate_dates(
56
+ spec,
57
+ ctx.catalog,
58
+ dates,
59
+ missing_ok=True,
60
+ missing_message=(
61
+ f"{parsed.operation.upper()} 必须在 WHERE 中约束分区列 {part},避免误写过期分区"
62
+ ),
63
+ )
64
+
65
+
66
+ def _evaluate_dates(
67
+ spec,
68
+ catalog,
69
+ partition_dates: list[date | None],
70
+ *,
71
+ missing_ok: bool,
72
+ missing_message: str,
73
+ ) -> GuardResult:
74
+ present = [d for d in partition_dates if d is not None]
75
+ if not present and not missing_ok:
76
+ return GuardResult.block(
77
+ NAME,
78
+ RULE_SCHEMA,
79
+ missing_message,
80
+ risk=RISK_MEDIUM,
81
+ evidence={"partition_column": spec.partition_column},
82
+ )
83
+ cutoff = catalog.cutoff_date
84
+ expired = [d for d in present if d < cutoff]
85
+ if expired:
86
+ worst = min(expired)
87
+ return GuardResult.block(
88
+ NAME,
89
+ RULE_EXPIRED,
90
+ (
91
+ f"分区 {spec.partition_column}={worst.isoformat()} 早于新鲜度截止日期 "
92
+ f"{cutoff.isoformat()}(as_of={catalog.as_of_date.isoformat()}, "
93
+ f"freshness_days={catalog.freshness_days}),拒绝写入"
94
+ ),
95
+ risk=RISK_MEDIUM,
96
+ evidence={
97
+ "partition_column": spec.partition_column,
98
+ "partition_value": worst.isoformat(),
99
+ "cutoff": cutoff.isoformat(),
100
+ },
101
+ )
102
+ return GuardResult.pass_(NAME)
@@ -0,0 +1,84 @@
1
+ """PII / restricted-column guard.
2
+
3
+ Writes to PII or restricted columns are BLOCK.
4
+ SELECT of PII columns is REQUIRE_APPROVAL (not silent allow).
5
+ SELECT of restricted columns is BLOCK.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from write_gate.decision import RULE_PII, RULE_RESTRICTED, RISK_CRITICAL, RISK_MEDIUM, GuardResult
11
+
12
+ NAME = "pii"
13
+
14
+
15
+ def check_pii(ctx) -> GuardResult:
16
+ parsed = ctx.parsed
17
+ if parsed.statement is None or parsed.error:
18
+ return GuardResult.pass_(NAME)
19
+
20
+ table_name = parsed.table
21
+ if not table_name:
22
+ return GuardResult.pass_(NAME)
23
+ spec = ctx.catalog.table(table_name)
24
+ if spec is None:
25
+ return GuardResult.pass_(NAME)
26
+
27
+ if parsed.operation in {"insert", "update"}:
28
+ cols = list(parsed.write_columns)
29
+ restricted_hit = [c for c in cols if c in spec.restricted_columns]
30
+ if restricted_hit:
31
+ return GuardResult.block(
32
+ NAME,
33
+ RULE_RESTRICTED,
34
+ (
35
+ f"禁止写入受限列 {restricted_hit}"
36
+ f"(表 {spec.name} 的 restricted_columns={sorted(spec.restricted_columns)})"
37
+ ),
38
+ risk=RISK_CRITICAL,
39
+ evidence={"columns": restricted_hit, "table": spec.name},
40
+ )
41
+ pii_hit = [c for c in cols if c in spec.pii_columns]
42
+ if pii_hit:
43
+ return GuardResult.block(
44
+ NAME,
45
+ RULE_PII,
46
+ (
47
+ f"禁止写入 PII 列 {pii_hit}"
48
+ f"(表 {spec.name} 的 pii_columns={sorted(spec.pii_columns)})"
49
+ ),
50
+ risk=RISK_CRITICAL,
51
+ evidence={"columns": pii_hit, "table": spec.name},
52
+ )
53
+ return GuardResult.pass_(NAME)
54
+
55
+ if parsed.operation == "select":
56
+ selected = _selected_columns(parsed, spec)
57
+ restricted_hit = [c for c in selected if c in spec.restricted_columns]
58
+ if restricted_hit:
59
+ return GuardResult.block(
60
+ NAME,
61
+ RULE_RESTRICTED,
62
+ f"禁止 SELECT 受限列 {restricted_hit}(表 {spec.name})",
63
+ risk=RISK_CRITICAL,
64
+ evidence={"columns": restricted_hit, "table": spec.name},
65
+ )
66
+ pii_hit = [c for c in selected if c in spec.pii_columns]
67
+ if pii_hit:
68
+ return GuardResult.approval(
69
+ NAME,
70
+ RULE_PII,
71
+ (
72
+ f"SELECT 包含 PII 列 {pii_hit},需要审批"
73
+ f"(表 {spec.name} 的 pii_columns={sorted(spec.pii_columns)})"
74
+ ),
75
+ risk=RISK_MEDIUM,
76
+ evidence={"columns": pii_hit, "table": spec.name},
77
+ )
78
+ return GuardResult.pass_(NAME)
79
+
80
+
81
+ def _selected_columns(parsed, spec) -> list[str]:
82
+ if parsed.star:
83
+ return list(spec.columns.keys())
84
+ return list(parsed.select_columns or parsed.columns)
@@ -0,0 +1,147 @@
1
+ """Schema guard: parse errors, unknown table/column, type mismatch, unsupported SQL."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from sqlglot import exp
6
+
7
+ from write_gate.catalog import TableSpec
8
+ from write_gate.decision import RULE_SCHEMA, GuardResult
9
+ from write_gate.parser import literal_value, type_ok
10
+
11
+ NAME = "schema"
12
+
13
+
14
+ def check_schema(ctx) -> GuardResult:
15
+ parsed = ctx.parsed
16
+ if parsed.error:
17
+ return GuardResult.block(
18
+ NAME,
19
+ parsed.error_rule or RULE_SCHEMA,
20
+ parsed.error,
21
+ risk="medium",
22
+ )
23
+
24
+ stmt = parsed.statement
25
+ if stmt is None:
26
+ return GuardResult.block(NAME, RULE_SCHEMA, "SQL 无法解析", risk="medium")
27
+
28
+ operation = parsed.operation
29
+ if operation == "select":
30
+ return GuardResult.pass_(NAME)
31
+
32
+ if operation == "ddl":
33
+ # Destructive/environment guards own DROP/ALTER/TRUNCATE/CREATE.
34
+ return GuardResult.pass_(NAME)
35
+
36
+ if operation not in {"insert", "update", "delete"}:
37
+ return GuardResult.block(
38
+ NAME,
39
+ RULE_SCHEMA,
40
+ f"不支持的写语句类型 {type(stmt).__name__},仅允许 INSERT/UPDATE/DELETE",
41
+ risk="medium",
42
+ )
43
+
44
+ table_name = parsed.table
45
+ if not table_name:
46
+ return GuardResult.block(NAME, RULE_SCHEMA, "无法从 SQL 中解析目标表", risk="medium")
47
+
48
+ spec = ctx.catalog.table(table_name)
49
+ if spec is None:
50
+ return GuardResult.block(
51
+ NAME,
52
+ RULE_SCHEMA,
53
+ f"未知表 {table_name},不在目录中",
54
+ risk="medium",
55
+ evidence={"table": table_name},
56
+ )
57
+
58
+ if isinstance(stmt, exp.Insert):
59
+ return _check_insert(parsed, spec)
60
+ if isinstance(stmt, exp.Update):
61
+ return _check_update(parsed, spec)
62
+ return GuardResult.pass_(NAME)
63
+
64
+
65
+ def _check_insert(parsed, spec: TableSpec) -> GuardResult:
66
+ cols = list(parsed.write_columns)
67
+ if not cols:
68
+ cols = list(spec.columns.keys())
69
+ parsed.write_columns = cols
70
+ parsed.columns = cols
71
+ if any(c == "" for c in cols):
72
+ return GuardResult.block(NAME, RULE_SCHEMA, "INSERT 列名无法解析", risk="medium")
73
+
74
+ rows = parsed.insert_rows
75
+ if rows is None:
76
+ return GuardResult.block(
77
+ NAME,
78
+ RULE_SCHEMA,
79
+ "仅支持 INSERT ... VALUES (...); INSERT ... SELECT 未开放",
80
+ risk="medium",
81
+ )
82
+
83
+ for row in rows:
84
+ if len(row) != len(cols):
85
+ return GuardResult.block(
86
+ NAME,
87
+ RULE_SCHEMA,
88
+ f"INSERT 列数 {len(cols)} 与值个数 {len(row)} 不一致",
89
+ risk="medium",
90
+ )
91
+ assignments = dict(zip(cols, row))
92
+ failed = _columns_and_types(spec, cols, assignments)
93
+ if failed:
94
+ return failed
95
+ return GuardResult.pass_(NAME)
96
+
97
+
98
+ def _check_update(parsed, spec: TableSpec) -> GuardResult:
99
+ assignments = parsed.assignments
100
+ cols = list(assignments.keys())
101
+ if not cols:
102
+ return GuardResult.block(NAME, RULE_SCHEMA, "UPDATE 未解析到 SET 列", risk="medium")
103
+ failed = _columns_and_types(spec, cols, assignments)
104
+ if failed:
105
+ return failed
106
+ return GuardResult.pass_(NAME)
107
+
108
+
109
+ def _columns_and_types(
110
+ spec: TableSpec,
111
+ write_cols: list[str],
112
+ assignments: dict,
113
+ ) -> GuardResult | None:
114
+ unknown = [c for c in write_cols if c not in spec.columns]
115
+ if unknown:
116
+ return GuardResult.block(
117
+ NAME,
118
+ RULE_SCHEMA,
119
+ f"未知列 {unknown},表 {spec.name} 的列为 {sorted(spec.columns)}",
120
+ risk="medium",
121
+ evidence={"unknown_columns": unknown, "table": spec.name},
122
+ )
123
+
124
+ for col in write_cols:
125
+ node = assignments.get(col)
126
+ expected = spec.columns[col]
127
+ if node is not None and not type_ok(expected, node):
128
+ got = literal_value(node)
129
+ return GuardResult.block(
130
+ NAME,
131
+ RULE_SCHEMA,
132
+ f"列 {col} 类型不匹配: 期望 {expected},实际值 {got!r}",
133
+ risk="medium",
134
+ evidence={"column": col, "expected": expected, "value": got},
135
+ )
136
+
137
+ skip = spec.pii_columns | spec.restricted_columns
138
+ not_allowed = [c for c in write_cols if c not in spec.allowed_write_columns and c not in skip]
139
+ if not_allowed:
140
+ return GuardResult.block(
141
+ NAME,
142
+ RULE_SCHEMA,
143
+ f"列 {not_allowed} 不在允许写入列表 {sorted(spec.allowed_write_columns)}",
144
+ risk="medium",
145
+ evidence={"not_allowed": not_allowed},
146
+ )
147
+ return None
write_gate/hooks.py ADDED
@@ -0,0 +1,360 @@
1
+ """PreToolUse hook: agents cannot talk to the DB via raw psql / other DB CLIs.
2
+
3
+ The hook never executes user SQL. Extracted statements go through WriteGate.check
4
+ only. Interactive or unparseable DB shells are refused.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import re
11
+ import shlex
12
+ import sys
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Any, TextIO
16
+
17
+ from write_gate.decision import ACTION_ALLOW, ACTION_APPROVAL, RULE_RAW_DB_CLI, Decision
18
+ from write_gate.wrapper import WriteGate
19
+
20
+ DB_CLIS = ("psql", "mysql", "mysqlsh", "duckdb", "sqlite3")
21
+
22
+ _SQL_FLAGS: dict[str, tuple[str, ...]] = {
23
+ "psql": ("-c", "--command"),
24
+ "mysql": ("-e", "--execute"),
25
+ "mysqlsh": ("-e", "--execute"),
26
+ "duckdb": ("-c", "--cmd", "-s", "--sql"),
27
+ "sqlite3": ("-cmd",),
28
+ }
29
+
30
+ _WRAPPERS = {"sudo", "env", "command", "nohup", "time", "nice", "stdbuf", "timeout"}
31
+
32
+ _SHELL_OPS = {"&&", "||", "|", ";", "&"}
33
+
34
+ _DB_CLI_WORD = re.compile(
35
+ r"(?:^|[^\w.-])(psql|mysqlsh|mysql|duckdb|sqlite3)(?:$|[^\w.-])",
36
+ re.IGNORECASE,
37
+ )
38
+
39
+ _ENV_ASSIGN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
40
+
41
+ _SQL_HEAD = re.compile(
42
+ r"^(SELECT|INSERT|UPDATE|DELETE|DROP|TRUNCATE|ALTER|CREATE|WITH|COPY|"
43
+ r"REPLACE|MERGE|CALL|DO|BEGIN|EXPLAIN|VACUUM|ANALYZE|GRANT|REVOKE)\b",
44
+ re.IGNORECASE,
45
+ )
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class _Segment:
50
+ cli: str | None
51
+ sqls: tuple[str, ...]
52
+ raw: bool
53
+
54
+
55
+ def command_from_payload(payload: Any) -> str | None:
56
+ """Pull the bash command out of a Claude / Codex PreToolUse JSON object."""
57
+ if not isinstance(payload, dict):
58
+ return None
59
+ tool_input = payload.get("tool_input")
60
+ if isinstance(tool_input, dict):
61
+ for key in ("command", "cmd", "sql"):
62
+ value = tool_input.get(key)
63
+ if isinstance(value, str) and value.strip():
64
+ return value
65
+ elif isinstance(tool_input, str) and tool_input.strip():
66
+ return tool_input
67
+ for key in ("command", "cmd"):
68
+ value = payload.get(key)
69
+ if isinstance(value, str) and value.strip():
70
+ return value
71
+ return None
72
+
73
+
74
+ def parse_stdin_payload(text: str) -> Any | None:
75
+ text = (text or "").strip()
76
+ if not text:
77
+ return None
78
+ try:
79
+ return json.loads(text)
80
+ except json.JSONDecodeError:
81
+ return None
82
+
83
+
84
+ def _basename(token: str) -> str:
85
+ name = Path(token).name.lower()
86
+ if name.endswith(".exe"):
87
+ name = name[:-4]
88
+ return name
89
+
90
+
91
+ def _is_sql_flag(cli: str, token: str) -> bool:
92
+ return token in _SQL_FLAGS.get(cli, ())
93
+
94
+
95
+ def _looks_like_sql(text: str) -> bool:
96
+ return bool(_SQL_HEAD.match(text.strip().lstrip("(")))
97
+
98
+
99
+ def _skip_wrappers(tokens: list[str]) -> list[str]:
100
+ i = 0
101
+ n = len(tokens)
102
+ while i < n:
103
+ t = tokens[i]
104
+ if _ENV_ASSIGN.match(t):
105
+ i += 1
106
+ continue
107
+ name = _basename(t)
108
+ if name in _WRAPPERS:
109
+ i += 1
110
+ while i < n and tokens[i].startswith("-"):
111
+ i += 1
112
+ # timeout DURATION cmd
113
+ if name == "timeout" and i < n and not tokens[i].startswith("-"):
114
+ i += 1
115
+ continue
116
+ break
117
+ return tokens[i:]
118
+
119
+
120
+ def _split_segments(tokens: list[str]) -> list[list[str]]:
121
+ segments: list[list[str]] = []
122
+ current: list[str] = []
123
+ for t in tokens:
124
+ if t in _SHELL_OPS:
125
+ if current:
126
+ segments.append(current)
127
+ current = []
128
+ continue
129
+ current.append(t)
130
+ if current:
131
+ segments.append(current)
132
+ return segments
133
+
134
+
135
+ def _collect_sql_tokens(tokens: list[str], start: int) -> tuple[list[str], int]:
136
+ if start >= len(tokens) or tokens[start].startswith("-"):
137
+ return [], start
138
+ first = tokens[start]
139
+ if any(ch.isspace() for ch in first):
140
+ return [first], start + 1
141
+ parts: list[str] = []
142
+ i = start
143
+ while i < len(tokens) and not tokens[i].startswith("-"):
144
+ parts.append(tokens[i])
145
+ i += 1
146
+ return parts, i
147
+
148
+
149
+ def _extract_from_cli(cli: str, tokens: list[str]) -> tuple[list[str], bool]:
150
+ """Return (sql_strings, saw_sql_flag_without_value)."""
151
+ sqls: list[str] = []
152
+ incomplete_flag = False
153
+ positionals: list[str] = []
154
+ i = 1
155
+ while i < len(tokens):
156
+ t = tokens[i]
157
+ if t.startswith("--") and "=" in t:
158
+ flag, _, value = t.partition("=")
159
+ if _is_sql_flag(cli, flag):
160
+ extra, i = _collect_sql_tokens(tokens, i + 1)
161
+ joined = " ".join([value, *extra]).strip() if extra else value.strip()
162
+ if joined:
163
+ sqls.append(joined)
164
+ else:
165
+ incomplete_flag = True
166
+ continue
167
+ i += 1
168
+ continue
169
+ if _is_sql_flag(cli, t):
170
+ extra, i = _collect_sql_tokens(tokens, i + 1)
171
+ if extra:
172
+ sqls.append(" ".join(extra).strip())
173
+ else:
174
+ incomplete_flag = True
175
+ continue
176
+ if t.startswith("-"):
177
+ i += 1
178
+ if i < len(tokens) and not tokens[i].startswith("-"):
179
+ i += 1
180
+ continue
181
+ positionals.append(t)
182
+ i += 1
183
+
184
+ if sqls:
185
+ return sqls, incomplete_flag
186
+ if incomplete_flag:
187
+ return [], True
188
+ if cli in {"duckdb", "sqlite3"}:
189
+ if len(positionals) >= 2:
190
+ return [positionals[1]], False
191
+ if len(positionals) == 1 and _looks_like_sql(positionals[0]):
192
+ return [positionals[0]], False
193
+ return [], False
194
+
195
+
196
+ def inspect_bash(command: str) -> list[_Segment]:
197
+ """Inspect a bash command for DB CLIs. Empty list means not a DB CLI."""
198
+ try:
199
+ tokens = shlex.split(command, posix=True)
200
+ except ValueError:
201
+ match = _DB_CLI_WORD.search(command)
202
+ if match:
203
+ return [_Segment(cli=match.group(1).lower(), sqls=(), raw=True)]
204
+ return []
205
+
206
+ found: list[_Segment] = []
207
+ for raw_seg in _split_segments(tokens):
208
+ stripped = _skip_wrappers(raw_seg)
209
+ if not stripped:
210
+ continue
211
+ cli = _basename(stripped[0])
212
+ if cli not in DB_CLIS:
213
+ continue
214
+ sqls, incomplete = _extract_from_cli(cli, stripped)
215
+ if sqls and not incomplete:
216
+ found.append(_Segment(cli=cli, sqls=tuple(sqls), raw=False))
217
+ else:
218
+ found.append(_Segment(cli=cli, sqls=(), raw=True))
219
+ return found
220
+
221
+
222
+ def _emit_refuse(
223
+ stream: TextIO,
224
+ *,
225
+ rule_id: str,
226
+ reason: str,
227
+ extra: str | None = None,
228
+ ) -> None:
229
+ stream.write("BLOCKED\n")
230
+ stream.write(f"Rule: {rule_id}\n")
231
+ stream.write(f"Reason: {reason}\n")
232
+ if extra:
233
+ stream.write(extra.rstrip() + "\n")
234
+
235
+
236
+ def _emit_decision(stream: TextIO, decision: Decision) -> None:
237
+ extra = None
238
+ if decision.action == ACTION_APPROVAL:
239
+ extra = (
240
+ "REQUIRE_APPROVAL is refused by the hook so agents cannot silently write. "
241
+ "Use sql-write-gate check|exec with a human in the loop."
242
+ )
243
+ _emit_refuse(
244
+ stream,
245
+ rule_id=decision.rule_id,
246
+ reason=decision.reason,
247
+ extra=extra,
248
+ )
249
+
250
+
251
+ def _raw_cli_reason(cli: str) -> str:
252
+ return (
253
+ f"Raw {cli} is blocked (interactive or unparseable; no -c/--command SQL). "
254
+ "Use sql-write-gate check|exec. Do not open a raw DB shell."
255
+ )
256
+
257
+
258
+ def _gate(
259
+ *,
260
+ database: str | None,
261
+ db: str | None,
262
+ catalog: str | None,
263
+ policy: str | None,
264
+ agent: str,
265
+ ) -> WriteGate:
266
+ return WriteGate(
267
+ db_path=Path(db) if db else None,
268
+ database=database,
269
+ catalog_path=Path(catalog) if catalog else None,
270
+ policy_path=Path(policy) if policy else None,
271
+ agent=agent,
272
+ )
273
+
274
+
275
+ def _check_sql(
276
+ sql: str,
277
+ *,
278
+ database: str | None,
279
+ db: str | None,
280
+ catalog: str | None,
281
+ policy: str | None,
282
+ agent: str,
283
+ err: TextIO,
284
+ ) -> int:
285
+ with _gate(
286
+ database=database,
287
+ db=db,
288
+ catalog=catalog,
289
+ policy=policy,
290
+ agent=agent,
291
+ ) as gate:
292
+ decision = gate.check(sql)
293
+ if decision.action == ACTION_ALLOW:
294
+ return 0
295
+ _emit_decision(err, decision)
296
+ return 2
297
+
298
+
299
+ def run_hook(
300
+ *,
301
+ bash_command: str | None = None,
302
+ sql: str | None = None,
303
+ stdin_text: str | None = None,
304
+ database: str | None = None,
305
+ db: str | None = None,
306
+ catalog: str | None = None,
307
+ policy: str | None = None,
308
+ agent: str = "hook",
309
+ err: TextIO | None = None,
310
+ ) -> int:
311
+ """Evaluate a PreToolUse payload or test flags. Never executes user SQL.
312
+
313
+ Returns 0 (allow / not a DB CLI) or 2 (BLOCK / REQUIRE_APPROVAL / raw CLI).
314
+ """
315
+ err = err if err is not None else sys.stderr
316
+
317
+ if sql and sql.strip():
318
+ return _check_sql(
319
+ sql,
320
+ database=database,
321
+ db=db,
322
+ catalog=catalog,
323
+ policy=policy,
324
+ agent=agent,
325
+ err=err,
326
+ )
327
+
328
+ command = bash_command
329
+ if not command:
330
+ payload = parse_stdin_payload(stdin_text or "")
331
+ command = command_from_payload(payload) if payload is not None else None
332
+
333
+ if not command or not str(command).strip():
334
+ return 0
335
+
336
+ segments = inspect_bash(command)
337
+ if not segments:
338
+ return 0
339
+
340
+ for seg in segments:
341
+ if seg.raw or not seg.sqls:
342
+ _emit_refuse(
343
+ err,
344
+ rule_id=RULE_RAW_DB_CLI,
345
+ reason=_raw_cli_reason(seg.cli or "db-cli"),
346
+ )
347
+ return 2
348
+ for statement in seg.sqls:
349
+ rc = _check_sql(
350
+ statement,
351
+ database=database,
352
+ db=db,
353
+ catalog=catalog,
354
+ policy=policy,
355
+ agent=agent,
356
+ err=err,
357
+ )
358
+ if rc != 0:
359
+ return rc
360
+ return 0