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
|
@@ -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)
|
write_gate/guards/pii.py
ADDED
|
@@ -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
|