Skip to content

phase 13.x.8: per-actor key isolation (plumbing)#25

Merged
nah414 merged 10 commits into
mainfrom
phase-13x-per-actor-keys
Jun 5, 2026
Merged

phase 13.x.8: per-actor key isolation (plumbing)#25
nah414 merged 10 commits into
mainfrom
phase-13x-per-actor-keys

Conversation

@nah414

@nah414 nah414 commented Jun 5, 2026

Copy link
Copy Markdown
Owner

Summary

Per-actor encryption plumbing — a fail-closed per-actor encryptor registry, per-actor keygen routing (CLI + admin endpoint + enumeration), and decrypt-routing — keyed by actor.name. Output of an adversarial design review (workflow wf_432eec7f, 6 reviewers, 50 findings) that found the v1 design assumed an ENCRYPTED_OPT_IN write path which does not exist in Phoenix (a deliberate 13-D2 deferral). This PR ships the per-actor plumbing only.

Honest scope: per-actor isolation is wired, tested, and ready — but NOT end-to-end-live until the deferred ENCRYPTED_OPT_IN write path activates. The write path + per-actor audit attribution stay deferred.

What ships

  • phoenix/ledger/encryption_actors.py — per-actor encryptor registry mirroring the shared encryption.py singleton. Fail-CLOSED by directory presence (actors/<name>/ exists + broken keys → PerActorKeyError, never a silent shared-key downgrade; dir absent → shared fallback). threading.Lock on the request-time cache. _ACTOR_NAME_RE validation + path-under-root traversal guard.
  • Decrypt-routing: _replay_encrypted reads payload.get("prompt_encryption_actor_id") (stored in payload_json, not a SQL column). Absent → shared; present → per-actor.
  • CLI: generate-encryption-key --actor <name> → keys under actors/<name>/ (+ 0o700 dir on POSIX).
  • Admin: rotate-key gains optional actor_name (+ cache eviction + 0o700 on success); new GET /v1/admin/encryption/actors enumeration (admin-only, read-cost).
  • Refactors: extracted encryptor_from_keys_dir (behavior-preserving) so shared + per-actor layouts share one builder; deduplicated the admin auth ladder in encryption_admin.py to the canonical _admin_authn (3 forks → 1; enumeration now uses admin.read cost instead of admin.mutate).

NOT shipped (deferred)

  • The ENCRYPTED_OPT_IN write path + startup set_prompt_encryptor() activation (13-D2).
  • Per-actor audit attribution (encrypt audit is currently system.encryptor).
  • No migration, no new permission flag, no SQL column.

Security properties (from the review)

  • v1's fallback failed open — a transient key-load failure silently downgraded an isolation-configured actor to shared-key. Now fail-closed by directory presence (tested both directions).
  • AgeKeyPermissionError handled in the fail-closed branch (loose-permission key → typed error, not a crash).
  • actor_name path-traversal guard (../primary refused; symlink-escape empirically verified in review).
  • threading.Lock on the request-time cache.
  • Auth-refactor verified: rotate still enforces is_admincan_rotate_encryption_key; enumeration is is_admin-only; no actor passes that previously failed.

Tests

~28 new across 4 files: registry/fail-closed/traversal (16), decrypt-routing (3), CLI --actor (3), admin endpoints (6). Full project pytest: 1343 passed, 44 skipped, 0 failed. mypy --strict clean (6 modules); ruff clean.

Spec / plan / review

  • Design v2: docs/superpowers/specs/2026-06-05-phase-13x8-per-actor-key-isolation-design-v2.md (d3a78f9, supersedes v1 0d2d9ab)
  • Plan: docs/superpowers/plans/2026-06-05-phase-13x8-per-actor-key-isolation.md (7e6afe1)
  • Adversarial design review: workflow wf_432eec7f (6 reviewers, 50 findings, verified against the tree)

🤖 Generated with Claude Code

Summary by Sourcery

Introduce per-actor prompt-encryption plumbing with fail-closed isolation keyed by actor name, including registry, routing, and admin/CLI surfaces, while keeping the write path deferred.

New Features:

  • Add per-actor prompt-encryptor registry and resolution keyed by actor name with fail-closed behavior.
  • Expose admin support for per-actor keys via rotate-key actor_name option and a new GET /v1/admin/encryption/actors enumeration endpoint.
  • Extend the CLI generate-encryption-key command with an --actor flag to provision per-actor key trees.

