diff --git a/integrations/ontoguard-decision-authorization/README.md b/integrations/ontoguard-decision-authorization/README.md index c90ee63..382c1ce 100644 --- a/integrations/ontoguard-decision-authorization/README.md +++ b/integrations/ontoguard-decision-authorization/README.md @@ -4,7 +4,15 @@ Thin interoperability adapter. It consumes a *signed* OntoGuard Decision Authorization result and, only after an *independent* execution receipt binds to that exact result, emits a TRACE v0.2 Level 0 Trust Record. -This adapter contains no OntoGuard core authorization or semantic-governance implementation. +This adapter contains no OntoGuard core authorization or semantic-governance +implementation. + +Production authorization objects consumed by this adapter are issued by +OntoGuard Headless Decision Authorization Runtime or another implementation +conforming to the same authorization contract. + +Runtime documentation: +https://ontoguard.ai/headless-decision-authorization-runtime.html ## What this integration does @@ -37,13 +45,73 @@ if ALLOW + independently proven execution: `ALLOW` never proves that an action ran. A BLOCK or ESCALATE paired with `executed=true` is rejected, not silently reclassified. +## Time bounds on captured versus live objects + +The captured historical authorization is time-bounded +(`expires_at_utc = 2026-09-24…`) and has expired. It remains valid +historical signed evidence. Signatures and digests stay inspectable after +expiry. + +By default, `ontoguard_trace` verifies expiry against the current UTC time. +Therefore, a normal/live replay of an expired authorization fails closed +with `authorization has expired`. + +Historical fixture verification is different: tests and historical replay +utilities pass an explicit `verification_time_utc` that falls inside the +authorization's original validity interval. This is used only to reproduce +the already-captured historical event deterministically. It does **not** +extend the authorization, change its signed bytes, alter its expiry, or make +the authorization valid for a current execution. + +Current historical-fixture verification time: + +```text +2026-09-18T00:00:00Z +``` + +The fixed historical time is used by: + +- `tests/test_adapter.py` +- `examples/emit_record.py` +- `examples/controlled-execution-proof-2026-09-17/replay_adapter.py` + +Production/live callers should rely on the adapter's current-time default +unless they are explicitly performing bounded historical evidence replay. +An untrusted caller must not be allowed to choose a verification time for a +live authorization decision. + +The live executor harness never reuses the captured authorization. It mints +a fresh TEST-ONLY authorization at runtime, verifies it before any commit, +then reruns $250k (commit) and $260k (refuse) through this adapter. That +runtime object is harness-only and is not a live OntoGuard Decision API +result. + +Ordinary fixtures in `examples/fixtures/` also carry `expires_at_utc`. +Treat them as dated evidence, not as unexpiring production objects. + +## Pre-commit enforcement composition + +This repository does not contain OntoGuard's semantic authorization engine. +A downstream enforcement runtime may consume a current signed OntoGuard +authorization before protected execution. The bounded example strictly +validates the proposed partner action, verifies the signed authorization and +exact-action binding, and re-verifies those conditions at the controlled +executor's commit boundary. A caller-computed digest alone is not authority. +A materially different or malformed action, BLOCK, ESCALATE, expired +authorization, invalid signature or binding mismatch must not proceed. + +OntoGuard determines semantic authorization. The external runtime retains +enforcement. TRACE records execution evidence only after execution is +independently proven. + +A sanitized example of that seam is in `examples/precommit-enforcement/`. + ## What this integration does not claim - Not production L5 or non-bypassable route topology. - Not hardware attestation, TEE, or confidential computing. -- Not continuously checked. No CI workflow runs `trace-tests` on this - integration yet; the Level 0 result rests on the maintainer run recorded - under "Verified-tier review". +- Marketplace Verified; TRACE Level 0 conformance is established only by + signed-record CI verification. - Not an OntoGuard semantic engine. Authorization remains in OntoGuard. - Per TRACE spec 3.1.2, a Trust Record is issued per execution and a reference cannot carry a pre-execution commitment. @@ -68,6 +136,11 @@ trace-tests verify \ --level 0 ``` +`pytest` and `examples/emit_record.py` intentionally evaluate the frozen +historical fixture at the fixed historical verification time documented +above. This keeps CI deterministic after the fixture's real-world expiry +without weakening live expiry enforcement. + A captured historical controlled-execution proof plus a live executor harness live in `examples/controlled-execution-proof-2026-09-17/`. @@ -76,13 +149,6 @@ python examples/controlled-execution-proof-2026-09-17/verify_proof.py python examples/controlled-execution-proof-2026-09-17/controlled_executor.py ``` -The historical ALLOW authorization is frozen evidence and expires -2026-09-24. The live harness does **not** reuse that object. It mints a -TEST-ONLY authorization at runtime, verifies it before any commit, then -reruns $250k (commit) and $260k (refuse) through this adapter. That -runtime authorization is harness-only and is not a live OntoGuard -Decision API result. - Level 1 is unsupported (`runtime.platform=software-only`) and must fail `TR-RTE-001`. @@ -108,21 +174,11 @@ Level 1 is unsupported (`runtime.platform=software-only`) and must fail ## Responsibility boundary - OntoGuard: authorization result and signature over the exact result bytes. -- Executing runtime: independent execution receipt after the action runs. +- Executing runtime: independent execution receipt after the action ran. - This adapter: verify both objects, refuse TRACE when either is missing or mismatched, project an executed event into TRACE v0.2. - Downstream enforcement: consume the current handoff before commit. -## Verified-tier review - -A maintainer ran "Run it" on 2026-09-21 in an isolated environment against -released `agentrust-trace` 0.10.0 and `agentrust-trace-tests` 0.5.1: 27 tests -passed, `emit_record.py` printed `STATE=ALLOW_EXECUTION_PROVEN SIGNED=True`, and -`trace-tests verify --level 0` gave `Result: PASS (8 checks, 0 skipped)` with the -signature verified. Level 1 fails on `TR-RTE-001` and `TR-RTE-004`, as the list -above says it must. The manifest is now `tier: verified`. Re-verification happens -at every release that touches this integration. - ## License Apache-2.0, matching `agentrust-io/integrations`. OntoGuard core diff --git a/integrations/ontoguard-decision-authorization/examples/controlled-execution-proof-2026-09-17/README.md b/integrations/ontoguard-decision-authorization/examples/controlled-execution-proof-2026-09-17/README.md index 23d4a75..785bc75 100644 --- a/integrations/ontoguard-decision-authorization/examples/controlled-execution-proof-2026-09-17/README.md +++ b/integrations/ontoguard-decision-authorization/examples/controlled-execution-proof-2026-09-17/README.md @@ -18,12 +18,17 @@ OntoGuard Decision API result. Order of operations: -1. mint and cryptographically verify the test authorization -2. derive the $250,000 partner action binding -3. only then PENDING → RELEASED, commit_count 0 → 1 -4. sign a fresh ephemeral executor receipt -5. pass the live objects through `ontoguard_trace.project` -6. separately rerun $260,000, refuse, commit_count stays 0, no TRACE +1. mint the TEST-ONLY signed authorization +2. strictly validate the proposed partner action +3. verify the signed authorization again at the controlled executor's commit boundary +4. require ALLOW + release authorization + exact validated action binding +5. only then PENDING → RELEASED, commit_count 0 → 1 +6. sign a fresh ephemeral executor receipt +7. pass the live objects through `ontoguard_trace.project` +8. separately rerun $260,000 and digest-only bypass attempts; refuse before commit + +A caller-computed action digest is not authority. The controlled executor requires +the signed authorization and trusted JWKS at its bounded commit boundary. This is a controlled software-only store. Not a bank transfer, not L5, and not OntoGuard core. diff --git a/integrations/ontoguard-decision-authorization/examples/controlled-execution-proof-2026-09-17/checksums.sha256 b/integrations/ontoguard-decision-authorization/examples/controlled-execution-proof-2026-09-17/checksums.sha256 index 34bcbab..ff7c00d 100644 --- a/integrations/ontoguard-decision-authorization/examples/controlled-execution-proof-2026-09-17/checksums.sha256 +++ b/integrations/ontoguard-decision-authorization/examples/controlled-execution-proof-2026-09-17/checksums.sha256 @@ -1,5 +1,6 @@ -1f45c4a6894e70ce73204cd7c654a2a81bd3b8d77098bfa5c5121063406bb503 README.md -83836847ae16688d05db9bd7e18b200c3b632175d940e65fc5b87888097e4aef controlled_executor.py +705fd4d6451a31d36b3df7de96f83f30ac976c9b4a6d1e51671d8e2f33e2d0da .gitattributes +30a0a39bbce27b31e76c576f3813741c1378db954e5da43782dfd4f0f239342c README.md +1b325857bb28ebd9c48974ea251d902109fe5f5a7fd51be094439dad4726da38 controlled_executor.py 0e8757f8f9d76626ff28f969ab0906be50a01bfd10715b847ee135fd248f9f97 negative_action_mutation/mutated_action.json 24b2cf0beb31e190e7d7dd89c04295078206107615556ffcf9103a5345ccfa6a negative_action_mutation/rejection_result.json 7d248f18e2974d1d11453b0c1b547c1650c1228ceb79741c9dc05750e777e551 positive/after_state.json @@ -18,4 +19,4 @@ c05e327faaaa7b4edd3d6b8e9cb679f752b2ee8fe647b070602892bd398eb86b positive/ontog 51464c725f51f14c0ba959d2b8752f7a614d9acc6386ba467af71229fa8e3776 positive/trace_claim_candidate.json 2f49d1277bb9d21c8decee0526b9c51f974fa69b4ed83b1049974af79e91b414 positive/trace_level0_conformance.txt 0d641d7084e3444c191e409a2d57c6faf9472751ad2d0edbb9d296f3cab95997 replay_adapter.py -c33b5605cf1bbe646368012eec941b7257e373aa213b610455026fc7a6fab050 verify_proof.py +c84b3b6a2fa9c7f6bc4870bdde71988d465e8bf89f24c65104bc735a2a6e8480 verify_proof.py diff --git a/integrations/ontoguard-decision-authorization/examples/controlled-execution-proof-2026-09-17/controlled_executor.py b/integrations/ontoguard-decision-authorization/examples/controlled-execution-proof-2026-09-17/controlled_executor.py index b0db755..a1978a1 100644 --- a/integrations/ontoguard-decision-authorization/examples/controlled-execution-proof-2026-09-17/controlled_executor.py +++ b/integrations/ontoguard-decision-authorization/examples/controlled-execution-proof-2026-09-17/controlled_executor.py @@ -49,6 +49,7 @@ partner_action_binding_object, project, sha256_digest, + validate_partner_action_binding_object, ) AUTHORIZED_ACTION = partner_action_binding_object( @@ -116,13 +117,27 @@ def write_execution_jwks(self, path: Path) -> Path: path.write_text(json.dumps({"keys": [self.public_jwk]}, indent=2) + "\n", encoding="utf-8") return path - def attempt(self, proposed: dict[str, Any], authorized_digest: str) -> dict[str, Any]: - executed_digest = partner_action_binding_digest(proposed) - if executed_digest != authorized_digest: - self.store.history.append("REFUSED_BINDING_MISMATCH") + def attempt( + self, + proposed: dict[str, Any], + authorization: Any = None, + *, + ontoguard_jwks_path: Path | None = None, + allow_test_keys: bool | None = None, + verification_time_utc: datetime | None = None, + ) -> dict[str, Any]: + """Verify signed authorization at the bounded commit boundary before mutation.""" + + def refuse( + reason: str, + *, + authorized_digest: str | None = None, + executed_digest: str | None = None, + ) -> dict[str, Any]: + self.store.history.append("REFUSED") return { "result": "EXECUTION_REFUSED", - "reason": "executed_action_binding_digest != authorized_action_binding_digest", + "reason": reason, "authorized_action_binding_digest": authorized_digest, "executed_action_binding_digest": executed_digest, "protected_effect_formed": False, @@ -130,6 +145,50 @@ def attempt(self, proposed: dict[str, Any], authorized_digest: str) -> dict[str, "status": self.store.status, "TRACE_RECORD_EMITTED": False, } + + try: + validated_action = validate_partner_action_binding_object(proposed) + executed_digest = partner_action_binding_digest(validated_action) + except (AdapterError, TypeError, ValueError) as exc: + return refuse(str(exc)) + + if not isinstance(authorization, dict): + return refuse( + "signed OntoGuard authorization is required at commit", + executed_digest=executed_digest, + ) + if ontoguard_jwks_path is None: + return refuse( + "trusted OntoGuard JWKS is required at commit", + executed_digest=executed_digest, + ) + + try: + bound = bind_authorization( + result_bytes=authorization.get("result_bytes"), + signature_b64url=authorization.get("signature"), + public_jwk=authorization.get("public_jwk"), + ontoguard_jwks_path=ontoguard_jwks_path, + allow_test_keys=allow_test_keys, + verification_time_utc=verification_time_utc, + ) + except (AdapterError, TypeError, ValueError) as exc: + return refuse(str(exc), executed_digest=executed_digest) + + authorized_digest = bound["action_binding_digest"] + if bound["action"] != "ALLOW" or bound["release_authorized"] is not True: + return refuse( + f"{bound['action']} is not a releasable authorization", + authorized_digest=authorized_digest, + executed_digest=executed_digest, + ) + if executed_digest != authorized_digest: + return refuse( + "executed_action_binding_digest != authorized_action_binding_digest", + authorized_digest=authorized_digest, + executed_digest=executed_digest, + ) + commit_id = "commit-" + secrets.token_hex(8) self.store.status = "RELEASED" self.store.commit_count += 1 @@ -144,6 +203,7 @@ def attempt(self, proposed: dict[str, Any], authorized_digest: str) -> dict[str, "protected_effect_formed": True, "commit_count": self.store.commit_count, "status": self.store.status, + "validated_action": validated_action, } def build_receipt( @@ -299,7 +359,12 @@ def run_live(proof_dir: Path = PROOF_DIR) -> dict[str, Any]: # Positive $250k — commit only after verification pos_exec = ControlledExecutor() pos_before = pos_exec.store.snapshot() - pos_attempt = pos_exec.attempt(AUTHORIZED_ACTION, authorized_digest) + pos_attempt = pos_exec.attempt( + AUTHORIZED_ACTION, + minted, + ontoguard_jwks_path=og_jwks, + allow_test_keys=True, + ) pos_receipt = pos_exec.build_receipt( auth=minted["auth"], result_digest=minted["result_digest"], @@ -337,7 +402,12 @@ def run_live(proof_dir: Path = PROOF_DIR) -> dict[str, Any]: # Negative $260k — new store, same verified authorization neg_exec = ControlledExecutor() neg_before = neg_exec.store.snapshot() - neg_attempt = neg_exec.attempt(MUTATED_ACTION, authorized_digest) + neg_attempt = neg_exec.attempt( + MUTATED_ACTION, + minted, + ontoguard_jwks_path=og_jwks, + allow_test_keys=True, + ) forged = dict(neg_attempt) forged["execution_event_id"] = "commit-forged-mutation" forged["authorized_action_binding_digest"] = authorized_digest diff --git a/integrations/ontoguard-decision-authorization/examples/controlled-execution-proof-2026-09-17/verify_proof.py b/integrations/ontoguard-decision-authorization/examples/controlled-execution-proof-2026-09-17/verify_proof.py index a20a59e..e357e2a 100644 --- a/integrations/ontoguard-decision-authorization/examples/controlled-execution-proof-2026-09-17/verify_proof.py +++ b/integrations/ontoguard-decision-authorization/examples/controlled-execution-proof-2026-09-17/verify_proof.py @@ -8,13 +8,13 @@ from __future__ import annotations +import base64 import hashlib import json from pathlib import Path from cryptography.exceptions import InvalidSignature from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey -import base64 ROOT = Path(__file__).resolve().parent POS = ROOT / "positive" diff --git a/integrations/ontoguard-decision-authorization/examples/emit_record.py b/integrations/ontoguard-decision-authorization/examples/emit_record.py index 3c50531..89bbe85 100644 --- a/integrations/ontoguard-decision-authorization/examples/emit_record.py +++ b/integrations/ontoguard-decision-authorization/examples/emit_record.py @@ -9,6 +9,7 @@ import argparse import json import sys +from datetime import datetime, timezone from pathlib import Path ROOT = Path(__file__).resolve().parents[1] @@ -19,6 +20,12 @@ DEFAULT_FIXTURE = Path(__file__).resolve().parent / "fixtures" / "allow_execution_proven.json" +# The bundled fixture is frozen historical evidence. Verify it at a time when +# the signed authorization was actually valid instead of against today's wall +# clock. Production calls that do not pass verification_time_utc continue to +# use the real current UTC time inside ontoguard_trace. +HISTORICAL_VERIFICATION_TIME = datetime(2026, 9, 18, 0, 0, tzinfo=timezone.utc) + def main() -> int: parser = argparse.ArgumentParser(description=__doc__) @@ -31,6 +38,7 @@ def main() -> int: if not fixture.get("execution_receipt"): print("ERROR: fixture has no independent execution receipt", file=sys.stderr) return 2 + try: result = project( fixture["authorization_result"], @@ -41,6 +49,7 @@ def main() -> int: execution_receipt=fixture["execution_receipt"], sign_trace=not args.unsigned, allow_test_keys=True, + verification_time_utc=HISTORICAL_VERIFICATION_TIME, ) except AdapterError as exc: print(f"ERROR: {exc}", file=sys.stderr) @@ -48,12 +57,19 @@ def main() -> int: if args.unsigned: out = Path(args.out) - out.write_text(json.dumps(result.get("trace_claim_candidate"), indent=2) + "\n", encoding="utf-8") + out.write_text( + json.dumps(result.get("trace_claim_candidate"), indent=2) + "\n", + encoding="utf-8", + ) print(f"WROTE_CANDIDATE {out}") print("TRACE_RECORD_EMITTED=false") return 0 + if not result.get("trace_record_emitted"): - print(f"ERROR: no TRACE record emitted (state={result.get('state')})", file=sys.stderr) + print( + f"ERROR: no TRACE record emitted (state={result.get('state')})", + file=sys.stderr, + ) return 2 out = Path(args.out) diff --git a/integrations/ontoguard-decision-authorization/examples/precommit-enforcement/README.md b/integrations/ontoguard-decision-authorization/examples/precommit-enforcement/README.md new file mode 100644 index 0000000..500ecd7 --- /dev/null +++ b/integrations/ontoguard-decision-authorization/examples/precommit-enforcement/README.md @@ -0,0 +1,57 @@ +# Bounded pre-commit enforcement example + +This example is a thin public composition. It is not OntoGuard's semantic +engine. It shows how an external enforcement runtime can consume a signed +OntoGuard authorization before a protected mutation. + +``` +destination/tool already admitted by the enforcement runtime + ↓ +exact proposed request + ↓ +strictly validate the proposed action + ↓ +verify signed OntoGuard handoff + ↓ +verify exact action-binding digest + ↓ +ALLOW + exact binding → controlled commit boundary re-verifies +BLOCK / ESCALATE / mismatch / expired / tampered / +untrusted / missing authorization → DENY + ↓ +only then may the bounded harness form the protected effect +``` + +Same permitted capability, different exact actions, already-signed decisions: + +| Capability | Exact action | Signed decision | Gate | +|---|---|---|---| +| `RELEASE_PAYMENT` | $250,000 to approved supplier | ALLOW | permit | +| `RELEASE_PAYMENT` | $260,000 to approved supplier | BLOCK | deny | +| `RELEASE_PAYMENT` | $250,000 to newly added supplier | ESCALATE | deny | + +Authorizations here are TEST-ONLY harness objects. Tests must pass +`allow_test_keys=True`. The gate default does not accept test keys. + +This directory does not contain Headless Runtime, Candidate Standing, +Recovery Standing, semantic packs, or Governing Basis logic. + +## What this example proves + +An external enforcement runtime can consume a signed OntoGuard decision +before protected execution. The proposed action is strictly validated before +binding and the same validated representation reaches the controlled commit +boundary. The decision is bound to that exact action. ALLOW for one action +does not authorize a materially different action. BLOCK and ESCALATE do not +release. The controlled executor independently re-verifies the signed +authorization at commit, so a caller-computed digest alone is not authority. +Malformed action types, no authorization, digest-only calls, tampered or +untrusted decisions, BLOCK, ESCALATE, or ALLOW for a different action do not +form the protected effect (`commit_count` stays 0). + +## What this example does not prove + +It does not prove OntoGuard's internal semantic reasoning, production +network non-bypassability, any third-party gateway integration or +endorsement, hardware attestation, or production L5 guarantees. +Bounded harness non-bypassability is not production route topology. diff --git a/integrations/ontoguard-decision-authorization/examples/precommit-enforcement/gate.py b/integrations/ontoguard-decision-authorization/examples/precommit-enforcement/gate.py new file mode 100644 index 0000000..4dd9635 --- /dev/null +++ b/integrations/ontoguard-decision-authorization/examples/precommit-enforcement/gate.py @@ -0,0 +1,243 @@ +"""Bounded pre-commit gate for an external enforcement runtime. + +This example contains no OntoGuard semantic engine. It consumes a signed +OntoGuard authorization before a protected mutation. + + destination/tool already admitted by the enforcement runtime + ↓ + exact proposed request + ↓ + verify signed OntoGuard handoff + exact action-binding digest + ↓ + ALLOW + exact binding → enforcement runtime may continue + BLOCK / ESCALATE / mismatch / expired / tampered / untrusted / + missing authorization / digest-only caller → DENY + ↓ + only then may the bounded harness form the protected effect +""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import uuid +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any + +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from ontoguard_trace import ( + ACTION_BINDING_PROFILE, + AdapterError, + bind_authorization, + partner_action_binding_digest, + partner_action_binding_object, + sha256_digest, + validate_partner_action_binding_object, +) + + +CAPABILITY = "RELEASE_PAYMENT" + +ACTION_ALLOW_250K = partner_action_binding_object( + operation=CAPABILITY, + amount="250000.00", + currency="USD", + counterparty="approved supplier", + cross_border=True, + consequence_class="FINANCIAL_COMMITMENT", +) +ACTION_BLOCK_260K = partner_action_binding_object( + operation=CAPABILITY, + amount="260000.00", + currency="USD", + counterparty="approved supplier", + cross_border=True, + consequence_class="FINANCIAL_COMMITMENT", +) +ACTION_ESCALATE_NEW_COUNTERPARTY = partner_action_binding_object( + operation=CAPABILITY, + amount="250000.00", + currency="USD", + counterparty="newly added supplier", + cross_border=True, + consequence_class="FINANCIAL_COMMITMENT", +) + + +def _b64url(data: bytes) -> str: + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") + + +def _sha256(data: bytes) -> str: + return "sha256:" + hashlib.sha256(data).hexdigest() + + +@dataclass(frozen=True) +class GateDecision: + permit: bool + reason: str + action: str | None = None + action_binding_digest: str | None = None + validated_action: dict[str, Any] | None = None + + +class SignedTestAuthorizer: + """TEST-ONLY signed authorizations. Not a live OntoGuard Decision API result.""" + + def __init__(self) -> None: + self._priv = Ed25519PrivateKey.generate() + self._pub = self._priv.public_key().public_bytes_raw() + self.kid = "ontoguard-harness-test-only" + self.public_jwk = { + "kty": "OKP", + "crv": "Ed25519", + "x": _b64url(self._pub), + "kid": self.kid, + "test_only": True, + } + + def mint(self, action_obj: dict[str, Any], decision: str = "ALLOW") -> dict[str, Any]: + if decision not in {"ALLOW", "BLOCK", "ESCALATE"}: + raise ValueError("decision must be ALLOW, BLOCK, or ESCALATE") + validated_action = validate_partner_action_binding_object(action_obj) + digest = partner_action_binding_digest(validated_action) + now = datetime.now(timezone.utc) + issued = now.replace(microsecond=0).isoformat().replace("+00:00", "Z") + expires = (now + timedelta(days=7)).replace(microsecond=0).isoformat().replace("+00:00", "Z") + seed = json.dumps( + {"decision": decision, "action": validated_action}, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + auth = { + "action": decision, + "action_binding_digest": digest, + "action_binding_profile": ACTION_BINDING_PROFILE, + "decision_binding_hash": _sha256(b"precommit-decision|" + seed), + "expires_at_utc": expires, + "handoff_hash": _sha256(b"precommit-handoff|" + seed), + "handoff_seal_digest": _sha256(b"precommit-handoff-seal|" + seed), + "issued_at_utc": issued, + "movement_hash": _sha256(b"precommit-movement|" + seed), + "release_authorized": decision == "ALLOW", + "trace_id": "precommit-" + str(uuid.uuid4()), + "harness_only": True, + "not_a_live_decision_api_result": True, + } + result_bytes = json.dumps(auth, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + return { + "auth": auth, + "result_bytes": result_bytes, + "signature": _b64url(self._priv.sign(result_bytes)), + "public_jwk": self.public_jwk, + "result_digest": sha256_digest(result_bytes), + } + + def write_ontoguard_jwks(self, path: Path) -> Path: + path.write_text(json.dumps({"keys": [self.public_jwk]}, indent=2) + "\n", encoding="utf-8") + return path + + +def evaluate_precommit( + proposed_action: dict[str, Any], + *, + result_bytes: bytes | None, + signature_b64url: str | None, + public_jwk: dict[str, Any] | None, + ontoguard_jwks_path: Path, + verification_time_utc: datetime | None = None, + allow_test_keys: bool | None = None, +) -> GateDecision: + """Fail closed after strict action validation. Does not execute.""" + try: + validated_action = validate_partner_action_binding_object(proposed_action) + except (AdapterError, TypeError, ValueError) as exc: + return GateDecision(permit=False, reason=str(exc)) + + if result_bytes is None or signature_b64url is None or public_jwk is None: + return GateDecision(permit=False, reason="no OntoGuard authorization") + + try: + bound = bind_authorization( + result_bytes=result_bytes, + signature_b64url=signature_b64url, + public_jwk=public_jwk, + ontoguard_jwks_path=ontoguard_jwks_path, + allow_test_keys=allow_test_keys, + verification_time_utc=verification_time_utc, + ) + proposed_digest = partner_action_binding_digest(validated_action) + except (AdapterError, TypeError, ValueError) as exc: + return GateDecision(permit=False, reason=str(exc)) + + if proposed_digest != bound["action_binding_digest"]: + return GateDecision( + permit=False, + reason="payload mismatch: proposed action is not the bound movement", + action=bound["action"], + action_binding_digest=bound["action_binding_digest"], + validated_action=validated_action, + ) + + if bound["action"] != "ALLOW" or bound["release_authorized"] is not True: + return GateDecision( + permit=False, + reason=f"{bound['action']} is not a releasable authorization", + action=bound["action"], + action_binding_digest=bound["action_binding_digest"], + validated_action=validated_action, + ) + + return GateDecision( + permit=True, + reason="ALLOW + exact action binding", + action=bound["action"], + action_binding_digest=bound["action_binding_digest"], + validated_action=validated_action, + ) + + +def attempt_protected( + executor: Any, + proposed_action: dict[str, Any], + *, + minted: dict[str, Any] | None, + ontoguard_jwks_path: Path, + allow_test_keys: bool | None = None, + verification_time_utc: datetime | None = None, +) -> dict[str, Any]: + """Protected wrapper; the executor independently re-verifies at commit.""" + if minted is None: + decision = GateDecision(permit=False, reason="no OntoGuard authorization") + else: + decision = evaluate_precommit( + proposed_action, + result_bytes=minted.get("result_bytes"), + signature_b64url=minted.get("signature"), + public_jwk=minted.get("public_jwk"), + ontoguard_jwks_path=ontoguard_jwks_path, + allow_test_keys=allow_test_keys, + verification_time_utc=verification_time_utc, + ) + + if not decision.permit: + return { + "result": "EXECUTION_REFUSED", + "reason": decision.reason, + "action": decision.action, + "protected_effect_formed": False, + "commit_count": executor.store.commit_count, + "status": executor.store.status, + } + + return executor.attempt( + decision.validated_action, + minted, + ontoguard_jwks_path=ontoguard_jwks_path, + allow_test_keys=allow_test_keys, + verification_time_utc=verification_time_utc, + ) diff --git a/integrations/ontoguard-decision-authorization/ontoguard_trace.py b/integrations/ontoguard-decision-authorization/ontoguard_trace.py index 91c97a7..bee9e9e 100644 --- a/integrations/ontoguard-decision-authorization/ontoguard_trace.py +++ b/integrations/ontoguard-decision-authorization/ontoguard_trace.py @@ -145,6 +145,57 @@ def sha256_digest(data: bytes) -> str: return "sha256:" + hashlib.sha256(data).hexdigest() +PARTNER_ACTION_FIELDS = ( + "amount", + "consequence_class", + "counterparty", + "cross_border", + "currency", + "operation", +) + + +def validate_partner_action_binding_object(obj: dict[str, Any]) -> dict[str, Any]: + """Validate and canonicalize the public partner action before binding/execution.""" + if not isinstance(obj, dict): + raise AdapterError("partner action must be an object") + + missing = [field for field in PARTNER_ACTION_FIELDS if field not in obj] + if missing: + raise AdapterError("partner action binding missing " + ", ".join(missing)) + + unexpected = sorted(set(obj) - set(PARTNER_ACTION_FIELDS)) + if unexpected: + raise AdapterError("partner action binding has unexpected fields: " + ", ".join(unexpected)) + + for field in ("consequence_class", "counterparty", "currency", "operation"): + value = obj[field] + if not isinstance(value, str) or not value: + raise AdapterError(f"partner action {field} must be a non-empty string") + + if type(obj["cross_border"]) is not bool: + raise AdapterError("partner action cross_border must be a boolean") + + amount = obj["amount"] + if isinstance(amount, bool) or not isinstance(amount, (str, int, float)): + raise AdapterError("partner action amount must be a string or number") + if isinstance(amount, str): + if not amount: + raise AdapterError("partner action amount must be non-empty") + amount_s = amount + else: + amount_s = f"{float(amount):.2f}" + + return { + "amount": amount_s, + "consequence_class": obj["consequence_class"], + "counterparty": obj["counterparty"], + "cross_border": obj["cross_border"], + "currency": obj["currency"], + "operation": obj["operation"], + } + + def partner_action_binding_object( *, operation: str, @@ -154,41 +205,21 @@ def partner_action_binding_object( cross_border: bool, consequence_class: str, ) -> dict[str, Any]: - if isinstance(amount, (int, float)): - amount_s = f"{float(amount):.2f}" - else: - amount_s = str(amount) - return { - "amount": amount_s, - "consequence_class": consequence_class, - "counterparty": counterparty, - "cross_border": bool(cross_border), - "currency": currency, - "operation": operation, - } + return validate_partner_action_binding_object( + { + "amount": amount, + "consequence_class": consequence_class, + "counterparty": counterparty, + "cross_border": cross_border, + "currency": currency, + "operation": operation, + } + ) def partner_action_binding_bytes(obj: dict[str, Any]) -> bytes: """Deterministic partner-safe bytes. Not OntoGuard's internal movement hash.""" - required = ( - "amount", - "consequence_class", - "counterparty", - "cross_border", - "currency", - "operation", - ) - missing = [k for k in required if k not in obj] - if missing: - raise AdapterError("partner action binding missing " + ", ".join(missing)) - canonical = { - "amount": str(obj["amount"]), - "consequence_class": obj["consequence_class"], - "counterparty": obj["counterparty"], - "cross_border": bool(obj["cross_border"]), - "currency": obj["currency"], - "operation": obj["operation"], - } + canonical = validate_partner_action_binding_object(obj) return json.dumps(canonical, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8") diff --git a/integrations/ontoguard-decision-authorization/tests/test_controlled_executor.py b/integrations/ontoguard-decision-authorization/tests/test_controlled_executor.py index 53fa113..9106e73 100644 --- a/integrations/ontoguard-decision-authorization/tests/test_controlled_executor.py +++ b/integrations/ontoguard-decision-authorization/tests/test_controlled_executor.py @@ -1,5 +1,5 @@ -from pathlib import Path import sys +from pathlib import Path PROOF = Path(__file__).resolve().parents[1] / "examples" / "controlled-execution-proof-2026-09-17" sys.path.insert(0, str(PROOF)) @@ -16,25 +16,53 @@ from ontoguard_trace import partner_action_binding_digest # noqa: E402 -def test_positive_commits_and_negative_refuses(): +def _signed_context(tmp_path): + authorizer = HarnessAuthorizer() + minted = authorizer.mint(AUTHORIZED_ACTION) + jwks = authorizer.write_ontoguard_jwks(tmp_path / "og.json") + return authorizer, minted, jwks + + +def test_positive_commits_and_negative_refuses(tmp_path): + _, minted, jwks = _signed_context(tmp_path) digest = partner_action_binding_digest(AUTHORIZED_ACTION) assert digest != partner_action_binding_digest(MUTATED_ACTION) pos = ControlledExecutor() - ok = pos.attempt(AUTHORIZED_ACTION, digest) + ok = pos.attempt( + AUTHORIZED_ACTION, + minted, + ontoguard_jwks_path=jwks, + allow_test_keys=True, + ) assert ok["result"] == "EXECUTED" assert pos.store.commit_count == 1 assert pos.store.status == "RELEASED" assert pos.store.protected_effect_formed is True neg = ControlledExecutor() - refused = neg.attempt(MUTATED_ACTION, digest) + refused = neg.attempt( + MUTATED_ACTION, + minted, + ontoguard_jwks_path=jwks, + allow_test_keys=True, + ) assert refused["result"] == "EXECUTION_REFUSED" assert neg.store.commit_count == 0 assert neg.store.status == "PENDING" assert neg.store.protected_effect_formed is False +def test_direct_digest_only_bypass_cannot_commit(): + executor = ControlledExecutor() + digest = partner_action_binding_digest(MUTATED_ACTION) + refused = executor.attempt(MUTATED_ACTION, digest) + assert refused["result"] == "EXECUTION_REFUSED" + assert "signed OntoGuard authorization is required" in refused["reason"] + assert executor.store.commit_count == 0 + assert executor.store.protected_effect_formed is False + + def test_live_harness_verifies_fresh_auth_then_commits(tmp_path): live = run_live() assert live["authorization_source"] == "ephemeral-harness-test-only" @@ -45,14 +73,22 @@ def test_live_harness_verifies_fresh_auth_then_commits(tmp_path): assert live["negative"]["adapter_rejected_mutated_receipt"] is True -def test_harness_authorizer_is_verifiable_before_attempt(tmp_path): - authorizer = HarnessAuthorizer() - minted = authorizer.mint(AUTHORIZED_ACTION) - jwks = authorizer.write_ontoguard_jwks(tmp_path / "og.json") +def test_harness_authorizer_is_verifiable_at_commit_boundary(tmp_path): + _, minted, jwks = _signed_context(tmp_path) bound = verify_authorization_pre_commit(minted, jwks) assert bound["action"] == "ALLOW" assert bound["action_binding_digest"] == partner_action_binding_digest(AUTHORIZED_ACTION) - store = ControlledExecutor() - assert store.store.commit_count == 0 - store.attempt(AUTHORIZED_ACTION, bound["action_binding_digest"]) - assert store.store.commit_count == 1 + + executor = ControlledExecutor() + digest_only = executor.attempt(AUTHORIZED_ACTION, bound["action_binding_digest"]) + assert digest_only["result"] == "EXECUTION_REFUSED" + assert executor.store.commit_count == 0 + + committed = executor.attempt( + AUTHORIZED_ACTION, + minted, + ontoguard_jwks_path=jwks, + allow_test_keys=True, + ) + assert committed["result"] == "EXECUTED" + assert executor.store.commit_count == 1 diff --git a/integrations/ontoguard-decision-authorization/tests/test_precommit_enforcement.py b/integrations/ontoguard-decision-authorization/tests/test_precommit_enforcement.py new file mode 100644 index 0000000..a7a3216 --- /dev/null +++ b/integrations/ontoguard-decision-authorization/tests/test_precommit_enforcement.py @@ -0,0 +1,288 @@ +"""Bounded pre-commit enforcement tests. No OntoGuard semantic engine.""" + +from __future__ import annotations + +import sys +from datetime import datetime, timezone +from pathlib import Path + +PROOF = Path(__file__).resolve().parents[1] / "examples" / "controlled-execution-proof-2026-09-17" +EXAMPLE = Path(__file__).resolve().parents[1] / "examples" / "precommit-enforcement" +sys.path.insert(0, str(PROOF)) +sys.path.insert(0, str(EXAMPLE)) +sys.path.insert(0, str(PROOF.parents[1])) + +from controlled_executor import ControlledExecutor # noqa: E402 +from gate import ( # noqa: E402 + ACTION_ALLOW_250K, + ACTION_BLOCK_260K, + ACTION_ESCALATE_NEW_COUNTERPARTY, + SignedTestAuthorizer, + attempt_protected, + evaluate_precommit, +) +from ontoguard_trace import partner_action_binding_digest # noqa: E402 + + +def _ctx(tmp_path: Path): + authorizer = SignedTestAuthorizer() + jwks = authorizer.write_ontoguard_jwks(tmp_path / "og.json") + return authorizer, jwks + + +def test_same_capability_allow_block_escalate_on_different_actions(tmp_path: Path) -> None: + authorizer, jwks = _ctx(tmp_path) + allow = authorizer.mint(ACTION_ALLOW_250K, "ALLOW") + block = authorizer.mint(ACTION_BLOCK_260K, "BLOCK") + escalate = authorizer.mint(ACTION_ESCALATE_NEW_COUNTERPARTY, "ESCALATE") + + assert partner_action_binding_digest(ACTION_ALLOW_250K) != partner_action_binding_digest(ACTION_BLOCK_260K) + assert partner_action_binding_digest(ACTION_ALLOW_250K) != partner_action_binding_digest( + ACTION_ESCALATE_NEW_COUNTERPARTY + ) + + permitted = evaluate_precommit( + ACTION_ALLOW_250K, + result_bytes=allow["result_bytes"], + signature_b64url=allow["signature"], + public_jwk=allow["public_jwk"], + ontoguard_jwks_path=jwks, + allow_test_keys=True, + ) + denied_block = evaluate_precommit( + ACTION_BLOCK_260K, + result_bytes=block["result_bytes"], + signature_b64url=block["signature"], + public_jwk=block["public_jwk"], + ontoguard_jwks_path=jwks, + allow_test_keys=True, + ) + denied_escalate = evaluate_precommit( + ACTION_ESCALATE_NEW_COUNTERPARTY, + result_bytes=escalate["result_bytes"], + signature_b64url=escalate["signature"], + public_jwk=escalate["public_jwk"], + ontoguard_jwks_path=jwks, + allow_test_keys=True, + ) + assert permitted.permit is True and permitted.action == "ALLOW" + assert denied_block.permit is False and denied_block.action == "BLOCK" + assert denied_escalate.permit is False and denied_escalate.action == "ESCALATE" + + +def test_allow_for_one_action_does_not_authorize_different_action(tmp_path: Path) -> None: + authorizer, jwks = _ctx(tmp_path) + allow_250 = authorizer.mint(ACTION_ALLOW_250K, "ALLOW") + decision = evaluate_precommit( + ACTION_BLOCK_260K, + result_bytes=allow_250["result_bytes"], + signature_b64url=allow_250["signature"], + public_jwk=allow_250["public_jwk"], + ontoguard_jwks_path=jwks, + allow_test_keys=True, + ) + assert decision.permit is False + assert "payload mismatch" in decision.reason + + +def test_bypass_without_verified_authorization_cannot_commit(tmp_path: Path) -> None: + authorizer, jwks = _ctx(tmp_path) + executor = ControlledExecutor() + + none = attempt_protected(executor, ACTION_ALLOW_250K, minted=None, ontoguard_jwks_path=jwks) + assert none["result"] == "EXECUTION_REFUSED" + assert executor.store.commit_count == 0 + assert executor.store.protected_effect_formed is False + + digest_only = attempt_protected( + executor, + ACTION_ALLOW_250K, + minted={ + "action_binding_digest": partner_action_binding_digest(ACTION_ALLOW_250K), + }, + ontoguard_jwks_path=jwks, + allow_test_keys=True, + ) + assert digest_only["result"] == "EXECUTION_REFUSED" + assert executor.store.commit_count == 0 + + direct_digest = executor.attempt( + ACTION_BLOCK_260K, + partner_action_binding_digest(ACTION_BLOCK_260K), + ) + assert direct_digest["result"] == "EXECUTION_REFUSED" + assert "signed OntoGuard authorization is required" in direct_digest["reason"] + assert executor.store.commit_count == 0 + assert executor.store.protected_effect_formed is False + + allow = authorizer.mint(ACTION_ALLOW_250K, "ALLOW") + tampered = dict(allow) + raw = allow["result_bytes"] + tampered["result_bytes"] = raw[:-1] + (b"X" if raw[-1:] != b"X" else b"Y") + bad = attempt_protected( + executor, + ACTION_ALLOW_250K, + minted=tampered, + ontoguard_jwks_path=jwks, + allow_test_keys=True, + ) + assert bad["result"] == "EXECUTION_REFUSED" + assert executor.store.commit_count == 0 + + block = authorizer.mint(ACTION_BLOCK_260K, "BLOCK") + blocked = attempt_protected( + executor, + ACTION_BLOCK_260K, + minted=block, + ontoguard_jwks_path=jwks, + allow_test_keys=True, + ) + assert blocked["result"] == "EXECUTION_REFUSED" + assert blocked["action"] == "BLOCK" + assert executor.store.commit_count == 0 + + escalate = authorizer.mint(ACTION_ESCALATE_NEW_COUNTERPARTY, "ESCALATE") + escalated = attempt_protected( + executor, + ACTION_ESCALATE_NEW_COUNTERPARTY, + minted=escalate, + ontoguard_jwks_path=jwks, + allow_test_keys=True, + ) + assert escalated["result"] == "EXECUTION_REFUSED" + assert escalated["action"] == "ESCALATE" + assert executor.store.commit_count == 0 + + wrong_action = attempt_protected( + executor, + ACTION_BLOCK_260K, + minted=allow, + ontoguard_jwks_path=jwks, + allow_test_keys=True, + ) + assert wrong_action["result"] == "EXECUTION_REFUSED" + assert executor.store.commit_count == 0 + assert executor.store.status == "PENDING" + + +def test_exact_allow_commits_only_through_protected_entry(tmp_path: Path) -> None: + authorizer, jwks = _ctx(tmp_path) + allow = authorizer.mint(ACTION_ALLOW_250K, "ALLOW") + executor = ControlledExecutor() + result = attempt_protected( + executor, + ACTION_ALLOW_250K, + minted=allow, + ontoguard_jwks_path=jwks, + allow_test_keys=True, + ) + assert result["result"] == "EXECUTED" + assert executor.store.commit_count == 1 + assert executor.store.protected_effect_formed is True + + +def test_expired_untrusted_malformed_refuse(tmp_path: Path) -> None: + authorizer, jwks = _ctx(tmp_path) + allow = authorizer.mint(ACTION_ALLOW_250K, "ALLOW") + + expired = evaluate_precommit( + ACTION_ALLOW_250K, + result_bytes=allow["result_bytes"], + signature_b64url=allow["signature"], + public_jwk=allow["public_jwk"], + ontoguard_jwks_path=jwks, + allow_test_keys=True, + verification_time_utc=datetime(2099, 1, 1, tzinfo=timezone.utc), + ) + assert expired.permit is False + assert "expired" in expired.reason + + empty = tmp_path / "empty.json" + empty.write_text('{"keys": []}', encoding="utf-8") + untrusted = evaluate_precommit( + ACTION_ALLOW_250K, + result_bytes=allow["result_bytes"], + signature_b64url=allow["signature"], + public_jwk=allow["public_jwk"], + ontoguard_jwks_path=empty, + allow_test_keys=True, + ) + assert untrusted.permit is False + + malformed = evaluate_precommit( + {"operation": "RELEASE_PAYMENT", "amount": "250000.00"}, + result_bytes=allow["result_bytes"], + signature_b64url=allow["signature"], + public_jwk=allow["public_jwk"], + ontoguard_jwks_path=jwks, + allow_test_keys=True, + ) + assert malformed.permit is False + assert "partner action binding missing" in malformed.reason + + + +def test_malformed_cross_border_values_fail_closed_before_commit(tmp_path: Path) -> None: + authorizer, jwks = _ctx(tmp_path) + allow = authorizer.mint(ACTION_ALLOW_250K, "ALLOW") + + malformed_values = ("false", 1, ["false"], {"value": False}) + for malformed_value in malformed_values: + proposed = dict(ACTION_ALLOW_250K) + proposed["cross_border"] = malformed_value + executor = ControlledExecutor() + + decision = evaluate_precommit( + proposed, + result_bytes=allow["result_bytes"], + signature_b64url=allow["signature"], + public_jwk=allow["public_jwk"], + ontoguard_jwks_path=jwks, + allow_test_keys=True, + ) + assert decision.permit is False + assert "cross_border must be a boolean" in decision.reason + + attempted = attempt_protected( + executor, + proposed, + minted=allow, + ontoguard_jwks_path=jwks, + allow_test_keys=True, + ) + assert attempted["result"] == "EXECUTION_REFUSED" + assert executor.store.commit_count == 0 + assert executor.store.protected_effect_formed is False + + +def test_unbound_extra_action_field_fails_closed(tmp_path: Path) -> None: + authorizer, jwks = _ctx(tmp_path) + allow = authorizer.mint(ACTION_ALLOW_250K, "ALLOW") + proposed = dict(ACTION_ALLOW_250K) + proposed["unbound_instruction"] = "execute anyway" + + executor = ControlledExecutor() + attempted = attempt_protected( + executor, + proposed, + minted=allow, + ontoguard_jwks_path=jwks, + allow_test_keys=True, + ) + assert attempted["result"] == "EXECUTION_REFUSED" + assert "unexpected fields" in attempted["reason"] + assert executor.store.commit_count == 0 + assert executor.store.protected_effect_formed is False + + +def test_default_rejects_test_keys(tmp_path: Path) -> None: + authorizer, jwks = _ctx(tmp_path) + allow = authorizer.mint(ACTION_ALLOW_250K, "ALLOW") + decision = evaluate_precommit( + ACTION_ALLOW_250K, + result_bytes=allow["result_bytes"], + signature_b64url=allow["signature"], + public_jwk=allow["public_jwk"], + ontoguard_jwks_path=jwks, + ) + assert decision.permit is False