Skip to content

Commit 9f470be

Browse files
committed
Phase 1: SDK only guard
1 parent 95d6b05 commit 9f470be

17 files changed

Lines changed: 762 additions & 2 deletions

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Pre-commit hooks for AgentIdentity repository
1+
# Pre-commit hooks for predicate-authority repository
22
# Baseline adapted from /Code/Sentience/sdk-python/.pre-commit-config.yaml
33

44
repos:

Makefile

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1-
.PHONY: hooks lint format format-python format-docs lint-docs
1+
.PHONY: hooks lint test format format-python format-docs lint-docs
22

33
hooks:
44
pre-commit install
55

66
lint:
77
pre-commit run --all-files
88

9+
test:
10+
python -m pytest -q
11+
912
format: format-python format-docs
1013

1114
format-python:

docs/better-sdk-opportunity-proposal.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,18 @@ This proposal answers the "Better SDK Opportunity" in `northstar.md` by combinin
1515
- Caracal's strongest ideas (short-lived mandates, scope checks, fail-closed gateway-style enforcement, immutable authority ledger),
1616
- A bridge-first strategy (works with Azure AD/Okta/Auth0 and existing agent stacks).
1717

18+
## Progress Dashboard
19+
20+
Status snapshot date: 2026-02-16
21+
22+
| Phase | Status | ETA | Owner |
23+
| --- | --- | --- | --- |
24+
| Phase 0: Architecture and Spec Lock | Partially complete | 1-2 weeks total (remaining: sign-off + schema/process formalization) | SDK + Platform + Security |
25+
| Phase 1: Local SDK Guard (MVP) | In progress | 3-5 weeks total (remaining: `sdk-python` hooks + OTel export + examples + CI publish flow) | SDK |
26+
| Phase 2: Sidecar + Identity Bridge | Not started (design only) | 4-6 weeks | Platform + Identity |
27+
| Phase 3: Hosted Governance Control Plane | Not started (design only) | 6-8 weeks | Platform + Product |
28+
| Phase 4: Enterprise Hardening and Scale | Not started (design only) | Ongoing (first 4-6 weeks) | Platform + Security + GTM |
29+
1830

1931
## TL;DR Design
2032

@@ -511,6 +523,21 @@ async def web_search_tool(query: str):
511523
- Basic policy DSL.
512524
- Trace/proof event emission to existing tracer.
513525

526+
Status (as of 2026-02-16): **in progress (MVP scaffold implemented in this `predicate-authority` repository)**
527+
528+
- Completed in repo:
529+
- `predicate-contracts` package scaffold with typed contracts and protocols.
530+
- `predicate-authority` local `ActionGuard.authorize(...)` + `enforce(...)`.
531+
- Signed local mandates with TTL + verification.
532+
- Local policy evaluation and normalized deny reasons.
533+
- In-memory proof ledger with optional trace emitter interface.
534+
- pytest coverage for policy, mandate signing, and proof emission paths.
535+
- Pending for full Phase 1 exit:
536+
- direct `sdk-python` integration hooks (pre-action + postcondition linkage),
537+
- OpenTelemetry-native event export (beyond protocol-level trace emitter),
538+
- developer quickstart/examples for browser/MCP/HTTP guard patterns,
539+
- package publishing pipeline verification (`predicate-contracts` -> `predicate-authority`).
540+
514541
## Phase 2: Sidecar and IdP bridge (4-8 weeks)
515542

516543
- `predicate-authorityd`.
@@ -609,6 +636,14 @@ Exit criteria:
609636
- compatibility mapping to existing `sdk-python` step lifecycle approved.
610637
- release orchestration design approved for multi-package PyPI publishing (`predicate-contracts` then `predicate-authority`).
611638