Enhancements:

  • Refactor encryption key loading into a reusable encryptor_from_keys_dir helper shared by default and per-actor layouts.
  • Reuse the shared parameterized admin authentication ladder for encryption admin endpoints and align action costs and capability checks.
  • Document per-actor key isolation plumbing, scope, and shipment details in the ledger README and changelog.

Tests:

  • Add unit tests for the per-actor encryption registry, fail-closed semantics, path traversal guards, and enumeration.
  • Add CLI tests for generate-encryption-key --actor behavior and error handling.
  • Add integration tests for admin rotate-key actor_name handling and the new /v1/admin/encryption/actors endpoint.
  • Add cognition replay tests to verify per-actor decrypt-routing based on payload-stored actor identifiers.

nah414 and others added 7 commits June 5, 2026 13:20
Add --actor <name> to the generate-encryption-key admin subcommand.
When set, the keypair is provisioned under actors/<name>/ via
_actor_keys_dir() (validated, path-traversal guarded) instead of the
shared layout. On POSIX the actor directory is tightened to 0o700
(keygen only chmods the identity file 0o600). Windows relies on NTFS
ACLs. 3 new tests; 13.x.7 CLI tests + mypy --strict green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…n; enumeration uses admin.read

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@sourcery-ai

sourcery-ai Bot commented Jun 5, 2026

Copy link
Copy Markdown

Reviewer's Guide

Implements per-actor encryption plumbing keyed by actor.name, including a fail-closed per-actor encryptor registry, per-actor key generation via CLI and admin APIs, decrypt-time routing based on payload metadata, and admin auth refactors plus tests and docs to support the new behavior while keeping the write path deferred.

Sequence diagram for per-actor decrypt-time encryptor resolution

sequenceDiagram
    participant CognitionReplay as cognition_replay._replay_encrypted
    participant Resolver as _resolve_decrypt_encryptor
    participant PerActorRegistry as encryption_actors.get_prompt_encryptor_for_actor
    participant SharedRegistry as encryption.get_prompt_encryptor
    participant FS as encryptor_from_keys_dir

    CognitionReplay->>Resolver: _resolve_decrypt_encryptor(payload)
    Resolver->>Resolver: actor_name = payload.get(prompt_encryption_actor_id)
    alt [no actor_name]
        Resolver->>SharedRegistry: get_prompt_encryptor()
        SharedRegistry-->>Resolver: shared_encryptor
    else [actor_name present]
        Resolver->>PerActorRegistry: get_prompt_encryptor_for_actor(actor_name)
        PerActorRegistry->>PerActorRegistry: _actor_keys_dir(actor_name)
        alt [actors/actor_name exists]
            PerActorRegistry->>FS: encryptor_from_keys_dir(actor_dir)
            alt [keys load ok]
                FS-->>PerActorRegistry: actor_encryptor
                PerActorRegistry-->>Resolver: actor_encryptor
            else [AgeKeyLoadError or AgeKeyPermissionError]
                PerActorRegistry-->>Resolver: PerActorKeyError
            end
        else [actors/actor_name missing]
            PerActorRegistry->>SharedRegistry: get_prompt_encryptor()
            SharedRegistry-->>PerActorRegistry: shared_encryptor
            PerActorRegistry-->>Resolver: shared_encryptor
        end
    end
    Resolver-->>CognitionReplay: encryptor
    CognitionReplay->>encryptor: decrypt(encrypted_bytes)
Loading

File-Level Changes

Change Details Files
Introduce per-actor encryptor registry and disk layout with fail-closed semantics and traversal-safe name resolution.
  • Add phoenix.ledger.encryption_actors module implementing a per-actor PromptEncryptor registry keyed by actor.name with a thread-safe cache.
  • Implement actor-name validation via a local regex pinned to the API routes regex, and a path-under-root guard for actors// directories.
  • Provide helpers to build encryptors from actors// trees, list actors with keys, and set/reset per-actor encryptors including cache eviction semantics.
phoenix/ledger/encryption_actors.py
tests/cognition/test_encryption_actors.py
Refactor age encryptor construction to share a generic keys-dir builder used by both shared and per-actor layouts.
  • Extract encryptor_from_keys_dir(keys_dir) from encryptor_from_default_layout, keeping behavior identical for the default shared layout.
  • Export encryptor_from_keys_dir for reuse by per-actor encryption_actors helpers.
