Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Binary caching in `~/.capiscio/bin/` directory
- Automatic executable permissions for Unix-like systems
- Fallback search order: `CAPISCIO_BINARY` env var → local development path → system PATH → cached binary → auto-download
- **Middleware Auto-Events**: `CapiscioMiddleware` now supports automatic event emission
- Opt-in via `emitter` parameter — pass an `EventEmitter` to enable
- Emits `request.received`, `verification.success`/`verification.failed`, and `request.completed` events
- Standardized fields: `method`, `path`, `caller_did`, `duration_ms`, `status_code`
- Safe by design: emitter errors never break request handling
- Excluded paths emit no events
- New event type constants: `EVENT_REQUEST_RECEIVED`, `EVENT_REQUEST_COMPLETED`, `EVENT_REQUEST_FAILED`, `EVENT_VERIFICATION_SUCCESS`, `EVENT_VERIFICATION_FAILED`
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

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

The changelog lists EVENT_REQUEST_FAILED as a new constant, but it isn’t defined in capiscio_sdk.events (and no corresponding middleware event type appears to be emitted). Please either add/emit this event type or remove it from the changelog entry to keep release notes accurate.

Suggested change
- New event type constants: `EVENT_REQUEST_RECEIVED`, `EVENT_REQUEST_COMPLETED`, `EVENT_REQUEST_FAILED`, `EVENT_VERIFICATION_SUCCESS`, `EVENT_VERIFICATION_FAILED`
- New event type constants: `EVENT_REQUEST_RECEIVED`, `EVENT_REQUEST_COMPLETED`, `EVENT_VERIFICATION_SUCCESS`, `EVENT_VERIFICATION_FAILED`

Copilot uses AI. Check for mistakes.

### Changed
- **Improved Process Management**: Enhanced error logging and binary discovery
Expand Down
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,30 @@ app.add_middleware(CapiscioMiddleware, guard=guard, config=config)
| `CAPISCIO_FAIL_MODE` | `block`, `monitor`, or `log` | `block` |
| `CAPISCIO_RATE_LIMIT_RPM` | Rate limit (requests/min) | `60` |

### Middleware Observability (Auto-Events)

Enable automatic event emission from the middleware to get visibility into request patterns, verification outcomes, and latency — no manual instrumentation required.

```python
from capiscio_sdk.events import EventEmitter
from capiscio_sdk.integrations.fastapi import CapiscioMiddleware

emitter = EventEmitter(agent_id="...", api_key="...", registry_url="...")
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

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

The README example uses EventEmitter(..., registry_url="..."), but the initializer parameter is server_url. As written, this snippet will raise TypeError when run; please update the argument name (or support registry_url as an alias).

Suggested change
emitter = EventEmitter(agent_id="...", api_key="...", registry_url="...")
emitter = EventEmitter(agent_id="...", api_key="...", server_url="...")

Copilot uses AI. Check for mistakes.
app.add_middleware(CapiscioMiddleware, guard=guard, emitter=emitter)
# Events flow automatically — no other code changes needed
```

The middleware emits these events when an `emitter` is provided:

| Event | When | Key Fields |
|-------|------|------------|
| `request.received` | Every inbound request | `method`, `path` |
| `verification.success` | Badge verified | `method`, `path`, `caller_did`, `duration_ms` |
| `verification.failed` | Badge missing/invalid | `method`, `path`, `reason`, `duration_ms` |
| `request.completed` | Response sent | `method`, `path`, `status_code`, `duration_ms`, `caller_did` |

**Privacy note:** Auto-events are strictly opt-in. No telemetry is sent unless you explicitly pass an `emitter`. Excluded paths emit no events.

## 🛡️ What You Get (Out of the Box)

1. **Zero-Config Identity**:
Expand Down
6 changes: 6 additions & 0 deletions capiscio_sdk/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ class EventEmitter:
EVENT_ERROR = "error"
EVENT_WARNING = "warning"
EVENT_INFO = "info"

# Middleware auto-event types (emitted automatically by CapiscioMiddleware)
EVENT_REQUEST_RECEIVED = "request.received"
EVENT_REQUEST_COMPLETED = "request.completed"
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

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