639+
Current status: **partially complete**
640+
641+
- [x] dependency graph/import boundaries documented in this proposal.
642+
- [x] package scaffolding started in this `predicate-authority` repository (`predicate-contracts`, `predicate-authority`).
643+
- [ ] formal design sign-off from SDK/platform/security.
644+
- [ ] versioned schema docs publication process.
645+
- [ ] approved compatibility mapping with `sdk-python` lifecycle owners.
646+
612647
## Phase 1: Local SDK Guard (MVP) (3-5 weeks)
613648

614649
Objective: deliver immediate value with in-process pre-execution authority.
@@ -632,6 +667,18 @@ Exit criteria:
632667
- developer quickstart validated end-to-end on local-only mode.
633668
- CI release pipeline can publish and verify `predicate-contracts` and `predicate-authority` in dependency order.
634669

670+
Current status: **in progress**
671+
672+
- [x] local `ActionGuard.authorize(...)`.
673+
- [x] signed local mandates.
674+
- [x] local policy evaluation.
675+
- [x] fail-closed deny path with normalized reason enums.
676+
- [x] deterministic regression tests for authorize/deny paths.
677+
- [ ] `sdk-python` runtime integration hooks.
678+
- [ ] OpenTelemetry-native authority event export.
679+
- [ ] quickstart/examples for browser/MCP/outbound HTTP.
680+
- [ ] dependency-ordered package publish pipeline in CI.
681+
635682
## Phase 2: Sidecar + Identity Bridge (4-6 weeks)
636683

637684
Objective: production-ready token lifecycle and enterprise identity compatibility.
@@ -657,6 +704,8 @@ Exit criteria:
657704
- bridge token exchange validated against at least one enterprise IdP.
658705
- sidecar survives restart/network partition with fail-closed guarantees.
659706

707+
Current status: **not started (design only)**
708+
660709
## Phase 3: Hosted Governance Control Plane (6-8 weeks)
661710

662711
Objective: ship monetizable cloud governance capabilities.
@@ -675,6 +724,8 @@ Exit criteria:
675724
- kill-switch propagation meets incident response target.
676725
- billable usage pipeline reconciles authority + snapshot credits accurately.
677726

727+
Current status: **not started (design only)**
728+
678729
## Phase 4: Enterprise Hardening and Scale (ongoing, first 4-6 weeks)
679730

680731
Objective: make it enterprise-ready for regulated production.
@@ -693,6 +744,8 @@ Exit criteria:
693744
- defined SLOs met in staging/load tests.
694745
- enterprise onboarding playbook validated with pilot accounts.
695746

747+
Current status: **not started (design only)**
748+
696749
## Cross-Phase Dependencies
697750

698751
- `sdk-python` runtime contract stability (snapshot schema, assertion labels, step metadata).

predicate_authority/__init__.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from predicate_authority.bridge import IdentityBridge, TokenExchangeResult
2+
from predicate_authority.errors import AuthorizationDeniedError
3+
from predicate_authority.guard import ActionExecutionResult, ActionGuard
4+
from predicate_authority.mandate import LocalMandateSigner
5+
from predicate_authority.policy import PolicyEngine, PolicyMatchResult
6+
from predicate_authority.proof import InMemoryProofLedger
7+
8+
__all__ = [
9+
"ActionExecutionResult",
10+
"ActionGuard",
11+
"AuthorizationDeniedError",
12+
"IdentityBridge",
13+
"InMemoryProofLedger",
14+
"LocalMandateSigner",
15+
"PolicyEngine",
16+
"PolicyMatchResult",
17+
"TokenExchangeResult",
18+
]

predicate_authority/bridge.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
from __future__ import annotations
2+
3+
import hashlib
4+
import time
5+
from dataclasses import dataclass
6+
7+
from predicate_contracts import PrincipalRef, StateEvidence
8+
9+
10+
@dataclass(frozen=True)
11+
class TokenExchangeResult:
12+
access_token: str
13+
expires_at_epoch_s: int
14+
token_type: str = "Bearer"
15+
16+
17+
class IdentityBridge:
18+
"""Local placeholder bridge for Phase 1.
19+
20+
This keeps an explicit interface so Phase 2 can swap in a real OIDC/Entra bridge.
21+
"""
22+
23+
def __init__(self, token_ttl_seconds: int = 300) -> None:
24+
self._token_ttl_seconds = token_ttl_seconds
25+
26+
def exchange_token(
27+
self, subject: PrincipalRef, state_evidence: StateEvidence
28+
) -> TokenExchangeResult:
29+
expires_at = int(time.time()) + self._token_ttl_seconds
30+
token_seed = f"{subject.principal_id}|{state_evidence.state_hash}|{expires_at}"
31+
token_hash = hashlib.sha256(token_seed.encode("utf-8")).hexdigest()
32+
return TokenExchangeResult(
33+
access_token=f"local.{token_hash}", expires_at_epoch_s=expires_at
34+
)

predicate_authority/errors.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from __future__ import annotations
2+
3+
from predicate_contracts import AuthorizationDecision
4+
5+
6+
class AuthorizationDeniedError(RuntimeError):
7+
def __init__(self, decision: AuthorizationDecision) -> None:
8+
self.decision = decision
9+
super().__init__(f"Authorization denied: {decision.reason.value}")

predicate_authority/guard.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
from __future__ import annotations
2+
3+
from collections.abc import Callable
4+
from dataclasses import dataclass
5+
from typing import Generic, TypeVar
6+
7+
from predicate_authority.errors import AuthorizationDeniedError
8+
from predicate_authority.mandate import LocalMandateSigner
9+
from predicate_authority.policy import PolicyEngine
10+
from predicate_authority.proof import InMemoryProofLedger
11+
from predicate_contracts import (
12+
ActionRequest,
13+
AuthorizationDecision,
14+
AuthorizationReason,
15+
SignedMandate,
16+
)
17+
18+
T = TypeVar("T")
19+
20+
21+
@dataclass(frozen=True)
22+
class ActionExecutionResult(Generic[T]):
23+
value: T
24+
decision: AuthorizationDecision
25+
mandate: SignedMandate
26+
27+
28+
class ActionGuard:
29+
def __init__(
30+
self,
31+
policy_engine: PolicyEngine,
32+
mandate_signer: LocalMandateSigner,
33+
proof_ledger: InMemoryProofLedger,
34+
) -> None:
35+
self._policy_engine = policy_engine
36+
self._mandate_signer = mandate_signer
37+
self._proof_ledger = proof_ledger
38+
39+
def authorize(self, request: ActionRequest) -> AuthorizationDecision:
40+
evaluation = self._policy_engine.evaluate(request)
41+
if not evaluation.allowed:
42+
decision = AuthorizationDecision(
43+
allowed=False,
44+
reason=evaluation.reason,
45+
violated_rule=evaluation.matched_rule,
46+
missing_labels=evaluation.missing_labels,
47+
)
48+
self._proof_ledger.record(decision, request)
49+
return decision
50+
51+
mandate = self._mandate_signer.issue(request)
52+
decision = AuthorizationDecision(
53+
allowed=True,
54+
reason=AuthorizationReason.ALLOWED,
55+
mandate=mandate,
56+
violated_rule=evaluation.matched_rule,
57+
)
58+
self._proof_ledger.record(decision, request)
59+
return decision
60+
61+
def enforce(
62+
self, action_callable: Callable[[], T], request: ActionRequest
63+
) -> ActionExecutionResult[T]:
64+
decision = self.authorize(request)
65+
if not decision.allowed or decision.mandate is None:
66+
raise AuthorizationDeniedError(decision)
67+
value = action_callable()
68+
return ActionExecutionResult(value=value, decision=decision, mandate=decision.mandate)

