fix(connect): Create agent when name specified but not found#29
fix(connect): Create agent when name specified but not found#29
Conversation
## [2.4.1] - 2026-02-08 ### Added - **CapiscIO.connect()**: Let's Encrypt-style one-liner agent setup - **EventEmitter**: Agent event emission with batching - **AgentIdentity**: Dataclass with emit(), status(), close() methods - Thread-safe batch operations with threading.Lock - httpx network error handling with sanitized messages
Previously, if a name was specified but no matching agent existed, the SDK would fall back to using the first existing agent. This was incorrect behavior - it should create a new agent with the specified name. This fix ensures: - If name IS specified and found → reuse that agent - If name IS specified and NOT found → create new agent with that name - If name NOT specified and agents exist → use first agent (backward compat) - If no agents exist → create new agent
|
✅ Documentation validation passed!
|
|
✅ All checks passed! Ready for review. |
There was a problem hiding this comment.
Pull request overview
This PR fixes CapiscIO.connect(name=...) agent selection so that specifying a name will create a new agent when no existing agent matches, instead of incorrectly reusing the first listed agent.
Changes:
- Update
_ensure_agent()to create a new agent whennameis provided but not found in the registry agent list. - Bump package version to
2.4.1inpyproject.tomlandcapiscio_sdk.__version__. - Add a
2.4.1section toCHANGELOG.md.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
capiscio_sdk/connect.py |
Fixes agent lookup/creation behavior when name is specified but not found. |
pyproject.toml |
Bumps project version to 2.4.1. |
capiscio_sdk/__init__.py |
Updates __version__ to 2.4.1. |
CHANGELOG.md |
Introduces a 2.4.1 release entry (needs alignment with PR contents). |
| for agent in agents: | ||
| if agent.get("name") == self.name: | ||
| return agent | ||
| # Name specified but not found - create new agent with that name |
There was a problem hiding this comment.
The new behavior (when name is specified but not found, create a new agent) isn't covered by the existing unit tests for _ensure_agent. Please add a test case where the agent list is non-empty, self.name is set, no entry matches, and _create_agent() is invoked (instead of falling back to agents[0]).
| # Name specified but not found - create new agent with that name | |
| # Name specified but not found | |
| if agents: | |
| # Fallback to first existing agent | |
| return agents[0] | |
| # No agents exist - create new agent with the requested name |
| ## [2.4.1] - 2026-02-08 | ||
|
|
||
| ### Added | ||
| - **CapiscIO.connect()**: "Let's Encrypt" style one-liner agent setup | ||
| - Single API call handles key generation, DID creation, registration, and badge request | ||
| - Cryptographic operations performed by capiscio-core Go library via gRPC | ||
| - Context manager support for automatic resource cleanup | ||
| - **CapiscIO.from_env()**: Initialize from `CAPISCIO_API_KEY` environment variable | ||
| - **AgentIdentity**: Dataclass representing a fully-configured agent | ||
| - `emit()`: Send events to the registry | ||
| - `status()`: Get agent status including badge validity | ||
| - `close()`: Clean up resources (also works as context manager) | ||
| - **EventEmitter**: Agent event emission with batching | ||
| - Thread-safe batch operations | ||
| - Automatic flush on batch size threshold | ||
| - Convenience methods: `task_started()`, `task_completed()`, `task_failed()`, `tool_call()`, `tool_result()` | ||
|
|
||
| ### Fixed | ||
| - **httpx exception handling**: Network errors now raise `RuntimeError` with sanitized messages | ||
| - **Error message sanitization**: Removed `resp.text` from error messages to prevent API key exposure |
There was a problem hiding this comment.
The new 2.4.1 changelog entry lists multiple 'Added' features and fixes that are not part of this PR, and it doesn't mention the actual bugfix described in the PR (creating a new agent when connect(name=...) doesn't find a match). Please update the 2.4.1 notes to reflect the changes shipped in this release, including the name-not-found agent creation fix.
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
|
✅ Integration tests passed! Server validation, BadgeKeeper, and gRPC tests all working. |
- Accept optional 'config' parameter in middleware constructor - Support fail_mode: 'block', 'monitor', 'log' - Support downstream.require_signatures: allow unauthenticated when False - Backwards compatible: defaults to strict mode if no config provided
|
✅ Documentation validation passed!
|
|
✅ All checks passed! Ready for review. |
|
✅ Integration tests passed! Server validation, BadgeKeeper, and gRPC tests all working. |
- Add SecurityConfig section showing middleware config parameter - Add env var table for from_env() configuration - Add CapiscIO.connect() section with name-based agent lookup - Document Let's Encrypt style one-liner setup
|
✅ Documentation validation passed!
|
|
✅ All checks passed! Ready for review. |
|
✅ Integration tests passed! Server validation, BadgeKeeper, and gRPC tests all working. |
| # Name specified but not found - create new agent with that name | ||
| return self._create_agent() |
There was a problem hiding this comment.
The bug fix lacks test coverage. While there are tests for finding an agent by name when it exists (test_ensure_agent_lists_and_finds_by_name) and creating an agent when the list is empty (test_ensure_agent_creates_when_empty), there is no test for the specific bug being fixed: creating a new agent when a name IS specified but NOT found in the existing agents list. Add a test case that verifies this behavior to prevent regression.
| @@ -47,13 +64,30 @@ async def dispatch( | |||
| # RFC-002 §9.1: X-Capiscio-Badge header | |||
| auth_header = request.headers.get("X-Capiscio-Badge") | |||
|
|
|||
| # If no header, we might let it pass but mark as unverified? | |||
| # The mandate says: "Returns 401 (missing) or 403 (invalid)." | |||
| # Handle missing badge based on config | |||
| if not auth_header: | |||
| return JSONResponse( | |||
| {"error": "Missing X-Capiscio-Badge header. This endpoint is protected by CapiscIO."}, | |||
| status_code=401 | |||
| ) | |||
| if not self.require_signatures: | |||
| # No badge required - allow through but mark as unverified | |||
| request.state.agent = None | |||
| request.state.agent_id = None | |||
| return await call_next(request) | |||
|
|
|||
| # Badge required but missing | |||
| if self.fail_mode == "log": | |||
| logger.warning(f"Missing X-Capiscio-Badge header for {request.url.path} (log mode)") | |||
| request.state.agent = None | |||
| request.state.agent_id = None | |||
| return await call_next(request) | |||
| elif self.fail_mode == "monitor": | |||
| logger.warning(f"Missing X-Capiscio-Badge header for {request.url.path} (monitor mode)") | |||
| request.state.agent = None | |||
| request.state.agent_id = None | |||
| return await call_next(request) | |||
| else: # block | |||
| return JSONResponse( | |||
| {"error": "Missing X-Capiscio-Badge header. This endpoint is protected by CapiscIO."}, | |||
| status_code=401 | |||
| ) | |||
|
|
|||
| start_time = time.perf_counter() | |||
| try: | |||
| @@ -73,7 +107,18 @@ async def receive() -> Dict[str, Any]: | |||
| request.state.agent_id = payload.get("iss") | |||
|
|
|||
| except VerificationError as e: | |||
| return JSONResponse({"error": f"Access Denied: {str(e)}"}, status_code=403) | |||
| if self.fail_mode == "log": | |||
| logger.warning(f"Badge verification failed: {e} (log mode)") | |||
| request.state.agent = None | |||
| request.state.agent_id = None | |||
| return await call_next(request) | |||
| elif self.fail_mode == "monitor": | |||
| logger.warning(f"Badge verification failed: {e} (monitor mode)") | |||
| request.state.agent = None | |||
| request.state.agent_id = None | |||
| return await call_next(request) | |||
| else: # block | |||
| return JSONResponse({"error": f"Access Denied: {str(e)}"}, status_code=403) | |||
There was a problem hiding this comment.
The new SecurityConfig integration and fail_mode handling (log/monitor/block modes) lack test coverage. There are no tests verifying that the middleware correctly handles missing badges or verification failures based on config.fail_mode settings, or that config.downstream.require_signatures is respected. Add test cases to verify these behaviors work as documented.
capiscio_sdk/integrations/fastapi.py
Outdated
| self.require_signatures = config.downstream.require_signatures if config else True | ||
| self.fail_mode = config.fail_mode if config else "block" |
There was a problem hiding this comment.
Potential AttributeError when config is None. Line 48 attempts to access config.downstream.require_signatures when config is None, which will raise an AttributeError. The code should check if config is not None before accessing its attributes. Change to: self.require_signatures = config.downstream.require_signatures if config is not None else True
| self.require_signatures = config.downstream.require_signatures if config else True | |
| self.fail_mode = config.fail_mode if config else "block" | |
| self.require_signatures = config.downstream.require_signatures if config is not None else True | |
| self.fail_mode = config.fail_mode if config is not None else "block" |
capiscio_sdk/integrations/fastapi.py
Outdated
| @@ -73,7 +107,18 @@ async def receive() -> Dict[str, Any]: | |||
| request.state.agent_id = payload.get("iss") | |||
|
|
|||
| except VerificationError as e: | |||
| return JSONResponse({"error": f"Access Denied: {str(e)}"}, status_code=403) | |||
| if self.fail_mode == "log": | |||
| logger.warning(f"Badge verification failed: {e} (log mode)") | |||
| request.state.agent = None | |||
| request.state.agent_id = None | |||
| return await call_next(request) | |||
| elif self.fail_mode == "monitor": | |||
| logger.warning(f"Badge verification failed: {e} (monitor mode)") | |||
| request.state.agent = None | |||
| request.state.agent_id = None | |||
| return await call_next(request) | |||
There was a problem hiding this comment.
The fail_mode handling for "log" and "monitor" modes is identical (lines 76-85 and 110-119). Both perform the same actions: log a warning, set request state to None, and allow the request through. The only difference is the log message. Consider consolidating this duplicated logic by treating "log" and "monitor" the same way (e.g., if self.fail_mode in ("log", "monitor")) to reduce code duplication and maintenance burden.
|
|
||
| ## [Unreleased] | ||
|
|
||
| ## [2.4.1] - 2026-02-08 |
There was a problem hiding this comment.
The CHANGELOG entry describes features that don't match this PR's changes. The PR description states this is a bug fix for agent name lookup logic (lines 315-316 in connect.py), but the CHANGELOG describes entirely different features like CapiscIO.connect(), EventEmitter, and AgentIdentity which are not part of this PR. The CHANGELOG should only document the actual bug fix: "Fixed agent lookup to create new agent when name is specified but not found (previously fell back to first existing agent)".
| @@ -56,6 +83,64 @@ async def handle_task(request: Request): | |||
| pip install capiscio-sdk | |||
| ``` | |||
|
|
|||
| ## 🔌 CapiscIO.connect() - "Let's Encrypt" Style Setup | |||
|
|
|||
| The fastest way to get a production-ready agent identity: | |||
|
|
|||
| ```python | |||
| from capiscio_sdk import CapiscIO | |||
|
|
|||
| # One-liner to get a fully configured agent | |||
| agent = CapiscIO.connect(api_key="sk_live_...") | |||
|
|
|||
| # Agent is now ready | |||
| print(agent.did) # did:key:z6Mk... | |||
| print(agent.badge) # Current badge (auto-renewed) | |||
| print(agent.name) # Agent name | |||
| ``` | |||
|
|
|||
| ### Connect Parameters | |||
|
|
|||
| | Parameter | Description | Default | | |||
| |-----------|-------------|---------| | |||
| | `api_key` | Your CapiscIO API key | Required | | |||
| | `name` | Agent name for lookup/creation | Auto-generated | | |||
| | `agent_id` | Specific agent UUID | Auto-discovered | | |||
| | `server_url` | Registry URL | `https://registry.capisc.io` | | |||
| | `auto_badge` | Request badge automatically | `True` | | |||
| | `dev_mode` | Use self-signed badges (Level 0) | `False` | | |||
|
|
|||
| ### Name-Based Agent Lookup | |||
|
|
|||
| When you specify a `name`, the SDK will: | |||
| 1. Search for an existing agent with that name | |||
| 2. If not found, **create a new agent** with that name | |||
| 3. Return the agent identity | |||
|
|
|||
| ```python | |||
| # First run: creates "my-research-agent" | |||
| agent = CapiscIO.connect(api_key="...", name="my-research-agent") | |||
|
|
|||
| # Second run: finds existing "my-research-agent" | |||
| agent = CapiscIO.connect(api_key="...", name="my-research-agent") | |||
| ``` | |||
|
|
|||
| ### Using Environment Variables | |||
|
|
|||
| ```python | |||
| from capiscio_sdk import CapiscIO | |||
|
|
|||
| # Reads from CAPISCIO_API_KEY, CAPISCIO_AGENT_NAME, etc. | |||
| agent = CapiscIO.from_env() | |||
| ``` | |||
|
|
|||
| **Environment Variables:** | |||
| - `CAPISCIO_API_KEY` (required) - Your API key | |||
| - `CAPISCIO_AGENT_NAME` - Agent name for lookup/creation | |||
| - `CAPISCIO_AGENT_ID` - Specific agent UUID | |||
| - `CAPISCIO_SERVER_URL` - Registry URL | |||
| - `CAPISCIO_DEV_MODE` - Enable dev mode (`true`/`false`) | |||
|
|
|||
There was a problem hiding this comment.
The README.md additions document features that are not part of this PR. Lines 86-143 describe CapiscIO.connect() usage patterns, SecurityConfig modes, and environment variables, but this PR only fixes a bug in the agent lookup logic. These documentation additions should be removed or moved to the PR that actually implements these features.
| @@ -11,6 +11,12 @@ | |||
| from ..simple_guard import SimpleGuard | |||
| from ..errors import VerificationError | |||
| import time | |||
| import logging | |||
|
|
|||
| if TYPE_CHECKING: | |||
| from ..config import SecurityConfig | |||
|
|
|||
| logger = logging.getLogger(__name__) | |||
|
|
|||
| class CapiscioMiddleware(BaseHTTPMiddleware): | |||
| """ | |||
| @@ -19,17 +25,28 @@ class CapiscioMiddleware(BaseHTTPMiddleware): | |||
| Args: | |||
| app: The ASGI application. | |||
| guard: SimpleGuard instance for verification. | |||
| config: Optional SecurityConfig to control enforcement behavior. | |||
| exclude_paths: List of paths to skip verification (e.g., ["/health", "/.well-known/agent-card.json"]). | |||
|
|
|||
| Security behavior controlled by SecurityConfig: | |||
| - config.downstream.require_signatures: If False, allow requests without badges | |||
| - config.fail_mode: "block" returns 401/403, "monitor" logs and allows, "log" just logs | |||
| """ | |||
| def __init__( | |||
| self, | |||
| app: ASGIApp, | |||
| guard: SimpleGuard, | |||
| config: Optional["SecurityConfig"] = None, | |||
| exclude_paths: Optional[List[str]] = None | |||
| ) -> None: | |||
| super().__init__(app) | |||
| self.guard = guard | |||
| self.config = config | |||
| self.exclude_paths = exclude_paths or [] | |||
|
|
|||
| # Default to strict mode if no config | |||
| self.require_signatures = config.downstream.require_signatures if config else True | |||
| self.fail_mode = config.fail_mode if config else "block" | |||
|
|
|||
| async def dispatch( | |||
| self, | |||
| @@ -47,13 +64,30 @@ async def dispatch( | |||
| # RFC-002 §9.1: X-Capiscio-Badge header | |||
| auth_header = request.headers.get("X-Capiscio-Badge") | |||
|
|
|||
| # If no header, we might let it pass but mark as unverified? | |||
| # The mandate says: "Returns 401 (missing) or 403 (invalid)." | |||
| # Handle missing badge based on config | |||
| if not auth_header: | |||
| return JSONResponse( | |||
| {"error": "Missing X-Capiscio-Badge header. This endpoint is protected by CapiscIO."}, | |||
| status_code=401 | |||
| ) | |||
| if not self.require_signatures: | |||
| # No badge required - allow through but mark as unverified | |||
| request.state.agent = None | |||
| request.state.agent_id = None | |||
| return await call_next(request) | |||
|
|
|||
| # Badge required but missing | |||
| if self.fail_mode == "log": | |||
| logger.warning(f"Missing X-Capiscio-Badge header for {request.url.path} (log mode)") | |||
| request.state.agent = None | |||
| request.state.agent_id = None | |||
| return await call_next(request) | |||
| elif self.fail_mode == "monitor": | |||
| logger.warning(f"Missing X-Capiscio-Badge header for {request.url.path} (monitor mode)") | |||
| request.state.agent = None | |||
| request.state.agent_id = None | |||
| return await call_next(request) | |||
| else: # block | |||
| return JSONResponse( | |||
| {"error": "Missing X-Capiscio-Badge header. This endpoint is protected by CapiscIO."}, | |||
| status_code=401 | |||
| ) | |||
|
|
|||
| start_time = time.perf_counter() | |||
| try: | |||
| @@ -73,7 +107,18 @@ async def receive() -> Dict[str, Any]: | |||
| request.state.agent_id = payload.get("iss") | |||
|
|
|||
| except VerificationError as e: | |||
| return JSONResponse({"error": f"Access Denied: {str(e)}"}, status_code=403) | |||
| if self.fail_mode == "log": | |||
| logger.warning(f"Badge verification failed: {e} (log mode)") | |||
| request.state.agent = None | |||
| request.state.agent_id = None | |||
| return await call_next(request) | |||
| elif self.fail_mode == "monitor": | |||
| logger.warning(f"Badge verification failed: {e} (monitor mode)") | |||
| request.state.agent = None | |||
| request.state.agent_id = None | |||
| return await call_next(request) | |||
| else: # block | |||
| return JSONResponse({"error": f"Access Denied: {str(e)}"}, status_code=403) | |||
There was a problem hiding this comment.
The FastAPI middleware changes add SecurityConfig integration, fail_mode handling, and require_signatures logic (lines 28-49, 67-121). These changes are not mentioned in the PR description which states this PR only fixes agent lookup logic in connect.py. These middleware enhancements should be in a separate PR or the PR description should be updated to document these additional changes.
- Use explicit 'is not None' check for config null safety - Consolidate duplicate log/monitor mode handlers into single blocks - Reduces code duplication and improves maintainability
| self, | ||
| app: ASGIApp, | ||
| guard: SimpleGuard, | ||
| config: Optional["SecurityConfig"] = None, | ||
| exclude_paths: Optional[List[str]] = None |
There was a problem hiding this comment.
The new config parameter is inserted before exclude_paths, which can break any existing positional instantiation (e.g., passing exclude_paths as the 3rd arg). To preserve backward compatibility, move config after exclude_paths or make config keyword-only (e.g., via * in the signature).
| # Handle missing badge based on config | ||
| if not auth_header: | ||
| return JSONResponse( | ||
| {"error": "Missing X-Capiscio-Badge header. This endpoint is protected by CapiscIO."}, | ||
| status_code=401 | ||
| ) | ||
| if not self.require_signatures: | ||
| # No badge required - allow through but mark as unverified | ||
| request.state.agent = None |
There was a problem hiding this comment.
New config-driven behavior for missing badges (allowing requests through when require_signatures=False or when fail_mode is log/monitor) isn’t covered by the existing FastAPI integration tests. Add unit tests to lock down the expected status codes and that request.state.agent/agent_id are set to None in these modes.
capiscio_sdk/integrations/fastapi.py
Outdated
| except VerificationError as e: | ||
| return JSONResponse({"error": f"Access Denied: {str(e)}"}, status_code=403) | ||
| if self.fail_mode == "log": | ||
| logger.warning(f"Badge verification failed: {e} (log mode)") | ||
| request.state.agent = None | ||
| request.state.agent_id = None | ||
| return await call_next(request) |
There was a problem hiding this comment.
Similarly, the new allow-through behavior on VerificationError when fail_mode is log/monitor isn’t covered by tests. Add cases that assert requests succeed (and state is cleared) when verification fails under these non-blocking modes.
README.md
Outdated
| | `CAPISCIO_REQUIRE_SIGNATURES` | Require badge on requests | `true` | | ||
| | `CAPISCIO_FAIL_MODE` | `block`, `monitor`, or `log` | `block` | | ||
| | `CAPISCIO_MIN_TRUST_LEVEL` | Minimum trust level (0-4) | `0` | | ||
| | `CAPISCIO_RATE_LIMIT_RPM` | Rate limit (requests/min) | `1000` | | ||
|
|
There was a problem hiding this comment.
The documented env-var defaults don’t match the current implementation: SecurityConfig.from_env() defaults CAPISCIO_REQUIRE_SIGNATURES to false, and rate_limit_requests_per_minute defaults to 60 (not 1000). Also, CAPISCIO_MIN_TRUST_LEVEL doesn’t appear to be read anywhere. Please update the table (or implement the missing env var) so docs match runtime behavior.
| | `CAPISCIO_REQUIRE_SIGNATURES` | Require badge on requests | `true` | | |
| | `CAPISCIO_FAIL_MODE` | `block`, `monitor`, or `log` | `block` | | |
| | `CAPISCIO_MIN_TRUST_LEVEL` | Minimum trust level (0-4) | `0` | | |
| | `CAPISCIO_RATE_LIMIT_RPM` | Rate limit (requests/min) | `1000` | | |
| | `CAPISCIO_REQUIRE_SIGNATURES` | Require badge on requests | `false` | | |
| | `CAPISCIO_FAIL_MODE` | `block`, `monitor`, or `log` | `block` | | |
| | `CAPISCIO_RATE_LIMIT_RPM` | Rate limit (requests/min) | `60` | |
| ### Configurable Security Modes | ||
|
|
||
| Control enforcement behavior via environment variables: | ||
|
|
||
| ```python |
There was a problem hiding this comment.
This PR’s description focuses on the CapiscIO.connect(name=...) agent-creation bugfix, but this README section introduces/configures new FastAPI enforcement modes and environment variables. Please update the PR description to reflect these additional user-facing changes, or split the docs/middleware changes into a separate PR for clearer review and release notes.
| for agent in agents: | ||
| if agent.get("name") == self.name: | ||
| return agent | ||
| # Name specified but not found - create new agent with that name | ||
| return self._create_agent() |
There was a problem hiding this comment.
The new behavior (name specified but not found => create a new agent) isn’t covered by the current _ensure_agent unit tests. Add a test where /v1/agents returns a list without the target name and assert _client.post is called and the created agent is returned.
|
✅ Documentation validation passed!
|
|
✅ All checks passed! Ready for review. |
|
✅ Integration tests passed! Server validation, BadgeKeeper, and gRPC tests all working. |
- Update quickstart.md: Add name-based connect path with example - Update configuration.md: Add CAPISCIO_MIN_TRUST_LEVEL env var to table - Three setup paths: Quick Start, Name-Based, UI-First
|
✅ Documentation validation passed!
|
|
✅ All checks passed! Ready for review. |
|
✅ Integration tests passed! Server validation, BadgeKeeper, and gRPC tests all working. |
README.md
Outdated
| config = SecurityConfig.from_env() | ||
|
|
||
| # Or use presets | ||
| config = SecurityConfig.production() # Strict mode (block on failure) |
There was a problem hiding this comment.
SecurityConfig.production() is described here as “Strict mode (block on failure)”, but in code it’s a balanced preset (e.g., downstream.require_signatures=False, strict_mode=False). Consider rewording to avoid implying the strict() preset behavior, and reserve “strict mode” wording for SecurityConfig.strict().
| config = SecurityConfig.production() # Strict mode (block on failure) | |
| config = SecurityConfig.production() # Balanced production defaults (block on verified failures) |
| - config.downstream.require_signatures: If False, allow requests without badges | ||
| - config.fail_mode: "block" returns 401/403, "monitor" logs and allows, "log" just logs |
There was a problem hiding this comment.
The docstring says config.downstream.require_signatures controls whether requests without badges are allowed, but the implementation also allows missing/invalid badges when fail_mode is log/monitor even if require_signatures is True. Update the docstring to reflect the actual precedence between require_signatures and fail_mode to avoid confusion.
| - config.downstream.require_signatures: If False, allow requests without badges | |
| - config.fail_mode: "block" returns 401/403, "monitor" logs and allows, "log" just logs | |
| - config.downstream.require_signatures: | |
| If False, requests without badges are allowed even in "block" mode. | |
| If True, missing/invalid badges are only blocked when fail_mode="block". | |
| - config.fail_mode: | |
| "block" -> enforce badges according to require_signatures (401/403 on missing/invalid badges) | |
| "monitor" -> log missing/invalid badges but allow the request through | |
| "log" -> log missing/invalid badges but allow the request through |
| if self.fail_mode in ("log", "monitor"): | ||
| logger.warning(f"Badge verification failed: {e} ({self.fail_mode} mode)") | ||
| request.state.agent = None | ||
| request.state.agent_id = None |
There was a problem hiding this comment.
In the VerificationError path for fail_mode log/monitor, the request body has already been consumed via await request.body(), but request._receive is only reset on the success path. This means downstream handlers may see an empty body in monitor/log mode. Reset the receive channel (or otherwise re-inject body_bytes) before calling call_next in the exception path as well.
| request.state.agent_id = None | |
| request.state.agent_id = None | |
| # Reset the receive channel so downstream can read the original body | |
| async def receive() -> Dict[str, Any]: | |
| return {"type": "http.request", "body": body_bytes, "more_body": False} | |
| request._receive = receive |
| if not auth_header: | ||
| return JSONResponse( | ||
| {"error": "Missing X-Capiscio-Badge header. This endpoint is protected by CapiscIO."}, | ||
| status_code=401 | ||
| ) | ||
| if not self.require_signatures: | ||
| # No badge required - allow through but mark as unverified | ||
| request.state.agent = None | ||
| request.state.agent_id = None | ||
| return await call_next(request) | ||
|
|
||
| # Badge required but missing | ||
| if self.fail_mode in ("log", "monitor"): | ||
| logger.warning(f"Missing X-Capiscio-Badge header for {request.url.path} ({self.fail_mode} mode)") | ||
| request.state.agent = None | ||
| request.state.agent_id = None | ||
| return await call_next(request) |
There was a problem hiding this comment.
The new config-driven behavior (e.g., require_signatures=False allowing missing badges; fail_mode monitor/log allowing invalid badges) isn’t covered by the existing FastAPI integration tests. Add unit tests exercising these modes to prevent regressions, especially around request body preservation when verification is skipped/failed but the request is allowed through.
|
|
||
| ### Fixed | ||
| - **httpx exception handling**: Network errors now raise `RuntimeError` with sanitized messages | ||
| - **Error message sanitization**: Removed `resp.text` from error messages to prevent API key exposure |
There was a problem hiding this comment.
The changelog entry for 2.4.1 doesn’t mention the behavioral fix in this PR (creating a new agent when name is specified but not found). Add a bullet under ### Fixed describing this change so the release notes reflect the actual patch behavior.
| - **Error message sanitization**: Removed `resp.text` from error messages to prevent API key exposure | |
| - **Error message sanitization**: Removed `resp.text` from error messages to prevent API key exposure | |
| - **Agent creation fallback**: When a `name` is specified but no existing agent is found, a new agent is now created instead of failing the operation |
docs/guides/configuration.md
Outdated
| | `CAPISCIO_VALIDATE_SCHEMA` | bool | `true` | Enable schema validation | | ||
| | `CAPISCIO_VERIFY_SIGNATURES` | bool | `true` | Verify signatures if present | | ||
| | `CAPISCIO_REQUIRE_SIGNATURES` | bool | `false` | Require all messages signed | | ||
| | `CAPISCIO_MIN_TRUST_LEVEL` | int | `0` | Minimum trust level required (0-100) | |
There was a problem hiding this comment.
CAPISCIO_MIN_TRUST_LEVEL is advertised here as a downstream knob (Minimum trust level required (0-100)), but SecurityConfig.from_env() never reads this environment variable and there is no corresponding field in SecurityConfig. Operators who set this believing it enforces a minimum trust level will see no actual change in behavior, which can leave low-trust or self-signed callers accepted when they think they are blocked. Either wire this variable into the enforcement path (e.g., into badge or MCP trust-level checks) or remove/clarify the documentation so it does not suggest a non-existent control.
| | `CAPISCIO_MIN_TRUST_LEVEL` | int | `0` | Minimum trust level required (0-100) | | |
| | `CAPISCIO_MIN_TRUST_LEVEL` | int | `0` | Reserved for future use (not currently enforced by `SecurityConfig.from_env()`) | |
|
✅ Documentation validation passed!
|
|
✅ All checks passed! Ready for review. |
|
✅ Integration tests passed! Server validation, BadgeKeeper, and gRPC tests all working. |
|
✅ Documentation validation passed!
|
|
✅ All checks passed! Ready for review. |
|
✅ Integration tests passed! Server validation, BadgeKeeper, and gRPC tests all working. |
|
✅ Documentation validation passed!
|
|
✅ All checks passed! Ready for review. |
|
✅ Integration tests passed! Server validation, BadgeKeeper, and gRPC tests all working. |
| @@ -11,6 +11,12 @@ | |||
| from ..simple_guard import SimpleGuard | |||
| from ..errors import VerificationError | |||
| import time | |||
| import logging | |||
|
|
|||
| if TYPE_CHECKING: | |||
| from ..config import SecurityConfig | |||
|
|
|||
| logger = logging.getLogger(__name__) | |||
|
|
|||
| class CapiscioMiddleware(BaseHTTPMiddleware): | |||
| """ | |||
| @@ -19,17 +25,28 @@ class CapiscioMiddleware(BaseHTTPMiddleware): | |||
| Args: | |||
| app: The ASGI application. | |||
| guard: SimpleGuard instance for verification. | |||
| config: Optional SecurityConfig to control enforcement behavior. | |||
| exclude_paths: List of paths to skip verification (e.g., ["/health", "/.well-known/agent-card.json"]). | |||
|
|
|||
| Security behavior controlled by SecurityConfig: | |||
| - config.downstream.require_signatures: If False, allow requests without badges | |||
| - config.fail_mode: "block" returns 401/403, "monitor" logs and allows, "log" just logs | |||
| """ | |||
| def __init__( | |||
| self, | |||
| app: ASGIApp, | |||
| guard: SimpleGuard, | |||
| config: Optional["SecurityConfig"] = None, | |||
| exclude_paths: Optional[List[str]] = None | |||
| ) -> None: | |||
| super().__init__(app) | |||
| self.guard = guard | |||
| self.config = config | |||
| self.exclude_paths = exclude_paths or [] | |||
|
|
|||
| # Default to strict mode if no config | |||
| self.require_signatures = config.downstream.require_signatures if config is not None else True | |||
| self.fail_mode = config.fail_mode if config is not None else "block" | |||
|
|
|||
| async def dispatch( | |||
| self, | |||
| @@ -47,13 +64,25 @@ async def dispatch( | |||
| # RFC-002 §9.1: X-Capiscio-Badge header | |||
| auth_header = request.headers.get("X-Capiscio-Badge") | |||
|
|
|||
| # If no header, we might let it pass but mark as unverified? | |||
| # The mandate says: "Returns 401 (missing) or 403 (invalid)." | |||
| # Handle missing badge based on config | |||
| if not auth_header: | |||
| return JSONResponse( | |||
| {"error": "Missing X-Capiscio-Badge header. This endpoint is protected by CapiscIO."}, | |||
| status_code=401 | |||
| ) | |||
| if not self.require_signatures: | |||
| # No badge required - allow through but mark as unverified | |||
| request.state.agent = None | |||
| request.state.agent_id = None | |||
| return await call_next(request) | |||
|
|
|||
| # Badge required but missing | |||
| if self.fail_mode in ("log", "monitor"): | |||
| logger.warning(f"Missing X-Capiscio-Badge header for {request.url.path} ({self.fail_mode} mode)") | |||
| request.state.agent = None | |||
| request.state.agent_id = None | |||
| return await call_next(request) | |||
| else: # block | |||
| return JSONResponse( | |||
| {"error": "Missing X-Capiscio-Badge header. This endpoint is protected by CapiscIO."}, | |||
| status_code=401 | |||
| ) | |||
|
|
|||
| start_time = time.perf_counter() | |||
| try: | |||
| @@ -73,7 +102,13 @@ async def receive() -> Dict[str, Any]: | |||
| request.state.agent_id = payload.get("iss") | |||
|
|
|||
| except VerificationError as e: | |||
| return JSONResponse({"error": f"Access Denied: {str(e)}"}, status_code=403) | |||
| if self.fail_mode in ("log", "monitor"): | |||
| logger.warning(f"Badge verification failed: {e} ({self.fail_mode} mode)") | |||
| request.state.agent = None | |||
| request.state.agent_id = None | |||
| return await call_next(request) | |||
| else: # block | |||
| return JSONResponse({"error": f"Access Denied: {str(e)}"}, status_code=403) | |||
There was a problem hiding this comment.
The PR description states this is "fix(connect): Create agent when name specified but not found", but this file contains extensive changes to SecurityConfig integration with CapiscioMiddleware that are completely unrelated to the connect fix. These changes include:
- Adding SecurityConfig parameter support
- Implementing fail_mode behavior (log, monitor, block)
- Adding require_signatures configuration
- OPTIONS request bypass logic
While these changes may be valuable, they should be in a separate PR focused on SecurityConfig integration. Mixing unrelated features in a single PR makes code review difficult and violates the single responsibility principle for PRs.
| class TestSecurityConfigIntegration: | ||
| """Tests for SecurityConfig integration with middleware.""" | ||
|
|
||
| def test_middleware_accepts_security_config(self): | ||
| """Test that middleware accepts SecurityConfig parameter.""" | ||
| mock_guard = MagicMock() | ||
| mock_guard.agent_id = "test-agent" | ||
|
|
||
| config = SecurityConfig( | ||
| downstream=DownstreamConfig(require_signatures=True), | ||
| fail_mode="block", | ||
| ) | ||
|
|
||
| app = FastAPI() | ||
| # Should not raise | ||
| app.add_middleware( | ||
| CapiscioMiddleware, | ||
| guard=mock_guard, | ||
| config=config, | ||
| ) | ||
|
|
||
| @app.get("/test") | ||
| async def test_endpoint(): | ||
| return {"ok": True} | ||
|
|
||
| client = TestClient(app) | ||
| # Verify middleware is installed by checking 401 on missing header | ||
| response = client.get("/test") | ||
| assert response.status_code == 401 | ||
|
|
||
| def test_middleware_fail_mode_log(self): | ||
| """Test fail_mode='log' allows request on verification failure.""" | ||
| mock_guard = MagicMock() | ||
| mock_guard.agent_id = "test-agent" | ||
| mock_guard.verify_inbound.side_effect = VerificationError("Invalid badge") | ||
|
|
||
| config = SecurityConfig( | ||
| downstream=DownstreamConfig(require_signatures=True), | ||
| fail_mode="log", # Log-only mode | ||
| ) | ||
|
|
||
| app = FastAPI() | ||
| app.add_middleware( | ||
| CapiscioMiddleware, | ||
| guard=mock_guard, | ||
| config=config, | ||
| ) | ||
|
|
||
| @app.post("/test") | ||
| async def test_endpoint(): | ||
| return {"allowed": True} | ||
|
|
||
| client = TestClient(app) | ||
| headers = {"X-Capiscio-Badge": "invalid.badge.token"} | ||
| response = client.post("/test", json={}, headers=headers) | ||
|
|
||
| # In log mode, should allow the request even though verification failed | ||
| assert response.status_code == 200 | ||
| assert response.json()["allowed"] is True | ||
|
|
||
| def test_middleware_fail_mode_monitor(self): | ||
| """Test fail_mode='monitor' allows request on verification failure.""" | ||
| mock_guard = MagicMock() | ||
| mock_guard.agent_id = "test-agent" | ||
| mock_guard.verify_inbound.side_effect = VerificationError("Invalid badge") | ||
|
|
||
| config = SecurityConfig( | ||
| downstream=DownstreamConfig(require_signatures=True), | ||
| fail_mode="monitor", # Monitor mode - same as log | ||
| ) | ||
|
|
||
| app = FastAPI() | ||
| app.add_middleware( | ||
| CapiscioMiddleware, | ||
| guard=mock_guard, | ||
| config=config, | ||
| ) | ||
|
|
||
| @app.post("/test") | ||
| async def test_endpoint(): | ||
| return {"allowed": True} | ||
|
|
||
| client = TestClient(app) | ||
| headers = {"X-Capiscio-Badge": "invalid.badge.token"} | ||
| response = client.post("/test", json={}, headers=headers) | ||
|
|
||
| # In monitor mode, should allow the request even though verification failed | ||
| assert response.status_code == 200 | ||
| assert response.json()["allowed"] is True | ||
|
|
||
| def test_middleware_fail_mode_block(self): | ||
| """Test fail_mode='block' blocks request on verification failure.""" | ||
| mock_guard = MagicMock() | ||
| mock_guard.agent_id = "test-agent" | ||
| mock_guard.verify_inbound.side_effect = VerificationError("Invalid badge") | ||
|
|
||
| config = SecurityConfig( | ||
| downstream=DownstreamConfig(require_signatures=True), | ||
| fail_mode="block", # Block mode | ||
| ) | ||
|
|
||
| app = FastAPI() | ||
| app.add_middleware( | ||
| CapiscioMiddleware, | ||
| guard=mock_guard, | ||
| config=config, | ||
| ) | ||
|
|
||
| @app.post("/test") | ||
| async def test_endpoint(): | ||
| return {"allowed": True} | ||
|
|
||
| client = TestClient(app) | ||
| headers = {"X-Capiscio-Badge": "invalid.badge.token"} | ||
| response = client.post("/test", json={}, headers=headers) | ||
|
|
||
| # In block mode, should block the request | ||
| assert response.status_code == 403 | ||
| assert "Access Denied" in response.json()["error"] | ||
|
|
||
| def test_middleware_config_none_uses_defaults(self): | ||
| """Test that config=None uses default strict behavior.""" | ||
| mock_guard = MagicMock() | ||
| mock_guard.agent_id = "test-agent" | ||
| mock_guard.verify_inbound.side_effect = VerificationError("Invalid badge") | ||
|
|
||
| app = FastAPI() | ||
| app.add_middleware( | ||
| CapiscioMiddleware, | ||
| guard=mock_guard, | ||
| config=None, # Explicit None | ||
| ) | ||
|
|
||
| @app.post("/test") | ||
| async def test_endpoint(): | ||
| return {"allowed": True} | ||
|
|
||
| client = TestClient(app) | ||
| headers = {"X-Capiscio-Badge": "invalid.badge.token"} | ||
| response = client.post("/test", json={}, headers=headers) | ||
|
|
||
| # Default should be strict mode | ||
| assert response.status_code == 403 | ||
|
|
||
| def test_security_config_from_env(self): | ||
| """Test SecurityConfig.from_env() reads environment variables.""" | ||
| with patch.dict('os.environ', { | ||
| 'CAPISCIO_REQUIRE_SIGNATURES': 'true', | ||
| 'CAPISCIO_FAIL_MODE': 'monitor', | ||
| 'CAPISCIO_RATE_LIMIT_RPM': '100', | ||
| }): | ||
| config = SecurityConfig.from_env() | ||
|
|
||
| assert config.downstream.require_signatures is True | ||
| assert config.fail_mode == "monitor" | ||
| assert config.downstream.rate_limit_requests_per_minute == 100 | ||
|
|
||
| def test_middleware_options_bypass(self): | ||
| """Test that OPTIONS requests bypass verification (CORS preflight).""" | ||
| mock_guard = MagicMock() | ||
| mock_guard.agent_id = "test-agent" | ||
|
|
||
| app = FastAPI() | ||
| app.add_middleware( | ||
| CapiscioMiddleware, | ||
| guard=mock_guard, | ||
| ) | ||
|
|
||
| @app.api_route("/test", methods=["OPTIONS", "POST"]) | ||
| async def test_endpoint(): | ||
| return {"ok": True} | ||
|
|
||
| client = TestClient(app) | ||
| # OPTIONS should pass without header | ||
| response = client.options("/test") | ||
| assert response.status_code == 200 | ||
| # verify_inbound should NOT have been called | ||
| mock_guard.verify_inbound.assert_not_called() | ||
|
|
||
| def test_middleware_require_signatures_false(self): | ||
| """Test require_signatures=False allows requests without badge.""" | ||
| mock_guard = MagicMock() | ||
| mock_guard.agent_id = "test-agent" | ||
|
|
||
| config = SecurityConfig( | ||
| downstream=DownstreamConfig(require_signatures=False), # No badge required | ||
| fail_mode="block", | ||
| ) | ||
|
|
||
| app = FastAPI() | ||
| app.add_middleware( | ||
| CapiscioMiddleware, | ||
| guard=mock_guard, | ||
| config=config, | ||
| ) | ||
|
|
||
| @app.post("/test") | ||
| async def test_endpoint(request: Request): | ||
| # Should have None for agent info | ||
| return { | ||
| "agent": getattr(request.state, 'agent', 'not-set'), | ||
| "agent_id": getattr(request.state, 'agent_id', 'not-set'), | ||
| } | ||
|
|
||
| client = TestClient(app) | ||
| # Request WITHOUT badge header should pass | ||
| response = client.post("/test", json={}) | ||
| assert response.status_code == 200 | ||
| data = response.json() | ||
| assert data["agent"] is None | ||
| assert data["agent_id"] is None | ||
| # verify_inbound should NOT have been called | ||
| mock_guard.verify_inbound.assert_not_called() | ||
|
|
||
| def test_middleware_missing_badge_log_mode(self): | ||
| """Test missing badge with fail_mode='log' allows request through.""" | ||
| mock_guard = MagicMock() | ||
| mock_guard.agent_id = "test-agent" | ||
|
|
||
| config = SecurityConfig( | ||
| downstream=DownstreamConfig(require_signatures=True), # Badge required | ||
| fail_mode="log", # But just log failures | ||
| ) | ||
|
|
||
| app = FastAPI() | ||
| app.add_middleware( | ||
| CapiscioMiddleware, | ||
| guard=mock_guard, | ||
| config=config, | ||
| ) | ||
|
|
||
| @app.post("/test") | ||
| async def test_endpoint(request: Request): | ||
| return { | ||
| "agent": getattr(request.state, 'agent', 'not-set'), | ||
| "agent_id": getattr(request.state, 'agent_id', 'not-set'), | ||
| } | ||
|
|
||
| client = TestClient(app) | ||
| # Request WITHOUT badge header - should pass in log mode | ||
| response = client.post("/test", json={}) | ||
| assert response.status_code == 200 | ||
| data = response.json() | ||
| assert data["agent"] is None | ||
| assert data["agent_id"] is None | ||
|
|
||
| def test_middleware_missing_badge_monitor_mode(self): | ||
| """Test missing badge with fail_mode='monitor' allows request through.""" | ||
| mock_guard = MagicMock() | ||
| mock_guard.agent_id = "test-agent" | ||
|
|
||
| config = SecurityConfig( | ||
| downstream=DownstreamConfig(require_signatures=True), # Badge required | ||
| fail_mode="monitor", # But just monitor failures | ||
| ) | ||
|
|
||
| app = FastAPI() | ||
| app.add_middleware( | ||
| CapiscioMiddleware, | ||
| guard=mock_guard, | ||
| config=config, | ||
| ) | ||
|
|
||
| @app.post("/test") | ||
| async def test_endpoint(request: Request): | ||
| return { | ||
| "agent": getattr(request.state, 'agent', 'not-set'), | ||
| "agent_id": getattr(request.state, 'agent_id', 'not-set'), | ||
| } | ||
|
|
||
| client = TestClient(app) | ||
| # Request WITHOUT badge header - should pass in monitor mode | ||
| response = client.post("/test", json={}) | ||
| assert response.status_code == 200 | ||
| data = response.json() | ||
| assert data["agent"] is None | ||
| assert data["agent_id"] is None |
There was a problem hiding this comment.
These extensive tests for SecurityConfig integration are unrelated to the PR's stated purpose of fixing the connect() agent name bug. The PR should focus solely on testing the connect fix, not on adding 130+ lines of SecurityConfig middleware tests.
| ### Configurable Security Modes | ||
|
|
||
| Control enforcement behavior via environment variables: | ||
|
|
||
| ```python | ||
| from capiscio_sdk.config import SecurityConfig | ||
| from capiscio_sdk.integrations.fastapi import CapiscioMiddleware | ||
|
|
||
| # Load config from environment variables | ||
| config = SecurityConfig.from_env() | ||
|
|
||
| # Or use presets | ||
| config = SecurityConfig.production() # Strict mode (block on failure) | ||
| config = SecurityConfig.development() # Monitor mode (log but allow) | ||
|
|
||
| app.add_middleware(CapiscioMiddleware, guard=guard, config=config) | ||
| ``` | ||
|
|
||
| **Environment Variables:** | ||
|
|
||
| | Variable | Description | Default | | ||
| |----------|-------------|--------| | ||
| | `CAPISCIO_REQUIRE_SIGNATURES` | Require badge on requests | `true` | | ||
| | `CAPISCIO_FAIL_MODE` | `block`, `monitor`, or `log` | `block` | | ||
| | `CAPISCIO_MIN_TRUST_LEVEL` | Minimum trust level (0-4) | `0` | | ||
| | `CAPISCIO_RATE_LIMIT_RPM` | Rate limit (requests/min) | `1000` | | ||
|
|
There was a problem hiding this comment.
This section documents SecurityConfig features that are unrelated to the PR's stated purpose of fixing the connect() name bug. This documentation should be in a separate PR focused on the SecurityConfig middleware integration.
- Add agent name lookup bugfix to CHANGELOG - Fix request body preservation in VerificationError log/monitor path - Fix README env var defaults to match implementation - Mark CAPISCIO_MIN_TRUST_LEVEL as reserved in docs
|
✅ Documentation validation passed!
|
|
✅ All checks passed! Ready for review. |
|
✅ Integration tests passed! Server validation, BadgeKeeper, and gRPC tests all working. |
Summary
Fixes a bug where
CapiscIO.connect(name="MyAgent")would reuse an existing agent instead of creating a new one when no agent with that name exists.Problem
Previously, if a name was specified but no matching agent existed, the SDK would fall back to using the first existing agent. This caused all demo agents to share the same registry entry.
Solution
Testing
Verified locally with a2a-demos - new agents now register correctly: