Skip to content

feat(flow): encrypt every PHI file at rest under one sealed index - #1354

Merged
abrichr merged 12 commits into
mainfrom
claude/phi-at-rest-sealed-sets
Oct 20, 2026
Merged

abrichr merged 12 commits into
mainfrom
claude/phi-at-rest-sealed-sets

Conversation

@abrichr

@abrichr abrichr commented Oct 19, 2026

Copy link
Copy Markdown
Member

Summary

An external review found that openadapt-flow seal encrypted only workflow.json and the template crops. Every other bundle file stayed plaintext, and so did recordings and run directories. This PR encrypts every file that can hold patient data, binds each set of files to one authenticated index, and keeps the commands that read these files working without writing plaintext.

New: sealed sets (openadapt_flow/sealed_set.py). A bundle, a recording or a run directory can be a sealed set. Each holds a header with the scrypt salt and an AES-256-GCM index of every file and its SHA-256. Data files are stored as <name>.enc. Opening a set authenticates the index, then refuses:

  • an added file;
  • a missing file;
  • a swapped file;
  • an older ciphertext of the same file;
  • a file moved from another set (the file path is in the associated data).

Writing into a set refuses a file the inventory doesn't know. The KDF and AEAD are the existing cryptography primitives. The key is derived once per directory; each file gets a fresh random nonce.

Inventory. BUNDLE_FILES and RECORDING_FILES classify every file a bundle or recording can contain. They are printed in docs/phi_at_rest.md, and a test keeps that table in step with the code. Only manifest.json (digests and provenance) stays plaintext in a bundle.

seal. Encrypts the whole bundle in memory, with no plaintext staging copy. It refuses unknown files, verifies the result as a reader would, and prints the sealed file-set digest.

record. With OPENADAPT_RECORDING_KEY, the web recorder writes only ciphertext from the first frame: frames, events, metadata, network capture and --verify rows. It falls back to OPENADAPT_BUNDLE_KEY, so encryption is on by default when that key is set. --no-encrypt opts out. Desktop recorders refuse an explicit recording key, because openadapt-capture writes a raw SQLite/video session that Flow can't encrypt yet.

Runs. A run of a sealed bundle writes a sealed run directory. That covers the report, step frames, identity and resolution crops, transition evidence, durable checkpoints, attended pause state and undo records. The durable store's own encrypted files are adopted as ciphertext. A plaintext file written by any component makes the run refuse to seal its index. Approval-gate runs, attended drafts and business-decision states are refused before they act, because those stores still write plaintext.

Commands. These read sealed or encrypted input in memory and keep their output sealed:

  • compile, induce, scaffold-verifier
  • seal, certify, qualify, admit
  • run, replay, approve, resume, undo

Without the key they refuse up front and name the variable. Any other command given a sealed path refuses rather than decrypt to disk. A bare Workflow.save() into a sealed bundle stays sealed.

Docs. Updated the README passage, phi_at_rest.md, PRIVACY.md, LIMITS.md and the CAPABILITIES row. Each says exactly what is encrypted and what isn't: data in memory, the app's own storage, file names and sizes, structural lock and manifest files, desktop recordings, admission-root records, and legacy save(encrypt=True) bundles.

Evidence

tests/test_sealed_at_rest.py has 21 tests. A process audit hook records every file the Python process writes, renames or deletes, and checks each one for plaintext markers, temp files included. The end-to-end test:

  1. records the synthetic clinic tutorial encrypted;
  2. compiles it;
  3. certifies it;
  4. runs it to VERIFIED under the Standard profile with a system-of-record check;
  5. renders the report;
  6. replays it through the CLI.

No plaintext reaches the disk. Other tests cover:

  • the tamper cases above;
  • wrong and missing keys;
  • refusal of an unknown file;
  • an unfinished recording;
  • pause and resume;
  • a halted run undone;
  • compile and induce from encrypted recordings;
  • scaffold-verifier, admit and qualify.

Cost (benchmark/at_rest/, 150 actions, 87 KB synthetic 1280x800 frames, measured with other load on the machine):

Plaintext Encrypted
Persist one frame (median) 0.12 ms 0.25 ms
Record one action, 2 frames + 1 event (median) 5.2 ms 5.8 ms
Key derivation, once per recording — 183 ms
Disk +0.24% (44 B per file + index)

Not in this PR (follow-ups)

  • Encrypted desktop recording, which needs openadapt-capture changes.
  • Sealed attended approvals and business decisions.
  • An offline Seal for encrypted run directories.
  • check mark, visualize, explain, teach, emit-skill and push on sealed input. They refuse today.
  • A legacy save(encrypt=True) bundle still decrypts on a bare re-save. Existing tests pin that behavior.

Test plan

  • tests/test_sealed_at_rest.py (21 passed)
  • Targeted suites for every touched module (see below)
  • ruff check / ruff format on changed files
  • Full suite in CI

🤖 Generated with Claude Code

abrichr and others added 9 commits October 19, 2026 11:04
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… output sealed

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ed runs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread packages/flow/openadapt_flow/sealed_set.py Fixed
Comment thread packages/flow/openadapt_flow/sealed_set.py Fixed
Comment thread packages/flow/openadapt_flow/sealed_set.py Fixed
abrichr and others added 3 commits October 19, 2026 15:02
…deQL findings, gitleaks false positive

- open_set no longer hashes the passphrase for its cache key; it compares
  the passphrase in constant time against the cached entry.
- recorder keeps declared secret parameter NAMES as _withheld_param_names.
- .gitleaksignore: fingerprint for the Workflow.load(key=key, ...) false positive.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	.gitleaksignore
#	llms-full.txt
#	packages/flow/openadapt_flow/recorder.py
#	packages/flow/public-artifacts.json
@abrichr

abrichr commented Oct 19, 2026

Copy link
Copy Markdown
Member Author

Included in batch #1384, which runs CI once for several reviewed PRs. This PR is marked merged when #1384 merges. Please don't push here unless asked; a new commit leaves it out of the batch.

@abrichr
abrichr merged commit cc1b237 into main Oct 20, 2026
40 checks passed
abrichr added a commit that referenced this pull request Oct 20, 2026
@abrichr
abrichr deleted the claude/phi-at-rest-sealed-sets branch October 20, 2026 02:18
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