Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,9 @@ seed/warehouse.duckdb
seed/warehouse.duckdb.wal
seed/orders.csv
.logs/*.jsonl
.logs/*.sqlite
.logs/*.lock
.logs/approval.key
.logs/demo_audit.jsonl
build/
dist/
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,30 @@

All notable changes to **sql-write-gate** are documented here.

## [1.1.0] — 2026-09-06

### SQLGuard depth (evolve-in-place)

- **AST patterns** (`ast_patterns` + `ast_guard`): cartesian / missing-predicate JOINs BLOCK; tautology WHERE (`1=1` / `TRUE`) clears `has_where` so destructive still owns full-table UPDATE/DELETE; DDL dangerous flags on findings.
- **Pluggable rule registry** (`registry.GuardRegistry`): register/unregister guards without forking `engine.py`; built-ins load via `default_registry()`.
- **Permissions** in `policy.yaml` (`permissions.tables` / `allow_tables` + `enforced`): table×operation allowlists (GameStream-style). Offline-safe when unset.
- **Numeric risk** on `Decision`: `risk_score` 0–100 + `risk_factors` (from guard results + AST flags + optional EXPLAIN).
- **Optional EXPLAIN cost** (`explain.py` + `explain_cost` guard): adapters when `conn` present; skips cleanly offline / when threshold unset.
- **Schema hallucination**: unknown table/column → `schema_hallucination` (catalog allowlist); SELECT path checks referenced tables/columns; `allow_unknown_tables/columns` knobs.
- **Audit**: `actor`, `model_id`, `prompt_summary`, `sql`, `risk`/`risk_score`, `latency_ms`, `success`, `decision`.
- **DataPilot BLOCK/EXECUTE**: stable `datapilot` verb on MCP (`datapilot_block_or_execute`), CLI `datapilot` / `serve` (HTTP `POST /v1/check|/v1/execute`), Python `write_gate.datapilot` / `write_gate.api`.
- Preserve freshness + PII behavior from 1.0.x.

### Docs / tests

- README: testable/demoable first (AST / registry / permissions / risk / DataPilot).
- Tests: `tests/test_v110.py`; v1.0.1 suite accepts `>= 1.0.1`.

### Notes

- Keep **非生产唯一边界 / 非唯一边界**.
- Package version **1.1.0**.

## [1.0.1] — 2026-09-06

### Fixes (pilot-ready / 非唯一边界 unchanged)
Expand Down
60 changes: 55 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
# sql-write-gate
# sql-write-gate (SQLGuard)

[![CI](https://github.com/tangyf07/sql-write-gate/actions/workflows/ci.yml/badge.svg)](https://github.com/tangyf07/sql-write-gate/actions/workflows/ci.yml)
[![Release](https://img.shields.io/github/v/release/tangyf07/sql-write-gate)](https://github.com/tangyf07/sql-write-gate/releases/latest)
[![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://www.python.org/downloads/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)

写库前门禁 · Policy firewall for AI agents writing to databases.
写库前门禁 · **SQLGuard** — policy firewall for AI agents writing to databases.

Prevent Claude Code, Codex, Cursor and MCP agents from executing unsafe database operations.

```
Agent SQL ──► sql-write-gate ──► ALLOW / BLOCK / APPROVAL ──► Database
DataPilot / Agent SQL ──► SQLGuard (sql-write-gate) ──► ALLOW / BLOCK / APPROVAL ──► Database
```

Deterministic policy engine (sqlglot AST + catalog + policy.yaml). **No LLM. No API key.**

> **v1.0.1 — pilot-ready on the declared support matrix** (DuckDB / PostgreSQL / MySQL / SQLite + listed SQL features + entrypoints below).
> **v1.1.0SQLGuard** — stronger AST analysis, permissions, risk scores, schema hallucination block, DataPilot HTTP API; pilot-ready on the declared support matrix (DuckDB / PostgreSQL / MySQL / SQLite + listed SQL features + entrypoints below).
> **非生产唯一边界 / 非唯一边界** — **not** the sole production DB security boundary. Combine with least-privilege DB roles, network isolation, and human workflows.
> **未列语法拒绝** — unsupported / ambiguous SQL → REJECT/BLOCK (`unsupported_sql`, fail closed), never silent ALLOW as read-only.

Expand All @@ -41,6 +41,18 @@ sql-write-gate check "DELETE FROM users"
# → BLOCKED rule=delete_without_where
```

## Try it

```bash
make install && make test && make demo
sql-write-gate check "DELETE FROM orders"
# → BLOCKED delete_without_where
sql-write-gate datapilot --json "SELECT o.order_id FROM orders o CROSS JOIN orders p"
# → {"datapilot": "BLOCK", "rule_id": "cartesian_join", ...}
sql-write-gate serve --port 8787
# POST /v1/check {"sql": "..."} → action + risk_score + datapilot
```

## Entrypoints (stable)

| Entrypoint | Role |
Expand All @@ -51,6 +63,7 @@ sql-write-gate check "DELETE FROM users"
| CLI `proxy` | Gate then execute if ALLOW |
| CLI `approve` / `resolve` / `reject` | Human approve / recover (trusted executor + token) |
| CLI `audit` / `pending` / `init` / `exec` | Ops helpers |
| CLI `serve` | SQLGuard DataPilot HTTP (`/v1/check`, `/v1/execute`) |

```bash
sql-write-gate check "SQL" # evaluate SQL; no execute
Expand All @@ -63,6 +76,39 @@ sql-write-gate audit # TIME / SOURCE / OP / TABLE / VERDICT
sql-write-gate init # scaffold policy.yaml + catalog.json
```

## DataPilot API contract (SQLGuard)

DataPilot calls this gate **outbound**. Prefer MCP `query_sql` / `write_sql`, CLI `check` / `exec` / `proxy`, or HTTP:

```bash
sql-write-gate serve --host 127.0.0.1 --port 8787
```

| Method | Path | Behavior |
|--------|------|----------|
| `GET` | `/healthz` | `{ok, product: SQLGuard, version}` |
| `POST` | `/v1/check` | Evaluate only → `action` + `risk_score` (`executed: false`) |
| `POST` | `/v1/execute` | Gate then execute **only on ALLOW** |
| `POST` | `/v1/block` | Alias of `/v1/check` |

Request JSON: `{ "sql": "...", "actor"?, "model_id"?, "prompt_summary"?, "database"?, "policy"? }`.

Response always includes `action` (`ALLOW` \| `BLOCK` \| `REQUIRE_APPROVAL`), `rule_id`, `reason`, `risk_score`, `risk_factors`, `executed`. Treat anything other than `ALLOW` as non-executing.

### GameStream-style permissions

```yaml
permissions:
enforce: true
tables:
orders: [select, insert, update]
hallucination:
allow_unknown_tables: false
allow_unknown_columns: false
```

Python alias: `from write_gate import sqlguard` (product helpers); package/CLI names unchanged.

## Declared databases (supported)

| Backend | How to connect | Notes |
Expand Down Expand Up @@ -91,7 +137,11 @@ Anything **not** in this matrix (other warehouses, wire-protocol proxies, distri
## What it does (on the matrix)

- `DROP` / `TRUNCATE` / `ALTER` → BLOCK
- `DELETE` / `UPDATE` without `WHERE` → BLOCK
- `DELETE` / `UPDATE` without `WHERE` (incl. tautology `WHERE 1=1` / `TRUE`) → BLOCK
- Cartesian / missing-predicate JOINs → BLOCK (`cartesian_join`)
- Unknown tables/columns → BLOCK (`schema_hallucination`) with evidence
- GameStream-style `permissions.tables` allowlists in `policy.yaml`
- Numeric `risk_score` (0–100) + `risk_factors` on every Decision
- Blast-radius COUNT vs `update_rows` / `delete_rows` (dialect quoting; fail-closed on estimate error)
- Schema / PII / restricted columns; PII `SELECT` → REQUIRE_APPROVAL (approve executes once)
- Freshness partitions (`dt`); range / NOT / OR / UPSERT SET expired → BLOCK
Expand Down
8 changes: 8 additions & 0 deletions examples/policy.demo.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,11 @@ rules:
limits:
update_rows: 10000
delete_rows: 10000
# SQLGuard: GameStream-style table allowlist (demo allows orders fully)
permissions:
enforce: true
tables:
orders: [select, insert, update, delete]
hallucination:
allow_unknown_tables: false
allow_unknown_columns: false
8 changes: 8 additions & 0 deletions examples/policy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,11 @@ rules:
limits:
update_rows: 100
delete_rows: 50
# SQLGuard 1.1 — GameStream-style table permissions (DataPilot / GameStream)
permissions:
enforce: true
tables:
orders: [select, insert, update]
hallucination:
allow_unknown_tables: false
allow_unknown_columns: false
7 changes: 7 additions & 0 deletions policy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,10 @@ rules:
limits:
update_rows: 100
delete_rows: 50
permissions:
enforce: true
tables:
orders: [select, insert, update]
hallucination:
allow_unknown_tables: false
allow_unknown_columns: false
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[project]
name = "sql-write-gate"
version = "1.0.1"
description = "Policy firewall for AI agents writing to databases (sql-write-gate)"
version = "1.1.0"
description = "SQLGuard — policy firewall for AI agents writing to databases (sql-write-gate)"
readme = "README.md"
license = { text = "MIT" }
requires-python = ">=3.11"
Expand Down
58 changes: 54 additions & 4 deletions scripts/demo.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Print the three canonical gate cases: legal / expired / PII. No API key."""
"""SQLGuard demo: classic three cases + AST / hallucination / risk / audit."""

from __future__ import annotations

Expand All @@ -16,11 +16,28 @@
from write_gate.paths import DB_PATH, DEMO_POLICY_PATH # noqa: E402
from write_gate.wrapper import WriteGate # noqa: E402


CASES = [
("用例 1 · 合法写入(新鲜分区 + 非 PII 列)", LEGAL_WRITE_SQL, True, "ok"),
("用例 2 · 过期分区写入", EXPIRED_WRITE_SQL, False, "expired_partition"),
("用例 3 · PII 列写入", PII_WRITE_SQL, False, "pii_column"),
(
"用例 4 · AST · DELETE WHERE 1=1(全表写)",
"DELETE FROM orders WHERE 1=1",
False,
"delete_without_where",
),
(
"用例 5 · AST · CROSS JOIN(笛卡尔积)",
"SELECT o.order_id FROM orders o CROSS JOIN orders p",
False,
"cartesian_join",
),
(
"用例 6 · Schema hallucination · 未知表",
"SELECT * FROM game_stream_sessions",
False,
"schema_hallucination",
),
]


Expand All @@ -33,14 +50,27 @@ def _banner(title: str) -> None:

def main() -> int:
rc = 0
with WriteGate(db_path=DB_PATH, policy_path=DEMO_POLICY_PATH) as gate:
audit_path = ROOT / ".logs" / "demo_audit.jsonl"
if audit_path.exists():
audit_path.unlink()
with WriteGate(
db_path=DB_PATH,
policy_path=DEMO_POLICY_PATH,
audit_path=audit_path,
agent="demo",
actor="sqlguard-demo",
model_id="demo-offline",
prompt_summary="make demo SQLGuard cases",
) as gate:
for title, sql, expect_allow, expect_rule in CASES:
_banner(title)
print(f"SQL:\n {sql}")
evidence, result = gate.execute(sql)
verdict = "ALLOWED" if evidence.allowed else "BLOCKED"
print(f"VERDICT: {verdict}")
print(f"rule_id: {evidence.rule_id}")
print(f"risk_score: {evidence.risk_score}")
print(f"risk_factors: {evidence.risk_factors}")
print(f"message: {evidence.message}")
print("evidence:")
print(json.dumps(evidence.to_dict(), ensure_ascii=False, indent=2))
Expand All @@ -57,8 +87,28 @@ def main() -> int:
)
rc = 1
print()

_banner("Audit · last lines (actor / risk_score / latency_ms)")
if audit_path.is_file():
lines = audit_path.read_text(encoding="utf-8").strip().splitlines()
for line in lines[-4:]:
rec = json.loads(line)
print(
json.dumps(
{
"actor": rec.get("actor"),
"decision": rec.get("decision"),
"rule_id": rec.get("rule_id"),
"risk_score": rec.get("risk_score"),
"latency_ms": rec.get("latency_ms"),
"success": rec.get("success"),
"sql": (rec.get("sql") or "")[:60],
},
ensure_ascii=False,
)
)
if rc == 0:
print("demo: three cases matched expected verdicts")
print("demo: SQLGuard cases matched expected verdicts")
return rc


Expand Down
18 changes: 18 additions & 0 deletions scripts/demo_walkthrough.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,26 @@ def _count_order(db_path: Path, order_id: int) -> int:
conn.close()


def _ensure_demo_approval_trust() -> None:
"""Trusted-executor token for approve/resolve in this offline demo."""
key = ROOT / ".logs" / "approval.key"
key.parent.mkdir(parents=True, exist_ok=True)
secret = "sqlguard-demo-approval-secret"
if not key.is_file():
key.write_text(secret + "\n", encoding="utf-8")
try:
key.chmod(0o600)
except OSError:
pass
else:
secret = key.read_text(encoding="utf-8").strip() or secret
os.environ["SQL_WRITE_GATE_APPROVAL_KEY_FILE"] = str(key)
os.environ["SQL_WRITE_GATE_APPROVAL_TOKEN"] = secret


def main() -> int:
os.chdir(ROOT)
_ensure_demo_approval_trust()
gate = _gate_cmd()
print(f"walkthrough CLI: {gate[0]}")

Expand Down
6 changes: 3 additions & 3 deletions src/write_gate/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""sql-write-gate: policy firewall for AI agents writing to databases."""
"""sql-write-gate (SQLGuard): policy firewall for AI agents writing to databases."""

from write_gate.decision import Decision, Evidence
from write_gate.wrapper import WriteGate

__all__ = ["WriteGate", "Evidence", "Decision"]
__version__ = "1.0.1"
__all__ = ["WriteGate", "Evidence", "Decision", "__version__"]
__version__ = "1.1.0"
Loading
Loading