Skip to content
Merged
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### 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
- **Agent name lookup**: When `name` is specified but no agent matches, a new agent is now created (previously fell back to first existing agent)

## [2.4.0] - 2026-01-18

Expand Down
84 changes: 84 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,32 @@ async def handle_task(request: Request):
return {"status": "accepted", "verified_caller": caller}
```

### Configurable Security Modes

Control enforcement behavior via environment variables:

```python
Comment on lines +35 to +39
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.
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() # Balanced production defaults (block on verified failures)
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 | `false` |
| `CAPISCIO_FAIL_MODE` | `block`, `monitor`, or `log` | `block` |
| `CAPISCIO_RATE_LIMIT_RPM` | Rate limit (requests/min) | `60` |

Comment on lines 35 to 60
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.
## 🛡️ What You Get (Out of the Box)

1. **Zero-Config Identity**:
Expand All @@ -56,6 +82,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`)

Comment on lines 35 to 142
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.
## 🎯 Agent Card Validation with CoreValidator

The SDK includes a **Go core-backed validator** for Agent Card validation. This ensures consistent validation behavior across all CapiscIO SDKs (Python, Node.js, etc.).
Expand Down
6 changes: 4 additions & 2 deletions capiscio_sdk/connect.py
Original file line number Diff line number Diff line change
Expand Up @@ -312,12 +312,14 @@ def _ensure_agent(self) -> Dict[str, Any]:
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.
return self._create_agent()
Comment on lines +315 to +316
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 312 to +316
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.

# Use first agent if available
# No name specified - use first agent if available
if agents:
return agents[0]

# Create new agent
# No agents exist - create new agent
return self._create_agent()

def _create_agent(self) -> Dict[str, Any]:
Expand Down
55 changes: 47 additions & 8 deletions capiscio_sdk/integrations/fastapi.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"""FastAPI integration for Capiscio SimpleGuard."""
from typing import Callable, Awaitable, Any, Dict, List, Optional
from typing import Callable, Awaitable, Any, Dict, List, Optional, TYPE_CHECKING
try:
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
Expand All @@ -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):
"""
Expand All @@ -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
Comment on lines +32 to +33
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.
"""
def __init__(
self,
app: ASGIApp,
guard: SimpleGuard,
config: Optional["SecurityConfig"] = None,
exclude_paths: Optional[List[str]] = None
Comment on lines 36 to 40
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.
) -> 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,
Expand All @@ -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
Comment on lines +67 to +71
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.
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)
Comment on lines 68 to +80
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.
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:
Expand All @@ -73,7 +102,17 @@ 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
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.
# Reset receive so downstream can read body (body_bytes was consumed above)
async def receive() -> Dict[str, Any]:
return {"type": "http.request", "body": body_bytes, "more_body": False}
request._receive = receive
return await call_next(request)
else: # block
return JSONResponse({"error": f"Access Denied: {str(e)}"}, status_code=403)
Comment on lines 39 to 115
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 2 to 115
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.
Comment on lines 2 to 115
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.

verification_duration = (time.perf_counter() - start_time) * 1000

Expand Down
22 changes: 21 additions & 1 deletion docs/getting-started/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,33 @@ from capiscio_sdk import CapiscIO
agent = CapiscIO.from_env()
```

### Two Setup Paths
### Three Setup Paths

| Path | When to Use | Code |
|------|-------------|------|
| **Quick Start** | Getting started, single agent | `CapiscIO.connect(api_key="...")` |
| **Name-Based** | Named agents, easier lookups | `CapiscIO.connect(api_key="...", name="my-support-agent")` |
| **UI-First** | Teams, multiple agents | `CapiscIO.connect(api_key="...", agent_id="agt_123")` |

#### Name-Based Connect (New!)

Use `name` to connect by agent name instead of ID. If the name doesn't exist, a new agent is created:

```python
from capiscio_sdk import CapiscIO

# Connects to existing "my-agent" or creates it
agent = CapiscIO.connect(api_key="sk_live_...", name="my-support-agent")

print(agent.agent_id) # Agent ID (created or found)
print(agent.did) # Agent DID
```

**Benefits:**
- No need to store agent IDs in config
- Idempotent - safe to call multiple times
- Works with environment-based setups

---

## Step 2: Secure Your Agent (Optional but Recommended)
Expand Down
1 change: 1 addition & 0 deletions docs/guides/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -575,6 +575,7 @@ Load configuration from environment variables using `SecurityConfig.from_env()`.
| `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` | Reserved for future use (not currently enforced) |
| `CAPISCIO_RATE_LIMITING` | bool | `true` | Enable rate limiting |
| `CAPISCIO_RATE_LIMIT_RPM` | int | `60` | Requests per minute limit |

Expand Down
42 changes: 42 additions & 0 deletions tests/unit/test_connect.py
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,48 @@ def test_ensure_agent_creates_when_empty(self):
assert result == {"id": "new-id", "name": "New"}
connector._client.post.assert_called_once()

def test_ensure_agent_creates_when_name_not_found(self):
"""Test _ensure_agent creates agent when name specified but not found.

This is the key "Let's Encrypt" behavior: if the user specifies a name
and no agent with that name exists, we create one with that name.
"""
connector = _Connector(
api_key="sk_test",
name="my-new-agent", # Specified name
agent_id=None,
server_url="https://test.server.com",
keys_dir=None,
auto_badge=True,
dev_mode=False,
)

# Agents exist but none match the specified name
list_response = MagicMock()
list_response.status_code = 200
list_response.json.return_value = {
"data": [
{"id": "agent-1", "name": "other-agent"},
{"id": "agent-2", "name": "another-agent"},
]
}

create_response = MagicMock()
create_response.status_code = 201
create_response.json.return_value = {"data": {"id": "new-id", "name": "my-new-agent"}}

connector._client.get = MagicMock(return_value=list_response)
connector._client.post = MagicMock(return_value=create_response)

result = connector._ensure_agent()

# Should create new agent, NOT return agents[0]
assert result == {"id": "new-id", "name": "my-new-agent"}
connector._client.post.assert_called_once()
# Verify the name was passed to create
call_args = connector._client.post.call_args
assert call_args[1]["json"]["name"] == "my-new-agent"

def test_create_agent_generates_name(self):
"""Test _create_agent generates name when not provided."""
connector = _Connector(
Expand Down
Loading
Loading