Skip to content

feat: make PoP (IAL-1) the default badge mode with CA fallback#69

Merged
beonde merged 3 commits into
mainfrom
feat/pop-default-badge
May 14, 2026
Merged

feat: make PoP (IAL-1) the default badge mode with CA fallback#69
beonde merged 3 commits into
mainfrom
feat/pop-default-badge

Conversation

@beonde
Copy link
Copy Markdown
Member

@beonde beonde commented May 14, 2026

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

  • New _request_pop_badge() method: reads private.jwk from keys_dir, calls challenge/pop endpoints
  • _setup_badge() tries PoP first, falls back to keeper CA mode on failure
  • get_badge() on AgentIdentity returns self.badge when keeper returns None (covers the PoP-only path where keeper isn't started)

capiscio_sdk/badge_keeper.py

  • Passes private_key_path config through to the start_keeper() RPC call
  • Required for Go core to find keys in custom keys_dir locations

Dependencies

Requires capiscio-core#81 for the Go core to respect the private_key_path parameter.

Testing

  • enforcement-demo: 5/5 scenarios passing with PoP badges
  • policy-demo: badge acquisition succeeds, MCP verification works
  • Fallback tested: if PoP endpoint returns error, keeper CA mode activates

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/).
Copilot AI review requested due to automatic review settings May 14, 2026 19:06
@github-actions
Copy link
Copy Markdown

✅ Documentation validation passed!

Unified docs will be deployed from capiscio-docs repo.

@codecov
Copy link
Copy Markdown

codecov Bot commented May 14, 2026

Codecov Report

❌ Patch coverage is 55.55556% with 20 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
capiscio_sdk/connect.py 54.54% 20 Missing ⚠️

📢 Thoughts on this report? Let us know!

@github-actions
Copy link
Copy Markdown

✅ All checks passed! Ready for review.

Copy link
Copy Markdown

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

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_badge now attempts a PoP badge first (_request_pop_badge) before starting the CA keeper; AgentIdentity.get_badge() falls back to self.badge when the keeper has none; new CAPISCIO_KEYS_DIR env var for overriding keys_dir.
  • badge_keeper.py: adds optional private_key_path to BadgeKeeperConfig, the BadgeKeeper.__init__ signature, and the start_keeper RPC call.
  • New _request_pop_badge helper that reads private.jwk, calls client.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 broad except Exception (line 936) swallows all PoP/keeper errors as a single warning. Combined with _request_pop_badge's own broad except Exception (line 989), real bugs (e.g. TypeError from the jti slice noted elsewhere, or misuse of result) get logged as "falling back to CA" with no traceback. Consider logger.warning(..., exc_info=True) or logger.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

Comment thread capiscio_sdk/connect.py Outdated
Comment thread capiscio_sdk/connect.py Outdated
Comment thread capiscio_sdk/connect.py Outdated
Comment thread capiscio_sdk/connect.py Outdated
Comment thread capiscio_sdk/connect.py
Comment thread capiscio_sdk/connect.py
@github-actions
Copy link
Copy Markdown

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

✅ Documentation validation passed!

Unified docs will be deployed from capiscio-docs repo.

@github-actions
Copy link
Copy Markdown

✅ All checks passed! Ready for review.

@github-actions
Copy link
Copy Markdown

✅ SDK server contract tests passed (test_server_integration.py). Cross-product scenarios are validated in capiscio-e2e-tests.

Copilot AI review requested due to automatic review settings May 14, 2026 19:24
@github-actions
Copy link
Copy Markdown

✅ Documentation validation passed!

Unified docs will be deployed from capiscio-docs repo.

@github-actions
Copy link
Copy Markdown

✅ All checks passed! Ready for review.

Copy link
Copy Markdown

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 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 (see request_pop_badge in _rpc/client.py:463), after which AgentIdentity.get_badge() will keep returning the now-stale self.badge indefinitely (no keeper means the if 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 wiring on_renew until the PoP token nears expiry, or (c) adding a lightweight timer that re-invokes _request_pop_badge before expires_at. At minimum, the limitation should be documented in AgentIdentity.get_badge() and connect() 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_badge reads private.jwk from 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 mode private_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 from ENV_AGENT_PRIVATE_KEY here 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 e here will swallow not only "PoP unavailable" type errors but also programming errors (e.g., AttributeError on self._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.jwt is written to disk via Path.write_text with 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 using os.open with O_CREAT|O_WRONLY|O_TRUNC and mode 0600) for consistency with other credential files in keys_dir.
                # Persist badge to disk
                badge_path = self.keys_dir / "badge.jwt"
                badge_path.write_text(token, encoding="utf-8")

Comment thread capiscio_sdk/connect.py
Comment thread capiscio_sdk/connect.py
@github-actions
Copy link
Copy Markdown

✅ SDK server contract tests passed (test_server_integration.py). Cross-product scenarios are validated in capiscio-e2e-tests.

@beonde beonde merged commit 94dbbb4 into main May 14, 2026
16 of 17 checks passed
@beonde beonde deleted the feat/pop-default-badge branch May 14, 2026 19:41
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.

2 participants