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,15 @@
1
+ name: sqlguard-ninja-core
2
+ version: "1.0.0"
3
+ description: >
4
+ Labelled SQL policy-enforcement cases for a synthetic multi-tenant retail schema.
5
+ Expected outcomes follow the policy semantics in docs/guides/BENCHMARK_METHODOLOGY.md and
6
+ are independent of any particular validator.
7
+ policy: ../../policies/retail_saas.yaml
8
+ default_context:
9
+ tenant_id: 1
10
+ case_files:
11
+ - cases/*.yaml
12
+ - generated/*.yaml
13
+ generator:
14
+ script: scripts/generate_corpus.py
15
+ seed: 20261024
@@ -0,0 +1,259 @@
1
+ # Benchmark policy for the synthetic multi-tenant retail SaaS schema.
2
+ # All data described here is synthetic. Classification labels are assigned explicitly;
3
+ # SQLGuard Ninja never infers sensitivity from column names when enforcing.
4
+ version: 1
5
+ name: retail_saas
6
+ description: Multi-tenant retail platform with support, warehouse, HR, finance and reporting roles.
7
+ dialect: postgres
8
+
9
+ labels:
10
+ personal_identifier:
11
+ description: Directly identifies a natural person (name, date of birth).
12
+ sensitivity: high
13
+ masking: pseudonymise or aggregate
14
+ contact:
15
+ description: Email, phone or postal address.
16
+ sensitivity: high
17
+ masking: return the email domain or a masked value
18
+ location:
19
+ description: City-level location.
20
+ sensitivity: medium
21
+ financial:
22
+ description: Bank details and individual financial amounts.
23
+ sensitivity: high
24
+ masking: aggregate
25
+ payment_card:
26
+ description: Primary account number (PCI DSS scope).
27
+ sensitivity: critical
28
+ masking: use card_last4 and card_brand
29
+ hr:
30
+ description: Employment terms and performance.
31
+ sensitivity: high
32
+ masking: aggregate by department
33
+ government_id:
34
+ description: National identification numbers.
35
+ sensitivity: critical
36
+ special_category:
37
+ description: Health data (GDPR Art. 9) — configured explicitly for this schema.
38
+ sensitivity: critical
39
+ special_category: true
40
+ free_text:
41
+ description: Unstructured text that may contain personal data.
42
+ sensitivity: medium
43
+
44
+ catalog:
45
+ default_schema: public
46
+ tables:
47
+ tenants:
48
+ columns: {id: int, name: text, plan: text, created_at: timestamp}
49
+ estimated_rows: 500
50
+ customers:
51
+ tenant_column: tenant_id
52
+ estimated_rows: 2000000
53
+ columns:
54
+ id: {type: int, identifying: true}
55
+ tenant_id: int
56
+ full_name: {labels: [personal_identifier]}
57
+ email: {labels: [contact], alternatives: [email_domain]}
58
+ email_domain: text
59
+ phone: {labels: [contact]}
60
+ date_of_birth: {type: date, labels: [personal_identifier]}
61
+ country: text
62
+ city: {labels: [location]}
63
+ loyalty_tier: text
64
+ marketing_opt_in: boolean
65
+ created_at: timestamp
66
+ profile:
67
+ type: json
68
+ json_fields:
69
+ preferences.newsletter: []
70
+ preferences.language: []
71
+ health.allergies: [special_category]
72
+ payment.card_number: [payment_card]
73
+ orders:
74
+ tenant_column: tenant_id
75
+ estimated_rows: 10000000
76
+ columns:
77
+ id: {type: int, identifying: false}
78
+ tenant_id: int
79
+ customer_id: int
80
+ status: text
81
+ total_amount: numeric
82
+ currency: text
83
+ created_at: timestamp
84
+ shipping_address: {labels: [contact, location]}
85
+ order_items:
86
+ tenant_column: tenant_id
87
+ estimated_rows: 30000000
88
+ columns: {id: int, tenant_id: int, order_id: int, product_id: int, quantity: int, unit_price: numeric}
89
+ products:
90
+ tenant_column: tenant_id
91
+ estimated_rows: 50000
92
+ columns: {id: int, tenant_id: int, sku: text, name: text, category: text, price: numeric}
93
+ payments:
94
+ tenant_column: tenant_id
95
+ estimated_rows: 10000000
96
+ columns:
97
+ id: int
98
+ tenant_id: int
99
+ order_id: int
100
+ card_number: {labels: [payment_card], alternatives: [card_last4]}
101
+ card_last4: text
102
+ card_brand: text
103
+ iban: {labels: [financial]}
104
+ amount: numeric
105
+ status: text
106
+ shipments:
107
+ tenant_column: tenant_id
108
+ estimated_rows: 9000000
109
+ columns:
110
+ id: int
111
+ tenant_id: int
112
+ order_id: int
113
+ warehouse_id: int
114
+ carrier: text
115
+ tracking_number: text
116
+ status: text
117
+ shipped_at: timestamp
118
+ delivery_address: {labels: [contact, location]}
119
+ warehouses:
120
+ estimated_rows: 40
121
+ columns: {id: int, name: text, region: text}
122
+ employees:
123
+ estimated_rows: 5000
124
+ columns:
125
+ id: {type: int, identifying: true}
126
+ full_name: {labels: [personal_identifier]}
127
+ email: {labels: [contact]}
128
+ department: text
129
+ title: text
130
+ salary: {type: numeric, labels: [hr, financial]}
131
+ bank_account: {labels: [financial]}
132
+ national_id: {labels: [government_id]}
133
+ health_notes: {labels: [special_category]}
134
+ hire_date: date
135
+ manager_id: int
136
+ warehouse_id: int
137
+ support_tickets:
138
+ tenant_column: tenant_id
139
+ estimated_rows: 3000000
140
+ columns:
141
+ id: int
142
+ tenant_id: int
143
+ customer_id: int
144
+ subject: text
145
+ body: {labels: [free_text]}
146
+ status: text
147
+ priority: text
148
+ created_at: timestamp
149
+
150
+ roles:
151
+ support_analyst:
152
+ description: Customer support. Order status yes; full payment details no.
153
+ tables:
154
+ customers: [id, tenant_id, full_name, email, email_domain, country, city, loyalty_tier, created_at, profile]
155
+ orders: "*"
156
+ order_items: "*"
157
+ products: "*"
158
+ shipments: "*"
159
+ support_tickets: "*"
160
+ payments: [id, tenant_id, order_id, card_last4, card_brand, amount, status]
161
+ labels:
162
+ personal_identifier: allow
163
+ contact: allow
164
+ location: allow
165
+ free_text: allow
166
+ payment_card: deny
167
+ financial: deny
168
+ special_category: deny
169
+
170
+ warehouse_analyst:
171
+ description: Shipments and stock. No HR or customer contact data.
172
+ tables:
173
+ shipments: "*"
174
+ warehouses: "*"
175
+ products: "*"
176
+ order_items: "*"
177
+ orders: [id, tenant_id, status, created_at]
178
+ labels:
179
+ location: aggregate_only
180
+ unlisted_label_access: deny
181
+
182
+ hr_analyst:
183
+ description: HR operations. Salary only in aggregate; never national ids or health notes.
184
+ tenant_scoped: false
185
+ tables:
186
+ employees: "*"
187
+ warehouses: "*"
188
+ labels:
189
+ personal_identifier: allow
190
+ contact: allow
191
+ hr: allow
192
+ financial: aggregate_only
193
+ government_id: deny
194
+ special_category: deny
195
+
196
+ finance_analyst:
197
+ description: Revenue and payments, without card numbers or customer contact details.
198
+ tables:
199
+ orders: [id, tenant_id, customer_id, status, total_amount, currency, created_at]
200
+ order_items: "*"
201
+ products: "*"
202
+ payments: "*"
203
+ labels:
204
+ financial: allow
205
+ payment_card: deny
206
+
207
+ reporting_agent:
208
+ description: Automated reporting. Aggregates only for anything personal.
209
+ tables:
210
+ orders: [id, tenant_id, customer_id, status, total_amount, currency, created_at]
211
+ order_items: "*"
212
+ products: "*"
213
+ customers: [id, tenant_id, country, city, loyalty_tier, created_at, email_domain, email]
214
+ labels:
215
+ location: aggregate_only
216
+ contact: deny
217
+ limits:
218
+ max_limit: 1000
219
+ max_joins: 4
220
+ max_subquery_depth: 3
221
+
222
+ platform_admin:
223
+ description: Internal cross-tenant operations. Sensitive classes need review.
224
+ tenant_scoped: false
225
+ tables:
226
+ tenants: "*"
227
+ customers: "*"
228
+ orders: "*"
229
+ order_items: "*"
230
+ products: "*"
231
+ payments: "*"
232
+ shipments: "*"
233
+ warehouses: "*"
234
+ support_tickets: "*"
235
+ labels:
236
+ personal_identifier: allow
237
+ contact: allow
238
+ location: allow
239
+ free_text: allow
240
+ financial: review
241
+ payment_card: deny
242
+ special_category: deny
243
+
244
+ tenant:
245
+ context_key: tenant_id
246
+ parameter_names: [tenant_id]
247
+
248
+ functions:
249
+ mode: denylist
250
+
251
+ limits:
252
+ max_joins: 6
253
+ max_subquery_depth: 4
254
+ max_limit: 10000
255
+
256
+ cost:
257
+ review_score: 50
258
+ deny_score: 80
259
+ large_table_rows: 1000000
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1 @@
1
+ """Benchmark engine: corpus, adapters, runner, metrics, comparison and reports."""
@@ -0,0 +1,303 @@
1
+ """Benchmark adapters: a uniform interface over different SQL validators.
2
+
3
+ * :class:`NativeAdapter` — SQLGuard Ninja's own :class:`~sqlguard_ninja.Guard`.
4
+ * :class:`CallableAdapter` — any user function ``module:function``.
5
+ * :class:`SubprocessAdapter` — a worker script run by *another* Python interpreter
6
+ (one virtual environment per external tool), speaking JSON Lines over stdin/stdout.
7
+ This isolates tools whose dependencies or import names conflict.
8
+
9
+ An adapter declares the case categories its policy model can express (``capabilities``).
10
+ Reports show metrics on all cases and on the capability subset, so a tool is not
11
+ penalised in a headline number for features it never claimed.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import importlib
17
+ import json
18
+ import subprocess
19
+ import time
20
+ from collections.abc import Callable
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+ from typing import Any, Protocol
24
+
25
+ from sqlguard_ninja._version import __version__
26
+ from sqlguard_ninja.benchmarking.dataset import BenchmarkCase
27
+ from sqlguard_ninja.benchmarking.translate import translate_for_role
28
+ from sqlguard_ninja.core.findings import Decision
29
+ from sqlguard_ninja.core.guard import Guard
30
+ from sqlguard_ninja.policy.model import Policy
31
+
32
+ WORKERS_DIR = Path(__file__).parent / "workers"
33
+
34
+ ALL_CATEGORIES: frozenset[str] | None = None
35
+ """Sentinel: the adapter claims every category."""
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class AdapterVerdict:
40
+ decision: Decision
41
+ reasons: tuple[str, ...] = ()
42
+ latency_ms: float = 0.0
43
+ error: str | None = None
44
+ """Set when the validator crashed; the decision is then DENY (fail closed) and the
45
+ error is counted separately in reports."""
46
+
47
+
48
+ class BenchmarkAdapter(Protocol):
49
+ name: str
50
+
51
+ @property
52
+ def version(self) -> str: ...
53
+
54
+ @property
55
+ def capabilities(self) -> frozenset[str] | None: ...
56
+
57
+ def evaluate(self, case: BenchmarkCase) -> AdapterVerdict: ...
58
+
59
+ def close(self) -> None: ...
60
+
61
+
62
+ class NativeAdapter:
63
+ """Evaluate cases with SQLGuard Ninja's built-in guard."""
64
+
65
+ name = "native"
66
+
67
+ def __init__(self, policy: Policy, guard: Guard | None = None) -> None:
68
+ self.guard = guard or Guard(policy)
69
+
70
+ @property
71
+ def version(self) -> str:
72
+ return __version__
73
+
74
+ @property
75
+ def capabilities(self) -> frozenset[str] | None:
76
+ return ALL_CATEGORIES
77
+
78
+ def evaluate(self, case: BenchmarkCase) -> AdapterVerdict:
79
+ started = time.perf_counter()
80
+ result = self.guard.validate(
81
+ case.sql, role=case.role, dialect=case.dialect, context=case.context
82
+ )
83
+ elapsed = (time.perf_counter() - started) * 1000
84
+ reasons = tuple(f.rule_id for f in result.blocking_findings)
85
+ return AdapterVerdict(result.decision, reasons, elapsed)
86
+
87
+ def close(self) -> None:
88
+ return None
89
+
90
+
91
+ def _coerce_decision(value: Any) -> tuple[Decision, tuple[str, ...]]:
92
+ """Normalise the many result shapes a user validator may return."""
93
+ reasons: tuple[str, ...] = ()
94
+ if isinstance(value, tuple) and len(value) == 2:
95
+ value, raw = value
96
+ reasons = tuple(str(r) for r in (raw or ()))
97
+ if isinstance(value, dict):
98
+ reasons = tuple(str(r) for r in value.get("reasons", ()))
99
+ value = value.get("decision", value.get("allowed"))
100
+ if isinstance(value, Decision):
101
+ return value, reasons
102
+ if isinstance(value, bool):
103
+ return (Decision.ALLOW if value else Decision.DENY), reasons
104
+ if isinstance(value, str) and value.lower() in {d.value for d in Decision}:
105
+ return Decision(value.lower()), reasons
106
+ raise TypeError(f"cannot interpret validator result {value!r}")
107
+
108
+
109
+ class CallableAdapter:
110
+ """Wrap a Python callable ``fn(sql, dialect, case) -> bool | str | dict | Decision``."""
111
+
112
+ def __init__(
113
+ self,
114
+ fn: Callable[[str, str, BenchmarkCase], Any],
115
+ name: str = "callable",
116
+ version: str = "unknown",
117
+ capabilities: frozenset[str] | None = ALL_CATEGORIES,
118
+ ) -> None:
119
+ self.fn = fn
120
+ self.name = name
121
+ self._version = version
122
+ self._capabilities = capabilities
123
+
124
+ @classmethod
125
+ def from_spec(cls, spec: str) -> CallableAdapter:
126
+ """Load ``package.module:function``."""
127
+ module_name, _, attr = spec.partition(":")
128
+ if not module_name or not attr:
129
+ raise ValueError("callable adapter spec must look like 'module:function'")
130
+ fn = getattr(importlib.import_module(module_name), attr)
131
+ return cls(fn, name=f"callable:{spec}")
132
+
133
+ @property
134
+ def version(self) -> str:
135
+ return self._version
136
+
137
+ @property
138
+ def capabilities(self) -> frozenset[str] | None:
139
+ return self._capabilities
140
+
141
+ def evaluate(self, case: BenchmarkCase) -> AdapterVerdict:
142
+ started = time.perf_counter()
143
+ try:
144
+ raw = self.fn(case.sql, case.dialect, case)
145
+ decision, reasons = _coerce_decision(raw)
146
+ error = None
147
+ except Exception as exc: # a crashing validator must not crash the benchmark
148
+ decision, reasons, error = Decision.DENY, (), f"{type(exc).__name__}: {exc}"
149
+ return AdapterVerdict(decision, reasons, (time.perf_counter() - started) * 1000, error)
150
+
151
+ def close(self) -> None:
152
+ return None
153
+
154
+
155
+ @dataclass
156
+ class ExternalToolSpec:
157
+ """Metadata for a bundled external worker."""
158
+
159
+ name: str
160
+ worker: str
161
+ package: str
162
+ pip_spec: str
163
+ capabilities: frozenset[str]
164
+ notes: str = ""
165
+ extra: dict[str, Any] = field(default_factory=dict)
166
+
167
+
168
+ _BASE_CAPS = frozenset(
169
+ {
170
+ "destructive",
171
+ "multi_statement",
172
+ "comments_formatting",
173
+ "unauthorized_table",
174
+ "unauthorized_column",
175
+ "lookalike_safe",
176
+ "legitimate_complex",
177
+ "cte_bypass",
178
+ "nested_query",
179
+ "join_subquery_policy",
180
+ "unicode_identifiers",
181
+ }
182
+ )
183
+
184
+ EXTERNAL_TOOLS: dict[str, ExternalToolSpec] = {
185
+ "sql-guardrail": ExternalToolSpec(
186
+ name="sql-guardrail",
187
+ worker="sql_guardrail_worker.py",
188
+ package="sql-guardrail",
189
+ pip_spec="sql-guardrail==0.5.2",
190
+ capabilities=_BASE_CAPS | {"tenant_isolation", "cost"},
191
+ notes="Column allow/deny lists and require_predicate; no roles, labels or review.",
192
+ ),
193
+ "agent-sql-guard": ExternalToolSpec(
194
+ name="agent-sql-guard",
195
+ worker="agent_sql_guard_worker.py",
196
+ package="agent-sql-guard",
197
+ pip_spec="agent-sql-guard==0.2.0",
198
+ capabilities=_BASE_CAPS | {"sensitive_field"},
199
+ notes="Table allowlist and a name-based PII denylist; no tenant predicates.",
200
+ ),
201
+ "krish2105-sqlguard": ExternalToolSpec(
202
+ name="krish2105-sqlguard",
203
+ worker="krish_sqlguard_worker.py",
204
+ package="sqlguard",
205
+ pip_spec="sqlguard @ git+https://github.com/krish2105/sqlguard@62327f214ed8c9ea82ecf820bf04c4b55a6161a2",
206
+ capabilities=_BASE_CAPS,
207
+ notes="Read-only AST check with a schema allow-list; not published on PyPI.",
208
+ ),
209
+ }
210
+
211
+
212
+ class SubprocessAdapter:
213
+ """Run an external validator in its own interpreter via a JSON Lines worker.
214
+
215
+ The worker receives one request per line::
216
+
217
+ {"id": ..., "sql": ..., "dialect": ..., "context": {...}, "config": {...}}
218
+
219
+ and answers with ``{"decision": "allow|deny|review", "reasons": [...],
220
+ "latency_ms": float, "error": str|null}``. Its first line is a hello message carrying
221
+ ``{"library": ..., "version": ...}``.
222
+ """
223
+
224
+ def __init__(
225
+ self,
226
+ tool: ExternalToolSpec,
227
+ python: str | Path,
228
+ policy: Policy,
229
+ timeout_s: float = 30.0,
230
+ ) -> None:
231
+ self.tool = tool
232
+ self.name = tool.name
233
+ self.policy = policy
234
+ self.timeout_s = timeout_s
235
+ worker = WORKERS_DIR / tool.worker
236
+ # -I: isolated mode, so the worker cannot import from the current directory.
237
+ self._proc = subprocess.Popen( # noqa: S603 - fixed argv, no shell
238
+ [str(python), "-I", str(worker)],
239
+ stdin=subprocess.PIPE,
240
+ stdout=subprocess.PIPE,
241
+ stderr=subprocess.PIPE,
242
+ text=True,
243
+ encoding="utf-8",
244
+ bufsize=1,
245
+ )
246
+ hello = self._read()
247
+ if "error" in hello and hello.get("error"):
248
+ self.close()
249
+ raise RuntimeError(f"{tool.name} worker failed to start: {hello['error']}")
250
+ self._version = str(hello.get("version", "unknown"))
251
+ self._configs: dict[tuple[str | None, str], dict[str, Any]] = {}
252
+
253
+ def _read(self) -> dict[str, Any]:
254
+ assert self._proc.stdout is not None
255
+ line = self._proc.stdout.readline()
256
+ if not line:
257
+ stderr = self._proc.stderr.read() if self._proc.stderr else ""
258
+ raise RuntimeError(f"{self.tool.name} worker exited: {stderr[-500:]}")
259
+ data: dict[str, Any] = json.loads(line)
260
+ return data
261
+
262
+ @property
263
+ def version(self) -> str:
264
+ return self._version
265
+
266
+ @property
267
+ def capabilities(self) -> frozenset[str] | None:
268
+ return self.tool.capabilities
269
+
270
+ def evaluate(self, case: BenchmarkCase) -> AdapterVerdict:
271
+ key = (case.role, case.dialect)
272
+ if key not in self._configs:
273
+ self._configs[key] = translate_for_role(self.policy, case.role, case.dialect)
274
+ request = {
275
+ "id": case.id,
276
+ "sql": case.sql,
277
+ "dialect": case.dialect,
278
+ "context": case.context,
279
+ "config": self._configs[key],
280
+ }
281
+ assert self._proc.stdin is not None
282
+ self._proc.stdin.write(json.dumps(request) + "\n")
283
+ self._proc.stdin.flush()
284
+ reply = self._read()
285
+ decision = Decision(reply.get("decision", "deny"))
286
+ return AdapterVerdict(
287
+ decision=decision,
288
+ reasons=tuple(reply.get("reasons", ())),
289
+ latency_ms=float(reply.get("latency_ms", 0.0)),
290
+ error=reply.get("error"),
291
+ )
292
+
293
+ def close(self) -> None:
294
+ if self._proc.poll() is None:
295
+ if self._proc.stdin:
296
+ self._proc.stdin.close()
297
+ try:
298
+ self._proc.wait(timeout=5)
299
+ except subprocess.TimeoutExpired:
300
+ self._proc.kill()
301
+ for stream in (self._proc.stdout, self._proc.stderr):
302
+ if stream:
303
+ stream.close()
@@ -0,0 +1,39 @@
1
+ """Locate the reference benchmark corpus and policy shipped with the package.
2
+
3
+ In a wheel, the files are packaged under ``sqlguard_ninja/_data/benchmarks``. In a source
4
+ checkout (editable install), they are read from the repository's ``benchmarks/`` directory.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ _PACKAGE = Path(__file__).resolve().parents[1]
12
+ _CANDIDATES = (_PACKAGE / "_data" / "benchmarks", _PACKAGE.parents[1] / "benchmarks")
13
+
14
+ PREFIX = "builtin:"
15
+
16
+
17
+ def benchmarks_root() -> Path:
18
+ for candidate in _CANDIDATES:
19
+ if (candidate / "datasets").is_dir() and (candidate / "policies").is_dir():
20
+ return candidate
21
+ raise FileNotFoundError("the bundled benchmark data is missing from this installation")
22
+
23
+
24
+ def resolve(spec: str | Path, kind: str) -> Path:
25
+ """Resolve ``builtin:<name>`` for ``kind`` 'dataset' or 'policy'; pass other paths through."""
26
+ text = str(spec)
27
+ if not text.startswith(PREFIX):
28
+ return Path(text)
29
+ name = text.removeprefix(PREFIX) or ("v1" if kind == "dataset" else "retail_saas")
30
+ root = benchmarks_root()
31
+ path = root / "datasets" / name if kind == "dataset" else root / "policies" / f"{name}.yaml"
32
+ if not path.exists():
33
+ available = sorted(
34
+ p.name if kind == "dataset" else p.stem
35
+ for p in (root / ("datasets" if kind == "dataset" else "policies")).iterdir()
36
+ if not p.name.startswith(".")
37
+ )
38
+ raise FileNotFoundError(f"no built-in {kind} {name!r}; available: {available}")
39
+ return path