Skip to content

Commit 4a6b26a

Browse files
authored
Merge pull request #20 from QWED-AI/fix/issue-5-remove-trusted-bypass
fix(interceptor): remove trusted-agent verification bypass to prevent false cryptographic endorsement (closes #5)
2 parents 83a7219 + 20d18bc commit 4a6b26a

4 files changed

Lines changed: 232 additions & 19 deletions

File tree

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ dependencies = [
3636
dev = [
3737
"pytest>=7.0.0",
3838
"pytest-asyncio>=0.21.0",
39+
"pytest-cov>=4.0.0",
40+
"httpx>=0.24.0",
3941
"black>=23.0.0",
4042
"ruff>=0.1.0",
4143
]

src/qwed_a2a/interceptor.py

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -96,21 +96,7 @@ async def intercept(
9696
self._record(verdict, message.sender_agent_id, start_time)
9797
return verdict
9898

99-
# --- Step 2: Check trusted agent bypass ---
100-
if self.config.trusted_agents and (
101-
message.sender_agent_id in self.config.trusted_agents
102-
):
103-
verdict = self._build_verdict(
104-
trace_id=trace_id,
105-
status=VerdictStatus.FORWARDED,
106-
reason="Sender is on the trusted agents allowlist",
107-
engine="bypass",
108-
message=message,
109-
)
110-
self._record(verdict, message.sender_agent_id, start_time)
111-
return verdict
112-
113-
# --- Step 3: Route to verification engine ---
99+
# --- Step 2: Route to verification engine ---
114100
try:
115101
engine_result = self._route_to_engine(message)
116102
except Exception as exc:

tests/test_endpoints.py

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
"""
2+
Tests for QWED A2A protocol endpoints — FastAPI gateway layer.
3+
Covers: get_interceptor(), configure_interceptor(), _load_trusted_agents(),
4+
/a2a/intercept, /a2a/health, /a2a/metrics routes.
5+
"""
6+
7+
from unittest.mock import MagicMock, patch
8+
9+
import pytest
10+
from fastapi import FastAPI
11+
from fastapi.testclient import TestClient
12+
13+
from qwed_a2a.protocol import endpoints as ep
14+
from qwed_a2a.protocol.endpoints import (
15+
_load_trusted_agents,
16+
configure_interceptor,
17+
get_interceptor,
18+
router,
19+
)
20+
from qwed_a2a.protocol.schema import InterceptorConfig
21+
22+
23+
# ─── fixtures ──────────────────────────────────────────────────────────────────
24+
25+
26+
@pytest.fixture(autouse=True)
27+
def reset_interceptor_singleton():
28+
ep._interceptor = None
29+
yield
30+
ep._interceptor = None
31+
32+
33+
@pytest.fixture
34+
def app():
35+
application = FastAPI()
36+
application.include_router(router)
37+
return application
38+
39+
40+
@pytest.fixture
41+
def client(app):
42+
return TestClient(app)
43+
44+
45+
@pytest.fixture
46+
def general_payload():
47+
return {
48+
"sender_agent_id": "agent-alpha",
49+
"receiver_agent_id": "agent-beta",
50+
"payload_type": "general",
51+
"payload": {"msg": "hello"},
52+
}
53+
54+
55+
@pytest.fixture
56+
def financial_payload():
57+
return {
58+
"sender_agent_id": "procurement-agent",
59+
"receiver_agent_id": "treasury-agent",
60+
"payload_type": "financial_transaction",
61+
"payload": {
62+
"data": {
63+
"claimed_total": "100.00",
64+
"line_items": [
65+
{"description": "Widget", "amount": "100.00", "quantity": 1}
66+
],
67+
}
68+
},
69+
}
70+
71+
72+
# ─── _load_trusted_agents ──────────────────────────────────────────────────────
73+
74+
75+
class TestLoadTrustedAgents:
76+
def test_loads_agents_from_env(self, monkeypatch):
77+
monkeypatch.setenv("QWED_A2A_TRUSTED_AGENTS", "agent-a,agent-b")
78+
interceptor = MagicMock()
79+
_load_trusted_agents(interceptor)
80+
assert interceptor.trust.trust_agent.call_count == 2
81+
calls = [c.args[0] for c in interceptor.trust.trust_agent.call_args_list]
82+
assert "agent-a" in calls
83+
assert "agent-b" in calls
84+
85+
def test_ignores_empty_entries(self, monkeypatch):
86+
monkeypatch.setenv("QWED_A2A_TRUSTED_AGENTS", "agent-a,, ,agent-b")
87+
interceptor = MagicMock()
88+
_load_trusted_agents(interceptor)
89+
assert interceptor.trust.trust_agent.call_count == 2
90+
91+
def test_no_env_var_no_agents_registered(self, monkeypatch):
92+
monkeypatch.delenv("QWED_A2A_TRUSTED_AGENTS", raising=False)
93+
interceptor = MagicMock()
94+
_load_trusted_agents(interceptor)
95+
interceptor.trust.trust_agent.assert_not_called()
96+
97+
def test_whitespace_stripped_from_agent_ids(self, monkeypatch):
98+
monkeypatch.setenv("QWED_A2A_TRUSTED_AGENTS", " agent-x , agent-y ")
99+
interceptor = MagicMock()
100+
_load_trusted_agents(interceptor)
101+
calls = [c.args[0] for c in interceptor.trust.trust_agent.call_args_list]
102+
assert "agent-x" in calls
103+
assert "agent-y" in calls
104+
105+
106+
# ─── singleton ────────────────────────────────────────────────────────────────
107+
108+
109+
class TestInterceptorSingleton:
110+
def test_get_interceptor_returns_instance(self, monkeypatch):
111+
monkeypatch.delenv("QWED_A2A_TRUSTED_AGENTS", raising=False)
112+
assert get_interceptor() is not None
113+
114+
def test_get_interceptor_is_singleton(self, monkeypatch):
115+
monkeypatch.delenv("QWED_A2A_TRUSTED_AGENTS", raising=False)
116+
assert get_interceptor() is get_interceptor()
117+
118+
def test_configure_interceptor_replaces_singleton(self, monkeypatch):
119+
monkeypatch.delenv("QWED_A2A_TRUSTED_AGENTS", raising=False)
120+
original = get_interceptor()
121+
configure_interceptor(InterceptorConfig())
122+
assert get_interceptor() is not original
123+
124+
125+
# ─── /a2a/health ──────────────────────────────────────────────────────────────
126+
127+
128+
class TestHealthEndpoint:
129+
def test_returns_200(self, client):
130+
assert client.get("/a2a/health").status_code == 200
131+
132+
def test_returns_correct_fields(self, client):
133+
data = client.get("/a2a/health").json()
134+
assert data["status"] == "healthy"
135+
assert data["service"] == "qwed-a2a"
136+
assert "version" in data
137+
138+
139+
# ─── /a2a/metrics ─────────────────────────────────────────────────────────────
140+
141+
142+
class TestMetricsEndpoint:
143+
def test_returns_200(self, client, monkeypatch):
144+
monkeypatch.delenv("QWED_A2A_TRUSTED_AGENTS", raising=False)
145+
assert client.get("/a2a/metrics").status_code == 200
146+
147+
def test_returns_dict(self, client, monkeypatch):
148+
monkeypatch.delenv("QWED_A2A_TRUSTED_AGENTS", raising=False)
149+
assert isinstance(client.get("/a2a/metrics").json(), dict)
150+
151+
152+
# ─── /a2a/intercept ───────────────────────────────────────────────────────────
153+
154+
155+
class TestInterceptEndpoint:
156+
def test_general_message_returns_200(self, client, general_payload, monkeypatch):
157+
monkeypatch.delenv("QWED_A2A_TRUSTED_AGENTS", raising=False)
158+
assert client.post("/a2a/intercept", json=general_payload).status_code == 200
159+
160+
def test_returns_verdict_fields(self, client, general_payload, monkeypatch):
161+
monkeypatch.delenv("QWED_A2A_TRUSTED_AGENTS", raising=False)
162+
data = client.post("/a2a/intercept", json=general_payload).json()
163+
assert "status" in data
164+
assert "audit_trace_id" in data
165+
166+
def test_valid_financial_forwarded(self, client, financial_payload, monkeypatch):
167+
# Both agents must be trusted so the zero-trust boundary allows the request
168+
monkeypatch.setenv(
169+
"QWED_A2A_TRUSTED_AGENTS", "procurement-agent,treasury-agent"
170+
)
171+
resp = client.post("/a2a/intercept", json=financial_payload)
172+
assert resp.status_code == 200
173+
assert resp.json()["status"] == "forwarded"
174+
175+
def test_malformed_body_returns_422(self, client, monkeypatch):
176+
monkeypatch.delenv("QWED_A2A_TRUSTED_AGENTS", raising=False)
177+
assert (
178+
client.post("/a2a/intercept", json={"bad": "data"}).status_code == 422
179+
)
180+
181+
def test_runtime_error_returns_503(self, client, general_payload, monkeypatch):
182+
monkeypatch.delenv("QWED_A2A_TRUSTED_AGENTS", raising=False)
183+
184+
async def _raise(*a, **kw):
185+
raise RuntimeError("crypto unavailable")
186+
187+
with patch.object(ep.A2AVerificationInterceptor, "intercept", new=_raise):
188+
assert (
189+
client.post("/a2a/intercept", json=general_payload).status_code == 503
190+
)
191+
192+
def test_unexpected_error_returns_500(self, client, general_payload, monkeypatch):
193+
monkeypatch.delenv("QWED_A2A_TRUSTED_AGENTS", raising=False)
194+
195+
async def _raise(*a, **kw):
196+
raise ValueError("unexpected boom")
197+
198+
with patch.object(ep.A2AVerificationInterceptor, "intercept", new=_raise):
199+
assert (
200+
client.post("/a2a/intercept", json=general_payload).status_code == 500
201+
)

tests/test_interceptor.py

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,10 @@ async def test_blocked_sender_rejected(self, interceptor, general_message):
9898
assert verdict.status == VerdictStatus.BLOCKED
9999
assert "trust boundary" in verdict.reason.lower()
100100

101-
async def test_trusted_agent_bypass(self, crypto_service, trust_boundary, general_message):
102-
"""Trusted agents should bypass verification."""
101+
async def test_trusted_agent_no_longer_bypasses_verification(
102+
self, crypto_service, trust_boundary, general_message
103+
):
104+
"""Trusted agents should still route through verification engines."""
103105
config = InterceptorConfig(
104106
trusted_agents=[general_message.sender_agent_id]
105107
)
@@ -108,6 +110,28 @@ async def test_trusted_agent_bypass(self, crypto_service, trust_boundary, genera
108110
crypto_service=crypto_service,
109111
trust_boundary=trust_boundary,
110112
)
111-
verdict = await interceptor.intercept(general_message, trace_id="t_trust_bypass")
113+
verdict = await interceptor.intercept(general_message, trace_id="t_trust_no_bypass")
112114
assert verdict.status == VerdictStatus.FORWARDED
113-
assert verdict.engine_used == "bypass"
115+
assert verdict.engine_used == "passthrough"
116+
117+
async def test_trusted_agent_financial_fraud_is_blocked(
118+
self, crypto_service, trust_boundary, hallucinated_financial_message
119+
):
120+
"""Trusted agents are still verified and blocked on financial hallucinations."""
121+
config = InterceptorConfig(
122+
trusted_agents=[hallucinated_financial_message.sender_agent_id]
123+
)
124+
interceptor = A2AVerificationInterceptor(
125+
config=config,
126+
crypto_service=crypto_service,
127+
trust_boundary=trust_boundary,
128+
)
129+
130+
verdict = await interceptor.intercept(
131+
hallucinated_financial_message, trace_id="t_trust_fin_fraud"
132+
)
133+
134+
assert verdict.status == VerdictStatus.BLOCKED
135+
assert verdict.engine_used == "finance_guard"
136+
assert verdict.reason is not None
137+
assert "hallucination" in verdict.reason.lower()

0 commit comments

Comments
 (0)