From f782bd616ecdf4ccb8d956319255e711725ba4ac Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Sun, 2 Aug 2026 13:42:16 -0400 Subject: [PATCH] fix(aura): reject history-free agents by default (#2652) * fix(aura): reject history-free agents by default * fix(aura): keep invalid responses fail-closed --- integrations/aura/README.md | 23 +++++---- integrations/aura/adapter.py | 41 +++++++++------- integrations/aura/tests/test_adapter.py | 64 +++++++++++++++++++++++-- 3 files changed, 96 insertions(+), 32 deletions(-) diff --git a/integrations/aura/README.md b/integrations/aura/README.md index 6cb08f0fc..99000362d 100644 --- a/integrations/aura/README.md +++ b/integrations/aura/README.md @@ -18,7 +18,7 @@ from aura import before_settle, AuraUntrusted def settle(counterparty_did: str, amount: float) -> None: try: - before_settle(counterparty_did) # rejects high_risk + unknown + before_settle(counterparty_did) # rejects high_risk + new + unknown except AuraUntrusted as e: log.warning("blocked: %s", e) return # your policy decides what to do @@ -41,9 +41,8 @@ if v.dimensions and v.dimensions.get("financial_integrity", 1) < 0.4: require_manual_review() # placeholder for your own policy ``` -> `v.ok` reflects the *verdict class* (True for `trusted`/`caution`), not the -> outcome of `require_trust()` — the gate's default `allow` also lets `new` -> through. Use the gate's return/raise for the decision, `v.ok` for display. +> `v.ok` reflects the *verdict class* (True for `trusted`/`caution`). Use the +> gate's return/raise for the policy decision and `v.ok` for display. ## Verdicts @@ -58,8 +57,8 @@ if v.dimensions and v.dimensions.get("financial_integrity", 1) < 0.4: ## Policy knobs ```python -# Reject brand-new agents too (strict): -before_settle(did, allow=("trusted", "caution")) +# Explicitly allow brand-new agents during a controlled onboarding flow: +before_settle(did, allow=("trusted", "caution", "new")) # Treat an *unreachable* AURA as a pass (fail-open). Off by default — # absence of evidence is not evidence of trust. @@ -78,11 +77,15 @@ before_settle(did, base_url="https://my-aura-mirror.example", timeout=5) - **default (`fail_open=False`)** — `unknown` is rejected → an unreachable AURA blocks the action. *Fail-closed.* -- **`fail_open=True`** — `unknown` from an unreachable endpoint is allowed - through, so AURA can never take your flow down. *Fail-open.* +- **`new` verdict** — rejected by default because the agent has no interaction + history. Onboarding flows can explicitly add `new` to `allow`. +- **`fail_open=True`** — `unknown` from a transport failure is allowed through. + HTTP errors, malformed JSON, and invalid response shapes remain blocked + because the endpoint was reached but did not return a trustworthy verdict. -This keeps the trust signal **purely additive**: if you remove the adapter or -AURA is down, your existing allow/deny logic runs exactly as before. +Removing the adapter leaves your existing allow/deny logic untouched. While +the gate is enabled, an AURA outage blocks the protected action by default; +callers must explicitly choose `fail_open=True` to preserve availability. ## Tests diff --git a/integrations/aura/adapter.py b/integrations/aura/adapter.py index fc36f968d..075c028e9 100644 --- a/integrations/aura/adapter.py +++ b/integrations/aura/adapter.py @@ -10,10 +10,9 @@ Design boundary (intentional): - read-only: the only network call is GET /check?did=... - no auth: /check is a public endpoint; no API key, no secret - no coupling: pure stdlib (urllib). No third-party imports, no SDK. - - fail-closed: on network failure the verdict is `unknown`, and the - default gate (before_settle) rejects `unknown` — so an - unreachable AURA never silently waves a counterparty - through. Flip `fail_open=True` to invert that. + - fail-closed: by default, the gate rejects agents without interaction + history (`new`) and agents it cannot verify (`unknown`). + Flip `fail_open=True` to excuse transport failures only. Public API: aura_verdict(did) -> AuraVerdict (never raises on network) @@ -43,9 +42,10 @@ __all__ = [ DEFAULT_BASE_URL = "https://agent.auraopenprotocol.org" DEFAULT_TIMEOUT = 8 # seconds -# Verdicts safe to proceed with by default. Rejects `high_risk` (poor track -# record) and `unknown` (no verifiable history / endpoint unreachable). -DEFAULT_ALLOW = ("trusted", "caution", "new") +# Verdicts safe to proceed with by default. `new` remains available as an +# explicit opt-in for onboarding flows, but history-free agents should not +# satisfy a reputation gate automatically. +DEFAULT_ALLOW = ("trusted", "caution") # All verdict classes the /check endpoint can return. VERDICTS = ("trusted", "caution", "high_risk", "new", "unknown") @@ -82,10 +82,10 @@ class AuraVerdict: score: Optional[float] = None has_history: bool = False dimensions: Optional[dict[str, float]] = None - # False only when AURA could not be reached (network/parse failure) and the - # verdict is a synthetic `unknown`. A reachable AURA that genuinely returns - # `unknown` has reachable=True. before_settle's fail_open keys on this, not - # on the verdict alone, so it can't wave through unverified counterparties. + # False only when AURA could not be reached because of a transport failure. + # HTTP errors, malformed JSON, invalid shapes, and genuine `unknown` + # verdicts remain reachable=True. before_settle's fail_open keys on this, + # not on the verdict alone, so it cannot wave through invalid responses. reachable: bool = True raw: dict[str, Any] = field(default_factory=dict, repr=False) @@ -121,9 +121,14 @@ class AuraVerdict: @classmethod def unreachable(cls, did: str, reason: str) -> "AuraVerdict": - """A synthetic `unknown` verdict for network/parse failures.""" + """A synthetic `unknown` verdict for transport failures.""" return cls(did=did, verdict="unknown", reason=reason, reachable=False) + @classmethod + def invalid_response(cls, did: str, reason: str) -> "AuraVerdict": + """A reachable endpoint response that could not be trusted.""" + return cls(did=did, verdict="unknown", reason=reason, reachable=True) + # Indirection point so tests can inject canned responses without a network. # Signature: (url: str, timeout: float) -> dict (raises on transport error) @@ -156,13 +161,15 @@ def aura_verdict( url = f"{base_url.rstrip('/')}/check?" + urllib.parse.urlencode({"did": did}) try: body = _fetch(url, timeout) + except urllib.error.HTTPError as e: + return AuraVerdict.invalid_response(did, f"AURA returned HTTP {e.code}: {e.reason}") except (urllib.error.URLError, TimeoutError, OSError) as e: return AuraVerdict.unreachable(did, f"AURA unreachable: {e}") except (json.JSONDecodeError, ValueError) as e: - return AuraVerdict.unreachable(did, f"AURA returned non-JSON: {e}") + return AuraVerdict.invalid_response(did, f"AURA returned non-JSON: {e}") if not isinstance(body, dict): - return AuraVerdict.unreachable(did, "AURA returned an unexpected shape") + return AuraVerdict.invalid_response(did, "AURA returned an unexpected shape") return AuraVerdict.from_payload(did, body) @@ -180,13 +187,13 @@ def before_settle( raises AuraUntrusted on fail. try: - before_settle(counterparty_did) # rejects high_risk + unknown + before_settle(counterparty_did) # rejects high_risk + new + unknown settle_payment(counterparty_did, amount) except AuraUntrusted as e: abort(str(e)) - Tighten to reject brand-new agents too: - before_settle(did, allow=("trusted", "caution")) + Explicitly allow brand-new agents in an onboarding flow: + before_settle(did, allow=("trusted", "caution", "new")) fail_open=True makes an *unreachable* AURA pass through (transport failure only — a reachable AURA that returns `unknown` is still rejected). Off by diff --git a/integrations/aura/tests/test_adapter.py b/integrations/aura/tests/test_adapter.py index 82615d6f4..9d4bf1d62 100644 --- a/integrations/aura/tests/test_adapter.py +++ b/integrations/aura/tests/test_adapter.py @@ -13,6 +13,8 @@ Coverage: from __future__ import annotations +import json +from typing import Any import urllib.error import pytest @@ -70,9 +72,14 @@ def test_gate_allows_trusted(): assert v.verdict == "trusted" -def test_gate_allows_caution_and_new_by_default(): +def test_gate_allows_caution_by_default() -> None: assert before_settle("did:aura:caution-bot", _fetch=FETCH).verdict == "caution" - assert before_settle("did:aura:fresh-bot", _fetch=FETCH).verdict == "new" + + +def test_gate_rejects_new_by_default() -> None: + with pytest.raises(AuraUntrusted) as exc_info: + before_settle("did:aura:fresh-bot", _fetch=FETCH) + assert exc_info.value.verdict.verdict == "new" def test_gate_rejects_high_risk(): @@ -86,9 +93,13 @@ def test_gate_rejects_unknown_by_default(): before_settle("did:aura:ghost-bot", _fetch=FETCH) -def test_strict_allow_rejects_new(): - with pytest.raises(AuraUntrusted): - before_settle("did:aura:fresh-bot", allow=("trusted", "caution"), _fetch=FETCH) +def test_opt_in_allow_can_include_new() -> None: + v = before_settle( + "did:aura:fresh-bot", + allow=("trusted", "caution", "new"), + _fetch=FETCH, + ) + assert v.verdict == "new" # ── network-failure path ────────────────────────────────────────────────────── @@ -120,6 +131,49 @@ def test_fail_open_does_not_pass_reachable_unknown(): before_settle("did:aura:ghost-bot", fail_open=True, _fetch=FETCH) +def test_fail_open_does_not_pass_malformed_response() -> None: + fetch = raising_fetch(json.JSONDecodeError("expecting value", "", 0)) + with pytest.raises(AuraUntrusted) as exc_info: + before_settle( + "did:aura:trusted-bot", + fail_open=True, + _fetch=fetch, + ) + assert exc_info.value.verdict.reachable is True + + +def test_fail_open_does_not_pass_invalid_response_shape() -> None: + def invalid_shape_fetch(_url: str, _timeout: float) -> Any: + return [] + + with pytest.raises(AuraUntrusted) as exc_info: + before_settle( + "did:aura:trusted-bot", + fail_open=True, + _fetch=invalid_shape_fetch, + ) + assert exc_info.value.verdict.reachable is True + + +def test_fail_open_does_not_pass_http_error_response() -> None: + fetch = raising_fetch( + urllib.error.HTTPError( + "https://agent.auraopenprotocol.org/check", + 503, + "service unavailable", + None, + None, + ) + ) + with pytest.raises(AuraUntrusted) as exc_info: + before_settle( + "did:aura:trusted-bot", + fail_open=True, + _fetch=fetch, + ) + assert exc_info.value.verdict.reachable is True + + def test_reachable_verdict_marked_reachable(): v = aura_verdict("did:aura:ghost-bot", _fetch=FETCH) assert v.reachable is True