feat: make PoP (IAL-1) the default badge mode with CA fallback#69
Conversation
CapiscIO.connect() now tries Proof-of-Possession badge issuance first: 1. Reads the agent's private key from keys_dir 2. Requests a PoP challenge from the registry 3. Signs the challenge to prove key ownership 4. Receives an IAL-1 badge (higher assurance than CA-issued) Falls back to CA-issued badge (IAL-0) via the gRPC keeper if PoP fails (e.g. server doesn't support PoP yet, network issues). Also passes private_key_path through to BadgeKeeper so the Go core can find the key in custom keys_dir locations (not just ~/.capiscio/keys/).
|
✅ Documentation validation passed!
|
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
|
✅ All checks passed! Ready for review. |
There was a problem hiding this comment.
Pull request overview
Makes Proof-of-Possession (IAL-1) the default initial badge mode in CapiscIO.connect() with transparent fallback to the keeper's CA (IAL-0) mode, and threads a private_key_path config through BadgeKeeper to the Go core keeper RPC.
Changes:
connect.py:_setup_badgenow attempts a PoP badge first (_request_pop_badge) before starting the CA keeper;AgentIdentity.get_badge()falls back toself.badgewhen the keeper has none; newCAPISCIO_KEYS_DIRenv var for overridingkeys_dir.badge_keeper.py: adds optionalprivate_key_pathtoBadgeKeeperConfig, theBadgeKeeper.__init__signature, and thestart_keeperRPC call.- New
_request_pop_badgehelper that readsprivate.jwk, callsclient.badge.request_pop_badge, persists the token, and updates the guard.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 6 comments.
| File | Description |
|---|---|
| capiscio_sdk/connect.py | PoP-first badge acquisition with CA fallback, new keys-dir env var, and get_badge() fallback to stored token. |
| capiscio_sdk/badge_keeper.py | Plumbs private_key_path through config, constructor, and the keeper RPC call. |
Comments suppressed due to low confidence (1)
capiscio_sdk/connect.py:993
_setup_badge's broadexcept Exception(line 936) swallows all PoP/keeper errors as a single warning. Combined with_request_pop_badge's own broadexcept Exception(line 989), real bugs (e.g.TypeErrorfrom thejtislice noted elsewhere, or misuse ofresult) get logged as "falling back to CA" with no traceback. Considerlogger.warning(..., exc_info=True)orlogger.exception(...)so operators can diagnose unexpected failures rather than silently degrading assurance level.
except Exception as e:
logger.warning(
"PoP badge request error: %s — falling back to CA (IAL-0)", e
)
return None, None
|
✅ SDK server contract tests passed (test_server_integration.py). Cross-product scenarios are validated in capiscio-e2e-tests. |
- Only start BadgeKeeper when PoP fails (CA fallback path) - Prevent IAL-1 PoP badge from being overwritten by IAL-0 CA badge - Only pass private_key_path when file exists (ephemeral env safety) - Use null-coalescing for jti to prevent TypeError on None values Addresses review feedback from PR #69.
|
✅ Documentation validation passed!
|
|
✅ All checks passed! Ready for review. |
|
✅ SDK server contract tests passed (test_server_integration.py). Cross-product scenarios are validated in capiscio-e2e-tests. |
|
✅ Documentation validation passed!
|
|
✅ All checks passed! Ready for review. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (4)
capiscio_sdk/connect.py:932
- When PoP succeeds, no keeper is started (
keeper = None), so there is no background renewal of the IAL-1 badge. The PoP badge has a default TTL of 300 seconds (seerequest_pop_badgein_rpc/client.py:463), after whichAgentIdentity.get_badge()will keep returning the now-staleself.badgeindefinitely (no keeper means theif self._keeper:branch is skipped and the cached field is returned). For any agent process living longer than ~5 minutes this turns "IAL-1 by default" into "an expired token by default", which is a regression compared to the previous CA-keeper behavior that continuously renewed. Consider either (a) increasing the PoP TTL substantially and surfacing renewal as a follow-up, (b) starting the keeper anyway in CA mode but writing to a different output_file and not wiringon_renewuntil the PoP token nears expiry, or (c) adding a lightweight timer that re-invokes_request_pop_badgebeforeexpires_at. At minimum, the limitation should be documented inAgentIdentity.get_badge()andconnect()so users are aware their badge will silently expire.
# Start keeper for continuous renewal (CA mode).
# Only start if PoP didn't succeed — otherwise the keeper would
# immediately overwrite the IAL-1 PoP badge with an IAL-0 CA badge.
# When PoP-based renewal is supported, keeper can be started always.
keeper = None
if badge is None:
private_key_file = self.keys_dir / "private.jwk"
keeper = BadgeKeeper(
api_url=self.server_url,
api_key=self.api_key,
agent_id=self.agent_id,
mode="dev" if self.dev_mode else "ca",
output_file=str(self.keys_dir / "badge.jwt"),
private_key_path=str(private_key_file) if private_key_file.exists() else None,
on_renew=lambda token: guard.set_badge_token(token),
)
keeper.start()
badge = keeper.get_current_badge()
if hasattr(keeper, 'badge_expires_at'):
expires_at = keeper.badge_expires_at
elif hasattr(keeper, 'get_badge_expiration'):
expires_at = keeper.get_badge_expiration()
capiscio_sdk/connect.py:955
_request_pop_badgereadsprivate.jwkfrom disk and passes it through gRPC to the Go core, but the existing key-injection path supports an env-only key (CAPISCIO_AGENT_PRIVATE_KEY_JWK, see line 43) where the key may never be persisted to disk. In that modeprivate_key_path.exists()will be False and PoP will be silently skipped (with a warning), causing every ephemeral/container deployment to drop from IAL-1 back to IAL-0 — exactly the kind of "silent regression" the PR aims to eliminate. Consider also accepting the JWK fromENV_AGENT_PRIVATE_KEYhere so ephemeral environments also get IAL-1 badges.
try:
private_key_path = self.keys_dir / "private.jwk"
if not private_key_path.exists():
logger.warning(
"No private key at %s — skipping PoP, will use CA (IAL-0)",
private_key_path,
)
return None, None
private_key_jwk = private_key_path.read_text(encoding="utf-8").strip()
capiscio_sdk/connect.py:993
- The bare
except Exception as ehere will swallow not only "PoP unavailable" type errors but also programming errors (e.g., AttributeError onself._rpc_client.badge, TypeError, etc.), turning every bug in this code path into a silent "fallback to CA". This makes the PoP path effectively un-debuggable in production. Consider narrowing the catch to expected gRPC/network/IO exceptions, or logging the traceback (logger.warning(..., exc_info=True)) so real failures can be diagnosed without an environment variable for verbose logging.
except Exception as e:
logger.warning(
"PoP badge request error: %s — falling back to CA (IAL-0)", e
)
return None, None
capiscio_sdk/connect.py:976
- After a successful PoP badge acquisition,
badge.jwtis written to disk viaPath.write_textwith default permissions, meaning the file inherits the process umask (typically world-readable on most Linux distros). The CA-keeper path delegates to Go core which may set restrictive permissions; this Python-side write does not. While a JWS badge token is not a private key, it is a bearer credential representing the agent's identity. Consider setting the file mode to 0600 after write (or usingos.openwithO_CREAT|O_WRONLY|O_TRUNCand mode 0600) for consistency with other credential files inkeys_dir.
# Persist badge to disk
badge_path = self.keys_dir / "badge.jwt"
badge_path.write_text(token, encoding="utf-8")
|
✅ SDK server contract tests passed (test_server_integration.py). Cross-product scenarios are validated in capiscio-e2e-tests. |
Summary
Makes Proof-of-Possession (IAL-1) the default badge acquisition mode in
CapiscIO.connect(), with transparent fallback to CA-issued (IAL-0) badges.Behavior Change
Before:
connect()always uses the gRPC keeper in CA mode → IAL-0 badge.After:
connect()tries PoP first → IAL-1 badge. Falls back to CA keeper if PoP fails.This is backward compatible — same API, same return type. Agents just get higher-assurance badges by default when the registry supports PoP.
Changes
capiscio_sdk/connect.py_request_pop_badge()method: readsprivate.jwkfromkeys_dir, calls challenge/pop endpoints_setup_badge()tries PoP first, falls back to keeper CA mode on failureget_badge()onAgentIdentityreturnsself.badgewhen keeper returns None (covers the PoP-only path where keeper isn't started)capiscio_sdk/badge_keeper.pyprivate_key_pathconfig through to thestart_keeper()RPC callkeys_dirlocationsDependencies
Requires capiscio-core#81 for the Go core to respect the
private_key_pathparameter.Testing