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
47 changes: 41 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ from capiscio_sdk import CapiscIO
agent = CapiscIO.connect(api_key="sk_live_...")

# Agent is now ready
print(agent.did) # did:key:z6Mk...
print(agent.did) # did:web:registry.capisc.io:agents:...
print(agent.badge) # Current badge (auto-renewed)
print(agent.name) # Agent name
```
Expand Down Expand Up @@ -171,11 +171,46 @@ 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`)

| Variable | Required | Description |
|----------|----------|-------------|
| `CAPISCIO_API_KEY` | Yes | Your API key |
| `CAPISCIO_AGENT_NAME` | No | Agent name for lookup/creation |
| `CAPISCIO_AGENT_ID` | No | Specific agent UUID |
| `CAPISCIO_SERVER_URL` | No | Registry URL (default: production) |
| `CAPISCIO_DEV_MODE` | No | Enable dev mode (`true`/`false`) |
| `CAPISCIO_AGENT_PRIVATE_KEY_JWK` | No | JSON-encoded Ed25519 private JWK for ephemeral environments |

### Deploying to Containers / Serverless

In ephemeral environments (Docker, Lambda, Cloud Run) the local `~/.capiscio/` directory
doesn't survive restarts. On first run the SDK generates a keypair and logs a capture hint:

```
╔══════════════════════════════════════════════════════════════════╗
║ New agent identity generated — save key for persistence ║
╚══════════════════════════════════════════════════════════════════╝

Add to your secrets manager / .env:

CAPISCIO_AGENT_PRIVATE_KEY_JWK='{"kty":"OKP","crv":"Ed25519","d":"...","x":"...","kid":"did:key:z6Mk..."}'
Comment on lines +186 to +196
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

These deployment instructions show a "capture hint" where the SDK logs the full CAPISCIO_AGENT_PRIVATE_KEY_JWK value, including the private Ed25519 key, and recommend copying it from logs. Emitting long‑lived private keys into application logs exposes them to log collectors and anyone with observability access, allowing undetected agent/DID impersonation. The bootstrap flow should be redesigned so private keys are never written to logs, and the docs should direct users to obtain the key from a secure export path (e.g., a one‑time CLI or local file), not from log output.

Copilot uses AI. Check for mistakes.
```

Copy that value into your secrets manager and set it as an environment variable.
On subsequent starts the SDK recovers the same DID without generating a new identity.

**Key resolution priority:** env var → local file → generate new.

```yaml
# docker-compose.yml
services:
my-agent:
environment:
CAPISCIO_API_KEY: "sk_live_..."
CAPISCIO_AGENT_PRIVATE_KEY_JWK: "${AGENT_KEY_JWK}" # from secrets
```