Docs/PR metadata refer to a EVENT_REQUEST_FAILED constant, but the code only defines EVENT_REQUEST_RECEIVED, EVENT_REQUEST_COMPLETED, EVENT_VERIFICATION_SUCCESS, and EVENT_VERIFICATION_FAILED. Either add the missing constant (and ensure it’s actually emitted where appropriate) or remove references to it from docs/changelog to avoid advertising a nonexistent API surface.

Suggested change
EVENT_REQUEST_COMPLETED = "request.completed"
EVENT_REQUEST_COMPLETED = "request.completed"
EVENT_REQUEST_FAILED = "request.failed"

Copilot uses AI. Check for mistakes.
EVENT_VERIFICATION_SUCCESS = "verification.success"
EVENT_VERIFICATION_FAILED = "verification.failed"

def __init__(
self,
Expand Down
92 changes: 84 additions & 8 deletions capiscio_sdk/integrations/fastapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

from ..simple_guard import SimpleGuard
from ..errors import VerificationError
from ..events import EventEmitter
import time
import logging

Expand All @@ -27,6 +28,10 @@ class CapiscioMiddleware(BaseHTTPMiddleware):
guard: SimpleGuard instance for verification.
exclude_paths: List of paths to skip verification (e.g., ["/health", "/.well-known/agent-card.json"]).
config: Optional SecurityConfig to control enforcement behavior.
emitter: Optional EventEmitter for auto-event emission. When provided,
the middleware automatically emits request.received, verification.success,
and verification.failed events. request.completed is emitted for requests
that reach the downstream ASGI app; blocked 4xx responses do not emit it.

Security behavior:
- If config is None, defaults to strict blocking mode
Expand All @@ -40,18 +45,20 @@ def __init__(
guard: SimpleGuard,
exclude_paths: Optional[List[str]] = None,
*, # Force config to be keyword-only
config: Optional["SecurityConfig"] = None
config: Optional["SecurityConfig"] = None,
emitter: Optional[EventEmitter] = None,
) -> None:
super().__init__(app)
self.guard = guard
self.config = config
self.exclude_paths = exclude_paths or []
self._emitter = emitter

# 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"

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

async def dispatch(
self,
Expand All @@ -69,6 +76,14 @@ async def dispatch(
logger.debug(f"CapiscioMiddleware: SKIPPING verification for {path}")
return await call_next(request)

request_start = time.perf_counter()

# Auto-event: request.received
self._auto_emit(EventEmitter.EVENT_REQUEST_RECEIVED, {
"method": request.method,
"path": path,
})

# RFC-002 §9.1: X-Capiscio-Badge header
auth_header = request.headers.get("X-Capiscio-Badge")

Expand All @@ -78,21 +93,37 @@ async def dispatch(
# No badge required - allow through but mark as unverified
request.state.agent = None
request.state.agent_id = None
return await call_next(request)
response = await call_next(request)
self._auto_emit_completed(request, response, request_start)
return response

# 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)")
self._auto_emit(EventEmitter.EVENT_VERIFICATION_FAILED, {
"method": request.method,
"path": path,
"reason": "missing_badge",
"duration_ms": round((time.perf_counter() - request_start) * 1000, 2),
})
request.state.agent = None
request.state.agent_id = None
return await call_next(request)
response = await call_next(request)
Comment on lines 101 to +111
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

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

In the missing-badge branch for fail_mode = "log"/"monitor", the middleware currently emits request.received and request.completed but does not emit verification.failed. This conflicts with the PR description that verification.failed should be emitted when the badge is missing; consider emitting it here as well (without blocking).

Copilot uses AI. Check for mistakes.
self._auto_emit_completed(request, response, request_start)
return response
else: # block
self._auto_emit(EventEmitter.EVENT_VERIFICATION_FAILED, {
"method": request.method,
"path": path,
"reason": "missing_badge",
"duration_ms": round((time.perf_counter() - request_start) * 1000, 2),
})
Comment on lines +115 to +120
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

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

The verification.failed auto-event emitted for a blocked missing-badge request does not include duration_ms, but the PR description lists duration_ms as a key field for verification failures. Consider including a duration (e.g., from request_start).

Copilot uses AI. Check for mistakes.
return JSONResponse(
{"error": "Missing X-Capiscio-Badge header. This endpoint is protected by CapiscIO."},
status_code=401
)

start_time = time.perf_counter()
verify_start = time.perf_counter()
try:
# Read the body for integrity check
body_bytes = await request.body()
Expand All @@ -108,8 +139,28 @@ async def receive() -> Dict[str, Any]:
# Inject claims into request.state
request.state.agent = payload
request.state.agent_id = payload.get("iss")

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

# Auto-event: verification.success
self._auto_emit(EventEmitter.EVENT_VERIFICATION_SUCCESS, {
"method": request.method,
"path": path,
"caller_did": payload.get("iss"),
"duration_ms": round(verification_duration, 2),
})

except VerificationError as e:
verification_duration = (time.perf_counter() - verify_start) * 1000

# Auto-event: verification.failed
self._auto_emit(EventEmitter.EVENT_VERIFICATION_FAILED, {
"method": request.method,
"path": path,
"reason": str(e),
"duration_ms": round(verification_duration, 2),
})

if self.fail_mode in ("log", "monitor"):
logger.warning(f"Badge verification failed: {e} ({self.fail_mode} mode)")
request.state.agent = None
Expand All @@ -118,16 +169,41 @@ async def receive() -> Dict[str, Any]:
async def receive() -> Dict[str, Any]:
return {"type": "http.request", "body": body_bytes, "more_body": False}
request._receive = receive
return await call_next(request)
response = await call_next(request)
self._auto_emit_completed(request, response, request_start)
return response
else: # block
return JSONResponse({"error": f"Access Denied: {str(e)}"}, status_code=403)

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

response = await call_next(request)

# Add Server-Timing header (standard for performance metrics)
# Syntax: metric_name;dur=123.4;desc="Description"
response.headers["Server-Timing"] = f"capiscio-auth;dur={verification_duration:.3f};desc=\"CapiscIO Verification\""

# Auto-event: request.completed
self._auto_emit_completed(request, response, request_start)

return response

def _auto_emit(self, event_type: str, data: Dict[str, Any]) -> None:
"""Emit an auto-event if emitter is configured."""
if self._emitter:
try:
self._emitter.emit(event_type, data)
except Exception:
logger.debug(f"Failed to emit auto-event {event_type}", exc_info=True)
Comment on lines +189 to +195
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

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

_auto_emit() calls EventEmitter.emit() synchronously from the async middleware. EventEmitter.emit() can trigger a flush, which performs a blocking httpx.Client.post() and can therefore block the event loop and add latency to requests. Consider offloading emission/flush to a background thread/queue (or using an async emitter) so middleware request handling isn’t impacted by network I/O.

Copilot uses AI. Check for mistakes.

def _auto_emit_completed(
self, request: Request, response: Response, start_time: float
) -> None:
"""Emit request.completed auto-event."""
if self._emitter:
duration_ms = (time.perf_counter() - start_time) * 1000
self._auto_emit(EventEmitter.EVENT_REQUEST_COMPLETED, {
"method": request.method,
"path": request.url.path,
"status_code": response.status_code,
"duration_ms": round(duration_ms, 2),
"caller_did": getattr(request.state, "agent_id", None),
})
18 changes: 18 additions & 0 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,24 @@ This section provides detailed API documentation for all public modules in the C
options:
show_root_heading: false

## Events

::: capiscio_sdk.events
options:
members:
- EventEmitter
- EVENT_TASK_STARTED
- EVENT_TASK_COMPLETED
- EVENT_TASK_FAILED
- EVENT_TOOL_CALL
- EVENT_TOOL_RESULT
- EVENT_REQUEST_RECEIVED
- EVENT_REQUEST_COMPLETED
- EVENT_REQUEST_FAILED
- EVENT_VERIFICATION_SUCCESS
- EVENT_VERIFICATION_FAILED
Comment on lines +210 to +219
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

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

docs/api-reference.md lists EVENT_TASK_STARTED, EVENT_TOOL_CALL, EVENT_REQUEST_FAILED, etc. as module members of capiscio_sdk.events, but these are currently class attributes on EventEmitter (and EVENT_REQUEST_FAILED isn’t defined at all). This will produce incorrect API docs (and may break doc builds depending on mkdocstrings settings). Consider documenting only EventEmitter here, or exporting module-level constants (e.g., EVENT_TASK_STARTED = EventEmitter.EVENT_TASK_STARTED, etc.) and ensuring all listed symbols exist.

Suggested change
- EVENT_TASK_STARTED
- EVENT_TASK_COMPLETED
- EVENT_TASK_FAILED
- EVENT_TOOL_CALL
- EVENT_TOOL_RESULT
- EVENT_REQUEST_RECEIVED
- EVENT_REQUEST_COMPLETED
- EVENT_REQUEST_FAILED
- EVENT_VERIFICATION_SUCCESS
- EVENT_VERIFICATION_FAILED

Copilot uses AI. Check for mistakes.
show_root_heading: false

## Errors

::: capiscio_sdk.errors
Expand Down
66 changes: 66 additions & 0 deletions docs/guides/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -654,6 +654,72 @@ data:

---

## Middleware Observability (Auto-Events)

The `CapiscioMiddleware` can automatically emit events at key request lifecycle points, giving you visibility into traffic patterns, verification outcomes, and latency without manual instrumentation.

### Enabling Auto-Events

Pass an `EventEmitter` instance to the middleware:

```python
from capiscio_sdk.events import EventEmitter
from capiscio_sdk.integrations.fastapi import CapiscioMiddleware

emitter = EventEmitter(
agent_id="your-agent-id",
api_key="sk_live_...",
registry_url="https://registry.capisc.io"
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

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

The example constructs EventEmitter with registry_url=..., but EventEmitter.__init__ expects server_url (there’s no registry_url kwarg). This snippet will raise TypeError if copied. Update the parameter name (or add a backwards-compatible alias in EventEmitter).

Suggested change
registry_url="https://registry.capisc.io"
server_url="https://registry.capisc.io"

Copilot uses AI. Check for mistakes.
)

app.add_middleware(
CapiscioMiddleware,
guard=guard,
emitter=emitter, # Enables auto-events
exclude_paths=["/health"]
)
```

### Emitted Events

| Event Type | When Emitted | Fields |
|------------|-------------|--------|
| `request.received` | Every inbound request (after exclusion check) | `method`, `path` |
| `verification.success` | Badge verified successfully | `method`, `path`, `caller_did`, `duration_ms` |
| `verification.failed` | Badge missing or verification failed | `method`, `path`, `reason`, `duration_ms` |
| `request.completed` | After response is sent | `method`, `path`, `status_code`, `duration_ms`, `caller_did` |

### Privacy & Opt-In Design

Auto-events are **strictly opt-in**. Without an `emitter` parameter, the middleware emits nothing — identical behavior to previous versions.

This is intentional: event data includes request paths and caller identities, which may be sensitive. The developer must explicitly enable telemetry by constructing and passing an `EventEmitter`.

Paths listed in `exclude_paths` (e.g., `/health`) emit no events at all.

### Error Resilience

Event emission is wrapped in error handling — if the emitter encounters a network error or other failure, the request proceeds normally. Observability never degrades availability.

### Using with CapiscIO.connect()

If you're using `CapiscIO.connect()`, the agent identity already has an emitter:

```python
from capiscio_sdk import CapiscIO
from capiscio_sdk.integrations.fastapi import CapiscioMiddleware

agent = CapiscIO.connect(api_key="sk_live_...")

app.add_middleware(
CapiscioMiddleware,
guard=guard,
emitter=agent.emitter # Use the agent's emitter
)
Comment on lines +706 to +718
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

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

This section says CapiscIO.connect() returns an object with agent.emitter, but AgentIdentity currently only exposes emit(...) and keeps the emitter as a private _emitter field. Either add a public emitter property/attribute on AgentIdentity, or update the docs to show the supported pattern (e.g., construct an EventEmitter from agent.server_url/agent.api_key/agent.agent_id, or just call agent.emit(...)).

Copilot uses AI. Check for mistakes.
```

---

## Common Scenarios

### API Gateway
Expand Down
Loading
Loading