themis-policy 0.1.0__tar.gz

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.
@@ -0,0 +1,61 @@
1
+ Metadata-Version: 2.4
2
+ Name: themis-policy
3
+ Version: 0.1.0
4
+ Summary: Themis policy kernel for bounded-autonomy LLM agents — locks, drafts, scopes, audit. Python reference implementation of Themis RFC v0.
5
+ Author: Adam Campbell
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/Adam-Camp-King/Themis
8
+ Project-URL: Specification, https://github.com/Adam-Camp-King/Themis/tree/main/spec
9
+ Keywords: llm,agents,policy,safety,bounded-autonomy,multi-tenant,audit
10
+ Classifier: License :: OSI Approved :: Apache Software License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Typing :: Typed
16
+ Requires-Python: >=3.11
17
+ Description-Content-Type: text/markdown
18
+ Provides-Extra: test
19
+ Requires-Dist: pytest>=8; extra == "test"
20
+
21
+ # themis-policy
22
+
23
+ Python reference implementation of **Themis RFC v0** — a policy kernel for
24
+ LLM agents that act on production systems. Four primitives — **locks**,
25
+ **drafts**, **scopes**, **audit** — composed by one engine into a single
26
+ decision per attempted action: `allow`, `deny`, `redirect`, or
27
+ `require_approval`.
28
+
29
+ Byte-compatible with [`themis-policy`](https://github.com/Adam-Camp-King/Themis)
30
+ (TypeScript): both pass the same [conformance vectors](../spec/conformance).
31
+
32
+ ```python
33
+ from themis import (
34
+ PolicyEngine, DefaultLockPolicy, DefaultScopePolicy, DefaultDraftPolicy,
35
+ Requestor, Action, PolicyContext, ConsoleSink, is_deny,
36
+ )
37
+
38
+ engine = PolicyEngine(audit_sink=ConsoleSink())
39
+ engine.add_policy(DefaultLockPolicy())
40
+ engine.add_policy(DefaultScopePolicy())
41
+ engine.add_policy(DefaultDraftPolicy())
42
+
43
+ decision = engine.evaluate(PolicyContext(
44
+ requestor=Requestor(id="key_1", kind="api_key", tenant_id=7, scopes=("pages:read",)),
45
+ action=Action(verb="pages.update", resource_type="page", tenant_id=7, resource_id=42,
46
+ required_scope="pages:write"),
47
+ now=0, correlation_id="req-123",
48
+ ))
49
+ if is_deny(decision):
50
+ print(decision.reason) # missing_scope
51
+ ```
52
+
53
+ The engine is synchronous (every core policy is pure CPU); `evaluate_async`
54
+ exists for callers already inside an event loop. Stores (`LockStore`,
55
+ `DraftStore`) are ports you implement against your persistence; audit sinks
56
+ receive one event per evaluation and may never fail an evaluation.
57
+
58
+ Install: `pip install themis-policy` (Python 3.11+, no dependencies).
59
+ Tests: `pip install -e '.[test]' && pytest`.
60
+
61
+ Apache-2.0 — see the repository LICENSE.
@@ -0,0 +1,41 @@
1
+ # themis-policy
2
+
3
+ Python reference implementation of **Themis RFC v0** — a policy kernel for
4
+ LLM agents that act on production systems. Four primitives — **locks**,
5
+ **drafts**, **scopes**, **audit** — composed by one engine into a single
6
+ decision per attempted action: `allow`, `deny`, `redirect`, or
7
+ `require_approval`.
8
+
9
+ Byte-compatible with [`themis-policy`](https://github.com/Adam-Camp-King/Themis)
10
+ (TypeScript): both pass the same [conformance vectors](../spec/conformance).
11
+
12
+ ```python
13
+ from themis import (
14
+ PolicyEngine, DefaultLockPolicy, DefaultScopePolicy, DefaultDraftPolicy,
15
+ Requestor, Action, PolicyContext, ConsoleSink, is_deny,
16
+ )
17
+
18
+ engine = PolicyEngine(audit_sink=ConsoleSink())
19
+ engine.add_policy(DefaultLockPolicy())
20
+ engine.add_policy(DefaultScopePolicy())
21
+ engine.add_policy(DefaultDraftPolicy())
22
+
23
+ decision = engine.evaluate(PolicyContext(
24
+ requestor=Requestor(id="key_1", kind="api_key", tenant_id=7, scopes=("pages:read",)),
25
+ action=Action(verb="pages.update", resource_type="page", tenant_id=7, resource_id=42,
26
+ required_scope="pages:write"),
27
+ now=0, correlation_id="req-123",
28
+ ))
29
+ if is_deny(decision):
30
+ print(decision.reason) # missing_scope
31
+ ```
32
+
33
+ The engine is synchronous (every core policy is pure CPU); `evaluate_async`
34
+ exists for callers already inside an event loop. Stores (`LockStore`,
35
+ `DraftStore`) are ports you implement against your persistence; audit sinks
36
+ receive one event per evaluation and may never fail an evaluation.
37
+
38
+ Install: `pip install themis-policy` (Python 3.11+, no dependencies).
39
+ Tests: `pip install -e '.[test]' && pytest`.
40
+
41
+ Apache-2.0 — see the repository LICENSE.
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "themis-policy"
7
+ version = "0.1.0"
8
+ description = "Themis policy kernel for bounded-autonomy LLM agents — locks, drafts, scopes, audit. Python reference implementation of Themis RFC v0."
9
+ readme = "README.md"
10
+ license = { text = "Apache-2.0" }
11
+ authors = [{ name = "Adam Campbell" }]
12
+ requires-python = ">=3.11"
13
+ dependencies = []
14
+ keywords = ["llm", "agents", "policy", "safety", "bounded-autonomy", "multi-tenant", "audit"]
15
+ classifiers = [
16
+ "License :: OSI Approved :: Apache Software License",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ "Typing :: Typed",
22
+ ]
23
+
24
+ [project.urls]
25
+ Homepage = "https://github.com/Adam-Camp-King/Themis"
26
+ Specification = "https://github.com/Adam-Camp-King/Themis/tree/main/spec"
27
+
28
+ [project.optional-dependencies]
29
+ test = ["pytest>=8"]
30
+
31
+ [tool.setuptools.packages.find]
32
+ where = ["src"]
33
+
34
+ [tool.setuptools.package-data]
35
+ themis = ["py.typed"]
36
+
37
+ [tool.pytest.ini_options]
38
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,30 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2026 Adam Campbell
3
+ """Themis — a policy kernel for bounded-autonomy LLM agents.
4
+
5
+ Python reference implementation of Themis RFC v0. Four primitives — locks,
6
+ drafts, scopes, audit — composed by one engine into a single decision per
7
+ attempted action: allow, deny, redirect, or require_approval.
8
+
9
+ Conformance: ``spec/conformance/v0`` in the Themis repository holds the
10
+ cross-language vectors; this package and ``themis-policy`` pass the same set.
11
+ """
12
+ from .types import (
13
+ Action, Allow, AuditEvent, AuditSink, Decision, Deny, DraftableEntity, DraftStore, Entity, Id,
14
+ LockableEntity, LockStore, Policy, PolicyContext, Redirect, RequireApproval, Requestor, RequestorKind,
15
+ decision_from_dict,
16
+ )
17
+ from .engine import PolicyEngine, EmitPolicy, build_audit_event
18
+ from .guards import is_allow, is_deny, is_redirect, is_require_approval
19
+ from .sinks import ConsoleSink, MemorySink, MultiSink, NoOpSink
20
+ from .policies import DefaultDraftPolicy, DefaultLockPolicy, DefaultScopePolicy
21
+
22
+ __version__ = "0.1.0"
23
+ __all__ = [
24
+ "Action", "Allow", "AuditEvent", "AuditSink", "Decision", "Deny", "DraftableEntity", "DraftStore", "Entity",
25
+ "Id", "LockableEntity", "LockStore", "Policy", "PolicyContext", "Redirect", "RequireApproval", "Requestor",
26
+ "RequestorKind", "decision_from_dict", "PolicyEngine", "EmitPolicy", "build_audit_event",
27
+ "is_allow", "is_deny", "is_redirect", "is_require_approval",
28
+ "ConsoleSink", "MemorySink", "MultiSink", "NoOpSink",
29
+ "DefaultDraftPolicy", "DefaultLockPolicy", "DefaultScopePolicy", "__version__",
30
+ ]
@@ -0,0 +1,128 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2026 Adam Campbell
3
+ """themis.engine — the Themis kernel (RFC v0 § 6).
4
+
5
+ Evaluation rules (normative, identical to ``themis-policy``):
6
+
7
+ 0. Tenancy gate — requestor.tenant_id != action.tenant_id → deny
8
+ {policy: 'engine', reason: 'tenancy_mismatch'} before any policy runs.
9
+ 1. Iterate policies in registration order.
10
+ 2. ``deny`` short-circuits.
11
+ 3. ``require_approval`` short-circuits.
12
+ 4. ``redirect`` does NOT short-circuit; a later deny wins; otherwise the LAST
13
+ redirect wins, with the full policy chain recorded.
14
+ 5. All ``allow`` → allow.
15
+ 6. Exactly one audit event per evaluation (when emit_policy != 'off').
16
+ Sink failures never fail the evaluation (§ 9.4).
17
+
18
+ The engine is synchronous: every policy here is pure CPU. ``evaluate_async``
19
+ is provided for callers already inside an event loop.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import time
24
+ import uuid
25
+ from typing import Any, Literal, Mapping, Optional, Sequence
26
+
27
+ from .types import (
28
+ Allow, AuditEvent, AuditSink, Decision, Deny, Policy, PolicyContext, Redirect, RequireApproval,
29
+ )
30
+
31
+ EmitPolicy = Literal["all", "denials_only", "off"]
32
+
33
+
34
+ def _now_ms() -> int:
35
+ return int(time.time() * 1000)
36
+
37
+
38
+ def build_audit_event(
39
+ ctx: PolicyContext, decision: Decision, policy_chain: Sequence[str], latency_ms: float,
40
+ ) -> AuditEvent:
41
+ """Denormalize requestor/action — an audit event must not carry live references."""
42
+ requestor: dict[str, Any] = {
43
+ "id": ctx.requestor.id,
44
+ "kind": ctx.requestor.kind,
45
+ "scopes": list(ctx.requestor.scopes) if ctx.requestor.scopes else None,
46
+ "role": ctx.requestor.role,
47
+ }
48
+ action: dict[str, Any] = {
49
+ "verb": ctx.action.verb,
50
+ "resource_type": ctx.action.resource_type,
51
+ "resource_id": ctx.action.resource_id,
52
+ "area": ctx.action.area,
53
+ "required_scope": ctx.action.required_scope,
54
+ }
55
+ return AuditEvent(
56
+ id=str(uuid.uuid4()),
57
+ timestamp=ctx.now or _now_ms(),
58
+ correlation_id=ctx.correlation_id,
59
+ tenant_id=ctx.action.tenant_id,
60
+ requestor=requestor,
61
+ action=action,
62
+ decision=decision,
63
+ policy_chain=tuple(policy_chain),
64
+ latency_ms=latency_ms,
65
+ metadata=ctx.action.metadata,
66
+ )
67
+
68
+
69
+ class PolicyEngine:
70
+ def __init__(self, audit_sink: Optional[AuditSink] = None, emit_policy: EmitPolicy = "all") -> None:
71
+ self.audit_sink = audit_sink
72
+ self._emit_policy: EmitPolicy = emit_policy
73
+ self._registrations: list[tuple[Policy, Mapping[str, Any]]] = []
74
+
75
+ def add_policy(self, policy: Policy, config: Optional[Mapping[str, Any]] = None) -> None:
76
+ self._registrations.append((policy, dict(config or {})))
77
+
78
+ @property
79
+ def policies(self) -> tuple[Policy, ...]:
80
+ return tuple(p for p, _ in self._registrations)
81
+
82
+ def evaluate(self, ctx: PolicyContext) -> Decision:
83
+ start = time.perf_counter()
84
+ chain: list[str] = []
85
+
86
+ # Rule 0 — tenancy gate
87
+ if ctx.requestor.tenant_id != ctx.action.tenant_id:
88
+ decision: Decision = Deny(
89
+ policy="engine",
90
+ reason="tenancy_mismatch",
91
+ message="requestor and action belong to different tenants",
92
+ detail={"requestor_tenant": ctx.requestor.tenant_id, "action_tenant": ctx.action.tenant_id},
93
+ )
94
+ self._emit_if_permitted(ctx, decision, chain, start)
95
+ return decision
96
+
97
+ current_redirect: Optional[Redirect] = None
98
+ for policy, _config in self._registrations:
99
+ d = policy.evaluate(ctx)
100
+ chain.append(policy.name)
101
+ if isinstance(d, (Deny, RequireApproval)):
102
+ self._emit_if_permitted(ctx, d, chain, start)
103
+ return d
104
+ if isinstance(d, Redirect):
105
+ current_redirect = d # does not short-circuit
106
+ # Allow → continue
107
+
108
+ final: Decision = current_redirect if current_redirect is not None else Allow()
109
+ self._emit_if_permitted(ctx, final, chain, start)
110
+ return final
111
+
112
+ async def evaluate_async(self, ctx: PolicyContext) -> Decision:
113
+ return self.evaluate(ctx)
114
+
115
+ def _emit_if_permitted(self, ctx: PolicyContext, decision: Decision, chain: list[str], start: float) -> None:
116
+ if self.audit_sink is None or self._emit_policy == "off":
117
+ return
118
+ if self._emit_policy == "denials_only" and isinstance(decision, Allow):
119
+ return
120
+ latency_ms = (time.perf_counter() - start) * 1000.0
121
+ event = build_audit_event(ctx, decision, chain, latency_ms)
122
+ try:
123
+ self.audit_sink.emit(event)
124
+ except Exception: # noqa: BLE001 — RFC § 9.4: audit failure never fails evaluation
125
+ pass
126
+
127
+
128
+ __all__ = ["PolicyEngine", "EmitPolicy", "build_audit_event"]
@@ -0,0 +1,27 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2026 Adam Campbell
3
+ """Decision narrowing helpers — the Python twins of ``themis-policy`` guards."""
4
+ from __future__ import annotations
5
+
6
+ from typing import TypeGuard
7
+
8
+ from .types import Allow, Decision, Deny, Redirect, RequireApproval
9
+
10
+
11
+ def is_allow(d: Decision) -> TypeGuard[Allow]:
12
+ return isinstance(d, Allow)
13
+
14
+
15
+ def is_deny(d: Decision) -> TypeGuard[Deny]:
16
+ return isinstance(d, Deny)
17
+
18
+
19
+ def is_redirect(d: Decision) -> TypeGuard[Redirect]:
20
+ return isinstance(d, Redirect)
21
+
22
+
23
+ def is_require_approval(d: Decision) -> TypeGuard[RequireApproval]:
24
+ return isinstance(d, RequireApproval)
25
+
26
+
27
+ __all__ = ["is_allow", "is_deny", "is_redirect", "is_require_approval"]
@@ -0,0 +1,6 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ from .lock import DefaultLockPolicy
3
+ from .scope import DefaultScopePolicy
4
+ from .draft import DefaultDraftPolicy
5
+
6
+ __all__ = ["DefaultLockPolicy", "DefaultScopePolicy", "DefaultDraftPolicy"]
@@ -0,0 +1,122 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2026 Adam Campbell
3
+ """DefaultDraftPolicy — the T12 primitive (RFC v0 § 5.2, § 9.3).
4
+
5
+ Decision rule:
6
+ 1. entity is None (create) → allow
7
+ 2. entity is not draftable → allow (inert)
8
+ 3. entity.is_published is not True → allow
9
+ 4. action.payload['publish'] is True → allow (explicit live write)
10
+ 5. otherwise → redirect {target: 'draft', payload}
11
+
12
+ Preview token — byte-identical to ``themis-policy``:
13
+ base64url( "v1.<entity_id>.<tenant_id>.<exp_ms>" + "." + hmac_sha256_hex )
14
+ Verify uses constant-time comparison, rejects expired/tampered/wrong-secret/
15
+ wrong-version tokens, and clamps TTL to 168 hours.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import base64
20
+ import hashlib
21
+ import hmac
22
+ import math
23
+ import re
24
+ import time
25
+ from typing import Any, Mapping, Optional, Union
26
+
27
+ from ..types import Allow, Decision, DraftableEntity, Id, PolicyContext, Redirect
28
+
29
+ TOKEN_VERSION = "v1"
30
+ MAX_TTL_HOURS = 168
31
+ DEFAULT_TTL_HOURS = 24
32
+ _INT = re.compile(r"^-?\d+$")
33
+
34
+
35
+ def _b64url(s: str) -> str:
36
+ return base64.urlsafe_b64encode(s.encode("utf-8")).decode("ascii").rstrip("=")
37
+
38
+
39
+ def _from_b64url(s: str) -> str:
40
+ pad = (4 - len(s) % 4) % 4
41
+ return base64.urlsafe_b64decode((s + "=" * pad).encode("ascii")).decode("utf-8")
42
+
43
+
44
+ def _hmac(secret: str, payload: str) -> str:
45
+ return hmac.new(secret.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).hexdigest()
46
+
47
+
48
+ def _clamp_ttl(hours: float) -> int:
49
+ if not isinstance(hours, (int, float)) or math.isnan(hours) or math.isinf(hours) or hours <= 0:
50
+ return DEFAULT_TTL_HOURS
51
+ if hours > MAX_TTL_HOURS:
52
+ return MAX_TTL_HOURS
53
+ return int(hours)
54
+
55
+
56
+ def _id_maybe_numeric(s: str) -> Id:
57
+ if _INT.match(s):
58
+ n = int(s)
59
+ if -(2**53 - 1) <= n <= 2**53 - 1:
60
+ return n
61
+ return s
62
+
63
+
64
+ class DefaultDraftPolicy:
65
+ name = "draft"
66
+
67
+ def evaluate(self, ctx: PolicyContext) -> Decision:
68
+ entity = ctx.entity
69
+ if entity is None or not isinstance(entity, DraftableEntity):
70
+ return Allow(policy=self.name)
71
+ if entity.is_published is not True:
72
+ return Allow(policy=self.name)
73
+ payload = ctx.action.payload
74
+ if isinstance(payload, Mapping) and payload.get("publish") is True:
75
+ return Allow(policy=self.name)
76
+ return Redirect(policy=self.name, target="draft", payload=payload if payload is not None else None)
77
+
78
+ def merge(self, entity: DraftableEntity, draft_payload: Any) -> DraftableEntity:
79
+ return DraftableEntity(
80
+ id=entity.id, tenant_id=entity.tenant_id, is_published=True,
81
+ has_pending_draft=False, draft_updated_at=None, draft_updated_by=None,
82
+ )
83
+
84
+ def clear(self, entity: DraftableEntity) -> DraftableEntity:
85
+ return DraftableEntity(
86
+ id=entity.id, tenant_id=entity.tenant_id, is_published=entity.is_published,
87
+ has_pending_draft=False, draft_updated_at=None, draft_updated_by=None,
88
+ )
89
+
90
+ def sign_preview_token(self, entity_id: Id, tenant_id: Id, ttl_hours: float, secret: str) -> str:
91
+ if not secret:
92
+ raise ValueError("sign_preview_token: secret must be non-empty")
93
+ exp_ms = int(time.time() * 1000) + _clamp_ttl(ttl_hours) * 3_600_000
94
+ payload = f"{TOKEN_VERSION}.{entity_id}.{tenant_id}.{exp_ms}"
95
+ return _b64url(f"{payload}.{_hmac(secret, payload)}")
96
+
97
+ def verify_preview_token(self, token: str, secret: str) -> Optional[dict[str, Id]]:
98
+ if not token or not secret:
99
+ return None
100
+ try:
101
+ decoded = _from_b64url(token)
102
+ except Exception: # noqa: BLE001 — malformed token is a None, never a raise
103
+ return None
104
+ parts = decoded.split(".")
105
+ if len(parts) != 5:
106
+ return None
107
+ version, entity_s, tenant_s, exp_s, mac = parts
108
+ if version != TOKEN_VERSION:
109
+ return None
110
+ expected = _hmac(secret, f"{version}.{entity_s}.{tenant_s}.{exp_s}")
111
+ if len(mac) != len(expected) or not hmac.compare_digest(mac, expected):
112
+ return None
113
+ try:
114
+ exp_ms = int(exp_s)
115
+ except ValueError:
116
+ return None
117
+ if exp_ms <= int(time.time() * 1000):
118
+ return None
119
+ return {"entity_id": _id_maybe_numeric(entity_s), "tenant_id": _id_maybe_numeric(tenant_s)}
120
+
121
+
122
+ __all__ = ["DefaultDraftPolicy"]
@@ -0,0 +1,60 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2026 Adam Campbell
3
+ """DefaultLockPolicy — the T10 primitive (RFC v0 § 5.1).
4
+
5
+ Decision rule, first match wins:
6
+ 1. entity is None (create) → allow
7
+ 2. entity.agency_owner_id is None (self-serve) → allow
8
+ 3. requestor.is_super_admin → allow
9
+ 4. requestor.role == 'agency' → allow
10
+ 5. requestor.id == entity.agency_owner_id → allow
11
+ 6. action.area not set → allow
12
+ 7. entity.locks[area] is not True → allow
13
+ 8. otherwise → deny
14
+ Bypass rules 3–5 MUST NOT be removed by implementations.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from typing import Mapping, Sequence
19
+
20
+ from ..types import Allow, Decision, Deny, LockableEntity, PolicyContext
21
+
22
+
23
+ class DefaultLockPolicy:
24
+ name = "lock"
25
+
26
+ def evaluate(self, ctx: PolicyContext) -> Decision:
27
+ entity = ctx.entity
28
+ if entity is None or not isinstance(entity, LockableEntity):
29
+ return Allow(policy=self.name)
30
+ if entity.agency_owner_id is None:
31
+ return Allow(policy=self.name)
32
+ if ctx.requestor.is_super_admin is True:
33
+ return Allow(policy=self.name)
34
+ if ctx.requestor.role == "agency":
35
+ return Allow(policy=self.name)
36
+ if ctx.requestor.id == entity.agency_owner_id:
37
+ return Allow(policy=self.name)
38
+ area = ctx.action.area
39
+ if not area:
40
+ return Allow(policy=self.name)
41
+ if entity.locks.get(area) is not True:
42
+ return Allow(policy=self.name)
43
+ return Deny(
44
+ policy=self.name,
45
+ reason="agency_lock",
46
+ message=f"Area '{area}' is locked by the agency for this resource.",
47
+ detail={"area": area, "entity_id": entity.id, "agency_owner_id": entity.agency_owner_id},
48
+ )
49
+
50
+ def describe(self, entity: LockableEntity, areas: Sequence[str]) -> dict[str, bool]:
51
+ return {a: entity.locks.get(a) is True for a in areas}
52
+
53
+ def apply(self, entity: LockableEntity, areas: Sequence[str], locked: bool) -> LockableEntity:
54
+ nxt: dict[str, bool] = dict(entity.locks)
55
+ for a in areas:
56
+ nxt[a] = locked
57
+ return LockableEntity(id=entity.id, tenant_id=entity.tenant_id, agency_owner_id=entity.agency_owner_id, locks=nxt)
58
+
59
+
60
+ __all__ = ["DefaultLockPolicy"]
@@ -0,0 +1,81 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2026 Adam Campbell
3
+ """DefaultScopePolicy — flat-membership scope authorization (RFC v0 § 5.3).
4
+
5
+ Decision rule, first match wins:
6
+ 1. requestor.is_super_admin → allow
7
+ 2. '*' in requestor.scopes → allow
8
+ 3. requestor.kind == 'user' and no scopes → allow (session user)
9
+ 4. required scope resolves to None → allow
10
+ 5. required scope in requestor.scopes → allow
11
+ 6. otherwise → deny
12
+ ``required_scope`` comes from the action first, else from the registered rule
13
+ table (method + path string/regex, or a predicate). Flat membership: no
14
+ implied hierarchy; wildcards beyond '*' are extensions, not core.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ from typing import Callable, Optional, Pattern, Union
20
+
21
+ from ..types import Allow, Decision, Deny, PolicyContext
22
+
23
+ Matcher = Union[tuple[str, Union[str, Pattern[str]]], Callable[[PolicyContext], bool]]
24
+
25
+
26
+ class DefaultScopePolicy:
27
+ name = "scope"
28
+
29
+ def __init__(self) -> None:
30
+ self._rules: list[tuple[Matcher, str]] = []
31
+
32
+ def evaluate(self, ctx: PolicyContext) -> Decision:
33
+ r = ctx.requestor
34
+ if r.is_super_admin is True:
35
+ return Allow(policy=self.name)
36
+ if "*" in r.scopes:
37
+ return Allow(policy=self.name)
38
+ if r.kind == "user" and len(r.scopes) == 0:
39
+ return Allow(policy=self.name)
40
+ required = ctx.action.required_scope or self._resolve_required(ctx)
41
+ if not required:
42
+ return Allow(policy=self.name)
43
+ if required in r.scopes:
44
+ return Allow(policy=self.name)
45
+ return Deny(
46
+ policy=self.name,
47
+ reason="missing_scope",
48
+ message=f"Missing required scope '{required}'.",
49
+ detail={"required": required, "held": list(r.scopes)},
50
+ )
51
+
52
+ def add_rule(self, matcher: Matcher, required_scope: str) -> None:
53
+ self._rules.append((matcher, required_scope))
54
+
55
+ def list_scopes(self) -> list[str]:
56
+ seen: dict[str, None] = {}
57
+ for _, s in self._rules:
58
+ seen.setdefault(s, None)
59
+ return list(seen)
60
+
61
+ def _resolve_required(self, ctx: PolicyContext) -> Optional[str]:
62
+ meta = ctx.action.metadata or {}
63
+ method = str(meta.get("method", "") or "")
64
+ path = str(meta.get("path", "") or "")
65
+ for matcher, scope in self._rules:
66
+ if callable(matcher):
67
+ if matcher(ctx):
68
+ return scope
69
+ continue
70
+ m_method, m_path = matcher
71
+ if method.upper() != m_method.upper():
72
+ continue
73
+ if isinstance(m_path, str):
74
+ if path == m_path:
75
+ return scope
76
+ elif m_path.search(path):
77
+ return scope
78
+ return None
79
+
80
+
81
+ __all__ = ["DefaultScopePolicy"]
File without changes
@@ -0,0 +1,65 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2026 Adam Campbell
3
+ """Reference audit sinks (RFC v0 § 7.3, § 9.4)."""
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import sys
8
+ from typing import Callable, Optional, Sequence
9
+
10
+ from .types import AuditEvent
11
+
12
+
13
+ class ConsoleSink:
14
+ """NDJSON to stdout — one JSON line per event."""
15
+
16
+ def __init__(self, out: Optional[Callable[[str], None]] = None) -> None:
17
+ self._out = out or (lambda s: sys.stdout.write(s))
18
+
19
+ def emit(self, event: AuditEvent) -> None:
20
+ self._out(json.dumps(event.to_dict(), default=str) + "\n")
21
+
22
+
23
+ class NoOpSink:
24
+ def emit(self, event: AuditEvent) -> None: # noqa: D401 — intentional
25
+ pass
26
+
27
+ def flush(self) -> None:
28
+ pass
29
+
30
+
31
+ class MemorySink:
32
+ """Collects events in memory — for tests and short-lived processes."""
33
+
34
+ def __init__(self) -> None:
35
+ self.events: list[AuditEvent] = []
36
+
37
+ def emit(self, event: AuditEvent) -> None:
38
+ self.events.append(event)
39
+
40
+
41
+ class MultiSink:
42
+ """Fan-out. One child failing never stops delivery to the others; if EVERY
43
+ child fails the first error is raised so the engine's § 9.4 catch sees it."""
44
+
45
+ def __init__(self, children: Sequence) -> None:
46
+ self._children = list(children)
47
+
48
+ def emit(self, event: AuditEvent) -> None:
49
+ errors: list[BaseException] = []
50
+ for child in self._children:
51
+ try:
52
+ child.emit(event)
53
+ except Exception as e: # noqa: BLE001
54
+ errors.append(e)
55
+ if errors and len(errors) == len(self._children):
56
+ raise errors[0]
57
+
58
+ def flush(self) -> None:
59
+ for child in self._children:
60
+ flush = getattr(child, "flush", None)
61
+ if callable(flush):
62
+ flush()
63
+
64
+
65
+ __all__ = ["ConsoleSink", "NoOpSink", "MemorySink", "MultiSink"]
@@ -0,0 +1,209 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2026 Adam Campbell
3
+ """themis.types — the public types of the Themis policy kernel.
4
+
5
+ Mirrors ``themis-policy`` ``types.ts`` one-for-one and conforms to Themis RFC v0.
6
+ Decisions are frozen dataclasses with a ``kind`` discriminator; ``to_dict()``
7
+ produces the exact wire shape the RFC specifies (keys with ``None`` omitted),
8
+ which is what the cross-language conformance vectors compare.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass, field
13
+ from typing import Any, Literal, Mapping, Optional, Protocol, Sequence, Union, runtime_checkable
14
+
15
+ Id = Union[int, str]
16
+ RequestorKind = Literal["user", "api_key", "agent", "service"]
17
+
18
+
19
+ def _drop_none(d: dict[str, Any]) -> dict[str, Any]:
20
+ return {k: v for k, v in d.items() if v is not None}
21
+
22
+
23
+ # ── 1 — Requestor ──────────────────────────────────────────────────────────
24
+ @dataclass(frozen=True)
25
+ class Requestor:
26
+ id: Id
27
+ kind: RequestorKind
28
+ tenant_id: Id
29
+ scopes: tuple[str, ...] = ()
30
+ role: Optional[str] = None
31
+ is_super_admin: Optional[bool] = None
32
+ metadata: Optional[Mapping[str, Any]] = None
33
+
34
+
35
+ # ── 2 — Action ─────────────────────────────────────────────────────────────
36
+ @dataclass(frozen=True)
37
+ class Action:
38
+ verb: str
39
+ resource_type: str
40
+ tenant_id: Id
41
+ resource_id: Optional[Id] = None
42
+ area: Optional[str] = None
43
+ required_scope: Optional[str] = None
44
+ payload: Any = None
45
+ metadata: Optional[Mapping[str, Any]] = None
46
+
47
+
48
+ # ── 6/7 — Entities ─────────────────────────────────────────────────────────
49
+ @dataclass(frozen=True)
50
+ class LockableEntity:
51
+ id: Id
52
+ tenant_id: Id
53
+ agency_owner_id: Optional[Id]
54
+ locks: Mapping[str, bool]
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class DraftableEntity:
59
+ id: Id
60
+ tenant_id: Id
61
+ is_published: bool
62
+ has_pending_draft: bool
63
+ draft_updated_at: Optional[int]
64
+ draft_updated_by: Optional[Id]
65
+
66
+
67
+ Entity = Union[LockableEntity, DraftableEntity]
68
+
69
+
70
+ # ── 3 — PolicyContext ──────────────────────────────────────────────────────
71
+ @dataclass(frozen=True)
72
+ class PolicyContext:
73
+ requestor: Requestor
74
+ action: Action
75
+ now: int
76
+ correlation_id: str
77
+ entity: Optional[Entity] = None
78
+ policy_metadata: Optional[Mapping[str, Mapping[str, Any]]] = None
79
+
80
+
81
+ # ── 4 — Decisions (discriminated union) ────────────────────────────────────
82
+ @dataclass(frozen=True)
83
+ class Allow:
84
+ kind: Literal["allow"] = "allow"
85
+ policy: Optional[str] = None
86
+
87
+ def to_dict(self) -> dict[str, Any]:
88
+ return _drop_none({"kind": self.kind, "policy": self.policy})
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class Deny:
93
+ policy: str
94
+ reason: str
95
+ message: Optional[str] = None
96
+ detail: Optional[Mapping[str, Any]] = None
97
+ kind: Literal["deny"] = "deny"
98
+
99
+ def to_dict(self) -> dict[str, Any]:
100
+ return _drop_none({
101
+ "kind": self.kind, "policy": self.policy, "reason": self.reason,
102
+ "message": self.message, "detail": dict(self.detail) if self.detail is not None else None,
103
+ })
104
+
105
+
106
+ @dataclass(frozen=True)
107
+ class Redirect:
108
+ policy: str
109
+ payload: Any
110
+ target: Literal["draft"] = "draft"
111
+ kind: Literal["redirect"] = "redirect"
112
+
113
+ def to_dict(self) -> dict[str, Any]:
114
+ return {"kind": self.kind, "policy": self.policy, "target": self.target, "payload": self.payload}
115
+
116
+
117
+ @dataclass(frozen=True)
118
+ class RequireApproval:
119
+ policy: str
120
+ approval_ref: str
121
+ message: Optional[str] = None
122
+ kind: Literal["require_approval"] = "require_approval"
123
+
124
+ def to_dict(self) -> dict[str, Any]:
125
+ return _drop_none({
126
+ "kind": self.kind, "policy": self.policy,
127
+ "approval_ref": self.approval_ref, "message": self.message,
128
+ })
129
+
130
+
131
+ Decision = Union[Allow, Deny, Redirect, RequireApproval]
132
+
133
+
134
+ def decision_from_dict(d: Mapping[str, Any]) -> Decision:
135
+ """Inverse of ``to_dict`` — used by adapters and the conformance harness."""
136
+ kind = d.get("kind")
137
+ if kind == "allow":
138
+ return Allow(policy=d.get("policy"))
139
+ if kind == "deny":
140
+ return Deny(policy=d["policy"], reason=d["reason"], message=d.get("message"), detail=d.get("detail"))
141
+ if kind == "redirect":
142
+ return Redirect(policy=d["policy"], payload=d.get("payload"), target=d.get("target", "draft"))
143
+ if kind == "require_approval":
144
+ return RequireApproval(policy=d["policy"], approval_ref=d["approval_ref"], message=d.get("message"))
145
+ raise ValueError(f"unknown decision kind: {kind!r}")
146
+
147
+
148
+ # ── 5 — AuditEvent ─────────────────────────────────────────────────────────
149
+ @dataclass(frozen=True)
150
+ class AuditEvent:
151
+ id: str
152
+ timestamp: int
153
+ correlation_id: str
154
+ tenant_id: Id
155
+ requestor: Mapping[str, Any]
156
+ action: Mapping[str, Any]
157
+ decision: Decision
158
+ policy_chain: tuple[str, ...]
159
+ latency_ms: float
160
+ metadata: Optional[Mapping[str, Any]] = None
161
+
162
+ def to_dict(self) -> dict[str, Any]:
163
+ return _drop_none({
164
+ "id": self.id,
165
+ "timestamp": self.timestamp,
166
+ "correlation_id": self.correlation_id,
167
+ "tenant_id": self.tenant_id,
168
+ "requestor": _drop_none(dict(self.requestor)),
169
+ "action": _drop_none(dict(self.action)),
170
+ "decision": self.decision.to_dict(),
171
+ "policy_chain": list(self.policy_chain),
172
+ "latency_ms": self.latency_ms,
173
+ "metadata": dict(self.metadata) if self.metadata is not None else None,
174
+ })
175
+
176
+
177
+ # ── 9/12 — Ports ───────────────────────────────────────────────────────────
178
+ @runtime_checkable
179
+ class Policy(Protocol):
180
+ name: str
181
+
182
+ def evaluate(self, ctx: PolicyContext) -> Decision: ...
183
+
184
+
185
+ @runtime_checkable
186
+ class AuditSink(Protocol):
187
+ def emit(self, event: AuditEvent) -> None: ...
188
+
189
+
190
+ class LockStore(Protocol):
191
+ def get(self, entity_id: Id) -> Optional[LockableEntity]: ...
192
+ def set_lock(self, entity_id: Id, areas: Sequence[str], locked: bool) -> LockableEntity: ...
193
+ def request_unlock(self, entity_id: Id, requestor_id: Id, areas: Sequence[str], reason: str) -> None: ...
194
+
195
+
196
+ class DraftStore(Protocol):
197
+ def get_entity(self, entity_id: Id) -> Optional[DraftableEntity]: ...
198
+ def write_draft(self, entity_id: Id, draft_payload: Any, updated_by: Id) -> None: ...
199
+ def write_live(self, entity_id: Id, payload: Any) -> None: ...
200
+ def list_pending_drafts(self, tenant_id: Id) -> Sequence[DraftableEntity]: ...
201
+ def publish(self, entity_id: Id) -> DraftableEntity: ...
202
+ def discard(self, entity_id: Id) -> DraftableEntity: ...
203
+
204
+
205
+ __all__ = [
206
+ "Id", "RequestorKind", "Requestor", "Action", "LockableEntity", "DraftableEntity", "Entity",
207
+ "PolicyContext", "Allow", "Deny", "Redirect", "RequireApproval", "Decision", "decision_from_dict",
208
+ "AuditEvent", "Policy", "AuditSink", "LockStore", "DraftStore",
209
+ ]
@@ -0,0 +1,61 @@
1
+ Metadata-Version: 2.4
2
+ Name: themis-policy
3
+ Version: 0.1.0
4
+ Summary: Themis policy kernel for bounded-autonomy LLM agents — locks, drafts, scopes, audit. Python reference implementation of Themis RFC v0.
5
+ Author: Adam Campbell
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/Adam-Camp-King/Themis
8
+ Project-URL: Specification, https://github.com/Adam-Camp-King/Themis/tree/main/spec
9
+ Keywords: llm,agents,policy,safety,bounded-autonomy,multi-tenant,audit
10
+ Classifier: License :: OSI Approved :: Apache Software License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Typing :: Typed
16
+ Requires-Python: >=3.11
17
+ Description-Content-Type: text/markdown
18
+ Provides-Extra: test
19
+ Requires-Dist: pytest>=8; extra == "test"
20
+
21
+ # themis-policy
22
+
23
+ Python reference implementation of **Themis RFC v0** — a policy kernel for
24
+ LLM agents that act on production systems. Four primitives — **locks**,
25
+ **drafts**, **scopes**, **audit** — composed by one engine into a single
26
+ decision per attempted action: `allow`, `deny`, `redirect`, or
27
+ `require_approval`.
28
+
29
+ Byte-compatible with [`themis-policy`](https://github.com/Adam-Camp-King/Themis)
30
+ (TypeScript): both pass the same [conformance vectors](../spec/conformance).
31
+
32
+ ```python
33
+ from themis import (
34
+ PolicyEngine, DefaultLockPolicy, DefaultScopePolicy, DefaultDraftPolicy,
35
+ Requestor, Action, PolicyContext, ConsoleSink, is_deny,
36
+ )
37
+
38
+ engine = PolicyEngine(audit_sink=ConsoleSink())
39
+ engine.add_policy(DefaultLockPolicy())
40
+ engine.add_policy(DefaultScopePolicy())
41
+ engine.add_policy(DefaultDraftPolicy())
42
+
43
+ decision = engine.evaluate(PolicyContext(
44
+ requestor=Requestor(id="key_1", kind="api_key", tenant_id=7, scopes=("pages:read",)),
45
+ action=Action(verb="pages.update", resource_type="page", tenant_id=7, resource_id=42,
46
+ required_scope="pages:write"),
47
+ now=0, correlation_id="req-123",
48
+ ))
49
+ if is_deny(decision):
50
+ print(decision.reason) # missing_scope
51
+ ```
52
+
53
+ The engine is synchronous (every core policy is pure CPU); `evaluate_async`
54
+ exists for callers already inside an event loop. Stores (`LockStore`,
55
+ `DraftStore`) are ports you implement against your persistence; audit sinks
56
+ receive one event per evaluation and may never fail an evaluation.
57
+
58
+ Install: `pip install themis-policy` (Python 3.11+, no dependencies).
59
+ Tests: `pip install -e '.[test]' && pytest`.
60
+
61
+ Apache-2.0 — see the repository LICENSE.
@@ -0,0 +1,18 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/themis/__init__.py
4
+ src/themis/engine.py
5
+ src/themis/guards.py
6
+ src/themis/py.typed
7
+ src/themis/sinks.py
8
+ src/themis/types.py
9
+ src/themis/policies/__init__.py
10
+ src/themis/policies/draft.py
11
+ src/themis/policies/lock.py
12
+ src/themis/policies/scope.py
13
+ src/themis_policy.egg-info/PKG-INFO
14
+ src/themis_policy.egg-info/SOURCES.txt
15
+ src/themis_policy.egg-info/dependency_links.txt
16
+ src/themis_policy.egg-info/requires.txt
17
+ src/themis_policy.egg-info/top_level.txt
18
+ tests/test_conformance.py
@@ -0,0 +1,3 @@
1
+
2
+ [test]
3
+ pytest>=8
@@ -0,0 +1,139 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Run spec/conformance/v0/*.json and preview-tokens.json through the Python kernel.
3
+
4
+ The same files are run by themis-policy. Passing them is what "conforms to
5
+ Themis RFC v0" means (RFC § 11).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import pathlib
11
+ from typing import Any
12
+
13
+ import pytest
14
+
15
+ from themis import (
16
+ Action, Allow, DefaultDraftPolicy, DefaultLockPolicy, DefaultScopePolicy, Deny, DraftableEntity,
17
+ LockableEntity, MemorySink, PolicyContext, PolicyEngine, Redirect, RequireApproval, Requestor,
18
+ )
19
+
20
+ SPEC = pathlib.Path(__file__).resolve().parents[2] / "spec" / "conformance"
21
+ VECTOR_FILES = sorted((SPEC / "v0").glob("*.json"))
22
+
23
+
24
+ class _Stub:
25
+ def __init__(self, spec: dict[str, Any]) -> None:
26
+ self.name = spec["name"]
27
+ self._spec = spec
28
+
29
+ def evaluate(self, ctx: PolicyContext):
30
+ s = self._spec
31
+ kind = s["stub"]
32
+ if kind == "allow":
33
+ return Allow(policy=self.name)
34
+ if kind == "deny":
35
+ return Deny(policy=self.name, reason=str(s.get("reason", "stub")), message=s.get("message"))
36
+ if kind == "redirect":
37
+ return Redirect(policy=self.name, target="draft", payload=s.get("payload"))
38
+ if kind == "require_approval":
39
+ return RequireApproval(policy=self.name, approval_ref=str(s.get("approval_ref", "ref")), message=s.get("message"))
40
+ raise ValueError(kind)
41
+
42
+
43
+ def _build(spec: Any):
44
+ if spec == "lock":
45
+ return DefaultLockPolicy()
46
+ if spec == "draft":
47
+ return DefaultDraftPolicy()
48
+ if spec == "scope":
49
+ return DefaultScopePolicy()
50
+ if "stub" in spec:
51
+ return _Stub(spec)
52
+ p = DefaultScopePolicy()
53
+ for r in spec.get("rules", []):
54
+ p.add_rule((r["method"], r["path"]), r["required_scope"])
55
+ return p
56
+
57
+
58
+ def _entity(e: Any):
59
+ if e is None:
60
+ return None
61
+ if "locks" in e and "agency_owner_id" in e:
62
+ return LockableEntity(id=e["id"], tenant_id=e["tenant_id"], agency_owner_id=e["agency_owner_id"], locks=dict(e["locks"]))
63
+ return DraftableEntity(
64
+ id=e["id"], tenant_id=e["tenant_id"], is_published=e["is_published"],
65
+ has_pending_draft=e.get("has_pending_draft", False),
66
+ draft_updated_at=e.get("draft_updated_at"), draft_updated_by=e.get("draft_updated_by"),
67
+ )
68
+
69
+
70
+ def _canon(v: Any) -> Any:
71
+ if isinstance(v, list):
72
+ return [_canon(x) for x in v]
73
+ if isinstance(v, dict):
74
+ return {k: _canon(x) for k, x in v.items() if x is not None}
75
+ return v
76
+
77
+
78
+ def _cases():
79
+ for f in VECTOR_FILES:
80
+ for c in json.loads(f.read_text())["cases"]:
81
+ yield pytest.param(c, id=f"{f.name} › {c['name']}")
82
+
83
+
84
+ @pytest.mark.parametrize("c", list(_cases()))
85
+ def test_vector(c: dict[str, Any]) -> None:
86
+ sink = MemorySink()
87
+ engine = PolicyEngine(audit_sink=sink, emit_policy=c.get("emit_policy", "all"))
88
+ for p in c.get("policies", []):
89
+ engine.add_policy(_build(p))
90
+ r, a = c["requestor"], c["action"]
91
+ ctx = PolicyContext(
92
+ requestor=Requestor(id=r["id"], kind=r["kind"], tenant_id=r["tenant_id"], scopes=tuple(r.get("scopes", [])),
93
+ role=r.get("role"), is_super_admin=r.get("is_super_admin")),
94
+ action=Action(verb=a["verb"], resource_type=a["resource_type"], tenant_id=a["tenant_id"], resource_id=a.get("resource_id"),
95
+ area=a.get("area"), required_scope=a.get("required_scope"), payload=a.get("payload"), metadata=a.get("metadata")),
96
+ entity=_entity(c.get("entity")),
97
+ now=1_700_000_000_000,
98
+ correlation_id=f"conf-{c['name']}",
99
+ )
100
+ decision = engine.evaluate(ctx)
101
+ assert _canon(decision.to_dict()) == _canon(c["expect"]["decision"])
102
+ audit = c["expect"].get("audit")
103
+ if audit == "none":
104
+ assert sink.events == []
105
+ return
106
+ assert len(sink.events) == 1, "exactly one audit event per evaluation"
107
+ e = sink.events[0]
108
+ assert list(e.policy_chain) == c["expect"]["policy_chain"]
109
+ assert _canon(e.decision.to_dict()) == _canon(c["expect"]["decision"])
110
+ assert e.tenant_id == a["tenant_id"]
111
+ assert e.correlation_id == ctx.correlation_id
112
+ if isinstance(audit, dict):
113
+ got = _canon({"requestor": dict(e.requestor), "action": dict(e.action)})
114
+ assert got == _canon(audit)
115
+
116
+
117
+ def test_preview_tokens() -> None:
118
+ doc = json.loads((SPEC / "preview-tokens.json").read_text())
119
+ p = DefaultDraftPolicy()
120
+ for c in doc["cases"]:
121
+ assert p.verify_preview_token(c["token"], doc["secret"]) == c["expect"], c["name"]
122
+
123
+
124
+ def test_sign_then_verify_roundtrip_and_ttl_clamp() -> None:
125
+ p = DefaultDraftPolicy()
126
+ tok = p.sign_preview_token(42, 7, 24, "s3cret")
127
+ assert p.verify_preview_token(tok, "s3cret") == {"entity_id": 42, "tenant_id": 7}
128
+ assert p.verify_preview_token(tok, "other") is None
129
+ with pytest.raises(ValueError):
130
+ p.sign_preview_token(1, 1, 1, "")
131
+
132
+
133
+ def test_audit_sink_failure_never_fails_evaluation() -> None:
134
+ class Boom:
135
+ def emit(self, event): # noqa: ANN001
136
+ raise RuntimeError("sink down")
137
+ engine = PolicyEngine(audit_sink=Boom())
138
+ r = Requestor(id=1, kind="user", tenant_id=1)
139
+ assert engine.evaluate(PolicyContext(requestor=r, action=Action(verb="x", resource_type="y", tenant_id=1), now=0, correlation_id="c")) == Allow()