See the [Configuration Guide](https://docs.capisc.io/reference/sdk-python/config/) for full deployment examples.

## 🎯 Agent Card Validation with CoreValidator

Expand Down
94 changes: 84 additions & 10 deletions capiscio_sdk/connect.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,41 @@
PROD_REGISTRY = "https://registry.capisc.io"
PROD_DASHBOARD = "https://app.capisc.io"

# Env var for injecting the private key in ephemeral environments
ENV_AGENT_PRIVATE_KEY = "CAPISCIO_AGENT_PRIVATE_KEY_JWK"


# =============================================================================
# Key injection helpers
# =============================================================================


def _public_jwk_from_private(private_jwk: dict) -> dict:
"""Derive the public JWK from a private JWK (remove 'd' parameter)."""
public = {k: v for k, v in private_jwk.items() if k != "d"}
return public


def _log_agent_key_capture_hint(agent_id: str, private_jwk: dict) -> None:
"""Log a one-time hint telling the user how to persist key material."""
Comment on lines +56 to +57
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

agent_id is accepted as a parameter but never used in _log_agent_key_capture_hint, which makes the signature misleading and prevents the hint from identifying which agent the key belongs to. Either include agent_id in the log message (useful when multiple agents run in the same logs) or remove the parameter to avoid dead arguments.

Copilot uses AI. Check for mistakes.
compact_json = json.dumps(private_jwk, separators=(",", ":"))
logger.warning(
"\n"
" \u2554" + "\u2550" * 62 + "\u2557\n"
" \u2551 New agent identity generated \u2014 save key for persistence \u2551\n"
" \u255a" + "\u2550" * 62 + "\u255d\n"
"\n"
" If this agent runs in an ephemeral environment (containers,\n"
" serverless, CI) the identity will be lost on restart unless\n"
" you persist the private key.\n"
"\n"
" Add to your secrets manager / .env:\n"
"\n"
" CAPISCIO_AGENT_PRIVATE_KEY_JWK='" + compact_json + "'\n"
"\n"
" The DID will be recovered automatically from the JWK on startup.\n"
)
Comment on lines +56 to +74
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

_log_agent_key_capture_hint logs the full private JWK (including the private d value). This will leak long-lived signing key material into application logs (often shipped to centralized log stores), which is a high-impact secret disclosure. Consider removing the private key from logs (e.g., log only the file path to private.jwk, or redact d), or gate printing the full JWK behind an explicit opt-in env var/flag so it never happens by default in production.

Copilot uses AI. Check for mistakes.


# =============================================================================
# Standalone Helper Functions (for testing and direct use)
Expand Down Expand Up @@ -278,6 +313,8 @@ def from_env(cls, **kwargs) -> AgentIdentity:
- CAPISCIO_AGENT_NAME (optional)
- CAPISCIO_SERVER_URL (optional, default: production)
- CAPISCIO_DEV_MODE (optional, default: false)
- CAPISCIO_AGENT_PRIVATE_KEY_JWK (optional — JSON-encoded Ed25519
private JWK for ephemeral environments; printed on first generation)
"""
api_key = os.environ.get("CAPISCIO_API_KEY")
if not api_key:
Expand Down Expand Up @@ -568,16 +605,44 @@ def _init_identity(self) -> str:

All cryptographic operations are performed by capiscio-core Go library.

Identity Recovery:
- If keys exist locally (private.jwk + public.jwk), we derive the DID
from public.jwk's `kid` field (per RFC-002 §6.1: did:key is self-describing)
- No did.txt file is required - it's redundant
- If server has a did:web assigned, we use that instead
Identity Recovery (priority order):
- CAPISCIO_AGENT_PRIVATE_KEY_JWK env var (ephemeral / containerised)
- Local keys on disk (private.jwk + public.jwk)
- Generate new identity via Init RPC (first run)
"""
private_key_path = self.keys_dir / "private.jwk"
public_key_path = self.keys_dir / "public.jwk"

# Check if we already have keys (for idempotency)

# ------------------------------------------------------------------
# Source 1: Environment variable (highest priority)
# ------------------------------------------------------------------
env_jwk_raw = os.environ.get(ENV_AGENT_PRIVATE_KEY)
if env_jwk_raw:
try:
private_jwk = json.loads(env_jwk_raw)
did = private_jwk.get("kid")
if not did or not did.startswith("did:"):
raise ValueError("JWK is missing a valid 'kid' field with a DID")

logger.info(f"Loaded agent identity from {ENV_AGENT_PRIVATE_KEY}: {did}")

# Derive public JWK and persist to disk for subsequent restarts
public_jwk = _public_jwk_from_private(private_jwk)
self.keys_dir.mkdir(parents=True, exist_ok=True)
private_key_path.write_text(json.dumps(private_jwk, indent=2))
os.chmod(private_key_path, 0o600)
public_key_path.write_text(json.dumps(public_jwk, indent=2))

# Register with server (idempotent)
Comment on lines +629 to +636
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

The env-var key injection path unconditionally writes private.jwk/public.jwk to keys_dir and chmods the private key. If keys_dir is read-only or chmod is unsupported (common in some container/serverless setups), this will raise and prevent startup even though the key is available via the env var. Consider making disk persistence best-effort (catch OSError/PermissionError) or allowing an opt-out so env-var injection still works without filesystem writes.

Suggested change
# Derive public JWK and persist to disk for subsequent restarts
public_jwk = _public_jwk_from_private(private_jwk)
self.keys_dir.mkdir(parents=True, exist_ok=True)
private_key_path.write_text(json.dumps(private_jwk, indent=2))
os.chmod(private_key_path, 0o600)
public_key_path.write_text(json.dumps(public_jwk, indent=2))
# Register with server (idempotent)
# Derive public JWK and (best-effort) persist to disk for subsequent restarts
public_jwk = _public_jwk_from_private(private_jwk)
try:
self.keys_dir.mkdir(parents=True, exist_ok=True)
private_key_path.write_text(json.dumps(private_jwk, indent=2))
try:
os.chmod(private_key_path, 0o600)
except (OSError, NotImplementedError) as chmod_err:
# On some platforms or filesystems chmod may not be supported
logger.debug(
f"Unable to set permissions on {private_key_path}: {chmod_err!r}"
)
public_key_path.write_text(json.dumps(public_jwk, indent=2))
except (OSError, PermissionError) as fs_err:
# In read-only/container/serverless environments, persisting keys may fail.
# Continue using the in-memory key from the environment variable.
logger.warning(
"Unable to persist agent keys to %s: %s; "
"continuing with in-memory key from %s",
self.keys_dir,
fs_err,
ENV_AGENT_PRIVATE_KEY,
)
# Register with server (idempotent) even if persistence failed

Copilot uses AI. Check for mistakes.
server_did = self._ensure_did_registered(did, public_jwk)
return server_did if server_did else did

except (json.JSONDecodeError, ValueError) as e:
logger.error(f"Invalid {ENV_AGENT_PRIVATE_KEY}: {e} — falling through to local keys")
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

When CAPISCIO_AGENT_PRIVATE_KEY_JWK is present but invalid JSON / missing fields, the code logs an error and silently falls through to local keys / generating a new identity. In ephemeral deployments this can cause an unexpected DID/key rotation (and invalidate existing badges) due to a simple misconfiguration. Safer behavior would be to fail fast with a ConfigurationError when the env var is set but invalid, so operators notice immediately rather than getting a new identity.

Suggested change
logger.error(f"Invalid {ENV_AGENT_PRIVATE_KEY}: {e} — falling through to local keys")
logger.error(f"Invalid {ENV_AGENT_PRIVATE_KEY}: {e}")
raise ConfigurationError(f"Invalid {ENV_AGENT_PRIVATE_KEY}: {e}") from e

Copilot uses AI. Check for mistakes.

# ------------------------------------------------------------------
# Source 2: Local keys on disk
# ------------------------------------------------------------------
if private_key_path.exists() and public_key_path.exists():
logger.debug("Found existing keys - recovering identity")

Expand All @@ -598,8 +663,9 @@ def _init_identity(self) -> str:
except (json.JSONDecodeError, IOError) as e:
logger.warning(f"Failed to read public.jwk: {e} - regenerating")

# No valid keys exist - generate new identity via Init RPC
# Connect to capiscio-core gRPC
# ------------------------------------------------------------------
# Source 3: Generate new identity via Init RPC (first run)
# ------------------------------------------------------------------
if not self._rpc_client:
self._rpc_client = CapiscioRPCClient()
self._rpc_client.connect()
Expand All @@ -625,7 +691,15 @@ def _init_identity(self) -> str:
logger.info(f"Identity initialized: {did}")
if result.get("registered"):
logger.info("DID registered with server")


# Log capture hint for ephemeral environments
if private_key_path.exists():
try:
private_jwk = json.loads(private_key_path.read_text())
_log_agent_key_capture_hint(self.agent_id, private_jwk)
except Exception:
pass # Best-effort hint

return did

def _ensure_did_registered(self, did: str, public_jwk: dict) -> Optional[str]:
Expand Down
55 changes: 55 additions & 0 deletions docs/guides/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -631,6 +631,8 @@ os.environ['CAPISCIO_BINARY'] = '/opt/capiscio/v2.4.0/capiscio-core'
services:
agent:
environment:
- CAPISCIO_API_KEY=sk_live_...
- CAPISCIO_AGENT_PRIVATE_KEY_JWK=${AGENT_KEY_JWK}
- CAPISCIO_FAIL_MODE=block
- CAPISCIO_RATE_LIMITING=true
- CAPISCIO_RATE_LIMIT_RPM=120
Expand All @@ -650,8 +652,61 @@ data:
CAPISCIO_RATE_LIMITING: "true"
CAPISCIO_RATE_LIMIT_RPM: "200"
CAPISCIO_TIMEOUT_MS: "5000"
---
apiVersion: v1
kind: Secret
metadata:
name: capiscio-identity
type: Opaque
stringData:
api-key: "sk_live_..."
agent-private-key-jwk: '{"kty":"OKP","crv":"Ed25519","d":"...","x":"...","kid":"did:key:z6Mk..."}'
```

### Agent Identity Variables (CapiscIO.connect)

These variables are used by `CapiscIO.connect()` and `CapiscIO.from_env()` for agent identity management:

| Variable | Required | Description |
|----------|----------|-------------|
| `CAPISCIO_API_KEY` | Yes | Registry API key |
| `CAPISCIO_AGENT_NAME` | No | Agent name for lookup/creation |
| `CAPISCIO_AGENT_ID` | No | Specific agent UUID |
| `CAPISCIO_SERVER_URL` | No | Registry URL (default: `https://registry.capisc.io`) |
| `CAPISCIO_DEV_MODE` | No | Enable dev mode (`true`/`false`) |
| `CAPISCIO_AGENT_PRIVATE_KEY_JWK` | No | JSON-encoded Ed25519 private JWK for ephemeral environments |

#### Ephemeral Environment Key Injection

In ephemeral environments (Docker, Lambda, Cloud Run) the local `~/.capiscio/` directory doesn't survive restarts. Set `CAPISCIO_AGENT_PRIVATE_KEY_JWK` to inject the agent's Ed25519 private key from your secrets manager.

**Key resolution priority:**

| Priority | Source | When Used |
|----------|--------|-----------|
| 1 | `CAPISCIO_AGENT_PRIVATE_KEY_JWK` env var | Containers, serverless, CI |
| 2 | Local key file (`~/.capiscio/keys/{agent_id}/private.jwk`) | Persistent environments |
| 3 | Generate new via capiscio-core Init RPC | First run only |

**First-run capture:** On the very first run, the SDK logs a capture hint to stderr with the full JWK. Copy it into your secrets manager:

```
CAPISCIO_AGENT_PRIVATE_KEY_JWK='{"kty":"OKP","crv":"Ed25519","d":"...","x":"...","kid":"did:key:z6Mk..."}'
```
Comment on lines +691 to 695
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

This section documents a flow where the SDK logs the full private Ed25519 JWK (including the d parameter) to stderr and instructs operators to copy it into a secrets manager. Logging full private keys means anyone with access to container/serverless logs or a central log aggregator can recover the key and impersonate the agent/DID indefinitely. Instead, the SDK and docs should avoid printing raw private key material to logs and use a secure bootstrap/export mechanism (e.g., explicit CLI command or manual export from local key files) that does not traverse shared logging infrastructure.

Copilot uses AI. Check for mistakes.

!!! warning "DID Changes on New Key Generation"
If neither the env var nor local files are available, the SDK generates a **new** keypair with a **different** DID. Any badges issued to the old DID will no longer be valid. Always persist the key in ephemeral environments.

#### Key Rotation

To rotate the agent identity:

1. Unset `CAPISCIO_AGENT_PRIVATE_KEY_JWK`
2. Remove local key files (`~/.capiscio/keys/{agent_id}/`)
3. Restart the agent — a new keypair and DID will be generated
4. Capture the new JWK from the log hint
5. Store the new key in your secrets manager

---

## Middleware Observability (Auto-Events)
Expand Down
Loading
Loading