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,342 @@
1
+ """Pre-execution SQL checkpoint for agents.
2
+
3
+ The checkpoint separates two questions:
4
+
5
+ 1. *Policy evaluation* — what does the policy say about this SQL for this caller?
6
+ (:meth:`Guard.validate`, a pure function of SQL + policy + trusted context.)
7
+ 2. *Execution authorization* — given that verdict, the enforcement mode and any human
8
+ approval, may this exact SQL run now?
9
+
10
+ Nothing an LLM produces can change either answer. The role and tenant come from a
11
+ :class:`RequestContext` the application builds from its own authentication. Tool arguments,
12
+ SQL comments and model "explanations" are not inputs to either decision. Approvals are bound
13
+ to the SHA-256 of the exact SQL, the trace and the requesting principal, and can be used once.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import threading
19
+ import time
20
+ import uuid
21
+ from collections.abc import Callable, Mapping
22
+ from dataclasses import dataclass, field
23
+ from enum import StrEnum
24
+ from typing import Any, Protocol, TypeVar
25
+
26
+ from sqlguard_ninja.core.findings import Decision, ValidationResult
27
+ from sqlguard_ninja.core.guard import Guard
28
+ from sqlguard_ninja.integrations.audit import AuditEvent, AuditSink, redact_sql
29
+
30
+ T = TypeVar("T")
31
+
32
+
33
+ class EnforcementMode(StrEnum):
34
+ BLOCK = "block"
35
+ """DENY and REVIEW are not executed."""
36
+ REVIEW = "review"
37
+ """DENY is not executed; REVIEW runs only with a valid human approval."""
38
+ AUDIT = "audit"
39
+ """Everything runs; findings are recorded with ``enforced=False``. Monitoring only."""
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class RequestContext:
44
+ """Trusted, server-side facts about the request. Build it from your auth layer only."""
45
+
46
+ principal: str
47
+ role: str | None
48
+ tenant_id: Any = None
49
+ trace_id: str = field(default_factory=lambda: uuid.uuid4().hex)
50
+ attributes: Mapping[str, Any] = field(default_factory=dict)
51
+
52
+ def policy_context(self, tenant_key: str) -> dict[str, Any]:
53
+ ctx = dict(self.attributes)
54
+ if self.tenant_id is not None:
55
+ ctx[tenant_key] = self.tenant_id
56
+ return ctx
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class ApprovalRequest:
61
+ request_id: str
62
+ trace_id: str
63
+ principal: str
64
+ role: str | None
65
+ sql_sha256: str
66
+ summary: str
67
+ created_at: float
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class ApprovalDecision:
72
+ approved: bool
73
+ approver: str
74
+ reason: str = ""
75
+
76
+
77
+ class Approver(Protocol):
78
+ """Decide (or defer) an approval request. Return None while pending."""
79
+
80
+ def decide(self, request: ApprovalRequest) -> ApprovalDecision | None: ...
81
+
82
+
83
+ class DenyAllApprover:
84
+ """Default: nothing is ever approved."""
85
+
86
+ def decide(self, request: ApprovalRequest) -> ApprovalDecision | None:
87
+ return ApprovalDecision(False, "system", "no approver configured")
88
+
89
+
90
+ class CallbackApprover:
91
+ """Synchronous approval through a callback (e.g. a CLI prompt or chat-ops bot)."""
92
+
93
+ def __init__(self, fn: Callable[[ApprovalRequest], ApprovalDecision | None]) -> None:
94
+ self.fn = fn
95
+
96
+ def decide(self, request: ApprovalRequest) -> ApprovalDecision | None:
97
+ return self.fn(request)
98
+
99
+
100
+ class ApprovalQueue:
101
+ """Asynchronous human approval.
102
+
103
+ A REVIEW query is parked as a pending request. A human (a different principal) approves it
104
+ out of band; the agent may then re-submit *the identical SQL* within ``ttl_s`` seconds.
105
+ Each approval is single-use.
106
+ """
107
+
108
+ def __init__(self, ttl_s: float = 900.0, clock: Callable[[], float] = time.time) -> None:
109
+ self.ttl_s = ttl_s
110
+ self.clock = clock
111
+ self.pending: dict[str, ApprovalRequest] = {}
112
+ self._decided: dict[str, tuple[ApprovalRequest, ApprovalDecision, float]] = {}
113
+ self._lock = threading.Lock()
114
+
115
+ @staticmethod
116
+ def _key(req: ApprovalRequest) -> str:
117
+ return f"{req.trace_id}|{req.principal}|{req.sql_sha256}"
118
+
119
+ def decide(self, request: ApprovalRequest) -> ApprovalDecision | None:
120
+ key = self._key(request)
121
+ with self._lock:
122
+ decided = self._decided.get(key)
123
+ if decided is not None:
124
+ _req, decision, at = decided
125
+ if self.clock() - at > self.ttl_s:
126
+ del self._decided[key]
127
+ return ApprovalDecision(False, decision.approver, "approval expired")
128
+ del self._decided[key] # single use
129
+ return decision
130
+ if not any(self._key(p) == key for p in self.pending.values()):
131
+ self.pending[request.request_id] = request
132
+ return None
133
+
134
+ def _resolve(self, request_id: str, approver: str, approved: bool, reason: str) -> None:
135
+ with self._lock:
136
+ req = self.pending.get(request_id)
137
+ if req is None:
138
+ raise KeyError(f"no pending approval {request_id!r}")
139
+ if approver == req.principal:
140
+ raise PermissionError("the requesting principal cannot approve its own query")
141
+ del self.pending[request_id]
142
+ self._decided[self._key(req)] = (
143
+ req,
144
+ ApprovalDecision(approved, approver, reason),
145
+ self.clock(),
146
+ )
147
+
148
+ def approve(self, request_id: str, approver: str, reason: str = "") -> None:
149
+ self._resolve(request_id, approver, True, reason)
150
+
151
+ def reject(self, request_id: str, approver: str, reason: str = "") -> None:
152
+ self._resolve(request_id, approver, False, reason)
153
+
154
+
155
+ @dataclass(frozen=True)
156
+ class CheckpointOutcome:
157
+ authorized: bool
158
+ """May the exact SQL be executed now?"""
159
+ enforced: bool
160
+ """False in audit mode: a negative verdict was recorded but did not stop execution."""
161
+ would_block: bool
162
+ """True whenever the policy verdict was not ALLOW, regardless of mode."""
163
+ mode: EnforcementMode
164
+ result: ValidationResult
165
+ agent_message: str
166
+ """Text safe to return to the LLM. Never contains approval tokens or internal detail."""
167
+ approval_request: ApprovalRequest | None = None
168
+ approval: ApprovalDecision | None = None
169
+ attempt: int = 1
170
+ locked_out: bool = False
171
+ audit_event: AuditEvent | None = None
172
+
173
+
174
+ class SQLBlockedError(PermissionError):
175
+ def __init__(self, outcome: CheckpointOutcome) -> None:
176
+ super().__init__(outcome.agent_message)
177
+ self.outcome = outcome
178
+
179
+
180
+ class SQLCheckpoint:
181
+ """Evaluate and authorize SQL before execution. Thread-safe."""
182
+
183
+ def __init__(
184
+ self,
185
+ guard: Guard,
186
+ mode: EnforcementMode | str = EnforcementMode.BLOCK,
187
+ *,
188
+ approver: Approver | None = None,
189
+ audit_sink: AuditSink | None = None,
190
+ max_denied_attempts: int | None = 5,
191
+ dialect: str | None = None,
192
+ log_redacted_sql: bool = True,
193
+ ) -> None:
194
+ self.guard = guard
195
+ self.mode = EnforcementMode(mode)
196
+ self.approver: Approver = approver or DenyAllApprover()
197
+ self.audit_sink = audit_sink
198
+ self.max_denied_attempts = max_denied_attempts
199
+ self.dialect = dialect or guard.policy.dialect
200
+ self.log_redacted_sql = log_redacted_sql
201
+ self._attempts: dict[str, int] = {}
202
+ self._denials: dict[str, int] = {}
203
+ self._lock = threading.Lock()
204
+
205
+ # ------------------------------------------------------------------ evaluate
206
+ def evaluate(self, sql: str, context: RequestContext) -> CheckpointOutcome:
207
+ started = time.perf_counter()
208
+ policy_ctx = context.policy_context(self.guard.policy.tenant.context_key)
209
+ result = self.guard.validate(
210
+ sql, role=context.role, dialect=self.dialect, context=policy_ctx
211
+ )
212
+ with self._lock:
213
+ attempt = self._attempts.get(context.trace_id, 0) + 1
214
+ self._attempts[context.trace_id] = attempt
215
+ denials = self._denials.get(context.trace_id, 0)
216
+ locked = self.max_denied_attempts is not None and denials >= self.max_denied_attempts
217
+
218
+ would_block = result.decision is not Decision.ALLOW
219
+ approval_request: ApprovalRequest | None = None
220
+ approval: ApprovalDecision | None = None
221
+ if locked and self.mode is not EnforcementMode.AUDIT:
222
+ authorized, enforced = False, True
223
+ elif result.decision is Decision.ALLOW:
224
+ authorized, enforced = True, True
225
+ elif self.mode is EnforcementMode.AUDIT:
226
+ authorized, enforced = True, False
227
+ elif result.decision is Decision.REVIEW and self.mode is EnforcementMode.REVIEW:
228
+ approval_request = ApprovalRequest(
229
+ request_id=uuid.uuid4().hex,
230
+ trace_id=context.trace_id,
231
+ principal=context.principal,
232
+ role=context.role,
233
+ sql_sha256=result.sql_sha256,
234
+ summary="; ".join(f.message for f in result.blocking_findings)[:500],
235
+ created_at=time.time(),
236
+ )
237
+ approval = self.approver.decide(approval_request)
238
+ if approval is not None and approval.approver == context.principal:
239
+ approval = ApprovalDecision(False, approval.approver, "self-approval rejected")
240
+ authorized = approval is not None and approval.approved
241
+ enforced = True
242
+ else:
243
+ authorized, enforced = False, True
244
+
245
+ if not authorized and enforced:
246
+ with self._lock:
247
+ self._denials[context.trace_id] = self._denials.get(context.trace_id, 0) + 1
248
+
249
+ message = self._agent_message(
250
+ result, authorized, enforced, locked, approval_request, approval
251
+ )
252
+ event = AuditEvent(
253
+ trace_id=context.trace_id,
254
+ principal=context.principal,
255
+ role=result.role,
256
+ tenant=None if context.tenant_id is None else str(context.tenant_id),
257
+ mode=self.mode.value,
258
+ decision=result.decision.value,
259
+ authorized=authorized,
260
+ enforced=enforced,
261
+ would_block=would_block,
262
+ sql_sha256=result.sql_sha256,
263
+ sql_redacted=redact_sql(sql, self.dialect) if self.log_redacted_sql else None,
264
+ dialect=self.dialect,
265
+ policy=result.policy_name,
266
+ findings=[
267
+ {"rule_id": f.rule_id, "action": f.action.value, "category": f.category.value}
268
+ for f in result.findings
269
+ ],
270
+ approval=None
271
+ if approval is None
272
+ else {"approved": approval.approved, "approver": approval.approver},
273
+ attempt=attempt,
274
+ elapsed_ms=(time.perf_counter() - started) * 1000,
275
+ )
276
+ if self.audit_sink is not None:
277
+ self.audit_sink.emit(event)
278
+ return CheckpointOutcome(
279
+ authorized=authorized,
280
+ enforced=enforced,
281
+ would_block=would_block,
282
+ mode=self.mode,
283
+ result=result,
284
+ agent_message=message,
285
+ approval_request=approval_request,
286
+ approval=approval,
287
+ attempt=attempt,
288
+ locked_out=locked,
289
+ audit_event=event,
290
+ )
291
+
292
+ @staticmethod
293
+ def _agent_message(
294
+ result: ValidationResult,
295
+ authorized: bool,
296
+ enforced: bool,
297
+ locked: bool,
298
+ approval_request: ApprovalRequest | None,
299
+ approval: ApprovalDecision | None,
300
+ ) -> str:
301
+ if locked and not authorized:
302
+ return (
303
+ "Query blocked: too many policy violations in this session. "
304
+ "No further queries will run in this conversation."
305
+ )
306
+ reasons = "; ".join(
307
+ f.message + (f" Suggestion: {f.remediation}" if f.remediation else "")
308
+ for f in result.blocking_findings
309
+ )
310
+ if authorized and not enforced:
311
+ return "Query executed (audit mode). Policy findings were recorded."
312
+ if authorized:
313
+ return "Query permitted."
314
+ if approval_request is not None:
315
+ if approval is None:
316
+ return (
317
+ "Query requires human approval and was not executed. "
318
+ f"Reason: {reasons} Approval is handled outside this conversation."
319
+ )
320
+ return f"Query was not approved and was not executed. Reason: {reasons}"
321
+ return (
322
+ f"Query blocked by policy and not executed. Reason: {reasons} "
323
+ "This decision is enforced by the server and cannot be overridden by the assistant."
324
+ )
325
+
326
+ # ------------------------------------------------------------------- execute
327
+ def execute(
328
+ self,
329
+ sql: str,
330
+ context: RequestContext,
331
+ executor: Callable[[str, Mapping[str, Any]], T],
332
+ ) -> T:
333
+ """Evaluate, then run ``executor(sql, params)`` only if authorized.
334
+
335
+ ``params`` contains the trusted context values (e.g. ``tenant_id``) for bind
336
+ parameters. The SQL is executed exactly as validated.
337
+ """
338
+ outcome = self.evaluate(sql, context)
339
+ if not outcome.authorized:
340
+ raise SQLBlockedError(outcome)
341
+ params = context.policy_context(self.guard.policy.tenant.context_key)
342
+ return executor(sql, params)
@@ -0,0 +1,64 @@
1
+ """Small, safe executors used by the integrations and examples.
2
+
3
+ These are the *last* line of defence after the checkpoint: they open read-only sessions so
4
+ that the database itself refuses writes even if a query were authorized by mistake (or
5
+ deliberately, in audit mode). Use a least-privilege database account in production.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sqlite3
11
+ import threading
12
+ from collections.abc import Callable, Mapping
13
+ from typing import Any
14
+
15
+ Rows = list[dict[str, Any]]
16
+
17
+
18
+ def _bound_params(sql: str, params: Mapping[str, Any]) -> dict[str, Any]:
19
+ # Only pass parameters the statement mentions; sqlite3 ignores extras for named style,
20
+ # but filtering keeps driver errors clear.
21
+ return {k: v for k, v in params.items() if f":{k}" in sql or f"%({k})s" in sql}
22
+
23
+
24
+ def sqlite_executor(conn: sqlite3.Connection, max_rows: int = 200) -> Callable[..., Rows]:
25
+ """Execute on SQLite with ``PRAGMA query_only = ON`` (writes fail at the engine).
26
+
27
+ Calls are serialised with a lock so one connection can be shared by threaded servers;
28
+ open it with ``check_same_thread=False`` for that.
29
+ """
30
+ conn.execute("PRAGMA query_only = ON")
31
+ lock = threading.Lock()
32
+
33
+ def run(sql: str, params: Mapping[str, Any]) -> Rows:
34
+ with lock:
35
+ cur = conn.execute(sql, _bound_params(sql, params))
36
+ cols = [d[0] for d in cur.description or ()]
37
+ return [dict(zip(cols, row, strict=False)) for row in cur.fetchmany(max_rows)]
38
+
39
+ return run
40
+
41
+
42
+ def psycopg_executor(
43
+ conninfo: str, max_rows: int = 200, statement_timeout_ms: int = 5000
44
+ ) -> Callable[..., Rows]: # pragma: no cover - exercised by PostgreSQL integration tests
45
+ """Execute on PostgreSQL in a READ ONLY transaction with a statement timeout.
46
+
47
+ Use ``%(tenant_id)s`` style bind parameters in SQL sent to PostgreSQL.
48
+ """
49
+ import psycopg # noqa: PLC0415 - optional dependency
50
+ from psycopg.rows import dict_row # noqa: PLC0415
51
+
52
+ def run(sql: str, params: Mapping[str, Any]) -> Rows:
53
+ with (
54
+ psycopg.connect(conninfo, row_factory=dict_row) as conn,
55
+ conn.transaction(force_rollback=True),
56
+ ):
57
+ conn.execute("SET TRANSACTION READ ONLY")
58
+ conn.execute(
59
+ "SELECT set_config('statement_timeout', %s, true)", (str(statement_timeout_ms),)
60
+ )
61
+ cur = conn.execute(sql, _bound_params(sql, params))
62
+ return list(cur.fetchmany(max_rows))
63
+
64
+ return run
@@ -0,0 +1,89 @@
1
+ """FastAPI integration: a router exposing validation and guarded execution.
2
+
3
+ Authentication is the application's job. Pass a dependency that returns a
4
+ :class:`RequestContext` built from your own auth (session, JWT, mTLS...). The request body
5
+ can never set the role or tenant. Install with ``pip install "sqlguard-ninja[fastapi]"``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable, Mapping
11
+ from typing import Any
12
+
13
+ from fastapi import APIRouter, Depends, HTTPException
14
+ from pydantic import BaseModel, ConfigDict, Field
15
+
16
+ from sqlguard_ninja.integrations.checkpoint import RequestContext, SQLBlockedError, SQLCheckpoint
17
+
18
+
19
+ class SQLRequest(BaseModel):
20
+ model_config = ConfigDict(extra="forbid")
21
+ sql: str = Field(max_length=100_000)
22
+
23
+
24
+ class FindingOut(BaseModel):
25
+ rule_id: str
26
+ action: str
27
+ message: str
28
+ remediation: str | None = None
29
+
30
+
31
+ class ValidateResponse(BaseModel):
32
+ decision: str
33
+ authorized: bool
34
+ message: str
35
+ findings: list[FindingOut]
36
+ trace_id: str
37
+
38
+
39
+ def create_router(
40
+ checkpoint: SQLCheckpoint,
41
+ context_dependency: Callable[..., RequestContext],
42
+ executor: Callable[[str, Mapping[str, Any]], Any] | None = None,
43
+ prefix: str = "/v1/sql",
44
+ ) -> APIRouter:
45
+ router = APIRouter(prefix=prefix, tags=["sql"])
46
+
47
+ @router.post("/validate", response_model=ValidateResponse)
48
+ def validate(
49
+ body: SQLRequest,
50
+ ctx: RequestContext = Depends(context_dependency), # noqa: B008
51
+ ) -> ValidateResponse:
52
+ outcome = checkpoint.evaluate(body.sql, ctx)
53
+ return ValidateResponse(
54
+ decision=outcome.result.decision.value,
55
+ authorized=outcome.authorized,
56
+ message=outcome.agent_message,
57
+ findings=[
58
+ FindingOut(
59
+ rule_id=f.rule_id,
60
+ action=f.action.value,
61
+ message=f.message,
62
+ remediation=f.remediation,
63
+ )
64
+ for f in outcome.result.findings
65
+ ],
66
+ trace_id=ctx.trace_id,
67
+ )
68
+
69
+ if executor is not None:
70
+ run = executor
71
+
72
+ @router.post("/query")
73
+ def query(
74
+ body: SQLRequest,
75
+ ctx: RequestContext = Depends(context_dependency), # noqa: B008
76
+ ) -> dict[str, Any]:
77
+ try:
78
+ rows = checkpoint.execute(body.sql, ctx, run)
79
+ except SQLBlockedError as exc:
80
+ raise HTTPException(
81
+ status_code=403,
82
+ detail={
83
+ "decision": exc.outcome.result.decision.value,
84
+ "message": exc.outcome.agent_message,
85
+ },
86
+ ) from exc
87
+ return {"rows": rows, "trace_id": ctx.trace_id}
88
+
89
+ return router
@@ -0,0 +1,67 @@
1
+ """LangChain integration: a SQL tool whose execution is gated by :class:`SQLCheckpoint`.
2
+
3
+ The tool's argument schema contains **only** the query. Role, tenant and approvals cannot be
4
+ supplied by the model; they come from ``context_provider``, which the application binds to its
5
+ authenticated request. Install with ``pip install "sqlguard-ninja[langchain]"``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from collections.abc import Callable, Mapping
12
+ from typing import Any
13
+
14
+ from langchain_core.tools import BaseTool
15
+ from pydantic import BaseModel, ConfigDict, Field, PrivateAttr
16
+
17
+ from sqlguard_ninja.integrations.checkpoint import RequestContext, SQLBlockedError, SQLCheckpoint
18
+
19
+ Executor = Callable[[str, Mapping[str, Any]], Any]
20
+
21
+
22
+ class SQLQueryInput(BaseModel):
23
+ """Arguments the model may send. Extra fields (e.g. ``role``) are rejected."""
24
+
25
+ model_config = ConfigDict(extra="forbid")
26
+
27
+ query: str = Field(description="A single read-only SQL SELECT statement.")
28
+
29
+
30
+ class GuardedSQLTool(BaseTool):
31
+ """Run SQL only after the checkpoint authorizes it; return rows or the policy reason."""
32
+
33
+ name: str = "sql_db_query"
34
+ description: str = (
35
+ "Run a read-only SQL query against the database and return rows as JSON. "
36
+ "Queries are checked against a server-side data access policy before execution; "
37
+ "if a query is blocked, the response explains why. Use bind parameter :tenant_id "
38
+ "for the current tenant."
39
+ )
40
+ args_schema: type[BaseModel] = SQLQueryInput
41
+ raise_on_block: bool = False
42
+
43
+ _checkpoint: SQLCheckpoint = PrivateAttr()
44
+ _executor: Executor = PrivateAttr()
45
+ _context_provider: Callable[[], RequestContext] = PrivateAttr()
46
+
47
+ def __init__(
48
+ self,
49
+ checkpoint: SQLCheckpoint,
50
+ executor: Executor,
51
+ context_provider: Callable[[], RequestContext],
52
+ **kwargs: Any,
53
+ ) -> None:
54
+ super().__init__(**kwargs)
55
+ self._checkpoint = checkpoint
56
+ self._executor = executor
57
+ self._context_provider = context_provider
58
+
59
+ def _run(self, query: str, **_: Any) -> str:
60
+ context = self._context_provider()
61
+ try:
62
+ rows = self._checkpoint.execute(query, context, self._executor)
63
+ except SQLBlockedError as exc:
64
+ if self.raise_on_block:
65
+ raise
66
+ return exc.outcome.agent_message
67
+ return json.dumps(rows, default=str)
@@ -0,0 +1,87 @@
1
+ """LangGraph integration: guard and execute nodes plus a router.
2
+
3
+ Trust model:
4
+
5
+ * The request context is read from ``config["configurable"][CONTEXT_KEY]``, which the
6
+ *application* sets when it invokes the graph. Graph nodes (including LLM nodes) can write
7
+ state, but they cannot change the run configuration.
8
+ * :func:`make_execute_node` re-evaluates the SQL through the checkpoint. It never trusts an
9
+ ``authorized`` flag found in state, so a node that tampers with state gains nothing.
10
+
11
+ Install with ``pip install "sqlguard-ninja[langgraph]"``.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import Callable, Mapping
17
+ from typing import Any
18
+
19
+ from langchain_core.runnables import RunnableConfig
20
+
21
+ from sqlguard_ninja.integrations.checkpoint import RequestContext, SQLBlockedError, SQLCheckpoint
22
+
23
+ CONTEXT_KEY = "sqlninja_context"
24
+
25
+ State = Mapping[str, Any]
26
+ Node = Callable[[State, RunnableConfig], dict[str, Any]]
27
+
28
+
29
+ def _context(config: Any) -> RequestContext:
30
+ configurable = (config or {}).get("configurable", {}) if isinstance(config, Mapping) else {}
31
+ ctx = configurable.get(CONTEXT_KEY)
32
+ if not isinstance(ctx, RequestContext):
33
+ raise PermissionError(
34
+ f"missing trusted RequestContext in config['configurable'][{CONTEXT_KEY!r}]"
35
+ )
36
+ return ctx
37
+
38
+
39
+ def make_guard_node(checkpoint: SQLCheckpoint, sql_key: str = "sql") -> Node:
40
+ """Node that evaluates ``state[sql_key]`` and records the verdict under ``guard``."""
41
+
42
+ # LangGraph injects the run config only when the parameter is annotated RunnableConfig.
43
+ def guard_node(state: State, config: RunnableConfig) -> dict[str, Any]:
44
+ outcome = checkpoint.evaluate(str(state.get(sql_key, "")), _context(config))
45
+ return {
46
+ "guard": {
47
+ "authorized": outcome.authorized,
48
+ "decision": outcome.result.decision.value,
49
+ "message": outcome.agent_message,
50
+ "rules": [f.rule_id for f in outcome.result.blocking_findings],
51
+ "locked_out": outcome.locked_out,
52
+ },
53
+ "attempts": int(state.get("attempts", 0)) + 1,
54
+ }
55
+
56
+ return guard_node
57
+
58
+
59
+ def make_execute_node(
60
+ checkpoint: SQLCheckpoint,
61
+ executor: Callable[[str, Mapping[str, Any]], Any],
62
+ sql_key: str = "sql",
63
+ ) -> Node:
64
+ """Node that executes the SQL *after re-checking it*; writes ``rows`` or ``error``."""
65
+
66
+ def execute_node(state: State, config: RunnableConfig) -> dict[str, Any]:
67
+ try:
68
+ rows = checkpoint.execute(str(state.get(sql_key, "")), _context(config), executor)
69
+ except SQLBlockedError as exc:
70
+ return {"rows": None, "error": exc.outcome.agent_message}
71
+ return {"rows": rows, "error": None}
72
+
73
+ return execute_node
74
+
75
+
76
+ def route_after_guard(max_attempts: int = 3) -> Callable[[State], str]:
77
+ """Router: ``"execute"`` if authorized, ``"revise"`` to retry, ``"end"`` when exhausted."""
78
+
79
+ def route(state: State) -> str:
80
+ guard = state.get("guard") or {}
81
+ if guard.get("authorized"):
82
+ return "execute"
83
+ if guard.get("locked_out") or int(state.get("attempts", 0)) >= max_attempts:
84
+ return "end"
85
+ return "revise"
86
+
87
+ return route
@@ -0,0 +1,39 @@
1
+ """Policy schema and loading."""
2
+
3
+ from sqlguard_ninja.policy.model import (
4
+ Access,
5
+ Catalog,
6
+ ColumnDef,
7
+ CostThresholds,
8
+ Defaults,
9
+ Disclosure,
10
+ FunctionPolicy,
11
+ LabelDef,
12
+ Limits,
13
+ Policy,
14
+ PolicyError,
15
+ Role,
16
+ Sensitivity,
17
+ TableDef,
18
+ TableGrant,
19
+ TenantConfig,
20
+ )
21
+
22
+ __all__ = [
23
+ "Access",
24
+ "Catalog",
25
+ "ColumnDef",
26
+ "CostThresholds",
27
+ "Defaults",
28
+ "Disclosure",
29
+ "FunctionPolicy",
30
+ "LabelDef",
31
+ "Limits",
32
+ "Policy",
33
+ "PolicyError",
34
+ "Role",
35
+ "Sensitivity",
36
+ "TableDef",
37
+ "TableGrant",
38
+ "TenantConfig",
39
+ ]