From a94ffbebccba96d9c2f9ca2016413f10c7526ff1 Mon Sep 17 00:00:00 2001 From: abrichr Date: Mon, 19 Oct 2026 11:04:47 -0400 Subject: [PATCH 1/9] WIP: seal every bundle and recording file under one index Co-Authored-By: Claude Opus 5.5 --- packages/flow/openadapt_flow/__main__.py | 67 +- .../flow/openadapt_flow/bundle_sealing.py | 130 ++- .../flow/openadapt_flow/bundle_validation.py | 21 +- .../openadapt_flow/compiler/api_proposal.py | 5 +- .../flow/openadapt_flow/compiler/compile.py | 87 +- .../openadapt_flow/compiler/fixup_passes.py | 12 +- .../openadapt_flow/compiler/param_confirm.py | 6 +- .../compiler/qualification_pins.py | 5 +- .../compiler/readback_authoring.py | 17 +- packages/flow/openadapt_flow/crypto.py | 68 ++ .../openadapt_flow/interactive_recorder.py | 15 +- packages/flow/openadapt_flow/ir.py | 75 +- .../flow/openadapt_flow/network_capture.py | 16 +- packages/flow/openadapt_flow/record_sor.py | 12 +- packages/flow/openadapt_flow/recorder.py | 53 +- packages/flow/openadapt_flow/report.py | 9 +- .../flow/openadapt_flow/runtime/replayer.py | 134 ++- packages/flow/openadapt_flow/sealed_set.py | 1031 +++++++++++++++++ packages/flow/openadapt_flow/tutorial.py | 5 + packages/flow/tests/test_cli_seal.py | 19 +- 20 files changed, 1659 insertions(+), 128 deletions(-) create mode 100644 packages/flow/openadapt_flow/sealed_set.py diff --git a/packages/flow/openadapt_flow/__main__.py b/packages/flow/openadapt_flow/__main__.py index 2b9059651..42f58aeda 100644 --- a/packages/flow/openadapt_flow/__main__.py +++ b/packages/flow/openadapt_flow/__main__.py @@ -637,6 +637,38 @@ def _enforce_surface_binding( return None +def _recording_encryption_key(args: argparse.Namespace, backend: str) -> Optional[str]: + """The passphrase that encrypts this recording, or ``None`` for plaintext. + + An explicit ``OPENADAPT_RECORDING_KEY`` turns encryption on. Where the + recorder can encrypt every file from the first frame (the web recorder), + a configured ``OPENADAPT_BUNDLE_KEY`` turns it on by default too, so a + deployment that seals its bundles does not leave the recordings they come + from in plaintext. ``--no-encrypt`` opts out. The desktop recorders write + a raw capture session through openadapt-capture that Flow cannot encrypt + yet, so an explicit recording key refuses them rather than write + plaintext. + """ + import os + + from openadapt_flow import crypto, sealed_set + + if getattr(args, "no_encrypt", False): + return None + explicit = os.environ.get(sealed_set.RECORDING_ENV_KEY) or None + if backend != "web": + if explicit: + raise SystemExit( + f"record: {sealed_set.RECORDING_ENV_KEY} is set, but the " + f"{backend} recorder writes its raw capture session (database, " + "video) through openadapt-capture, which Flow cannot encrypt " + "yet. Nothing was recorded. Record on an encrypted volume and " + "pass --no-encrypt, or use --backend web." + ) + return None + return explicit or crypto.resolve_key(None) + + def _stamp_recording_surface(recording_dir: Path, surface: str) -> None: """Stamp the recorded surface into ``meta.json`` (additive, Section 5). @@ -650,15 +682,19 @@ def _stamp_recording_surface(recording_dir: Path, surface: str) -> None: """ import json + from openadapt_flow import sealed_set + meta_path = Path(recording_dir) / "meta.json" try: - meta = json.loads(meta_path.read_text()) + meta = json.loads(sealed_set.read_text(meta_path)) + except sealed_set.SealedSetError: + raise except (OSError, ValueError): return if meta.get("surface") == surface: return meta["surface"] = surface - meta_path.write_text(json.dumps(meta, indent=2)) + sealed_set.write_text(meta_path, json.dumps(meta, indent=2)) def _report_backend_kind(kind: object) -> "ExecutionTargetKind": @@ -1259,6 +1295,7 @@ def _cmd_record_capture(args: argparse.Namespace) -> int: getattr(args, "browser_cdp_endpoint", None) or getattr(args, "browser_page_url", None) ) + recording_key = _recording_encryption_key(args, backend) if backend in ("windows", "macos", "linux", "rdp", "citrix"): if getattr(args, "capture_network", False): raise SystemExit( @@ -1323,11 +1360,17 @@ def _cmd_record_capture(args: argparse.Namespace) -> int: stop_when=_record_until_stop(args), surface="web", capture_network=bool(getattr(args, "capture_network", False)), + encryption_key=recording_key, ) except BrowserAttachError as exc: raise SystemExit(f"record: browser attachment refused: {exc}") from exc _stamp_recording_surface(out, "web") print(f"Recording written to {out}") + if recording_key: + print( + "Encrypted at rest: frames, events, metadata and network capture " + "were sealed with AES-256-GCM before they reached the disk." + ) secrets = sorted(args.secret or ()) if secrets: print( @@ -1352,8 +1395,10 @@ def _recording_privacy_notices(recording_dir: Path) -> list[str]: """ import json + from openadapt_flow import sealed_set + try: - meta = json.loads((Path(recording_dir) / "meta.json").read_text()) + meta = json.loads(sealed_set.read_text(Path(recording_dir) / "meta.json")) except (OSError, ValueError): return [] notices: list[str] = [] @@ -4693,6 +4738,7 @@ def _cmd_seal(args: argparse.Namespace) -> int: return 2 print(f"Sealed bundle: {sealed.path}") print(f"Content digest: sha256:{sealed.content_digest}") + print(f"Sealed file set: sha256:{sealed.set_digest}") if sealed.certification_invalidated: print( "Prior certification invalidated: certify the sealed destination " @@ -7713,6 +7759,15 @@ def build_parser() -> argparse.ArgumentParser: "pre-profile default." ), ) + p.add_argument( + "--no-encrypt", + action="store_true", + help=( + "Write the recording in plaintext even when OPENADAPT_BUNDLE_KEY " + "or OPENADAPT_RECORDING_KEY is set (web recordings are encrypted " + "by default when a key is configured)" + ), + ) p.add_argument( "--capture-network", action="store_true", @@ -9077,9 +9132,9 @@ def build_parser() -> argparse.ArgumentParser: p = sub.add_parser( "seal", help=( - "Copy a bundle to a new path, encrypt its workflow and template " - "evidence with OPENADAPT_BUNDLE_KEY, verify integrity, and publish " - "it atomically" + "Encrypt every file of a bundle that can hold patient data with " + "OPENADAPT_BUNDLE_KEY, bind the whole file set to one sealed " + "index, verify it, and publish it atomically at a new path" ), ) p.add_argument("source", help="Existing workflow bundle directory") diff --git a/packages/flow/openadapt_flow/bundle_sealing.py b/packages/flow/openadapt_flow/bundle_sealing.py index 476b63f73..70e21f412 100644 --- a/packages/flow/openadapt_flow/bundle_sealing.py +++ b/packages/flow/openadapt_flow/bundle_sealing.py @@ -1,4 +1,4 @@ -"""Atomic, non-destructive encryption of a compiled workflow bundle.""" +"""Atomic, non-destructive encryption of a whole compiled workflow bundle.""" from __future__ import annotations @@ -13,7 +13,7 @@ from errno import EEXIST, ENOTEMPTY from pathlib import Path -from openadapt_flow import crypto +from openadapt_flow import crypto, sealed_set from openadapt_flow.ir import Workflow @@ -28,6 +28,7 @@ class SealedBundle: path: Path content_digest: str certification_invalidated: bool + set_digest: str = "" def _lexists(path: Path) -> bool: @@ -75,34 +76,71 @@ def _validate_paths(source: Path, destination: Path) -> tuple[Path, Path]: return source, destination -def _verify_sealed_bundle(bundle: Path, key: str) -> Workflow: - if (bundle / "workflow.json").exists(): - raise BundleSealingError("sealed bundle retained plaintext workflow.json") - encrypted_workflow = bundle / "workflow.json.enc" - if not encrypted_workflow.is_file() or not crypto.is_encrypted( - encrypted_workflow.read_bytes() - ): - raise BundleSealingError("sealed bundle is missing encrypted workflow.json") - - templates = bundle / "templates" - if templates.is_dir(): - for path in templates.rglob("*"): - if path.is_symlink(): - raise BundleSealingError("sealed bundle contains a template symlink") - if not path.is_file(): - continue - if path.suffix != ".enc" or not crypto.is_encrypted(path.read_bytes()): - raise BundleSealingError( - "sealed bundle retained a plaintext template asset: " - f"{path.relative_to(bundle).as_posix()}" - ) - +def _verify_sealed_bundle(bundle: Path, key: str) -> tuple[Workflow, str]: + """Re-open the staged bundle exactly as a reader would and check that no + file holding patient data is left in plaintext.""" + try: + opened = sealed_set.SealedSet.open(bundle, key, expect="bundle") + opened.verify_all() + except sealed_set.SealedSetError as exc: + raise BundleSealingError(f"sealed bundle failed verification: {exc}") from exc + for rel in opened.files(): + entry = sealed_set.classify("bundle", rel) + if entry is None or ( + entry.classification != "structural" and opened.codec(rel) != "set" + ): + raise BundleSealingError(f"sealed bundle retained plaintext {rel}") workflow = Workflow.load(bundle, key=key, verify_integrity=True) if not workflow.encrypted or workflow.manifest is None: raise BundleSealingError("sealed bundle did not retain its encrypted manifest") if not workflow.manifest.content_digest: raise BundleSealingError("sealed bundle has no integrity content digest") - return workflow + return workflow, opened.set_digest + + +def _source_files(source: Path, key: str) -> dict[str, bytes]: + """Every file of the source bundle except the parts ``Workflow`` owns, + read into memory and classified. An unknown file is refused.""" + handled = {"workflow.json", "workflow.json.enc", "manifest.json"} + reader: sealed_set.SealedSet | None = None + if sealed_set.is_sealed(source): + reader = sealed_set.SealedSet.open(source, key, expect="bundle") + names = reader.files() + else: + names = [ + p.relative_to(source).as_posix() + for p in sorted(source.rglob("*")) + if p.is_file() + ] + out: dict[str, bytes] = {} + unknown: list[str] = [] + for rel in names: + if rel in handled or rel.startswith("templates/"): + continue + if sealed_set.classify("bundle", rel) is None: + unknown.append(rel) + continue + out[rel] = reader.read_bytes(rel) if reader else (source / rel).read_bytes() + if unknown: + raise BundleSealingError( + "source bundle contains file(s) Flow does not know, so it cannot " + "say whether they hold patient data: " + + ", ".join(unknown) + + ". Move them out of the bundle (or delete them) and seal again." + ) + return out + + +def _template_assets(source: Path, workflow: Workflow) -> dict[str, bytes]: + """Plaintext template crops: decrypted in memory for an encrypted source, + read from disk for a plaintext one.""" + assets = workflow.decrypted_templates() + tdir = source / "templates" + if tdir.is_dir() and not sealed_set.is_sealed(source): + for path in sorted(tdir.rglob("*")): + if path.is_file() and path.suffix != ".enc": + assets.setdefault(path.relative_to(source).as_posix(), path.read_bytes()) + return assets def _publish_no_replace(staging: Path, destination: Path) -> None: @@ -219,10 +257,16 @@ def _cleanup_staging(staging: Path | None, original_error: Exception) -> None: def seal_bundle(source: Path | str, destination: Path | str) -> SealedBundle: - """Copy, validate, encrypt, verify, and atomically publish a bundle. - - The source is never modified. ``OPENADAPT_BUNDLE_KEY`` is the only supported - key input so the passphrase cannot leak through process arguments. + """Validate, encrypt, verify, and atomically publish a sealed bundle. + + Every file of the source is classified against + :data:`openadapt_flow.sealed_set.BUNDLE_FILES`; an unknown file refuses + the seal. Every file that can hold patient data is encrypted in memory, + and the whole set is bound to one authenticated index, so no file can be + swapped, added or dropped afterwards (:mod:`openadapt_flow.sealed_set`). + The source is never modified. ``OPENADAPT_BUNDLE_KEY`` is the only + supported key input so the passphrase cannot leak through process + arguments. """ staging: Path | None = None try: @@ -234,9 +278,13 @@ def seal_bundle(source: Path | str, destination: Path | str) -> SealedBundle: source_path, destination_path = _validate_paths(Path(source), Path(destination)) _reject_symlinks(source_path) try: - Workflow.load(source_path, key=key, verify_integrity=True) + source_workflow = Workflow.load( + source_path, key=key, verify_integrity=True + ) except Exception as exc: raise BundleSealingError(f"source bundle validation failed: {exc}") from exc + others = _source_files(source_path, key) + assets = _template_assets(source_path, source_workflow) destination_path.parent.mkdir(parents=True, exist_ok=True) staging = Path( @@ -246,19 +294,16 @@ def seal_bundle(source: Path | str, destination: Path | str) -> SealedBundle: ) ) staging.chmod(0o700) - shutil.copytree( - source_path, - staging, - dirs_exist_ok=True, - symlinks=True, - ) - # Refuse a symlink introduced between the source preflight and copy. - _reject_symlinks(staging) - staged_workflow = Workflow.load(staging, key=key, verify_integrity=True) - certification_invalidated = _invalidate_prior_certification(staged_workflow) - staged_workflow.save(staging, encrypt=True, key=key) + # Nothing is copied: every byte is encrypted in memory and only + # ciphertext (and the structural manifest) is written to staging. + certification_invalidated = _invalidate_prior_certification(source_workflow) + source_workflow._decrypted_templates = dict(assets) + with sealed_set.writing(staging, "bundle", key) as writer: + source_workflow.save(staging, encrypt=True, key=key) + for rel, data in others.items(): + writer.write(rel, data) _reject_symlinks(staging) - sealed = _verify_sealed_bundle(staging, key) + sealed, set_digest = _verify_sealed_bundle(staging, key) manifest = sealed.manifest if manifest is None: # Defensive: _verify_sealed_bundle already refuses this. raise BundleSealingError("sealed bundle did not retain its manifest") @@ -273,6 +318,7 @@ def seal_bundle(source: Path | str, destination: Path | str) -> SealedBundle: path=destination_path, content_digest=manifest.content_digest, certification_invalidated=certification_invalidated, + set_digest=set_digest, ) except BundleSealingError as exc: _cleanup_staging(staging, exc) diff --git a/packages/flow/openadapt_flow/bundle_validation.py b/packages/flow/openadapt_flow/bundle_validation.py index 3387d3645..5663bec1e 100644 --- a/packages/flow/openadapt_flow/bundle_validation.py +++ b/packages/flow/openadapt_flow/bundle_validation.py @@ -410,15 +410,25 @@ def interstitial_asset_paths(interstitials: Iterable[Interstitial]) -> set[str]: return refs -def compute_file_hashes(workflow: "Workflow", bundle_dir: Path | str) -> dict[str, str]: +def compute_file_hashes( + workflow: "Workflow", + bundle_dir: Path | str, + *, + assets: Optional[dict[str, bytes]] = None, +) -> dict[str, str]: """SHA-256 every template/image asset in the bundle. + ``assets`` (bundle-relative path -> plaintext) replaces the disk scan for + a sealed bundle, whose assets exist only as ciphertext on disk. + Hashes the union of (a) every file under ``templates/`` and (b) every bundle-relative image path the IR references, that actually exists on disk. Keyed by POSIX bundle-relative path, sorted for a stable, reproducible manifest. Missing referenced files are simply omitted (the structural validator reports dangling references separately; the digest reflects what is actually present).""" + if assets is not None: + return {rel: _sha256_bytes(assets[rel]) for rel in sorted(assets)} bundle = Path(bundle_dir) paths: set[str] = set() @@ -503,7 +513,12 @@ def compute_parameter_schema_digest(workflow: "Workflow") -> str: return _sha256_bytes(canonical.encode("utf-8")) -def build_manifest(workflow: "Workflow", bundle_dir: Path | str) -> BundleManifest: +def build_manifest( + workflow: "Workflow", + bundle_dir: Path | str, + *, + assets: Optional[dict[str, bytes]] = None, +) -> BundleManifest: """Compute a fresh :class:`BundleManifest` for ``workflow`` in ``bundle_dir``. Recomputes the asset hashes and content digest, stamps the current compiler @@ -516,7 +531,7 @@ def build_manifest(workflow: "Workflow", bundle_dir: Path | str) -> BundleManife prior = workflow.manifest prior_prov = prior.provenance if prior is not None else None - file_hashes = compute_file_hashes(workflow, bundle_dir) + file_hashes = compute_file_hashes(workflow, bundle_dir, assets=assets) digest = compute_content_digest(workflow, file_hashes) provenance = BundleProvenance( diff --git a/packages/flow/openadapt_flow/compiler/api_proposal.py b/packages/flow/openadapt_flow/compiler/api_proposal.py index 8be653a45..0d14204fe 100644 --- a/packages/flow/openadapt_flow/compiler/api_proposal.py +++ b/packages/flow/openadapt_flow/compiler/api_proposal.py @@ -50,6 +50,7 @@ from pathlib import Path from typing import Any, Optional +from openadapt_flow import sealed_set as _sealed from openadapt_flow import network_capture as nc from openadapt_flow.ir import ( ActionKind, @@ -81,7 +82,7 @@ def _events(recording: Path) -> list[dict[str, Any]]: path = recording / "events.jsonl" return [ json.loads(line) - for line in path.read_text(encoding="utf-8").splitlines() + for line in _sealed.read_text(path).splitlines() if line.strip() ] @@ -138,7 +139,7 @@ def propose_api_step( None, ["the program has branches; API steps cover linear programs only"], ) - meta = json.loads((recording / "meta.json").read_text(encoding="utf-8")) + meta = json.loads(_sealed.read_text(recording / "meta.json")) params = {k: str(v) for k, v in (meta.get("params") or {}).items()} if not params: return workflow, None, ["the recording has no parameters to map into a request"] diff --git a/packages/flow/openadapt_flow/compiler/compile.py b/packages/flow/openadapt_flow/compiler/compile.py index 5a953566a..0c0aaf87c 100644 --- a/packages/flow/openadapt_flow/compiler/compile.py +++ b/packages/flow/openadapt_flow/compiler/compile.py @@ -33,6 +33,7 @@ import numpy as np +from openadapt_flow import sealed_set as _sealed from openadapt_flow import volatility from openadapt_flow.ir import ( ActionKind, @@ -219,7 +220,7 @@ def _selection_readback_region(png: bytes, target_region: Region) -> Region: def _load_events(recording_dir: Path) -> list[dict]: """Parse events.jsonl into a list of event dicts.""" events = [] - with (recording_dir / "events.jsonl").open() as f: + with _sealed.open_text(recording_dir / "events.jsonl") as f: for line in f: line = line.strip() if line: @@ -229,7 +230,7 @@ def _load_events(recording_dir: Path) -> list[dict]: def _read_png(path: Path) -> Optional[bytes]: """Read PNG bytes, or None if the file does not exist.""" - return path.read_bytes() if path.exists() else None + return _sealed.read_bytes(path) if _sealed.is_file(path) else None def _png_viewport(png: bytes, *, source: str) -> tuple[int, int]: @@ -1073,7 +1074,7 @@ def _postconditions( template_rel: Optional[str] = None if bundle is not None and step_id is not None: template_rel = f"templates/{step_id}_expect.png" - (bundle / template_rel).write_bytes(_crop_png(after_png, padded)) + _sealed.write_bytes(bundle / template_rel, _crop_png(after_png, padded)) expect.append( Postcondition( kind=PostconditionKind.REGION_STABLE, @@ -1511,7 +1512,7 @@ def _emit_identifier_crop( return None, None, reason crop_rel = f"{IDENTIFIER_CROP_DIR}/{step_id}.png" (bundle / IDENTIFIER_CROP_DIR).mkdir(parents=True, exist_ok=True) - (bundle / crop_rel).write_bytes(_crop_png(before_png, region)) + _sealed.write_bytes(bundle / crop_rel, _crop_png(before_png, region)) return crop_rel, region, None @@ -1815,6 +1816,47 @@ def _apply_param_overrides( def compile_recording( + recording_dir: Path | str, + out_bundle_dir: Path | str, + *, + name: str, + **kwargs: Any, +) -> Workflow: + """Compile a recording directory into a workflow bundle. + + See :func:`_compile_recording` for the compiler itself. This wrapper + decides where the output's bytes go. An encrypted recording (a sealed + set, see :mod:`openadapt_flow.sealed_set`) is read in memory and compiles + into a sealed bundle: every crop, sidecar and ``workflow.json`` is + encrypted before it reaches the disk, under ``OPENADAPT_BUNDLE_KEY`` or, + when that is unset, the recording's own key. Without a key the compile + refuses before reading anything. + """ + recording = Path(recording_dir) + bundle = Path(out_bundle_dir) + sealed_input = _sealed.is_sealed(recording) + sealed_output = _sealed.is_sealed(bundle) + if _sealed.active_writer(bundle) is not None or not ( + sealed_input or sealed_output + ): + return _compile_recording(recording, bundle, name=name, **kwargs) + if sealed_input: + # Opening the recording verifies its whole file set before compiling. + _sealed.open_set(recording) + passphrase = _sealed.passphrase_for("bundle") or _sealed.recording_key() + if not passphrase: + raise _sealed.SealedSetKeyError(_sealed.missing_key_message(bundle, "bundle")) + if not sealed_output and bundle.exists() and any(bundle.iterdir()): + raise _sealed.SealedSetError( + f"{bundle} already holds an unencrypted bundle; compile an " + "encrypted recording into a new directory so no plaintext is mixed " + "into the sealed output" + ) + with _sealed.writing(bundle, "bundle", passphrase, resume=sealed_output): + return _compile_recording(recording, bundle, name=name, **kwargs) + + +def _compile_recording( recording_dir: Path | str, out_bundle_dir: Path | str, *, @@ -1982,7 +2024,7 @@ def compile_recording( bundle = Path(out_bundle_dir) (bundle / "templates").mkdir(parents=True, exist_ok=True) - meta = json.loads((recording / "meta.json").read_text()) + meta = json.loads(_sealed.read_text(recording / "meta.json")) backend_hints_raw = meta.get("backend_hints") backend_hints: Optional[BackendHints] = None if backend_hints_raw is not None: @@ -2075,8 +2117,8 @@ def cached_lines(i: int, suffix: str, png: bytes) -> list[OcrLine]: step_id = f"step_{i:03d}" before_path = recording / "frames" / f"{i:04d}_before.png" after_path = recording / "frames" / f"{i:04d}_after.png" - if before_path.is_file() != after_path.is_file(): - missing = before_path if not before_path.is_file() else after_path + if _sealed.is_file(before_path) != _sealed.is_file(after_path): + missing = before_path if not _sealed.is_file(before_path) else after_path raise FileNotFoundError( f"incomplete frame pair for {kind} event {i}: missing {missing}" ) @@ -2142,7 +2184,7 @@ def cached_lines(i: int, suffix: str, png: bytes) -> list[OcrLine]: ) template_bytes = _crop_png(before_png, crop_region) template_rel = f"templates/{step_id}.png" - (bundle / template_rel).write_bytes(template_bytes) + _sealed.write_bytes(bundle / template_rel, template_bytes) ocr_text = _best_target_text( ocr(before_png, region=crop_region), @@ -2265,7 +2307,7 @@ def cached_lines(i: int, suffix: str, png: bytes) -> list[OcrLine]: ) if selected_target is not None: anchor_region, anchor_ocr_text = selected_target - (bundle / template_rel).write_bytes( + _sealed.write_bytes(bundle / template_rel, _crop_png(before_png, anchor_region) ) if event.get("identifier_scope") == "dialog": @@ -2306,7 +2348,7 @@ def cached_lines(i: int, suffix: str, png: bytes) -> list[OcrLine]: ) end_crop_region = _discriminative_crop_region(frame, drag_end) end_template_rel = f"templates/{step_id}_drag_end.png" - (bundle / end_template_rel).write_bytes( + _sealed.write_bytes(bundle / end_template_rel, _crop_png(before_png, end_crop_region) ) end_ocr_text = _best_crop_text( @@ -2533,7 +2575,7 @@ def cached_lines(i: int, suffix: str, png: bytes) -> list[OcrLine]: raise ValueError(f"read event {i} has a label box but no frame") lx, ly, lw, lh = (int(v) for v in label_region) label_rel = f"templates/{step_id}.png" - (bundle / label_rel).write_bytes( + _sealed.write_bytes(bundle / label_rel, _crop_png(before_png, (lx, ly, lw, lh)) ) read_anchor = Anchor( @@ -3088,7 +3130,7 @@ def cached_lines(i: int, suffix: str, png: bytes) -> list[OcrLine]: ) source_recording_sha256: Optional[str] = None - if (recording / ".openadapt-approval.json").is_file(): + if _sealed.is_file(recording / ".openadapt-approval.json"): from openadapt_flow.sanitized_artifact import load_valid_approval source_recording_sha256 = load_valid_approval(recording)[ @@ -3162,7 +3204,7 @@ def cached_lines(i: int, suffix: str, png: bytes) -> list[OcrLine]: drop_region, _mask_value(drop_text), ) - (bundle / "param_hygiene.json").write_text( + _sealed.write_text(bundle / "param_hygiene.json", json.dumps( { "note": ( @@ -3185,7 +3227,7 @@ def cached_lines(i: int, suffix: str, png: bytes) -> list[OcrLine]: ) ) else: - (bundle / "param_hygiene.json").unlink(missing_ok=True) + _sealed.unlink(bundle / "param_hygiene.json", missing_ok=True) # DETERMINISTIC field-label parameter proposals (no model, $0, always on): # a typed constant whose step carries field_label evidence yields a @@ -3223,7 +3265,7 @@ def cached_lines(i: int, suffix: str, png: bytes) -> list[OcrLine]: } ) if label_proposals: - (bundle / "param_proposals.json").write_text( + _sealed.write_text(bundle / "param_proposals.json", json.dumps( { "note": ( @@ -3248,7 +3290,7 @@ def cached_lines(i: int, suffix: str, png: bytes) -> list[OcrLine]: else: # A recompile after the confirm pass must not leave a stale sidecar # behind: the sidecar always reflects the OUTSTANDING proposals. - (bundle / "param_proposals.json").unlink(missing_ok=True) + _sealed.unlink(bundle / "param_proposals.json", missing_ok=True) # Opt-in COMPILE-TIME model annotation (off by default). Runs LAST, over the # fully-built workflow. Applies model proposals with the confirm-don't-trust @@ -3262,7 +3304,7 @@ def cached_lines(i: int, suffix: str, png: bytes) -> list[OcrLine]: ann = annotator if annotator is not None else AnthropicStepAnnotator() result = apply_annotations(workflow, ann) workflow = result.workflow - (bundle / "annotations.json").write_text( + _sealed.write_text(bundle / "annotations.json", result.model_dump_json(indent=2, exclude={"workflow"}) ) for flag in result.flagged: @@ -3280,7 +3322,7 @@ def cached_lines(i: int, suffix: str, png: bytes) -> list[OcrLine]: # check that can't be compiled fails the compile rather than being dropped. from openadapt_flow.compiler.readback_authoring import CHECK_FILE, apply_check - if (recording / CHECK_FILE).is_file(): + if _sealed.is_file(recording / CHECK_FILE): workflow, _check = apply_check(workflow, recording) logger.info( "compiled the demonstrated check: %d field(s) on %s (%s)", @@ -3294,7 +3336,7 @@ def cached_lines(i: int, suffix: str, png: bytes) -> list[OcrLine]: # after a person admits it (openadapt-flow api admit). from openadapt_flow.network_capture import NETWORK_FILE - if (recording / NETWORK_FILE).is_file(): + if _sealed.is_file(recording / NETWORK_FILE): from openadapt_flow.compiler.api_proposal import propose_api_step workflow, _api_step, _api_reasons = propose_api_step(workflow, recording) @@ -3312,7 +3354,7 @@ def cached_lines(i: int, suffix: str, png: bytes) -> list[OcrLine]: else: logger.info("no API step proposed: %s", "; ".join(_api_reasons)) # Say what happened either way, so "no API step" is never silent. - (bundle / "api_proposal.json").write_text( + _sealed.write_text(bundle / "api_proposal.json", json.dumps( { "proposed": _api_step is not None, @@ -3358,7 +3400,8 @@ def cached_lines(i: int, suffix: str, png: bytes) -> list[OcrLine]: if layout_fingerprints: fp_path = bundle / _layout.FINGERPRINT_ASSET fp_path.parent.mkdir(parents=True, exist_ok=True) - fp_path.write_bytes( + _sealed.write_bytes( + fp_path, _layout.dump_bundle_fingerprints( layout_fingerprints, scale_landmarks={"salt": scale_salt, "steps": scale_landmarks}, @@ -3367,7 +3410,7 @@ def cached_lines(i: int, suffix: str, png: bytes) -> list[OcrLine]: write_compile_notes(bundle, compile_notes) workflow.save(bundle) - (bundle / "workflow.py").write_text(render_workflow_py(workflow)) + _sealed.write_text(bundle / "workflow.py", render_workflow_py(workflow)) return workflow diff --git a/packages/flow/openadapt_flow/compiler/fixup_passes.py b/packages/flow/openadapt_flow/compiler/fixup_passes.py index cac6c31c0..5b068f94c 100644 --- a/packages/flow/openadapt_flow/compiler/fixup_passes.py +++ b/packages/flow/openadapt_flow/compiler/fixup_passes.py @@ -75,6 +75,7 @@ from pathlib import Path from typing import Any, Callable, Mapping, Optional +from openadapt_flow import sealed_set as _sealed from openadapt_flow.ir import ActionKind, Postcondition, PostconditionKind, Step #: Frames per step id: ``(before_png, after_png)``; either may be ``None``. @@ -271,7 +272,7 @@ def checkable(step: Step) -> frozenset[str]: changed = True rel = f"templates/{step.id}_expect.png" (bundle_dir / rel).parent.mkdir(parents=True, exist_ok=True) - (bundle_dir / rel).write_bytes(crop_png(after, region)) # type: ignore[arg-type] + _sealed.write_bytes(bundle_dir / rel, crop_png(after, region)) # type: ignore[arg-type] new.expect = [ Postcondition( kind=PostconditionKind.REGION_STABLE, @@ -311,7 +312,7 @@ def checkable(step: Step) -> frozenset[str]: region = tuple(int(v) for v in step.anchor.region) rel = f"templates/identifiers/{step.id}.png" (bundle_dir / rel).parent.mkdir(parents=True, exist_ok=True) - (bundle_dir / rel).write_bytes(crop_png(before, region)) # type: ignore[arg-type] + _sealed.write_bytes(bundle_dir / rel, crop_png(before, region)) # type: ignore[arg-type] assert new.anchor is not None new.anchor.identifier_crop = rel new.anchor.identifier_region = region # type: ignore[assignment] @@ -335,11 +336,14 @@ def write_compile_notes(bundle_dir: Path, notes: list[dict[str, Any]]) -> None: import json + from openadapt_flow import sealed_set + path = Path(bundle_dir) / COMPILE_NOTES_FILE if not notes: - path.unlink(missing_ok=True) + sealed_set.unlink(path, missing_ok=True) return - path.write_text( + sealed_set.write_text( + path, json.dumps( { "schema": COMPILE_NOTES_SCHEMA, diff --git a/packages/flow/openadapt_flow/compiler/param_confirm.py b/packages/flow/openadapt_flow/compiler/param_confirm.py index 74aa097b9..5978a7edd 100644 --- a/packages/flow/openadapt_flow/compiler/param_confirm.py +++ b/packages/flow/openadapt_flow/compiler/param_confirm.py @@ -37,6 +37,8 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, Callable, Iterable, Optional +from openadapt_flow import sealed_set as _sealed + if TYPE_CHECKING: # pragma: no cover from openadapt_flow.ir import Workflow @@ -73,9 +75,9 @@ class ParamDecision: def load_proposals(bundle_dir: Path | str) -> list[ParamProposal]: """Read the flagged proposals sidecar; empty list when there is none.""" path = Path(bundle_dir) / PROPOSALS_FILENAME - if not path.is_file(): + if not _sealed.is_file(path): return [] - raw = json.loads(path.read_text()) + raw = json.loads(_sealed.read_text(path)) proposals: list[ParamProposal] = [] for entry in raw.get("proposals", []) or []: if not isinstance(entry, dict): diff --git a/packages/flow/openadapt_flow/compiler/qualification_pins.py b/packages/flow/openadapt_flow/compiler/qualification_pins.py index d75acb8fe..900852a1d 100644 --- a/packages/flow/openadapt_flow/compiler/qualification_pins.py +++ b/packages/flow/openadapt_flow/compiler/qualification_pins.py @@ -19,6 +19,7 @@ from pydantic import BaseModel, ConfigDict, Field +from openadapt_flow import sealed_set as _sealed from openadapt_flow.ir import ActionKind, Step, Workflow from openadapt_flow.runtime.effects.effect import Effect from openadapt_flow.traversal import iter_workflow_steps @@ -132,10 +133,10 @@ def load_recording_meta(recording_dir: Path | str | None) -> Optional[dict[str, if recording_dir is None: return None path = Path(recording_dir) / "meta.json" - if not path.is_file(): + if not _sealed.is_file(path): return None try: - payload = json.loads(path.read_text(encoding="utf-8")) + payload = json.loads(_sealed.read_text(path)) except (OSError, json.JSONDecodeError): return None return payload if isinstance(payload, dict) else None diff --git a/packages/flow/openadapt_flow/compiler/readback_authoring.py b/packages/flow/openadapt_flow/compiler/readback_authoring.py index daf82a5c4..042a52e01 100644 --- a/packages/flow/openadapt_flow/compiler/readback_authoring.py +++ b/packages/flow/openadapt_flow/compiler/readback_authoring.py @@ -71,6 +71,7 @@ from pydantic import BaseModel, ConfigDict, Field +from openadapt_flow import sealed_set as _sealed from openadapt_flow.compiler.effect_mining import _commit_labels from openadapt_flow.ir import ActionKind, Step, Workflow from openadapt_flow.runtime.effects.effect import ( @@ -222,15 +223,15 @@ def write_check( start_event=start_event, fields=fields, reset=reset, prompt=prompt ) path = Path(recording_dir) / CHECK_FILE - path.write_text(marks.model_dump_json(indent=2) + "\n", encoding="utf-8") + _sealed.write_text(path, marks.model_dump_json(indent=2) + "\n") return path def load_check(recording_dir: Path | str) -> Optional[CheckMarks]: path = Path(recording_dir) / CHECK_FILE - if not path.is_file(): + if not _sealed.is_file(path): return None - return CheckMarks.model_validate_json(path.read_text(encoding="utf-8")) + return CheckMarks.model_validate_json(_sealed.read_text(path)) def _event_index(step: Step) -> Optional[int]: @@ -328,7 +329,7 @@ def _marked_frame(recording_dir: Path, marks: CheckMarks, n_events: int) -> Path last = n_events - 1 candidates = [frames / f"{last:04d}_after.png", frames / f"{last:04d}_before.png"] for path in candidates: - if path.is_file(): + if _sealed.is_file(path): return path raise CheckError( "the recording has no frame of the screen the fields were marked on" @@ -336,7 +337,7 @@ def _marked_frame(recording_dir: Path, marks: CheckMarks, n_events: int) -> Path def _count_events(recording_dir: Path) -> int: - lines = (recording_dir / "events.jsonl").read_text(encoding="utf-8").splitlines() + lines = _sealed.read_text(recording_dir / "events.jsonl").splitlines() return sum(1 for line in lines if line.strip()) @@ -419,7 +420,7 @@ def apply_check( vision = vision_module png = ( - _marked_frame(recording, marks, _count_events(recording)).read_bytes() + _sealed.read_bytes(_marked_frame(recording, marks, _count_events(recording))) if not all(f.structure_matched for f in marks.fields) else b"" ) @@ -540,7 +541,9 @@ def _demonstrated_by(recording: Path) -> Optional[str]: """``demonstrated_by`` from the recording's ``meta.json``, if it says.""" try: - meta = json.loads((recording / "meta.json").read_text(encoding="utf-8")) + meta = json.loads(_sealed.read_text(recording / "meta.json")) + except _sealed.SealedSetError: + raise except (OSError, ValueError): return None value = meta.get("demonstrated_by") if isinstance(meta, dict) else None diff --git a/packages/flow/openadapt_flow/crypto.py b/packages/flow/openadapt_flow/crypto.py index 521f8a847..325fff233 100644 --- a/packages/flow/openadapt_flow/crypto.py +++ b/packages/flow/openadapt_flow/crypto.py @@ -129,6 +129,8 @@ def is_encrypted(data: bytes) -> bool: ``format`` field is the module magic. Plaintext ``workflow.json`` / a checkpoint JSON fails this, so the unencrypted path is never misrouted. """ + if data[: len(SET_FILE_MAGIC)] == SET_FILE_MAGIC: + return True try: obj = json.loads(data) except (ValueError, TypeError): @@ -201,3 +203,69 @@ def decrypt_bytes(container: bytes, key: Optional[str], *, aad: bytes) -> bytes: "decryption failed: wrong key or the ciphertext was tampered with " "(authentication tag mismatch). The payload was NOT loaded." ) from exc + + +# -- one derived key for a whole sealed set ---------------------------------- +# +# ``encrypt_bytes`` runs scrypt for every payload, which is right for one +# workflow.json and wrong for a recording that writes a frame every few hundred +# milliseconds. A sealed set (a sealed bundle or an encrypted recording; see +# :mod:`openadapt_flow.sealed_set`) runs scrypt ONCE over a per-set random salt +# and then seals each file with AES-256-GCM under that key, a fresh random +# 96-bit nonce per file, and the file's set-relative path in the associated +# data. Nothing here is a new primitive: the KDF and the AEAD are the same +# :mod:`cryptography` objects used above. + +#: Prefix of a per-file container sealed under a :class:`SetKey`. +SET_FILE_MAGIC: Final[bytes] = b"OAFLOW-SEALED-1\n" + + +class SetKey: + """An AES-256-GCM key derived once from a passphrase and a set salt.""" + + __slots__ = ("_aead",) + + def __init__(self, data_key: bytes) -> None: + if len(data_key) != _KEY_LEN: + raise CryptoError("a set key must be 32 bytes") + self._aead = AESGCM(data_key) + + @classmethod + def derive( + cls, + passphrase: str, + salt: bytes, + *, + n: int = _SCRYPT_N, + r: int = _SCRYPT_R, + p: int = _SCRYPT_P, + ) -> "SetKey": + return cls(_derive(passphrase, salt, n=n, r=r, p=p)) + + @staticmethod + def new_salt() -> bytes: + return secrets.token_bytes(_SALT_LEN) + + def seal(self, plaintext: bytes, *, aad: bytes) -> bytes: + nonce = secrets.token_bytes(_NONCE_LEN) + return SET_FILE_MAGIC + nonce + self._aead.encrypt(nonce, plaintext, aad) + + def open(self, container: bytes, *, aad: bytes) -> bytes: + if not container.startswith(SET_FILE_MAGIC): + raise DecryptionError("not an openadapt-flow sealed-set file") + body = container[len(SET_FILE_MAGIC) :] + nonce, ciphertext = body[:_NONCE_LEN], body[_NONCE_LEN:] + if len(nonce) != _NONCE_LEN: + raise DecryptionError("truncated sealed-set file") + try: + return self._aead.decrypt(nonce, ciphertext, aad) + except InvalidTag as exc: + raise DecryptionError( + "decryption failed: wrong key, or the file was tampered with, " + "renamed or moved from another sealed set. Nothing was loaded." + ) from exc + + +def kdf_parameters() -> dict[str, int]: + """The scrypt work factors new containers record.""" + return {"kdf_n": _SCRYPT_N, "kdf_r": _SCRYPT_R, "kdf_p": _SCRYPT_P} diff --git a/packages/flow/openadapt_flow/interactive_recorder.py b/packages/flow/openadapt_flow/interactive_recorder.py index 8872e355d..857108f0b 100644 --- a/packages/flow/openadapt_flow/interactive_recorder.py +++ b/packages/flow/openadapt_flow/interactive_recorder.py @@ -91,6 +91,7 @@ PlaywrightBackend, ) from openadapt_flow.ir import StructuralLocator, structural_locator_is_locatable +from openadapt_flow import sealed_set as _sealed from openadapt_flow.recorder import Recorder from openadapt_flow.submit_binding import ( OBSERVE_SUBMIT_BINDING_JS, @@ -2209,9 +2210,14 @@ def __init__( stop_when: Optional[Callable[[], bool]] = None, surface: str = "web", capture_network: bool = False, + encryption_key: Optional[str] = None, ) -> None: self._url = url self._surface = surface + # Encrypted recording: with a key the recorder writes ciphertext only + # (openadapt_flow.sealed_set), and the published directory is a + # finalized sealed set. + self._encryption_key = encryption_key # ``record --capture-network``: keep the page's requests next to the # events (openadapt_flow.network_capture). Off by default. self._capture_network = capture_network @@ -2464,6 +2470,8 @@ def start(self) -> None: system_of_record_reader=self._system_of_record_reader, network_capture=self._network, page_backend_factory=self._surface_page_backend, + encryption_key=self._encryption_key, + finalize_on_finish=False, **self._settle, ) if self._owns_browser: @@ -2711,7 +2719,7 @@ def finish(self) -> Path: if self._listener_error is not None: raise self._listener_error meta_path = out / "meta.json" - meta = json.loads(meta_path.read_text()) + meta = json.loads(_sealed.read_text(meta_path)) meta["source"] = ( "openadapt-flow-playwright" if self._owns_browser @@ -2748,7 +2756,8 @@ def finish(self) -> Path: meta["viewport"] = list(self._initial_attached_viewport) meta["viewport_mode"] = "per-event" meta["viewport_history"] = list(self._viewport_history) - meta_path.write_text(json.dumps(meta, indent=2)) + _sealed.write_text(meta_path, json.dumps(meta, indent=2)) + self.recorder.finalize_encryption() if self._listener_error is not None: raise self._listener_error self._assert_no_new_pages() @@ -2765,6 +2774,8 @@ def abort(self) -> None: self.done = True self._pyq.clear() + if self.recorder is not None: + self.recorder.abandon_encryption() try: self._cleanup_page_listeners() finally: diff --git a/packages/flow/openadapt_flow/ir.py b/packages/flow/openadapt_flow/ir.py index 1b64ae8ec..c9e90b9db 100644 --- a/packages/flow/openadapt_flow/ir.py +++ b/packages/flow/openadapt_flow/ir.py @@ -2821,6 +2821,13 @@ def save( """ do_encrypt = encrypt or key is not None bundle = Path(bundle_dir) + from openadapt_flow import sealed_set as _sealed + + if _sealed.active_writer(bundle) is not None or _sealed.is_sealed(bundle): + # A sealed bundle stays sealed whatever ``encrypt`` says: many + # commands re-save a bundle with a bare ``save(bundle)``, and that + # must never turn a sealed bundle back into plaintext. + return self._save_sealed_set(bundle, key, seal_manifest=seal_manifest) bundle.mkdir(parents=True, exist_ok=True) (bundle / "templates").mkdir(exist_ok=True) # Restore any in-memory crops (from a prior encrypted load/save) to disk @@ -2867,6 +2874,48 @@ def save( ) return path + def _save_sealed_set( + self, bundle: Path, key: Optional[str], *, seal_manifest: bool + ) -> Path: + """Save into a sealed bundle (see :mod:`openadapt_flow.sealed_set`). + + Every asset, ``workflow.json`` and the manifest go through the set's + writer, so no plaintext of this workflow touches the disk. Assets come + from the in-memory crop map and from crops already sealed in the set. + """ + from openadapt_flow import sealed_set as _sealed + + writer = _sealed.active_writer(bundle) + owned = writer is None + if writer is None: + passphrase = _sealed.passphrase_for("bundle", key) + if not passphrase: + raise _sealed.SealedSetKeyError( + _sealed.missing_key_message(bundle, "bundle") + ) + writer = _sealed.SealedSetWriter(bundle, "bundle", passphrase, resume=True) + assets = {rel: writer.read_bytes(rel) for rel in writer.files("templates/")} + for rel, data in self._decrypted_templates.items(): + if assets.get(rel) != data: + writer.write(rel, data) + assets[rel] = data + if self.schema_version < SCHEMA_VERSION: + self.schema_version = SCHEMA_VERSION + self.encrypted = True + if seal_manifest: + from openadapt_flow import bundle_validation as _bv + + self.manifest = _bv.build_manifest(self, bundle, assets=assets) + writer.write("workflow.json", self.model_dump_json(indent=2).encode("utf-8")) + if self.manifest is not None: + writer.write( + "manifest.json", self.manifest.model_dump_json(indent=2).encode("utf-8") + ) + self._decrypted_templates = dict(assets) + if owned: + writer.finalize() + return bundle / ("workflow.json" + _sealed.SEALED_SUFFIX) + @classmethod def load( cls, @@ -2906,10 +2955,24 @@ def load( bundle = Path(bundle_dir) from openadapt_flow import bundle_validation as _bv + from openadapt_flow import sealed_set as _sealed + encrypted_path = bundle / "workflow.json.enc" plaintext_path = bundle / "workflow.json" + sealed_assets: Optional[dict[str, bytes]] = None bundle_encrypted = encrypted_path.is_file() - if bundle_encrypted: + if _sealed.active_writer(bundle) is not None or _sealed.is_sealed(bundle): + # A sealed set: verify the whole file set, then decrypt the + # workflow and every crop in memory. + reader: Any = _sealed.active_writer(bundle) or _sealed.open_set( + bundle, key + ) + bundle_encrypted = True + raw = json.loads(reader.read_bytes("workflow.json")) + sealed_assets = { + rel: reader.read_bytes(rel) for rel in reader.files("templates/") + } + elif bundle_encrypted: from openadapt_flow import crypto as _crypto decrypted = _crypto.decrypt_bytes( @@ -2930,7 +2993,9 @@ def load( wf.manifest = BundleManifest.model_validate_json(sidecar.read_text()) persisted = wf.manifest - if bundle_encrypted: + if sealed_assets is not None: + wf._decrypted_templates = sealed_assets + elif bundle_encrypted: # Decrypt the sealed image crops IN MEMORY (fail-loud on wrong key / # tamper, exactly as the workflow.json above). The resolver reads # them via ``decrypted_template``; nothing cleartext lands on disk. @@ -5235,6 +5300,12 @@ def save( raise ValueError("report filename must be one local file name") path = run / filename payload = self.model_dump_json(indent=2).encode("utf-8") + from openadapt_flow import sealed_set as _sealed + + if _sealed.is_sealed_path(path): + # The run of a sealed bundle: the report is encrypted in memory. + _sealed.write_bytes(path, payload) + return path temporary = run / (f".{filename}.{os.getpid()}.{secrets.token_hex(8)}.tmp") descriptor = os.open( temporary, diff --git a/packages/flow/openadapt_flow/network_capture.py b/packages/flow/openadapt_flow/network_capture.py index 5f98bca52..864b0eba6 100644 --- a/packages/flow/openadapt_flow/network_capture.py +++ b/packages/flow/openadapt_flow/network_capture.py @@ -201,12 +201,14 @@ def lines(self, t0_monotonic: float) -> list[dict[str, Any]]: ) def write(self, recording_dir: Path | str, t0_monotonic: float) -> Path: + from openadapt_flow import sealed_set + path = Path(recording_dir) / NETWORK_FILE lines = self.lines(t0_monotonic) - with path.open("w", encoding="utf-8") as fh: - fh.write(json.dumps({"schema": SCHEMA, "exchanges": len(lines)}) + "\n") - for line in lines: - fh.write(json.dumps(line, sort_keys=True) + "\n") + text = json.dumps({"schema": SCHEMA, "exchanges": len(lines)}) + "\n" + text += "".join(json.dumps(line, sort_keys=True) + "\n" for line in lines) + # Sealed in memory when the recording is encrypted. + sealed_set.write_text(path, text) return path @@ -289,12 +291,14 @@ def scrub(text: str) -> str: def load(recording_dir: Path | str) -> list[dict[str, Any]]: """Read ``network.jsonl``; an absent file is an empty capture.""" + from openadapt_flow import sealed_set + path = Path(recording_dir) / NETWORK_FILE - if not path.is_file(): + if not sealed_set.is_file(path): return [] rows = [ json.loads(line) - for line in path.read_text(encoding="utf-8").splitlines() + for line in sealed_set.read_text(path).splitlines() if line.strip() ] if not rows or rows[0].get("schema") != SCHEMA: diff --git a/packages/flow/openadapt_flow/record_sor.py b/packages/flow/openadapt_flow/record_sor.py index 5dc93822b..f01a49f84 100644 --- a/packages/flow/openadapt_flow/record_sor.py +++ b/packages/flow/openadapt_flow/record_sor.py @@ -379,6 +379,12 @@ def propose_effects( def _write_private(path: Path, text: str) -> None: + from openadapt_flow import sealed_set + + if sealed_set.is_sealed_path(path): + # An encrypted recording: seal the rows in memory, never as plaintext. + sealed_set.write_text(path, text) + return tmp = path.with_name(path.name + ".tmp") fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) with os.fdopen(fd, "w", encoding="utf-8") as fh: @@ -477,7 +483,11 @@ def finish( def _recorded_params(recording: Path) -> dict[str, str]: try: - meta = json.loads((recording / "meta.json").read_text(encoding="utf-8")) + from openadapt_flow import sealed_set + + meta = json.loads(sealed_set.read_text(recording / "meta.json")) + except sealed_set.SealedSetError: + raise except (OSError, ValueError): return {} params = meta.get("params") if isinstance(meta, dict) else None diff --git a/packages/flow/openadapt_flow/recorder.py b/packages/flow/openadapt_flow/recorder.py index ef686ecf0..899e8f1bd 100644 --- a/packages/flow/openadapt_flow/recorder.py +++ b/packages/flow/openadapt_flow/recorder.py @@ -59,6 +59,7 @@ from PIL import Image, ImageDraw +from openadapt_flow import sealed_set as _sealed from openadapt_flow.backend import ( Backend, PageCloseExpectationBackend, @@ -140,6 +141,8 @@ def __init__( surface_id: str = PRIMARY_SURFACE_ID, surface_kind: Optional[str] = None, page_backend_factory: Optional[Callable[[Any], Backend]] = None, + encryption_key: Optional[str] = None, + finalize_on_finish: bool = True, ) -> None: # ``_backend`` is always the ACTIVE surface's backend; on a # single-surface recording it never changes. @@ -166,10 +169,22 @@ def __init__( self._known_pages: list[Any] = [] self._remember_pages(backend) self._dir = Path(out_dir) + # Encrypted recording (docs/phi_at_rest.md): with a key, every frame, + # event line and the metadata are sealed in memory and only + # ciphertext is written, from the first frame on. The directory is a + # sealed set that is finalized (indexed) when the recording finishes. + self._sealed_writer: Optional[_sealed.SealedSetWriter] = None + self._finalize_on_finish = finalize_on_finish + if encryption_key: + self._sealed_writer = _sealed.SealedSetWriter( + self._dir, "recording", encryption_key + ) + _sealed.register_writer(self._sealed_writer) self._frames_dir = self._dir / "frames" self._frames_dir.mkdir(parents=True, exist_ok=True) self._events_path = self._dir / "events.jsonl" - self._events_path.write_text("") # truncate any stale file + # Truncate any stale file. + _sealed.write_bytes(self._events_path, b"") self._app_url = app_url # This observer belongs to recording, not the execution backend. A GUI # backend must never advertise an authoritative business-effect oracle @@ -344,9 +359,36 @@ def finish(self) -> Path: }, *self._surface_meta, ] - (self._dir / "meta.json").write_text(json.dumps(meta, indent=2)) + _sealed.write_text(self._dir / "meta.json", json.dumps(meta, indent=2)) + if self._finalize_on_finish: + self.finalize_encryption() return self._dir + @property + def encrypted(self) -> bool: + return self._sealed_writer is not None + + def finalize_encryption(self) -> Optional[str]: + """Index and seal an encrypted recording; returns its set digest. + + A no-op for a plaintext recording. Callers that still need to amend + ``meta.json`` construct the recorder with ``finalize_on_finish=False`` + and call this once they are done. + """ + writer, self._sealed_writer = self._sealed_writer, None + if writer is None: + return None + try: + return writer.finalize() + finally: + _sealed.unregister_writer(writer) + + def abandon_encryption(self) -> None: + """Stop routing writes for an aborted encrypted recording.""" + writer, self._sealed_writer = self._sealed_writer, None + if writer is not None: + _sealed.unregister_writer(writer) + # -- multi-surface (docs/MULTI_SURFACE.md) --------------------------------- @property @@ -802,8 +844,8 @@ def _commit( if redact_region is not None: before_png = self._redact(before_png, redact_region) after_png = self._redact(after_png, redact_region) - (self._frames_dir / f"{i:04d}_before.png").write_bytes(before_png) - (self._frames_dir / f"{i:04d}_after.png").write_bytes(after_png) + _sealed.write_bytes(self._frames_dir / f"{i:04d}_before.png", before_png) + _sealed.write_bytes(self._frames_dir / f"{i:04d}_after.png", after_png) with Image.open(io.BytesIO(before_png)) as before_image: before_viewport = [int(before_image.width), int(before_image.height)] with Image.open(io.BytesIO(after_png)) as after_image: @@ -823,8 +865,7 @@ def _commit( for key, value in structural_after.items(): line[f"{key}_after"] = value line["t"] = round(time.monotonic() - self._t0, 3) - with self._events_path.open("a") as f: - f.write(json.dumps(line) + "\n") + _sealed.append_line(self._events_path, json.dumps(line)) self._i += 1 def _structural_locator_at(self, x: int, y: int) -> Optional[StructuralLocator]: diff --git a/packages/flow/openadapt_flow/report.py b/packages/flow/openadapt_flow/report.py index 26b8a2aee..81f4b731b 100644 --- a/packages/flow/openadapt_flow/report.py +++ b/packages/flow/openadapt_flow/report.py @@ -277,9 +277,9 @@ def render_run_report(run_dir: Path | str) -> Path: FileNotFoundError: If ``run_dir/report.json`` does not exist. """ run = Path(run_dir) - report = RunReport.model_validate_json( - (run / "report.json").read_text(encoding="utf-8") - ) + from openadapt_flow import sealed_set + + report = RunReport.model_validate_json(sealed_set.read_text(run / "report.json")) _warn_if_plaintext_phi(report) ok_count = sum(1 for r in report.results if r.ok) @@ -655,7 +655,8 @@ def render_run_report(run_dir: Path | str) -> Path: lines.append("") out = run / "REPORT.md" - out.write_text("\n".join(lines), encoding="utf-8") + # Encrypted when the run directory is a sealed set. + sealed_set.write_text(out, "\n".join(lines)) return out diff --git a/packages/flow/openadapt_flow/runtime/replayer.py b/packages/flow/openadapt_flow/runtime/replayer.py index fe4105905..0047d8a30 100644 --- a/packages/flow/openadapt_flow/runtime/replayer.py +++ b/packages/flow/openadapt_flow/runtime/replayer.py @@ -61,6 +61,7 @@ ) from urllib.parse import urlsplit +from openadapt_flow import sealed_set as _sealed from openadapt_flow.backend import ( ActionDeliveryUncertain, Backend, @@ -1434,6 +1435,103 @@ def run( ) -> RunReport: """Execute the workflow and write a run directory. + See :meth:`_run_unsealed` for the run itself. When ``bundle_dir`` is a + sealed bundle (:mod:`openadapt_flow.sealed_set`), the run directory is + a sealed set too: the report, step screenshots, identity and + resolution crops and every other run artifact are encrypted in memory + before they are written, and durable checkpoints use the same key. + No plaintext of the bundle or of the live screen reaches the disk. + """ + bundle_path = Path(bundle_dir) + if not ( + _sealed.is_sealed(bundle_path) + or _sealed.active_writer(bundle_path) is not None + ) or _sealed.active_writer(Path(run_dir)) is not None: + return self._run_unsealed( + workflow, + params=params, + worklists=worklists, + bundle_dir=bundle_dir, + run_dir=run_dir, + save_healed_to=save_healed_to, + resume_from=resume_from, + resume_program=resume_program, + run_id=run_id, + idempotency_key=idempotency_key, + execution_target_kind=execution_target_kind, + surface_override=surface_override, + execution_origin=execution_origin, + execution_entry_url=execution_entry_url, + execution_target_origin=execution_target_origin, + prior_screenshots_may_leave_box=prior_screenshots_may_leave_box, + prior_model_calls=prior_model_calls, + prior_external_network_calls=prior_external_network_calls, + ) + passphrase = _sealed.passphrase_for("bundle") + if not passphrase: + raise _sealed.SealedSetKeyError( + _sealed.missing_key_message(bundle_path, "bundle") + ) + if self.checkpoint_key is None: + # A sealed bundle's run keeps its durable state under the same key. + self.checkpoint_key = passphrase + run_path = Path(run_dir) + writer = _sealed.SealedSetWriter( + run_path, "run", passphrase, resume=_sealed.is_sealed(run_path) + ) + _sealed.register_writer(writer) + try: + report = self._run_unsealed( + workflow, + params=params, + worklists=worklists, + bundle_dir=bundle_dir, + run_dir=run_dir, + save_healed_to=save_healed_to, + resume_from=resume_from, + resume_program=resume_program, + run_id=run_id, + idempotency_key=idempotency_key, + execution_target_kind=execution_target_kind, + surface_override=surface_override, + execution_origin=execution_origin, + execution_entry_url=execution_entry_url, + execution_target_origin=execution_target_origin, + prior_screenshots_may_leave_box=prior_screenshots_may_leave_box, + prior_model_calls=prior_model_calls, + prior_external_network_calls=prior_external_network_calls, + ) + finally: + _sealed.unregister_writer(writer) + writer.finalize(adopt_external=True) + return report + + def _run_unsealed( + self, + workflow: Workflow, + *, + params: Optional[dict[str, RuntimeParamScalar]] = None, + worklists: Optional[dict[str, list[dict[str, str]]]] = None, + bundle_dir: Path, + run_dir: Path, + save_healed_to: Optional[Path] = None, + resume_from: Optional[int] = None, + resume_program: Optional[ProgramCheckpoint] = None, + run_id: Optional[str] = None, + idempotency_key: Optional[str] = None, + execution_target_kind: Optional[ExecutionTargetKind] = None, + surface_override: bool = False, + execution_origin: Optional[str] = None, + execution_entry_url: Optional[str] = None, + execution_target_origin: Optional[str] = None, + prior_screenshots_may_leave_box: bool = False, + prior_model_calls: int = 0, + prior_external_network_calls: Literal[ + "none", "observed", "unknown" + ] = "unknown", + ) -> RunReport: + """Execute the workflow and write a run directory. + A workflow with no ``program`` graph runs the LINEAR ``steps`` loop exactly as before (byte-for-byte back-compatible). A workflow carrying a ``program`` (Workflow-program IR Phase 2) is interpreted as a STATE @@ -4370,19 +4468,25 @@ def _retain_program_transition_evidence( frame_path = root / frame_ref if frame_path.is_symlink(): raise OSError("transition frame path is a symlink") - existing = frame_path.read_bytes() if frame_path.exists() else None + existing = ( + _sealed.read_bytes(frame_path) + if _sealed.exists(frame_path) + else None + ) if existing is not None and existing != frame: raise OSError("transition frame digest path has other bytes") - frame_path.write_bytes(frame) + _sealed.write_bytes(frame_path, frame) context_path = root / context_ref if context_path.is_symlink(): raise OSError("transition context path is a symlink") existing_context = ( - context_path.read_bytes() if context_path.exists() else None + _sealed.read_bytes(context_path) + if _sealed.exists(context_path) + else None ) if existing_context is not None and existing_context != context_payload: raise OSError("transition context digest path has other bytes") - context_path.write_bytes(context_payload) + _sealed.write_bytes(context_path, context_payload) except OSError as exc: raise _ProgramHalt( "halt", @@ -4433,12 +4537,14 @@ def _retain_program_transition_evidence( path = root / inventory_ref if path.is_symlink(): raise OSError("transition asset path is a symlink") - existing = path.read_bytes() if path.exists() else None + existing = ( + _sealed.read_bytes(path) if _sealed.exists(path) else None + ) if existing is not None and existing != payload: raise OSError( "transition asset digest path has other bytes" ) - path.write_bytes(payload) + _sealed.write_bytes(path, payload) except OSError as exc: raise _ProgramHalt( "halt", @@ -9804,10 +9910,12 @@ def _retain_pixel_identity_evidence( path = inventory_dir / f"{digest}.png" if path.is_symlink(): raise OSError("pixel evidence crop path is a symlink") - existing = path.read_bytes() if path.exists() else None + existing = ( + _sealed.read_bytes(path) if _sealed.exists(path) else None + ) if existing is not None and existing != payload: raise OSError("pixel evidence digest path has other bytes") - path.write_bytes(payload) + _sealed.write_bytes(path, payload) except OSError: raise @@ -9876,10 +9984,10 @@ def _retain_visual_resolution_evidence( path = inventory_dir / f"{digest}.png" if path.is_symlink(): raise OSError("visual resolution input path is a symlink") - existing = path.read_bytes() if path.exists() else None + existing = _sealed.read_bytes(path) if _sealed.exists(path) else None if existing is not None and existing != payload: raise OSError("visual resolution digest path has other bytes") - path.write_bytes(payload) + _sealed.write_bytes(path, payload) return resolution.model_copy( update={ @@ -11494,7 +11602,7 @@ def frame() -> Optional[bytes]: if run_dir is None or not result.before_png: return None try: - return (run_dir / result.before_png).read_bytes() + return _sealed.read_bytes(run_dir / result.before_png) except OSError: return None @@ -16354,7 +16462,7 @@ def _new_step_evidence_key(run_dir: Path, step_id: str) -> str: candidate = safe occurrence = 1 while any( - (steps_dir / f"{candidate}_{suffix}.png").exists() + _sealed.exists(steps_dir / f"{candidate}_{suffix}.png") for suffix in ("before", "after", "attended-before", "attended-after") ): occurrence += 1 @@ -16373,5 +16481,5 @@ def _save_step_png(run_dir: Path, step_id: str, suffix: str, png: bytes) -> str: (see openadapt_flow.privacy and docs/PRIVACY.md). """ rel = f"steps/{step_id}_{suffix}.png" - (Path(run_dir) / rel).write_bytes(_scrub_png(png)) + _sealed.write_bytes(Path(run_dir) / rel, _scrub_png(png)) return rel diff --git a/packages/flow/openadapt_flow/sealed_set.py b/packages/flow/openadapt_flow/sealed_set.py new file mode 100644 index 000000000..bb652b2f2 --- /dev/null +++ b/packages/flow/openadapt_flow/sealed_set.py @@ -0,0 +1,1031 @@ +"""Seal a whole bundle or recording directory under one authenticated index. + +A compiled bundle and a recording are both directories of files, and most of +those files can hold patient data: screen pixels, typed values, page text, +request and response bodies. This module encrypts all of them. + +A *sealed set* is a directory with a plaintext header file, +``.openadapt-sealed.json``. The header holds the scrypt salt and parameters and +an *index* sealed with AES-256-GCM under the derived key. The index lists every +other file in the directory with the SHA-256 of its bytes on disk. Opening a +sealed set: + +- derives the key once and authenticates the index, so a wrong key or an + edited header fails here; +- refuses the directory if a file is present that the index does not list, or + a listed file is missing, so nothing can be added or dropped; +- checks each file's on-disk SHA-256 against the index before decrypting it, + so nothing can be swapped, including for an older ciphertext of the same + file; +- decrypts in memory only. Each file is sealed with its set-relative path in + the associated data, so a ciphertext cannot be renamed or moved between sets. + +The ``set_digest`` is the SHA-256 of the canonical index. It names the exact +file set, and ``seal`` prints it. + +Every file kind is classified in :data:`BUNDLE_FILES` and +:data:`RECORDING_FILES`. Writing into a sealed set refuses a file the +inventory does not know, rather than passing it through. ``structural`` files +hold only hashes, counts and format markers and stay readable without the key; +every other file is encrypted. + +Callers read and write through the path-based helpers at the end of this +module (:func:`read_bytes`, :func:`write_bytes`, :func:`append_line` and so +on). Given an ordinary path they behave like :mod:`pathlib`; given a path +inside a sealed set, or inside a directory a :class:`SealedSetWriter` is +writing, they decrypt or encrypt in memory. That keeps the command code the +same for plain and sealed input. + +Primitives come from :mod:`openadapt_flow.crypto` (scrypt and AES-256-GCM from +the ``cryptography`` package); nothing here is a new primitive. +""" + +from __future__ import annotations + +import base64 +import fnmatch +import hashlib +import io +import json +import os +import secrets +import threading +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from typing import Final, Iterator, Literal, Optional + +from openadapt_flow import crypto + +HEADER: Final[str] = ".openadapt-sealed.json" +FORMAT: Final[str] = "openadapt-flow-sealed-set" +FORMAT_VERSION: Final[int] = 1 +SEALED_SUFFIX: Final[str] = ".enc" + +Kind = Literal["bundle", "recording", "run"] +Codec = Literal["set", "plain", "external"] +Classification = Literal["phi", "structural"] + +_INDEX_AAD: Final[bytes] = b"openadapt-flow/sealed-set/index/" +_FILE_AAD: Final[bytes] = b"openadapt-flow/sealed-set/file/" + +#: Environment variable naming the passphrase for recordings. When it is not +#: set, :data:`openadapt_flow.crypto.ENV_KEY` is used, so one deployment key +#: covers recordings and bundles. +RECORDING_ENV_KEY: Final[str] = "OPENADAPT_RECORDING_KEY" + + +class SealedSetError(ValueError): + """A sealed set is malformed, incomplete or was changed after sealing.""" + + +class SealedSetKeyError(SealedSetError): + """A sealed set was found but no key was configured to open it.""" + + +@dataclass(frozen=True) +class FileKind: + """One kind of file a bundle or recording can contain.""" + + pattern: str + classification: Classification + holds: str + + +# The inventory (docs/phi_at_rest.md prints it). A set-relative POSIX path +# takes the first entry whose ``fnmatch`` pattern it fits; ``*`` also crosses +# ``/``. A path that fits no entry is unknown and is refused when sealing. +BUNDLE_FILES: Final[tuple[FileKind, ...]] = ( + FileKind( + "workflow.json", + "phi", + "the compiled program: target labels, typed literals, landmark text, " + "structural selectors, URLs, effect values, data-source rows, " + "parameter defaults, window titles", + ), + FileKind( + "templates/*", + "phi", + "pixel crops of the recorded screen (targets, expected regions, drag " + "ends, identifier crops) and the layout fingerprint", + ), + FileKind( + "manifest.json", + "structural", + "SHA-256 digests, compiler version and certification provenance", + ), + FileKind("workflow.py", "phi", "a readable rendering of workflow.json"), + FileKind( + "compile_notes.json", "phi", "gesture rewrites the compiler made, by step" + ), + FileKind( + "param_hygiene.json", "phi", "dropped checks with masked matched text" + ), + FileKind( + "param_proposals.json", + "phi", + "proposed parameters: field labels and partly masked examples", + ), + FileKind( + "annotations.json", + "phi", + "model annotations, including raw example values and labels", + ), + FileKind( + "api_proposal.json", + "phi", + "API-step proposal reasons, which can quote screen text and URL paths", + ), + FileKind( + "effect_contract.yaml", + "phi", + "a scaffolded effect contract with observed and typed values", + ), + FileKind("undo-lineage.json", "phi", "prior content digests of undo bundles"), + FileKind( + "record-check-binding.json", "phi", "record-check coverage by step and field" + ), + FileKind( + "qualification-proposal.json", + "phi", + "qualification pins (app origin), failure matrix and suggestions", + ), + FileKind( + "qualification-admission.local-dev.json", + "phi", + "a local-development admission record", + ), + FileKind( + "admission-decision.json", + "phi", + "a signed admission decision with the decider's identity", + ), + FileKind( + "admission.json", + "phi", + "a signed admission manifest, which can carry read-back values", + ), + FileKind("jit_draft.json", "phi", "draft state of a just-in-time program"), + FileKind("admit.json", "phi", "authoring admission with the local login name"), + FileKind( + "openadapt-card.json", "phi", "operator-written name, purpose and inputs" + ), + FileKind( + "repair/*", + "phi", + "repair candidates: old and new anchors with their screen text", + ), + FileKind( + "qualification/*", "phi", "qualification cases, inputs and reports" + ), + FileKind("evidence/*", "phi", "run evidence a command copied into the bundle"), +) + +RECORDING_FILES: Final[tuple[FileKind, ...]] = ( + FileKind( + "events.jsonl", + "phi", + "every action: typed text, field labels, structural locators, page " + "URL and title, and system-of-record rows when a reader was attached", + ), + FileKind("frames/*", "phi", "before and after screenshots of every action"), + FileKind( + "meta.json", + "phi", + "app URL, parameter example values, window title, identifier region, " + "task description", + ), + FileKind( + "network.jsonl", + "phi", + "request URLs, form fields and bodies, response bodies", + ), + FileKind( + "sor_snapshots.json", + "phi", + "system-of-record rows read before and after the recording", + ), + FileKind( + "verify.proposed.yaml", "phi", "effects proposed from the record diff" + ), + FileKind("check.json", "phi", "a demonstrated check: marked fields and reset"), + FileKind( + "record_check.proposed.yaml", "phi", "a proposed record check with values" + ), + FileKind("record_check.proof.json", "phi", "record-check fault proof"), + FileKind("identity.proposed.json", "phi", "proposed identity with record text"), + FileKind( + "effect_contract.yaml", + "phi", + "a scaffolded effect contract with observed values", + ), + FileKind("distill.json", "phi", "a distillation report"), + FileKind( + ".openadapt-approval.json", "phi", "approval answers about recorded steps" + ), + FileKind( + ".openadapt-sanitization.json", "phi", "redaction manifest of a derivative" + ), +) + +#: A run directory written while executing a sealed bundle. Every file is +#: encrypted; ``checkpoints/`` holds the durable store's own AES-GCM +#: containers, which the run set adopts as they are. +RUN_FILES: Final[tuple[FileKind, ...]] = ( + FileKind(".durable_run.claim", "structural", "run id and namespace claim"), + FileKind(".durable_state.lock", "structural", "an empty lock file"), + FileKind( + ".attended_capability.key", + "structural", + "a random per-run capability secret (no recorded data)", + ), + FileKind("checkpoints/*", "phi", "durable checkpoints (already encrypted)"), + FileKind( + "*", + "phi", + "run report, step screenshots, identity and resolution crops, " + "transition frames, read-back evidence, escalations", + ), +) + + +def inventory(kind: Kind) -> tuple[FileKind, ...]: + if kind == "bundle": + return BUNDLE_FILES + if kind == "recording": + return RECORDING_FILES + return RUN_FILES + + +def classify(kind: Kind, rel: str) -> Optional[FileKind]: + """The inventory entry for ``rel``, or ``None`` for an unknown file.""" + for entry in inventory(kind): + if fnmatch.fnmatchcase(rel, entry.pattern): + return entry + return None + + +# -- keys --------------------------------------------------------------------- + + +def recording_key(explicit: Optional[str] = None) -> Optional[str]: + """The passphrase for recordings: explicit, then the recording variable, + then the bundle variable. ``None`` means no key is configured.""" + if explicit: + return explicit + return os.environ.get(RECORDING_ENV_KEY) or crypto.resolve_key(None) + + +def passphrase_for(kind: str, explicit: Optional[str] = None) -> Optional[str]: + if kind == "recording": + return recording_key(explicit) + return crypto.resolve_key(explicit) + + +def missing_key_message(root: Path | str, kind: str) -> str: + variable = ( + f"{RECORDING_ENV_KEY} or {crypto.ENV_KEY}" + if kind == "recording" + else crypto.ENV_KEY + ) + return ( + f"{root} is an encrypted {kind}; set {variable} to the key it was " + "sealed with. Nothing was read." + ) + + +def _require_passphrase(root: Path, kind: str, key: Optional[str]) -> str: + resolved = passphrase_for(kind, key) + if not resolved: + raise SealedSetKeyError(missing_key_message(root, kind)) + return resolved + + +def _file_aad(kind: str, rel: str) -> bytes: + return _FILE_AAD + kind.encode("ascii") + b"/" + rel.encode("utf-8") + + +def _index_aad(kind: str) -> bytes: + return _INDEX_AAD + kind.encode("ascii") + + +def _sha256(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def _check_rel(rel: str) -> str: + path = PurePosixPath(rel) + if path.is_absolute() or ".." in path.parts or rel in ("", "."): + raise SealedSetError(f"refusing an unsafe path in a sealed set: {rel!r}") + if path.name == HEADER: + raise SealedSetError("the sealed-set header is not a data file") + return path.as_posix() + + +def _physical(rel: str, codec: Codec) -> str: + return rel + SEALED_SUFFIX if codec == "set" else rel + + +def _canonical(files: dict[str, dict[str, str]]) -> bytes: + return json.dumps(files, sort_keys=True, separators=(",", ":")).encode("utf-8") + + +def _atomic_write(path: Path, data: bytes) -> None: + """Replace ``path`` with ``data`` (ciphertext or structural only).""" + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_name(f".{path.name}.{secrets.token_hex(6)}.tmp") + try: + with open(tmp, "wb") as fh: + fh.write(data) + os.replace(tmp, path) + finally: + if os.path.lexists(tmp): + os.unlink(tmp) + + +def _disk_files(root: Path) -> list[str]: + out: list[str] = [] + for current, dirnames, filenames in os.walk(root, followlinks=False): + here = Path(current) + for name in dirnames: + if (here / name).is_symlink(): + raise SealedSetError( + "sealed set contains a symlink: " + f"{(here / name).relative_to(root).as_posix()}" + ) + for name in filenames: + path = here / name + rel = path.relative_to(root).as_posix() + if path.is_symlink() or not path.is_file(): + raise SealedSetError(f"sealed set contains a non-file entry: {rel}") + if rel != HEADER: + out.append(rel) + return sorted(out) + + +# -- header ------------------------------------------------------------------ + + +def is_sealed(root: Path | str) -> bool: + """Whether ``root`` is a sealed set (bundle or recording).""" + return os.path.isfile(os.path.join(root, HEADER)) + + +def read_header(root: Path | str) -> dict[str, object]: + try: + header = json.loads((Path(root) / HEADER).read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + raise SealedSetError(f"unreadable sealed-set header: {exc}") from exc + if not isinstance(header, dict) or header.get("format") != FORMAT: + raise SealedSetError("not an openadapt-flow sealed set") + if header.get("version") != FORMAT_VERSION: + raise SealedSetError( + f"sealed-set format version {header.get('version')!r} is not supported" + ) + if header.get("kind") not in ("bundle", "recording", "run"): + raise SealedSetError("sealed-set header has no valid kind") + return header + + +def header_kind(root: Path | str) -> Kind: + return read_header(root)["kind"] # type: ignore[return-value] + + +def _derive(header: dict[str, object], passphrase: str) -> crypto.SetKey: + try: + salt = base64.b64decode(str(header["salt"]), validate=True) + return crypto.SetKey.derive( + passphrase, + salt, + n=int(header["kdf_n"]), # type: ignore[call-overload] + r=int(header["kdf_r"]), # type: ignore[call-overload] + p=int(header["kdf_p"]), # type: ignore[call-overload] + ) + except (KeyError, TypeError, ValueError) as exc: + raise SealedSetError(f"sealed-set header is malformed: {exc}") from exc + + +def _open_index( + root: Path, header: dict[str, object], key: crypto.SetKey +) -> dict[str, dict[str, str]]: + sealed_index = header.get("index") + if not isinstance(sealed_index, str): + raise SealedSetError( + f"{root} was never finalized (the recorder or sealer stopped before " + "writing its index); refusing a partial set" + ) + kind = str(header["kind"]) + try: + raw = base64.b64decode(sealed_index, validate=True) + except ValueError as exc: + raise SealedSetError(f"sealed-set index is malformed: {exc}") from exc + index = json.loads(key.open(raw, aad=_index_aad(kind))) + files = index.get("files") if isinstance(index, dict) else None + if not isinstance(files, dict) or index.get("kind") != kind: + raise SealedSetError("sealed-set index is malformed") + for rel, entry in files.items(): + _check_rel(rel) + if not isinstance(entry, dict) or entry.get("codec") not in ( + "set", + "plain", + "external", + ): + raise SealedSetError(f"sealed-set index entry for {rel} is malformed") + return files + + +# -- reading ----------------------------------------------------------------- + + +class SealedSet: + """A verified, opened sealed set. Plaintext is only ever held in memory.""" + + sealed = True + + def __init__( + self, + root: Path, + kind: Kind, + key: crypto.SetKey, + files: dict[str, dict[str, str]], + header: dict[str, object], + ) -> None: + self.root = root + self.kind: Kind = kind + self._key = key + self._files = files + self._header = header + + @property + def set_digest(self) -> str: + return _sha256(_canonical(self._files)) + + @classmethod + def open( + cls, + root: Path | str, + key: Optional[str] = None, + *, + expect: Optional[Kind] = None, + ) -> "SealedSet": + base = Path(root) + header = read_header(base) + kind: Kind = header["kind"] # type: ignore[assignment] + if expect is not None and kind != expect: + raise SealedSetError(f"{base} is a sealed {kind}, not a {expect}") + passphrase = _require_passphrase(base, kind, key) + set_key = _derive(header, passphrase) + files = _open_index(base, header, set_key) + expected = sorted(_physical(rel, e["codec"]) for rel, e in files.items()) # type: ignore[arg-type] + present = _disk_files(base) + if present != expected: + added = sorted(set(present) - set(expected)) + dropped = sorted(set(expected) - set(present)) + detail = [] + if added: + detail.append("not in the sealed index: " + ", ".join(added)) + if dropped: + detail.append("missing: " + ", ".join(dropped)) + raise SealedSetError( + f"{base} does not match its sealed index ({'; '.join(detail)})" + ) + return cls(base, kind, set_key, files, header) + + def is_file(self, rel: str) -> bool: + return rel in self._files + + def codec(self, rel: str) -> Codec: + entry = self._files.get(rel) + if entry is None: + raise FileNotFoundError(f"{rel} is not in sealed set {self.root}") + return entry["codec"] # type: ignore[return-value] + + def is_dir(self, rel: str) -> bool: + prefix = rel.rstrip("/") + "/" + return any(name.startswith(prefix) for name in self._files) + + def files(self, prefix: str = "") -> list[str]: + return sorted(rel for rel in self._files if rel.startswith(prefix)) + + def read_bytes(self, rel: str) -> bytes: + entry = self._files.get(rel) + if entry is None: + raise FileNotFoundError(f"{rel} is not in sealed set {self.root}") + raw = (self.root / _physical(rel, entry["codec"])).read_bytes() # type: ignore[arg-type] + if _sha256(raw) != entry["sha256"]: + raise SealedSetError( + f"{rel} in {self.root} was changed after sealing (hash mismatch)" + ) + if entry["codec"] != "set": + # Structural plaintext, or ciphertext another keyed store owns + # (durable checkpoints): returned as stored, hash-checked above. + return raw + return self._key.open(raw, aad=_file_aad(self.kind, rel)) + + def verify_all(self) -> None: + """Hash-check and decrypt every file once.""" + for rel in self._files: + self.read_bytes(rel) + + +# -- writing ----------------------------------------------------------------- + + +class SealedSetWriter: + """Write a sealed set file by file, ciphertext only, then finalize it. + + A new set writes its header (salt and parameters, no index) first, so a + crash leaves a directory that :meth:`SealedSet.open` refuses as unfinished + instead of a readable partial recording. ``resume=True`` reopens a + finalized set (verifying it) to add or replace files; :meth:`finalize` + then re-verifies every file this writer did not write. + """ + + def __init__( + self, + root: Path | str, + kind: Kind, + passphrase: str, + *, + resume: bool = False, + ) -> None: + self.root = Path(root) + self.kind: Kind = kind + self._codecs: dict[str, Codec] = {} + self._hashes: dict[str, str] = {} + self._lines: dict[str, list[bytes]] = {} + self._lock = threading.RLock() + if resume and is_sealed(self.root): + opened = SealedSet.open(self.root, passphrase, expect=kind) + self._header: dict[str, object] = dict(opened._header) + self._key = opened._key + for rel, entry in opened._files.items(): + self._codecs[rel] = entry["codec"] # type: ignore[assignment] + self._hashes[rel] = entry["sha256"] + return + if is_sealed(self.root): + raise SealedSetError(f"{self.root} is already a sealed set") + self.root.mkdir(parents=True, exist_ok=True) + salt = crypto.SetKey.new_salt() + self._header = { + "format": FORMAT, + "version": FORMAT_VERSION, + "kind": kind, + "cipher": "AES-256-GCM", + "kdf": "scrypt", + **crypto.kdf_parameters(), + "salt": base64.b64encode(salt).decode("ascii"), + } + self._key = _derive(self._header, passphrase) + self._write_header() + + def _write_header(self) -> None: + _atomic_write( + self.root / HEADER, + json.dumps(self._header, indent=2, sort_keys=True).encode("utf-8"), + ) + + def _classified(self, rel: str) -> FileKind: + rel = _check_rel(rel) + entry = classify(self.kind, rel) + if entry is None: + raise SealedSetError( + f"refusing to seal {rel}: it is not a known {self.kind} file, so " + "Flow cannot say whether it holds patient data" + ) + return entry + + def write(self, rel: str, data: bytes) -> None: + """Seal ``data`` as ``rel``; a structural file stays plaintext.""" + entry = self._classified(rel) + with self._lock: + old = self._codecs.get(rel) + if entry.classification == "structural": + codec: Codec = "plain" + blob = data + else: + codec = "set" + blob = self._key.seal(data, aad=_file_aad(self.kind, rel)) + if old is not None and old != codec: + os.unlink(self.root / _physical(rel, old)) + _atomic_write(self.root / _physical(rel, codec), blob) + self._codecs[rel] = codec + self._hashes[rel] = _sha256(blob) + + def append_line(self, rel: str, line: str) -> None: + """Append one line to a sealed line log. + + The log is kept in memory and the whole file is resealed on each + append, so the file on disk is always one complete ciphertext. + """ + with self._lock: + lines = self._lines.get(rel) + if lines is None: + lines = [] + if rel in self._codecs: + lines = self.read_bytes(rel).splitlines(keepends=True) + self._lines[rel] = lines + lines.append(line.rstrip("\n").encode("utf-8") + b"\n") + self.write(rel, b"".join(lines)) + + def unlink(self, rel: str, *, missing_ok: bool = False) -> None: + with self._lock: + codec = self._codecs.pop(rel, None) + self._hashes.pop(rel, None) + self._lines.pop(rel, None) + if codec is None: + if missing_ok: + return + raise FileNotFoundError(f"{rel} is not in {self.root}") + os.unlink(self.root / _physical(rel, codec)) + + def read_bytes(self, rel: str) -> bytes: + """Decrypt a file in this set (for read-modify-write).""" + with self._lock: + codec = self._codecs.get(rel) + if codec is None: + raise FileNotFoundError(f"{rel} is not in {self.root}") + raw = (self.root / _physical(rel, codec)).read_bytes() + if _sha256(raw) != self._hashes[rel]: + raise SealedSetError(f"{rel} in {self.root} changed under the writer") + if codec != "set": + return raw + return self._key.open(raw, aad=_file_aad(self.kind, rel)) + + def is_file(self, rel: str) -> bool: + return rel in self._codecs + + def is_dir(self, rel: str) -> bool: + prefix = rel.rstrip("/") + "/" + return any(name.startswith(prefix) for name in self._codecs) + + def files(self, prefix: str = "") -> list[str]: + return sorted(rel for rel in self._codecs if rel.startswith(prefix)) + + def _adopt_external(self) -> None: + sealed_physical = { + _physical(rel, c) for rel, c in self._codecs.items() if c == "set" + } + for rel in list(self._codecs): + if self._codecs[rel] != "set" and not (self.root / rel).is_file(): + # A lock or checkpoint its owner removed. + del self._codecs[rel] + self._hashes.pop(rel, None) + for rel in _disk_files(self.root): + if rel in sealed_physical: + continue + raw = (self.root / rel).read_bytes() + entry = classify(self.kind, rel) + if entry is not None and entry.classification == "structural": + codec: Codec = "plain" + elif crypto.is_encrypted(raw): + codec = "external" + else: + raise SealedSetError( + f"refusing to seal {self.root}: {rel} was written in " + "plaintext outside the sealed writer" + ) + self._codecs[rel] = codec + self._hashes[rel] = _sha256(raw) + + def finalize(self, *, adopt_external: bool = False) -> str: + """Check the directory holds exactly what was written, seal the index, + and return the set digest. Refuses any unexpected file. + + ``adopt_external`` (run directories) also accepts files another + component wrote directly, but only two kinds: a structural file the + inventory names (lock and claim files), and ciphertext from another + keyed store (durable checkpoints, :func:`crypto.is_encrypted`). Any + other file is plaintext that escaped the writer and is refused. + """ + with self._lock: + if adopt_external: + self._adopt_external() + expected = {_physical(rel, c): rel for rel, c in self._codecs.items()} + present = _disk_files(self.root) + unexpected = sorted(set(present) - set(expected)) + if unexpected: + raise SealedSetError( + f"refusing to seal {self.root}: file(s) were written " + "outside the sealed writer: " + ", ".join(unexpected) + ) + missing = sorted(set(expected) - set(present)) + if missing: + raise SealedSetError( + f"refusing to seal {self.root}: missing " + ", ".join(missing) + ) + files: dict[str, dict[str, str]] = {} + for physical, rel in sorted(expected.items()): + actual = _sha256((self.root / physical).read_bytes()) + if actual != self._hashes[rel]: + raise SealedSetError( + f"refusing to seal {self.root}: {rel} changed outside " + "the sealed writer" + ) + files[rel] = {"codec": self._codecs[rel], "sha256": actual} + index = json.dumps({"kind": self.kind, "files": files}, sort_keys=True) + self._header["index"] = base64.b64encode( + self._key.seal(index.encode("utf-8"), aad=_index_aad(self.kind)) + ).decode("ascii") + digest = _sha256(_canonical(files)) + # Informational copy for inventories; the authenticated value is + # the one recomputed from the sealed index on open. + self._header["set_digest"] = digest + self._write_header() + _forget(self.root) + return digest + + +# -- path-based helpers ------------------------------------------------------ +# +# Command code keeps passing ordinary ``Path`` objects. These helpers find the +# sealed set (or the active writer) that contains a path and route the read or +# write through it. + +_WRITERS: dict[str, SealedSetWriter] = {} +_OPENED: dict[tuple[str, str], SealedSet] = {} +_STATE_LOCK = threading.RLock() + + +def _abs(path: Path | str) -> str: + return os.path.abspath(os.fspath(path)) + + +def _forget(root: Path) -> None: + key = _abs(root) + with _STATE_LOCK: + for cached in [k for k in _OPENED if k[0] == key]: + _OPENED.pop(cached, None) + + +@contextmanager +def writing( + root: Path | str, + kind: Kind, + passphrase: str, + *, + resume: bool = False, +) -> Iterator[SealedSetWriter]: + """Route every helper write under ``root`` through one sealed writer and + finalize it when the block succeeds. On error the set stays unfinalized, + which every reader refuses.""" + writer = SealedSetWriter(root, kind, passphrase, resume=resume) + register_writer(writer) + try: + yield writer + writer.finalize() + finally: + unregister_writer(writer) + + +def register_writer(writer: SealedSetWriter) -> None: + with _STATE_LOCK: + _WRITERS[_abs(writer.root)] = writer + + +def unregister_writer(writer: SealedSetWriter) -> None: + with _STATE_LOCK: + if _WRITERS.get(_abs(writer.root)) is writer: + _WRITERS.pop(_abs(writer.root), None) + + +def active_writer(root: Path | str) -> Optional[SealedSetWriter]: + with _STATE_LOCK: + return _WRITERS.get(_abs(root)) + + +def _locate(path: Path | str) -> tuple[Optional[str], str]: + """Return ``(root, rel)`` of the writer or sealed set holding ``path``.""" + full = _abs(path) + with _STATE_LOCK: + writers = sorted(_WRITERS, key=len, reverse=True) + for root in writers: + if full.startswith(root + os.sep): + return root, full[len(root) + 1 :].replace(os.sep, "/") + current = os.path.dirname(full) + while True: + if os.path.isfile(os.path.join(current, HEADER)): + return current, full[len(current) + 1 :].replace(os.sep, "/") + parent = os.path.dirname(current) + if parent == current: + return None, full + current = parent + + +def open_set(root: Path | str, key: Optional[str] = None) -> SealedSet: + """Open and verify a sealed set once per process and key.""" + base = _abs(root) + header = read_header(base) + passphrase = _require_passphrase(Path(base), str(header["kind"]), key) + cache_key = (base, _sha256(passphrase.encode("utf-8")) + str(header.get("index"))) + with _STATE_LOCK: + cached = _OPENED.get(cache_key) + if cached is not None: + return cached + opened = SealedSet.open(base, passphrase) + with _STATE_LOCK: + _OPENED[cache_key] = opened + return opened + + +def _reader(root: str) -> SealedSet | SealedSetWriter: + with _STATE_LOCK: + writer = _WRITERS.get(root) + if writer is not None: + return writer + return open_set(root) + + +def sealed_root(path: Path | str) -> Optional[Path]: + """The sealed set or active writer root that contains ``path``.""" + root, _rel = _locate(path) + return Path(root) if root is not None else None + + +def is_sealed_path(path: Path | str) -> bool: + """Whether ``path`` is, or is inside, a sealed set or active writer.""" + full = _abs(path) + with _STATE_LOCK: + if full in _WRITERS: + return True + return is_sealed(full) or sealed_root(full) is not None + + +def read_bytes(path: Path | str) -> bytes: + root, rel = _locate(path) + if root is None: + return Path(path).read_bytes() + return _reader(root).read_bytes(rel) + + +def read_text(path: Path | str, encoding: str = "utf-8") -> str: + return read_bytes(path).decode(encoding) + + +def is_file(path: Path | str) -> bool: + root, rel = _locate(path) + if root is None: + return Path(path).is_file() + return _reader(root).is_file(rel) + + +def exists(path: Path | str) -> bool: + full = _abs(path) + root, rel = _locate(full) + if root is None: + return os.path.exists(full) + reader = _reader(root) + return reader.is_file(rel) or reader.is_dir(rel) + + +def is_dir(path: Path | str) -> bool: + full = _abs(path) + with _STATE_LOCK: + if full in _WRITERS: + return True + if is_sealed(full): + return True + root, rel = _locate(full) + if root is None: + return os.path.isdir(full) + return _reader(root).is_dir(rel) + + +def list_files(directory: Path | str, pattern: str = "*") -> list[Path]: + """Files directly or recursively under ``directory`` matching ``pattern`` + (``fnmatch`` on the path relative to ``directory``).""" + full = _abs(directory) + with _STATE_LOCK: + writer = _WRITERS.get(full) + if writer is not None or is_sealed(full): + root, prefix = full, "" + else: + root_opt, rel = _locate(full) + if root_opt is None: + base = Path(full) + if not base.is_dir(): + return [] + return sorted( + p + for p in base.rglob("*") + if p.is_file() + and fnmatch.fnmatchcase(p.relative_to(base).as_posix(), pattern) + ) + root, prefix = root_opt, rel.rstrip("/") + "/" + reader = _reader(root) + out = [] + for name in reader.files(prefix): + tail = name[len(prefix) :] + if fnmatch.fnmatchcase(tail, pattern): + out.append(Path(root) / name) + return out + + +def open_text(path: Path | str, encoding: str = "utf-8") -> io.StringIO: + """A readable text stream over ``path`` (decrypted in memory if sealed).""" + return io.StringIO(read_text(path, encoding)) + + +def write_bytes(path: Path | str, data: bytes, *, key: Optional[str] = None) -> None: + """Write ``path``; sealed if it lies in an active writer or a sealed set.""" + root, rel = _locate(path) + if root is None: + target = Path(path) + target.write_bytes(data) + return + with _STATE_LOCK: + writer = _WRITERS.get(root) + if writer is not None: + writer.write(rel, data) + return + kind = header_kind(root) + passphrase = _require_passphrase(Path(root), kind, key) + resumed = SealedSetWriter(root, kind, passphrase, resume=True) + resumed.write(rel, data) + resumed.finalize() + + +def write_text( + path: Path | str, text: str, *, encoding: str = "utf-8", key: Optional[str] = None +) -> None: + write_bytes(path, text.encode(encoding), key=key) + + +def append_line(path: Path | str, line: str) -> None: + """Append one line to ``path`` (a sealed line log inside a writer).""" + root, rel = _locate(path) + if root is not None: + with _STATE_LOCK: + writer = _WRITERS.get(root) + if writer is None: + raise SealedSetError( + f"cannot append to {rel} in finalized sealed set {root}" + ) + writer.append_line(rel, line) + return + with open(path, "a", encoding="utf-8") as fh: + fh.write(line.rstrip("\n") + "\n") + + +def unlink(path: Path | str, *, missing_ok: bool = False, key: Optional[str] = None) -> None: + root, rel = _locate(path) + if root is None: + Path(path).unlink(missing_ok=missing_ok) + return + with _STATE_LOCK: + writer = _WRITERS.get(root) + if writer is not None: + writer.unlink(rel, missing_ok=missing_ok) + return + kind = header_kind(root) + resumed = SealedSetWriter( + root, kind, _require_passphrase(Path(root), kind, key), resume=True + ) + resumed.unlink(rel, missing_ok=missing_ok) + resumed.finalize() + + +def require_readable(path: Path | str, what: str) -> None: + """Refuse a sealed directory in a command that cannot read one.""" + if is_sealed_path(path): + root = sealed_root(path) or Path(path) + kind = header_kind(root) if is_sealed(root) else "bundle" + raise SealedSetError( + f"{what} does not read encrypted {kind}s, and Flow will not decrypt " + f"{root} to disk for it. Use an unencrypted copy inside your managed " + "storage, or a command that reads sealed input." + ) + + +__all__ = [ + "BUNDLE_FILES", + "HEADER", + "RECORDING_ENV_KEY", + "RECORDING_FILES", + "SealedSet", + "SealedSetError", + "SealedSetKeyError", + "SealedSetWriter", + "append_line", + "classify", + "exists", + "inventory", + "is_dir", + "is_file", + "is_sealed", + "is_sealed_path", + "list_files", + "missing_key_message", + "open_set", + "open_text", + "passphrase_for", + "read_bytes", + "read_text", + "recording_key", + "require_readable", + "sealed_root", + "unlink", + "write_bytes", + "write_text", + "writing", +] diff --git a/packages/flow/openadapt_flow/tutorial.py b/packages/flow/openadapt_flow/tutorial.py index f03d1a0f5..395ef784b 100644 --- a/packages/flow/openadapt_flow/tutorial.py +++ b/packages/flow/openadapt_flow/tutorial.py @@ -304,9 +304,13 @@ def record_tutorial( *, headed: bool = False, presentation_delay_s: float = 0.0, + encryption_key: Optional[str] = None, ) -> Path: """Record the triage demonstration WITH system-of-record observation. + ``encryption_key`` records an encrypted (sealed) recording; see + :mod:`openadapt_flow.sealed_set`. + Every action goes through the ordinary :class:`~openadapt_flow.recorder.Recorder`, so frames and events are captured exactly as a human demonstration would be. The one addition is ``system_of_record_reader``: after each action the @@ -352,6 +356,7 @@ def read_records() -> list[dict[str, Any]]: recording_dir, app_url=entry_url, system_of_record_reader=read_records, + encryption_key=encryption_key, ) def save() -> None: diff --git a/packages/flow/tests/test_cli_seal.py b/packages/flow/tests/test_cli_seal.py index 9657dea44..3575454dd 100644 --- a/packages/flow/tests/test_cli_seal.py +++ b/packages/flow/tests/test_cli_seal.py @@ -6,7 +6,7 @@ import pytest -from openadapt_flow import bundle_sealing, crypto +from openadapt_flow import bundle_sealing, crypto, sealed_set from openadapt_flow.__main__ import main from openadapt_flow.bundle_sealing import BundleSealingError, seal_bundle from openadapt_flow.ir import ( @@ -32,7 +32,7 @@ def _source(tmp_path: Path) -> Path: bundle = tmp_path / "source" (bundle / "templates").mkdir(parents=True) (bundle / "templates" / "submit.png").write_bytes(_CROP) - (bundle / "operator-notes.txt").write_text("complete-bundle-marker") + (bundle / "param_hygiene.json").write_text('{"note": "complete-bundle-marker"}') Workflow( name="graph-ready", steps=[ @@ -101,7 +101,17 @@ def test_cli_seal_preserves_source_and_verifies_encrypted_copy( assert main(["seal", str(source), "--out", str(destination)]) == 0 assert _snapshot(source) == before - assert (destination / "operator-notes.txt").read_text() == "complete-bundle-marker" + # Every file that can hold patient data is ciphertext; only the + # structural manifest and the sealed-set header stay readable. + assert not (destination / "param_hygiene.json").exists() + assert crypto.is_encrypted((destination / "param_hygiene.json.enc").read_bytes()) + on_disk = b"".join(_snapshot(destination).values()) + assert b"complete-bundle-marker" not in on_disk + assert _CROP not in on_disk + assert b"Submitted" not in on_disk + assert sealed_set.read_text(destination / "param_hygiene.json") == ( + '{"note": "complete-bundle-marker"}' + ) assert not (destination / "workflow.json").exists() assert crypto.is_encrypted((destination / "workflow.json.enc").read_bytes()) sealed_crop = destination / "templates" / "submit.png.enc" @@ -312,5 +322,6 @@ def test_sealed_template_tampering_fails_authentication( payload[-1] ^= 1 crop.write_bytes(payload) - with pytest.raises(crypto.DecryptionError): + # The sealed index catches the changed bytes before any decryption. + with pytest.raises(sealed_set.SealedSetError, match="changed after sealing"): Workflow.load(destination, key=_KEY, verify_integrity=True) From 1ceaf08d018d1e5998d42ca1c19921c945d7ffa2 Mon Sep 17 00:00:00 2001 From: abrichr Date: Mon, 19 Oct 2026 11:13:24 -0400 Subject: [PATCH 2/9] WIP: sealed runs, CLI guard, at-rest tests Co-Authored-By: Claude Opus 5.5 --- packages/flow/openadapt_flow/__main__.py | 89 ++++ packages/flow/openadapt_flow/offline_seal.py | 6 + packages/flow/openadapt_flow/qualification.py | 7 +- .../flow/openadapt_flow/runtime/replayer.py | 24 + packages/flow/openadapt_flow/sealed_set.py | 2 + packages/flow/tests/test_sealed_at_rest.py | 489 ++++++++++++++++++ 6 files changed, 615 insertions(+), 2 deletions(-) create mode 100644 packages/flow/tests/test_sealed_at_rest.py diff --git a/packages/flow/openadapt_flow/__main__.py b/packages/flow/openadapt_flow/__main__.py index 42f58aeda..838cf2c1d 100644 --- a/packages/flow/openadapt_flow/__main__.py +++ b/packages/flow/openadapt_flow/__main__.py @@ -11380,6 +11380,81 @@ def _qualify_default_form(argv: list[str]) -> list[str]: _PREWARM_SKIP_FLAGS = frozenset({"-h", "--help", "--version"}) +#: Commands that read sealed bundles and encrypted recordings in memory and +#: keep what they write sealed (docs/phi_at_rest.md). Any other command given +#: a sealed path refuses before it reads anything, rather than decrypt to disk +#: or misread the ciphertext. Keyed by ``module:function``. +_SEALED_READY_COMMANDS: frozenset[str] = frozenset( + { + "openadapt_flow.__main__:_cmd_seal", + "openadapt_flow.__main__:_cmd_certify", + "openadapt_flow.__main__:_cmd_record", + "openadapt_flow.__main__:_cmd_compile", + "openadapt_flow.__main__:_cmd_induce", + "openadapt_flow.__main__:_cmd_scaffold_verifier", + "openadapt_flow.__main__:_cmd_run", + "openadapt_flow.__main__:_cmd_replay", + "openadapt_flow.__main__:_cmd_resume", + "openadapt_flow.__main__:_cmd_qualify", + "openadapt_flow.cli_admit:cmd_admit_run", + "openadapt_flow.cli_admit:cmd_admit_decide", + "openadapt_flow.cli_admit:cmd_admit_status", + "openadapt_flow.cli_undo:_cmd_undo_declare", + "openadapt_flow.cli_undo:_cmd_undo_list", + "openadapt_flow.cli_undo:_cmd_undo_approve", + "openadapt_flow.cli_undo:_cmd_undo_run", + } +) + + +def _sealed_paths_in(args: argparse.Namespace) -> list[Path]: + from openadapt_flow import sealed_set + + found: list[Path] = [] + for name, value in vars(args).items(): + if name == "func": + continue + values = value if isinstance(value, (list, tuple)) else [value] + for item in values: + if not isinstance(item, (str, Path)) or not str(item): + continue + text = str(item) + if "://" in text or "\n" in text or len(text) > 4096: + continue + path = Path(text) + root = path if sealed_set.is_sealed(path) else sealed_set.sealed_root(path) + if root is not None and root not in found: + found.append(root) + return found + + +def _sealed_input_refusal(args: argparse.Namespace) -> Optional[str]: + """Refuse a sealed path in a command that cannot keep it sealed, and a + sealed path whose key is not configured, before the command runs.""" + from openadapt_flow import sealed_set + + sealed = _sealed_paths_in(args) + if not sealed: + return None + func = getattr(args, "func", None) + command = f"{getattr(func, '__module__', '')}:{getattr(func, '__name__', '')}" + if command not in _SEALED_READY_COMMANDS: + root = sealed[0] + kind = sealed_set.header_kind(root) + return ( + f"{root} is an encrypted {kind}, and this command does not read " + "encrypted input yet. Flow will not decrypt it to disk for this " + "command. Commands that read sealed input: record, compile, " + "induce, scaffold-verifier, seal, certify, qualify, admit, run, " + "replay, resume and undo." + ) + for root in sealed: + kind = sealed_set.header_kind(root) + if not sealed_set.passphrase_for(kind): + return sealed_set.missing_key_message(root, kind) + return None + + def main(argv: Sequence[str] | None = None) -> int: """CLI entry point. Returns a process exit code.""" raw = list(sys.argv[1:] if argv is None else argv) @@ -11403,6 +11478,15 @@ def main(argv: Sequence[str] | None = None) -> int: no_outbound.enforce_from_env() args = build_parser().parse_args(argv) + from openadapt_flow import sealed_set + + try: + refusal = _sealed_input_refusal(args) + except sealed_set.SealedSetError as exc: + refusal = str(exc) + if refusal is not None: + print(f"REFUSED: {refusal}", file=sys.stderr) + return 2 try: code = args.func(args) if getattr(args, "command", None) in ("run", "replay"): @@ -11415,6 +11499,11 @@ def main(argv: Sequence[str] | None = None) -> int: if note: print(note, file=sys.stderr) return code + except sealed_set.SealedSetError as exc: + # A sealed bundle or encrypted recording that is missing its key, + # was changed after sealing, or holds an unknown file: fail closed. + print(f"REFUSED: {exc}", file=sys.stderr) + return 2 except BrowserSupportMissing as exc: # Missing optional browser support is a setup decision, not a crash. print(f"Browser setup required:\n{exc}", file=sys.stderr) diff --git a/packages/flow/openadapt_flow/offline_seal.py b/packages/flow/openadapt_flow/offline_seal.py index 94c9f6a62..7d38bbc31 100644 --- a/packages/flow/openadapt_flow/offline_seal.py +++ b/packages/flow/openadapt_flow/offline_seal.py @@ -311,6 +311,12 @@ def seal_after_run( """ if run_dir is None or not mode_enabled(env): return None + from openadapt_flow import sealed_set + + if sealed_set.is_sealed(Path(run_dir)): + # The offline Seal rewrites report.json in place; that is not built + # for an encrypted run directory yet, so say so instead of skipping. + return "offline seal not written: the run directory is encrypted at rest" if not (Path(run_dir) / "report.json").is_file(): return None try: diff --git a/packages/flow/openadapt_flow/qualification.py b/packages/flow/openadapt_flow/qualification.py index 31c3a3817..11038cd0b 100644 --- a/packages/flow/openadapt_flow/qualification.py +++ b/packages/flow/openadapt_flow/qualification.py @@ -5695,11 +5695,14 @@ def save_qualified_workflow( raise QualificationError( "governed authorization template changed the bundle content digest" ) + from openadapt_flow import sealed_set + workflow_py = Path(bundle_dir) / "workflow.py" - if workflow_py.is_file(): + if sealed_set.is_file(workflow_py): from openadapt_flow.compiler.codegen import render_workflow_py - workflow_py.write_text(render_workflow_py(workflow), encoding="utf-8") + # Resealed in memory when the bundle is a sealed set. + sealed_set.write_text(workflow_py, render_workflow_py(workflow), key=key) return path diff --git a/packages/flow/openadapt_flow/runtime/replayer.py b/packages/flow/openadapt_flow/runtime/replayer.py index 0047d8a30..10f52feb3 100644 --- a/packages/flow/openadapt_flow/runtime/replayer.py +++ b/packages/flow/openadapt_flow/runtime/replayer.py @@ -1472,6 +1472,15 @@ def run( raise _sealed.SealedSetKeyError( _sealed.missing_key_message(bundle_path, "bundle") ) + unsupported = self._sealed_run_unsupported(workflow) + if unsupported is not None: + # Refuse before anything is actuated or written: these stores + # keep their own plaintext state files in the run directory. + raise _sealed.SealedSetError( + f"a sealed bundle cannot run with {unsupported} yet, because " + "that state is written to the run directory in plaintext. " + "Nothing was executed." + ) if self.checkpoint_key is None: # A sealed bundle's run keeps its durable state under the same key. self.checkpoint_key = passphrase @@ -1506,6 +1515,21 @@ def run( writer.finalize(adopt_external=True) return report + def _sealed_run_unsupported(self, workflow: Workflow) -> Optional[str]: + """Name a run feature whose run-directory state is not sealed yet.""" + if self.approval_gate is not None: + return "an approval gate (attended approvals)" + if self._attended_draft_by is not None: + return "an attended draft" + program = workflow.program + if program is not None: + graphs = [program, *(getattr(workflow, "subflows", None) or {}).values()] + for graph in graphs: + for state in getattr(graph, "states", {}).values(): + if getattr(state, "kind", None) == StateKind.BUSINESS_DECISION: + return "a business-decision state" + return None + def _run_unsealed( self, workflow: Workflow, diff --git a/packages/flow/openadapt_flow/sealed_set.py b/packages/flow/openadapt_flow/sealed_set.py index bb652b2f2..9149b85e7 100644 --- a/packages/flow/openadapt_flow/sealed_set.py +++ b/packages/flow/openadapt_flow/sealed_set.py @@ -239,6 +239,8 @@ class FileKind: "structural", "a random per-run capability secret (no recorded data)", ), + FileKind(".attended_action.lease", "structural", "an action lease marker"), + FileKind("*.lock", "structural", "a lock file"), FileKind("checkpoints/*", "phi", "durable checkpoints (already encrypted)"), FileKind( "*", diff --git a/packages/flow/tests/test_sealed_at_rest.py b/packages/flow/tests/test_sealed_at_rest.py new file mode 100644 index 000000000..647e09f5d --- /dev/null +++ b/packages/flow/tests/test_sealed_at_rest.py @@ -0,0 +1,489 @@ +"""Everything that can hold patient data is ciphertext at rest. + +Contract (docs/phi_at_rest.md): + +- ``seal`` encrypts every bundle file the inventory marks as patient data, + binds the whole file set to one sealed index, and refuses a file it does + not know. +- With a key configured, ``record`` writes an encrypted recording from the + first frame; ``compile`` turns it into a sealed bundle; a run of a sealed + bundle writes a sealed run directory. None of them writes plaintext to + disk, temporary files included. +- Without the key, each of them refuses with a message that names the key. + +The no-plaintext check uses a process audit hook: every file this Python +process opens for writing, renames or deletes during the watched block is +checked for plaintext markers, including files that are gone by the end. +Everything here is synthetic. +""" + +from __future__ import annotations + +import io +import json +import os +import sys +from contextlib import contextmanager +from pathlib import Path +from typing import Iterator + +import pytest +from PIL import Image + +from openadapt_flow import crypto, sealed_set +from openadapt_flow.__main__ import main +from openadapt_flow.ir import ( + ActionKind, + Anchor, + Postcondition, + PostconditionKind, + Step, + Workflow, +) +from openadapt_flow.recorder import Recorder + +KEY = "synthetic-at-rest-key" +NOTE = "Synthetic follow-up in two weeks" +PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" + + +class _DiskWatch: + """Remember every file written, renamed or removed while armed.""" + + def __init__(self) -> None: + self.armed = False + self.written: set[str] = set() + self.transient: list[tuple[str, bytes]] = [] + self._busy = False + + def _snapshot(self, raw: object) -> None: + if self._busy or isinstance(raw, int): + return + self._busy = True + try: + path = os.fsdecode(raw) # type: ignore[arg-type] + if os.path.isfile(path): + with open(path, "rb") as fh: + self.transient.append((path, fh.read())) + finally: + self._busy = False + + def __call__(self, event: str, args: tuple) -> None: + if not self.armed or self._busy: + return + if event == "open": + path, mode, flags = args + if path is None or isinstance(path, int): + return + writes = (isinstance(mode, str) and any(c in mode for c in "wax+")) or ( + isinstance(flags, int) + and flags & (os.O_WRONLY | os.O_RDWR | os.O_CREAT) + ) + if writes: + self.written.add(os.path.abspath(os.fsdecode(path))) + elif event == "os.remove": + self._snapshot(args[0]) + elif event == "os.rename": + self._snapshot(args[0]) + self.written.add(os.path.abspath(os.fsdecode(args[1]))) + + def leaks(self, markers: list[bytes]) -> list[str]: + found: list[str] = [] + for path in sorted(self.written): + if os.path.isfile(path): + with open(path, "rb") as fh: + data = fh.read() + found += [f"{path}: {m!r}" for m in markers if m in data] + for path, data in self.transient: + found += [f"{path} (transient): {m!r}" for m in markers if m in data] + return found + + +_WATCH = _DiskWatch() +# Audit hooks cannot be removed; this one does nothing unless a test arms it. +sys.addaudithook(_WATCH) + + +@contextmanager +def watching_disk() -> Iterator[_DiskWatch]: + _WATCH.written.clear() + _WATCH.transient.clear() + _WATCH.armed = True + try: + yield _WATCH + finally: + _WATCH.armed = False + + +def _files(root: Path) -> dict[str, bytes]: + return { + p.relative_to(root).as_posix(): p.read_bytes() + for p in sorted(root.rglob("*")) + if p.is_file() + } + + +def _assert_ciphertext_only(root: Path) -> None: + """Each file is the header, a structural file, or a sealed container.""" + kind = sealed_set.header_kind(root) + for rel, data in _files(root).items(): + if rel == sealed_set.HEADER: + continue + entry = sealed_set.classify(kind, rel) + if entry is not None and entry.classification == "structural": + continue + assert crypto.is_encrypted(data), f"{root.name}/{rel} is not ciphertext" + + +def _png(size: tuple[int, int] = (320, 200), shade: int = 240) -> bytes: + buf = io.BytesIO() + Image.new("RGB", size, (shade, shade, shade)).save(buf, format="PNG") + return buf.getvalue() + + +# -- sealing a compiled bundle ---------------------------------------------- + + +def _plain_bundle(root: Path) -> Path: + bundle = root / "plain" + (bundle / "templates").mkdir(parents=True) + (bundle / "templates" / "open.png").write_bytes(_png(shade=10)) + (bundle / "templates" / "save.png").write_bytes(_png(shade=20)) + Workflow( + name="synthetic-intake", + steps=[ + Step( + id=f"step_{i}", + intent=f"Click {name}", + action=ActionKind.CLICK, + anchor=Anchor( + template=f"templates/{name}.png", + region=(10, 20, 30, 40), + click_point=(25, 40), + ocr_text=f"{name} Testpatient Zed", + ), + expect=[ + Postcondition( + kind=PostconditionKind.TEXT_PRESENT, text=f"{name} done" + ) + ], + ) + for i, name in enumerate(("open", "save")) + ], + ).save(bundle) + (bundle / "workflow.py").write_text("# Testpatient Zed\n") + (bundle / "param_proposals.json").write_text( + json.dumps({"proposals": [{"field_label": "Testpatient Zed"}]}) + ) + return bundle + + +PLAIN_MARKERS = [b"Testpatient", PNG_SIGNATURE] + + +def test_seal_encrypts_every_file_and_binds_the_set( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + source = _plain_bundle(tmp_path) + sealed = tmp_path / "sealed" + monkeypatch.setenv(crypto.ENV_KEY, KEY) + + with watching_disk() as watch: + assert main(["seal", str(source), "--out", str(sealed)]) == 0 + written_under_sealed = [p for p in watch.written if p.startswith(str(sealed.parent))] + assert written_under_sealed + assert [ + leak for leak in watch.leaks(PLAIN_MARKERS) if str(source) not in leak + ] == [] + + _assert_ciphertext_only(sealed) + on_disk = _files(sealed) + assert set(on_disk) == { + sealed_set.HEADER, + "manifest.json", + "workflow.json.enc", + "workflow.py.enc", + "param_proposals.json.enc", + "templates/open.png.enc", + "templates/save.png.enc", + } + opened = sealed_set.SealedSet.open(sealed, KEY) + assert f"Sealed file set: sha256:{opened.set_digest}" in capsys.readouterr().out + assert sealed_set.read_text(sealed / "workflow.py") == "# Testpatient Zed\n" + + loaded = Workflow.load(sealed, key=KEY) + assert loaded.encrypted + assert loaded.decrypted_template("templates/open.png") == _png(shade=10) + + +def test_seal_refuses_a_file_it_does_not_know( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + source = _plain_bundle(tmp_path) + (source / "notes.txt").write_text("Testpatient Zed called back") + monkeypatch.setenv(crypto.ENV_KEY, KEY) + + assert main(["seal", str(source), "--out", str(tmp_path / "sealed")]) == 2 + out = capsys.readouterr().out + assert "notes.txt" in out and "does not know" in out + assert not (tmp_path / "sealed").exists() + + +@pytest.fixture +def sealed_pair(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> tuple[Path, Path]: + source = _plain_bundle(tmp_path) + monkeypatch.setenv(crypto.ENV_KEY, KEY) + first, second = tmp_path / "first", tmp_path / "second" + assert main(["seal", str(source), "--out", str(first)]) == 0 + assert main(["seal", str(source), "--out", str(second)]) == 0 + return first, second + + +def test_sealed_set_refuses_an_added_file(sealed_pair: tuple[Path, Path]) -> None: + sealed, _ = sealed_pair + (sealed / "templates" / "extra.png.enc").write_bytes( + (sealed / "templates" / "open.png.enc").read_bytes() + ) + with pytest.raises(sealed_set.SealedSetError, match="not in the sealed index"): + Workflow.load(sealed, key=KEY) + + +def test_sealed_set_refuses_a_dropped_file(sealed_pair: tuple[Path, Path]) -> None: + sealed, _ = sealed_pair + (sealed / "param_proposals.json.enc").unlink() + with pytest.raises(sealed_set.SealedSetError, match="missing"): + Workflow.load(sealed, key=KEY) + + +def test_sealed_set_refuses_swapped_files(sealed_pair: tuple[Path, Path]) -> None: + sealed, _ = sealed_pair + first = sealed / "templates" / "open.png.enc" + second = sealed / "templates" / "save.png.enc" + a, b = first.read_bytes(), second.read_bytes() + first.write_bytes(b) + second.write_bytes(a) + with pytest.raises(sealed_set.SealedSetError, match="changed after sealing"): + Workflow.load(sealed, key=KEY) + + +def test_sealed_set_refuses_a_ciphertext_from_another_seal( + sealed_pair: tuple[Path, Path], +) -> None: + sealed, other = sealed_pair + (sealed / "workflow.json.enc").write_bytes( + (other / "workflow.json.enc").read_bytes() + ) + with pytest.raises(sealed_set.SealedSetError, match="changed after sealing"): + Workflow.load(sealed, key=KEY) + + +def test_sealed_set_refuses_an_edited_index(sealed_pair: tuple[Path, Path]) -> None: + sealed, other = sealed_pair + header = json.loads((sealed / sealed_set.HEADER).read_text()) + header["index"] = json.loads((other / sealed_set.HEADER).read_text())["index"] + (sealed / sealed_set.HEADER).write_text(json.dumps(header)) + with pytest.raises(crypto.DecryptionError): + Workflow.load(sealed, key=KEY) + + +def test_sealed_bundle_fails_closed_without_or_with_a_wrong_key( + sealed_pair: tuple[Path, Path], + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + sealed, _ = sealed_pair + with pytest.raises(crypto.DecryptionError): + Workflow.load(sealed, key="not-the-key") + monkeypatch.delenv(crypto.ENV_KEY) + with pytest.raises(sealed_set.SealedSetKeyError, match=crypto.ENV_KEY): + Workflow.load(sealed) + assert main(["certify", str(sealed), "--policy", "permissive"]) == 2 + assert crypto.ENV_KEY in capsys.readouterr().err + + +def test_command_that_cannot_read_sealed_input_refuses( + sealed_pair: tuple[Path, Path], capsys: pytest.CaptureFixture[str] +) -> None: + sealed, _ = sealed_pair + before = _files(sealed) + assert main(["visualize", str(sealed)]) == 2 + err = capsys.readouterr().err + assert "does not read encrypted input" in err + assert _files(sealed) == before + + +def test_certify_and_resave_keep_a_sealed_bundle_sealed( + sealed_pair: tuple[Path, Path], +) -> None: + sealed, _ = sealed_pair + with watching_disk() as watch: + assert main(["certify", str(sealed), "--policy", "permissive"]) == 0 + # Many commands re-save a bundle with a bare save(); a sealed bundle + # must stay sealed and its index must follow the change. + workflow = Workflow.load(sealed, key=KEY) + workflow.stamp_certification("permissive", passed=True) + workflow.save(sealed) + assert watch.leaks(PLAIN_MARKERS) == [] + _assert_ciphertext_only(sealed) + reloaded = Workflow.load(sealed, key=KEY) + assert reloaded.manifest is not None and reloaded.manifest.provenance.certified + assert reloaded.decrypted_template("templates/save.png") == _png(shade=20) + + +# -- encrypted recording ------------------------------------------------------ + + +class _FakeBackend: + def __init__(self) -> None: + self._png = _png((640, 400)) + + @property + def viewport(self) -> tuple[int, int]: + return (640, 400) + + def screenshot(self) -> bytes: + return self._png + + def click(self, x: int, y: int, *, double: bool = False) -> None: + pass + + def type_text(self, text: str) -> None: + pass + + def press(self, key: str) -> None: + pass + + def scroll(self, dx: int, dy: int) -> None: + pass + + +def test_encrypted_recording_writes_no_plaintext(tmp_path: Path) -> None: + rec_dir = tmp_path / "rec" + with watching_disk() as watch: + rec = Recorder( + _FakeBackend(), rec_dir, app_url="http://app.test/", encryption_key=KEY + ) + rec.click(10, 20) + rec.type_text("Testpatient Zed", param="patient") + rec.press("Enter") + rec.finish() + assert watch.leaks([b"Testpatient", PNG_SIGNATURE, b'"viewport_before"']) == [] + _assert_ciphertext_only(rec_dir) + opened = sealed_set.SealedSet.open(rec_dir, KEY) + assert opened.files() == [ + "events.jsonl", + "frames/0000_after.png", + "frames/0000_before.png", + "frames/0001_after.png", + "frames/0001_before.png", + "frames/0002_after.png", + "frames/0002_before.png", + "meta.json", + ] + meta = json.loads(opened.read_bytes("meta.json")) + assert meta["params"] == {"patient": "Testpatient Zed"} + events = [json.loads(x) for x in opened.read_bytes("events.jsonl").splitlines()] + assert [e["kind"] for e in events] == ["click", "type", "key"] + + +def test_unfinished_encrypted_recording_is_refused(tmp_path: Path) -> None: + rec_dir = tmp_path / "rec" + rec = Recorder(_FakeBackend(), rec_dir, encryption_key=KEY) + rec.click(10, 20) + rec.abandon_encryption() # the recorder stopped before finish() + with pytest.raises(sealed_set.SealedSetError, match="never finalized"): + sealed_set.SealedSet.open(rec_dir, KEY) + + +def test_plaintext_written_beside_an_encrypted_recording_is_refused( + tmp_path: Path, +) -> None: + rec_dir = tmp_path / "rec" + rec = Recorder( + _FakeBackend(), rec_dir, encryption_key=KEY, finalize_on_finish=False + ) + rec.click(10, 20) + rec.finish() + (rec_dir / "frames" / "0000_before.png").write_bytes(_png()) + with pytest.raises(sealed_set.SealedSetError, match="outside the sealed writer"): + rec.finalize_encryption() + + +# -- record, compile and run, end to end -------------------------------------- + + +@pytest.fixture +def fault_app() -> Iterator[str]: + from openadapt_flow.mockmed.fault_server import serve + + base_url, _db, stop = serve() + try: + yield base_url + finally: + stop() + + +E2E_MARKERS = [ + NOTE.encode(), + PNG_SIGNATURE, + b'"viewport_before"', # events.jsonl + b'"app_url"', # meta.json + b'"anchor"', # workflow.json + b'"results"', # report.json +] + + +def test_record_compile_and_governed_run_write_no_plaintext( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, fault_app: str +) -> None: + from openadapt_flow import tutorial + from openadapt_flow.compiler import compile_recording + from openadapt_flow.report import render_run_report + + monkeypatch.setenv(crypto.ENV_KEY, KEY) + import tempfile + + system_tmp = tmp_path / "system-tmp" + system_tmp.mkdir() + monkeypatch.setattr(tempfile, "tempdir", str(system_tmp)) + + rec_dir, bundle, run_dir = tmp_path / "rec", tmp_path / "bundle", tmp_path / "run" + with watching_disk() as watch: + tutorial.record_tutorial(fault_app, rec_dir, encryption_key=KEY) + compile_recording(rec_dir, bundle, name="sealed-tutorial", mine_effects=True) + workflow = Workflow.load(bundle) + tutorial.certify_tutorial(workflow) + report = tutorial.run_tutorial_workflow( + base_url=fault_app, workflow=workflow, bundle_dir=bundle, run_dir=run_dir + ) + render_run_report(run_dir) + + assert report.execution_outcome == "VERIFIED" + assert watch.leaks(E2E_MARKERS) == [] + assert list(system_tmp.iterdir()) == [] + for root in (rec_dir, bundle, run_dir): + _assert_ciphertext_only(root) + sealed_set.SealedSet.open(root, KEY).verify_all() + stored = json.loads(sealed_set.read_text(run_dir / "report.json")) + assert stored["execution_outcome"] == "VERIFIED" + + +def test_encrypted_recording_and_bundle_refuse_without_the_key( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + rec_dir = tmp_path / "rec" + rec = Recorder(_FakeBackend(), rec_dir, encryption_key=KEY) + rec.click(10, 20) + rec.finish() + monkeypatch.delenv(crypto.ENV_KEY, raising=False) + monkeypatch.delenv(sealed_set.RECORDING_ENV_KEY, raising=False) + + code = main(["compile", str(rec_dir), "--out", str(tmp_path / "b"), "--name", "x"]) + assert code == 2 + err = capsys.readouterr().err + assert sealed_set.RECORDING_ENV_KEY in err and "Nothing was read" in err + assert not (tmp_path / "b").exists() + + monkeypatch.setenv(sealed_set.RECORDING_ENV_KEY, "wrong key") + with pytest.raises(crypto.DecryptionError): + sealed_set.SealedSet.open(rec_dir) From bbc452fdb8847d2344e83a000062902eefbe04f5 Mon Sep 17 00:00:00 2001 From: abrichr Date: Mon, 19 Oct 2026 11:18:55 -0400 Subject: [PATCH 3/9] WIP: keep compile, induce, qualify, admit, scaffold-verifier and undo output sealed Co-Authored-By: Claude Opus 5.5 --- packages/flow/openadapt_flow/__main__.py | 47 ++++++++++++-- .../flow/openadapt_flow/admit_pipeline.py | 64 ++++++++++++++++--- packages/flow/openadapt_flow/after_stop.py | 52 +++++++++------ packages/flow/openadapt_flow/cli_undo.py | 19 +++--- .../openadapt_flow/qualification_drills.py | 12 ++-- .../openadapt_flow/qualification_proposal.py | 15 +++-- .../openadapt_flow/record_check_identity.py | 20 +++++- .../openadapt_flow/record_check_synthesis.py | 24 ++++--- .../flow/openadapt_flow/scaffold_verifier.py | 36 +++++++++-- packages/flow/openadapt_flow/sealed_set.py | 9 +++ 10 files changed, 232 insertions(+), 66 deletions(-) diff --git a/packages/flow/openadapt_flow/__main__.py b/packages/flow/openadapt_flow/__main__.py index 838cf2c1d..65224801b 100644 --- a/packages/flow/openadapt_flow/__main__.py +++ b/packages/flow/openadapt_flow/__main__.py @@ -304,7 +304,9 @@ def _replay_params( if params_file: path = Path(params_file) try: - raw = json.loads(path.read_text(encoding="utf-8")) + from openadapt_flow import sealed_set + + raw = json.loads(sealed_set.read_text(path)) except (OSError, json.JSONDecodeError) as exc: raise SystemExit(f"--params-file could not be read as JSON: {exc}") from exc if not isinstance(raw, dict): @@ -1946,13 +1948,25 @@ def _cmd_induce(args: argparse.Namespace) -> int: # (a dir containing workflow.json -> loaded as a Workflow). Detecting the # bundle case CLI-side keeps induce usable on artifacts the operator # already has, without touching the library API. + from openadapt_flow import sealed_set + traces: list = [] + sealed_inputs = False for d in args.recording: path = Path(d) - if (path / "workflow.json").is_file(): + sealed_inputs = sealed_inputs or sealed_set.is_sealed(path) + if (path / "workflow.json").is_file() or (path / "workflow.json.enc").is_file(): traces.append(Workflow.load(path)) else: traces.append(path) + if sealed_inputs and (args.report or args.probe_on): + # Both write the induced program (or probe bundles) outside any + # sealed set; refuse rather than write plaintext. + print( + "induce REFUSED: --report and --probe-on are not available for " + "encrypted inputs yet. Nothing was written." + ) + return 2 if args.probe_on: probed = _induce_with_probes(args, traces) @@ -1984,7 +1998,14 @@ def _cmd_induce(args: argparse.Namespace) -> int: if args.name: workflow = workflow.model_copy(update={"name": args.name}) out = Path(args.out) - workflow.save(out) + if sealed_inputs: + # Encrypted demonstrations induce an encrypted program. + passphrase = sealed_set.passphrase_for("bundle") or sealed_set.recording_key() + assert passphrase is not None # the CLI guard refused a missing key + with sealed_set.writing(out, "bundle", passphrase): + workflow.save(out) + else: + workflow.save(out) print( f"\nCERTIFIED — induced program bundle written to {out} " f"(workflow: {workflow.name!r}, " @@ -5193,9 +5214,11 @@ def _retain_drill_evidence(run_dir: Path, driver) -> None: from openadapt_flow.ir import RunReport from openadapt_flow.qualification_drills import write_drill_evidence + from openadapt_flow import sealed_set + try: report = RunReport.model_validate_json( - (run_dir / "report.json").read_text(encoding="utf-8") + sealed_set.read_text(run_dir / "report.json") ) except (OSError, ValueError): report = None @@ -5740,6 +5763,18 @@ def _cmd_qualify_proposal(args: argparse.Namespace) -> int: workflow = Workflow.load(bundle) except (OSError, ValueError) as exc: raise SystemExit(f"could not load bundle: {exc}") from exc + from openadapt_flow import sealed_set + + out_arg = getattr(args, "out", None) + if ( + out_arg + and sealed_set.is_sealed_path(bundle) + and not sealed_set.is_sealed_path(Path(out_arg)) + ): + raise SystemExit( + f"qualify {verb} REFUSED: the bundle is encrypted, so --out must be " + "inside it (or omitted). Nothing was written." + ) if verb == "propose": try: @@ -5833,7 +5868,9 @@ def _cmd_qualify_proposal(args: argparse.Namespace) -> int: if args.out else bundle / "qualification-admission.local-dev.json" ) - out.write_text(local.model_dump_json(indent=2), encoding="utf-8") + from openadapt_flow import sealed_set + + sealed_set.write_text(out, local.model_dump_json(indent=2)) print(local.model_dump_json(indent=2)) return 0 diff --git a/packages/flow/openadapt_flow/admit_pipeline.py b/packages/flow/openadapt_flow/admit_pipeline.py index d7610e177..b0a8e9852 100644 --- a/packages/flow/openadapt_flow/admit_pipeline.py +++ b/packages/flow/openadapt_flow/admit_pipeline.py @@ -210,8 +210,14 @@ def _bundle_digest(bundle: Path) -> str: ) from openadapt_flow.ir import Workflow + from openadapt_flow import sealed_set + workflow = Workflow.load(bundle) - return compute_content_digest(workflow, compute_file_hashes(workflow, bundle)) + # A sealed bundle's crops are ciphertext on disk; hash the plaintext. + assets = workflow.decrypted_templates() if sealed_set.is_sealed(bundle) else None + return compute_content_digest( + workflow, compute_file_hashes(workflow, bundle, assets=assets) + ) def _is_bundle(path: Path) -> bool: @@ -350,7 +356,7 @@ def stop(status: PendingStatus, reason: str, **extra: Any) -> PendingAdmission: for index, path in enumerate(paths): if not path.is_dir(): raise AdmissionError(f"{path} is not a directory") - if recording_meta is None and (path / "meta.json").is_file(): + if recording_meta is None and _sealed_is_file(path / "meta.json"): recording_meta = path if _is_bundle(path): compiled.append(path) @@ -377,14 +383,32 @@ def stop(status: PendingStatus, reason: str, **extra: Any) -> PendingAdmission: workflow = result.workflow induced = True workflow = workflow.model_copy(update={"name": name}) - _copy_templates(compiled, bundle_dir) - workflow.save(bundle_dir) + from openadapt_flow import sealed_set + + sealed = any(sealed_set.is_sealed(path) for path in compiled) + if sealed: + # Encrypted recordings or bundles admit into a sealed bundle: the + # crops are merged in memory and never copied out as plaintext. + passphrase = sealed_set.passphrase_for("bundle") or sealed_set.recording_key() + if not passphrase: + raise AdmissionError(sealed_set.missing_key_message(compiled[0], "bundle")) + templates: dict[str, bytes] = {} + for source in compiled: + for item in sealed_set.list_files(source / "templates"): + rel = item.relative_to(source).as_posix() + templates.setdefault(rel, sealed_set.read_bytes(item)) + workflow._decrypted_templates = templates + with sealed_set.writing(bundle_dir, "bundle", passphrase): + workflow.save(bundle_dir) + else: + _copy_templates(compiled, bundle_dir) + workflow.save(bundle_dir) workflow = Workflow.load(bundle_dir) proposal = propose_qualification( workflow, recording_dir=recording_meta, policy_pack=policy_pack ) - write_proposal(proposal, target / PROPOSAL_FILE) + write_proposal(proposal, _proposal_path(target)) steps = list(iter_workflow_steps(workflow)) detail: dict[str, Any] = { "recordings": len(compiled), @@ -411,6 +435,23 @@ def stop(status: PendingStatus, reason: str, **extra: Any) -> PendingAdmission: return pending +def _sealed_is_file(path: Path) -> bool: + from openadapt_flow import sealed_set + + return sealed_set.is_file(path) + + +def _proposal_path(target: Path) -> Path: + """The draft proposal sits beside the bundle, or inside it (encrypted) + when the bundle is sealed.""" + from openadapt_flow import sealed_set + + bundle = target / BUNDLE_DIR + if sealed_set.is_sealed(bundle): + return bundle / PROPOSAL_FILE + return target / PROPOSAL_FILE + + def _copy_templates(compiled: Sequence[Path], bundle_dir: Path) -> None: """Carry each compiled bundle's crops; the first bundle wins a name clash.""" @@ -452,7 +493,10 @@ def load_pending( def _write_json(path: Path, payload: dict[str, Any]) -> None: - path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", "utf-8") + from openadapt_flow import sealed_set + + # Sealed in memory when ``path`` is inside a sealed bundle. + sealed_set.write_text(path, json.dumps(payload, indent=2, sort_keys=True) + "\n") def _key_dir(key_dir: Optional[Path | str]) -> Path: @@ -676,7 +720,7 @@ def decide_admission( ) record["approver_grant"] = signed.model_dump(mode="json", by_alias=True) if decision == "accept": - proposal = load_proposal(target / PROPOSAL_FILE) + proposal = load_proposal(_proposal_path(target)) if proposal.sha256() != pending.proposal_sha256: raise AdmissionError("the check proposal changed since it was shown") workflow = Workflow.load(bundle) @@ -732,11 +776,13 @@ def admitted_approval_mode( longer verifies, names the reason and gives no mode. Never raises. """ + from openadapt_flow import sealed_set + path = Path(bundle) / DECISION_FILE - if not path.is_file() or path.is_symlink(): + if not sealed_set.is_file(path) or path.is_symlink(): return AdmittedMode() try: - data = json.loads(path.read_text("utf-8")) + data = json.loads(sealed_set.read_text(path)) except (OSError, ValueError): return AdmittedMode(refusal="admission_unreadable") if not _signed(data, key_dir): diff --git a/packages/flow/openadapt_flow/after_stop.py b/packages/flow/openadapt_flow/after_stop.py index edf05bb92..d0e326bd4 100644 --- a/packages/flow/openadapt_flow/after_stop.py +++ b/packages/flow/openadapt_flow/after_stop.py @@ -39,6 +39,7 @@ from pydantic import BaseModel, Field +from openadapt_flow import sealed_set as _sealed from openadapt_flow.ir import ( ChangeLedgerEffect, ChangeLedgerEntry, @@ -522,9 +523,9 @@ def _canonical(payload: Any) -> bytes: def _load_report(run_dir: Path) -> tuple[RunReport, str]: path = run_dir / "report.json" - if not path.is_file(): + if not _sealed.is_file(path): raise UndoNotOffered(f"{run_dir} holds no report.json") - raw = path.read_bytes() + raw = _sealed.read_bytes(path) return RunReport.model_validate_json(raw), hashlib.sha256(raw).hexdigest() @@ -550,10 +551,10 @@ def _has_loop(workflow: Workflow) -> bool: def _read_lineage(bundle_dir: Path) -> list[str]: path = bundle_dir / LINEAGE_FILENAME - if not path.is_file(): + if not _sealed.is_file(path): return [] try: - raw = json.loads(path.read_text(encoding="utf-8")) + raw = json.loads(_sealed.read_text(path)) except (OSError, json.JSONDecodeError): return [] if not isinstance(raw, dict) or raw.get("schema") != _LINEAGE_SCHEMA: @@ -568,11 +569,11 @@ def _record_lineage(bundle_dir: Path, prior_digest: str) -> None: digests = _read_lineage(bundle_dir) if prior_digest not in digests: digests.append(prior_digest) - (bundle_dir / LINEAGE_FILENAME).write_text( + _sealed.write_text( + bundle_dir / LINEAGE_FILENAME, json.dumps( {"schema": _LINEAGE_SCHEMA, "prior_content_digests": digests}, indent=2 ), - encoding="utf-8", ) @@ -700,6 +701,13 @@ def _find_offer( def _write_once(path: Path, payload: str) -> None: + if _sealed.is_sealed_path(path): + # The run of a sealed bundle: the record is encrypted in memory. The + # existence check and the write are not one atomic step here. + if _sealed.exists(path): + raise FileExistsError(str(path)) + _sealed.write_text(path, payload) + return path.parent.mkdir(parents=True, exist_ok=True) fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) with os.fdopen(fd, "w", encoding="utf-8") as handle: @@ -726,7 +734,7 @@ def approve_undo( run_dir = Path(run_dir) offer = _find_offer(run_dir, workflow, undo_id, bundle_dir) undo_dir = _undo_dir(run_dir, undo_id) - if (undo_dir / CLAIM_FILENAME).exists(): + if _sealed.exists(undo_dir / CLAIM_FILENAME): raise UndoAlreadyRun(f"undo {undo_id!r} was already run; it runs once") approval = UndoApproval( undo_id=undo_id, @@ -739,12 +747,15 @@ def approve_undo( existing = read_undo_approval(run_dir, undo_id) if existing is not None and existing.offer_digest == offer.offer_digest: raise UndoRefused(f"undo {undo_id!r} is already approved for this offer") - if existing is None and not path.exists(): + if existing is None and not _sealed.exists(path): try: _write_once(path, approval.model_dump_json(indent=2)) except FileExistsError as exc: raise UndoRefused(f"undo {undo_id!r} already has an approval") from exc return approval + if _sealed.is_sealed_path(path): + _sealed.write_text(path, approval.model_dump_json(indent=2)) + return approval # A stale or unreadable approval that was never used: replace it whole. staging = undo_dir / f".{APPROVAL_FILENAME}.{os.getpid()}" _write_once(staging, approval.model_dump_json(indent=2)) @@ -754,19 +765,19 @@ def approve_undo( def read_undo_approval(run_dir: Path | str, undo_id: str) -> Optional[UndoApproval]: path = _undo_dir(Path(run_dir), undo_id) / APPROVAL_FILENAME - if not path.is_file(): + if not _sealed.is_file(path): return None try: - return UndoApproval.model_validate_json(path.read_text(encoding="utf-8")) + return UndoApproval.model_validate_json(_sealed.read_text(path)) except ValueError: return None def read_undo_result(run_dir: Path | str, undo_id: str) -> Optional[UndoResult]: path = _undo_dir(Path(run_dir), undo_id) / RESULT_FILENAME - if not path.is_file(): + if not _sealed.is_file(path): return None - return UndoResult.model_validate_json(path.read_text(encoding="utf-8")) + return UndoResult.model_validate_json(_sealed.read_text(path)) @dataclass(frozen=True) @@ -813,15 +824,13 @@ def prepare_undo_run( run_dir = Path(run_dir) offer = _find_offer(run_dir, workflow, undo_id, bundle_dir) approval_path = _undo_dir(run_dir, undo_id) / APPROVAL_FILENAME - if not approval_path.is_file(): + if not _sealed.is_file(approval_path): raise UndoNotApproved( f"undo {undo_id!r} has no approval. A person must review and " "approve it first; Flow never runs an undo on its own" ) try: - approval = UndoApproval.model_validate_json( - approval_path.read_text(encoding="utf-8") - ) + approval = UndoApproval.model_validate_json(_sealed.read_text(approval_path)) except ValueError as exc: raise UndoNotApproved(f"undo {undo_id!r} approval is unreadable") from exc if approval.undo_id != undo_id or approval.offer_digest != offer.offer_digest: @@ -917,8 +926,9 @@ def finish_undo_run( for line in r.effect_results ], ) - (_undo_dir(Path(run_dir), prepared.undo.id) / RESULT_FILENAME).write_text( - result.model_dump_json(indent=2), encoding="utf-8" + _sealed.write_text( + _undo_dir(Path(run_dir), prepared.undo.id) / RESULT_FILENAME, + result.model_dump_json(indent=2), ) return result @@ -1039,9 +1049,13 @@ def declare_undo( if problems: raise ValueError(f"undo {undo.id!r} {problems[0]}") for rel in assets or {}: - if (bundle_dir / rel).exists(): + if _sealed.exists(bundle_dir / rel): raise ValueError(f"the bundle already has {rel}") for rel, source in (assets or {}).items(): + if _sealed.is_sealed(bundle_dir): + # The crop joins the in-memory map; save() seals it below. + workflow._decrypted_templates[rel] = Path(source).read_bytes() + continue (bundle_dir / rel).parent.mkdir(parents=True, exist_ok=True) shutil.copyfile(source, bundle_dir / rel) if workflow.manifest is not None and workflow.manifest.content_digest: diff --git a/packages/flow/openadapt_flow/cli_undo.py b/packages/flow/openadapt_flow/cli_undo.py index 9249a0e7d..56ded8a3e 100644 --- a/packages/flow/openadapt_flow/cli_undo.py +++ b/packages/flow/openadapt_flow/cli_undo.py @@ -17,6 +17,7 @@ from pathlib import Path from typing import Callable, Literal, Optional, Sequence +from openadapt_flow import sealed_set from openadapt_flow.after_stop import ( UndoRefused, approve_undo, @@ -204,7 +205,7 @@ def _cmd_undo_list(args: argparse.Namespace) -> int: result = read_undo_result(run_dir, offer.undo_id) approval = read_undo_approval(run_dir, offer.undo_id) approved = approval is not None and approval.offer_digest == offer.offer_digest - claimed = (run_dir / "undo" / offer.undo_id / "claim.json").exists() + claimed = sealed_set.exists(run_dir / "undo" / offer.undo_id / "claim.json") state = ( f"ran: {result.status}" if result @@ -311,9 +312,13 @@ def _cmd_undo_run(args: argparse.Namespace) -> int: print(f"undo run REFUSED: {exc}. Nothing was executed.", file=sys.stderr) return 3 params_path = prepared.undo_run_dir.parent / "params.json" - fd = os.open(params_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) - with os.fdopen(fd, "w", encoding="utf-8") as handle: - json.dump(prepared.params, handle) + if sealed_set.is_sealed_path(params_path): + # Parameter values are patient data; keep them sealed with the run. + sealed_set.write_text(params_path, json.dumps(prepared.params)) + else: + fd = os.open(params_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(prepared.params, handle) replay_args = argparse.Namespace(**vars(args)) replay_args.run_dir = str(prepared.undo_run_dir) replay_args.param = None @@ -329,10 +334,8 @@ def _cmd_undo_run(args: argparse.Namespace) -> int: args._undo_replay(replay_args) finally: report_path = prepared.undo_run_dir / "report.json" - if report_path.is_file(): - report = RunReport.model_validate_json( - report_path.read_text(encoding="utf-8") - ) + if sealed_set.is_file(report_path): + report = RunReport.model_validate_json(sealed_set.read_text(report_path)) result = finish_undo_run(run_dir, prepared, report) if result.status == "verified": print(f"Undo {result.undo_id} VERIFIED: {result.reason}.") diff --git a/packages/flow/openadapt_flow/qualification_drills.py b/packages/flow/openadapt_flow/qualification_drills.py index 12587fb61..fbb5230b7 100644 --- a/packages/flow/openadapt_flow/qualification_drills.py +++ b/packages/flow/openadapt_flow/qualification_drills.py @@ -831,8 +831,10 @@ def write_drill_evidence(run_dir: Path, driver: Any, report: Any) -> dict[str, A """Keep a drill's signed receipt and mutation record next to its report. Desktop records them as the ``fault_receipt`` and ``fault_mutation`` - evidence of the case result. Returns a PHI-free summary. + evidence of the case result. Returns a PHI-free summary. Sealed in memory + when the run directory is encrypted (the run of a sealed bundle). """ + from openadapt_flow import sealed_set as _sealed folder = Path(run_dir) / "qualification-fault" receipts = list(getattr(report, "qualification_fault_mutations", None) or []) @@ -844,17 +846,17 @@ def write_drill_evidence(run_dir: Path, driver: Any, report: Any) -> dict[str, A folder.mkdir(mode=0o700, parents=True, exist_ok=True) if receipts: receipt = receipts[0] - (folder / "receipt.json").write_bytes(receipt.artifact_bytes()) + _sealed.write_bytes(folder / "receipt.json", receipt.artifact_bytes()) summary["receipt"] = "qualification-fault/receipt.json" summary["receipt_sha256"] = receipt.receipt_sha256() mutation_bytes = getattr(driver, "mutation_bytes", None) artifact = mutation_bytes(receipt) if callable(mutation_bytes) else None if artifact is not None: - (folder / "mutation.json").write_bytes(artifact) + _sealed.write_bytes(folder / "mutation.json", artifact) summary["mutation"] = "qualification-fault/mutation.json" summary["mutation_sha256"] = hashlib.sha256(artifact).hexdigest() - (folder / "status.json").write_text( - json.dumps(summary, sort_keys=True, indent=2) + "\n", encoding="utf-8" + _sealed.write_text( + folder / "status.json", json.dumps(summary, sort_keys=True, indent=2) + "\n" ) return summary diff --git a/packages/flow/openadapt_flow/qualification_proposal.py b/packages/flow/openadapt_flow/qualification_proposal.py index b0ca0768c..f5fab34c7 100644 --- a/packages/flow/openadapt_flow/qualification_proposal.py +++ b/packages/flow/openadapt_flow/qualification_proposal.py @@ -474,13 +474,18 @@ def admit_local_dev( def write_proposal(proposal: QualificationProposal, path: Path | str) -> Path: + from openadapt_flow import sealed_set + target = Path(path) - target.write_text(proposal.model_dump_json(indent=2), encoding="utf-8") + # Sealed in memory when the target is inside a sealed bundle. + sealed_set.write_text(target, proposal.model_dump_json(indent=2)) return target def load_proposal(path: Path | str) -> QualificationProposal: - payload = json.loads(Path(path).read_text(encoding="utf-8")) + from openadapt_flow import sealed_set + + payload = json.loads(sealed_set.read_text(Path(path))) return QualificationProposal.model_validate(payload) @@ -498,9 +503,11 @@ def save_accepted_bundle( if proposal is not None: write_proposal(proposal, bundle / "qualification-proposal.json") if local_admission is not None: - (bundle / "qualification-admission.local-dev.json").write_text( + from openadapt_flow import sealed_set + + sealed_set.write_text( + bundle / "qualification-admission.local-dev.json", local_admission.model_dump_json(indent=2), - encoding="utf-8", ) return path diff --git a/packages/flow/openadapt_flow/record_check_identity.py b/packages/flow/openadapt_flow/record_check_identity.py index ec0ee62c7..a8aa87b98 100644 --- a/packages/flow/openadapt_flow/record_check_identity.py +++ b/packages/flow/openadapt_flow/record_check_identity.py @@ -20,6 +20,7 @@ from pathlib import Path from typing import Any, Mapping, Optional +from openadapt_flow import sealed_set as _sealed from openadapt_flow.record_check_synthesis import param_lookup #: Accessibility roles whose accessible name is the record row itself; the @@ -31,10 +32,10 @@ def _events(recording_dir: Path) -> list[dict[str, Any]]: path = Path(recording_dir) / "events.jsonl" - if not path.is_file(): + if not _sealed.is_file(path): return [] out: list[dict[str, Any]] = [] - for line in path.read_text("utf-8").splitlines(): + for line in _sealed.read_text(path).splitlines(): try: out.append(json.loads(line) if line.strip() else {}) except ValueError: @@ -159,6 +160,21 @@ def write_identity_proposals( if not proposals: return None path = out_dir / IDENTITY_PROPOSED_NAME + if _sealed.is_sealed_path(path): + _sealed.write_text( + path, + json.dumps( + { + "schema": "openadapt.identity-proposal/v1", + "approved": False, + "proposals": proposals, + }, + indent=2, + sort_keys=True, + ) + + "\n", + ) + return path fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) with os.fdopen(fd, "w", encoding="utf-8") as fh: json.dump( diff --git a/packages/flow/openadapt_flow/record_check_synthesis.py b/packages/flow/openadapt_flow/record_check_synthesis.py index ea9e3673b..c606e2d2d 100644 --- a/packages/flow/openadapt_flow/record_check_synthesis.py +++ b/packages/flow/openadapt_flow/record_check_synthesis.py @@ -51,6 +51,7 @@ from typing import Any, Iterable, Mapping, Optional from urllib.parse import parse_qsl, quote, urlsplit +from openadapt_flow import sealed_set as _sealed from openadapt_flow.runtime.effects._common import judge_records from openadapt_flow.runtime.effects.effect import ( Effect, @@ -103,12 +104,12 @@ def load_network(recording_dir: Path) -> list[dict[str, Any]]: """The capture's request lines; an absent file is an empty capture.""" path = Path(recording_dir) / NETWORK_FILE - if not path.is_file(): + if not _sealed.is_file(path): return [] try: rows = [ json.loads(line) - for line in path.read_text(encoding="utf-8").splitlines() + for line in _sealed.read_text(path).splitlines() if line.strip() ] except (OSError, ValueError) as exc: @@ -126,7 +127,9 @@ def recorded_params(recording_dir: Path) -> dict[str, str]: """The demonstration's parameter values from ``meta.json``.""" try: - meta = json.loads((Path(recording_dir) / "meta.json").read_text("utf-8")) + meta = json.loads(_sealed.read_text(Path(recording_dir) / "meta.json")) + except _sealed.SealedSetError: + raise except (OSError, ValueError): return {} params = meta.get("params") if isinstance(meta, dict) else None @@ -431,9 +434,9 @@ def _save_event_times(recording_dir: Path) -> tuple[Optional[float], Optional[fl times: dict[int, float] = {} events_path = Path(recording_dir) / "events.jsonl" - if not events_path.is_file(): + if not _sealed.is_file(events_path): return None, None - for index, line in enumerate(events_path.read_text("utf-8").splitlines()): + for index, line in enumerate(_sealed.read_text(events_path).splitlines()): try: event = json.loads(line) if line.strip() else {} except ValueError: @@ -1513,9 +1516,9 @@ def synthesize( snapshots = None if recording_dir is not None: snap = Path(recording_dir) / "sor_snapshots.json" - if snap.is_file(): + if _sealed.is_file(snap): try: - snapshots = json.loads(snap.read_text("utf-8")) + snapshots = json.loads(_sealed.read_text(snap)) except ValueError: snapshots = None reads_from_verifier(verifier, params, search, snapshots=snapshots) @@ -1608,10 +1611,11 @@ def write(proposal: Proposal, out_dir: Path) -> tuple[Path, Path]: out_dir.mkdir(parents=True, exist_ok=True) check = out_dir / PROPOSED_NAME proof = out_dir / PROOF_NAME - check.write_text(render(proposal), encoding="utf-8") - proof.write_text( + # Sealed in memory when ``out_dir`` is an encrypted recording or bundle. + _sealed.write_text(check, render(proposal)) + _sealed.write_text( + proof, json.dumps(proposal.proof(), indent=2, sort_keys=True, default=str) + "\n", - encoding="utf-8", ) return check, proof diff --git a/packages/flow/openadapt_flow/scaffold_verifier.py b/packages/flow/openadapt_flow/scaffold_verifier.py index 27dcce433..9b62e180c 100644 --- a/packages/flow/openadapt_flow/scaffold_verifier.py +++ b/packages/flow/openadapt_flow/scaffold_verifier.py @@ -43,6 +43,8 @@ from pathlib import Path from typing import Any, Optional +from openadapt_flow import sealed_set as _sealed + BUNDLE_MARKERS = ("workflow.json", "workflow.json.enc") RECORDING_MARKER = "events.jsonl" @@ -72,6 +74,8 @@ def classify_target(path: Path) -> str: """Return ``"bundle"`` or ``"recording"`` for an existing artifact path.""" if not path.exists(): raise ScaffoldRefused(f"scaffold-verifier: path not found: {path}") + if _sealed.is_sealed(path): + return _sealed.header_kind(path) if any((path / marker).is_file() for marker in BUNDLE_MARKERS): return "bundle" if (path / RECORDING_MARKER).is_file(): @@ -141,7 +145,7 @@ def _demonstrated_param_values(recording_dir: Path) -> tuple[str, ...]: import json try: - meta = json.loads((recording_dir / "meta.json").read_text(encoding="utf-8")) + meta = json.loads(_sealed.read_text(recording_dir / "meta.json")) except (OSError, json.JSONDecodeError): return () params = meta.get("params") @@ -199,7 +203,7 @@ def candidates_from_recording(recording_dir: Path) -> list[WriteCandidate]: events_path = recording_dir / RECORDING_MARKER try: - lines = events_path.read_text(encoding="utf-8").splitlines() + lines = _sealed.read_text(events_path).splitlines() except OSError as exc: raise ScaffoldRefused( f"scaffold-verifier: could not read {events_path}: {exc}" @@ -452,7 +456,7 @@ def write_draft(source: Path, out_dir: Optional[Path] = None) -> tuple[Path, int import json try: - meta = json.loads((source / "meta.json").read_text(encoding="utf-8")) + meta = json.loads(_sealed.read_text(source / "meta.json")) except (OSError, json.JSONDecodeError): meta = {} workflow_name = str(meta.get("app_url") or source.name) @@ -467,12 +471,27 @@ def write_draft(source: Path, out_dir: Optional[Path] = None) -> tuple[Path, int workflow_name=workflow_name, candidates=candidates, ) + _refuse_plaintext_output(source, out_dir) destination = (out_dir or source) / CONTRACT_FILENAME destination.parent.mkdir(parents=True, exist_ok=True) - destination.write_text(text, encoding="utf-8") + # Sealed in memory when the destination is an encrypted bundle/recording. + _sealed.write_text(destination, text) return destination, len(candidates) +def _refuse_plaintext_output(source: Path, out_dir: Optional[Path]) -> None: + """An encrypted source keeps its drafts inside its own sealed set.""" + if ( + out_dir is not None + and _sealed.is_sealed_path(source) + and not _sealed.is_sealed_path(out_dir) + ): + raise ScaffoldRefused( + "scaffold-verifier: the source is encrypted, so its drafts are " + "written inside it, encrypted. Drop --out; nothing was written." + ) + + # --------------------------------------------------------------------------- # A working record check from the reads the app made # --------------------------------------------------------------------------- @@ -517,6 +536,15 @@ def build_record_check( bundle_dir = source if kind == "bundle" else bundle if recording_dir is None: return None + for sealed_input in (recording_dir, bundle_dir): + if sealed_input is not None and _sealed.is_sealed_path(sealed_input): + _refuse_plaintext_output(sealed_input, out_dir) + if prove: + raise ScaffoldRefused( + "scaffold-verifier --prove is not available for encrypted " + "input yet (the live proof uses a temporary directory). " + "Nothing was written." + ) writes = ( candidates_from_bundle(bundle_dir) if kind == "bundle" and bundle_dir is not None diff --git a/packages/flow/openadapt_flow/sealed_set.py b/packages/flow/openadapt_flow/sealed_set.py index 9149b85e7..c449899cf 100644 --- a/packages/flow/openadapt_flow/sealed_set.py +++ b/packages/flow/openadapt_flow/sealed_set.py @@ -155,6 +155,11 @@ class FileKind: "phi", "a local-development admission record", ), + FileKind( + "qualification-proposal.draft.json", + "phi", + "an admission's draft check proposal", + ), FileKind( "admission-decision.json", "phi", @@ -346,6 +351,9 @@ def _atomic_write(path: Path, data: bytes) -> None: def _disk_files(root: Path) -> list[str]: + """Every file under ``root`` except the header. A subdirectory that is + itself a sealed set (an undo run inside a run directory) is a separate, + separately verified set and is skipped.""" out: list[str] = [] for current, dirnames, filenames in os.walk(root, followlinks=False): here = Path(current) @@ -355,6 +363,7 @@ def _disk_files(root: Path) -> list[str]: "sealed set contains a symlink: " f"{(here / name).relative_to(root).as_posix()}" ) + dirnames[:] = [name for name in dirnames if not is_sealed(here / name)] for name in filenames: path = here / name rel = path.relative_to(root).as_posix() From 59a45e653721e7ba4af08bbc179cda969dee9518 Mon Sep 17 00:00:00 2001 From: abrichr Date: Mon, 19 Oct 2026 11:24:45 -0400 Subject: [PATCH 4/9] WIP: sealed resume, attended state, nested undo runs Co-Authored-By: Claude Opus 5.5 --- .../runtime/durable/attended.py | 54 ++-- .../runtime/durable/continuation.py | 10 +- .../flow/openadapt_flow/runtime/replayer.py | 9 +- packages/flow/openadapt_flow/sealed_set.py | 63 ++++- packages/flow/tests/test_sealed_at_rest.py | 230 +++++++++++++++++- 5 files changed, 328 insertions(+), 38 deletions(-) diff --git a/packages/flow/openadapt_flow/runtime/durable/attended.py b/packages/flow/openadapt_flow/runtime/durable/attended.py index eeabaf46a..ba1d1c4d1 100644 --- a/packages/flow/openadapt_flow/runtime/durable/attended.py +++ b/packages/flow/openadapt_flow/runtime/durable/attended.py @@ -34,6 +34,7 @@ from pydantic import BaseModel, ConfigDict, Field, model_serializer, model_validator +from openadapt_flow import sealed_set as _sealed from openadapt_flow.ir import ( AttendedProgramTransitionEvidence, IdentityCheck, @@ -794,6 +795,11 @@ def _fsync_parent(path: Path) -> None: @staticmethod def _atomic_write(path: Path, payload: bytes, *, mode: int = 0o600) -> None: + if _sealed.is_sealed_path(path): + # The run of a sealed bundle: this state can carry the source + # record's identity, so it is encrypted in memory. + _sealed.write_bytes(path, payload) + return path.parent.mkdir(parents=True, exist_ok=True) tmp = path.with_name(f".{path.name}.{secrets.token_hex(8)}.tmp") fd = os.open(tmp, os.O_CREAT | os.O_EXCL | os.O_WRONLY, mode) @@ -971,20 +977,20 @@ def _signed_capabilities(self) -> list[AttendedPauseCapability]: "the attended capability path must not be a symlink" ) capabilities: list[AttendedPauseCapability] = [] - if self.capability_path.is_file(): + if _sealed.is_file(self.capability_path): try: capabilities.append( AttendedPauseCapability.model_validate_json( - self.capability_path.read_text() + _sealed.read_text(self.capability_path) ) ) except ValueError as exc: raise AttendedActionRefused( "the current attended capability is invalid" ) from exc - if self.capability_history_path.is_file(): + if _sealed.is_file(self.capability_history_path): try: - raw_history = json.loads(self.capability_history_path.read_text()) + raw_history = json.loads(_sealed.read_text(self.capability_history_path)) if not isinstance(raw_history, list): raise ValueError("history is not a list") capabilities.extend( @@ -1185,7 +1191,7 @@ def write_reconciliation_receipt( path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) if os.name != "nt": os.chmod(path.parent, 0o700) - if path.is_file(): + if _sealed.is_file(path): existing = self.read_reconciliation_receipt(sealed.pause_id) if existing != sealed: raise AttendedActionRefused( @@ -1205,7 +1211,7 @@ def read_reconciliation_receipt( ) try: receipt = AttendedReconciliationReceipt.model_validate_json( - path.read_text() + _sealed.read_text(path) ) except (FileNotFoundError, ValueError) as exc: raise AttendedActionRefused( @@ -1250,7 +1256,7 @@ def write_program_receipt( path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) if os.name != "nt": os.chmod(path.parent, 0o700) - if path.is_file(): + if _sealed.is_file(path): existing = self.read_program_receipt(sealed.pause_id) if existing != sealed: raise AttendedActionRefused( @@ -1269,7 +1275,7 @@ def read_program_receipt(self, pause_id: str) -> ProgramTransitionReceipt: "the private program receipt path must not be a symlink" ) try: - receipt = ProgramTransitionReceipt.model_validate_json(path.read_text()) + receipt = ProgramTransitionReceipt.model_validate_json(_sealed.read_text(path)) except (FileNotFoundError, ValueError) as exc: raise AttendedActionRefused( "the exact program transition receipt is missing or invalid" @@ -1352,7 +1358,7 @@ def issue( delivery_state, ) event_sequence = 1 - if self.capability_path.is_file(): + if _sealed.is_file(self.capability_path): existing = self.read() if ( existing.pause_digest == pause_digest @@ -1376,9 +1382,9 @@ def issue( # an append-only history and let the engine issue the new pause; # browser callers still present the exact current digest. history: list[dict[str, Any]] = [] - if self.capability_history_path.is_file(): + if _sealed.is_file(self.capability_history_path): try: - raw_history = json.loads(self.capability_history_path.read_text()) + raw_history = json.loads(_sealed.read_text(self.capability_history_path)) if isinstance(raw_history, list): history = [ item for item in raw_history if isinstance(item, dict) @@ -1393,7 +1399,7 @@ def issue( json.dumps(history, indent=2, sort_keys=True).encode("utf-8"), ) event_sequence = existing.event_sequence + 1 - elif self.capability_history_path.is_file(): + elif _sealed.is_file(self.capability_history_path): historical = self._signed_capabilities() event_sequence = max(item.event_sequence for item in historical) + 1 now = _now() @@ -1443,13 +1449,13 @@ def retire_current(self) -> None: must not remain visible as the current operator action surface. """ - if not self.capability_path.is_file(): + if not _sealed.is_file(self.capability_path): return existing = self.read() history: list[dict[str, Any]] = [] - if self.capability_history_path.is_file(): + if _sealed.is_file(self.capability_history_path): try: - raw_history = json.loads(self.capability_history_path.read_text()) + raw_history = json.loads(_sealed.read_text(self.capability_history_path)) if not isinstance(raw_history, list) or not all( isinstance(item, dict) for item in raw_history ): @@ -1466,13 +1472,13 @@ def retire_current(self) -> None: self.capability_history_path, json.dumps(history, indent=2, sort_keys=True).encode("utf-8"), ) - self.capability_path.unlink() + _sealed.unlink(self.capability_path) self._fsync_parent(self.capability_path) def read(self) -> AttendedPauseCapability: try: capability = AttendedPauseCapability.model_validate_json( - self.capability_path.read_text() + _sealed.read_text(self.capability_path) ) except (FileNotFoundError, ValueError) as exc: raise AttendedActionRefused( @@ -1567,10 +1573,10 @@ def _read_log_with_head(self) -> tuple[AttendedDecisionLog, str]: "the external attended decision journal is unavailable or invalid" ) from exc if snapshot is None: - if self.decisions_path.is_file(): + if _sealed.is_file(self.decisions_path): try: local = AttendedDecisionLog.model_validate_json( - self.decisions_path.read_text() + _sealed.read_text(self.decisions_path) ) except (OSError, ValueError) as exc: raise AttendedActionRefused( @@ -1591,9 +1597,9 @@ def _read_log_with_head(self) -> tuple[AttendedDecisionLog, str]: payload = snapshot.encode("utf-8") try: local_matches = ( - self.decisions_path.is_file() + _sealed.is_file(self.decisions_path) and not self.decisions_path.is_symlink() - and self.decisions_path.read_bytes() == payload + and _sealed.read_bytes(self.decisions_path) == payload ) except OSError: local_matches = False @@ -3638,7 +3644,7 @@ def _resume_program( + [item.decision_index for item in pending.program_exception_evidence_delta] ) receipt_path = action_store._receipt_path(receipt.pause_id) - receipt_sha256 = hashlib.sha256(receipt_path.read_bytes()).hexdigest() + receipt_sha256 = hashlib.sha256(_sealed.read_bytes(receipt_path)).hexdigest() control_payload = json.dumps( [frame.model_dump(mode="json") for frame in pending.program_frames], sort_keys=True, @@ -3661,8 +3667,8 @@ def _resume_program( raise AttendedActionRefused( "the attended transition control path leaves the run directory" ) - if control_path.is_file(): - if control_path.read_bytes() != control_payload: + if _sealed.is_file(control_path): + if _sealed.read_bytes(control_path) != control_payload: raise AttendedActionRefused( "the attended transition control digest has other bytes" ) diff --git a/packages/flow/openadapt_flow/runtime/durable/continuation.py b/packages/flow/openadapt_flow/runtime/durable/continuation.py index f3d6258bf..94e8ff735 100644 --- a/packages/flow/openadapt_flow/runtime/durable/continuation.py +++ b/packages/flow/openadapt_flow/runtime/durable/continuation.py @@ -562,7 +562,11 @@ def prepare_terminal(self, token: ContinuationToken, report_path: Path) -> str: raise ContinuationBusy( "the durable manifest disappeared at terminal commit" ) - report_sha256 = "sha256:" + hashlib.sha256(report_path.read_bytes()).hexdigest() + from openadapt_flow import sealed_set + + report_sha256 = ( + "sha256:" + hashlib.sha256(sealed_set.read_bytes(report_path)).hexdigest() + ) try: self.authority.prepare_terminal( manifest, @@ -662,7 +666,9 @@ def correct_terminal_projection_failure( if record.phase != "completed": raise ContinuationBusy("the local continuation is not completed") try: - corrected_report_json = corrected_report_path.read_bytes() + from openadapt_flow import sealed_set + + corrected_report_json = sealed_set.read_bytes(corrected_report_path) except OSError as exc: raise ContinuationBusy( "the corrected terminal report is unavailable" diff --git a/packages/flow/openadapt_flow/runtime/replayer.py b/packages/flow/openadapt_flow/runtime/replayer.py index 10f52feb3..88db9ba60 100644 --- a/packages/flow/openadapt_flow/runtime/replayer.py +++ b/packages/flow/openadapt_flow/runtime/replayer.py @@ -2377,7 +2377,14 @@ def acknowledge_delivery(self_nonlocal) -> None: candidate ) durable_run.complete() - os.replace(candidate, run_dir / "report.json") + if _sealed.is_sealed_path(candidate): + # Encrypted run directory: the rename is a sealed rewrite. + _sealed.write_bytes( + run_dir / "report.json", _sealed.read_bytes(candidate) + ) + _sealed.unlink(candidate) + else: + os.replace(candidate, run_dir / "report.json") from openadapt_flow.runtime.durable.checkpoint import CheckpointStore CheckpointStore._fsync_directory(run_dir) diff --git a/packages/flow/openadapt_flow/sealed_set.py b/packages/flow/openadapt_flow/sealed_set.py index c449899cf..95be501a5 100644 --- a/packages/flow/openadapt_flow/sealed_set.py +++ b/packages/flow/openadapt_flow/sealed_set.py @@ -487,8 +487,10 @@ def open( passphrase = _require_passphrase(base, kind, key) set_key = _derive(header, passphrase) files = _open_index(base, header, set_key) - expected = sorted(_physical(rel, e["codec"]) for rel, e in files.items()) # type: ignore[arg-type] present = _disk_files(base) + if kind == "run": + files = _reconcile_run_files(base, files, present) + expected = sorted(_physical(rel, e["codec"]) for rel, e in files.items()) # type: ignore[arg-type] if present != expected: added = sorted(set(present) - set(expected)) dropped = sorted(set(expected) - set(present)) @@ -539,6 +541,54 @@ def verify_all(self) -> None: self.read_bytes(rel) +def _adoptable(root: Path, kind: str, rel: str) -> Optional[Codec]: + """How a file another component wrote directly may join a run set: + ``plain`` for a structural file the inventory names, ``external`` for + ciphertext of another keyed store, ``None`` for plaintext (refused).""" + entry = classify(kind, rel) # type: ignore[arg-type] + if entry is not None and entry.classification == "structural": + return "plain" + if crypto.is_encrypted((root / rel).read_bytes()): + return "external" + return None + + +def _reconcile_run_files( + root: Path, files: dict[str, dict[str, str]], present: list[str] +) -> dict[str, dict[str, str]]: + """A run directory keeps changing after its index was sealed: the durable + store adds, replaces and removes its own encrypted checkpoints, approvals + and locks between legs of a run. Accept exactly those changes; any file + the run's own writer sealed must still be present, and any other new file + must be ciphertext or a structural lock, never plaintext.""" + out = dict(files) + sealed_physical = { + _physical(rel, e["codec"]): rel # type: ignore[arg-type] + for rel, e in files.items() + } + present_set = set(present) + for physical, rel in sealed_physical.items(): + if physical not in present_set and files[rel]["codec"] != "set": + del out[rel] + for rel in present: + if rel in sealed_physical: + continue + codec = _adoptable(root, "run", rel) + if codec is None: + raise SealedSetError( + f"{root} holds {rel} in plaintext, outside its sealed index" + ) + raw = (root / rel).read_bytes() + out[rel] = {"codec": codec, "sha256": _sha256(raw)} + for rel, entry in out.items(): + if entry["codec"] != "set": + physical = root / rel + if physical.is_file(): + # Owned and authenticated by its own store; track its bytes. + entry["sha256"] = _sha256(physical.read_bytes()) + return out + + # -- writing ----------------------------------------------------------------- @@ -685,19 +735,14 @@ def _adopt_external(self) -> None: for rel in _disk_files(self.root): if rel in sealed_physical: continue - raw = (self.root / rel).read_bytes() - entry = classify(self.kind, rel) - if entry is not None and entry.classification == "structural": - codec: Codec = "plain" - elif crypto.is_encrypted(raw): - codec = "external" - else: + codec = _adoptable(self.root, self.kind, rel) + if codec is None: raise SealedSetError( f"refusing to seal {self.root}: {rel} was written in " "plaintext outside the sealed writer" ) self._codecs[rel] = codec - self._hashes[rel] = _sha256(raw) + self._hashes[rel] = _sha256((self.root / rel).read_bytes()) def finalize(self, *, adopt_external: bool = False) -> str: """Check the directory holds exactly what was written, seal the index, diff --git a/packages/flow/tests/test_sealed_at_rest.py b/packages/flow/tests/test_sealed_at_rest.py index 647e09f5d..62bea89fe 100644 --- a/packages/flow/tests/test_sealed_at_rest.py +++ b/packages/flow/tests/test_sealed_at_rest.py @@ -124,10 +124,10 @@ def _files(root: Path) -> dict[str, bytes]: def _assert_ciphertext_only(root: Path) -> None: - """Each file is the header, a structural file, or a sealed container.""" + """Each file is a header, a structural file, or a sealed container.""" kind = sealed_set.header_kind(root) for rel, data in _files(root).items(): - if rel == sealed_set.HEADER: + if Path(rel).name == sealed_set.HEADER: continue entry = sealed_set.classify(kind, rel) if entry is not None and entry.classification == "structural": @@ -487,3 +487,229 @@ def test_encrypted_recording_and_bundle_refuse_without_the_key( monkeypatch.setenv(sealed_set.RECORDING_ENV_KEY, "wrong key") with pytest.raises(crypto.DecryptionError): sealed_set.SealedSet.open(rec_dir) + + +# -- the commands that read sealed input -------------------------------------- + + +def _sealed_bundle_of(workflow: Workflow, bundle: Path) -> Path: + with sealed_set.writing(bundle, "bundle", KEY): + workflow.save(bundle) + return bundle + + +def test_halted_sealed_run_is_undone_without_plaintext( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from openadapt_flow.after_stop import ( + approve_undo, + offer_undos, + read_undo_result, + run_undo, + ) + from openadapt_flow.mockmed.fault_server import serve + from openadapt_flow.runtime.effects import RestRecordVerifier + from openadapt_flow.runtime.replayer import Replayer + from tests.test_after_stop_mockmed import ( + MockMedBackend, + _undo_replayer, + _vision, + _workflow, + ) + + monkeypatch.setenv(crypto.ENV_KEY, KEY) + url, _db, stop = serve() + url = url.rstrip("/") + try: + bundle = _sealed_bundle_of(_workflow(), tmp_path / "bundle") + workflow = Workflow.load(bundle) + run_dir = tmp_path / "run" + with watching_disk() as watch: + report = Replayer( + MockMedBackend(url), + vision=_vision(), + effect_verifier=RestRecordVerifier(url), + poll_interval_s=0.01, + ).run(workflow, bundle_dir=bundle, run_dir=run_dir) + assert report.execution_outcome == "HALTED" + assert [o.undo_id for o in offer_undos(run_dir, workflow)] == [ + "undo-triage" + ] + approve_undo(run_dir, workflow, "undo-triage", approver="nurse-a") + replayer, backend = _undo_replayer(url) + result = run_undo( + run_dir, workflow, "undo-triage", replayer=replayer, bundle_dir=bundle + ) + assert result.status == "verified", result.reason + assert backend.deletes == 1 + assert read_undo_result(run_dir, "undo-triage") == result + finally: + stop() + assert watch.leaks([b"nurse-a", b"Triage", b'"results"', PNG_SIGNATURE]) == [] + _assert_ciphertext_only(run_dir) + undo_run = run_dir / "undo" / "undo-triage" / "run" + assert sealed_set.is_sealed(undo_run) + _assert_ciphertext_only(undo_run) + sealed_set.SealedSet.open(run_dir, KEY).verify_all() + + +def _recording(rec_dir: Path, *, key: str | None) -> Path: + rec = Recorder(_FakeBackend(), rec_dir, app_url="http://app.test/", encryption_key=key) + rec.click(40, 40) + rec.type_text("Testpatient Zed", param="patient") + rec.click(80, 80) + return rec.finish() + + +def test_compile_and_induce_from_encrypted_recordings_stay_sealed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv(crypto.ENV_KEY, KEY) + first = _recording(tmp_path / "rec1", key=KEY) + second = _recording(tmp_path / "rec2", key=KEY) + with watching_disk() as watch: + assert ( + main(["compile", str(first), "--out", str(tmp_path / "b"), "--name", "x"]) + == 0 + ) + induced = main( + ["induce", str(first), str(second), "--out", str(tmp_path / "induced")] + ) + assert watch.leaks([b"Testpatient", PNG_SIGNATURE, b'"anchor"']) == [] + _assert_ciphertext_only(tmp_path / "b") + assert Workflow.load(tmp_path / "b").encrypted + assert induced == 0 + _assert_ciphertext_only(tmp_path / "induced") + assert Workflow.load(tmp_path / "induced").encrypted + + +def test_scaffold_verifier_keeps_its_draft_inside_the_sealed_recording( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.setenv(crypto.ENV_KEY, KEY) + rec = tmp_path / "rec" + with sealed_set.writing(rec, "recording", KEY) as writer: + writer.write("meta.json", json.dumps({"params": {"p": "Testpatient Zed"}}).encode()) + writer.write( + "events.jsonl", + json.dumps( + {"i": 0, "kind": "click", "x": 5, "y": 5, "field_label": "Save encounter"} + ).encode() + + b"\n", + ) + with watching_disk() as watch: + assert main(["scaffold-verifier", str(rec)]) == 0 + with pytest.raises(SystemExit, match="Drop --out"): + main(["scaffold-verifier", str(rec), "--out", str(tmp_path / "o")]) + assert watch.leaks([b"Testpatient", PNG_SIGNATURE]) == [] + assert not (tmp_path / "o").exists() + _assert_ciphertext_only(rec) + opened = sealed_set.SealedSet.open(rec, KEY) + assert "effect_contract.yaml" in opened.files() + opened.verify_all() + + +def test_qualify_and_admit_keep_a_sealed_bundle_sealed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv(crypto.ENV_KEY, KEY) + monkeypatch.setenv("HOME", str(tmp_path / "home")) + rec = _recording(tmp_path / "rec", key=KEY) + target = tmp_path / "admission" + with watching_disk() as watch: + code = main( + [ + "admit", + "run", + str(rec), + "--out", + str(target), + "--name", + "sealed-admission", + ] + ) + assert watch.leaks([b"Testpatient", PNG_SIGNATURE, b'"anchor"']) == [], code + # The synthetic demo has no identity or effect evidence, so the admission + # stops for a person; what matters here is that it stays sealed. + bundle = target / "bundle" + assert sealed_set.is_sealed(bundle) + _assert_ciphertext_only(bundle) + assert "qualification-proposal.draft.json" in sealed_set.open_set(bundle).files() + assert not (target / "qualification-proposal.draft.json").exists() + for compiled in (target / "compiled").iterdir(): + _assert_ciphertext_only(compiled) + with watching_disk() as watch: + main(["qualify", "propose", str(bundle), "--recording", str(rec)]) + assert watch.leaks([b"Testpatient", PNG_SIGNATURE]) == [] + _assert_ciphertext_only(bundle) + + +def test_sealed_run_pauses_and_resumes_without_plaintext( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from openadapt_flow.runtime.durable import ( + CheckpointStore, + bundle_version, + issue_resume_approval, + resume, + ) + from openadapt_flow.runtime.replayer import Replayer + from tests.test_durable_runtime import ( + FakeSoRVerifier, + _three_step_workflow, + _vision_ok, + ) + from tests.test_replayer import FakeBackend + + monkeypatch.setenv(crypto.ENV_KEY, KEY) + bundle = _sealed_bundle_of( + _three_step_workflow(with_effects=True), tmp_path / "bundle" + ) + run_dir = tmp_path / "run" + verifier = FakeSoRVerifier() + verifier.refute.add((("step", "s2"),)) + with watching_disk() as watch: + report = Replayer( + FakeBackend(), + vision=_vision_ok(), + effect_verifier=verifier, + durable=True, + poll_interval_s=0.01, + ).run( + Workflow.load(bundle), + params={"who": "Testpatient Zed"}, + bundle_dir=bundle, + run_dir=run_dir, + ) + assert report.success is False + store = CheckpointStore(run_dir, key=KEY) + manifest, pending = store.read_manifest(), store.read_pending() + assert manifest is not None and pending is not None + approval = issue_resume_approval( + pending, + approver="operator@example.com", + resolution="verified the system of record; approve resume", + bundle_version=bundle_version(bundle), + run_id=manifest.run_id, + workflow_name=manifest.workflow_name, + run_dir=run_dir, + ) + verifier.refute.clear() + resumed_backend = FakeBackend() + resumed = resume( + run_dir, + Replayer( + resumed_backend, + vision=_vision_ok(), + effect_verifier=verifier, + poll_interval_s=0.01, + ), + approval=approval, + key=KEY, + ) + assert resumed.success is True + assert ("press", "A") not in resumed_backend.actions + assert ("press", "C") in resumed_backend.actions + assert watch.leaks([b"Testpatient", b"operator@example.com", b'"results"']) == [] + _assert_ciphertext_only(run_dir) + sealed_set.SealedSet.open(run_dir, KEY).verify_all() From 06c5af15ee13618f152cb15591a32873d39cf817 Mon Sep 17 00:00:00 2001 From: abrichr Date: Mon, 19 Oct 2026 11:34:42 -0400 Subject: [PATCH 5/9] WIP: docs, measurements, record --no-encrypt Co-Authored-By: Claude Opus 5.5 --- CAPABILITIES.md | 1 + llms-full.txt | 77 +++- packages/flow/README.md | 10 +- packages/flow/benchmark/at_rest/RESULTS.md | 36 ++ packages/flow/benchmark/at_rest/results.json | 82 ++++ packages/flow/benchmark/at_rest/run.py | 232 +++++++++++ packages/flow/docs/LIMITS.md | 9 +- packages/flow/docs/PRIVACY.md | 14 +- packages/flow/docs/phi_at_rest.md | 384 +++++++++++------- packages/flow/openadapt_flow/__main__.py | 16 +- .../flow/openadapt_flow/admit_pipeline.py | 3 +- .../flow/openadapt_flow/bundle_sealing.py | 8 +- .../openadapt_flow/compiler/api_proposal.py | 2 +- .../flow/openadapt_flow/compiler/compile.py | 38 +- .../openadapt_flow/compiler/fixup_passes.py | 2 +- packages/flow/openadapt_flow/crypto.py | 7 +- .../openadapt_flow/interactive_recorder.py | 2 +- packages/flow/openadapt_flow/ir.py | 5 +- .../runtime/durable/attended.py | 16 +- .../flow/openadapt_flow/runtime/replayer.py | 15 +- packages/flow/openadapt_flow/sealed_set.py | 20 +- packages/flow/tests/test_sealed_at_rest.py | 42 +- 22 files changed, 785 insertions(+), 236 deletions(-) create mode 100644 packages/flow/benchmark/at_rest/RESULTS.md create mode 100644 packages/flow/benchmark/at_rest/results.json create mode 100644 packages/flow/benchmark/at_rest/run.py diff --git a/CAPABILITIES.md b/CAPABILITIES.md index c9125ded3..b928bb477 100644 --- a/CAPABILITIES.md +++ b/CAPABILITIES.md @@ -219,6 +219,7 @@ improvising or retrying when something is off. | Find and redact personal and health data in text, screenshots and structure | Released (privacy 1.1.3) | Unit tests | [packages/privacy](packages/privacy/README.md), [PRIVACY.md](packages/flow/docs/PRIVACY.md) | | Scrub by default before anything leaves the machine | On main ([#1262](https://github.com/OpenAdaptAI/OpenAdapt/pull/1262)) | Unit tests | `openadapt-privacy` | | Each package installs on its own within a size budget, checked in CI (privacy without desktop, capture without plotting) | On main ([#1270](https://github.com/OpenAdaptAI/OpenAdapt/pull/1270), [#1271](https://github.com/OpenAdaptAI/OpenAdapt/pull/1271), [#1279](https://github.com/OpenAdaptAI/OpenAdapt/pull/1279)) | CI | `.github/workflows/packages.yml` | +| Encrypt PHI at rest: every bundle file that can hold patient data under one sealed index (unknown files refused), web recordings from the first frame, and the run directories of sealed bundles; compile, induce, scaffold-verifier, qualify, admit, run, resume and undo read them in memory and refuse without the key | In review | Synthetic (no-plaintext audit-hook test over record, compile and a VERIFIED run; per-frame cost measured) | `openadapt-flow seal`, `record`; [phi_at_rest.md](packages/flow/docs/phi_at_rest.md) | | Signed receipts (Seals), countersigned and timestamped, offline by default | Released | Unit tests, real TSA tokens | `openadapt-flow seal`, `countersign`, `local-seal` | | Strict no-outbound mode and an unattended on-prem service | Released | Synthetic | `openadapt-flow no-outbound`, `service`; [ON_PREM.md](packages/flow/docs/ON_PREM.md) | diff --git a/llms-full.txt b/llms-full.txt index 623dfd5b6..364c12e80 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -375,7 +375,8 @@ shipping in the next release; a pull request number means it is being merged. 1. **Learns from any successful run, a person's or an agent's.** A person records a task, or a computer-use agent does it once and OpenAdapt compiles the verified run into a program (`openadapt_flow.jit`, released; - prompt-to-program `compile-agent`, landing). Agents can drive the recording + prompt-to-program `compile-agent`, landing; the agent can act through code + instead of screenshots, [#1305](https://github.com/OpenAdaptAI/OpenAdapt/pull/1305)). Agents can drive the recording on web, native Windows, RDP and Citrix. 2. **Compiles to the fastest layer the app allows.** Page structure first (DOM, Windows UI Automation, macOS Accessibility), direct API calls where the app's @@ -436,9 +437,10 @@ shipping in the next release; a pull request number means it is being merged. | Runs steps as direct API calls; compiling them from captured network traffic is being merged. | `runtime/actuators/api.py`, [#1269](https://github.com/OpenAdaptAI/OpenAdapt/pull/1269) | | Repairs itself when the app changes: governed repair, agent-demonstrated fixes, fleet patches, and self-repair probed up to the save. | [REPAIR_LIFECYCLE.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/REPAIR_LIFECYCLE.md) | | Compiles a computer-use agent's verified run, with no human demonstration needed. | [JIT.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/JIT.md) | +| The agent that does a task once can act through code, not a screenshot-and-click loop: it reads the page or accessibility tree, acts by role and name, keeps notes with a sandboxed read-only shell, and asks for a screenshot only when it needs one. It is arm 7 of the scoreboard; its figures are quoted from there once its rows are in. | `CODE_AS_ACTION.md` in [#1305](https://github.com/OpenAdaptAI/OpenAdapt/pull/1305) | | Binds each approval to the exact screenshot and form state, and re-reads the form before saving. | [APPROVAL_MODES.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/APPROVAL_MODES.md) | | Fills inputs from a referral PDF, fax image or email, and holds back anything unclear for a person. | `intake`, [INTAKE.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/INTAKE.md) | -| Ships as one monorepo with separately installable packages (flow, agent, types, capture, and privacy and desktop moving in). | [README](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/README.md) | +| Ships as one monorepo with separately installable packages (flow, agent, types, capture, privacy and desktop), each released from the monorepo. | [README](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/README.md) | | Makes no model calls on healthy runs. Model training (openadapt-ml) is separate research. | [README](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/README.md) | ## The full map @@ -474,6 +476,7 @@ improvising or retrying when something is off. | Compile one or more demonstrations into a deterministic program | Released | Real app (OpenEMR referral, 62 timed runs) | `openadapt-flow record`, `compile`; [packages/flow](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/README.md) | | Right-record check before any write (structure, then OCR, then pixels) | Released | Real app and synthetic | [RESOLUTION_LADDER.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/RESOLUTION_LADDER.md), `runtime/identity.py` | | Read the saved record back from the system of record (API, database, FHIR, separate session) | Released | Real app (OpenEMR 12 of 12 fields) | [VERIFICATION.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/VERIFICATION.md), `runtime/effects/` | +| Read values out of an app as a workflow step (READ), with a records format, a signed review queue and export to CSV or a database | On main ([#1229](https://github.com/OpenAdaptAI/OpenAdapt/pull/1229), [#1215](https://github.com/OpenAdaptAI/OpenAdapt/pull/1215), [#1218](https://github.com/OpenAdaptAI/OpenAdapt/pull/1218)–[#1224](https://github.com/OpenAdaptAI/OpenAdapt/pull/1224)) | Synthetic (mock desktop apps) | `openadapt_flow/interchange/` | | Stop before the effect instead of improvising (`HALTED_BEFORE_EFFECT`) | Released | Real app and synthetic | `runtime/replayer.py` | | Never retry an uncertain save (`RECONCILIATION_REQUIRED`) | Released | Synthetic (fault server) | [VERIFICATION.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/VERIFICATION.md) | | One check-level scale, 0 to 5, from "unchecked" to "counterparty confirmed" | Released | Unit tests | `openadapt_flow/check_level.py` | @@ -508,7 +511,7 @@ improvising or retrying when something is off. | Watch many real runs in the background and mine candidate branches (shadow mode) | On main ([#1212](https://github.com/OpenAdaptAI/OpenAdapt/pull/1212)) | Synthetic | [SHADOW_MINING.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/capture/docs/SHADOW_MINING.md), [LEARN_FROM_MANY_RUNS.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/LEARN_FROM_MANY_RUNS.md) | | Fill a workflow's inputs from a referral PDF, fax image or email | Released | Synthetic | `openadapt-flow intake`, [INTAKE.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/INTAKE.md) | | Use spoken narration while recording to propose the rule behind a branch | In review ([#1291](https://github.com/OpenAdaptAI/OpenAdapt/pull/1291)) | Synthetic (generated audio) | `openadapt-capture[audio,privacy]` | -| Settle a disagreement between recordings by trying both paths on a test copy of the app and reading the record back | In review ([#1276](https://github.com/OpenAdaptAI/OpenAdapt/pull/1276)) | Synthetic (MockMed) | `induce --probe-on` | +| Settle a disagreement between recordings by trying both paths on a test copy of the app and reading the record back | On main ([#1276](https://github.com/OpenAdaptAI/OpenAdapt/pull/1276), [#1289](https://github.com/OpenAdaptAI/OpenAdapt/pull/1289)) | Synthetic (MockMed; desktop and remote test copies) | `induce --probe-on` | | Route each item to the right verified variant of a program | On main ([#1264](https://github.com/OpenAdaptAI/OpenAdapt/pull/1264)) | Synthetic | `variants`, `route` | ## Surfaces @@ -519,7 +522,8 @@ improvising or retrying when something is off. | Native Windows (UI Automation) | Released, early | Synthetic (WinForms, 3 of 3 trials) | [SURFACES.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/SURFACES.md) | | Native macOS (Accessibility) | Released, early | Synthetic (TextEdit, 3 of 3 trials) | [SURFACES.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/SURFACES.md) | | RDP (pixels; Aardwolf or headless FreeRDP with a session pool) | Released, early | Real Windows 11 host, 3 of 3, plus a FreeRDP batch | [SURFACES.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/SURFACES.md), `backends/xfreerdp.py` | -| Citrix | Exploratory | A no-DOM stand-in only; no real ICA/HDX session yet | [LIMITS.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/LIMITS.md) | +| Citrix and other virtual desktops, with OpenAdapt installed inside the session (`in_session`): the same structure-first path as a local desktop | Released, early | Same evidence as the Windows path; no live Citrix session yet | [SURFACES.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/SURFACES.md) | +| Citrix from outside the session, pixels only (`remote_display`), the fallback when nothing can be installed inside | Exploratory | A no-DOM stand-in only; no real ICA/HDX session yet | [LIMITS.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/LIMITS.md) | | Chain programs across surfaces, passing confirmed values | Released | Synthetic | `openadapt-flow compose`, `process`; [PROCESS_CONTRACT.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/PROCESS_CONTRACT.md) | | One program that switches surfaces, tabs and popups mid-run | On main ([#1197](https://github.com/OpenAdaptAI/OpenAdapt/pull/1197)) | Real Chromium; Windows switch on a mock | [#1197](https://github.com/OpenAdaptAI/OpenAdapt/pull/1197) | | Halt when a workflow qualified with page structure runs on a pixels-only surface | Released | Synthetic | `openadapt-flow qualify` | @@ -533,7 +537,10 @@ improvising or retrying when something is off. | Run a step as a direct API call (`ApiActuator`, `ApiBinding`) | Released, hand-authored bindings | Synthetic benchmarks | `runtime/actuators/api.py` | | Capture a recording's network traffic and compile a clean save into an API step, with the same record check | In review ([#1269](https://github.com/OpenAdaptAI/OpenAdapt/pull/1269)) | Synthetic patients on a local OpenEMR test copy | network capture, `ApiBinding` | | Fall back from the API step to page structure when the API is unavailable, before anything is sent | In review ([#1269](https://github.com/OpenAdaptAI/OpenAdapt/pull/1269)) | Synthetic | [#1269](https://github.com/OpenAdaptAI/OpenAdapt/pull/1269) | -| Run a worklist on several workers at once, one signed-in session each, with a stop on any worker stopping all | In review ([#1296](https://github.com/OpenAdaptAI/OpenAdapt/pull/1296)) | Synthetic | `--workers` | +| Run a worklist on several workers at once, one signed-in session each, each item claimed once, with a stop on any worker stopping all | In review ([#1296](https://github.com/OpenAdaptAI/OpenAdapt/pull/1296)) | Synthetic patients on a local OpenEMR test copy | `--workers`, [THROUGHPUT.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/THROUGHPUT.md) | +| Read a save back while the next item runs (pipelined read-back, REST, FHIR, GraphQL or SQL record checks) | In review ([#1296](https://github.com/OpenAdaptAI/OpenAdapt/pull/1296)) | Synthetic | `--readback-window`, [THROUGHPUT.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/THROUGHPUT.md) | +| A faster speed profile that keeps every check (one insert per plain text field, shorter page-quiet wait) | In review ([#1296](https://github.com/OpenAdaptAI/OpenAdapt/pull/1296)) | Synthetic | `runtime.speed: fast`, [THROUGHPUT.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/THROUGHPUT.md) | +| Record where each step's time goes in every run report | In review ([#1296](https://github.com/OpenAdaptAI/OpenAdapt/pull/1296)) | Real app and synthetic | `timings` in `report.json` | ## When the app changes @@ -544,7 +551,7 @@ improvising or retrying when something is off. | Font and layout drift halts with a named reason | Released | Synthetic | `runtime/layout_drift.py` | | Self-repair: an agent proposes a fix for one halted click, Flow probes it up to the save without saving, a person approves | In review ([#1265](https://github.com/OpenAdaptAI/OpenAdapt/pull/1265)) | Synthetic (MockMed redesign) | `run --self-repair`, [#1265](https://github.com/OpenAdaptAI/OpenAdapt/pull/1265) | | Self-repair across several new steps (a new dialog, a control moved into a menu) | In progress | Synthetic (MockMed drift modes) | bridge branch, stacked on #1265 | -| Keep pixel-path runs working across display scaling and font smoothing changes | In review ([#1286](https://github.com/OpenAdaptAI/OpenAdapt/pull/1286)) | Synthetic (RDP and Citrix-style renderings) | `vision/match.py` | +| Keep pixel-path runs working across display scaling and font smoothing changes | On main ([#1286](https://github.com/OpenAdaptAI/OpenAdapt/pull/1286)) | Synthetic (in-process RDP and canvas renderings: 17 of 24 healthy runs across 100–175%, up from 3; 0 wrong saves) | [benchmark/pixel_scale](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/benchmark/pixel_scale/README.md) | | Pre-approved repairs: a signed policy lists low-risk changes that apply without a person, a test-record run before each queue, and one approval for every item halted the same way | In review ([#1294](https://github.com/OpenAdaptAI/OpenAdapt/pull/1294)) | Synthetic | standard changes | | Fleet drift patches: a verified fix with no patient data, shared across sites under policy | Released, local reference hub | Simulator | `openadapt-flow fleet`, [FLEET_DRIFT_PATCHES.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/FLEET_DRIFT_PATCHES.md) | @@ -553,9 +560,11 @@ improvising or retrying when something is off. | Capability | Status | Evidence | Where | | --- | --- | --- | --- | | Compile a computer-use agent's successful run into a program (JIT); later runs make no model calls | Released | Synthetic (a recorded Claude run on MockMed) | `openadapt_flow.jit`, [JIT.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/JIT.md) | +| Code as action: the run-1 agent acts through one terminal tool (structure first, actions by role and name, a sandboxed read-only shell, screenshots on request); every action is a structured step checked against the compiled program | In review ([#1305](https://github.com/OpenAdaptAI/OpenAdapt/pull/1305)) | Real app (local OpenEMR) and synthetic | `ClaudeAgent(vision="code")`, `CODE_AS_ACTION.md` ([#1305](https://github.com/OpenAdaptAI/OpenAdapt/pull/1305)) | | Admit a draft by a person, an admitter key, or an agent under a person-signed policy, including self-admission when the policy allows it | Released | Unit tests | [JIT_ADMISSION.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/JIT_ADMISSION.md) | | MCP server: your agent decides what to enter, OpenAdapt makes the entry and checks it | Released | Synthetic sandbox | `openadapt-agent`, [packages/agent](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/agent/README.md) | -| Compile an agent's first verified run from a prompt (`compile-agent`), and a code-as-action authoring agent | In review ([#1277](https://github.com/OpenAdaptAI/OpenAdapt/pull/1277), [#1305](https://github.com/OpenAdaptAI/OpenAdapt/pull/1305)) | Synthetic | `compile-agent` | +| Compile an agent's first verified run from a prompt (`compile-agent`), and a code-as-action authoring agent | In review ([#1277](https://github.com/OpenAdaptAI/OpenAdapt/pull/1277), [#1305](https://github.com/OpenAdaptAI/OpenAdapt/pull/1305)); the agent harness behind it runs on every backend, on main ([#1283](https://github.com/OpenAdaptAI/OpenAdapt/pull/1283)) | Synthetic | `compile-agent` | +| Score harness optimizers (Harness², Meta-Harness) on GUI tasks with the record read-back as the reward, with full trace export | On main ([#1299](https://github.com/OpenAdaptAI/OpenAdapt/pull/1299), [#1300](https://github.com/OpenAdaptAI/OpenAdapt/pull/1300), [#1301](https://github.com/OpenAdaptAI/OpenAdapt/pull/1301)) | Synthetic (scripted proposer) | `integrations/harness2/` | | One scoreboard comparing the agent alone with OpenAdapt across tasks (correctness by read-back, cost, time, items per hour) | In progress ([#1303](https://github.com/OpenAdaptAI/OpenAdapt/pull/1303)) | Synthetic and local OpenEMR and openIMIS test copies | `benchmark/scoreboard/` | ## Recording, privacy and proof @@ -565,6 +574,8 @@ improvising or retrying when something is off. | Record mouse, keyboard, screen video and accessibility trees on one clock (Windows, macOS, Linux X11) | Released (capture 1.3.0) | Real OS | [packages/capture](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/capture/README.md); Wayland is not supported | | Find and redact personal and health data in text, screenshots and structure | Released (privacy 1.1.3) | Unit tests | [packages/privacy](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/privacy/README.md), [PRIVACY.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/PRIVACY.md) | | Scrub by default before anything leaves the machine | On main ([#1262](https://github.com/OpenAdaptAI/OpenAdapt/pull/1262)) | Unit tests | `openadapt-privacy` | +| Each package installs on its own within a size budget, checked in CI (privacy without desktop, capture without plotting) | On main ([#1270](https://github.com/OpenAdaptAI/OpenAdapt/pull/1270), [#1271](https://github.com/OpenAdaptAI/OpenAdapt/pull/1271), [#1279](https://github.com/OpenAdaptAI/OpenAdapt/pull/1279)) | CI | `.github/workflows/packages.yml` | +| Encrypt PHI at rest: every bundle file that can hold patient data under one sealed index (unknown files refused), web recordings from the first frame, and the run directories of sealed bundles; compile, induce, scaffold-verifier, qualify, admit, run, resume and undo read them in memory and refuse without the key | In review | Synthetic (no-plaintext audit-hook test over record, compile and a VERIFIED run; per-frame cost measured) | `openadapt-flow seal`, `record`; [phi_at_rest.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/phi_at_rest.md) | | Signed receipts (Seals), countersigned and timestamped, offline by default | Released | Unit tests, real TSA tokens | `openadapt-flow seal`, `countersign`, `local-seal` | | Strict no-outbound mode and an unattended on-prem service | Released | Synthetic | `openadapt-flow no-outbound`, `service`; [ON_PREM.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/ON_PREM.md) | @@ -574,7 +585,7 @@ Each `openadapt-flow` command, grouped by the capability it serves. A test fails when a command is added without being listed here. - **Record and build a program:** `record`, `demo-record`, `compile`, `induce`, `disambiguate`, `for-each`, `compose`, `process`, `intake`, `visualize`, `pull`, `variants`, `route`. -- **Check and admit:** `lint`, `scaffold-verifier`, `check`, `bind-record-checks`, `certify`, `qualify`, `propose-qualification`, `known-ids`, `jit`, `admit`, `approver-grant`. +- **Check and admit:** `lint`, `scaffold-verifier`, `check`, `api`, `bind-record-checks`, `certify`, `qualify`, `propose-qualification`, `known-ids`, `jit`, `admit`, `approver-grant`. - **Run and answer:** `run`, `replay`, `resume`, `approve`, `item-approval`, `triage`, `business-decisions`, `explain`, `console`. - **Judge any executor's write:** `verify-run`, `serve-reward` (scores agent training episodes by reading the system of record). - **Repair and learn from halts:** `teach`, `teach-branch`, `repair`, `fleet`, `undo` (the undo steps a person approves after a stop). @@ -962,6 +973,31 @@ class named with `--allow-halt`. A handoff copies an effect-bound parameter from a confirmed receipt. Missing evidence HALTs. Subflows and worklists stay inside each child, on that child's surface. +## Parallel worklists + +`--workers N` runs a worklist's items on N signed-in sessions at once +([`THROUGHPUT.md`](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/THROUGHPUT.md)). Every item keeps every check. The run +refuses N above the deployment's `worklist.max_workers`, fewer accounts than +workers, two accounts for the same user, two workers sharing one session, a +program with steps outside its loop, and rows not declared +`items_independent`. It can't tell on its own whether two rows touch the same +record; that declaration is yours. A halt on any worker stops new items on +all of them, and an item claimed before a crash is never run again +automatically: it is marked `uncertain` for a person to check. The pipelined +read-back window applies only to an item's last save and only to read-backs +that don't use the screen; durable runs, which include the Standard and +Regulated profiles, always read back inline. Parallel RDP runs use the +FreeRDP session pool and have unit tests only. Throughput figures come from +one machine and a local OpenEMR test copy, not from a production EMR. + +## Short parameter values + +The right-record check binds a typed value into its expected text only when +the value has at least 4 characters. An item whose bound value is shorter, +such as the first name "Bo", halts at the identity check instead of saving. +This is deliberate: a short value matches too much unrelated text to identify +a record. + ## Save triage `openadapt-flow triage` orders verified saves for review under @@ -1296,12 +1332,16 @@ grade is `screen_readback`, never `independent_readback`. **Without a read that is independent of the acting session, the run cannot be `VERIFIED`.** That read is either a system-of-record read (REST, FHIR, SQL, -file; tier 1) or the application's record page read in a separate signed-in -session (`ui_readback` for web, `fresh_session` for desktop and RDP, -tested on mocks only; tier 2). The second goes through the same -application, so it is not a system-of-record read and has its own limits -([`EFFECT_KIT.md`](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/EFFECT_KIT.md#separate-session-ui-read-back-ui_readback)). -A pixel-only Citrix or RDP session with neither is that case. Reopening the +file; tier 1) or the application's own screens read in a separate signed-in +session (tier 2): a record page in a separate browser (`ui_readback`), a +relaunched desktop app or a second RDP session (`fresh_session`, tested on +mocks only), or a look-up in a separate desktop, RDP or Citrix session +(`desktop_readback`, tier 2 with its own RDP logon). None of them goes around +the application, so none is a system-of-record read, and each has its own +limits ([`EFFECT_KIT.md`](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/EFFECT_KIT.md#separate-session-ui-read-back-ui_readback), +[`desktop_readback`](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/EFFECT_KIT.md#separate-session-desktop-read-back-desktop_readback)). +If a person can look the record up, the check can too. A pixel-only Citrix or +RDP deployment that declares no such check is the remaining case. Reopening the record in the acting session (tier 3) is reported as its own check level, `reopened` ("Reopened in the same session"), and is not `VERIFIED`: a confirmed reopen ends `COMPLETED_UNVERIFIED`, and the agent result says @@ -1513,8 +1553,13 @@ host hardening, storage encryption, backups, retention, access control, monitoring, effect-verifier integration, update validation, and disaster recovery. -The repository supplies integrity manifests, optional bundle encryption, -destination policy, local egress checks, and audit primitives. A content digest +The repository supplies integrity manifests, optional encryption at rest for +bundles, web recordings and the run directories of sealed bundles, destination +policy, local egress checks, and audit primitives. Encryption at rest does not +cover data in process memory, the target application's own storage, file names +and sizes, desktop recordings, or commands outside the list in +[`phi_at_rest.md`](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/phi_at_rest.md), which refuse sealed input; the key is a +passphrase in the environment, not a KMS. A content digest proves byte identity, not publisher identity. A local hash chain is tamper evident against ordinary changes, not tamper proof against an administrator who can rewrite it. Local approvals record provenance but are not enterprise-IdP diff --git a/packages/flow/README.md b/packages/flow/README.md index 24d148f7d..3f0eef5d9 100644 --- a/packages/flow/README.md +++ b/packages/flow/README.md @@ -380,9 +380,13 @@ export OPENADAPT_FLOW_SCRUB=on The bundle and `report.json` keep literal identifiers, because the identity check and the audit trail need them ([docs/PRIVACY.md](docs/PRIVACY.md)). With `OPENADAPT_BUNDLE_KEY` set, `openadapt-flow seal ./bundle --out ./bundle-sealed` -encrypts `workflow.json` and the image crops with AES-256-GCM. Other bundle -files aren't encrypted, so treat every source bundle as PHI -([docs/phi_at_rest.md](docs/phi_at_rest.md)). +encrypts every bundle file that can hold patient data with AES-256-GCM, binds +the whole file set to one sealed index, and refuses a file it doesn't know. +Runs of a sealed bundle write an encrypted run directory, and web recordings +are encrypted from the first frame (`OPENADAPT_RECORDING_KEY`, or the bundle +key). Data in memory, the app's own storage, file names and sizes, and desktop +recordings are not encrypted; the source bundle you sealed is still plaintext, +so treat it as PHI ([docs/phi_at_rest.md](docs/phi_at_rest.md)). OpenAdapt Cloud at `app.openadapt.ai` is an optional hosted control plane for browser workflows. Desktop and Citrix runs are self-hosted diff --git a/packages/flow/benchmark/at_rest/RESULTS.md b/packages/flow/benchmark/at_rest/RESULTS.md new file mode 100644 index 000000000..d30b6c23b --- /dev/null +++ b/packages/flow/benchmark/at_rest/RESULTS.md @@ -0,0 +1,36 @@ +# Cost of encrypting a recording + +`run.py` records the same synthetic 150-action session twice through the real +`Recorder`, once in plaintext and once with an encryption key, and times the +parts that change. Frames are synthetic 1280x800 application screens with +table text (82-90 KB PNGs). The settle wait is disabled so the numbers show +the write path, not the screen. + +Measured 2026-10-19 on an Apple-silicon Mac (12 cores, macOS 15.7, Python +3.12), with a 1-minute load average of 11 from other work on the machine. +Raw numbers: [`results.json`](results.json). + +| | Plaintext | Encrypted | Difference | +| --- | --- | --- | --- | +| Persist one frame (median) | 0.12 ms | 0.25 ms | +0.14 ms | +| Persist one frame (p95) | 0.51 ms | 0.55 ms | +0.05 ms | +| Record one action: 2 frames, 1 event (median) | 5.2 ms | 5.8 ms | +0.6 ms | +| Same, last 15 actions of 150 (median) | 5.1 ms | 6.1 ms | +0.9 ms | +| Key derivation, once per recording (scrypt) | none | 183 ms | +183 ms | +| Open and verify the finished recording | n/a | 221 ms | | +| Disk, 303 files (25.98 MB of content) | | +62.6 KB | +0.24% | + +The disk overhead is 44 bytes per file (format marker, nonce, GCM tag) plus +the sealed index in the header (49 KB here: one SHA-256 per file). The cost +per action grows slowly with the recording's length because the event log is +resealed whole on each append; at 150 actions it adds under 1 ms. + +In a real recording the screenshot and the settle wait take tens to hundreds +of milliseconds per action, so encryption adds about 1% or less to recording +time. + +Reproduce: + +```bash +python benchmark/at_rest/run.py --actions 150 --out benchmark/at_rest/results.json +``` diff --git a/packages/flow/benchmark/at_rest/results.json b/packages/flow/benchmark/at_rest/results.json new file mode 100644 index 000000000..78b722bac --- /dev/null +++ b/packages/flow/benchmark/at_rest/results.json @@ -0,0 +1,82 @@ +{ + "schema": "openadapt.at-rest-benchmark/v1", + "measured_at": "2026-10-19T11:29:36-0400", + "host": { + "platform": "macOS-15.7.3-arm64-arm-64bit", + "python": "3.12.7", + "cpu_count": 12, + "load_average_1m": 11.08 + }, + "frame_png_kb": { + "median": 86.7, + "min": 82.3, + "max": 89.5 + }, + "actions": 150, + "key_derivation_scrypt": { + "median_ms": 183.076, + "p95_ms": 185.445, + "mean_ms": 184.129, + "n": 5 + }, + "persist_one_frame": { + "plaintext": { + "median_ms": 0.118, + "p95_ms": 0.505, + "mean_ms": 0.212, + "n": 200 + }, + "sealed": { + "median_ms": 0.254, + "p95_ms": 0.551, + "mean_ms": 0.309, + "n": 200 + } + }, + "record_one_action": { + "plaintext": { + "median_ms": 5.241, + "p95_ms": 5.957, + "mean_ms": 5.88, + "n": 150 + }, + "sealed": { + "median_ms": 5.815, + "p95_ms": 6.356, + "mean_ms": 5.825, + "n": 150 + }, + "note": "two frames plus one event line per action; settle wait disabled" + }, + "record_one_action_last_10pct": { + "plaintext": { + "median_ms": 5.13, + "p95_ms": 5.486, + "mean_ms": 5.194, + "n": 15 + }, + "sealed": { + "median_ms": 6.059, + "p95_ms": 6.407, + "mean_ms": 6.059, + "n": 15 + }, + "note": "the sealed event log is resealed whole on each append" + }, + "open_and_verify_sealed_recording_ms": 221.4, + "disk": { + "plaintext_recording": { + "files": 302, + "bytes": 25978012 + }, + "sealed_recording": { + "files": 303, + "bytes": 26040619 + }, + "sealed_plaintext_content_bytes": 25978024, + "overhead_bytes": 62595, + "overhead_percent": 0.241, + "header_bytes": 49307, + "per_file_overhead_bytes": 44 + } +} diff --git a/packages/flow/benchmark/at_rest/run.py b/packages/flow/benchmark/at_rest/run.py new file mode 100644 index 000000000..25bc1b349 --- /dev/null +++ b/packages/flow/benchmark/at_rest/run.py @@ -0,0 +1,232 @@ +"""Measure what encrypting a recording costs per frame and on disk. + +Records the same synthetic session twice through the real +:class:`openadapt_flow.recorder.Recorder`, once in plaintext and once with an +encryption key (``docs/phi_at_rest.md``), and reports: + +- the one-time key derivation (scrypt) when an encrypted recording starts; +- the time to persist one frame, plaintext ``write_bytes`` against the sealed + writer (AES-256-GCM plus an atomic rename); +- the time per recorded action end to end (two frames plus one event line); +- the time to append one event line late in a long recording (the sealed log + is resealed whole on each append); +- bytes on disk for both recordings. + +Frames are synthetic 1280x800 screens with table text, so the PNGs compress +like real application screens rather than like a flat colour. Everything is +synthetic; nothing is read from a real application. + + python benchmark/at_rest/run.py --actions 150 --out benchmark/at_rest/results.json +""" + +from __future__ import annotations + +import argparse +import io +import json +import os +import platform +import shutil +import statistics +import sys +import tempfile +import time +from pathlib import Path +from typing import Any + +from PIL import Image, ImageDraw + +from openadapt_flow import crypto, sealed_set +from openadapt_flow.recorder import Recorder + +KEY = "benchmark-only-synthetic-key" + + +def synthetic_screen(index: int, size: tuple[int, int] = (1280, 800)) -> bytes: + """A deterministic application-like screen: header, sidebar, table rows.""" + image = Image.new("RGB", size, (246, 247, 249)) + draw = ImageDraw.Draw(image) + draw.rectangle([0, 0, size[0], 56], fill=(32, 64, 120)) + draw.text((20, 18), f"Synthetic Clinic - screen {index}", fill=(255, 255, 255)) + draw.rectangle([0, 56, 220, size[1]], fill=(232, 235, 240)) + for row in range(12): + draw.text((16, 80 + row * 40), f"Menu item {row}", fill=(40, 40, 40)) + for row in range(24): + y = 80 + row * 28 + shade = 255 if row % 2 else 240 + draw.rectangle([240, y, size[0] - 20, y + 26], fill=(shade, shade, shade)) + draw.text( + (250, y + 7), + f"Testpatient {index:03d}-{row:02d} DOB 19{row + 40}-0{row % 9 + 1}-1{row % 9}" + f" MRN SYN{index * 100 + row:07d} Follow-up note {row * index}", + fill=(20, 20, 20), + ) + out = io.BytesIO() + image.save(out, format="PNG") + return out.getvalue() + + +class ScriptedBackend: + """Hands the recorder a new synthetic screen after every action.""" + + def __init__(self, frames: list[bytes]) -> None: + self._frames = frames + self._i = 0 + + @property + def viewport(self) -> tuple[int, int]: + return (1280, 800) + + def screenshot(self) -> bytes: + return self._frames[self._i % len(self._frames)] + + def click(self, x: int, y: int, *, double: bool = False) -> None: + self._i += 1 + + def type_text(self, text: str) -> None: + self._i += 1 + + def press(self, key: str) -> None: + self._i += 1 + + def scroll(self, dx: int, dy: int) -> None: + self._i += 1 + + +def _ms(seconds: list[float]) -> dict[str, float]: + ordered = sorted(seconds) + return { + "median_ms": round(statistics.median(ordered) * 1000, 3), + "p95_ms": round(ordered[int(0.95 * (len(ordered) - 1))] * 1000, 3), + "mean_ms": round(statistics.fmean(ordered) * 1000, 3), + "n": len(ordered), + } + + +def _disk(root: Path) -> dict[str, int]: + files = [p for p in root.rglob("*") if p.is_file()] + return {"files": len(files), "bytes": sum(p.stat().st_size for p in files)} + + +def record( + root: Path, frames: list[bytes], actions: int, key: str | None +) -> list[float]: + recorder = Recorder( + ScriptedBackend(frames), + root, + app_url="http://synthetic.test/", + settle_interval_s=0.0, + settle_stable_frames=1, + encryption_key=key, + ) + per_action: list[float] = [] + for i in range(actions): + started = time.perf_counter() + if i % 3 == 2: + recorder.type_text(f"Testpatient {i:03d}", param=f"field_{i}") + else: + recorder.click(300 + i % 400, 100 + i % 600) + per_action.append(time.perf_counter() - started) + recorder.finish() + return per_action + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--actions", type=int, default=150) + parser.add_argument("--frames", type=int, default=40, help="distinct screens") + parser.add_argument("--out", type=Path, default=None) + args = parser.parse_args(argv) + + frames = [synthetic_screen(i) for i in range(args.frames)] + work = Path(tempfile.mkdtemp(prefix="at-rest-bench-")) + results: dict[str, Any] = { + "schema": "openadapt.at-rest-benchmark/v1", + "measured_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"), + "host": { + "platform": platform.platform(), + "python": sys.version.split()[0], + "cpu_count": os.cpu_count(), + "load_average_1m": round(os.getloadavg()[0], 2), + }, + "frame_png_kb": { + "median": round(statistics.median(len(f) for f in frames) / 1000, 1), + "min": round(min(len(f) for f in frames) / 1000, 1), + "max": round(max(len(f) for f in frames) / 1000, 1), + }, + "actions": args.actions, + } + + # One-time key derivation when an encrypted recording or sealed set opens. + derive = [] + for _ in range(5): + started = time.perf_counter() + crypto.SetKey.derive(KEY, crypto.SetKey.new_salt()) + derive.append(time.perf_counter() - started) + results["key_derivation_scrypt"] = _ms(derive) + + # Persisting one frame: plaintext write against the sealed writer. + plain_dir = work / "frame-plain" + plain_dir.mkdir() + plain = [] + for i, frame in enumerate(frames * 5): + started = time.perf_counter() + (plain_dir / f"{i:04d}.png").write_bytes(frame) + plain.append(time.perf_counter() - started) + writer = sealed_set.SealedSetWriter(work / "frame-sealed", "recording", KEY) + sealed = [] + for i, frame in enumerate(frames * 5): + started = time.perf_counter() + writer.write(f"frames/{i:04d}_before.png", frame) + sealed.append(time.perf_counter() - started) + results["persist_one_frame"] = {"plaintext": _ms(plain), "sealed": _ms(sealed)} + + # Whole actions through the real recorder. + plain_actions = record(work / "rec-plain", frames, args.actions, None) + sealed_actions = record(work / "rec-sealed", frames, args.actions, KEY) + results["record_one_action"] = { + "plaintext": _ms(plain_actions), + "sealed": _ms(sealed_actions), + "note": "two frames plus one event line per action; settle wait disabled", + } + tail = max(10, args.actions // 10) + results["record_one_action_last_10pct"] = { + "plaintext": _ms(plain_actions[-tail:]), + "sealed": _ms(sealed_actions[-tail:]), + "note": "the sealed event log is resealed whole on each append", + } + # Reading the sealed recording back, as compile does. + started = time.perf_counter() + opened = sealed_set.SealedSet.open(work / "rec-sealed", KEY) + opened.verify_all() + results["open_and_verify_sealed_recording_ms"] = round( + (time.perf_counter() - started) * 1000, 1 + ) + # Exact overhead: each sealed file against its own plaintext, plus the + # header. (The two recordings' totals also differ by which synthetic + # screens the settle loop happened to capture, so they are informational.) + sealed_root = work / "rec-sealed" + plaintext_bytes = sum(len(opened.read_bytes(rel)) for rel in opened.files()) + sealed_disk = _disk(sealed_root) + results["disk"] = { + "plaintext_recording": _disk(work / "rec-plain"), + "sealed_recording": sealed_disk, + "sealed_plaintext_content_bytes": plaintext_bytes, + "overhead_bytes": sealed_disk["bytes"] - plaintext_bytes, + "overhead_percent": round( + 100 * (sealed_disk["bytes"] - plaintext_bytes) / plaintext_bytes, 3 + ), + "header_bytes": (sealed_root / sealed_set.HEADER).stat().st_size, + "per_file_overhead_bytes": len(crypto.SET_FILE_MAGIC) + 12 + 16, + } + + shutil.rmtree(work) + text = json.dumps(results, indent=2) + print(text) + if args.out is not None: + args.out.write_text(text + "\n", encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/flow/docs/LIMITS.md b/packages/flow/docs/LIMITS.md index 90f33aa90..d585fe597 100644 --- a/packages/flow/docs/LIMITS.md +++ b/packages/flow/docs/LIMITS.md @@ -625,8 +625,13 @@ host hardening, storage encryption, backups, retention, access control, monitoring, effect-verifier integration, update validation, and disaster recovery. -The repository supplies integrity manifests, optional bundle encryption, -destination policy, local egress checks, and audit primitives. A content digest +The repository supplies integrity manifests, optional encryption at rest for +bundles, web recordings and the run directories of sealed bundles, destination +policy, local egress checks, and audit primitives. Encryption at rest does not +cover data in process memory, the target application's own storage, file names +and sizes, desktop recordings, or commands outside the list in +[`phi_at_rest.md`](phi_at_rest.md), which refuse sealed input; the key is a +passphrase in the environment, not a KMS. A content digest proves byte identity, not publisher identity. A local hash chain is tamper evident against ordinary changes, not tamper proof against an administrator who can rewrite it. Local approvals record provenance but are not enterprise-IdP diff --git a/packages/flow/docs/PRIVACY.md b/packages/flow/docs/PRIVACY.md index ebe212ec4..584ad0656 100644 --- a/packages/flow/docs/PRIVACY.md +++ b/packages/flow/docs/PRIVACY.md @@ -96,9 +96,9 @@ what protects it. | # | Where (file:symbol) | PHI written | Control | |---|---|---|---| -| 1 | `recorder.py` recording dir | `frames/*.png` (full screenshots), `events.jsonl` (literal typed `text` incl. param values, `structured_identity` DOM text), `meta.json` (`params` example values) | **Documented boundary** — raw capture, operator's machine. Not scrubbed (the recording is the training/compile input; scrubbing it would corrupt the demo). Filesystem controls + retention policy. | -| 2 | `compiler/compile.py` → `ir.Workflow.save` → `workflow.json` | `anchor.ocr_text`, `anchor.context_text`, `anchor.structured_identity` (identity band = name/DOB/MRN), `step.text` (literal TYPE), `params`, `step.intent`; plus `templates/*.png` and `identifier_crop` PNGs (rendered PHI) | **Documented boundary** — the compiled bundle *must* carry the recorded identity evidence to verify identity on replay; scrubbing it would defeat the wrong-patient safety check. Filesystem controls + retention policy. | -| 3 | `runtime/replayer.py` → `RunReport.save` → `report.json` | `params`, `workflow_name`, per-step `intent`, `error`, `IdentityCheck.expected`/`observed` (recorded vs live band text — raw PHI), `UnarmedStep.*` | **Documented boundary** — machine artifact and identity **audit trail**; the literal expected/observed text is what lets an operator prove a wrong-patient halt fired. Filesystem controls + retention policy. The shareable derivative (`REPORT.md`) IS scrubbed — see below. | +| 1 | `recorder.py` recording dir | `frames/*.png` (full screenshots), `events.jsonl` (literal typed `text` incl. param values, `structured_identity` DOM text), `meta.json` (`params` example values), `network.jsonl` | **Encrypted at rest with a key** — with `OPENADAPT_RECORDING_KEY` (or `OPENADAPT_BUNDLE_KEY`) the web recorder writes only AES-256-GCM ciphertext from the first frame ([phi_at_rest.md](phi_at_rest.md)). Not scrubbed (the recording is the compile input; scrubbing it would corrupt the demo). Without a key, and for desktop recordings: filesystem controls + retention policy. | +| 2 | `compiler/compile.py` → `ir.Workflow.save` → `workflow.json` | `anchor.ocr_text`, `anchor.context_text`, `anchor.structured_identity` (identity band = name/DOB/MRN), `step.text` (literal TYPE), `params`, `step.intent`; plus `templates/*.png` and `identifier_crop` PNGs (rendered PHI) and the compiler sidecars | **Encrypted at rest when sealed** — `seal`, or `compile` of an encrypted recording, encrypts every bundle file that can hold patient data under one sealed index ([phi_at_rest.md](phi_at_rest.md)). Not scrubbed: the bundle *must* carry the recorded identity evidence to verify identity on replay. An unsealed bundle: filesystem controls + retention policy. | +| 3 | `runtime/replayer.py` → `RunReport.save` → `report.json` | `params`, `workflow_name`, per-step `intent`, `error`, `IdentityCheck.expected`/`observed` (recorded vs live band text — raw PHI), `UnarmedStep.*` | **Encrypted at rest for a sealed bundle** — the run directory of a sealed bundle is itself sealed: report, frames, crops, checkpoints and approvals. Otherwise a documented boundary: the machine artifact and identity audit trail, under filesystem controls + retention policy. The shareable derivative (`REPORT.md`) IS scrubbed — see below. | | 4 | `runtime/replayer.py:_save_step_png` → `steps/*.png` | full before/after frames | **Scrubbed** — routed through `scrub_image_bytes`; redacted when `OPENADAPT_FLOW_SCRUB_IMAGES=1` (opt-in under `auto`) **or implied under `SCRUB=on`** (a pinned run redacts frames without the extra flag). | | 5 | `runtime/heal.py:persist_heal` → `heals//{template,screen}.png`, `heal.json` | heal crop, full frame, Anchor text | **Scrubbed** for the PNGs (same gate as #4); `heal.json` text is a documented boundary (audit trail, same as #3). | | 6 | `report.py:render_run_report` → **`REPORT.md`** | `workflow_name`, `params` values, per-step `intent`, `error`, `UnarmedStep.intent`/`reason` | **Scrubbed** — every free-text field passes through `_md_phi` (scrub → escape). This is the artifact that gets committed to repos / shared with stakeholders, so it is scrubbed by default (auto) whenever the capability is present; when it is **not** present, a one-time `PlaintextPHIWarning` fires (see above). Embedded frames follow #4. | @@ -156,9 +156,11 @@ through. The control is therefore a **data-flow boundary**, stated in full in - **Bundle / recording / `report.json` text is not scrubbed.** By design: the recorded identity evidence and the audit trail require the literal - identifiers. These artifacts are PHI-at-rest protected by filesystem controls - and the operator's retention policy, not by scrubbing. Do not commit real - bundles or run dirs to a public repo. + identifiers. With a key they are encrypted at rest instead (sealed bundles, + encrypted web recordings, and the run directories of sealed bundles; see + [phi_at_rest.md](phi_at_rest.md) for what stays readable). Without one they + are protected only by filesystem controls and the operator's retention + policy. Do not commit real bundles or run dirs to a public repo. - **Image redaction is best-effort.** Presidio image redaction is OCR+NER over the frame; it can miss non-textual PHI or unusual layouts. It is opt-in under `auto` (off by default) and implied under `SCRUB=on`. Treat persisted frames diff --git a/packages/flow/docs/phi_at_rest.md b/packages/flow/docs/phi_at_rest.md index 3355338e5..f469e312f 100644 --- a/packages/flow/docs/phi_at_rest.md +++ b/packages/flow/docs/phi_at_rest.md @@ -1,141 +1,245 @@ -# PHI at rest — the compiled bundle - -A compiled bundle (`workflow.json` + `templates/*.png` + `workflow.py`) is a -**persistent record** produced from a recording of a patient screen. In a -healthcare deployment it is a **HIPAA-designated record** and must be -classified, access-controlled, retention-managed, and **encrypted at rest** by -the operator. This page states the current at-rest posture, what each -remediation does, and the remaining key-management evolution. - -See also [PRIVACY.md](PRIVACY.md) (the in-flight / scrubbing map), -[phi_in_transit.md](phi_in_transit.md) (PHI **on the wire** — the win_agent -control channel is now TLS-encrypted with certificate pinning), and the -`openadapt_flow.ir.Workflow` manifest fields. - -## What is in a bundle, and how it is protected today - -| Artifact | Contents | At-rest protection today | +# PHI at rest: bundles, recordings and runs + +A recording, the bundle compiled from it, and the run directory written when +the bundle runs are all persistent records of a patient screen. In a +healthcare deployment each one is a HIPAA-designated record that must be +classified, access-controlled, retention-managed and encrypted at rest. This +page states what Flow encrypts, how, what it costs, and what it still leaves +to the operator. + +See also [PRIVACY.md](PRIVACY.md) (what is scrubbed before anything leaves the +machine), [phi_in_transit.md](phi_in_transit.md) (PHI on the wire) and +`openadapt_flow.sealed_set` (the implementation). + +## Summary + +With a key configured, these are ciphertext on disk: + +- every file of a sealed bundle that can hold patient data + (`openadapt-flow seal`, or `compile` of an encrypted recording); +- every file of a web recording, from the first frame (`record`); +- every file of the run directory of a sealed bundle: the report, step + screenshots, identity and resolution crops, transition evidence, + checkpoints, pauses, approvals and undo records. + +Flow reads them in memory. The test +`tests/test_sealed_at_rest.py::test_record_compile_and_governed_run_write_no_plaintext` +records a demonstration against the synthetic clinic app, compiles it, runs it +to VERIFIED under the Standard profile and renders the report, while a process +audit hook checks every file the process writes, renames or deletes, temporary +files included. It fails if any of them holds plaintext. + +What stays readable is listed under [What is not encrypted](#what-is-not-encrypted). + +## Keys + +| Variable | Used for | +| --- | --- | +| `OPENADAPT_BUNDLE_KEY` | Sealed bundles and their run directories. Also turns on encrypted web recording by default. | +| `OPENADAPT_RECORDING_KEY` | Recordings. When it is unset, recordings use `OPENADAPT_BUNDLE_KEY`. | + +The key is a passphrase. Flow stretches it with scrypt (N=2^15, r=8, p=1) +over a random per-directory salt, once per directory, and seals each file with +AES-256-GCM under the derived key, a fresh random 96-bit nonce and the file's +path in the associated data. Both primitives come from the `cryptography` +package. Key management (KMS, escrow, rotation) is the operator's: Flow reads +the passphrase from the environment and never writes it. + +## Sealed sets + +A sealed bundle, an encrypted recording and an encrypted run directory share +one format, the sealed set: + +- `.openadapt-sealed.json` holds the salt, the scrypt parameters and an index + sealed with the derived key. The index lists every other file with the + SHA-256 of its bytes on disk. The header also repeats the index digest + (`set_digest`) for an inventory that has no key; Flow trusts only the copy + it recomputes from the authenticated index. +- Each file that can hold patient data is stored as `.enc`. +- Opening the set authenticates the index, refuses any file the index does not + list and any listed file that is missing, and checks each file's hash before + decrypting it. A file cannot be added, dropped, swapped for another file, + replaced with an older ciphertext of itself, or moved to another set. +- Writing into a set refuses a file the inventory below does not know, rather + than passing it through. +- A recording that stopped before it finished has no index, and every reader + refuses it as unfinished. + +`openadapt-flow seal ./bundle --out ./bundle-sealed` prints the content digest +(the workflow and its plaintext crops) and the sealed file-set digest: + +```bash +export OPENADAPT_BUNDLE_KEY='' +openadapt-flow seal ./bundle-v2 --out ./bundle-prod +openadapt-flow certify ./bundle-prod --policy clinical-write +``` + +`seal` never modifies the source and never overwrites a destination. It reads +the source into memory, encrypts it into a private staging directory, reopens +the result as a reader would, and publishes it with an atomic no-replace +rename. Because encryption changes the artifact contract, sealing expires any +prior certification; certify the sealed destination. + +## Inventory + +`openadapt_flow.sealed_set.BUNDLE_FILES` and `RECORDING_FILES` are the source +of truth; a test keeps this table in step with them. + +### Bundle + +| File | Holds | At rest | +| --- | --- | --- | +| `workflow.json` | The program: target labels, typed literals, landmark text, selectors, URLs, effect values, data-source rows, parameter defaults, window titles | Encrypted | +| `templates/*` | Crops of the recorded screen (targets, expected regions, drag ends, identifier crops) and the layout fingerprint | Encrypted | +| `manifest.json` | SHA-256 digests, compiler version, certification provenance | Plaintext (structural) | +| `workflow.py` | A readable rendering of `workflow.json` | Encrypted | +| `compile_notes.json`, `param_hygiene.json`, `param_proposals.json`, `annotations.json`, `api_proposal.json` | Compiler sidecars; they quote labels, masked or raw example values and screen text | Encrypted | +| `effect_contract.yaml` | A scaffolded effect contract with observed values | Encrypted | +| `undo-lineage.json`, `record-check-binding.json` | Prior digests; record-check coverage | Encrypted | +| `qualification-proposal.json`, `qualification-proposal.draft.json`, `qualification-admission.local-dev.json`, `qualification/*` | Qualification pins, cases and reports | Encrypted | +| `admission-decision.json`, `admission.json`, `admit.json`, `jit_draft.json` | Admission records; some carry read-back values or a login name | Encrypted | +| `openadapt-card.json`, `repair/*`, `evidence/*` | Operator card, repair candidates with anchor text, copied evidence | Encrypted | +| Anything else | Unknown | `seal` refuses | + +### Recording + +| File | Holds | At rest | +| --- | --- | --- | +| `events.jsonl` | Every action: typed text, field labels, structural locators, page URL and title, system-of-record rows when a reader was attached | Encrypted | +| `frames/*` | Before and after screenshots of every action | Encrypted | +| `meta.json` | App URL, parameter example values, window title, identifier region | Encrypted | +| `network.jsonl` | Request URLs, form fields and bodies, response bodies (`--capture-network`) | Encrypted | +| `sor_snapshots.json`, `verify.proposed.yaml` | System-of-record rows read by `record --verify`, and the effects proposed from them | Encrypted | +| `check.json`, `record_check.proposed.yaml`, `record_check.proof.json`, `identity.proposed.json`, `effect_contract.yaml` | Checks and proposals derived from the recording | Encrypted | +| `distill.json`, `.openadapt-approval.json`, `.openadapt-sanitization.json` | Derived reports and approvals | Encrypted | +| Anything else | Unknown | Refused | + +### Run directory of a sealed bundle + +Every file is encrypted except these structural ones: `.durable_run.claim` +(run id and namespace), the empty `*.lock` files, `.attended_action.lease` +(nonce digests and times) and `.attended_capability.key` (a random per-run +secret, no recorded data). The durable store's checkpoints, pauses and +approvals are sealed under the same key with their own AES-256-GCM containers, +and the run set adopts them as ciphertext. A file written in plaintext by any +other component makes the run refuse to seal its index. + +## Recording encrypted from the start + +```bash +export OPENADAPT_RECORDING_KEY='' +openadapt-flow record --backend web --url https://app.example --out ./rec +``` + +The web recorder seals every frame, event line, the metadata and the network +capture in memory and writes only ciphertext, from the first frame. The event +log is resealed whole on each append, so the file on disk is always one +complete ciphertext. The recording is finalized (indexed) when it finishes. +`record --verify` keeps its system-of-record reads inside it, encrypted; +`record --with-check` says it cannot mark a check in an encrypted recording +yet and marks nothing. + +Encryption is on by default where it is safe: when `OPENADAPT_BUNDLE_KEY` is +set, a deployment that seals its bundles also encrypts its web recordings. +`--no-encrypt` opts out. The desktop recorders (`--backend +windows/macos/linux/rdp/citrix`) write a raw capture session (a SQLite +database and video) through openadapt-capture, which Flow cannot encrypt yet. +With `OPENADAPT_RECORDING_KEY` set they refuse to record; with only the bundle +key set they record in plaintext as before. + +### Cost + +Measured on 2026-10-19 with `benchmark/at_rest/run.py` (results in +[`benchmark/at_rest/results.json`](../benchmark/at_rest/results.json), +summary in [`benchmark/at_rest/RESULTS.md`](../benchmark/at_rest/RESULTS.md)): + +| | Plaintext | Encrypted | | --- | --- | --- | -| `workflow.json` identity band | **Salted-hash `identity_template`** — no plaintext name/DOB/MRN (REM-2) | One-way hash + governance guard + operator disk encryption | -| `workflow.json` postconditions | `TEXT_PRESENT` assertions; identifier-bearing ones **dropped** when the Presidio scrub is active (REM-2/GAP-3) | Compile-time scrub (optional) + governance guard | -| `workflow.json` landmarks | `anchor.landmarks[].ocr_text` — nearby ROW text used by the geometry rung, often the patient name; identifier-bearing ones **dropped** when the Presidio scrub is active | Compile-time scrub (optional) + governance guard | -| `workflow.json` labels/typed text | `anchor.ocr_text`, literal `Step.text` — may echo an identifier that is **load-bearing** for replay (the target label / the typed search value) | **Not scrubbed** (scrubbing would break resolution/typing); governance guard flags it; **parameterize** the typed identifier (`entity_ref`) so it is supplied at run time and never stored | -| `templates/*.png` | Pixel crops of the recorded screen — **image PHI** | **Sealed with AES-256-GCM** in an encrypted bundle (`save(encrypt=True)` → `templates/*.png.enc`, no cleartext crop on disk); governance guards + operator disk encryption otherwise | -| `workflow.py` | Human-readable rendering; identity band is now a PHI-free note (REM-2) | Same as `workflow.json` | - -### The identity template is NOT encryption - -`identity_template` removes **plaintext** PHI: no grep-visible, human-readable, -log-leakable, git-committable identifier remains, and the wrong-patient guard -still runs against the hashes. But a salted hash of a **low-entropy** identifier -(a name, a DOB) is **brute-forceable** by anyone who holds **both** the bundle -and the salt. So the template is a real reduction in exposure, **not a -cryptographic control**. - -To raise the bar today, set `OPENADAPT_FLOW_IDENTITY_SALT` at **both** compile -and replay: the salt is then kept **out of the bundle**, so the hashes are -one-way to anyone without the external secret. Manage that secret like any other -(OS keychain / KMS / CI secret). - -## Governance (REM-1, shipped) - -- `.gitignore` excludes bundle output dirs; the committed - `docs/showcase-openemr/bundle` is an explicit **synthetic-data** exception. -- Manifest fields on `workflow.json`: `contains_phi`, `phi_scrubbed`, - `encrypted` — for a compliance inventory. -- Pre-commit / CI guard (`scripts/check_bundle_phi.py`) **blocks** committing a - bundle whose steps carry a plaintext identity band (always), and — with the - `privacy` extra installed — an identifier-bearing postcondition / label. - -## Shipped: profile-enforced AEAD encryption (REM-1 crypto) - -Encryption-at-rest remains opt-in for Demo, low-level embedding, and Standard -deployments whose qualified storage boundary already supplies it. Regulated -requires application-level sealing and refuses before actuation when it is -absent. When Standard uses an application-sealed bundle, the runtime also -requires encrypted durable state rather than silently mixing protection modes: - -- **Bundle.** The production CLI path is: - - ```bash - export OPENADAPT_BUNDLE_KEY='' - openadapt-flow seal ./bundle-v2 --out ./bundle-prod - openadapt-flow certify ./bundle-prod --policy clinical-write - ``` - - It preserves the source, refuses symlinks and existing destinations, stages - the complete copy privately, verifies the resulting integrity digest, and - atomically publishes the encrypted bundle. Because encryption changes the - artifact contract, sealing expires prior certification with an explicit - provenance reason; certify the sealed destination, and run any versioned - qualification cases against that destination before `qualify certify`. This - command seals `workflow.json` and files under `templates/`; other bundle files - are copied unchanged and must not be assumed encrypted. The library primitive - `Workflow.save(bundle_dir, encrypt=True, key=…)` seals the - serialized `workflow.json` with **AES-256-GCM** and writes it as - `workflow.json.enc` (no plaintext `workflow.json` on disk), **and seals every - `templates/*.png` image crop the same way** — each becomes - `templates/*.png.enc` (under a distinct `TEMPLATE_AAD` domain so a crop - ciphertext can't be swapped for the workflow-json one) with its plaintext - removed, so an encrypted bundle leaves **no cleartext PHI-bearing screenshot** - on disk. `Workflow.load(bundle_dir, key=…)` decrypts both `workflow.json` and - the crops **in memory** (the resolver reads a decrypted crop via - `Workflow.decrypted_template(rel)`; nothing is rewritten as cleartext). The - passphrase comes from the `key` argument or the **`OPENADAPT_BUNDLE_KEY`** - environment variable; a per-bundle random salt + scrypt KDF stretches it to the - 256-bit data key. -- **Durable checkpoints.** `CheckpointStore(run_dir, key=…)` (wired through - `Replayer(checkpoint_key=…)` and `resume(…, key=…)`) seals every checkpoint / - pending-escalation / run-manifest / Phase-2 interpreter checkpoint the same - way (`…​.json.enc`), so a resumable run's persisted params + effect contracts - are ciphertext at rest. The `run` CLI resolves `OPENADAPT_BUNDLE_KEY` once for - the encrypted bundle and reuses it for Regulated durable state and for any - application-sealed Standard bundle; a library caller must pass the same key - to `Replayer(checkpoint_key=…)`. -- **Integrity preserved.** The schema-v2 manifest (content digest + per-asset - SHA-256 + provenance) is sealed over the **plaintext** content *before* - encryption — including the template crops, whose digests stay over the - **plaintext** PNG — so a decrypted load still verifies integrity end-to-end - (the crop check runs against the decrypted bytes in memory). The - `encrypted: true` manifest flag is now live (mirrored on `Workflow.encrypted`) - and now covers **both** `workflow.json` and the crops, and the `manifest.json` - sidecar stays plaintext so a compliance inventory can read it without the key. -- **Fails loud + safe.** A **wrong or missing key**, or a **tampered ciphertext** - (a flipped byte breaks the GCM tag — of `workflow.json` *or* a crop), raises - `crypto.DecryptionError` / `crypto.MissingKeyError`; a **missing** or - **swapped** crop ciphertext raises `BundleIntegrityError`. Never a partial or - silent load. - -**Scope / done:** both `workflow.json` **and** the `templates/*.png` image crops -are now sealed into AES-256-GCM containers in an encrypted bundle — no cleartext -PHI-bearing screenshot is left on disk. (The COMPLIANCE.md at-rest line for -`templates/` can therefore flip from "operator-disk-encryption only" to "sealed"; -that file is owned by a separate PR.) - -The live resolver consumes the keyed load's in-memory decrypted crops and does -not materialize plaintext PNGs on disk. Deployment-time **key management** (OS -keychain / KMS / envelope keys, escrow, and rotation) is still the operator's -responsibility: the passphrase is supplied via environment or library argument. -This provides the AEAD substrate, not a KMS. - -### Future key-management design (envelope keys / whole container) - -1. **Sealed container.** Serialize `workflow.json` + `templates/` into a single - encrypted container (e.g. an [age](https://github.com/FiloSottile/age) / - libsodium sealed archive). The bundle on disk is ciphertext; nothing readable - without the key. -2. **Envelope keys.** A per-bundle data key (DEK) encrypts the container; the - DEK is wrapped by a deployment key (KEK) held in the operator's KMS / OS - keychain. Rotating the KEK re-wraps DEKs without re-encrypting bundles. -3. **Decrypt only in memory at replay.** `Workflow.load` gains a decrypt path - that unwraps the DEK from the configured key provider and materializes the - bundle **in memory only**; plaintext never lands on disk. -4. **Manifest.** `encrypted: true` plus the key id / wrapping metadata (never the - key). The governance guard treats an `encrypted: true` bundle as opaque. -5. **Fail closed.** A compliance-pinned deployment (`OPENADAPT_FLOW_SCRUB=on`) - refuses to write an **un**encrypted bundle once the key provider is - configured. - -Even with bundle sealing enabled, treat every bundle as PHI, keep it off public -or unmanaged shared storage and git, and use **full-disk encryption** on the -machines that hold it. +| Persist one 1280x800 frame (median) | 0.12 ms | 0.25 ms | +| Record one action: two frames, one event (median) | 5.2 ms | 5.8 ms | +| Same, actions 136-150 of 150 (median) | 5.1 ms | 6.1 ms | +| Key derivation, once per recording | none | 183 ms | +| Disk for a 150-action recording (26 MB) | | +62.6 KB (0.24%) | + +The disk overhead is 44 bytes per file plus the sealed index (one SHA-256 per +file). The settle wait was disabled for these numbers; with it, a real action +takes tens to hundreds of milliseconds, so encryption adds about 1% or less. + +## Commands on sealed input + +`record`, `compile`, `induce`, `scaffold-verifier`, `seal`, `certify`, +`qualify`, `admit`, `run`, `replay`, `resume` and `undo` read sealed bundles +and encrypted recordings in memory, given the key, and keep what they write +sealed: + +- `compile` of an encrypted recording writes a sealed bundle, under + `OPENADAPT_BUNDLE_KEY` or, when that is unset, the recording's key. It + refuses an existing plaintext output directory. +- `induce` of encrypted inputs writes a sealed program. `--report` and + `--probe-on` refuse, because they write outside a sealed set. +- `scaffold-verifier` writes its drafts inside the encrypted source. `--out` + and `--prove` refuse. +- `qualify` reseals the bundle; `--out` must be inside it. +- `admit` compiles into sealed bundles and merges crops in memory. The + pending and decision records at the admission root stay plaintext; they hold + digests, counts, the workflow name and the decider. +- `run`, `replay` and `resume` write a sealed run directory. A run with an + approval gate, an attended draft or a business-decision state refuses before + it acts, because those stores still write plaintext state. +- `undo` keeps its approval, claim, parameters and result in the sealed run + directory; the undo run is a sealed set of its own inside it. + +Without the key, each of these refuses before it reads anything and names the +variable to set. Any other command given a sealed path refuses with a message +that lists the commands above, rather than decrypt to disk. A bare +`Workflow.save()` into a sealed bundle keeps it sealed. + +## What is not encrypted + +- Data in memory. Decrypted frames, crops and the workflow live in process + memory while a command runs, and can reach swap or a crash dump. Use + encrypted swap and full-disk encryption. +- The application itself. The browser profile and cache of the app being + recorded or driven, and the app's own storage, are outside Flow. +- Terminal output. Commands print what they always printed (paths, outcomes, + `induce` summaries); redirecting it to a file writes plaintext. +- Names and sizes. File names (`templates/step_003.png.enc`), file counts and + ciphertext sizes show the number of steps and frames and roughly how large + each image is. +- Structural files: `manifest.json`, the sealed-set headers, and the run + directory's claim, lock and lease files. +- Desktop recordings, as above. +- The admission root's `admission-pending.json` and + `admission-decision.json`. +- Bundles encrypted with the older `Workflow.save(encrypt=True)` library call + and no `seal`: only `workflow.json` and `templates/` are encrypted there. +- The offline Seal of a run (`local-seal`) is not written for an encrypted + run directory yet; the run says so on stderr. +- Commands outside the list above, such as `visualize`, `explain`, `teach`, + `emit-skill` and `push`, refuse sealed input rather than read it. + +## The identity template is not encryption + +`identity_template` removes plaintext identifiers from `workflow.json`: no +readable, greppable name, DOB or MRN remains, and the wrong-patient check +still runs against the hashes. But a salted hash of a low-entropy identifier +can be brute-forced by anyone holding the bundle and the salt. Set +`OPENADAPT_FLOW_IDENTITY_SALT` at both compile and replay to keep the salt out +of the bundle. In a sealed bundle the template is encrypted as well. + +## Governance + +- `.gitignore` excludes bundle output directories; the committed + `docs/showcase-openemr/bundle` is a synthetic-data exception. +- `workflow.json` carries `contains_phi`, `phi_scrubbed` and `encrypted` for a + compliance inventory; `manifest.json` mirrors `encrypted` without the key. +- `scripts/check_bundle_phi.py` blocks committing a bundle with a plaintext + identity band, and, with the `privacy` extra, identifier-bearing + postconditions and labels. +- Profiles: Regulated requires an application-sealed bundle and refuses before + actuation without one; Standard with a sealed bundle requires encrypted + durable state. + +Even with everything above, treat every bundle, recording and run directory as +PHI, keep it off public or unmanaged storage and out of git, and use full-disk +encryption on the machines that hold it. diff --git a/packages/flow/openadapt_flow/__main__.py b/packages/flow/openadapt_flow/__main__.py index 65224801b..b3f8a9368 100644 --- a/packages/flow/openadapt_flow/__main__.py +++ b/packages/flow/openadapt_flow/__main__.py @@ -1239,11 +1239,17 @@ def _cmd_record(args: argparse.Namespace) -> int: for line in summary_lines(contract, proposal, diff): print(line) if code == 0 and with_check: + from openadapt_flow import sealed_set from openadapt_flow.cli_check import guided_check from openadapt_flow.cli_hints import command recording = Path(args.out) - if not (recording / "events.jsonl").is_file(): + if sealed_set.is_sealed(recording): + print( + "record --with-check: marking a check in an encrypted recording " + "is not available yet; nothing was marked." + ) + elif not (recording / "events.jsonl").is_file(): print("record: no events.jsonl to mark a check in; skipped") elif not sys.stdin.isatty(): mark = command( @@ -1940,15 +1946,14 @@ def _cmd_compile(args: argparse.Namespace) -> int: def _cmd_induce(args: argparse.Namespace) -> int: - from openadapt_flow.compiler.induction import induce_program, validate_held_out - from openadapt_flow.ir import Workflow - # Accept both RECORDING directories (compiled via the single-trace # bootstrap by induce_program) and already-compiled BUNDLE directories # (a dir containing workflow.json -> loaded as a Workflow). Detecting the # bundle case CLI-side keeps induce usable on artifacts the operator # already has, without touching the library API. from openadapt_flow import sealed_set + from openadapt_flow.compiler.induction import induce_program, validate_held_out + from openadapt_flow.ir import Workflow traces: list = [] sealed_inputs = False @@ -5211,11 +5216,10 @@ def _qualification_drill_driver(args: argparse.Namespace, workflow, params): def _retain_drill_evidence(run_dir: Path, driver) -> None: """Keep the drill's receipt and mutation record next to its report.""" + from openadapt_flow import sealed_set from openadapt_flow.ir import RunReport from openadapt_flow.qualification_drills import write_drill_evidence - from openadapt_flow import sealed_set - try: report = RunReport.model_validate_json( sealed_set.read_text(run_dir / "report.json") diff --git a/packages/flow/openadapt_flow/admit_pipeline.py b/packages/flow/openadapt_flow/admit_pipeline.py index b0a8e9852..c7d7ee980 100644 --- a/packages/flow/openadapt_flow/admit_pipeline.py +++ b/packages/flow/openadapt_flow/admit_pipeline.py @@ -204,14 +204,13 @@ def _now() -> str: def _bundle_digest(bundle: Path) -> str: + from openadapt_flow import sealed_set from openadapt_flow.bundle_validation import ( compute_content_digest, compute_file_hashes, ) from openadapt_flow.ir import Workflow - from openadapt_flow import sealed_set - workflow = Workflow.load(bundle) # A sealed bundle's crops are ciphertext on disk; hash the plaintext. assets = workflow.decrypted_templates() if sealed_set.is_sealed(bundle) else None diff --git a/packages/flow/openadapt_flow/bundle_sealing.py b/packages/flow/openadapt_flow/bundle_sealing.py index 70e21f412..0e3de9f94 100644 --- a/packages/flow/openadapt_flow/bundle_sealing.py +++ b/packages/flow/openadapt_flow/bundle_sealing.py @@ -139,7 +139,9 @@ def _template_assets(source: Path, workflow: Workflow) -> dict[str, bytes]: if tdir.is_dir() and not sealed_set.is_sealed(source): for path in sorted(tdir.rglob("*")): if path.is_file() and path.suffix != ".enc": - assets.setdefault(path.relative_to(source).as_posix(), path.read_bytes()) + assets.setdefault( + path.relative_to(source).as_posix(), path.read_bytes() + ) return assets @@ -278,9 +280,7 @@ def seal_bundle(source: Path | str, destination: Path | str) -> SealedBundle: source_path, destination_path = _validate_paths(Path(source), Path(destination)) _reject_symlinks(source_path) try: - source_workflow = Workflow.load( - source_path, key=key, verify_integrity=True - ) + source_workflow = Workflow.load(source_path, key=key, verify_integrity=True) except Exception as exc: raise BundleSealingError(f"source bundle validation failed: {exc}") from exc others = _source_files(source_path, key) diff --git a/packages/flow/openadapt_flow/compiler/api_proposal.py b/packages/flow/openadapt_flow/compiler/api_proposal.py index 0d14204fe..c450e8427 100644 --- a/packages/flow/openadapt_flow/compiler/api_proposal.py +++ b/packages/flow/openadapt_flow/compiler/api_proposal.py @@ -50,8 +50,8 @@ from pathlib import Path from typing import Any, Optional -from openadapt_flow import sealed_set as _sealed from openadapt_flow import network_capture as nc +from openadapt_flow import sealed_set as _sealed from openadapt_flow.ir import ( ActionKind, ApiAdmission, diff --git a/packages/flow/openadapt_flow/compiler/compile.py b/packages/flow/openadapt_flow/compiler/compile.py index 0c0aaf87c..f512a5562 100644 --- a/packages/flow/openadapt_flow/compiler/compile.py +++ b/packages/flow/openadapt_flow/compiler/compile.py @@ -1836,9 +1836,7 @@ def compile_recording( bundle = Path(out_bundle_dir) sealed_input = _sealed.is_sealed(recording) sealed_output = _sealed.is_sealed(bundle) - if _sealed.active_writer(bundle) is not None or not ( - sealed_input or sealed_output - ): + if _sealed.active_writer(bundle) is not None or not (sealed_input or sealed_output): return _compile_recording(recording, bundle, name=name, **kwargs) if sealed_input: # Opening the recording verifies its whole file set before compiling. @@ -2307,8 +2305,8 @@ def cached_lines(i: int, suffix: str, png: bytes) -> list[OcrLine]: ) if selected_target is not None: anchor_region, anchor_ocr_text = selected_target - _sealed.write_bytes(bundle / template_rel, - _crop_png(before_png, anchor_region) + _sealed.write_bytes( + bundle / template_rel, _crop_png(before_png, anchor_region) ) if event.get("identifier_scope") == "dialog": # The click landed on the control's own label. @@ -2348,8 +2346,8 @@ def cached_lines(i: int, suffix: str, png: bytes) -> list[OcrLine]: ) end_crop_region = _discriminative_crop_region(frame, drag_end) end_template_rel = f"templates/{step_id}_drag_end.png" - _sealed.write_bytes(bundle / end_template_rel, - _crop_png(before_png, end_crop_region) + _sealed.write_bytes( + bundle / end_template_rel, _crop_png(before_png, end_crop_region) ) end_ocr_text = _best_crop_text( ocr(before_png, region=end_crop_region), click_y=drag_end[1] @@ -2575,8 +2573,8 @@ def cached_lines(i: int, suffix: str, png: bytes) -> list[OcrLine]: raise ValueError(f"read event {i} has a label box but no frame") lx, ly, lw, lh = (int(v) for v in label_region) label_rel = f"templates/{step_id}.png" - _sealed.write_bytes(bundle / label_rel, - _crop_png(before_png, (lx, ly, lw, lh)) + _sealed.write_bytes( + bundle / label_rel, _crop_png(before_png, (lx, ly, lw, lh)) ) read_anchor = Anchor( template=label_rel, @@ -3204,7 +3202,8 @@ def cached_lines(i: int, suffix: str, png: bytes) -> list[OcrLine]: drop_region, _mask_value(drop_text), ) - _sealed.write_text(bundle / "param_hygiene.json", + _sealed.write_text( + bundle / "param_hygiene.json", json.dumps( { "note": ( @@ -3224,7 +3223,7 @@ def cached_lines(i: int, suffix: str, png: bytes) -> list[OcrLine]: ], }, indent=2, - ) + ), ) else: _sealed.unlink(bundle / "param_hygiene.json", missing_ok=True) @@ -3265,7 +3264,8 @@ def cached_lines(i: int, suffix: str, png: bytes) -> list[OcrLine]: } ) if label_proposals: - _sealed.write_text(bundle / "param_proposals.json", + _sealed.write_text( + bundle / "param_proposals.json", json.dumps( { "note": ( @@ -3279,7 +3279,7 @@ def cached_lines(i: int, suffix: str, png: bytes) -> list[OcrLine]: "proposals": label_proposals, }, indent=2, - ) + ), ) for flag in label_result.flagged: logger.warning( @@ -3304,8 +3304,9 @@ def cached_lines(i: int, suffix: str, png: bytes) -> list[OcrLine]: ann = annotator if annotator is not None else AnthropicStepAnnotator() result = apply_annotations(workflow, ann) workflow = result.workflow - _sealed.write_text(bundle / "annotations.json", - result.model_dump_json(indent=2, exclude={"workflow"}) + _sealed.write_text( + bundle / "annotations.json", + result.model_dump_json(indent=2, exclude={"workflow"}), ) for flag in result.flagged: logger.warning( @@ -3354,7 +3355,8 @@ def cached_lines(i: int, suffix: str, png: bytes) -> list[OcrLine]: else: logger.info("no API step proposed: %s", "; ".join(_api_reasons)) # Say what happened either way, so "no API step" is never silent. - _sealed.write_text(bundle / "api_proposal.json", + _sealed.write_text( + bundle / "api_proposal.json", json.dumps( { "proposed": _api_step is not None, @@ -3363,7 +3365,7 @@ def cached_lines(i: int, suffix: str, png: bytes) -> list[OcrLine]: }, indent=2, ) - + "\n" + + "\n", ) # Bind each consequential click on an unlabeled control to the form whose @@ -3405,7 +3407,7 @@ def cached_lines(i: int, suffix: str, png: bytes) -> list[OcrLine]: _layout.dump_bundle_fingerprints( layout_fingerprints, scale_landmarks={"salt": scale_salt, "steps": scale_landmarks}, - ) + ), ) write_compile_notes(bundle, compile_notes) diff --git a/packages/flow/openadapt_flow/compiler/fixup_passes.py b/packages/flow/openadapt_flow/compiler/fixup_passes.py index 5b068f94c..575b10480 100644 --- a/packages/flow/openadapt_flow/compiler/fixup_passes.py +++ b/packages/flow/openadapt_flow/compiler/fixup_passes.py @@ -356,7 +356,7 @@ def write_compile_notes(bundle_dir: Path, notes: list[dict[str, Any]]) -> None: }, indent=2, ) - + "\n" + + "\n", ) diff --git a/packages/flow/openadapt_flow/crypto.py b/packages/flow/openadapt_flow/crypto.py index 325fff233..84ad95758 100644 --- a/packages/flow/openadapt_flow/crypto.py +++ b/packages/flow/openadapt_flow/crypto.py @@ -125,9 +125,10 @@ def require_key(key: Optional[str]) -> str: def is_encrypted(data: bytes) -> bool: """Whether ``data`` is one of this module's encrypted containers. - A cheap, allocation-light check: an encrypted payload is a JSON object whose - ``format`` field is the module magic. Plaintext ``workflow.json`` / a - checkpoint JSON fails this, so the unencrypted path is never misrouted. + A cheap check: either a sealed-set file (it starts with + :data:`SET_FILE_MAGIC`) or a JSON object whose ``format`` field is the + module magic. Plaintext ``workflow.json`` / a checkpoint JSON fails this, + so the unencrypted path is never misrouted. """ if data[: len(SET_FILE_MAGIC)] == SET_FILE_MAGIC: return True diff --git a/packages/flow/openadapt_flow/interactive_recorder.py b/packages/flow/openadapt_flow/interactive_recorder.py index 857108f0b..bac22becd 100644 --- a/packages/flow/openadapt_flow/interactive_recorder.py +++ b/packages/flow/openadapt_flow/interactive_recorder.py @@ -86,12 +86,12 @@ from PIL import Image +from openadapt_flow import sealed_set as _sealed from openadapt_flow.backends.playwright_backend import ( GUARDED_ACTIONABLE_SELECTOR, PlaywrightBackend, ) from openadapt_flow.ir import StructuralLocator, structural_locator_is_locatable -from openadapt_flow import sealed_set as _sealed from openadapt_flow.recorder import Recorder from openadapt_flow.submit_binding import ( OBSERVE_SUBMIT_BINDING_JS, diff --git a/packages/flow/openadapt_flow/ir.py b/packages/flow/openadapt_flow/ir.py index c9e90b9db..0b65d4def 100644 --- a/packages/flow/openadapt_flow/ir.py +++ b/packages/flow/openadapt_flow/ir.py @@ -2954,7 +2954,6 @@ def load( """ bundle = Path(bundle_dir) from openadapt_flow import bundle_validation as _bv - from openadapt_flow import sealed_set as _sealed encrypted_path = bundle / "workflow.json.enc" @@ -2964,9 +2963,7 @@ def load( if _sealed.active_writer(bundle) is not None or _sealed.is_sealed(bundle): # A sealed set: verify the whole file set, then decrypt the # workflow and every crop in memory. - reader: Any = _sealed.active_writer(bundle) or _sealed.open_set( - bundle, key - ) + reader: Any = _sealed.active_writer(bundle) or _sealed.open_set(bundle, key) bundle_encrypted = True raw = json.loads(reader.read_bytes("workflow.json")) sealed_assets = { diff --git a/packages/flow/openadapt_flow/runtime/durable/attended.py b/packages/flow/openadapt_flow/runtime/durable/attended.py index ba1d1c4d1..a0818fd92 100644 --- a/packages/flow/openadapt_flow/runtime/durable/attended.py +++ b/packages/flow/openadapt_flow/runtime/durable/attended.py @@ -990,7 +990,9 @@ def _signed_capabilities(self) -> list[AttendedPauseCapability]: ) from exc if _sealed.is_file(self.capability_history_path): try: - raw_history = json.loads(_sealed.read_text(self.capability_history_path)) + raw_history = json.loads( + _sealed.read_text(self.capability_history_path) + ) if not isinstance(raw_history, list): raise ValueError("history is not a list") capabilities.extend( @@ -1275,7 +1277,9 @@ def read_program_receipt(self, pause_id: str) -> ProgramTransitionReceipt: "the private program receipt path must not be a symlink" ) try: - receipt = ProgramTransitionReceipt.model_validate_json(_sealed.read_text(path)) + receipt = ProgramTransitionReceipt.model_validate_json( + _sealed.read_text(path) + ) except (FileNotFoundError, ValueError) as exc: raise AttendedActionRefused( "the exact program transition receipt is missing or invalid" @@ -1384,7 +1388,9 @@ def issue( history: list[dict[str, Any]] = [] if _sealed.is_file(self.capability_history_path): try: - raw_history = json.loads(_sealed.read_text(self.capability_history_path)) + raw_history = json.loads( + _sealed.read_text(self.capability_history_path) + ) if isinstance(raw_history, list): history = [ item for item in raw_history if isinstance(item, dict) @@ -1455,7 +1461,9 @@ def retire_current(self) -> None: history: list[dict[str, Any]] = [] if _sealed.is_file(self.capability_history_path): try: - raw_history = json.loads(_sealed.read_text(self.capability_history_path)) + raw_history = json.loads( + _sealed.read_text(self.capability_history_path) + ) if not isinstance(raw_history, list) or not all( isinstance(item, dict) for item in raw_history ): diff --git a/packages/flow/openadapt_flow/runtime/replayer.py b/packages/flow/openadapt_flow/runtime/replayer.py index 88db9ba60..365b6605f 100644 --- a/packages/flow/openadapt_flow/runtime/replayer.py +++ b/packages/flow/openadapt_flow/runtime/replayer.py @@ -1443,10 +1443,13 @@ def run( No plaintext of the bundle or of the live screen reaches the disk. """ bundle_path = Path(bundle_dir) - if not ( - _sealed.is_sealed(bundle_path) - or _sealed.active_writer(bundle_path) is not None - ) or _sealed.active_writer(Path(run_dir)) is not None: + if ( + not ( + _sealed.is_sealed(bundle_path) + or _sealed.active_writer(bundle_path) is not None + ) + or _sealed.active_writer(Path(run_dir)) is not None + ): return self._run_unsealed( workflow, params=params, @@ -9941,9 +9944,7 @@ def _retain_pixel_identity_evidence( path = inventory_dir / f"{digest}.png" if path.is_symlink(): raise OSError("pixel evidence crop path is a symlink") - existing = ( - _sealed.read_bytes(path) if _sealed.exists(path) else None - ) + existing = _sealed.read_bytes(path) if _sealed.exists(path) else None if existing is not None and existing != payload: raise OSError("pixel evidence digest path has other bytes") _sealed.write_bytes(path, payload) diff --git a/packages/flow/openadapt_flow/sealed_set.py b/packages/flow/openadapt_flow/sealed_set.py index 95be501a5..06b261850 100644 --- a/packages/flow/openadapt_flow/sealed_set.py +++ b/packages/flow/openadapt_flow/sealed_set.py @@ -118,9 +118,7 @@ class FileKind: FileKind( "compile_notes.json", "phi", "gesture rewrites the compiler made, by step" ), - FileKind( - "param_hygiene.json", "phi", "dropped checks with masked matched text" - ), + FileKind("param_hygiene.json", "phi", "dropped checks with masked matched text"), FileKind( "param_proposals.json", "phi", @@ -172,17 +170,13 @@ class FileKind: ), FileKind("jit_draft.json", "phi", "draft state of a just-in-time program"), FileKind("admit.json", "phi", "authoring admission with the local login name"), - FileKind( - "openadapt-card.json", "phi", "operator-written name, purpose and inputs" - ), + FileKind("openadapt-card.json", "phi", "operator-written name, purpose and inputs"), FileKind( "repair/*", "phi", "repair candidates: old and new anchors with their screen text", ), - FileKind( - "qualification/*", "phi", "qualification cases, inputs and reports" - ), + FileKind("qualification/*", "phi", "qualification cases, inputs and reports"), FileKind("evidence/*", "phi", "run evidence a command copied into the bundle"), ) @@ -210,9 +204,7 @@ class FileKind: "phi", "system-of-record rows read before and after the recording", ), - FileKind( - "verify.proposed.yaml", "phi", "effects proposed from the record diff" - ), + FileKind("verify.proposed.yaml", "phi", "effects proposed from the record diff"), FileKind("check.json", "phi", "a demonstrated check: marked fields and reset"), FileKind( "record_check.proposed.yaml", "phi", "a proposed record check with values" @@ -1023,7 +1015,9 @@ def append_line(path: Path | str, line: str) -> None: fh.write(line.rstrip("\n") + "\n") -def unlink(path: Path | str, *, missing_ok: bool = False, key: Optional[str] = None) -> None: +def unlink( + path: Path | str, *, missing_ok: bool = False, key: Optional[str] = None +) -> None: root, rel = _locate(path) if root is None: Path(path).unlink(missing_ok=missing_ok) diff --git a/packages/flow/tests/test_sealed_at_rest.py b/packages/flow/tests/test_sealed_at_rest.py index 62bea89fe..0f6c7e32b 100644 --- a/packages/flow/tests/test_sealed_at_rest.py +++ b/packages/flow/tests/test_sealed_at_rest.py @@ -190,7 +190,9 @@ def test_seal_encrypts_every_file_and_binds_the_set( with watching_disk() as watch: assert main(["seal", str(source), "--out", str(sealed)]) == 0 - written_under_sealed = [p for p in watch.written if p.startswith(str(sealed.parent))] + written_under_sealed = [ + p for p in watch.written if p.startswith(str(sealed.parent)) + ] assert written_under_sealed assert [ leak for leak in watch.leaks(PLAIN_MARKERS) if str(source) not in leak @@ -457,11 +459,25 @@ def test_record_compile_and_governed_run_write_no_plaintext( base_url=fault_app, workflow=workflow, bundle_dir=bundle, run_dir=run_dir ) render_run_report(run_dir) + # The CLI path too. Demo replay has no record check, so it stops + # before the save; what matters here is what it leaves on disk. + tutorial._http_json(f"{fault_app}/api/reset", method="POST", body={}) + replay_code = main( + [ + "replay", + str(bundle), + "--url", + f"{fault_app}/{tutorial.TUTORIAL_ENTRY_QUERY}", + "--run-dir", + str(tmp_path / "run-cli"), + ] + ) assert report.execution_outcome == "VERIFIED" + assert replay_code in (0, 1) assert watch.leaks(E2E_MARKERS) == [] assert list(system_tmp.iterdir()) == [] - for root in (rec_dir, bundle, run_dir): + for root in (rec_dir, bundle, run_dir, tmp_path / "run-cli"): _assert_ciphertext_only(root) sealed_set.SealedSet.open(root, KEY).verify_all() stored = json.loads(sealed_set.read_text(run_dir / "report.json")) @@ -554,7 +570,9 @@ def test_halted_sealed_run_is_undone_without_plaintext( def _recording(rec_dir: Path, *, key: str | None) -> Path: - rec = Recorder(_FakeBackend(), rec_dir, app_url="http://app.test/", encryption_key=key) + rec = Recorder( + _FakeBackend(), rec_dir, app_url="http://app.test/", encryption_key=key + ) rec.click(40, 40) rec.type_text("Testpatient Zed", param="patient") rec.click(80, 80) @@ -589,11 +607,19 @@ def test_scaffold_verifier_keeps_its_draft_inside_the_sealed_recording( monkeypatch.setenv(crypto.ENV_KEY, KEY) rec = tmp_path / "rec" with sealed_set.writing(rec, "recording", KEY) as writer: - writer.write("meta.json", json.dumps({"params": {"p": "Testpatient Zed"}}).encode()) + writer.write( + "meta.json", json.dumps({"params": {"p": "Testpatient Zed"}}).encode() + ) writer.write( "events.jsonl", json.dumps( - {"i": 0, "kind": "click", "x": 5, "y": 5, "field_label": "Save encounter"} + { + "i": 0, + "kind": "click", + "x": 5, + "y": 5, + "field_label": "Save encounter", + } ).encode() + b"\n", ) @@ -713,3 +739,9 @@ def test_sealed_run_pauses_and_resumes_without_plaintext( assert watch.leaks([b"Testpatient", b"operator@example.com", b'"results"']) == [] _assert_ciphertext_only(run_dir) sealed_set.SealedSet.open(run_dir, KEY).verify_all() + + +def test_phi_at_rest_doc_lists_every_inventory_entry() -> None: + doc = (Path(__file__).parents[1] / "docs" / "phi_at_rest.md").read_text() + for entry in (*sealed_set.BUNDLE_FILES, *sealed_set.RECORDING_FILES): + assert f"`{entry.pattern}`" in doc, entry.pattern From 9cdba9a4af7b630839dd42d04b5fa9cf49b8e04e Mon Sep 17 00:00:00 2001 From: abrichr Date: Mon, 19 Oct 2026 11:34:49 -0400 Subject: [PATCH 6/9] Register the at-rest benchmark results in the public artifact inventory Co-Authored-By: Claude Opus 5.5 --- packages/flow/public-artifacts.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/flow/public-artifacts.json b/packages/flow/public-artifacts.json index f137b0fc7..2dbf24b52 100644 --- a/packages/flow/public-artifacts.json +++ b/packages/flow/public-artifacts.json @@ -147,6 +147,10 @@ "path": "benchmark/appliance_validation/results_supplement.json", "sha256": "d6594bf87583158de54935ae777eebbb6dc21f44636907921e79422ea5ddd59c" }, + { + "path": "benchmark/at_rest/results.json", + "sha256": "7c942699ad13b0781376d0de24f1942531a4bac2ef3f1904f410a00b1580b93c" + }, { "path": "benchmark/canvas_ladder/results.json", "sha256": "4502affb014de71e5c584e48cef5bc6a7631983e2517551d5ea9c20c8a0668e2" From 6fe77bfce9ebb77048ef20b0b91a61cd67e44a35 Mon Sep 17 00:00:00 2001 From: abrichr Date: Mon, 19 Oct 2026 11:40:31 -0400 Subject: [PATCH 7/9] WIP: bind-record-checks keeps a sealed bundle sealed; approve on sealed runs Co-Authored-By: Claude Opus 5.5 --- packages/flow/docs/phi_at_rest.md | 9 +++++---- packages/flow/openadapt_flow/__main__.py | 3 ++- packages/flow/openadapt_flow/record_check_binding.py | 11 ++++++++--- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/packages/flow/docs/phi_at_rest.md b/packages/flow/docs/phi_at_rest.md index f469e312f..bf9c9bb7d 100644 --- a/packages/flow/docs/phi_at_rest.md +++ b/packages/flow/docs/phi_at_rest.md @@ -168,9 +168,9 @@ takes tens to hundreds of milliseconds, so encryption adds about 1% or less. ## Commands on sealed input `record`, `compile`, `induce`, `scaffold-verifier`, `seal`, `certify`, -`qualify`, `admit`, `run`, `replay`, `resume` and `undo` read sealed bundles -and encrypted recordings in memory, given the key, and keep what they write -sealed: +`qualify`, `admit`, `run`, `replay`, `approve`, `resume` and `undo` read +sealed bundles and encrypted recordings in memory, given the key, and keep +what they write sealed: - `compile` of an encrypted recording writes a sealed bundle, under `OPENADAPT_BUNDLE_KEY` or, when that is unset, the recording's key. It @@ -183,7 +183,8 @@ sealed: - `admit` compiles into sealed bundles and merges crops in memory. The pending and decision records at the admission root stay plaintext; they hold digests, counts, the workflow name and the decider. -- `run`, `replay` and `resume` write a sealed run directory. A run with an +- `run`, `replay` and `resume` write a sealed run directory, and `approve` + writes its approval into it encrypted. A run with an approval gate, an attended draft or a business-decision state refuses before it acts, because those stores still write plaintext state. - `undo` keeps its approval, claim, parameters and result in the sealed run diff --git a/packages/flow/openadapt_flow/__main__.py b/packages/flow/openadapt_flow/__main__.py index b3f8a9368..004002fd1 100644 --- a/packages/flow/openadapt_flow/__main__.py +++ b/packages/flow/openadapt_flow/__main__.py @@ -11436,6 +11436,7 @@ def _qualify_default_form(argv: list[str]) -> list[str]: "openadapt_flow.__main__:_cmd_run", "openadapt_flow.__main__:_cmd_replay", "openadapt_flow.__main__:_cmd_resume", + "openadapt_flow.__main__:_cmd_approve", "openadapt_flow.__main__:_cmd_qualify", "openadapt_flow.cli_admit:cmd_admit_run", "openadapt_flow.cli_admit:cmd_admit_decide", @@ -11487,7 +11488,7 @@ def _sealed_input_refusal(args: argparse.Namespace) -> Optional[str]: "encrypted input yet. Flow will not decrypt it to disk for this " "command. Commands that read sealed input: record, compile, " "induce, scaffold-verifier, seal, certify, qualify, admit, run, " - "replay, resume and undo." + "replay, approve, resume and undo." ) for root in sealed: kind = sealed_set.header_kind(root) diff --git a/packages/flow/openadapt_flow/record_check_binding.py b/packages/flow/openadapt_flow/record_check_binding.py index 30dfe2d5f..5f49418c5 100644 --- a/packages/flow/openadapt_flow/record_check_binding.py +++ b/packages/flow/openadapt_flow/record_check_binding.py @@ -78,6 +78,7 @@ from pydantic import BaseModel, ConfigDict, Field, ValidationError +from openadapt_flow import sealed_set from openadapt_flow.ir import Workflow from openadapt_flow.runtime.effects.effect import Effect, EffectKind, ValueExpr from openadapt_flow.traversal import iter_workflow_steps @@ -566,7 +567,9 @@ def bind_record_checks( os.chmod(staging, 0o700) bundle = staging / "bundle" shutil.copytree(source, bundle, symlinks=False) - (bundle / PROVENANCE_FILENAME).unlink(missing_ok=True) + # A sealed source stays sealed: the copy is ciphertext, and the + # provenance sidecar is removed and rewritten through its index. + sealed_set.unlink(bundle / PROVENANCE_FILENAME, missing_ok=True, key=key) working = Workflow.load(bundle, verify_integrity=True, key=key) replaced = _apply(working, bound) coverage = record_check_coverage(working, binding.not_compared) @@ -602,8 +605,10 @@ def bind_record_checks( "certification_invalidated": invalidated, "coverage": coverage.model_dump(mode="json"), } - (bundle / PROVENANCE_FILENAME).write_text( - json.dumps(provenance, indent=2, sort_keys=True) + "\n", encoding="utf-8" + sealed_set.write_text( + bundle / PROVENANCE_FILENAME, + json.dumps(provenance, indent=2, sort_keys=True) + "\n", + key=key, ) try: _publish_no_replace(bundle, destination) From 7c04617f01db742ec222970d4353754350c2b226 Mon Sep 17 00:00:00 2001 From: abrichr Date: Mon, 19 Oct 2026 11:55:59 -0400 Subject: [PATCH 8/9] Seal look-up check proposals in encrypted recordings; refresh llms-full Co-Authored-By: Claude Opus 5.5 --- packages/flow/docs/phi_at_rest.md | 2 +- packages/flow/openadapt_flow/__main__.py | 6 +++++- .../openadapt_flow/compiler/lookup_detect.py | 21 ++++++++++--------- packages/flow/openadapt_flow/sealed_set.py | 10 +++++++++ 4 files changed, 27 insertions(+), 12 deletions(-) diff --git a/packages/flow/docs/phi_at_rest.md b/packages/flow/docs/phi_at_rest.md index bf9c9bb7d..cc101f920 100644 --- a/packages/flow/docs/phi_at_rest.md +++ b/packages/flow/docs/phi_at_rest.md @@ -110,7 +110,7 @@ of truth; a test keeps this table in step with them. | `meta.json` | App URL, parameter example values, window title, identifier region | Encrypted | | `network.jsonl` | Request URLs, form fields and bodies, response bodies (`--capture-network`) | Encrypted | | `sor_snapshots.json`, `verify.proposed.yaml` | System-of-record rows read by `record --verify`, and the effects proposed from them | Encrypted | -| `check.json`, `record_check.proposed.yaml`, `record_check.proof.json`, `identity.proposed.json`, `effect_contract.yaml` | Checks and proposals derived from the recording | Encrypted | +| `check.json`, `check.proposed.json`, `readback.proposed.yaml`, `record_check.proposed.yaml`, `record_check.proof.json`, `identity.proposed.json`, `effect_contract.yaml` | Checks and proposals derived from the recording | Encrypted | | `distill.json`, `.openadapt-approval.json`, `.openadapt-sanitization.json` | Derived reports and approvals | Encrypted | | Anything else | Unknown | Refused | diff --git a/packages/flow/openadapt_flow/__main__.py b/packages/flow/openadapt_flow/__main__.py index f06ff5909..256d494aa 100644 --- a/packages/flow/openadapt_flow/__main__.py +++ b/packages/flow/openadapt_flow/__main__.py @@ -1276,7 +1276,11 @@ def _recorded_surface(recording: Path) -> Optional[str]: import json try: - surface = json.loads((recording / "meta.json").read_text()).get("surface") + from openadapt_flow import sealed_set + + surface = json.loads(sealed_set.read_text(recording / "meta.json")).get( + "surface" + ) except (OSError, ValueError, AttributeError): return None return str(surface) if surface else None diff --git a/packages/flow/openadapt_flow/compiler/lookup_detect.py b/packages/flow/openadapt_flow/compiler/lookup_detect.py index 148f29d98..831167d22 100644 --- a/packages/flow/openadapt_flow/compiler/lookup_detect.py +++ b/packages/flow/openadapt_flow/compiler/lookup_detect.py @@ -36,6 +36,7 @@ from pathlib import Path from typing import Any, Callable, Mapping, Optional +from openadapt_flow import sealed_set as _sealed from openadapt_flow.runtime.effects.desktop_readback import save_like_words CHECK_PROPOSAL_FILE = "check.proposed.json" @@ -175,10 +176,10 @@ def summary(self) -> list[str]: def _events(recording: Path) -> list[dict[str, Any]]: path = recording / "events.jsonl" - if not path.is_file(): + if not _sealed.is_file(path): return [] events = [] - for line in path.read_text().splitlines(): + for line in _sealed.read_text(path).splitlines(): line = line.strip() if line: events.append(json.loads(line)) @@ -187,14 +188,14 @@ def _events(recording: Path) -> list[dict[str, Any]]: def _meta(recording: Path) -> dict[str, Any]: path = recording / "meta.json" - if not path.is_file(): + if not _sealed.is_file(path): return {} - return json.loads(path.read_text()) + return json.loads(_sealed.read_text(path)) def _frame(recording: Path, index: int, which: str) -> Optional[bytes]: path = recording / "frames" / f"{index:04d}_{which}.png" - return path.read_bytes() if path.is_file() else None + return _sealed.read_bytes(path) if _sealed.is_file(path) else None def _params(meta: Mapping[str, Any]) -> dict[str, str]: @@ -757,7 +758,7 @@ def propose_from_recording( try: proposal = detect_lookup(recording, ocr=ocr, values=values) if proposal is None: - if (recording / CHECK_FILE).is_file(): + if _sealed.is_file(recording / CHECK_FILE): return None, [], [] return None, [], [LOOKUP_GUIDANCE] written = write_proposals(recording, proposal, session_kind=session_kind) @@ -782,12 +783,12 @@ def accept_check_proposal(recording: str | Path) -> Path: recording = Path(recording) source = recording / CHECK_PROPOSAL_FILE target = recording / CHECK_FILE - if not source.is_file(): + if not _sealed.is_file(source): raise FileNotFoundError(f"no {CHECK_PROPOSAL_FILE} in {recording}") - if target.exists(): + if _sealed.exists(target): raise FileExistsError(f"{target} exists; remove it first to replace it") - marks = CheckMarks.model_validate_json(source.read_text()) - target.write_text(marks.model_dump_json(indent=2) + "\n") + marks = CheckMarks.model_validate_json(_sealed.read_text(source)) + _sealed.write_text(target, marks.model_dump_json(indent=2) + "\n") return target diff --git a/packages/flow/openadapt_flow/sealed_set.py b/packages/flow/openadapt_flow/sealed_set.py index 06b261850..52a03d8f7 100644 --- a/packages/flow/openadapt_flow/sealed_set.py +++ b/packages/flow/openadapt_flow/sealed_set.py @@ -206,6 +206,16 @@ class FileKind: ), FileKind("verify.proposed.yaml", "phi", "effects proposed from the record diff"), FileKind("check.json", "phi", "a demonstrated check: marked fields and reset"), + FileKind( + "check.proposed.json", + "phi", + "a check proposed from a recorded look-up, with screen text and regions", + ), + FileKind( + "readback.proposed.yaml", + "phi", + "a desktop read-back proposed from a recorded look-up", + ), FileKind( "record_check.proposed.yaml", "phi", "a proposed record check with values" ), From fe5b4105cf31cf5c047c1b8d2fa9a714813671d1 Mon Sep 17 00:00:00 2001 From: abrichr Date: Mon, 19 Oct 2026 15:02:03 -0400 Subject: [PATCH 9/9] Fix CI: mypy in sealed_set, test doubles without encryption hooks, CodeQL 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 --- .gitleaksignore | 4 ++++ .../openadapt_flow/interactive_recorder.py | 10 +++++++-- packages/flow/openadapt_flow/recorder.py | 7 ++++--- packages/flow/openadapt_flow/sealed_set.py | 21 ++++++++++++------- 4 files changed, 29 insertions(+), 13 deletions(-) diff --git a/.gitleaksignore b/.gitleaksignore index bbaa1a008..34444c886 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -48,3 +48,7 @@ eb78575c688f06d7edc04afc69389aad10e1f5fa:packages/flow/tests/fixtures/terminal_v # branch commit that added them. The lines carry gitleaks:allow from the next commit on. 3f0df2191a649540e79a621ac5406ebe2ff228e2:packages/flow/openadapt_flow/jit/authority.py:generic-api-key:101 3f0df2191a649540e79a621ac5406ebe2ff228e2:packages/flow/openadapt_flow/jit/authority.py:generic-api-key:390 +# PR #1354: false positive on the code `Workflow.load(source_path, key=key, +# verify_integrity=True)` in bundle_sealing.py; no secret (the key comes from +# OPENADAPT_BUNDLE_KEY at run time). +a94ffbebccba96d9c2f9ca2016413f10c7526ff1:packages/flow/openadapt_flow/bundle_sealing.py:generic-api-key:282 diff --git a/packages/flow/openadapt_flow/interactive_recorder.py b/packages/flow/openadapt_flow/interactive_recorder.py index bac22becd..e7e69fe4c 100644 --- a/packages/flow/openadapt_flow/interactive_recorder.py +++ b/packages/flow/openadapt_flow/interactive_recorder.py @@ -2757,7 +2757,11 @@ def finish(self) -> Path: meta["viewport_mode"] = "per-event" meta["viewport_history"] = list(self._viewport_history) _sealed.write_text(meta_path, json.dumps(meta, indent=2)) - self.recorder.finalize_encryption() + # A test double may stand in for the recorder; only the real one + # can hold an encrypted recording. + finalize = getattr(self.recorder, "finalize_encryption", None) + if callable(finalize): + finalize() if self._listener_error is not None: raise self._listener_error self._assert_no_new_pages() @@ -2775,7 +2779,9 @@ def abort(self) -> None: self.done = True self._pyq.clear() if self.recorder is not None: - self.recorder.abandon_encryption() + abandon = getattr(self.recorder, "abandon_encryption", None) + if callable(abandon): + abandon() try: self._cleanup_page_listeners() finally: diff --git a/packages/flow/openadapt_flow/recorder.py b/packages/flow/openadapt_flow/recorder.py index 899e8f1bd..3d8dd8994 100644 --- a/packages/flow/openadapt_flow/recorder.py +++ b/packages/flow/openadapt_flow/recorder.py @@ -194,7 +194,8 @@ def __init__( self._settle_stable_frames = settle_stable_frames self._settle_timeout_s = settle_timeout_s self._params: dict[str, str] = {} - self._secret_params: set[str] = set() + # Names of parameters declared secret (never their values). + self._withheld_param_names: set[str] = set() self._i = 0 self._t0 = time.monotonic() @@ -340,7 +341,7 @@ def finish(self) -> Path: "coordinate_space": "page", "app_url": self._app_url, "params": dict(self._params), - "secret_params": sorted(self._secret_params), + "secret_params": sorted(self._withheld_param_names), } if self._network_capture is not None: from openadapt_flow.network_capture import NETWORK_FILE @@ -796,7 +797,7 @@ def record_observed( if secret: event["secret"] = True event.pop("text", None) - self._secret_params.add(param) + self._withheld_param_names.add(param) else: self._params[param] = str(event.get("text", "")) # A caller that already captured the settled after-frame at the right diff --git a/packages/flow/openadapt_flow/sealed_set.py b/packages/flow/openadapt_flow/sealed_set.py index 52a03d8f7..d976dc4a0 100644 --- a/packages/flow/openadapt_flow/sealed_set.py +++ b/packages/flow/openadapt_flow/sealed_set.py @@ -45,6 +45,7 @@ import base64 import fnmatch import hashlib +import hmac import io import json import os @@ -584,10 +585,10 @@ def _reconcile_run_files( out[rel] = {"codec": codec, "sha256": _sha256(raw)} for rel, entry in out.items(): if entry["codec"] != "set": - physical = root / rel - if physical.is_file(): + owned = root / rel + if owned.is_file(): # Owned and authenticated by its own store; track its bytes. - entry["sha256"] = _sha256(physical.read_bytes()) + entry["sha256"] = _sha256(owned.read_bytes()) return out @@ -801,7 +802,7 @@ def finalize(self, *, adopt_external: bool = False) -> str: # write through it. _WRITERS: dict[str, SealedSetWriter] = {} -_OPENED: dict[tuple[str, str], SealedSet] = {} +_OPENED: dict[tuple[str, str], tuple[str, SealedSet]] = {} _STATE_LOCK = threading.RLock() @@ -875,14 +876,18 @@ def open_set(root: Path | str, key: Optional[str] = None) -> SealedSet: base = _abs(root) header = read_header(base) passphrase = _require_passphrase(Path(base), str(header["kind"]), key) - cache_key = (base, _sha256(passphrase.encode("utf-8")) + str(header.get("index"))) + # Keyed by the sealed index, never by any digest of the passphrase; a + # cached set is reused only for the same passphrase (constant-time check). + cache_key = (base, str(header.get("index"))) with _STATE_LOCK: cached = _OPENED.get(cache_key) - if cached is not None: - return cached + if cached is not None and hmac.compare_digest( + cached[0].encode("utf-8"), passphrase.encode("utf-8") + ): + return cached[1] opened = SealedSet.open(base, passphrase) with _STATE_LOCK: - _OPENED[cache_key] = opened + _OPENED[cache_key] = (passphrase, opened) return opened