mirror of
https://github.com/Jeuners/ECC.git
synced 2026-09-09 15:02:30 +02:00
fix(aura): reject history-free agents by default (#2652)
* fix(aura): reject history-free agents by default * fix(aura): keep invalid responses fail-closed
This commit is contained in:
parent
e4e4163101
commit
f782bd616e
3 changed files with 96 additions and 32 deletions
|
|
@ -18,7 +18,7 @@ from aura import before_settle, AuraUntrusted
|
||||||
|
|
||||||
def settle(counterparty_did: str, amount: float) -> None:
|
def settle(counterparty_did: str, amount: float) -> None:
|
||||||
try:
|
try:
|
||||||
before_settle(counterparty_did) # rejects high_risk + unknown
|
before_settle(counterparty_did) # rejects high_risk + new + unknown
|
||||||
except AuraUntrusted as e:
|
except AuraUntrusted as e:
|
||||||
log.warning("blocked: %s", e)
|
log.warning("blocked: %s", e)
|
||||||
return # your policy decides what to do
|
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
|
require_manual_review() # placeholder for your own policy
|
||||||
```
|
```
|
||||||
|
|
||||||
> `v.ok` reflects the *verdict class* (True for `trusted`/`caution`), not the
|
> `v.ok` reflects the *verdict class* (True for `trusted`/`caution`). Use the
|
||||||
> outcome of `require_trust()` — the gate's default `allow` also lets `new`
|
> gate's return/raise for the policy decision and `v.ok` for display.
|
||||||
> through. Use the gate's return/raise for the decision, `v.ok` for display.
|
|
||||||
|
|
||||||
## Verdicts
|
## Verdicts
|
||||||
|
|
||||||
|
|
@ -58,8 +57,8 @@ if v.dimensions and v.dimensions.get("financial_integrity", 1) < 0.4:
|
||||||
## Policy knobs
|
## Policy knobs
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# Reject brand-new agents too (strict):
|
# Explicitly allow brand-new agents during a controlled onboarding flow:
|
||||||
before_settle(did, allow=("trusted", "caution"))
|
before_settle(did, allow=("trusted", "caution", "new"))
|
||||||
|
|
||||||
# Treat an *unreachable* AURA as a pass (fail-open). Off by default —
|
# Treat an *unreachable* AURA as a pass (fail-open). Off by default —
|
||||||
# absence of evidence is not evidence of trust.
|
# 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
|
- **default (`fail_open=False`)** — `unknown` is rejected → an unreachable AURA
|
||||||
blocks the action. *Fail-closed.*
|
blocks the action. *Fail-closed.*
|
||||||
- **`fail_open=True`** — `unknown` from an unreachable endpoint is allowed
|
- **`new` verdict** — rejected by default because the agent has no interaction
|
||||||
through, so AURA can never take your flow down. *Fail-open.*
|
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
|
Removing the adapter leaves your existing allow/deny logic untouched. While
|
||||||
AURA is down, your existing allow/deny logic runs exactly as before.
|
the gate is enabled, an AURA outage blocks the protected action by default;
|
||||||
|
callers must explicitly choose `fail_open=True` to preserve availability.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,9 @@ Design boundary (intentional):
|
||||||
- read-only: the only network call is GET /check?did=...
|
- read-only: the only network call is GET /check?did=...
|
||||||
- no auth: /check is a public endpoint; no API key, no secret
|
- no auth: /check is a public endpoint; no API key, no secret
|
||||||
- no coupling: pure stdlib (urllib). No third-party imports, no SDK.
|
- no coupling: pure stdlib (urllib). No third-party imports, no SDK.
|
||||||
- fail-closed: on network failure the verdict is `unknown`, and the
|
- fail-closed: by default, the gate rejects agents without interaction
|
||||||
default gate (before_settle) rejects `unknown` — so an
|
history (`new`) and agents it cannot verify (`unknown`).
|
||||||
unreachable AURA never silently waves a counterparty
|
Flip `fail_open=True` to excuse transport failures only.
|
||||||
through. Flip `fail_open=True` to invert that.
|
|
||||||
|
|
||||||
Public API:
|
Public API:
|
||||||
aura_verdict(did) -> AuraVerdict (never raises on network)
|
aura_verdict(did) -> AuraVerdict (never raises on network)
|
||||||
|
|
@ -43,9 +42,10 @@ __all__ = [
|
||||||
DEFAULT_BASE_URL = "https://agent.auraopenprotocol.org"
|
DEFAULT_BASE_URL = "https://agent.auraopenprotocol.org"
|
||||||
DEFAULT_TIMEOUT = 8 # seconds
|
DEFAULT_TIMEOUT = 8 # seconds
|
||||||
|
|
||||||
# Verdicts safe to proceed with by default. Rejects `high_risk` (poor track
|
# Verdicts safe to proceed with by default. `new` remains available as an
|
||||||
# record) and `unknown` (no verifiable history / endpoint unreachable).
|
# explicit opt-in for onboarding flows, but history-free agents should not
|
||||||
DEFAULT_ALLOW = ("trusted", "caution", "new")
|
# satisfy a reputation gate automatically.
|
||||||
|
DEFAULT_ALLOW = ("trusted", "caution")
|
||||||
|
|
||||||
# All verdict classes the /check endpoint can return.
|
# All verdict classes the /check endpoint can return.
|
||||||
VERDICTS = ("trusted", "caution", "high_risk", "new", "unknown")
|
VERDICTS = ("trusted", "caution", "high_risk", "new", "unknown")
|
||||||
|
|
@ -82,10 +82,10 @@ class AuraVerdict:
|
||||||
score: Optional[float] = None
|
score: Optional[float] = None
|
||||||
has_history: bool = False
|
has_history: bool = False
|
||||||
dimensions: Optional[dict[str, float]] = None
|
dimensions: Optional[dict[str, float]] = None
|
||||||
# False only when AURA could not be reached (network/parse failure) and the
|
# False only when AURA could not be reached because of a transport failure.
|
||||||
# verdict is a synthetic `unknown`. A reachable AURA that genuinely returns
|
# HTTP errors, malformed JSON, invalid shapes, and genuine `unknown`
|
||||||
# `unknown` has reachable=True. before_settle's fail_open keys on this, not
|
# verdicts remain reachable=True. before_settle's fail_open keys on this,
|
||||||
# on the verdict alone, so it can't wave through unverified counterparties.
|
# not on the verdict alone, so it cannot wave through invalid responses.
|
||||||
reachable: bool = True
|
reachable: bool = True
|
||||||
raw: dict[str, Any] = field(default_factory=dict, repr=False)
|
raw: dict[str, Any] = field(default_factory=dict, repr=False)
|
||||||
|
|
||||||
|
|
@ -121,9 +121,14 @@ class AuraVerdict:
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def unreachable(cls, did: str, reason: str) -> "AuraVerdict":
|
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)
|
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.
|
# Indirection point so tests can inject canned responses without a network.
|
||||||
# Signature: (url: str, timeout: float) -> dict (raises on transport error)
|
# 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})
|
url = f"{base_url.rstrip('/')}/check?" + urllib.parse.urlencode({"did": did})
|
||||||
try:
|
try:
|
||||||
body = _fetch(url, timeout)
|
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:
|
except (urllib.error.URLError, TimeoutError, OSError) as e:
|
||||||
return AuraVerdict.unreachable(did, f"AURA unreachable: {e}")
|
return AuraVerdict.unreachable(did, f"AURA unreachable: {e}")
|
||||||
except (json.JSONDecodeError, ValueError) as 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):
|
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)
|
return AuraVerdict.from_payload(did, body)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -180,13 +187,13 @@ def before_settle(
|
||||||
raises AuraUntrusted on fail.
|
raises AuraUntrusted on fail.
|
||||||
|
|
||||||
try:
|
try:
|
||||||
before_settle(counterparty_did) # rejects high_risk + unknown
|
before_settle(counterparty_did) # rejects high_risk + new + unknown
|
||||||
settle_payment(counterparty_did, amount)
|
settle_payment(counterparty_did, amount)
|
||||||
except AuraUntrusted as e:
|
except AuraUntrusted as e:
|
||||||
abort(str(e))
|
abort(str(e))
|
||||||
|
|
||||||
Tighten to reject brand-new agents too:
|
Explicitly allow brand-new agents in an onboarding flow:
|
||||||
before_settle(did, allow=("trusted", "caution"))
|
before_settle(did, allow=("trusted", "caution", "new"))
|
||||||
|
|
||||||
fail_open=True makes an *unreachable* AURA pass through (transport failure
|
fail_open=True makes an *unreachable* AURA pass through (transport failure
|
||||||
only — a reachable AURA that returns `unknown` is still rejected). Off by
|
only — a reachable AURA that returns `unknown` is still rejected). Off by
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ Coverage:
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
import urllib.error
|
import urllib.error
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
@ -70,9 +72,14 @@ def test_gate_allows_trusted():
|
||||||
assert v.verdict == "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: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():
|
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)
|
before_settle("did:aura:ghost-bot", _fetch=FETCH)
|
||||||
|
|
||||||
|
|
||||||
def test_strict_allow_rejects_new():
|
def test_opt_in_allow_can_include_new() -> None:
|
||||||
with pytest.raises(AuraUntrusted):
|
v = before_settle(
|
||||||
before_settle("did:aura:fresh-bot", allow=("trusted", "caution"), _fetch=FETCH)
|
"did:aura:fresh-bot",
|
||||||
|
allow=("trusted", "caution", "new"),
|
||||||
|
_fetch=FETCH,
|
||||||
|
)
|
||||||
|
assert v.verdict == "new"
|
||||||
|
|
||||||
|
|
||||||
# ── network-failure path ──────────────────────────────────────────────────────
|
# ── 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)
|
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", "<html>", 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():
|
def test_reachable_verdict_marked_reachable():
|
||||||
v = aura_verdict("did:aura:ghost-bot", _fetch=FETCH)
|
v = aura_verdict("did:aura:ghost-bot", _fetch=FETCH)
|
||||||
assert v.reachable is True
|
assert v.reachable is True
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue