Skip to content

Commit 072cce8

Browse files
authored
fix: eliminate agent card JSON dependency from connect, simple_guard, and fastapi integration (#37)
* docs: align README with Agent Guard product branding Mirror the MCP Guard naming pattern: - Title: 'CapiscIO SDK (Python)' → 'CapiscIO Agent Guard' - Description: introduce Agent Guard as the product name - Keep all class names (SimpleGuard, CapiscioMiddleware) unchanged * fix: eliminate agent card JSON dependency from connect, simple_guard, and fastapi integration - Remove agent_card_json parameter from connect flow - Refactor simple_guard to use badge-based identity - Update fastapi integration for new identity model - Add/update unit tests for all changes * fix: address copilot review comments on PR #37 - connect.py: Use self.did (DID) instead of self.agent_id (UUID) for SimpleGuard agent_id, fixing invalid JWS issuer claims - fastapi.py: Add isinstance(guard, type) check to guard detection, preventing SimpleGuard class from being used as a guard instance - fastapi.py: Fail closed with 503 when guard is None instead of passing through as unverified (security: prevents unintended access) - Updated tests for fail-closed behavior (guard=None -> 503) * ci: increase integration test timeout from 15m to 25m The workflow builds Go from source (capiscio-core + capiscio-server Docker) plus a Python test runner image. 15 minutes is too tight for shared runners.
1 parent 3711dbe commit 072cce8

7 files changed

Lines changed: 217 additions & 69 deletions

File tree

.github/workflows/integration-tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ jobs:
2727
integration-tests:
2828
name: SDK Server Contract Tests
2929
runs-on: ubuntu-latest
30-
timeout-minutes: 15
30+
timeout-minutes: 25
3131

3232
steps:
3333
- name: Checkout SDK repository

capiscio_sdk/connect.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,7 @@ def connect(self) -> AgentIdentity:
398398
# Step 3: Initialize identity via capiscio-core Init RPC (one call does everything)
399399
# If keys already exist locally, we recover the DID without calling core.
400400
did = self._init_identity()
401+
self.did = did
401402
logger.info(f"DID: {did}")
402403

403404
# Step 3.5: Activate agent on server
@@ -804,11 +805,13 @@ def _setup_badge(self):
804805
from .badge_keeper import BadgeKeeper
805806
from .simple_guard import SimpleGuard
806807

807-
# Set up SimpleGuard with correct parameters
808+
# Set up SimpleGuard — keys are already loaded in gRPC server
809+
# from _init_identity(), so skip file-based PEM loading
808810
guard = SimpleGuard(
809-
base_dir=str(self.keys_dir.parent),
810-
agent_id=self.agent_id,
811+
agent_id=self.did,
811812
dev_mode=self.dev_mode,
813+
signing_kid=self.did,
814+
keys_preloaded=True,
812815
)
813816

814817
# Set up BadgeKeeper with correct parameters

capiscio_sdk/integrations/fastapi.py

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
"""FastAPI integration for Capiscio SimpleGuard."""
2-
from typing import Callable, Awaitable, Any, Dict, List, Optional, TYPE_CHECKING
2+
from typing import Callable, Awaitable, Any, Dict, List, Optional, Union, TYPE_CHECKING
33
try:
44
from starlette.middleware.base import BaseHTTPMiddleware
55
from starlette.requests import Request
@@ -25,7 +25,9 @@ class CapiscioMiddleware(BaseHTTPMiddleware):
2525
2626
Args:
2727
app: The ASGI application.
28-
guard: SimpleGuard instance for verification.
28+
guard: SimpleGuard instance, or a callable returning one (for lazy binding).
29+
When a callable is provided, the guard is resolved on first request.
30+
This allows registering middleware at module level before connect() runs.
2931
exclude_paths: List of paths to skip verification (e.g., ["/health", "/.well-known/agent-card.json"]).
3032
config: Optional SecurityConfig to control enforcement behavior.
3133
emitter: Optional EventEmitter for auto-event emission. When provided,
@@ -42,14 +44,21 @@ class CapiscioMiddleware(BaseHTTPMiddleware):
4244
def __init__(
4345
self,
4446
app: ASGIApp,
45-
guard: SimpleGuard,
47+
guard: Union[SimpleGuard, Callable[[], Optional[SimpleGuard]], None] = None,
4648
exclude_paths: Optional[List[str]] = None,
4749
*, # Force config to be keyword-only
4850
config: Optional["SecurityConfig"] = None,
4951
emitter: Optional[EventEmitter] = None,
5052
) -> None:
5153
super().__init__(app)
52-
self.guard = guard
54+
self._guard_factory: Optional[Callable[[], Optional[SimpleGuard]]] = None
55+
# Treat as factory if it's a plain callable without guard interface,
56+
# OR if it's a class/type (e.g. passing SimpleGuard itself, not an instance)
57+
if guard is not None and callable(guard) and (isinstance(guard, type) or not hasattr(guard, 'verify_inbound')):
58+
self._guard_factory = guard
59+
self._guard: Optional[SimpleGuard] = None
60+
else:
61+
self._guard = guard
5362
self.config = config
5463
self.exclude_paths = exclude_paths or []
5564
self._emitter = emitter
@@ -60,6 +69,21 @@ def __init__(
6069

6170
logger.info(f"CapiscioMiddleware initialized: exclude_paths={self.exclude_paths}, require_signatures={self.require_signatures}, fail_mode={self.fail_mode}, auto_events={emitter is not None}")
6271

72+
@property
73+
def guard(self) -> Optional[SimpleGuard]:
74+
"""Resolve guard lazily if a factory was provided."""
75+
if self._guard is None and self._guard_factory is not None:
76+
self._guard = self._guard_factory()
77+
return self._guard
78+
79+
def set_guard(self, guard: SimpleGuard) -> None:
80+
"""Set or replace the guard instance after construction.
81+
82+
Useful for binding the guard in a lifespan handler after connect().
83+
"""
84+
self._guard = guard
85+
self._guard_factory = None
86+
6387
async def dispatch(
6488
self,
6589
request: Request,
@@ -76,6 +100,22 @@ async def dispatch(
76100
logger.debug(f"CapiscioMiddleware: SKIPPING verification for {path}")
77101
return await call_next(request)
78102

103+
# If guard is not yet bound (lazy binding), fail closed to avoid unverified access
104+
if self.guard is None:
105+
logger.error("CapiscioMiddleware: guard not bound or unavailable; blocking request")
106+
request.state.agent = None
107+
request.state.agent_id = None
108+
self._auto_emit(EventEmitter.EVENT_VERIFICATION_FAILED, {
109+
"method": request.method,
110+
"path": path,
111+
"reason": "guard_unavailable",
112+
"duration_ms": 0.0,
113+
})
114+
return JSONResponse(
115+
{"error": "CapiscIO guard is not available. Requests cannot be verified at this time."},
116+
status_code=503,
117+
)
118+
79119
request_start = time.perf_counter()
80120

81121
# Auto-event: request.received

capiscio_sdk/simple_guard.py

Lines changed: 61 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ def __init__(
4444
rpc_address: Optional[str] = None,
4545
agent_id: Optional[str] = None,
4646
badge_token: Optional[str] = None,
47+
signing_kid: Optional[str] = None,
48+
keys_preloaded: bool = False,
4749
) -> None:
4850
"""
4951
Initialize SimpleGuard.
@@ -54,12 +56,17 @@ def __init__(
5456
rpc_address: gRPC server address. If None, auto-starts local server.
5557
agent_id: Explicit agent DID. If None:
5658
- In dev_mode: Auto-generates did:key from keypair
57-
- Otherwise: Loaded from agent-card.json
59+
- Otherwise: Loaded from agent-card.json (deprecated)
5860
badge_token: Pre-obtained badge token to use for identity. When set,
5961
make_headers() will use this token instead of signing.
62+
signing_kid: Explicit key ID for signing. When provided with agent_id,
63+
skips agent-card.json entirely.
64+
keys_preloaded: If True, skip file-based key loading (keys already
65+
loaded in gRPC server, e.g. from CapiscIO.connect()).
6066
"""
6167
self.dev_mode = dev_mode
6268
self._explicit_agent_id = agent_id
69+
self._explicit_signing_kid = signing_kid
6370
self._badge_token = badge_token
6471

6572
# 1. Safety Check
@@ -69,26 +76,29 @@ def __init__(
6976
"This is insecure! Disable dev_mode in production."
7077
)
7178

72-
# 2. Resolve base_dir
79+
# 2. Resolve base_dir (skip walking for agent-card.json when identity params provided)
7380
self.project_root = self._resolve_project_root(base_dir)
7481
self.keys_dir = self.project_root / "capiscio_keys"
7582
self.trusted_dir = self.keys_dir / "trusted"
76-
self.agent_card_path = self.project_root / "agent-card.json"
7783

7884
# 3. Connect to gRPC server
7985
self._client = CapiscioRPCClient(address=rpc_address)
8086
self._client.connect()
8187

82-
# 4. Load or generate agent identity
88+
# 4. Resolve agent identity
8389
self.agent_id: str
8490
self.signing_kid: str
85-
self._load_or_generate_card()
91+
self._resolve_identity()
8692

8793
# 5. Load or generate keys via gRPC (may update agent_id with did:key)
88-
self._load_or_generate_keys()
94+
if not keys_preloaded:
95+
self._load_or_generate_keys()
96+
else:
97+
logger.info(f"Keys preloaded in gRPC server, skipping file-based key loading")
8998

9099
# 6. Load trust store
91-
self._setup_trust_store()
100+
if not keys_preloaded:
101+
self._setup_trust_store()
92102

93103
def sign_outbound(self, payload: Dict[str, Any], body: Optional[bytes] = None) -> str:
94104
"""
@@ -201,9 +211,17 @@ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
201211
self.close()
202212

203213
def _resolve_project_root(self, base_dir: Optional[Union[str, Path]]) -> Path:
204-
"""Walk up the directory tree to find agent-card.json or stop at root."""
214+
"""Resolve the project root directory.
215+
216+
When agent_id is provided explicitly, uses base_dir (or cwd) directly
217+
without walking up the tree looking for agent-card.json.
218+
"""
205219
current = Path(base_dir or os.getcwd()).resolve()
206220

221+
# When identity params are provided, don't walk looking for agent-card.json
222+
if self._explicit_agent_id:
223+
return current
224+
207225
search_path = current
208226
while search_path != search_path.parent:
209227
if (search_path / "agent-card.json").exists():
@@ -212,18 +230,31 @@ def _resolve_project_root(self, base_dir: Optional[Union[str, Path]]) -> Path:
212230

213231
return current
214232

215-
def _load_or_generate_card(self) -> None:
216-
"""Load agent-card.json or generate a minimal one in dev_mode."""
217-
# If explicit agent_id was provided, use it
233+
def _resolve_identity(self) -> None:
234+
"""Resolve agent identity from explicit params, agent-card.json (legacy), or dev defaults.
235+
236+
Priority order:
237+
1. Explicit agent_id + signing_kid params (preferred — no file needed)
238+
2. Explicit agent_id only (signing_kid defaults to "key-0")
239+
3. Legacy agent-card.json file (deprecated)
240+
4. Dev mode auto-generation
241+
"""
242+
# Case 1 & 2: Explicit agent_id provided
218243
if self._explicit_agent_id:
219244
self.agent_id = self._explicit_agent_id
220-
self.signing_kid = "key-0" # Will be updated when keys are generated/loaded
245+
self.signing_kid = self._explicit_signing_kid or "key-0"
221246
logger.info(f"Using explicit agent_id: {self.agent_id}")
222247
return
223-
224-
if self.agent_card_path.exists():
248+
249+
# Case 3: Legacy agent-card.json (deprecated path)
250+
agent_card_path = self.project_root / "agent-card.json"
251+
if agent_card_path.exists():
252+
logger.warning(
253+
"Loading identity from agent-card.json is deprecated. "
254+
"Pass agent_id and signing_kid to SimpleGuard() directly."
255+
)
225256
try:
226-
with open(self.agent_card_path, "r") as f:
257+
with open(agent_card_path, "r") as f:
227258
data = json.load(f)
228259
self.agent_id = data.get("agent_id")
229260
keys = data.get("public_keys", [])
@@ -233,15 +264,25 @@ def _load_or_generate_card(self) -> None:
233264

234265
if not self.agent_id or not self.signing_kid:
235266
raise ConfigurationError("agent-card.json missing 'agent_id' or 'public_keys[0].kid'.")
267+
except ConfigurationError:
268+
raise
236269
except Exception as e:
237270
raise ConfigurationError(f"Failed to load agent-card.json: {e}")
238-
elif self.dev_mode:
271+
return
272+
273+
# Case 4: Dev mode — placeholder until key generation
274+
if self.dev_mode:
239275
logger.info("Dev Mode: Will generate did:key identity from keypair")
240-
# Placeholder - will be updated with did:key after key generation
241-
self.agent_id = "local-dev-agent" # Temporary, replaced in _load_or_generate_keys
276+
self.agent_id = "local-dev-agent"
242277
self.signing_kid = "local-dev-key"
243-
else:
244-
raise ConfigurationError(f"agent-card.json not found at {self.project_root}")
278+
return
279+
280+
raise ConfigurationError(
281+
"No agent identity configured. Either:\n"
282+
" - Pass agent_id (and optionally signing_kid) to SimpleGuard()\n"
283+
" - Use dev_mode=True for auto-generated identity\n"
284+
" - Use CapiscIO.connect() which handles identity automatically"
285+
)
245286

246287
def _load_or_generate_keys(self) -> None:
247288
"""Load keys or generate them in dev_mode via gRPC.
@@ -290,39 +331,9 @@ def _load_or_generate_keys(self) -> None:
290331
# Save public key
291332
with open(public_key_path, "w") as f:
292333
f.write(key_info["public_key_pem"])
293-
294-
# Update agent-card.json with JWK
295-
self._update_agent_card_with_pem(key_info["public_key_pem"])
296334
else:
297335
raise ConfigurationError(f"private.pem not found at {private_key_path}")
298336

299-
def _update_agent_card_with_pem(self, public_key_pem: str) -> None:
300-
"""Helper to write agent-card.json with the generated key."""
301-
# For simplicity, just create a minimal card
302-
# In production, would convert PEM to JWK
303-
card_data = {
304-
"agent_id": self.agent_id,
305-
"public_keys": [{
306-
"kty": "OKP",
307-
"crv": "Ed25519",
308-
"kid": self.signing_kid,
309-
"use": "sig",
310-
# Note: x would need to be extracted from PEM
311-
}],
312-
"protocolVersion": "0.3.0",
313-
"name": "Local Dev Agent",
314-
"description": "Auto-generated by SimpleGuard",
315-
"url": "http://localhost:8000",
316-
"version": "0.1.0",
317-
"provider": {
318-
"organization": "Local Dev"
319-
}
320-
}
321-
322-
with open(self.agent_card_path, "w") as f:
323-
json.dump(card_data, f, indent=2)
324-
logger.info(f"Created agent-card.json at {self.agent_card_path}")
325-
326337
def _setup_trust_store(self) -> None:
327338
"""Ensure trust store exists and add self-trust in dev_mode."""
328339
if not self.trusted_dir.exists() and self.dev_mode:

tests/unit/test_connect.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -978,6 +978,7 @@ def test_setup_badge_success(self, tmp_path):
978978
auto_badge=True,
979979
dev_mode=False,
980980
)
981+
connector.did = "did:key:z6Mktest"
981982

982983
mock_keeper = MagicMock()
983984
mock_keeper.get_current_badge.return_value = "badge-jwt"

tests/unit/test_fastapi_integration.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,75 @@ async def test_endpoint(request: Request):
428428
assert data["agent_id"] is None
429429

430430

431+
class TestLazyGuardBinding:
432+
"""Tests for lazy guard binding (callable and set_guard)."""
433+
434+
def test_middleware_with_callable_guard(self):
435+
"""Test that guard can be a callable that returns the SimpleGuard."""
436+
mock_guard = MagicMock()
437+
mock_guard.agent_id = "test-agent"
438+
mock_guard.verify_inbound.return_value = {"iss": "did:key:caller", "sub": "test"}
439+
440+
app = FastAPI()
441+
app.add_middleware(
442+
CapiscioMiddleware,
443+
guard=lambda: mock_guard,
444+
exclude_paths=["/health"],
445+
)
446+
447+
@app.post("/test")
448+
async def test_endpoint(request: Request):
449+
return {"agent_id": getattr(request.state, 'agent_id', None)}
450+
451+
client = TestClient(app)
452+
headers = {"X-Capiscio-Badge": "mock.jws.token", "Content-Type": "application/json"}
453+
response = client.post("/test", json={}, headers=headers)
454+
assert response.status_code == 200
455+
assert response.json()["agent_id"] == "did:key:caller"
456+
457+
def test_middleware_with_none_guard_passes_through(self):
458+
"""Test that None guard returns 503 (fail closed)."""
459+
app = FastAPI()
460+
app.add_middleware(
461+
CapiscioMiddleware,
462+
guard=None,
463+
exclude_paths=["/health"],
464+
)
465+
466+
@app.post("/test")
467+
async def test_endpoint(request: Request):
468+
return {
469+
"agent": getattr(request.state, 'agent', 'not-set'),
470+
"agent_id": getattr(request.state, 'agent_id', 'not-set'),
471+
}
472+
473+
client = TestClient(app)
474+
response = client.post("/test", json={})
475+
assert response.status_code == 503
476+
assert "guard is not available" in response.json()["error"]
477+
478+
def test_middleware_callable_returning_none(self):
479+
"""Test that callable returning None (guard not ready) returns 503."""
480+
app = FastAPI()
481+
app.add_middleware(
482+
CapiscioMiddleware,
483+
guard=lambda: None,
484+
exclude_paths=["/health"],
485+
)
486+
487+
@app.post("/test")
488+
async def test_endpoint(request: Request):
489+
return {
490+
"agent": getattr(request.state, 'agent', 'not-set'),
491+
"agent_id": getattr(request.state, 'agent_id', 'not-set'),
492+
}
493+
494+
client = TestClient(app)
495+
response = client.post("/test", json={})
496+
assert response.status_code == 503
497+
assert "guard is not available" in response.json()["error"]
498+
499+
431500
class TestAutoEvents:
432501
"""Tests for middleware auto-event emission."""
433502

0 commit comments

Comments
 (0)