ten-grd 0.2.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.
- ten_grd/__init__.py +17 -0
- ten_grd/adapters.py +48 -0
- ten_grd/client.py +61 -0
- ten_grd/context.py +49 -0
- ten_grd/engine.py +154 -0
- ten_grd/exceptions.py +25 -0
- ten_grd/integrations/__init__.py +1 -0
- ten_grd/integrations/_shared.py +53 -0
- ten_grd/integrations/cache.py +126 -0
- ten_grd/integrations/langchain.py +89 -0
- ten_grd/integrations/postgres.py +55 -0
- ten_grd/integrations/qdrant.py +124 -0
- ten_grd/integrations/semantic_cache.py +67 -0
- ten_grd/integrations/sql.py +53 -0
- ten_grd/integrations/sqlite.py +63 -0
- ten_grd/operations.py +113 -0
- ten_grd/predicates.py +87 -0
- ten_grd-0.2.0.dist-info/METADATA +180 -0
- ten_grd-0.2.0.dist-info/RECORD +22 -0
- ten_grd-0.2.0.dist-info/WHEEL +5 -0
- ten_grd-0.2.0.dist-info/licenses/LICENSE +201 -0
- ten_grd-0.2.0.dist-info/top_level.txt +1 -0
ten_grd/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Tennant Guard: tenant isolation primitives for agentic developers."""
|
|
2
|
+
from .adapters import InMemoryAdapter, IsolationAdapter
|
|
3
|
+
from .client import TenantGuard
|
|
4
|
+
from .context import TenantContext, current_context, tenant_scope
|
|
5
|
+
from .engine import VerificationResult, VerificationStatus, Z3InvariantEngine
|
|
6
|
+
from .exceptions import (GuardError, InvalidContextError, InvalidOperationError,
|
|
7
|
+
IsolationViolationError, VerificationError, AdapterError)
|
|
8
|
+
from .predicates import And, Eq, Not, Or
|
|
9
|
+
|
|
10
|
+
__version__ = "0.2.0"
|
|
11
|
+
__all__ = [
|
|
12
|
+
"TenantGuard", "TenantContext", "tenant_scope", "current_context",
|
|
13
|
+
"InMemoryAdapter", "IsolationAdapter", "Z3InvariantEngine",
|
|
14
|
+
"VerificationResult", "VerificationStatus", "Eq", "And", "Or", "Not",
|
|
15
|
+
"GuardError", "InvalidContextError", "InvalidOperationError",
|
|
16
|
+
"IsolationViolationError", "VerificationError", "AdapterError",
|
|
17
|
+
]
|
ten_grd/adapters.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Trusted adapter contract and atomic, in-memory reference implementation."""
|
|
2
|
+
from threading import RLock
|
|
3
|
+
from typing import Protocol
|
|
4
|
+
|
|
5
|
+
from .exceptions import InvalidOperationError, IsolationViolationError
|
|
6
|
+
from .operations import Operation, OperationKind, Record
|
|
7
|
+
from .predicates import matches
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class IsolationAdapter(Protocol):
|
|
11
|
+
"""Implementations must preserve the exact checked operation semantics.
|
|
12
|
+
|
|
13
|
+
An adapter is trusted enforcement code, not an untrusted plugin. Backend
|
|
14
|
+
implementations must bind ownership atomically and document model fidelity.
|
|
15
|
+
"""
|
|
16
|
+
def execute(self, operation: Operation) -> tuple[Record, ...]: ...
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class InMemoryAdapter:
|
|
20
|
+
"""Reference metadata semantics; intentionally not a persistent backend."""
|
|
21
|
+
|
|
22
|
+
def __init__(self):
|
|
23
|
+
self._records: dict[tuple[str, str], Record] = {}
|
|
24
|
+
self._lock = RLock()
|
|
25
|
+
|
|
26
|
+
def execute(self, operation: Operation) -> tuple[Record, ...]:
|
|
27
|
+
if type(operation) is not Operation:
|
|
28
|
+
raise InvalidOperationError("Expected an Operation")
|
|
29
|
+
operation.validate()
|
|
30
|
+
with self._lock:
|
|
31
|
+
if operation.kind is OperationKind.SEARCH:
|
|
32
|
+
return tuple(record for record in self._records.values()
|
|
33
|
+
if record.tenant_id == operation.tenant_id
|
|
34
|
+
and matches(operation.selector, record.fields()))
|
|
35
|
+
key = (operation.tenant_id, operation.record_id)
|
|
36
|
+
if operation.kind is OperationKind.PUT:
|
|
37
|
+
record = operation.record
|
|
38
|
+
if (record.tenant_id != operation.tenant_id
|
|
39
|
+
or not matches(operation.selector, record.fields())):
|
|
40
|
+
raise IsolationViolationError("Write does not satisfy its checked scope")
|
|
41
|
+
self._records[key] = record
|
|
42
|
+
return (record,)
|
|
43
|
+
record = self._records.get(key)
|
|
44
|
+
if record is None or not matches(operation.selector, record.fields()):
|
|
45
|
+
return ()
|
|
46
|
+
if operation.kind is OperationKind.DELETE:
|
|
47
|
+
del self._records[key]
|
|
48
|
+
return (record,)
|
ten_grd/client.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Guarded operations for agent tools, memory, and application data."""
|
|
2
|
+
from .context import current_context
|
|
3
|
+
from .engine import Z3InvariantEngine
|
|
4
|
+
from .exceptions import InvalidOperationError, IsolationViolationError, VerificationError
|
|
5
|
+
from .operations import Operation, OperationKind, Record
|
|
6
|
+
from .predicates import And, Eq, Predicate, matches, validate_predicate
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TenantGuard:
|
|
10
|
+
def __init__(self, adapter, *, engine=None):
|
|
11
|
+
self._adapter = adapter
|
|
12
|
+
self._engine = engine if engine is not None else Z3InvariantEngine()
|
|
13
|
+
|
|
14
|
+
def search(self, predicate: Predicate | None = None) -> tuple[Record, ...]:
|
|
15
|
+
return self._run(OperationKind.SEARCH, predicate=predicate)
|
|
16
|
+
|
|
17
|
+
def get(self, record_id: str) -> Record | None:
|
|
18
|
+
records = self._run(OperationKind.GET, record_id=record_id)
|
|
19
|
+
return records[0] if records else None
|
|
20
|
+
|
|
21
|
+
def put(self, record_id: str, data, *, metadata=None) -> Record:
|
|
22
|
+
context = current_context()
|
|
23
|
+
record = Record.create(context.tenant_id, record_id, data, metadata)
|
|
24
|
+
records = self._run(OperationKind.PUT, record_id=record_id, record=record)
|
|
25
|
+
if len(records) != 1 or records[0] != record:
|
|
26
|
+
raise VerificationError("Adapter did not return the exact written record")
|
|
27
|
+
return records[0]
|
|
28
|
+
|
|
29
|
+
def delete(self, record_id: str) -> bool:
|
|
30
|
+
return bool(self._run(OperationKind.DELETE, record_id=record_id))
|
|
31
|
+
|
|
32
|
+
def _run(self, kind, *, predicate=None, record_id=None, record=None):
|
|
33
|
+
context = current_context()
|
|
34
|
+
terms = [Eq("tenant_id", context.tenant_id)]
|
|
35
|
+
if predicate is not None:
|
|
36
|
+
validate_predicate(predicate)
|
|
37
|
+
terms.append(predicate)
|
|
38
|
+
if record_id is not None:
|
|
39
|
+
terms.append(Eq("record_id", record_id))
|
|
40
|
+
operation = Operation(context.tenant_id, kind, And(tuple(terms)),
|
|
41
|
+
record_id, record)
|
|
42
|
+
result = self._engine.verify_operation(context, operation)
|
|
43
|
+
result.require_proven()
|
|
44
|
+
if (result.operation_digest != operation.digest
|
|
45
|
+
or result.policy_version != context.policy_version):
|
|
46
|
+
raise VerificationError("Verification evidence does not match the operation")
|
|
47
|
+
if current_context() != context:
|
|
48
|
+
raise VerificationError("Tenant context changed during verification")
|
|
49
|
+
records = self._adapter.execute(operation)
|
|
50
|
+
# Materialize and validate the entire batch before returning any content.
|
|
51
|
+
if type(records) is not tuple:
|
|
52
|
+
raise InvalidOperationError("Adapter must return a materialized record tuple")
|
|
53
|
+
if kind is not OperationKind.SEARCH and len(records) > 1:
|
|
54
|
+
raise VerificationError("Adapter returned multiple records for one target")
|
|
55
|
+
for item in records:
|
|
56
|
+
if type(item) is not Record:
|
|
57
|
+
raise IsolationViolationError("Adapter returned an unsupported or unlabeled record")
|
|
58
|
+
self._engine.verify_ownership(context, item.tenant_id)
|
|
59
|
+
if not matches(operation.selector, item.fields()):
|
|
60
|
+
raise IsolationViolationError("Adapter result falls outside the checked selector")
|
|
61
|
+
return records
|
ten_grd/context.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Request-local scope supplied by the host authentication boundary."""
|
|
2
|
+
from contextlib import contextmanager
|
|
3
|
+
from contextvars import ContextVar
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from .exceptions import InvalidContextError
|
|
7
|
+
|
|
8
|
+
_MAX_IDENTIFIER = 256
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def validate_identifier(value: str, name: str) -> None:
|
|
12
|
+
if (not isinstance(value, str) or not value or len(value) > _MAX_IDENTIFIER
|
|
13
|
+
or value != value.strip() or any(ord(c) < 32 for c in value)):
|
|
14
|
+
raise InvalidContextError(f"{name} must be a nonempty canonical identifier")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class TenantContext:
|
|
19
|
+
"""Create only after the host validates identity and tenant membership."""
|
|
20
|
+
tenant_id: str
|
|
21
|
+
subject_id: str
|
|
22
|
+
request_id: str
|
|
23
|
+
policy_version: str = "1"
|
|
24
|
+
|
|
25
|
+
def __post_init__(self):
|
|
26
|
+
for name in ("tenant_id", "subject_id", "request_id", "policy_version"):
|
|
27
|
+
validate_identifier(getattr(self, name), name)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_current: ContextVar[TenantContext | None] = ContextVar("ten_grd_context", default=None)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def current_context() -> TenantContext:
|
|
34
|
+
context = _current.get()
|
|
35
|
+
if context is None:
|
|
36
|
+
raise InvalidContextError("No authenticated tenant context is active")
|
|
37
|
+
return context
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@contextmanager
|
|
41
|
+
def tenant_scope(context: TenantContext):
|
|
42
|
+
"""Bind validated context; always restore the prior scope on exit."""
|
|
43
|
+
if type(context) is not TenantContext:
|
|
44
|
+
raise InvalidContextError("Expected a validated TenantContext")
|
|
45
|
+
token = _current.set(context)
|
|
46
|
+
try:
|
|
47
|
+
yield context
|
|
48
|
+
finally:
|
|
49
|
+
_current.reset(token)
|
ten_grd/engine.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Prove isolation of the supported selector model; fail closed otherwise."""
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from enum import Enum
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import z3
|
|
7
|
+
|
|
8
|
+
from .context import TenantContext, validate_identifier
|
|
9
|
+
from .exceptions import (GuardError, IsolationViolationError,
|
|
10
|
+
InvalidOperationError, VerificationError)
|
|
11
|
+
from .operations import Operation, OperationKind
|
|
12
|
+
from .predicates import And, Eq, Not, Or, predicate_data, validate_predicate
|
|
13
|
+
|
|
14
|
+
MODEL_VERSION = "string-metadata-v1"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class VerificationStatus(str, Enum):
|
|
18
|
+
PROVEN = "proven"
|
|
19
|
+
VIOLATION = "violation"
|
|
20
|
+
UNKNOWN = "unknown"
|
|
21
|
+
UNSUPPORTED = "unsupported"
|
|
22
|
+
ERROR = "error"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class VerificationResult:
|
|
27
|
+
status: VerificationStatus
|
|
28
|
+
operation_digest: str
|
|
29
|
+
policy_version: str
|
|
30
|
+
reason: str
|
|
31
|
+
model_version: str = MODEL_VERSION
|
|
32
|
+
|
|
33
|
+
def require_proven(self):
|
|
34
|
+
if self.status is VerificationStatus.VIOLATION:
|
|
35
|
+
raise IsolationViolationError(self.reason)
|
|
36
|
+
if self.status is not VerificationStatus.PROVEN:
|
|
37
|
+
raise VerificationError(self.reason)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Z3InvariantEngine:
|
|
41
|
+
"""No raw queries or caller-provided filter booleans are accepted."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, timeout_ms: int = 1000):
|
|
44
|
+
if type(timeout_ms) is not int or not 1 <= timeout_ms <= 60_000:
|
|
45
|
+
raise ValueError("timeout_ms must be between 1 and 60000")
|
|
46
|
+
self.timeout_ms = timeout_ms
|
|
47
|
+
|
|
48
|
+
def verify_selector(self, context: TenantContext, selector) -> VerificationResult:
|
|
49
|
+
return self._verify(context, selector, "")
|
|
50
|
+
|
|
51
|
+
def verify_scope(self, context: TenantContext, selector, *, required) -> VerificationResult:
|
|
52
|
+
"""Prove tenant, subject, policy and additional cache scope dimensions."""
|
|
53
|
+
try:
|
|
54
|
+
if type(context) is not TenantContext:
|
|
55
|
+
raise InvalidOperationError("Validated context required")
|
|
56
|
+
expected = {"tenant_id": context.tenant_id, "subject_id": context.subject_id,
|
|
57
|
+
"policy_version": context.policy_version}
|
|
58
|
+
extra = dict(required)
|
|
59
|
+
if any(key in expected and expected[key] != value for key, value in extra.items()):
|
|
60
|
+
return VerificationResult(VerificationStatus.VIOLATION, "", "",
|
|
61
|
+
"Scope conflicts with authenticated context")
|
|
62
|
+
expected.update(extra)
|
|
63
|
+
validate_predicate(And(tuple(Eq(k, v) for k, v in expected.items())))
|
|
64
|
+
return self._verify(context, selector, "", required=expected)
|
|
65
|
+
except Exception:
|
|
66
|
+
return VerificationResult(VerificationStatus.UNSUPPORTED, "", "",
|
|
67
|
+
"Scope requirements are unsupported")
|
|
68
|
+
|
|
69
|
+
def verify_operation(self, context: TenantContext,
|
|
70
|
+
operation: Operation) -> VerificationResult:
|
|
71
|
+
try:
|
|
72
|
+
if type(operation) is not Operation:
|
|
73
|
+
raise InvalidOperationError("Expected an immutable Operation")
|
|
74
|
+
operation.validate()
|
|
75
|
+
if type(context) is not TenantContext or operation.tenant_id != context.tenant_id:
|
|
76
|
+
return VerificationResult(VerificationStatus.VIOLATION, "",
|
|
77
|
+
"", "Operation tenant differs from active context")
|
|
78
|
+
if (operation.kind is OperationKind.PUT
|
|
79
|
+
and operation.record.tenant_id != context.tenant_id):
|
|
80
|
+
raise IsolationViolationError("Write ownership differs from context")
|
|
81
|
+
return self._verify(context, operation.selector, operation.digest)
|
|
82
|
+
except GuardError:
|
|
83
|
+
return VerificationResult(VerificationStatus.UNSUPPORTED, "", "",
|
|
84
|
+
"Operation is outside the supported contract")
|
|
85
|
+
|
|
86
|
+
def _verify(self, context, selector, digest, required=None):
|
|
87
|
+
try:
|
|
88
|
+
if type(context) is not TenantContext:
|
|
89
|
+
raise InvalidOperationError("A validated context is required")
|
|
90
|
+
validate_predicate(selector)
|
|
91
|
+
if not digest:
|
|
92
|
+
raw = json.dumps([context.tenant_id, context.policy_version,
|
|
93
|
+
predicate_data(selector), required], ensure_ascii=True)
|
|
94
|
+
digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
|
95
|
+
# Per-call contexts prevent Z3 AST/solver sharing across requests.
|
|
96
|
+
ctx = z3.Context()
|
|
97
|
+
solver = z3.Solver(ctx=ctx)
|
|
98
|
+
solver.set(timeout=self.timeout_ms)
|
|
99
|
+
fields = {}
|
|
100
|
+
|
|
101
|
+
def field(name):
|
|
102
|
+
if name not in fields:
|
|
103
|
+
index = len(fields)
|
|
104
|
+
fields[name] = (z3.String(f"v{index}", ctx=ctx),
|
|
105
|
+
z3.Bool(f"p{index}", ctx=ctx))
|
|
106
|
+
return fields[name]
|
|
107
|
+
|
|
108
|
+
def compile_node(node):
|
|
109
|
+
if type(node) is Eq:
|
|
110
|
+
value, present = field(node.field)
|
|
111
|
+
return z3.And(present, value == z3.StringVal(node.value, ctx=ctx))
|
|
112
|
+
if type(node) is And:
|
|
113
|
+
terms = [compile_node(p) for p in node.terms]
|
|
114
|
+
return z3.And(*terms) if terms else z3.BoolVal(True, ctx=ctx)
|
|
115
|
+
if type(node) is Or:
|
|
116
|
+
terms = [compile_node(p) for p in node.terms]
|
|
117
|
+
return z3.Or(*terms) if terms else z3.BoolVal(False, ctx=ctx)
|
|
118
|
+
return z3.Not(compile_node(node.term))
|
|
119
|
+
|
|
120
|
+
selected = compile_node(selector)
|
|
121
|
+
expected = {"tenant_id": context.tenant_id}
|
|
122
|
+
expected.update(required or {})
|
|
123
|
+
violations = []
|
|
124
|
+
for name, expected_value in expected.items():
|
|
125
|
+
value, present = field(name)
|
|
126
|
+
violations.append(z3.Or(z3.Not(present),
|
|
127
|
+
value != z3.StringVal(expected_value, ctx=ctx)))
|
|
128
|
+
solver.add(selected, z3.Or(*violations))
|
|
129
|
+
outcome = solver.check()
|
|
130
|
+
if outcome == z3.unsat:
|
|
131
|
+
status, reason = VerificationStatus.PROVEN, "Selector preserves tenant ownership"
|
|
132
|
+
elif outcome == z3.sat:
|
|
133
|
+
status, reason = VerificationStatus.VIOLATION, "Selector can include foreign or unlabeled data"
|
|
134
|
+
else:
|
|
135
|
+
status = VerificationStatus.UNKNOWN
|
|
136
|
+
reason = "Solver inconclusive: " + solver.reason_unknown()
|
|
137
|
+
return VerificationResult(status, digest, context.policy_version, reason)
|
|
138
|
+
except GuardError:
|
|
139
|
+
return VerificationResult(VerificationStatus.UNSUPPORTED, digest, "",
|
|
140
|
+
"Selector or context is unsupported")
|
|
141
|
+
except Exception:
|
|
142
|
+
# Do not expose solver/input details or interpret errors as success.
|
|
143
|
+
return VerificationResult(VerificationStatus.ERROR, digest, "",
|
|
144
|
+
"Verification failed")
|
|
145
|
+
|
|
146
|
+
def verify_ownership(self, context: TenantContext, owner: str) -> None:
|
|
147
|
+
if type(context) is not TenantContext:
|
|
148
|
+
raise VerificationError("A validated context is required")
|
|
149
|
+
try:
|
|
150
|
+
validate_identifier(owner, "owner")
|
|
151
|
+
except GuardError as exc:
|
|
152
|
+
raise IsolationViolationError("Resource ownership is missing or invalid") from exc
|
|
153
|
+
if owner != context.tenant_id:
|
|
154
|
+
raise IsolationViolationError("Resource belongs to another tenant")
|
ten_grd/exceptions.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Failures never authorize an operation."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class GuardError(Exception):
|
|
5
|
+
"""Base error raised before protected output is exposed."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class InvalidContextError(GuardError):
|
|
9
|
+
"""A valid host-authenticated tenant context is required."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class InvalidOperationError(GuardError):
|
|
13
|
+
"""The operation is outside the supported contract."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class VerificationError(GuardError):
|
|
17
|
+
"""Verification did not establish isolation."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class IsolationViolationError(VerificationError):
|
|
21
|
+
"""A modeled violation or foreign-owned result was detected."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AdapterError(GuardError):
|
|
25
|
+
"""A backend operation failed; execution never falls back to broader scope."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Optional backends. Import modules individually to load only needed drivers."""
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Shared record codec and enforcement for backend adapters."""
|
|
2
|
+
from ..context import current_context
|
|
3
|
+
from ..exceptions import IsolationViolationError, VerificationError
|
|
4
|
+
from ..operations import Operation, OperationKind, Record
|
|
5
|
+
from ..predicates import And, Eq, matches, validate_predicate
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def payload(record):
|
|
9
|
+
return {"tenant_id": record.tenant_id, "record_id": record.record_id,
|
|
10
|
+
"data_json": record.data_json, "metadata": dict(record.metadata)}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def decode_record(value):
|
|
14
|
+
try:
|
|
15
|
+
if type(value) is not dict or set(value) != {"tenant_id", "record_id", "data_json", "metadata"}:
|
|
16
|
+
raise ValueError()
|
|
17
|
+
if type(value["metadata"]) is not dict:
|
|
18
|
+
raise ValueError()
|
|
19
|
+
return Record(value["tenant_id"], value["record_id"], value["data_json"],
|
|
20
|
+
tuple(value["metadata"].items()))
|
|
21
|
+
except Exception:
|
|
22
|
+
raise IsolationViolationError("Backend returned an invalid ownership envelope") from None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def authorize(context, operation, engine):
|
|
26
|
+
result = engine.verify_operation(context, operation)
|
|
27
|
+
result.require_proven()
|
|
28
|
+
if (result.operation_digest != operation.digest
|
|
29
|
+
or result.policy_version != context.policy_version
|
|
30
|
+
or current_context() != context):
|
|
31
|
+
raise VerificationError("Operation evidence or active context changed")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def scoped_operation(context, predicate=None, *, kind=OperationKind.SEARCH,
|
|
35
|
+
record_id=None, record=None):
|
|
36
|
+
terms = [Eq("tenant_id", context.tenant_id)]
|
|
37
|
+
if predicate is not None:
|
|
38
|
+
validate_predicate(predicate)
|
|
39
|
+
terms.append(predicate)
|
|
40
|
+
if record_id is not None:
|
|
41
|
+
terms.append(Eq("record_id", record_id))
|
|
42
|
+
return Operation(context.tenant_id, kind, And(tuple(terms)), record_id, record)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def checked_records(context, operation, records, engine):
|
|
46
|
+
result = tuple(records)
|
|
47
|
+
for record in result:
|
|
48
|
+
engine.verify_ownership(context, record.tenant_id)
|
|
49
|
+
if not matches(operation.selector, record.fields()):
|
|
50
|
+
raise IsolationViolationError("Backend result is outside the verified selector")
|
|
51
|
+
if current_context() != context:
|
|
52
|
+
raise VerificationError("Active context changed during backend execution")
|
|
53
|
+
return result
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Exact cache scopes bind tenant, subject, policy and configuration."""
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
from ..context import current_context, validate_identifier
|
|
7
|
+
from ..engine import Z3InvariantEngine
|
|
8
|
+
from ..exceptions import AdapterError, InvalidOperationError, IsolationViolationError, VerificationError
|
|
9
|
+
from ..operations import _json
|
|
10
|
+
from ..predicates import And, Eq
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class CacheScope:
|
|
15
|
+
tenant_id: str
|
|
16
|
+
subject_id: str
|
|
17
|
+
policy_version: str
|
|
18
|
+
namespace: str
|
|
19
|
+
model_version: str
|
|
20
|
+
|
|
21
|
+
def fields(self):
|
|
22
|
+
return dict(tenant_id=self.tenant_id, subject_id=self.subject_id,
|
|
23
|
+
policy_version=self.policy_version, namespace=self.namespace,
|
|
24
|
+
model_version=self.model_version)
|
|
25
|
+
|
|
26
|
+
def selector(self):
|
|
27
|
+
return And(tuple(Eq(k, v) for k, v in self.fields().items()))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class CacheHit:
|
|
32
|
+
value_json: str
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def data(self):
|
|
36
|
+
return json.loads(self.value_json)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def active_scope(namespace, model_version, engine):
|
|
40
|
+
context = current_context()
|
|
41
|
+
scope = CacheScope(context.tenant_id, context.subject_id, context.policy_version,
|
|
42
|
+
namespace, model_version)
|
|
43
|
+
engine.verify_scope(context, scope.selector(), required=scope.fields()).require_proven()
|
|
44
|
+
if current_context() != context:
|
|
45
|
+
raise VerificationError("Active context changed during cache verification")
|
|
46
|
+
return context, scope
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def request_json(request):
|
|
50
|
+
encoded = _json(request)
|
|
51
|
+
if len(encoded) > 65536:
|
|
52
|
+
raise InvalidOperationError("Cache request exceeds 64 KiB")
|
|
53
|
+
return encoded
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def cache_key(scope, encoded_request):
|
|
57
|
+
# Hashing is NOT modeled as injective. Reads validate the full binding.
|
|
58
|
+
canonical = _json(["ten-grd-cache-v1", scope.fields(), encoded_request])
|
|
59
|
+
return "ten-grd:cache:v1:" + hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def validate_ttl(ttl_seconds):
|
|
63
|
+
if type(ttl_seconds) is not int or not 1 <= ttl_seconds <= 31_536_000:
|
|
64
|
+
raise InvalidOperationError("TTL must be an integer between 1 and 31536000 seconds")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class RedisCache:
|
|
68
|
+
"""Synchronous redis-py client or compatible backend; no broad-scope fallback."""
|
|
69
|
+
def __init__(self, client, *, namespace, model_version, engine=None):
|
|
70
|
+
validate_identifier(namespace, "namespace")
|
|
71
|
+
validate_identifier(model_version, "model_version")
|
|
72
|
+
self.client, self.namespace, self.model_version = client, namespace, model_version
|
|
73
|
+
self.engine = engine if engine is not None else Z3InvariantEngine()
|
|
74
|
+
|
|
75
|
+
def _binding(self, request):
|
|
76
|
+
context, scope = active_scope(self.namespace, self.model_version, self.engine)
|
|
77
|
+
encoded = request_json(request)
|
|
78
|
+
return context, scope, encoded, cache_key(scope, encoded)
|
|
79
|
+
|
|
80
|
+
def set(self, request, data, *, ttl_seconds=300):
|
|
81
|
+
validate_ttl(ttl_seconds)
|
|
82
|
+
context, scope, encoded, key = self._binding(request)
|
|
83
|
+
value_json = _json(data)
|
|
84
|
+
if len(value_json) > 1_048_576:
|
|
85
|
+
raise InvalidOperationError("Cached value exceeds 1 MiB")
|
|
86
|
+
envelope = _json({"scope": scope.fields(), "request_json": encoded,
|
|
87
|
+
"value_json": value_json})
|
|
88
|
+
if current_context() != context:
|
|
89
|
+
raise VerificationError("Active context changed before cache write")
|
|
90
|
+
try:
|
|
91
|
+
written = self.client.set(key, envelope, ex=ttl_seconds)
|
|
92
|
+
except Exception:
|
|
93
|
+
raise AdapterError("Redis cache write failed") from None
|
|
94
|
+
if not written:
|
|
95
|
+
raise AdapterError("Redis did not acknowledge the cache write")
|
|
96
|
+
|
|
97
|
+
def get(self, request):
|
|
98
|
+
context, scope, encoded, key = self._binding(request)
|
|
99
|
+
try:
|
|
100
|
+
raw = self.client.get(key)
|
|
101
|
+
except Exception:
|
|
102
|
+
raise AdapterError("Redis cache lookup failed") from None
|
|
103
|
+
if raw is None:
|
|
104
|
+
return None
|
|
105
|
+
try:
|
|
106
|
+
envelope = json.loads(raw)
|
|
107
|
+
if (type(envelope) is not dict or envelope["scope"] != scope.fields()
|
|
108
|
+
or envelope["request_json"] != encoded
|
|
109
|
+
or not isinstance(envelope["value_json"], str)):
|
|
110
|
+
raise ValueError()
|
|
111
|
+
value = json.loads(envelope["value_json"])
|
|
112
|
+
canonical = _json(value)
|
|
113
|
+
if len(canonical) > 1_048_576:
|
|
114
|
+
raise ValueError()
|
|
115
|
+
except Exception:
|
|
116
|
+
raise IsolationViolationError("Cache hit has invalid ownership or request binding") from None
|
|
117
|
+
if current_context() != context:
|
|
118
|
+
raise VerificationError("Active context changed during cache lookup")
|
|
119
|
+
return CacheHit(canonical)
|
|
120
|
+
|
|
121
|
+
def delete(self, request):
|
|
122
|
+
_, _, _, key = self._binding(request)
|
|
123
|
+
try:
|
|
124
|
+
return bool(self.client.delete(key))
|
|
125
|
+
except Exception:
|
|
126
|
+
raise AdapterError("Redis cache invalidation failed") from None
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""LangChain tools with invocation-local scope and no tenant arguments."""
|
|
2
|
+
import asyncio
|
|
3
|
+
from functools import wraps
|
|
4
|
+
from typing import Any
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
6
|
+
from langchain_core.tools import StructuredTool
|
|
7
|
+
|
|
8
|
+
from ..context import current_context
|
|
9
|
+
from ..exceptions import InvalidOperationError, VerificationError
|
|
10
|
+
from ..predicates import Eq
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _Args(BaseModel):
|
|
14
|
+
model_config = ConfigDict(extra="forbid")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class _RecordArgs(_Args):
|
|
18
|
+
record_id: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _SearchArgs(_Args):
|
|
22
|
+
field: str | None = None
|
|
23
|
+
value: str | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _PutArgs(_RecordArgs):
|
|
27
|
+
data: Any
|
|
28
|
+
metadata: dict[str, str] | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _RetrievalArgs(_Args):
|
|
32
|
+
query: str
|
|
33
|
+
limit: int = Field(default=10, ge=1, le=1000)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _tool(function, name, description, schema):
|
|
37
|
+
@wraps(function)
|
|
38
|
+
async def asynchronous(*args, **kwargs):
|
|
39
|
+
# to_thread propagates the invocation's contextvars, not creation scope.
|
|
40
|
+
return await asyncio.to_thread(function, *args, **kwargs)
|
|
41
|
+
return StructuredTool.from_function(func=function, coroutine=asynchronous,
|
|
42
|
+
name=name, description=description, args_schema=schema)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _prefix(prefix):
|
|
46
|
+
if not isinstance(prefix, str) or not prefix.isidentifier() or len(prefix) > 64:
|
|
47
|
+
raise InvalidOperationError("Tool prefix must be an identifier of at most 64 characters")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def memory_tools(guard, *, prefix="grd", include_writes=False):
|
|
51
|
+
"""Host authenticates each invocation; tools never capture a tenant at creation."""
|
|
52
|
+
_prefix(prefix)
|
|
53
|
+
def read(record_id):
|
|
54
|
+
record = guard.get(record_id)
|
|
55
|
+
return None if record is None else record.data
|
|
56
|
+
|
|
57
|
+
def search(field=None, value=None):
|
|
58
|
+
if (field is None) != (value is None):
|
|
59
|
+
raise InvalidOperationError("Provide both field and value, or neither")
|
|
60
|
+
predicate = None if field is None else Eq(field, value)
|
|
61
|
+
return [{"record_id": record.record_id, "data": record.data}
|
|
62
|
+
for record in guard.search(predicate)]
|
|
63
|
+
|
|
64
|
+
tools = [
|
|
65
|
+
_tool(read, prefix + "_read", "Read a record in the authenticated tenant.", _RecordArgs),
|
|
66
|
+
_tool(search, prefix + "_search", "Search records in the authenticated tenant.", _SearchArgs),
|
|
67
|
+
]
|
|
68
|
+
if include_writes:
|
|
69
|
+
def put(record_id, data, metadata=None):
|
|
70
|
+
return guard.put(record_id, data, metadata=metadata).data
|
|
71
|
+
def delete(record_id):
|
|
72
|
+
return guard.delete(record_id)
|
|
73
|
+
tools.extend([
|
|
74
|
+
_tool(put, prefix + "_put", "Write a record in the authenticated tenant.", _PutArgs),
|
|
75
|
+
_tool(delete, prefix + "_delete", "Delete a record in the authenticated tenant.", _RecordArgs),
|
|
76
|
+
])
|
|
77
|
+
return tools
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def retrieval_tool(retriever, embed_query, *, name="grd_retrieve"):
|
|
81
|
+
_prefix(name)
|
|
82
|
+
def retrieve(query, limit=10):
|
|
83
|
+
context = current_context()
|
|
84
|
+
vector = embed_query(query)
|
|
85
|
+
if current_context() != context:
|
|
86
|
+
raise VerificationError("Embedding callback changed the active context")
|
|
87
|
+
return [{"record_id": hit.record.record_id, "data": hit.record.data, "score": hit.score}
|
|
88
|
+
for hit in retriever.search(vector, limit=limit)]
|
|
89
|
+
return _tool(retrieve, name, "Retrieve documents within the authenticated tenant.", _RetrievalArgs)
|