phase 13.x.8: per-actor key isolation (plumbing)#25
Conversation
… traversal guard)
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>
Reviewer's GuideImplements 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 resolutionsequenceDiagram
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)
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
There was a problem hiding this comment.
💡 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".
| try: | ||
| keys_dir = _actor_keys_dir(actor) |
There was a problem hiding this comment.
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 👍 / 👎.
…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>
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 (workflowwf_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 sharedencryption.pysingleton. Fail-CLOSED by directory presence (actors/<name>/exists + broken keys →PerActorKeyError, never a silent shared-key downgrade; dir absent → shared fallback).threading.Lockon the request-time cache._ACTOR_NAME_REvalidation + path-under-root traversal guard._replay_encryptedreadspayload.get("prompt_encryption_actor_id")(stored inpayload_json, not a SQL column). Absent → shared; present → per-actor.generate-encryption-key --actor <name>→ keys underactors/<name>/(+ 0o700 dir on POSIX).rotate-keygains optionalactor_name(+ cache eviction + 0o700 on success); newGET /v1/admin/encryption/actorsenumeration (admin-only, read-cost).encryptor_from_keys_dir(behavior-preserving) so shared + per-actor layouts share one builder; deduplicated the admin auth ladder inencryption_admin.pyto the canonical_admin_authn(3 forks → 1; enumeration now usesadmin.readcost instead ofadmin.mutate).NOT shipped (deferred)
set_prompt_encryptor()activation (13-D2).system.encryptor).Security properties (from the review)
AgeKeyPermissionErrorhandled in the fail-closed branch (loose-permission key → typed error, not a crash).actor_namepath-traversal guard (../primaryrefused; symlink-escape empirically verified in review).threading.Lockon the request-time cache.is_admin∩can_rotate_encryption_key; enumeration isis_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
docs/superpowers/specs/2026-06-05-phase-13x8-per-actor-key-isolation-design-v2.md(d3a78f9, supersedes v10d2d9ab)docs/superpowers/plans/2026-06-05-phase-13x8-per-actor-key-isolation.md(7e6afe1)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:
Enhancements:
Tests: