Skip to content

Comments

fix(connect): Create agent when name specified but not found#29

Merged
beonde merged 10 commits intomainfrom
fix/connect-agent-name-matching
Feb 17, 2026
Merged

fix(connect): Create agent when name specified but not found#29
beonde merged 10 commits intomainfrom
fix/connect-agent-name-matching

Conversation

@beonde
Copy link
Member

@beonde beonde commented Feb 16, 2026

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

  • If name IS specified and found → reuse that agent (unchanged)
  • If name IS specified and NOT found → create new agent with that name (FIX)
  • If name NOT specified and agents exist → use first agent (backward compat)
  • If no agents exist → create new agent (unchanged)

Testing

Verified locally with a2a-demos - new agents now register correctly:

Created new agent: LangChain Research Agent
Agent: LangChain Research Agent (154efc73-2a61-49ce-9688-120fc66eb5f0)

## [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
Copilot AI review requested due to automatic review settings February 16, 2026 17:16
@github-actions
Copy link

✅ Documentation validation passed!

Unified docs will be deployed from capiscio-docs repo.

@github-actions
Copy link

✅ All checks passed! Ready for review.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 when name is provided but not found in the registry agent list.
  • Bump package version to 2.4.1 in pyproject.toml and capiscio_sdk.__version__.
  • Add a 2.4.1 section to CHANGELOG.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
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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]).

Suggested change
# 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

Copilot uses AI. Check for mistakes.
Comment on lines 10 to 29
## [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
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
@codecov
Copy link

codecov bot commented Feb 16, 2026

Codecov Report

❌ Patch coverage is 96.15385% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
capiscio_sdk/integrations/fastapi.py 96.00% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@github-actions
Copy link

✅ 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
@github-actions
Copy link

✅ Documentation validation passed!

Unified docs will be deployed from capiscio-docs repo.

@github-actions
Copy link

✅ All checks passed! Ready for review.

@github-actions
Copy link

✅ 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
Copilot AI review requested due to automatic review settings February 16, 2026 22:12
@github-actions
Copy link

✅ Documentation validation passed!

Unified docs will be deployed from capiscio-docs repo.

@github-actions
Copy link

✅ All checks passed! Ready for review.

@github-actions
Copy link

✅ Integration tests passed! Server validation, BadgeKeeper, and gRPC tests all working.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 7 comments.

Comment on lines +315 to +316
# Name specified but not found - create new agent with that name
return self._create_agent()
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines 39 to 121
@@ -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)
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines 48 to 49
self.require_signatures = config.downstream.require_signatures if config else True
self.fail_mode = config.fail_mode if config else "block"
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Suggested change
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"

Copilot uses AI. Check for mistakes.
Comment on lines 76 to 119
@@ -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)
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

## [Unreleased]

## [2.4.1] - 2026-02-08
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)".

Copilot uses AI. Check for mistakes.
Comment on lines 35 to 143
@@ -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`)

Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines 2 to 121
@@ -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)
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
- 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
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 6 comments.

Comment on lines 36 to 40
self,
app: ASGIApp,
guard: SimpleGuard,
config: Optional["SecurityConfig"] = None,
exclude_paths: Optional[List[str]] = None
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +67 to +71
# 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
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines 109 to 114
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)
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
README.md Outdated
Comment on lines 57 to 61
| `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` |

Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
| `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` |

Copilot uses AI. Check for mistakes.
Comment on lines +35 to +39
### Configurable Security Modes

Control enforcement behavior via environment variables:

```python
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines 312 to +316
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()
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
@github-actions
Copy link

✅ Documentation validation passed!

Unified docs will be deployed from capiscio-docs repo.

@github-actions
Copy link

✅ All checks passed! Ready for review.

@github-actions
Copy link

✅ 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
Copilot AI review requested due to automatic review settings February 16, 2026 22:50
@github-actions
Copy link

✅ Documentation validation passed!

Unified docs will be deployed from capiscio-docs repo.

@github-actions
Copy link

✅ All checks passed! Ready for review.

@github-actions
Copy link

✅ Integration tests passed! Server validation, BadgeKeeper, and gRPC tests all working.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 6 comments.

README.md Outdated
config = SecurityConfig.from_env()

# Or use presets
config = SecurityConfig.production() # Strict mode (block on failure)
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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().

Suggested change
config = SecurityConfig.production() # Strict mode (block on failure)
config = SecurityConfig.production() # Balanced production defaults (block on verified failures)

Copilot uses AI. Check for mistakes.
Comment on lines +32 to +33
- config.downstream.require_signatures: If False, allow requests without badges
- config.fail_mode: "block" returns 401/403, "monitor" logs and allows, "log" just logs
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
- 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

Copilot uses AI. Check for mistakes.
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
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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

Copilot uses AI. Check for mistakes.
Comment on lines 68 to +80
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)
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

### 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
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
- **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

Copilot uses AI. Check for mistakes.
| `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) |
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
| `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()`) |

Copilot uses AI. Check for mistakes.
@github-actions
Copy link

✅ Documentation validation passed!

Unified docs will be deployed from capiscio-docs repo.

@github-actions
Copy link

✅ All checks passed! Ready for review.

@github-actions
Copy link

✅ Integration tests passed! Server validation, BadgeKeeper, and gRPC tests all working.

@github-actions
Copy link

✅ Documentation validation passed!

Unified docs will be deployed from capiscio-docs repo.

@github-actions
Copy link

✅ All checks passed! Ready for review.

@github-actions
Copy link

✅ Integration tests passed! Server validation, BadgeKeeper, and gRPC tests all working.

Copilot AI review requested due to automatic review settings February 17, 2026 16:47
@github-actions
Copy link

✅ Documentation validation passed!

Unified docs will be deployed from capiscio-docs repo.

@github-actions
Copy link

✅ All checks passed! Ready for review.

@github-actions
Copy link

✅ Integration tests passed! Server validation, BadgeKeeper, and gRPC tests all working.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.

Comment on lines 2 to 111
@@ -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)
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Adding SecurityConfig parameter support
  2. Implementing fail_mode behavior (log, monitor, block)
  3. Adding require_signatures configuration
  4. 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.

Copilot uses AI. Check for mistakes.
Comment on lines +151 to +427
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
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines 35 to 61
### 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` |

Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
- 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
@github-actions
Copy link

✅ Documentation validation passed!

Unified docs will be deployed from capiscio-docs repo.

@github-actions
Copy link

✅ All checks passed! Ready for review.

@github-actions
Copy link

✅ Integration tests passed! Server validation, BadgeKeeper, and gRPC tests all working.

@beonde beonde merged commit cfa68ac into main Feb 17, 2026
13 checks passed
@beonde beonde deleted the fix/connect-agent-name-matching branch February 17, 2026 20:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant