sqlguard-ninja 0.1.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 (68) hide show
  1. sqlguard_ninja/__init__.py +35 -0
  2. sqlguard_ninja/_data/benchmarks/datasets/v1/cases/01_destructive.yaml +169 -0
  3. sqlguard_ninja/_data/benchmarks/datasets/v1/cases/02_multi_statement_and_formatting.yaml +155 -0
  4. sqlguard_ninja/_data/benchmarks/datasets/v1/cases/03_table_and_column_access.yaml +205 -0
  5. sqlguard_ninja/_data/benchmarks/datasets/v1/cases/04_privacy.yaml +245 -0
  6. sqlguard_ninja/_data/benchmarks/datasets/v1/cases/05_tenant_isolation.yaml +167 -0
  7. sqlguard_ninja/_data/benchmarks/datasets/v1/cases/06_cte_nested_unicode.yaml +168 -0
  8. sqlguard_ninja/_data/benchmarks/datasets/v1/cases/07_legitimate_lookalike_cost_dialect.yaml +243 -0
  9. sqlguard_ninja/_data/benchmarks/datasets/v1/generated/grant_matrix.yaml +573 -0
  10. sqlguard_ninja/_data/benchmarks/datasets/v1/generated/label_matrix.yaml +403 -0
  11. sqlguard_ninja/_data/benchmarks/datasets/v1/generated/tenant_matrix.yaml +684 -0
  12. sqlguard_ninja/_data/benchmarks/datasets/v1/generated/transforms.yaml +927 -0
  13. sqlguard_ninja/_data/benchmarks/datasets/v1/manifest.yaml +15 -0
  14. sqlguard_ninja/_data/benchmarks/policies/retail_saas.yaml +259 -0
  15. sqlguard_ninja/_version.py +1 -0
  16. sqlguard_ninja/benchmarking/__init__.py +1 -0
  17. sqlguard_ninja/benchmarking/adapters.py +303 -0
  18. sqlguard_ninja/benchmarking/builtin.py +39 -0
  19. sqlguard_ninja/benchmarking/compare.py +168 -0
  20. sqlguard_ninja/benchmarking/dataset.py +265 -0
  21. sqlguard_ninja/benchmarking/fixtures.py +274 -0
  22. sqlguard_ninja/benchmarking/metrics.py +114 -0
  23. sqlguard_ninja/benchmarking/runner.py +126 -0
  24. sqlguard_ninja/benchmarking/translate.py +64 -0
  25. sqlguard_ninja/benchmarking/workers/_protocol.py +53 -0
  26. sqlguard_ninja/benchmarking/workers/agent_sql_guard_worker.py +40 -0
  27. sqlguard_ninja/benchmarking/workers/krish_sqlguard_worker.py +25 -0
  28. sqlguard_ninja/benchmarking/workers/sql_guardrail_worker.py +48 -0
  29. sqlguard_ninja/cli/__init__.py +1 -0
  30. sqlguard_ninja/cli/config.py +76 -0
  31. sqlguard_ninja/cli/main.py +433 -0
  32. sqlguard_ninja/cli/templates.py +91 -0
  33. sqlguard_ninja/core/__init__.py +1 -0
  34. sqlguard_ninja/core/analysis.py +581 -0
  35. sqlguard_ninja/core/findings.py +133 -0
  36. sqlguard_ninja/core/guard.py +256 -0
  37. sqlguard_ninja/core/rules/__init__.py +1 -0
  38. sqlguard_ninja/core/rules/access.py +127 -0
  39. sqlguard_ninja/core/rules/base.py +40 -0
  40. sqlguard_ninja/core/rules/catalog.py +61 -0
  41. sqlguard_ninja/core/rules/functions.py +197 -0
  42. sqlguard_ninja/core/rules/statements.py +99 -0
  43. sqlguard_ninja/core/rules/structure.py +72 -0
  44. sqlguard_ninja/core/rules/tenant.py +196 -0
  45. sqlguard_ninja/integrations/__init__.py +43 -0
  46. sqlguard_ninja/integrations/audit.py +109 -0
  47. sqlguard_ninja/integrations/checkpoint.py +342 -0
  48. sqlguard_ninja/integrations/executors.py +64 -0
  49. sqlguard_ninja/integrations/fastapi.py +89 -0
  50. sqlguard_ninja/integrations/langchain.py +67 -0
  51. sqlguard_ninja/integrations/langgraph.py +87 -0
  52. sqlguard_ninja/policy/__init__.py +39 -0
  53. sqlguard_ninja/policy/model.py +370 -0
  54. sqlguard_ninja/privacy/__init__.py +9 -0
  55. sqlguard_ninja/privacy/rules.py +286 -0
  56. sqlguard_ninja/privacy/suggest.py +46 -0
  57. sqlguard_ninja/py.typed +0 -0
  58. sqlguard_ninja/query_cost/__init__.py +1 -0
  59. sqlguard_ninja/query_cost/postgres.py +226 -0
  60. sqlguard_ninja/query_cost/static.py +241 -0
  61. sqlguard_ninja/reporting/__init__.py +1 -0
  62. sqlguard_ninja/reporting/markdown.py +137 -0
  63. sqlguard_ninja-0.1.0.dist-info/METADATA +370 -0
  64. sqlguard_ninja-0.1.0.dist-info/RECORD +68 -0
  65. sqlguard_ninja-0.1.0.dist-info/WHEEL +4 -0
  66. sqlguard_ninja-0.1.0.dist-info/entry_points.txt +2 -0
  67. sqlguard_ninja-0.1.0.dist-info/licenses/LICENSE +202 -0
  68. sqlguard_ninja-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +56 -0
@@ -0,0 +1,25 @@
1
+ """Worker for krish2105/sqlguard (import name ``sqlguard``; installed from git, MIT)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.metadata import version
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ exec((Path(__file__).parent / "_protocol.py").read_text(encoding="utf-8"))
10
+
11
+ from sqlguard import SqlGuard # type: ignore[import-not-found]
12
+
13
+
14
+ def build(config: dict[str, Any]): # type: ignore[no-untyped-def]
15
+ allow = {name: set(t["allow_columns"]) for name, t in config["tables"].items()}
16
+ guard = SqlGuard(allow, source_dialect=config["dialect"])
17
+
18
+ def evaluate(sql, dialect, context, _config): # type: ignore[no-untyped-def]
19
+ report = guard.check(sql)
20
+ return ("allow" if report.allowed else "deny"), list(report.errors)[:5]
21
+
22
+ return evaluate
23
+
24
+
25
+ serve("sqlguard (krish2105)", version("sqlguard"), build)
@@ -0,0 +1,48 @@
1
+ """Worker for nickusevich/sql-guardrail (import name ``sqlguard``). MIT licensed upstream."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.metadata import version
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ exec((Path(__file__).parent / "_protocol.py").read_text(encoding="utf-8"))
10
+
11
+ from sqlguard import Policy, verify # type: ignore[import-not-found]
12
+
13
+
14
+ def build(config: dict[str, Any]): # type: ignore[no-untyped-def]
15
+ tables = []
16
+ for name, t in config["tables"].items():
17
+ entry: dict[str, Any] = {
18
+ "name": name,
19
+ "allow_columns": t["allow_columns"],
20
+ "deny_columns": t["deny_columns"],
21
+ "large": False,
22
+ }
23
+ if t["tenant_column"]:
24
+ entry["require_predicate"] = [
25
+ {"column": t["tenant_column"], "op": "=", "value": "${tenant_id}"}
26
+ ]
27
+ tables.append(entry)
28
+ limits = {k: v for k, v in config["limits"].items() if v is not None}
29
+ data = {
30
+ "read_only": True,
31
+ "tables": tables,
32
+ "limits": {
33
+ "max_joins": limits.get("max_joins", 10),
34
+ "max_subquery_depth": limits.get("max_subquery_depth", 8),
35
+ "max_limit_value": limits.get("max_limit", 10_000),
36
+ },
37
+ }
38
+ policy = Policy.from_dict(data)
39
+
40
+ def evaluate(sql, dialect, context, _config): # type: ignore[no-untyped-def]
41
+ ctx = {k: v for k, v in context.items() if v is not None}
42
+ result = verify(sql, policy, context=ctx)
43
+ return ("allow" if result.allowed else "deny"), [v.code.value for v in result.violations]
44
+
45
+ return evaluate
46
+
47
+
48
+ serve("sql-guardrail", version("sql-guardrail"), build)
@@ -0,0 +1 @@
1
+ """Command-line interface."""
@@ -0,0 +1,76 @@
1
+ """Project configuration file (``sqlninja.yaml``) for CLI and CI use."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Literal
7
+
8
+ import yaml
9
+ from pydantic import BaseModel, ConfigDict, Field
10
+
11
+ from sqlguard_ninja.benchmarking.compare import Thresholds
12
+
13
+
14
+ class _M(BaseModel):
15
+ model_config = ConfigDict(extra="forbid")
16
+
17
+
18
+ class BenchmarkSection(_M):
19
+ dataset: str = "builtin:v1"
20
+ adapter: str = "native"
21
+ output: str = "results/latest.json"
22
+ report: str | None = "results/latest.md"
23
+ dialect: str | None = None
24
+
25
+
26
+ class CISection(_M):
27
+ baseline: str = "benchmarks/baselines/native.json"
28
+ max_security_regressions: int = 0
29
+ max_usability_regressions: int = 0
30
+ max_new_errors: int = 0
31
+ min_recall: float | None = None
32
+ max_false_positive_rate: float | None = None
33
+ max_p95_latency_increase_pct: float | None = None
34
+
35
+ def thresholds(self, allow_benchmark_change: bool = False) -> Thresholds:
36
+ return Thresholds(
37
+ max_security_regressions=self.max_security_regressions,
38
+ max_usability_regressions=self.max_usability_regressions,
39
+ max_new_errors=self.max_new_errors,
40
+ min_recall=self.min_recall,
41
+ max_false_positive_rate=self.max_false_positive_rate,
42
+ max_p95_latency_increase_pct=self.max_p95_latency_increase_pct,
43
+ allow_benchmark_change=allow_benchmark_change,
44
+ )
45
+
46
+
47
+ class ProjectConfig(_M):
48
+ version: Literal[1] = 1
49
+ policy: str | None = None
50
+ benchmark: BenchmarkSection = Field(default_factory=BenchmarkSection)
51
+ ci: CISection = Field(default_factory=CISection)
52
+
53
+ @classmethod
54
+ def load(cls, path: str | Path) -> tuple[ProjectConfig, Path]:
55
+ """Load the config; relative paths inside it are resolved against its directory."""
56
+ p = Path(path)
57
+ data = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
58
+ return cls.model_validate(data), p.resolve().parent
59
+
60
+
61
+ DEFAULT_CONFIG = """\
62
+ # sqlninja project configuration
63
+ version: 1
64
+ policy: policies/policy.yaml
65
+ benchmark:
66
+ dataset: benchmarks/cases # a directory with manifest.yaml
67
+ adapter: native
68
+ output: results/latest.json
69
+ report: results/latest.md
70
+ ci:
71
+ baseline: benchmarks/baseline.json # approved result; update only by explicit review
72
+ max_security_regressions: 0 # unsafe query newly allowed
73
+ max_usability_regressions: 0 # legitimate query newly blocked
74
+ max_new_errors: 0
75
+ min_recall: 0.95
76
+ """
@@ -0,0 +1,433 @@
1
+ """``sqlninja`` command-line interface.
2
+
3
+ Exit codes:
4
+
5
+ * ``validate``: 0 allow, 1 deny, 3 review, 2 usage or configuration error.
6
+ * ``benchmark compare`` / ``policy check``: 0 pass, 1 fail, 2 usage or configuration error.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import os
14
+ import shutil
15
+ import sys
16
+ from collections.abc import Sequence
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from sqlguard_ninja._version import __version__
21
+ from sqlguard_ninja.benchmarking import builtin
22
+ from sqlguard_ninja.benchmarking.adapters import (
23
+ EXTERNAL_TOOLS,
24
+ BenchmarkAdapter,
25
+ CallableAdapter,
26
+ NativeAdapter,
27
+ SubprocessAdapter,
28
+ )
29
+ from sqlguard_ninja.benchmarking.compare import compare
30
+ from sqlguard_ninja.benchmarking.dataset import DatasetError, load_dataset
31
+ from sqlguard_ninja.benchmarking.runner import load_result, run_benchmark, write_result
32
+ from sqlguard_ninja.cli.config import CISection, ProjectConfig
33
+ from sqlguard_ninja.cli.templates import FILES
34
+ from sqlguard_ninja.core.findings import Decision
35
+ from sqlguard_ninja.core.guard import Guard
36
+ from sqlguard_ninja.policy.model import Policy, PolicyError
37
+ from sqlguard_ninja.privacy.suggest import suggest_labels
38
+ from sqlguard_ninja.reporting.markdown import benchmark_report, comparison_report
39
+
40
+ EXIT_OK, EXIT_FAIL, EXIT_USAGE, EXIT_REVIEW = 0, 1, 2, 3
41
+
42
+
43
+ class CLIError(Exception):
44
+ """A user-facing error; printed without a traceback, exit code 2."""
45
+
46
+
47
+ def _out(text: str) -> None:
48
+ line = text + ("\n" if not text.endswith("\n") else "")
49
+ try:
50
+ sys.stdout.write(line)
51
+ except UnicodeEncodeError:
52
+ # Legacy consoles (e.g. Windows cp1252) cannot print symbols used in reports.
53
+ encoding = getattr(sys.stdout, "encoding", None) or "ascii"
54
+ sys.stdout.write(line.encode(encoding, errors="replace").decode(encoding))
55
+
56
+
57
+ def _err(text: str) -> None:
58
+ sys.stderr.write(text + "\n")
59
+
60
+
61
+ def _load_config(path: str | None) -> tuple[ProjectConfig, Path]:
62
+ if path is None:
63
+ default = Path("sqlninja.yaml")
64
+ if default.exists():
65
+ return ProjectConfig.load(default)
66
+ return ProjectConfig(), Path.cwd()
67
+ try:
68
+ return ProjectConfig.load(path)
69
+ except (OSError, ValueError) as exc:
70
+ raise CLIError(f"cannot load config {path}: {exc}") from exc
71
+
72
+
73
+ def _resolve(base: Path, value: str | None) -> Path | str | None:
74
+ if value is None:
75
+ return None
76
+ if value.startswith(builtin.PREFIX):
77
+ return value # resolved by builtin.resolve() where it is loaded
78
+ p = Path(value)
79
+ return p if p.is_absolute() else base / p
80
+
81
+
82
+ def _resolve_file(base: Path, value: str | None) -> Path | None:
83
+ """Resolve an output/baseline file path (never a ``builtin:`` reference)."""
84
+ if value is None:
85
+ return None
86
+ p = Path(value)
87
+ return p if p.is_absolute() else base / p
88
+
89
+
90
+ def _load_policy(path: str | Path | None) -> Policy:
91
+ if path is None:
92
+ raise CLIError(
93
+ "no policy given (use --policy FILE, --policy builtin:retail_saas, "
94
+ "or set 'policy' in sqlninja.yaml)"
95
+ )
96
+ try:
97
+ return Policy.from_file(builtin.resolve(path, "policy"))
98
+ except (PolicyError, FileNotFoundError) as exc:
99
+ raise CLIError(str(exc)) from exc
100
+
101
+
102
+ def _write_summary(markdown: str) -> None:
103
+ """Append to the GitHub Actions job summary when running in Actions."""
104
+ target = os.environ.get("GITHUB_STEP_SUMMARY")
105
+ if target:
106
+ with open(target, "a", encoding="utf-8") as fh:
107
+ fh.write(markdown + "\n")
108
+
109
+
110
+ # ------------------------------------------------------------------------------- commands
111
+ def cmd_validate(args: argparse.Namespace) -> int:
112
+ cfg, base = _load_config(args.config)
113
+ policy = _load_policy(args.policy or _resolve(base, cfg.policy))
114
+ if sum(x is not None for x in (args.sql, args.file)) + bool(args.stdin) != 1:
115
+ raise CLIError("give exactly one of --sql, --file or --stdin")
116
+ if args.sql is not None:
117
+ sql = args.sql
118
+ elif args.file is not None:
119
+ sql = Path(args.file).read_text(encoding="utf-8")
120
+ else:
121
+ sql = sys.stdin.read()
122
+ context: dict[str, Any] = {}
123
+ if args.context:
124
+ try:
125
+ context = json.loads(args.context)
126
+ except json.JSONDecodeError as exc:
127
+ raise CLIError(f"--context is not valid JSON: {exc}") from exc
128
+ if not isinstance(context, dict):
129
+ raise CLIError("--context must be a JSON object")
130
+ if args.tenant is not None:
131
+ context[policy.tenant.context_key] = args.tenant
132
+ result = Guard(policy).validate(sql, role=args.role, dialect=args.dialect, context=context)
133
+ if args.format == "json":
134
+ _out(result.model_dump_json(indent=2))
135
+ else:
136
+ _out(result.explain(include_detail=args.verbose))
137
+ return {Decision.ALLOW: EXIT_OK, Decision.DENY: EXIT_FAIL, Decision.REVIEW: EXIT_REVIEW}[
138
+ result.decision
139
+ ]
140
+
141
+
142
+ def cmd_policy_check(args: argparse.Namespace) -> int:
143
+ cfg, base = _load_config(args.config)
144
+ policy = _load_policy(args.policy or _resolve(base, cfg.policy))
145
+ tables = policy.catalog.tables
146
+ labelled = sum(1 for t in tables.values() for c in t.columns.values() if c.effective_labels())
147
+ _out(f"policy '{policy.name}' (version {policy.version}, dialect {policy.dialect}) is valid")
148
+ _out(f" tables: {len(tables)} labelled columns: {labelled} labels: {len(policy.labels)}")
149
+ _out(f" roles: {', '.join(policy.roles) or '(none: implicit role)'}")
150
+ tenant_tables = [n for n, t in tables.items() if t.tenant_column]
151
+ _out(f" tenant-scoped tables: {', '.join(tenant_tables) or '(none)'}")
152
+ warnings = []
153
+ if policy.functions.mode == "denylist":
154
+ warnings.append("functions.mode is 'denylist'; 'allowlist' is safer for production")
155
+ for name, role in policy.roles.items():
156
+ if not role.tenant_scoped and tenant_tables:
157
+ warnings.append(f"role '{name}' is not tenant-scoped")
158
+ for w in warnings:
159
+ _out(f" warning: {w}")
160
+ return EXIT_OK
161
+
162
+
163
+ def cmd_policy_suggest(args: argparse.Namespace) -> int:
164
+ cfg, base = _load_config(args.config)
165
+ policy = _load_policy(args.policy or _resolve(base, cfg.policy))
166
+ suggestions = suggest_labels(policy.catalog)
167
+ _out("# Suggestions only - review with the data owner before adding to the policy.")
168
+ if not suggestions:
169
+ _out("# no unlabelled columns matched the heuristics")
170
+ for s in suggestions:
171
+ _out(f"{s.table}.{s.column}: [{s.label}] # matched '{s.matched}'")
172
+ return EXIT_OK
173
+
174
+
175
+ def cmd_init(args: argparse.Namespace) -> int:
176
+ root = Path(args.directory)
177
+ existing = [name for name in FILES if (root / name).exists()]
178
+ if existing and not args.force:
179
+ raise CLIError(f"refusing to overwrite {existing}; use --force")
180
+ for name, text in FILES.items():
181
+ path = root / name
182
+ path.parent.mkdir(parents=True, exist_ok=True)
183
+ path.write_text(text, encoding="utf-8")
184
+ _out(f"wrote {path}")
185
+ _out("next: sqlninja benchmark run --config " + str(root / "sqlninja.yaml"))
186
+ return EXIT_OK
187
+
188
+
189
+ def _make_adapter(spec: str, policy: Policy, python: str | None) -> BenchmarkAdapter:
190
+ if spec == "native":
191
+ return NativeAdapter(policy)
192
+ if spec.startswith("callable:"):
193
+ return CallableAdapter.from_spec(spec.removeprefix("callable:"))
194
+ if spec.startswith("external:"):
195
+ name = spec.removeprefix("external:")
196
+ if name not in EXTERNAL_TOOLS:
197
+ raise CLIError(f"unknown external tool {name!r}; choose from {sorted(EXTERNAL_TOOLS)}")
198
+ interpreter = python or str(
199
+ Path(".venvs")
200
+ / name
201
+ / ("Scripts/python.exe" if sys.platform == "win32" else "bin/python")
202
+ )
203
+ if not Path(interpreter).exists():
204
+ raise CLIError(
205
+ f"no interpreter for {name} at {interpreter}; run "
206
+ "scripts/setup_external_adapters.py or pass --python"
207
+ )
208
+ return SubprocessAdapter(EXTERNAL_TOOLS[name], interpreter, policy)
209
+ raise CLIError("adapter must be 'native', 'external:<tool>' or 'callable:<module>:<fn>'")
210
+
211
+
212
+ def cmd_benchmark_run(args: argparse.Namespace) -> int:
213
+ cfg, base = _load_config(args.config)
214
+ bench = cfg.benchmark
215
+ dataset_path = args.dataset or _resolve(base, bench.dataset)
216
+ try:
217
+ dataset = load_dataset(builtin.resolve(str(dataset_path), "dataset"))
218
+ except (DatasetError, PolicyError, OSError) as exc:
219
+ raise CLIError(f"cannot load dataset {dataset_path}: {exc}") from exc
220
+ cases = dataset.filter(dialect=args.dialect or bench.dialect, category=args.category)
221
+ if not cases:
222
+ raise CLIError("no cases match the filters")
223
+ adapter = _make_adapter(args.adapter or bench.adapter, dataset.policy, args.python)
224
+ try:
225
+ result = run_benchmark(dataset, adapter, cases)
226
+ finally:
227
+ adapter.close()
228
+ output = Path(args.output) if args.output else _resolve_file(base, bench.output)
229
+ assert output is not None
230
+ write_result(result, output)
231
+ report = benchmark_report(result)
232
+ report_path = Path(args.report) if args.report else _resolve_file(base, bench.report)
233
+ if report_path is not None:
234
+ report_path.parent.mkdir(parents=True, exist_ok=True)
235
+ report_path.write_text(report + "\n", encoding="utf-8")
236
+ if args.summary:
237
+ _write_summary(report)
238
+ m = result["metrics"]["overall"]
239
+ _out(
240
+ f"{adapter.name} {adapter.version}: {m['cases']} evaluations, recall "
241
+ f"{m['recall']}, precision {m['precision']}, FPR {m['false_positive_rate']}, "
242
+ f"p95 {m['latency_ms']['p95']} ms -> {output}"
243
+ )
244
+ return EXIT_OK
245
+
246
+
247
+ def _thresholds(args: argparse.Namespace, ci: CISection) -> Any:
248
+ t = ci.thresholds(allow_benchmark_change=args.allow_benchmark_change)
249
+ t = type(t)(**{**t.__dict__, "allow_label_changes": args.allow_label_changes})
250
+ overrides = {
251
+ k: getattr(args, k)
252
+ for k in (
253
+ "max_security_regressions",
254
+ "max_usability_regressions",
255
+ "min_recall",
256
+ "max_false_positive_rate",
257
+ "max_p95_latency_increase_pct",
258
+ )
259
+ if getattr(args, k) is not None
260
+ }
261
+ return type(t)(**{**t.__dict__, **overrides})
262
+
263
+
264
+ def cmd_benchmark_compare(args: argparse.Namespace) -> int:
265
+ cfg, base = _load_config(args.config)
266
+ baseline_path = args.baseline or _resolve_file(base, cfg.ci.baseline)
267
+ candidate_path = args.candidate or _resolve_file(base, cfg.benchmark.output)
268
+ try:
269
+ baseline = load_result(str(baseline_path))
270
+ candidate = load_result(str(candidate_path))
271
+ except (OSError, ValueError) as exc:
272
+ raise CLIError(str(exc)) from exc
273
+ comp = compare(baseline, candidate, _thresholds(args, cfg.ci))
274
+ report = comparison_report(comp)
275
+ if args.report:
276
+ Path(args.report).parent.mkdir(parents=True, exist_ok=True)
277
+ Path(args.report).write_text(report + "\n", encoding="utf-8")
278
+ if args.json:
279
+ Path(args.json).parent.mkdir(parents=True, exist_ok=True)
280
+ Path(args.json).write_text(json.dumps(comp.as_dict(), indent=2) + "\n", encoding="utf-8")
281
+ if args.summary:
282
+ _write_summary(report)
283
+ _out(report)
284
+ return EXIT_OK if comp.passed else EXIT_FAIL
285
+
286
+
287
+ def cmd_benchmark_baseline(args: argparse.Namespace) -> int:
288
+ """Copy a reviewed result to the baseline path. Meant to be run by a human."""
289
+ cfg, base = _load_config(args.config)
290
+ source = Path(args.candidate) if args.candidate else _resolve_file(base, cfg.benchmark.output)
291
+ target = Path(args.output) if args.output else _resolve_file(base, cfg.ci.baseline)
292
+ assert source is not None and target is not None
293
+ if os.environ.get("CI") and not args.i_understand_this_is_ci:
294
+ raise CLIError("refusing to update a baseline inside CI; review and commit it locally")
295
+ load_result(source)
296
+ target.parent.mkdir(parents=True, exist_ok=True)
297
+ shutil.copyfile(source, target)
298
+ _out(f"baseline updated: {target} (commit it for review)")
299
+ return EXIT_OK
300
+
301
+
302
+ def cmd_report_generate(args: argparse.Namespace) -> int:
303
+ result = load_result(args.result)
304
+ text = benchmark_report(result)
305
+ if args.output:
306
+ Path(args.output).parent.mkdir(parents=True, exist_ok=True)
307
+ Path(args.output).write_text(text + "\n", encoding="utf-8")
308
+ else:
309
+ _out(text)
310
+ return EXIT_OK
311
+
312
+
313
+ def cmd_dataset_stats(args: argparse.Namespace) -> int:
314
+ cfg, base = _load_config(args.config)
315
+ path = args.dataset or _resolve(base, cfg.benchmark.dataset)
316
+ try:
317
+ ds = load_dataset(builtin.resolve(str(path), "dataset"))
318
+ except (DatasetError, PolicyError, OSError) as exc:
319
+ raise CLIError(str(exc)) from exc
320
+ _out(json.dumps({"fingerprint": ds.fingerprint, **ds.stats()}, indent=2))
321
+ return EXIT_OK
322
+
323
+
324
+ # --------------------------------------------------------------------------------- parser
325
+ def build_parser() -> argparse.ArgumentParser:
326
+ p = argparse.ArgumentParser(
327
+ prog="sqlninja",
328
+ description="Policy enforcement and security benchmarking for LLM-generated SQL.",
329
+ )
330
+ p.add_argument("--version", action="version", version=f"sqlninja {__version__}")
331
+ sub = p.add_subparsers(dest="command", required=True)
332
+
333
+ def common(sp: argparse.ArgumentParser) -> None:
334
+ sp.add_argument("--config", help="sqlninja.yaml (default: ./sqlninja.yaml if present)")
335
+
336
+ v = sub.add_parser("validate", help="validate one SQL statement")
337
+ common(v)
338
+ v.add_argument("--policy")
339
+ v.add_argument("--sql")
340
+ v.add_argument("--file")
341
+ v.add_argument("--stdin", action="store_true")
342
+ v.add_argument("--role")
343
+ v.add_argument("--dialect")
344
+ v.add_argument("--tenant", help="trusted tenant id (sets the policy's tenant context key)")
345
+ v.add_argument("--context", help="JSON object of trusted request context")
346
+ v.add_argument("--format", choices=["text", "json"], default="text")
347
+ v.add_argument("--verbose", "-v", action="store_true", help="include internal detail")
348
+ v.set_defaults(func=cmd_validate)
349
+
350
+ i = sub.add_parser("init", help="write a starter sqlninja.yaml, policy and test cases")
351
+ i.add_argument("directory", nargs="?", default=".")
352
+ i.add_argument("--force", action="store_true")
353
+ i.set_defaults(func=cmd_init)
354
+
355
+ pol = sub.add_parser("policy", help="policy utilities").add_subparsers(
356
+ dest="policy_cmd", required=True
357
+ )
358
+ pc = pol.add_parser("check", help="validate a policy file and print a summary")
359
+ common(pc)
360
+ pc.add_argument("--policy")
361
+ pc.set_defaults(func=cmd_policy_check)
362
+ ps = pol.add_parser("suggest", help="suggest labels for unlabelled columns (heuristic)")
363
+ common(ps)
364
+ ps.add_argument("--policy")
365
+ ps.set_defaults(func=cmd_policy_suggest)
366
+
367
+ bench = sub.add_parser("benchmark", help="run and compare benchmarks").add_subparsers(
368
+ dest="bench_cmd", required=True
369
+ )
370
+ br = bench.add_parser("run", help="evaluate a dataset with an adapter")
371
+ common(br)
372
+ br.add_argument("--dataset")
373
+ br.add_argument("--adapter", help="native | external:<tool> | callable:<module>:<function>")
374
+ br.add_argument("--python", help="interpreter for an external adapter's venv")
375
+ br.add_argument("--dialect")
376
+ br.add_argument("--category")
377
+ br.add_argument("--output")
378
+ br.add_argument("--report")
379
+ br.add_argument("--summary", action="store_true", help="append to GITHUB_STEP_SUMMARY")
380
+ br.set_defaults(func=cmd_benchmark_run)
381
+
382
+ bc = bench.add_parser("compare", help="compare a result with an approved baseline")
383
+ common(bc)
384
+ bc.add_argument("--baseline")
385
+ bc.add_argument("--candidate")
386
+ bc.add_argument("--report")
387
+ bc.add_argument("--json")
388
+ bc.add_argument("--summary", action="store_true")
389
+ bc.add_argument("--max-security-regressions", type=int)
390
+ bc.add_argument("--max-usability-regressions", type=int)
391
+ bc.add_argument("--min-recall", type=float)
392
+ bc.add_argument("--max-false-positive-rate", type=float)
393
+ bc.add_argument("--max-p95-latency-increase-pct", type=float)
394
+ bc.add_argument("--allow-benchmark-change", action="store_true")
395
+ bc.add_argument("--allow-label-changes", action="store_true")
396
+ bc.set_defaults(func=cmd_benchmark_compare)
397
+
398
+ bb = bench.add_parser("baseline", help="accept a reviewed result as the new baseline")
399
+ common(bb)
400
+ bb.add_argument("--candidate")
401
+ bb.add_argument("--output")
402
+ bb.add_argument("--i-understand-this-is-ci", action="store_true", help=argparse.SUPPRESS)
403
+ bb.set_defaults(func=cmd_benchmark_baseline)
404
+
405
+ rep = sub.add_parser("report", help="reports").add_subparsers(dest="report_cmd", required=True)
406
+ rg = rep.add_parser("generate", help="render a Markdown report from a result JSON")
407
+ rg.add_argument("--result", required=True)
408
+ rg.add_argument("--output")
409
+ rg.set_defaults(func=cmd_report_generate)
410
+
411
+ ds = sub.add_parser("dataset", help="dataset utilities").add_subparsers(
412
+ dest="dataset_cmd", required=True
413
+ )
414
+ dst = ds.add_parser("stats", help="validate a dataset and print statistics")
415
+ common(dst)
416
+ dst.add_argument("--dataset")
417
+ dst.set_defaults(func=cmd_dataset_stats)
418
+ return p
419
+
420
+
421
+ def main(argv: Sequence[str] | None = None) -> int:
422
+ parser = build_parser()
423
+ args = parser.parse_args(argv)
424
+ try:
425
+ code: int = args.func(args)
426
+ except CLIError as exc:
427
+ _err(f"error: {exc}")
428
+ return EXIT_USAGE
429
+ return code
430
+
431
+
432
+ if __name__ == "__main__": # pragma: no cover
433
+ raise SystemExit(main())
@@ -0,0 +1,91 @@
1
+ """Starter files written by ``sqlninja init``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from sqlguard_ninja.cli.config import DEFAULT_CONFIG
6
+
7
+ POLICY = """\
8
+ # Starter policy. Labels are assigned explicitly by you; nothing is inferred from names.
9
+ version: 1
10
+ name: starter
11
+ dialect: postgres
12
+ labels:
13
+ contact: {sensitivity: high, masking: return the email domain}
14
+ financial: {sensitivity: high, masking: aggregate}
15
+ catalog:
16
+ default_schema: public
17
+ tables:
18
+ customers:
19
+ tenant_column: tenant_id
20
+ columns:
21
+ id: int
22
+ tenant_id: int
23
+ name: text
24
+ email: {labels: [contact], alternatives: [email_domain]}
25
+ email_domain: text
26
+ orders:
27
+ tenant_column: tenant_id
28
+ columns:
29
+ id: int
30
+ tenant_id: int
31
+ customer_id: int
32
+ status: text
33
+ total: {type: numeric, labels: [financial]}
34
+ roles:
35
+ support:
36
+ tables: {customers: "*", orders: "*"}
37
+ labels: {contact: allow, financial: aggregate_only}
38
+ tenant:
39
+ context_key: tenant_id
40
+ parameter_names: [tenant_id]
41
+ """
42
+
43
+ MANIFEST = """\
44
+ name: my-sql-policy-tests
45
+ version: "0.1.0"
46
+ policy: ../../policies/policy.yaml
47
+ default_context: {tenant_id: 1}
48
+ case_files: [cases/*.yaml]
49
+ """
50
+
51
+ CASES = """\
52
+ defaults:
53
+ role: support
54
+ dialects: [postgres]
55
+ cases:
56
+ - id: OK-001
57
+ sql: SELECT o.id, o.status FROM orders o WHERE o.tenant_id = 1
58
+ category: legitimate_complex
59
+ expected: allow
60
+ explanation: Tenant-pinned order lookup.
61
+ - id: TEN-001
62
+ sql: SELECT o.id FROM orders o
63
+ category: tenant_isolation
64
+ expected: deny
65
+ reason: tenant_isolation
66
+ explanation: Missing tenant filter.
67
+ - id: PRV-001
68
+ sql: SELECT o.total FROM orders o WHERE o.tenant_id = 1
69
+ category: sensitive_field
70
+ expected: deny
71
+ reason: aggregate_only
72
+ explanation: Individual order totals are aggregate-only.
73
+ - id: PRV-002
74
+ sql: SELECT SUM(o.total) FROM orders o WHERE o.tenant_id = 1
75
+ category: sensitive_field
76
+ expected: allow
77
+ explanation: Aggregated totals are permitted.
78
+ - id: DES-001
79
+ sql: DELETE FROM orders WHERE tenant_id = 1
80
+ category: destructive
81
+ expected: deny
82
+ reason: write_statement
83
+ explanation: Writes are never allowed.
84
+ """
85
+
86
+ FILES = {
87
+ "sqlninja.yaml": DEFAULT_CONFIG,
88
+ "policies/policy.yaml": POLICY,
89
+ "benchmarks/cases/manifest.yaml": MANIFEST,
90
+ "benchmarks/cases/cases/examples.yaml": CASES,
91
+ }
@@ -0,0 +1 @@
1
+ """core package."""