predicate_authority/mandate.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
from __future__ import annotations
2+
3+
import base64
4+
import hashlib
5+
import hmac
6+
import json
7+
import time
8+
from dataclasses import asdict
9+
10+
from predicate_contracts import ActionRequest, MandateClaims, SignedMandate
11+
12+
13+
class LocalMandateSigner:
14+
def __init__(self, secret_key: str, ttl_seconds: int = 300) -> None:
15+
if ttl_seconds <= 0:
16+
raise ValueError("ttl_seconds must be > 0")
17+
self._secret_key = secret_key.encode("utf-8")
18+
self._ttl_seconds = ttl_seconds
19+
20+
def issue(self, request: ActionRequest) -> SignedMandate:
21+
issued_at = int(time.time())
22+
expires_at = issued_at + self._ttl_seconds
23+
intent_hash = hashlib.sha256(request.action_spec.intent.encode("utf-8")).hexdigest()
24+
mandate_id_seed = (
25+
f"{request.principal.principal_id}|"
26+
f"{request.action_spec.action}|"
27+
f"{request.action_spec.resource}|"
28+
f"{intent_hash}|"
29+
f"{request.state_evidence.state_hash}|"
30+
f"{issued_at}"
31+
)
32+
mandate_id = hashlib.sha256(mandate_id_seed.encode("utf-8")).hexdigest()[:24]
33+
34+
claims = MandateClaims(
35+
mandate_id=mandate_id,
36+
principal_id=request.principal.principal_id,
37+
action=request.action_spec.action,
38+
resource=request.action_spec.resource,
39+
intent_hash=intent_hash,
40+
state_hash=request.state_evidence.state_hash,
41+
issued_at_epoch_s=issued_at,
42+
expires_at_epoch_s=expires_at,
43+
)
44+
token, signature = self._sign_claims(claims)
45+
return SignedMandate(token=token, claims=claims, signature=signature)
46+
47+
def verify(self, token: str) -> SignedMandate | None:
48+
parts = token.split(".")
49+
if len(parts) != 3:
50+
return None
51+
52+
encoded_header, encoded_payload, encoded_signature = parts
53+
signing_input = f"{encoded_header}.{encoded_payload}".encode()
54+
expected_signature = self._hmac(signing_input)
55+
expected_signature_encoded = self._base64url_encode(expected_signature)
56+
if not hmac.compare_digest(expected_signature_encoded, encoded_signature):
57+
return None
58+
59+
try:
60+
payload_json = self._base64url_decode(encoded_payload).decode("utf-8")
61+
payload = json.loads(payload_json)
62+
claims = MandateClaims(**payload)
63+
except (ValueError, TypeError, json.JSONDecodeError):
64+
return None
65+
66+
now_epoch = int(time.time())
67+
if claims.expires_at_epoch_s < now_epoch:
68+
return None
69+
return SignedMandate(token=token, claims=claims, signature=encoded_signature)
70+
71+
def _sign_claims(self, claims: MandateClaims) -> tuple[str, str]:
72+
header_json = json.dumps(
73+
{"alg": "HS256", "typ": "JWT"}, separators=(",", ":"), sort_keys=True
74+
)
75+
payload_json = json.dumps(asdict(claims), separators=(",", ":"), sort_keys=True)
76+
77+
encoded_header = self._base64url_encode(header_json.encode("utf-8"))
78+
encoded_payload = self._base64url_encode(payload_json.encode("utf-8"))
79+
signing_input = f"{encoded_header}.{encoded_payload}".encode()
80+
signature = self._base64url_encode(self._hmac(signing_input))
81+
token = f"{encoded_header}.{encoded_payload}.{signature}"
82+
return token, signature
83+
84+
def _hmac(self, payload: bytes) -> bytes:
85+
return hmac.new(self._secret_key, payload, hashlib.sha256).digest()
86+
87+
@staticmethod
88+
def _base64url_encode(value: bytes) -> str:
89+
return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii")
90+
91+
@staticmethod
92+
def _base64url_decode(value: str) -> bytes:
93+
padding = "=" * ((4 - len(value) % 4) % 4)
94+
return base64.urlsafe_b64decode(value + padding)

0 commit comments

Comments
 (0)