phoenix/ledger/encryption_age.py
Wire decrypt-time routing to choose shared vs per-actor encryptors based on payload-stored actor identifier.
  • Introduce _resolve_decrypt_encryptor helper that reads prompt_encryption_actor_id from payload_json and returns either the shared or per-actor encryptor.
  • Update _replay_encrypted to call the new resolver instead of always using the shared encryptor.
  • Add tests that hand-construct ENCRYPTED_OPT_IN payloads to verify routing behavior for absent, present, and null actor IDs.
phoenix/ledger/cognition_replay.py
tests/cognition/test_replay_per_actor_routing.py
Extend CLI generate-encryption-key to support per-actor key provisioning under actors// with stricter directory permissions.
  • Add --actor option to the admin generate-encryption-key command to route keys under actors// using encryption_actors._actor_keys_dir.
  • Handle invalid actor names by printing a human-readable error and exiting non-zero, mirroring existing CLI patterns.
  • On POSIX, chmod the per-actor key directory to 0o700 after generation to tighten directory permissions.
  • Add CLI-level tests verifying actor routing, shared layout fallback, and invalid-name behavior.
phoenix/cli/entry.py
phoenix/cli/commands/admin.py
tests/cli/test_admin_generate_encryption_key_actor.py
Update admin rotate-key endpoint to support per-actor key rotation, cache eviction, and introduce an admin-only enumeration endpoint.
  • Replace the local admin auth chain with the shared parameterized _admin_authn helper from phoenix.admin.verification_inspect, wiring rotate-key to use admin.mutate cost plus can_rotate_encryption_key capability and preserving audit prefixes.
  • Extend RotateKeyPayload with optional actor_name controlling whether keys are written under actors/<actor_name>/ and pinning the per-actor key name to primary.
  • Route per-actor rotations to actors// directories via encryption_actors._actor_keys_dir, returning 400 on invalid names, and log actor_name in success audits.
  • On per-actor rotation, evict the per-actor encryptor cache and chmod the actor directory to 0o700 on POSIX.
  • Add GET /v1/admin/encryption/actors endpoint that uses admin.read cost and no rotate capability to list actors with valid per-actor keys and returns a count field.
  • Add integration tests for per-actor rotate-key behavior and the enumeration endpoint, including auth checks and response shape.
phoenix/admin/encryption_admin.py
tests/integration/test_admin_encryption_actors.py
Document per-actor key isolation phase and update ledger documentation to reflect new behavior and remaining deferrals.
  • Add a Phase 13.x.8 changelog entry describing per-actor plumbing scope, new module, storage semantics, CLI/admin behavior, refactors, tests, and design references.
  • Extend ledger README with a 'Per-actor key isolation' section documenting CLI/admin usage, fail-closed behavior, and scope notes, and update the shipped/NOT shipped lists accordingly.
CHANGELOG.md
phoenix/ledger/README.md

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hey - I've reviewed your changes and they look great!


Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 14e235e8e2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread phoenix/cli/commands/admin.py
Comment on lines +145 to +146
try:
keys_dir = _actor_keys_dir(actor)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Apply --keys-dir before appending actors

If an operator combines --keys-dir with --actor, this assignment discards the explicit directory parsed above and calls _actor_keys_dir, which is rooted at default_keys_dir() instead. For example, --keys-dir /mnt/phoenix/encryption_keys --actor adam writes under the default/env tree, so a daemon configured to read /mnt/phoenix/encryption_keys will not find the provisioned actor key and secrets may be created in an unexpected location. The actor path should resolve beneath the already-selected keys_dir when one was supplied.

Useful? React with 👍 / 👎.

nah414 and others added 3 commits June 5, 2026 14:23
…dpoint + README)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CI lint (mypy --strict over whole tree) caught:
  encryption_admin.py:254: Argument 1 to "chmod" has incompatible type
  "Path | None"; expected "...PathLike..."

The success-path dir-chmod reuses ``rotate_keys_dir`` (the Task 5 review
follow-up to avoid re-resolving _actor_keys_dir). That var is typed
``Path | None`` (None on the shared layout) and is only assigned inside
the line-162 ``if actor_name:`` block. The chmod lives in a *second*
``if actor_name:`` block, so it's provably non-None at runtime, but mypy
can't carry narrowing across two guards keyed on a different variable.

Add ``assert rotate_keys_dir is not None`` to document the invariant and
satisfy --strict. Local ``mypy phoenix`` now: Success (174 files).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@nah414 nah414 merged commit 5feeaff into main Jun 5, 2026
10 checks passed
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.

1 participant