From 1191e11c0b622e7acb70abf4be2a250dd0256468 Mon Sep 17 00:00:00 2001 From: abrichr Date: Tue, 3 Nov 2026 13:27:29 -0500 Subject: [PATCH 01/21] feat: encrypt recordings, bundles and checkpoints at rest by default With no key configured, Flow generates a local key (0600) and encrypts web recordings, compiled and induced bundles (also from a plaintext recording) and durable checkpoints. OPENADAPT_FLOW_PLAINTEXT_AT_REST opts out, with a warning and a ledger line. OPENADAPT_FLOW_AT_REST=strict encrypts every run directory and refuses what can't be encrypted yet; outside strict mode those writes are named on stderr and in the ledger, and Desktop shows a notice. Compile-agent screenshots are redacted before they reach the model, and heal artifacts go through the sealed-set helpers. Remaining work: #1751. --- CAPABILITIES.md | 1 + llms-full.txt | 1 + packages/agent/tests/conftest.py | 14 + .../openadapt_desktop/at_rest_notice.py | 74 +++++ .../desktop/openadapt_desktop/dispatch.py | 8 + .../desktop/openadapt_desktop/flow_bridge.py | 7 +- .../desktop/src/App.atRestNotice.test.tsx | 62 ++++ packages/desktop/src/App.tsx | 37 +++ packages/desktop/src/lib/engine.ts | 1 + packages/desktop/tests/conftest.py | 14 + .../tests/test_engine/test_at_rest_notice.py | 43 +++ .../tests/test_engine/test_dispatch.py | 1 + packages/flow/docs/PRIVACY.md | 15 +- packages/flow/docs/phi_at_rest.md | 56 +++- packages/flow/openadapt_flow/__main__.py | 139 ++++++-- .../approval_mode/item_approval.py | 5 + packages/flow/openadapt_flow/at_rest.py | 310 ++++++++++++++++++ .../openadapt_flow/compile_agent/agents.py | 12 +- .../openadapt_flow/compile_agent/harness.py | 12 +- .../flow/openadapt_flow/compiler/compile.py | 21 +- packages/flow/openadapt_flow/crypto.py | 20 +- packages/flow/openadapt_flow/privacy.py | 37 ++- .../openadapt_flow/runtime/composition.py | 3 + packages/flow/openadapt_flow/runtime/heal.py | 12 +- .../runtime/healing/pipeline.py | 4 +- .../flow/openadapt_flow/runtime/process_v1.py | 3 + .../flow/openadapt_flow/runtime/replayer.py | 36 +- .../openadapt_flow/runtime/variant_router.py | 3 + .../openadapt_flow/runtime/worklist_pool.py | 3 + packages/flow/tests/conftest.py | 7 + packages/flow/tests/test_at_rest_default.py | 277 ++++++++++++++++ tests/conftest.py | 14 + 32 files changed, 1168 insertions(+), 84 deletions(-) create mode 100644 packages/desktop/openadapt_desktop/at_rest_notice.py create mode 100644 packages/desktop/src/App.atRestNotice.test.tsx create mode 100644 packages/desktop/tests/test_engine/test_at_rest_notice.py create mode 100644 packages/flow/openadapt_flow/at_rest.py create mode 100644 packages/flow/tests/test_at_rest_default.py diff --git a/CAPABILITIES.md b/CAPABILITIES.md index 1f9d0d7c2d..23736b547c 100644 --- a/CAPABILITIES.md +++ b/CAPABILITIES.md @@ -302,6 +302,7 @@ improvising or retrying when something is off. | See a compiled program as a map (every step, exact edge, loop and stop rule, with each step's checks) and play a recorded run over it; one React package that Desktop, app.openadapt.ai and openadapt.ai share | On main ([packages/program-graph](packages/program-graph/README.md)) | Real compiled OpenEMR referral program and a recorded scoreboard run, both on synthetic data | `@openadapt/program-graph`; `openadapt-flow visualize --format json` | | Named sections for every compiled program ("Who: title, first name, last name", "Contact", "Create New Patient"), found without a model from the save, the recorded form structure, the fields and any narration; a person can rename, merge or split them on the review screen | On main | Unit tests; the certified OpenEMR referral bundle and a real recording of it on a local test copy with made-up patients | `openadapt-flow sections`; [SECTIONS.md](packages/flow/docs/SECTIONS.md) | | 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 | On main | 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) | +| Encrypt at rest by default: with no key configured, a locally generated key (0600) encrypts web recordings, compiled and induced bundles (also from a plaintext recording) and durable checkpoints. Artifacts that can't be encrypted yet (run directories by default, desktop recordings, approval requests, `visualize`/`explain`/`teach`/`emit-skill`/`push` output) are named on stderr and in a ledger that Desktop shows; `OPENADAPT_FLOW_AT_REST=strict` refuses them and encrypts every run directory. `OPENADAPT_FLOW_PLAINTEXT_AT_REST` opts out, recorded. Remaining work: [#1751](https://github.com/OpenAdaptAI/OpenAdapt/issues/1751) | On main | Synthetic (key, opt-out, strict and run-directory tests) | `openadapt_flow.at_rest`; [phi_at_rest.md](packages/flow/docs/phi_at_rest.md#default-opt-out-and-strict-mode) | | Signed receipts (Seals), countersigned and timestamped, offline by default | Released | Unit tests, real TSA tokens | `openadapt-flow seal`, `countersign`, `local-seal` | | Offline Seals signed with a key the customer holds: a key file, the OS keychain, or a PKCS#11 token or HSM; pinned keys with rotation and revocation; no fallback to a generated key | On main ([#1392](https://github.com/OpenAdaptAI/OpenAdapt/pull/1392)) | Unit tests with synthetic data (keychain and PKCS#11 through stand-ins) | `openadapt-flow local-seal --key`, `local-seal key create`, `pin`, `revoke`; `openadapt seal verify FOLDER` checks every sealed run in a folder; [OFFLINE_SEALS.md](packages/flow/docs/OFFLINE_SEALS.md#customer-held-keys) | | Anchor an offline Seal in the public Sigstore Rekor transparency log (opt-in; sends one digest and the public key), and verify the anchor offline from the stored inclusion proof | On main ([#1394](https://github.com/OpenAdaptAI/OpenAdapt/pull/1394)) | Unit tests against a simulated Rekor log; real public Rekor entries and Sigstore TSA tokens for the landing demo's referral runs, verified offline in tests; Ed25519ph matches the RFC 8032 test vector | `openadapt-flow local-seal anchor`, `local-seal verify --require-anchor`; [OFFLINE_SEALS.md](packages/flow/docs/OFFLINE_SEALS.md#optional-anchor-a-seal-in-a-public-transparency-log) | diff --git a/llms-full.txt b/llms-full.txt index 46b17ec00a..6976494ea9 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -699,6 +699,7 @@ improvising or retrying when something is off. | See a compiled program as a map (every step, exact edge, loop and stop rule, with each step's checks) and play a recorded run over it; one React package that Desktop, app.openadapt.ai and openadapt.ai share | On main ([packages/program-graph](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/program-graph/README.md)) | Real compiled OpenEMR referral program and a recorded scoreboard run, both on synthetic data | `@openadapt/program-graph`; `openadapt-flow visualize --format json` | | Named sections for every compiled program ("Who: title, first name, last name", "Contact", "Create New Patient"), found without a model from the save, the recorded form structure, the fields and any narration; a person can rename, merge or split them on the review screen | On main | Unit tests; the certified OpenEMR referral bundle and a real recording of it on a local test copy with made-up patients | `openadapt-flow sections`; [SECTIONS.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/SECTIONS.md) | | 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 | On main | 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) | +| Encrypt at rest by default: with no key configured, a locally generated key (0600) encrypts web recordings, compiled and induced bundles (also from a plaintext recording) and durable checkpoints. Artifacts that can't be encrypted yet (run directories by default, desktop recordings, approval requests, `visualize`/`explain`/`teach`/`emit-skill`/`push` output) are named on stderr and in a ledger that Desktop shows; `OPENADAPT_FLOW_AT_REST=strict` refuses them and encrypts every run directory. `OPENADAPT_FLOW_PLAINTEXT_AT_REST` opts out, recorded. Remaining work: [#1751](https://github.com/OpenAdaptAI/OpenAdapt/issues/1751) | On main | Synthetic (key, opt-out, strict and run-directory tests) | `openadapt_flow.at_rest`; [phi_at_rest.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/phi_at_rest.md#default-opt-out-and-strict-mode) | | Signed receipts (Seals), countersigned and timestamped, offline by default | Released | Unit tests, real TSA tokens | `openadapt-flow seal`, `countersign`, `local-seal` | | Offline Seals signed with a key the customer holds: a key file, the OS keychain, or a PKCS#11 token or HSM; pinned keys with rotation and revocation; no fallback to a generated key | On main ([#1392](https://github.com/OpenAdaptAI/OpenAdapt/pull/1392)) | Unit tests with synthetic data (keychain and PKCS#11 through stand-ins) | `openadapt-flow local-seal --key`, `local-seal key create`, `pin`, `revoke`; `openadapt seal verify FOLDER` checks every sealed run in a folder; [OFFLINE_SEALS.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/OFFLINE_SEALS.md#customer-held-keys) | | Anchor an offline Seal in the public Sigstore Rekor transparency log (opt-in; sends one digest and the public key), and verify the anchor offline from the stored inclusion proof | On main ([#1394](https://github.com/OpenAdaptAI/OpenAdapt/pull/1394)) | Unit tests against a simulated Rekor log; real public Rekor entries and Sigstore TSA tokens for the landing demo's referral runs, verified offline in tests; Ed25519ph matches the RFC 8032 test vector | `openadapt-flow local-seal anchor`, `local-seal verify --require-anchor`; [OFFLINE_SEALS.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/OFFLINE_SEALS.md#optional-anchor-a-seal-in-a-public-transparency-log) | diff --git a/packages/agent/tests/conftest.py b/packages/agent/tests/conftest.py index 08c0335675..efa6bc99f7 100644 --- a/packages/agent/tests/conftest.py +++ b/packages/agent/tests/conftest.py @@ -184,3 +184,17 @@ def _isolated_seal_keys(tmp_path_factory, monkeypatch): "openadapt_agent.seal.default_keys_path", lambda: tmp_path_factory.mktemp("home") / ".openadapt" / "seal-keys.json", ) + + +@pytest.fixture(autouse=True) +def _isolated_flow_at_rest(tmp_path_factory, monkeypatch): + """Keep Flow's at-rest key and plaintext ledger out of the real home. + + Flow encrypts at rest by default (openadapt_flow.at_rest); these tests + use its recorded opt-out, as the Flow suite does. + """ + monkeypatch.setenv( + "OPENADAPT_FLOW_AT_REST_HOME", str(tmp_path_factory.mktemp("at-rest-home")) + ) + monkeypatch.setenv("OPENADAPT_FLOW_PLAINTEXT_AT_REST", "test suite: synthetic data") + monkeypatch.delenv("OPENADAPT_FLOW_AT_REST", raising=False) diff --git a/packages/desktop/openadapt_desktop/at_rest_notice.py b/packages/desktop/openadapt_desktop/at_rest_notice.py new file mode 100644 index 0000000000..e918c3731c --- /dev/null +++ b/packages/desktop/openadapt_desktop/at_rest_notice.py @@ -0,0 +1,74 @@ +"""What Desktop tells the person about patient data stored unencrypted. + +Flow encrypts recordings, bundles and checkpoints by default. A few artifacts +can't be encrypted yet (run directories outside strict mode, desktop +recordings, approval requests, ``visualize`` and similar output), and Desktop +runs Flow under the recorded plaintext opt-out while its own per-workflow +keychain encryption covers certified workflows. Every such plaintext write +is a line in Flow's ledger, ``plaintext-at-rest.jsonl``. This module reads +that ledger so the window can show a notice, not only a line on stderr. + +It reads the file directly, with the same location rule as +``openadapt_flow.at_rest``, so it works with any installed Flow version. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any + +LEDGER_NAME = "plaintext-at-rest.jsonl" +OPT_OUT_ENV = "OPENADAPT_FLOW_PLAINTEXT_AT_REST" +MODE_ENV = "OPENADAPT_FLOW_AT_REST" +DESKTOP_OPT_OUT_REASON = ( + "OpenAdapt Desktop: certified workflows are encrypted with per-workflow " + "keys in the system keychain; Flow's default encryption is not wired into " + "Desktop yet" +) + + +def ledger_path(env: dict[str, str] | None = None) -> Path: + env = dict(os.environ) if env is None else env + root = env.get("OPENADAPT_FLOW_AT_REST_HOME") or env.get("OPENADAPT_HOME") + return (Path(root) if root else Path.home() / ".openadapt") / LEDGER_NAME + + +def strict(env: dict[str, str] | None = None) -> bool: + env = dict(os.environ) if env is None else env + return (env.get(MODE_ENV) or "").strip().lower() == "strict" + + +def flow_env(env: dict[str, str]) -> dict[str, str]: + """The Flow subprocess environment, with Desktop's recorded opt-out. + + Strict mode and an opt-out the person set themselves are left alone. + """ + if not strict(env) and not (env.get(OPT_OUT_ENV) or "").strip(): + env[OPT_OUT_ENV] = DESKTOP_OPT_OUT_REASON + return env + + +def status(env: dict[str, str] | None = None, *, limit: int = 200) -> dict[str, Any]: + """Counts and the latest unencrypted write, for the window's notice.""" + entries: list[dict[str, Any]] = [] + try: + lines = ledger_path(env).read_text(encoding="utf-8").splitlines()[-limit:] + except OSError: + lines = [] + for line in lines: + try: + entry = json.loads(line) + except ValueError: + continue + if isinstance(entry, dict): + entries.append(entry) + latest = entries[-1] if entries else {} + return { + "strict": strict(env), + "unencrypted_writes": len(entries), + "latest_purpose": str(latest.get("purpose") or ""), + "latest_at": latest.get("recorded_at"), + "ledger": str(ledger_path(env)), + } diff --git a/packages/desktop/openadapt_desktop/dispatch.py b/packages/desktop/openadapt_desktop/dispatch.py index d16e83d758..1d09ea6013 100644 --- a/packages/desktop/openadapt_desktop/dispatch.py +++ b/packages/desktop/openadapt_desktop/dispatch.py @@ -427,6 +427,8 @@ def _register(self) -> None: "shadow_set_work_domains": self.shadow_set_work_domains, "shadow_decide": self.shadow_decide, "shadow_scan": self.shadow_scan, + # Unencrypted patient data on this computer (Flow's ledger). + "at_rest_status": self.at_rest_status, } # ------------------------------------------------------- shadow mode @@ -6235,6 +6237,12 @@ def _runner_service(self) -> Any: self.services.runner = RunnerService(self.config, self.services, emit=self.emit) return self.services.runner + def at_rest_status(self, **params: Any) -> dict: + """How many writes Flow recorded as unencrypted, for the notice.""" + from openadapt_desktop.at_rest_notice import status + + return status() + def runner_status(self, **params: Any) -> dict: """Return the ``RunnerStatus``-shaped dict for the Runner screen.""" return self._runner_service().status() diff --git a/packages/desktop/openadapt_desktop/flow_bridge.py b/packages/desktop/openadapt_desktop/flow_bridge.py index 7b8cb748a9..73b4daa35c 100644 --- a/packages/desktop/openadapt_desktop/flow_bridge.py +++ b/packages/desktop/openadapt_desktop/flow_bridge.py @@ -549,7 +549,12 @@ def _subprocess_env() -> dict[str, str]: # commands from --approver-root. Drop any inherited value so no Flow # version or helper can read one. env.pop("OPENADAPT_APPROVER_ROOT", None) - return env + # Flow encrypts at rest by default; Desktop's own encryption covers + # certified workflows, so Flow runs under its recorded opt-out here, and + # each plaintext write it records shows as a notice in the window. + from openadapt_desktop.at_rest_notice import flow_env + + return flow_env(env) def _safe_command_for_log(cmd: list[str]) -> str: diff --git a/packages/desktop/src/App.atRestNotice.test.tsx b/packages/desktop/src/App.atRestNotice.test.tsx new file mode 100644 index 0000000000..f7ee14c353 --- /dev/null +++ b/packages/desktop/src/App.atRestNotice.test.tsx @@ -0,0 +1,62 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; + +// Flow records each write of patient data it can't encrypt yet in a local +// ledger; the window must say so (openadapt_desktop/at_rest_notice.py). + +vi.mock("./lib/engine", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + engineTry: vi.fn(), + onEngineEvent: vi.fn(() => Promise.resolve(() => {})), + sidecarRunning: vi.fn(async () => true), + }; +}); + +vi.mock("./screens/Welcome", () => ({ + Welcome: () =>
Welcome screen
, +})); + +import App from "./App"; +import { CMD, engineTry } from "./lib/engine"; + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +function engineWith(atRest: Record | null) { + vi.mocked(engineTry).mockImplementation(async (command, _params, fallback) => { + if (command === CMD.GET_AUTH_STATUS) return { authenticated: true }; + if (command === CMD.GET_WORKFLOWS) return []; + if (command === CMD.GET_FIRST_WORKFLOW_STATE) return { ok: true, state: null }; + if (command === CMD.GET_NEEDS_ATTENTION) { + return { count: 0, open_halts: 0, failed_runs: 0 }; + } + if (command === CMD.GET_SYNC_STATE) return { state: "synced", queued: 0 }; + if (command === CMD.AUTHORING_STATUS) return { status: "idle" }; + if (command === CMD.AT_REST_STATUS) return atRest; + return fallback ?? null; + }); +} + +it("names unencrypted writes and where they are listed", async () => { + engineWith({ + strict: false, + unencrypted_writes: 3, + latest_purpose: "a run directory at /tmp/run", + ledger: "/Users/me/.openadapt/plaintext-at-rest.jsonl", + }); + render(); + const notice = await screen.findByText(/without encryption/); + expect(notice.textContent).toContain("3 recordings or run records"); + expect(notice.textContent).toContain("/Users/me/.openadapt/plaintext-at-rest.jsonl"); +}); + +it("shows nothing when nothing was written unencrypted", async () => { + engineWith({ strict: false, unencrypted_writes: 0, latest_purpose: "", ledger: "x" }); + render(); + await screen.findByText(/./); + expect(screen.queryByText(/without encryption/)).toBeNull(); +}); diff --git a/packages/desktop/src/App.tsx b/packages/desktop/src/App.tsx index bb0bfbc9f2..430cc3ee97 100644 --- a/packages/desktop/src/App.tsx +++ b/packages/desktop/src/App.tsx @@ -220,6 +220,13 @@ function DesktopEntryShell({ children }: { children: React.ReactNode }) { ); } +type AtRestStatus = { + strict: boolean; + unencrypted_writes: number; + latest_purpose: string; + ledger: string; +}; + export default function App() { const [auth, setAuth] = useState(null); const [checkedAuth, setCheckedAuth] = useState(false); @@ -257,6 +264,7 @@ export default function App() { allowed?: boolean; coach_only?: boolean; } | null>(null); + const [atRest, setAtRest] = useState(null); const [authoringUrl, setAuthoringUrl] = useState(""); const [firstRunPersistencePending, setFirstRunPersistencePending] = useState(false); @@ -384,6 +392,9 @@ export default function App() { if (authoringStatus?.status && authoringStatus.status !== "idle") { setAuthoring(authoringStatus); } + setAtRest( + await engineTry(CMD.AT_REST_STATUS, {}, null), + ); } finally { setCheckedAuth(true); } @@ -490,6 +501,23 @@ export default function App() { )} ) : null; + // Flow records every write of patient data it can't encrypt yet; say so in + // the window, not only on stderr (openadapt_desktop/at_rest_notice.py). + const atRestNotice = + atRest && atRest.unencrypted_writes > 0 ? ( +
+ + {`OpenAdapt stored ${atRest.unencrypted_writes === 1 ? "1 recording or run record" : `${atRest.unencrypted_writes} recordings or run records`} on this computer without encryption. Keep full-disk encryption on. The list is in ${atRest.ledger}.`} + + +
+ ) : null; const authoringNotice = authoring && (authoring.status === "pending_allow" || @@ -729,6 +757,8 @@ export default function App() { <> {pairingNotice} {authoringNotice} + {atRestNotice} + {atRestNotice} {firstWorkflowStageNotice}
Loading…
@@ -742,6 +772,8 @@ export default function App() { <> {pairingNotice} {authoringNotice} + {atRestNotice} + {atRestNotice} {firstWorkflowStageNotice} {pairingNotice} {authoringNotice} + {atRestNotice} + {atRestNotice} {firstWorkflowStageNotice} void retryEntry()} /> @@ -779,6 +813,8 @@ export default function App() { <> {pairingNotice} {authoringNotice} + {atRestNotice} + {atRestNotice} {firstWorkflowStageNotice} {pairingNotice} {authoringNotice} + {atRestNotice} {firstWorkflowStageNotice}
diff --git a/packages/desktop/src/lib/engine.ts b/packages/desktop/src/lib/engine.ts index 2a03af3b78..b7db543120 100644 --- a/packages/desktop/src/lib/engine.ts +++ b/packages/desktop/src/lib/engine.ts @@ -104,6 +104,7 @@ export const CMD = { AUTHORING_ALLOW: "authoring_allow", AUTHORING_DENY: "authoring_deny", AUTHORING_STATUS: "authoring_status", + AT_REST_STATUS: "at_rest_status", AUTHORING_PIN_TARGET: "authoring_pin_target", // config / settings (lane, phi_mode, hosted host) GET_CONFIG: "get_config", diff --git a/packages/desktop/tests/conftest.py b/packages/desktop/tests/conftest.py index 06a0a946c1..1c3ba9418a 100644 --- a/packages/desktop/tests/conftest.py +++ b/packages/desktop/tests/conftest.py @@ -110,3 +110,17 @@ def sample_capture_dir(tmp_data_dir: Path) -> Path: (capture_dir / "meta.json").write_text(json.dumps(meta)) return capture_dir + + +@pytest.fixture(autouse=True) +def _isolated_flow_at_rest(tmp_path_factory, monkeypatch): + """Keep Flow's at-rest key and plaintext ledger out of the real home. + + Flow encrypts at rest by default (openadapt_flow.at_rest); these tests + use its recorded opt-out, as the Flow suite does. + """ + monkeypatch.setenv( + "OPENADAPT_FLOW_AT_REST_HOME", str(tmp_path_factory.mktemp("at-rest-home")) + ) + monkeypatch.setenv("OPENADAPT_FLOW_PLAINTEXT_AT_REST", "test suite: synthetic data") + monkeypatch.delenv("OPENADAPT_FLOW_AT_REST", raising=False) diff --git a/packages/desktop/tests/test_engine/test_at_rest_notice.py b/packages/desktop/tests/test_engine/test_at_rest_notice.py new file mode 100644 index 0000000000..2dd03a4d53 --- /dev/null +++ b/packages/desktop/tests/test_engine/test_at_rest_notice.py @@ -0,0 +1,43 @@ +"""Desktop reads Flow's plaintext-at-rest ledger for its notice.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from openadapt_desktop import at_rest_notice + + +def test_status_counts_ledger_lines(tmp_path: Path) -> None: + env = {"OPENADAPT_FLOW_AT_REST_HOME": str(tmp_path)} + assert at_rest_notice.status(env)["unencrypted_writes"] == 0 + ledger = tmp_path / "plaintext-at-rest.jsonl" + ledger.write_text( + json.dumps({"purpose": "a run directory at /r1", "recorded_at": 1}) + + "\nnot json\n" + + json.dumps({"purpose": "a windows recording at /rec", "recorded_at": 2}) + + "\n" + ) + got = at_rest_notice.status(env) + assert got["unencrypted_writes"] == 2 + assert got["latest_purpose"] == "a windows recording at /rec" + assert got["ledger"] == str(ledger) + assert got["strict"] is False + + +def test_flow_env_adds_the_recorded_opt_out_except_in_strict_mode() -> None: + assert at_rest_notice.flow_env({})[at_rest_notice.OPT_OUT_ENV] + mine = {at_rest_notice.OPT_OUT_ENV: "my reason"} + assert at_rest_notice.flow_env(mine)[at_rest_notice.OPT_OUT_ENV] == "my reason" + strict = at_rest_notice.flow_env({at_rest_notice.MODE_ENV: "strict"}) + assert at_rest_notice.OPT_OUT_ENV not in strict + + +def test_flow_subprocess_env_carries_the_opt_out(monkeypatch) -> None: + from openadapt_desktop.flow_bridge import _subprocess_env + + monkeypatch.delenv(at_rest_notice.OPT_OUT_ENV, raising=False) + monkeypatch.delenv(at_rest_notice.MODE_ENV, raising=False) + assert _subprocess_env()[at_rest_notice.OPT_OUT_ENV] == ( + at_rest_notice.DESKTOP_OPT_OUT_REASON + ) diff --git a/packages/desktop/tests/test_engine/test_dispatch.py b/packages/desktop/tests/test_engine/test_dispatch.py index 72a6f26f5a..96b1a44db6 100644 --- a/packages/desktop/tests/test_engine/test_dispatch.py +++ b/packages/desktop/tests/test_engine/test_dispatch.py @@ -2865,6 +2865,7 @@ def test_all_frontend_commands_registered(self, deps) -> None: "shadow_turn_off", "shadow_pause", "shadow_unpause", + "at_rest_status", } assert expected.issubset(set(disp.commands)) diff --git a/packages/flow/docs/PRIVACY.md b/packages/flow/docs/PRIVACY.md index 1dfb06b1d4..7f36dacbe2 100644 --- a/packages/flow/docs/PRIVACY.md +++ b/packages/flow/docs/PRIVACY.md @@ -53,10 +53,17 @@ Paths this rule does not cover yet are listed under ## Local posture -Two environment variables control what is written locally. The default is -**predictable, not maximally private**: `auto` writes plaintext when the extra is absent (so -the local demo works with no NER model), but it is not silent about it — see -the plaintext warning below. Pin `on` for a regulated deployment. +Scrubbing and encryption are separate controls. Encryption at rest is on by +default: recordings, compiled bundles and durable checkpoints are encrypted +under a configured key or a locally generated one, and +`OPENADAPT_FLOW_AT_REST=strict` refuses every artifact that can't be +encrypted yet ([phi_at_rest.md](phi_at_rest.md#default-opt-out-and-strict-mode)). + +Two environment variables control scrubbing of what is written locally. The +default is **predictable, not maximally private**: `auto` writes unscrubbed +text when the extra is absent (so the local demo works with no NER model), +but it is not silent about it: a warning goes to stderr once per process, +and see the plaintext warning below. Pin `on` for a regulated deployment. | Variable | Values | Default | Meaning | |---|---|---|---| diff --git a/packages/flow/docs/phi_at_rest.md b/packages/flow/docs/phi_at_rest.md index aa6dd7d295..33a163ee31 100644 --- a/packages/flow/docs/phi_at_rest.md +++ b/packages/flow/docs/phi_at_rest.md @@ -11,6 +11,49 @@ 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). +## Default, opt-out and strict mode + +Encryption at rest is the default. When no key is configured, Flow creates +one on first use at `~/.openadapt/keys/at-rest.key` (`$OPENADAPT_HOME`, or +`$OPENADAPT_FLOW_AT_REST_HOME`, moves it), mode 0600 in a 0700 directory, and +refuses it if anyone else can read it. Back it up: what it encrypts can't be +read without it. A configured `OPENADAPT_BUNDLE_KEY` or +`OPENADAPT_RECORDING_KEY` always wins. + +| | Default | `OPENADAPT_FLOW_PLAINTEXT_AT_REST=''` | `OPENADAPT_FLOW_AT_REST=strict` | +| --- | --- | --- | --- | +| Web recordings | Encrypted | Plaintext | Encrypted | +| Bundles from `compile`, `induce`, `admit` | Encrypted, also from a plaintext recording | Plaintext | Encrypted | +| Durable checkpoints | Encrypted | Plaintext | Encrypted | +| Run directories | Plaintext, named | Plaintext | Encrypted, also for a plaintext bundle | +| Desktop recordings | Plaintext, named | Plaintext | Refused | +| Approval requests and frames, attended drafts, business decisions | Plaintext, named | Plaintext | Refused | +| `visualize`, `explain`, `teach`, `emit-skill`, `push`, `compose`, `process`, `variants`, `route`, `--save-healed-to`, `induce --report` | Plaintext, named | Plaintext | Refused before they run | + +**Named** means stderr names the path and one line goes to +`~/.openadapt/plaintext-at-rest.jsonl` (the ledger). OpenAdapt Desktop shows +a notice in its window from the same ledger. + +Run directories stay plaintext by default because the offline Seal, `undo`, +`attach-seal` and Desktop don't read encrypted runs yet. With a configured +key, a sealed bundle's run directory is encrypted as before. Issue +[#1751](https://github.com/OpenAdaptAI/OpenAdapt/issues/1751) tracks every +artifact that can't be encrypted yet. When the last one can, strict becomes +the default. + +The opt-out is for development and already de-identified data. Each process +that uses it prints a warning and adds a ledger line with the reason; +`record --no-encrypt` does the same for one web recording. Strict mode +ignores the opt-out, and an unknown value of `OPENADAPT_FLOW_AT_REST` stops +the command. + +**Healthcare and other regulated deployments:** set +`OPENADAPT_FLOW_AT_REST=strict` and a key from your secret manager. + +OpenAdapt Desktop runs Flow under the opt-out, recorded with its own reason: +it encrypts certified workflows with per-workflow keys in the system +keychain, and its other files follow the table's opt-out column. + ## Summary With a key configured, these are ciphertext on disk: @@ -139,13 +182,13 @@ complete ciphertext. The recording is finalized (indexed) when it finishes. `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 +Web recordings are encrypted by default, under the configured key or the +local one. `--no-encrypt` opts out for one recording, with a warning and a +ledger line; strict mode refuses it. 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. +By default they record in plaintext and name the directory; with +`OPENADAPT_RECORDING_KEY` set, or in strict mode, they refuse. ### Cost @@ -197,6 +240,9 @@ that lists the commands above, rather than decrypt to disk. A bare ## What is not encrypted +Everything in the [default table](#default-opt-out-and-strict-mode) marked +"Plaintext, named", outside strict mode. Also: + - 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. diff --git a/packages/flow/openadapt_flow/__main__.py b/packages/flow/openadapt_flow/__main__.py index bb52425ba8..aaabc96495 100644 --- a/packages/flow/openadapt_flow/__main__.py +++ b/packages/flow/openadapt_flow/__main__.py @@ -644,33 +644,45 @@ def _enforce_surface_binding( 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. + The web recorder encrypts every file from the first frame under + ``OPENADAPT_RECORDING_KEY``, else ``OPENADAPT_BUNDLE_KEY``, else the local + key (:mod:`openadapt_flow.at_rest`). ``--no-encrypt`` and + ``OPENADAPT_FLOW_PLAINTEXT_AT_REST`` opt out, with a warning and a ledger + line. The desktop recorders write a raw capture session through + openadapt-capture that Flow cannot encrypt yet: by default they record in + plaintext and name the directory on stderr and in the ledger; + ``OPENADAPT_FLOW_AT_REST=strict`` and a configured recording key refuse. """ import os - from openadapt_flow import crypto, sealed_set + from openadapt_flow import at_rest, 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) + out = getattr(args, "out", None) or "the recording directory" + try: + if getattr(args, "no_encrypt", False): + if at_rest.strict(): + at_rest.unsealed_write("a recording (--no-encrypt)", out) + at_rest.record_plaintext(f"a recording at {out}", "record --no-encrypt") + 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." + ) + if not at_rest.plaintext_allowed(f"a desktop recording at {out}"): + at_rest.unsealed_write(f"a {backend} recording", out) + return None + except at_rest.AtRestRefused as exc: + raise SystemExit(f"record: {exc}") from exc + key = explicit or crypto.resolve_key(None) + if key is None: + at_rest.plaintext_allowed("a recording") + return key def _stamp_recording_surface(recording_dir: Path, surface: str) -> None: @@ -2165,12 +2177,19 @@ def _cmd_induce(args: argparse.Namespace) -> int: traces.append(Workflow.load(path)) else: traces.append(path) - if sealed_inputs and (args.report or args.probe_on): + from openadapt_flow import at_rest + + # Encrypted demonstrations induce an encrypted program, and so do + # plaintext ones unless plaintext at rest is explicitly allowed. + seal_output = sealed_inputs or not at_rest.plaintext_allowed("an induced program") + if seal_output 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." + "induce REFUSED: --report and --probe-on write outside an " + "encrypted bundle, so they are not available while Flow encrypts " + f"at rest. Set {at_rest.OPT_OUT_ENV}='' to allow plaintext. " + "Nothing was written." ) return 2 @@ -2207,10 +2226,9 @@ def _cmd_induce(args: argparse.Namespace) -> int: if args.name: workflow = workflow.model_copy(update={"name": args.name}) out = Path(args.out) - if sealed_inputs: - # Encrypted demonstrations induce an encrypted program. + if seal_output: passphrase = sealed_set.passphrase_for("bundle") or sealed_set.recording_key() - assert passphrase is not None # the CLI guard refused a missing key + assert passphrase is not None # resolves to the local key by default with sealed_set.writing(out, "bundle", passphrase): workflow.save(out) else: @@ -8703,9 +8721,10 @@ def build_parser() -> argparse.ArgumentParser: "--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)" + "Write a web recording in plaintext (recorded in " + "~/.openadapt/plaintext-at-rest.jsonl). Web recordings are " + "encrypted by default, under the configured key or a local one; " + "refused under OPENADAPT_FLOW_AT_REST=strict" ), ) p.add_argument( @@ -12571,6 +12590,50 @@ def _qualify_default_form(argv: list[str]) -> list[str]: ) +#: Commands whose output can't be encrypted yet (docs/phi_at_rest.md): by +#: default they write it in plaintext and name it on stderr and in the ledger; +#: OPENADAPT_FLOW_AT_REST=strict refuses them before they run. +_UNSEALED_OUTPUT_COMMANDS: dict[str, str] = { + "openadapt_flow.__main__:_cmd_visualize": "the visualize page", + "openadapt_flow.__main__:_cmd_visualize_process": "the visualize page", + "openadapt_flow.__main__:_cmd_explain": "the explain output", + "openadapt_flow.__main__:_cmd_teach": "the teach output", + "openadapt_flow.__main__:_cmd_emit_skill": "the emitted skill", + "openadapt_flow.__main__:_cmd_push": "the push upload copy", + # Their parent reports (composition, variant, process) can't be sealed + # yet and are written after records change, so strict refuses up front. + "openadapt_flow.__main__:_cmd_compose": "the composition report", + "openadapt_flow.__main__:_cmd_process": "the process report", + "openadapt_flow.__main__:_cmd_variants": "the variant report", + "openadapt_flow.__main__:_cmd_route": "the variant report", +} + + +def _unsealed_output_check(args: argparse.Namespace) -> None: + """Warn and record, or refuse in strict mode, a command whose output + can't be encrypted yet. Also rejects a mistyped OPENADAPT_FLOW_AT_REST.""" + from openadapt_flow import at_rest + + at_rest.strict() # raises on an unknown mode before anything runs + healed = getattr(args, "save_healed_to", None) + if healed and at_rest.strict(): + at_rest.unsealed_write("a healed bundle (--save-healed-to)", healed) + func = getattr(args, "func", None) + command = f"{getattr(func, '__module__', '')}:{getattr(func, '__name__', '')}" + artifact = _UNSEALED_OUTPUT_COMMANDS.get(command) + if artifact is None: + return + target = next( + ( + getattr(args, name) + for name in ("out", "output", "out_dir", "dest") + if isinstance(getattr(args, name, None), (str, Path)) + ), + "its output", + ) + at_rest.unsealed_write(artifact, target) + + def _sealed_paths_in(args: argparse.Namespace) -> list[Path]: from openadapt_flow import sealed_set @@ -12670,6 +12733,13 @@ def main(argv: Sequence[str] | None = None) -> int: if refusal is not None: print(f"REFUSED: {refusal}", file=sys.stderr) return 2 + from openadapt_flow import at_rest + + try: + _unsealed_output_check(args) + except (at_rest.AtRestRefused, ValueError) as exc: + print(f"REFUSED: {exc}", file=sys.stderr) + return 2 try: code = args.func(args) if getattr(args, "command", None) in ("run", "replay"): @@ -12682,9 +12752,10 @@ def main(argv: Sequence[str] | None = None) -> int: if note: print(note, file=sys.stderr) return code - except sealed_set.SealedSetError as exc: + except (sealed_set.SealedSetError, at_rest.AtRestRefused) as exc: # A sealed bundle or encrypted recording that is missing its key, - # was changed after sealing, or holds an unknown file: fail closed. + # was changed after sealing, or holds an unknown file, or a write + # strict mode refuses: fail closed. print(f"REFUSED: {exc}", file=sys.stderr) return 2 except BrowserSupportMissing as exc: diff --git a/packages/flow/openadapt_flow/approval_mode/item_approval.py b/packages/flow/openadapt_flow/approval_mode/item_approval.py index 796bafc2da..58cce9463b 100644 --- a/packages/flow/openadapt_flow/approval_mode/item_approval.py +++ b/packages/flow/openadapt_flow/approval_mode/item_approval.py @@ -1030,6 +1030,11 @@ def open_request( else None ), ) + from openadapt_flow import at_rest + + # The approval frame and request can't be encrypted yet: named on + # stderr and in the ledger, or refused under strict mode. + at_rest.unsealed_write("approval requests and frames", self.dir) _write_exclusive(self.dir / f"{request_id}.png", frame_png) _write_exclusive( self.dir / f"request-{request_id}.json", diff --git a/packages/flow/openadapt_flow/at_rest.py b/packages/flow/openadapt_flow/at_rest.py new file mode 100644 index 0000000000..3f6dc99fc9 --- /dev/null +++ b/packages/flow/openadapt_flow/at_rest.py @@ -0,0 +1,310 @@ +"""Encryption at rest by default: a local key, or a loud, recorded opt-out. + +Recordings, bundles, run directories (screenshots, reports, evidence) and +durable checkpoints can hold patient data. Flow encrypts them whenever a key +is configured (:mod:`openadapt_flow.sealed_set`, :mod:`openadapt_flow.crypto`). +This module decides what happens when none is: Flow does not fall back to +plaintext. + +* With ``OPENADAPT_BUNDLE_KEY`` (or ``OPENADAPT_RECORDING_KEY``) set, that key + is used, as before. +* Otherwise Flow uses a key it generated on this machine, + ``$OPENADAPT_HOME/keys/at-rest.key`` (default ``~/.openadapt``; + ``OPENADAPT_FLOW_AT_REST_HOME`` overrides the directory). It is + created on first use with mode 0600 in a 0700 directory, and refused if + anyone but the owner can read it. Losing it loses every artifact sealed + with it, so back it up like any other secret. +* ``OPENADAPT_FLOW_PLAINTEXT_AT_REST=''`` turns encryption off for + development or for data that is already de-identified. Each process that + writes plaintext prints a warning to stderr and appends one line to + ``$OPENADAPT_HOME/plaintext-at-rest.jsonl`` with the reason. + +Some artifacts can't be sealed yet: run directories (the offline Seal, undo +and other readers don't read sealed runs yet), approval and attended state, +desktop recordings, and the output of ``visualize``, ``explain``, ``teach``, +``emit-skill`` and ``push``. By default they are still written in plaintext, +and each write goes through :func:`unsealed_write`, which names the path on +stderr and adds a ledger line that Desktop shows as a notice. +``OPENADAPT_FLOW_AT_REST=strict``, the setting for healthcare and regulated +deployments, refuses them instead, seals every run directory, and ignores the +opt-out. ``docs/phi_at_rest.md`` lists them and links the tracking issue. + +Stdlib only, so :mod:`openadapt_flow.crypto` can import it on every load. +""" + +from __future__ import annotations + +import base64 +import json +import os +import secrets +import stat +import sys +import threading +import time +from pathlib import Path +from typing import Final, Optional + +OPT_OUT_ENV: Final[str] = "OPENADAPT_FLOW_PLAINTEXT_AT_REST" +MODE_ENV: Final[str] = "OPENADAPT_FLOW_AT_REST" +LEDGER_NAME: Final[str] = "plaintext-at-rest.jsonl" +KEY_NAME: Final[str] = "at-rest.key" + +_KEY_BYTES: Final[int] = 32 +_lock = threading.Lock() +_warned: set[str] = set() + + +class LocalKeyError(RuntimeError): + """The local at-rest key is unreadable, unsafe or malformed.""" + + +class AtRestRefused(RuntimeError): + """Strict mode refused a write that can't be sealed yet.""" + + +def strict() -> bool: + """Whether ``OPENADAPT_FLOW_AT_REST=strict``. Any other non-empty value + raises, so a mistyped setting never runs in the weaker default mode.""" + value = (os.environ.get(MODE_ENV) or "").strip().lower() + if value in ("", "default"): + return False + if value == "strict": + return True + raise ValueError(f"{MODE_ENV} takes 'strict' or 'default', not {value!r}") + + +def explicit_key() -> bool: + """Whether the deployment configured a key (rather than the local one).""" + return bool( + os.environ.get("OPENADAPT_BUNDLE_KEY") + or os.environ.get("OPENADAPT_RECORDING_KEY") + ) + + +def unsealed_write(artifact: str, path: Path | str, *, refuse: bool = True) -> None: + """Allow a plaintext write of an artifact Flow can't seal yet, or refuse it. + + Strict mode raises :class:`AtRestRefused`. Otherwise the write goes ahead: + stderr names ``path`` and a ledger line records it, once per path in a + process. + + ``refuse=False`` is for a report written after a run already changed a + record: refusing then would lose the only account of what was written. + It warns and records even in strict mode; the commands that reach these + writers are refused before they run instead. + """ + if refuse and strict(): + raise AtRestRefused( + f"Not writing {artifact} at {path}: it can't be encrypted yet, and " + f"{MODE_ENV}=strict refuses plaintext. Nothing was written." + ) + _record( + f"{artifact} at {path}", + "can't be encrypted yet", + path=str(path), + warning=( + f"WARNING: writing {artifact} in plaintext at {path}: it can't be " + "encrypted yet and can hold patient data. Set " + f"{MODE_ENV}=strict to refuse instead." + ), + ) + + +HOME_ENV: Final[str] = "OPENADAPT_FLOW_AT_REST_HOME" + + +def _home() -> Path: + # Its own override first, so a test suite or a service can keep the key + # and the ledger apart from the rest of the engine's state. + root = os.environ.get(HOME_ENV) or os.environ.get("OPENADAPT_HOME") + return Path(root) if root else Path.home() / ".openadapt" + + +def local_key_path() -> Path: + """Where the locally generated at-rest key lives.""" + return _home() / "keys" / KEY_NAME + + +def recent_plaintext_writes(limit: int = 20) -> list[dict]: + """The last ``limit`` ledger lines, newest last (for the Desktop notice).""" + try: + lines = ledger_path().read_text(encoding="utf-8").splitlines() + except OSError: + return [] + out: list[dict] = [] + for line in lines[-limit:]: + try: + entry = json.loads(line) + except ValueError: + continue + if isinstance(entry, dict): + out.append(entry) + return out + + +def ledger_path() -> Path: + """The record of every process that wrote plaintext under the opt-out.""" + return _home() / LEDGER_NAME + + +def opt_out_reason() -> Optional[str]: + """The reason given in ``OPENADAPT_FLOW_PLAINTEXT_AT_REST``, or None. + + None in strict mode, which ignores the opt-out. + """ + reason = (os.environ.get(OPT_OUT_ENV) or "").strip() + if reason and strict(): + with _lock: + first = "strict-ignores-opt-out" not in _warned + _warned.add("strict-ignores-opt-out") + if first: + print( + f"WARNING: {OPT_OUT_ENV} is ignored because {MODE_ENV}=strict.", + file=sys.stderr, + ) + return None + return reason or None + + +def plaintext_allowed(purpose: str) -> bool: + """Whether ``purpose`` may be written in plaintext, recording it if so. + + True only under the opt-out. The first time each purpose is used in a + process, a warning goes to stderr and a line to :func:`ledger_path`. + """ + reason = opt_out_reason() + if reason is None: + return False + record_plaintext(purpose, reason) + return True + + +def record_plaintext(purpose: str, reason: str) -> None: + """Warn and record that ``purpose`` is written in plaintext for ``reason``. + + Used directly by an explicit per-command opt-out such as + ``record --no-encrypt``. + """ + _record( + purpose, + reason, + warning=( + f"WARNING: writing {purpose} in plaintext at rest ({reason!r}). " + "It can hold patient data; encryption is the default." + ), + ) + + +def _record( + purpose: str, reason: str, *, warning: str, path: Optional[str] = None +) -> None: + with _lock: + if purpose in _warned: + return + _warned.add(purpose) + print(warning, file=sys.stderr) + try: + ledger = ledger_path() + ledger.parent.mkdir(parents=True, exist_ok=True) + line = json.dumps( + { + "schema": "openadapt.flow.plaintext-at-rest/v1", + "recorded_at": time.time(), + "purpose": purpose, + "reason": reason, + "path": path, + "pid": os.getpid(), + }, + sort_keys=True, + ) + fd = os.open(ledger, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600) + try: + os.write(fd, (line + "\n").encode("utf-8")) + finally: + os.close(fd) + except OSError as exc: + print( + f"WARNING: could not record the plaintext opt-out: {exc}", file=sys.stderr + ) + + +def refusal(purpose: str, why: str) -> str: + """The message for a write that would be plaintext and has no opt-out.""" + return ( + f"Not writing {purpose}: {why}, and Flow does not write patient data " + f"in plaintext by default. Set {OPT_OUT_ENV}='' to allow " + "plaintext for development or de-identified data (recorded)." + ) + + +def _check_mode(path: Path) -> None: + if os.name != "posix": + return + mode = path.stat().st_mode + if mode & (stat.S_IRWXG | stat.S_IRWXO): + raise LocalKeyError( + f"{path} is readable by group or others (mode {oct(mode & 0o777)}); " + f"run chmod 600 {path}. Nothing was read or written." + ) + + +def _read(path: Path) -> str: + _check_mode(path) + text = path.read_text(encoding="ascii").strip() + try: + raw = base64.urlsafe_b64decode(text.encode("ascii")) + except (ValueError, UnicodeEncodeError) as exc: + raise LocalKeyError(f"{path} does not hold a Flow at-rest key") from exc + if len(raw) != _KEY_BYTES: + raise LocalKeyError(f"{path} does not hold a Flow at-rest key") + return text + + +def local_key() -> str: + """Return this machine's at-rest key, creating it on first use.""" + path = local_key_path() + with _lock: + if path.exists(): + return _read(path) + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + if os.name == "posix": + os.chmod(path.parent, 0o700) + text = base64.urlsafe_b64encode(secrets.token_bytes(_KEY_BYTES)).decode() + # Write a private temporary file, then link it into place, which + # fails if another process got there first: no reader ever sees a + # half-written key, and no key is ever replaced. + tmp = path.with_name(f".{KEY_NAME}.{os.getpid()}.{secrets.token_hex(4)}") + fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + try: + os.write(fd, (text + "\n").encode("ascii")) + os.fsync(fd) + finally: + os.close(fd) + try: + os.link(tmp, path) + except FileExistsError: + return _read(path) + finally: + tmp.unlink() + print( + f"Created a local encryption key for recordings, bundles and runs " + f"at {path}. Back it up: what it encrypts can't be read without it.", + file=sys.stderr, + ) + return text + + +def default_key() -> Optional[str]: + """The key to use when none is configured: the local key, or None under + the opt-out (plaintext, the behaviour before encryption was the default). + Strict mode ignores the opt-out. + """ + if opt_out_reason() is not None: + return None + return local_key() + + +def _reset_for_tests() -> None: + with _lock: + _warned.clear() diff --git a/packages/flow/openadapt_flow/compile_agent/agents.py b/packages/flow/openadapt_flow/compile_agent/agents.py index 3d5fd1dadc..f56012854b 100644 --- a/packages/flow/openadapt_flow/compile_agent/agents.py +++ b/packages/flow/openadapt_flow/compile_agent/agents.py @@ -353,8 +353,16 @@ def _get(block: Any, name: str, default: Any = None) -> Any: def _image(png: bytes) -> dict[str, Any]: + """An image block for the model. Every screenshot the model sees leaves + the machine, so it is redacted first (or sent raw under the recorded + ``OPENADAPT_FLOW_SEND_UNSCRUBBED`` opt-out); without the privacy extra + this raises and nothing is sent.""" import base64 + from openadapt_flow.privacy import prepare_outbound_image + + png = prepare_outbound_image(png, "a compile-agent screenshot") + return { "type": "image", "source": { @@ -620,9 +628,7 @@ def _computer( ) -> list[dict[str, Any]]: obs = harness.act({"action": name, **args}) harness.stats.screenshots_every_turn += 1 - from openadapt_flow.privacy import scrub_image_bytes - - out: list[dict[str, Any]] = [_image(scrub_image_bytes(obs.png))] + out: list[dict[str, Any]] = [_image(obs.png)] if obs.notes: out.insert(0, {"type": "text", "text": "; ".join(obs.notes)}) return out diff --git a/packages/flow/openadapt_flow/compile_agent/harness.py b/packages/flow/openadapt_flow/compile_agent/harness.py index b1a44b0a1c..1c4b2a3002 100644 --- a/packages/flow/openadapt_flow/compile_agent/harness.py +++ b/packages/flow/openadapt_flow/compile_agent/harness.py @@ -177,12 +177,12 @@ def screenshot(self, why: str = "requested") -> bytes: """A screenshot for the agent's context. ``why`` is ``requested``, ``gate`` or ``every_turn``; each is counted.""" - from openadapt_flow.privacy import scrub_image_bytes - counter = _SCREENSHOT_COUNTERS[why] png = self.computer.screenshot() setattr(self.stats, counter, getattr(self.stats, counter) + 1) - return scrub_image_bytes(png) + # Raw here: the model-facing agent redacts what it sends off the + # machine (compile_agent.agents._image). + return png # -- actions on refs (selective vision) ----------------------------------------- @@ -614,12 +614,12 @@ def observe(self) -> SurfaceObservation: return self._last def screenshot(self, why: str = "requested") -> bytes: - from openadapt_flow.privacy import scrub_image_bytes - counter = _SCREENSHOT_COUNTERS[why] png = self.computer.screenshot() setattr(self.stats, counter, getattr(self.stats, counter) + 1) - return scrub_image_bytes(png) + # Raw here: the model-facing agent redacts what it sends off the + # machine (compile_agent.agents._image). + return png # -- actions -------------------------------------------------------------------- diff --git a/packages/flow/openadapt_flow/compiler/compile.py b/packages/flow/openadapt_flow/compiler/compile.py index bd385d5e2e..c63725362e 100644 --- a/packages/flow/openadapt_flow/compiler/compile.py +++ b/packages/flow/openadapt_flow/compiler/compile.py @@ -2043,15 +2043,25 @@ def compile_recording( 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. + when that is unset, the recording's own key. A plaintext recording + compiles into a sealed bundle too, under the configured or local key + (:mod:`openadapt_flow.at_rest`), unless ``OPENADAPT_FLOW_PLAINTEXT_AT_REST`` + allows plaintext. """ 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): + if _sealed.active_writer(bundle) is not None: return _compile_recording(recording, bundle, name=name, **kwargs) + if not (sealed_input or sealed_output): + from openadapt_flow import at_rest + + # A plaintext recording (one made before encryption was the default, + # or under the opt-out) still compiles to a sealed bundle unless + # plaintext is explicitly allowed. + if at_rest.plaintext_allowed("a compiled bundle"): + 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) @@ -2060,9 +2070,8 @@ def compile_recording( 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" + f"{bundle} already holds an unencrypted bundle; compile 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) diff --git a/packages/flow/openadapt_flow/crypto.py b/packages/flow/openadapt_flow/crypto.py index 84ad957585..4f2f5fcbcc 100644 --- a/packages/flow/openadapt_flow/crypto.py +++ b/packages/flow/openadapt_flow/crypto.py @@ -1,4 +1,4 @@ -"""Opt-in authenticated encryption-at-rest for bundles and durable checkpoints. +"""Authenticated encryption at rest for bundles and durable checkpoints. A compiled bundle (``workflow.json`` + its manifest) and the durable checkpoints written during a run are **persistent records** produced from a @@ -29,8 +29,10 @@ This module is deliberately import-light (stdlib + :mod:`cryptography`) and brings in no OCR / cv2 / model dependencies, so it is safe to import on every -bundle load. Encryption is ALWAYS opt-in: nothing here runs unless a caller -explicitly asks for it, and the default serialization stays plaintext. +bundle load. When no key is configured, :func:`resolve_key` returns the local +key from :mod:`openadapt_flow.at_rest`, so callers that encrypt whenever a key +resolves encrypt by default; ``OPENADAPT_FLOW_PLAINTEXT_AT_REST`` restores the +plaintext default, loudly. """ from __future__ import annotations @@ -98,12 +100,18 @@ class DecryptionError(CryptoError): def resolve_key(key: Optional[str]) -> Optional[str]: """Return the effective passphrase: the explicit ``key`` if non-empty, else - the ``OPENADAPT_BUNDLE_KEY`` environment variable, else ``None`` (no key - configured -- the plaintext default).""" + the ``OPENADAPT_BUNDLE_KEY`` environment variable, else this machine's + local at-rest key (:func:`openadapt_flow.at_rest.default_key`, created on + first use). ``None`` only under ``OPENADAPT_FLOW_PLAINTEXT_AT_REST``, the + recorded plaintext opt-out.""" if key: return key env = os.environ.get(ENV_KEY) - return env or None + if env: + return env + from openadapt_flow import at_rest + + return at_rest.default_key() def require_key(key: Optional[str]) -> str: diff --git a/packages/flow/openadapt_flow/privacy.py b/packages/flow/openadapt_flow/privacy.py index 2a63943e87..253f7dd10e 100644 --- a/packages/flow/openadapt_flow/privacy.py +++ b/packages/flow/openadapt_flow/privacy.py @@ -15,9 +15,11 @@ Posture is controlled by two environment variables, safe by default: * ``OPENADAPT_FLOW_SCRUB`` — ``auto`` (default) | ``on`` | ``off`` - - ``auto``: scrub whenever the capability is installed; if it is not, write - plaintext (keeps the local demo working). Clearly documented in - ``docs/PRIVACY.md``. + - ``auto``: scrub whenever the capability is installed. If it is not, + text is written unscrubbed, with a warning on stderr once per process. + What is written still goes into encrypted recordings, bundles and run + directories unless plaintext at rest is allowed + (:mod:`openadapt_flow.at_rest`). Documented in ``docs/PRIVACY.md``. - ``on``: scrub, and **fail closed** — raise if the capability is missing. This is the setting a compliance team pins for a clinical deployment. - ``off``: never scrub (e.g. an already-de-identified fixture corpus). @@ -136,16 +138,38 @@ def get_text_scrubber() -> Optional[TextScrubber]: return None if _text_scrubber is _UNSET: _text_scrubber = _build_provider() + if _text_scrubber is None and mode == "auto": + _warn_unscrubbed() if _text_scrubber is None and mode == "on": raise PrivacyNotAvailable( "OPENADAPT_FLOW_SCRUB=on but openadapt-privacy is not installed. " "Install it with: pip install 'openadapt-flow[privacy]' and " "python -m spacy download en_core_web_sm, or set " - "OPENADAPT_FLOW_SCRUB=auto to write plaintext locally." + "OPENADAPT_FLOW_SCRUB=auto to write unscrubbed text (with a warning)." ) return _text_scrubber # type: ignore[return-value] +_warned_unscrubbed = False + + +def _warn_unscrubbed() -> None: + """Say once per process that ``auto`` found no scrubber.""" + global _warned_unscrubbed + if _warned_unscrubbed: + return + _warned_unscrubbed = True + import sys + + print( + "WARNING: openadapt-privacy is not installed, so reports and logs are " + "not scrubbed of patient data (OPENADAPT_FLOW_SCRUB=auto). Install " + "'openadapt-flow[privacy]', or set OPENADAPT_FLOW_SCRUB=on to refuse " + "instead.", + file=sys.stderr, + ) + + def _get_image_scrubber() -> Optional[ImageScrubber]: """Return the image scrubber (Presidio provider), or None when unavailable.""" global _image_scrubber @@ -250,6 +274,7 @@ def scrub_text(text: Optional[str]) -> Optional[str]: "scrub text. Install its required models or configure a " "working provider before continuing." ) from exc + _warn_unscrubbed() return text @@ -269,6 +294,7 @@ def scrub_params(params: dict[str, str]) -> dict[str, str]: "scrub parameters. Install its required models or configure " "a working provider before continuing." ) from exc + _warn_unscrubbed() return params @@ -466,6 +492,7 @@ def set_image_scrubber(scrubber: Optional[ImageScrubber]) -> None: def reset_scrubbers() -> None: """Clear cached scrubbers so the next call rebuilds from the environment.""" - global _text_scrubber, _image_scrubber + global _text_scrubber, _image_scrubber, _warned_unscrubbed _text_scrubber = _UNSET _image_scrubber = _UNSET + _warned_unscrubbed = False diff --git a/packages/flow/openadapt_flow/runtime/composition.py b/packages/flow/openadapt_flow/runtime/composition.py index a9b7b5e982..76db8edca7 100644 --- a/packages/flow/openadapt_flow/runtime/composition.py +++ b/packages/flow/openadapt_flow/runtime/composition.py @@ -331,6 +331,9 @@ def _write_parent_report(run_dir: Path, report: CompositionReport) -> None: for item in report.children ], } + from openadapt_flow import at_rest as _at_rest + + _at_rest.unsealed_write("a composition report", run_dir, refuse=False) (run_dir / "composition-report.json").write_text( json.dumps(payload, indent=2), encoding="utf-8" ) diff --git a/packages/flow/openadapt_flow/runtime/heal.py b/packages/flow/openadapt_flow/runtime/heal.py index e83d10dd56..4d3f9c9a38 100644 --- a/packages/flow/openadapt_flow/runtime/heal.py +++ b/packages/flow/openadapt_flow/runtime/heal.py @@ -284,10 +284,13 @@ def persist_heal( # Heal artifacts (crop + full frame) are embedded by relative path into the # shareable REPORT.md. Redact PII/PHI when opt-in image redaction is on # (OPENADAPT_FLOW_SCRUB_IMAGES=1); no-op otherwise (see openadapt_flow.privacy). - (heal_dir / _HEAL_TEMPLATE_NAME).write_bytes(_scrub_png(crop_png)) - (heal_dir / _HEAL_SCREEN_NAME).write_bytes(_scrub_png(frame_png)) + # Through the sealed-set helpers, so a sealed run keeps them encrypted. + from openadapt_flow import sealed_set + + sealed_set.write_bytes(heal_dir / _HEAL_TEMPLATE_NAME, _scrub_png(crop_png)) + sealed_set.write_bytes(heal_dir / _HEAL_SCREEN_NAME, _scrub_png(frame_png)) event.screenshot = f"heals/{event.step_id}/{_HEAL_SCREEN_NAME}" - (heal_dir / _HEAL_JSON_NAME).write_text(event.model_dump_json(indent=2)) + sealed_set.write_text(heal_dir / _HEAL_JSON_NAME, event.model_dump_json(indent=2)) return heal_dir @@ -316,6 +319,9 @@ def write_healed_bundle( """ src = Path(src_bundle_dir) dest = Path(dest_bundle_dir) + from openadapt_flow import at_rest as _at_rest + + _at_rest.unsealed_write("a healed bundle (--save-healed-to)", dest, refuse=False) dest.mkdir(parents=True, exist_ok=True) (dest / "templates").mkdir(parents=True, exist_ok=True) diff --git a/packages/flow/openadapt_flow/runtime/healing/pipeline.py b/packages/flow/openadapt_flow/runtime/healing/pipeline.py index d43e8d4249..00a1bd873e 100644 --- a/packages/flow/openadapt_flow/runtime/healing/pipeline.py +++ b/packages/flow/openadapt_flow/runtime/healing/pipeline.py @@ -128,7 +128,9 @@ def persist_patch(patch: HealPatch, run_dir: Path) -> Path: heal_dir = Path(run_dir) / "heals" / patch.step_id heal_dir.mkdir(parents=True, exist_ok=True) path = heal_dir / _PATCH_JSON_NAME - path.write_text(patch.model_dump_json(indent=2)) + from openadapt_flow import sealed_set + + sealed_set.write_text(path, patch.model_dump_json(indent=2)) return path diff --git a/packages/flow/openadapt_flow/runtime/process_v1.py b/packages/flow/openadapt_flow/runtime/process_v1.py index 21ef5e07ed..f7279bba7e 100644 --- a/packages/flow/openadapt_flow/runtime/process_v1.py +++ b/packages/flow/openadapt_flow/runtime/process_v1.py @@ -531,6 +531,9 @@ def _execute_code( f"code child {child.name!r} exceeded its timeout" ) from exc limit = manifest.permissions.output_limit_bytes + from openadapt_flow import at_rest as _at_rest + + _at_rest.unsealed_write("a code child's output", run, refuse=False) (run / "stdout.bin").write_bytes(completed.stdout[:limit]) (run / "stderr.bin").write_bytes(completed.stderr[:limit]) if completed.returncode: diff --git a/packages/flow/openadapt_flow/runtime/replayer.py b/packages/flow/openadapt_flow/runtime/replayer.py index bc097cbaf4..7672d367ec 100644 --- a/packages/flow/openadapt_flow/runtime/replayer.py +++ b/packages/flow/openadapt_flow/runtime/replayer.py @@ -1617,14 +1617,28 @@ def run( before they are written, and durable checkpoints use the same key. No plaintext of the bundle or of the live screen reaches the disk. """ + from openadapt_flow import at_rest + 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 - ): + bundle_sealed = ( + _sealed.is_sealed(bundle_path) + or _sealed.active_writer(bundle_path) is not None + ) + # A run directory is sealed under strict mode, or when the deployment + # configured a key and the bundle is sealed. Otherwise it is one of the + # artifacts that can't be sealed yet by default (the offline Seal and + # other readers don't read sealed runs): it is written in plaintext, + # named on stderr and in the ledger (docs/phi_at_rest.md). + seal_run = at_rest.strict() or (bundle_sealed and at_rest.explicit_key()) + if not seal_run or _sealed.active_writer(Path(run_dir)) is not None: + if _sealed.active_writer(Path(run_dir)) is None: + if not at_rest.plaintext_allowed("a run directory"): + at_rest.unsealed_write("a run directory", run_dir) + if self.checkpoint_key is None: + # Durable checkpoints can still be encrypted here. + from openadapt_flow import crypto as _crypto + + self.checkpoint_key = _crypto.resolve_key(None) return self._run_unsealed( workflow, params=params, @@ -1655,9 +1669,11 @@ def run( # 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." + f"a sealed run cannot use {unsupported} yet, because that " + "state is written to the run directory in plaintext. Nothing " + "was executed. Outside OPENADAPT_FLOW_AT_REST=strict, and " + "without a configured key, the run directory is written in " + "plaintext (recorded) and this works." ) if self.checkpoint_key is None: # A sealed bundle's run keeps its durable state under the same key. diff --git a/packages/flow/openadapt_flow/runtime/variant_router.py b/packages/flow/openadapt_flow/runtime/variant_router.py index 517df0830a..0ece43e959 100644 --- a/packages/flow/openadapt_flow/runtime/variant_router.py +++ b/packages/flow/openadapt_flow/runtime/variant_router.py @@ -348,6 +348,9 @@ def _now() -> str: def _write(run_dir: Path, report: VariantRunReport) -> VariantRunReport: run_dir.mkdir(parents=True, exist_ok=True) + from openadapt_flow import at_rest as _at_rest + + _at_rest.unsealed_write("a variant report", run_dir, refuse=False) (run_dir / VARIANT_REPORT_FILENAME).write_text( report.model_dump_json(indent=2, exclude_none=True), encoding="utf-8" ) diff --git a/packages/flow/openadapt_flow/runtime/worklist_pool.py b/packages/flow/openadapt_flow/runtime/worklist_pool.py index 55befc2436..42c2e45a34 100644 --- a/packages/flow/openadapt_flow/runtime/worklist_pool.py +++ b/packages/flow/openadapt_flow/runtime/worklist_pool.py @@ -585,6 +585,9 @@ def render_md(self) -> str: def save(self, run_dir: Path) -> Path: run_dir.mkdir(parents=True, exist_ok=True) + from openadapt_flow import at_rest as _at_rest + + _at_rest.unsealed_write("a worklist report", run_dir, refuse=False) path = run_dir / POOL_REPORT_FILENAME tmp = path.with_suffix(".tmp") tmp.write_text(json.dumps(self.to_json(), indent=1, default=str)) diff --git a/packages/flow/tests/conftest.py b/packages/flow/tests/conftest.py index 145f115e44..8c2c9ece81 100644 --- a/packages/flow/tests/conftest.py +++ b/packages/flow/tests/conftest.py @@ -60,3 +60,10 @@ def _isolate_durable_authority(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) # The uncertain-write ledger: per test, never ~/.openadapt/ledger. monkeypatch.setenv("OPENADAPT_LEDGER_DIR", str(tmp_path / ".write-ledger")) monkeypatch.delenv("OPENADAPT_FRAME_SECRET_FILE", raising=False) + # Encryption at rest is the default (openadapt_flow.at_rest). The suite + # keeps the plaintext behaviour its fixtures assert, under the recorded + # opt-out, and keeps the local key and the ledger out of the real home. + # tests/test_at_rest_default.py covers the default and strict modes. + monkeypatch.setenv("OPENADAPT_FLOW_PLAINTEXT_AT_REST", "test suite: synthetic data") + monkeypatch.setenv("OPENADAPT_FLOW_AT_REST_HOME", str(tmp_path / ".at-rest-home")) + monkeypatch.delenv("OPENADAPT_FLOW_AT_REST", raising=False) diff --git a/packages/flow/tests/test_at_rest_default.py b/packages/flow/tests/test_at_rest_default.py new file mode 100644 index 0000000000..729c545720 --- /dev/null +++ b/packages/flow/tests/test_at_rest_default.py @@ -0,0 +1,277 @@ +"""Encryption at rest by default, the recorded opt-out, and strict mode. + +The rest of the suite runs under the plaintext opt-out (tests/conftest.py). +These tests remove it to check the default and ``OPENADAPT_FLOW_AT_REST=strict``. +""" + +from __future__ import annotations + +import argparse +import json +import os +import stat +from pathlib import Path + +import pytest + +from openadapt_flow import at_rest, crypto, sealed_set +from openadapt_flow.__main__ import _recording_encryption_key, main +from openadapt_flow.ir import Workflow +from tests.test_sealed_at_rest import ( + PNG_SIGNATURE, + _assert_ciphertext_only, + _recording, + watching_disk, +) + + +@pytest.fixture +def default_mode(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + for name in ( + at_rest.OPT_OUT_ENV, + at_rest.MODE_ENV, + crypto.ENV_KEY, + sealed_set.RECORDING_ENV_KEY, + ): + monkeypatch.delenv(name, raising=False) + home = tmp_path / "at-rest-home" + monkeypatch.setenv(at_rest.HOME_ENV, str(home)) + at_rest._reset_for_tests() + yield home + at_rest._reset_for_tests() + + +def _ledger(home: Path) -> list[dict]: + path = home / at_rest.LEDGER_NAME + if not path.exists(): + return [] + return [json.loads(line) for line in path.read_text().splitlines()] + + +# -- the local key ------------------------------------------------------------- + + +def test_local_key_is_created_private_and_reused(default_mode: Path) -> None: + first = crypto.resolve_key(None) + path = default_mode / "keys" / at_rest.KEY_NAME + assert first and path.read_text().strip() == first + if os.name == "posix": + assert stat.S_IMODE(path.stat().st_mode) == 0o600 + assert stat.S_IMODE(path.parent.stat().st_mode) == 0o700 + assert crypto.resolve_key(None) == first + assert not [p for p in path.parent.iterdir() if p.name != at_rest.KEY_NAME] + + +@pytest.mark.skipif(os.name != "posix", reason="POSIX permissions") +def test_a_key_others_can_read_is_refused(default_mode: Path) -> None: + crypto.resolve_key(None) + path = default_mode / "keys" / at_rest.KEY_NAME + path.chmod(0o644) + with pytest.raises(at_rest.LocalKeyError, match="chmod 600"): + crypto.resolve_key(None) + + +def test_a_malformed_key_is_refused(default_mode: Path) -> None: + path = default_mode / "keys" / at_rest.KEY_NAME + path.parent.mkdir(parents=True, mode=0o700) + path.write_text("short\n") + path.chmod(0o600) + with pytest.raises(at_rest.LocalKeyError): + crypto.resolve_key(None) + + +def test_a_configured_key_wins(default_mode: Path, monkeypatch) -> None: + monkeypatch.setenv(crypto.ENV_KEY, "deployment-key") + assert crypto.resolve_key(None) == "deployment-key" + assert not (default_mode / "keys").exists() + + +# -- the opt-out and strict mode ----------------------------------------------- + + +def test_opt_out_restores_plaintext_loudly_and_records_it( + default_mode: Path, monkeypatch, capsys +) -> None: + monkeypatch.setenv(at_rest.OPT_OUT_ENV, "de-identified fixtures") + assert crypto.resolve_key(None) is None + assert at_rest.plaintext_allowed("a compiled bundle") + assert at_rest.plaintext_allowed("a compiled bundle") # warns once + err = capsys.readouterr().err + assert err.count("WARNING: writing a compiled bundle in plaintext") == 1 + [line] = _ledger(default_mode) + assert line["purpose"] == "a compiled bundle" + assert line["reason"] == "de-identified fixtures" + + +def test_strict_mode_ignores_the_opt_out( + default_mode: Path, monkeypatch, capsys +) -> None: + monkeypatch.setenv(at_rest.OPT_OUT_ENV, "dev") + monkeypatch.setenv(at_rest.MODE_ENV, "strict") + assert crypto.resolve_key(None) + assert not at_rest.plaintext_allowed("a compiled bundle") + assert "ignored" in capsys.readouterr().err + + +def test_an_unknown_mode_stops_the_cli( + default_mode: Path, monkeypatch, tmp_path +) -> None: + monkeypatch.setenv(at_rest.MODE_ENV, "stricter") + with pytest.raises(ValueError): + at_rest.strict() + assert main(["explain", str(tmp_path / "missing")]) == 2 + + +def test_unsealed_write_names_the_path_or_refuses( + default_mode: Path, monkeypatch, capsys +) -> None: + at_rest.unsealed_write("a run directory", "/data/run-7") + assert "/data/run-7" in capsys.readouterr().err + assert _ledger(default_mode)[0]["path"] == "/data/run-7" + monkeypatch.setenv(at_rest.MODE_ENV, "strict") + with pytest.raises(at_rest.AtRestRefused, match="strict"): + at_rest.unsealed_write("a run directory", "/data/run-8") + assert len(_ledger(default_mode)) == 1 + + +# -- what is sealed by default --------------------------------------------------- + + +def test_a_plaintext_recording_compiles_to_a_sealed_bundle( + default_mode: Path, tmp_path: Path +) -> None: + rec = _recording(tmp_path / "rec", key=None) # made before the default + assert main(["compile", str(rec), "--out", str(tmp_path / "b"), "--name", "x"]) == 0 + _assert_ciphertext_only(tmp_path / "b") + assert Workflow.load(tmp_path / "b").encrypted + assert sealed_set.SealedSet.open(tmp_path / "b", crypto.resolve_key(None)) + + +def test_compile_under_the_opt_out_stays_plaintext( + default_mode: Path, tmp_path: Path, monkeypatch +) -> None: + monkeypatch.setenv(at_rest.OPT_OUT_ENV, "fixtures") + rec = _recording(tmp_path / "rec", key=None) + assert main(["compile", str(rec), "--out", str(tmp_path / "b"), "--name", "x"]) == 0 + assert (tmp_path / "b" / "workflow.json").is_file() + assert _ledger(default_mode)[0]["purpose"] == "a compiled bundle" + + +def test_induce_seals_by_default_and_refuses_a_plaintext_report( + default_mode: Path, tmp_path: Path +) -> None: + first = _recording(tmp_path / "rec1", key=None) + second = _recording(tmp_path / "rec2", key=None) + report = tmp_path / "induce.json" + refused = main( + [ + "induce", + str(first), + str(second), + "--out", + str(tmp_path / "x"), + "--report", + str(report), + ] + ) + assert refused == 2 and not report.exists() + assert main(["induce", str(first), str(second), "--out", str(tmp_path / "i")]) == 0 + _assert_ciphertext_only(tmp_path / "i") + + +def _record_args(out: Path, *, no_encrypt: bool = False) -> argparse.Namespace: + return argparse.Namespace(out=str(out), no_encrypt=no_encrypt) + + +def test_web_recordings_get_the_local_key(default_mode: Path, tmp_path: Path) -> None: + assert _recording_encryption_key(_record_args(tmp_path / "r"), "web") == ( + crypto.resolve_key(None) + ) + + +def test_no_encrypt_is_recorded_and_strict_refuses_it( + default_mode: Path, tmp_path: Path, monkeypatch +) -> None: + assert ( + _recording_encryption_key(_record_args(tmp_path / "r", no_encrypt=True), "web") + is None + ) + assert "record --no-encrypt" in _ledger(default_mode)[0]["reason"] + monkeypatch.setenv(at_rest.MODE_ENV, "strict") + with pytest.raises(SystemExit, match="strict"): + _recording_encryption_key(_record_args(tmp_path / "r2", no_encrypt=True), "web") + + +def test_desktop_recorders_work_by_default_and_refuse_in_strict_mode( + default_mode: Path, tmp_path: Path, monkeypatch, capsys +) -> None: + out = tmp_path / "desk" + assert _recording_encryption_key(_record_args(out), "windows") is None + assert str(out) in capsys.readouterr().err + assert _ledger(default_mode)[0]["path"] == str(out) + monkeypatch.setenv(at_rest.MODE_ENV, "strict") + with pytest.raises(SystemExit, match="strict"): + _recording_encryption_key(_record_args(tmp_path / "desk2"), "windows") + + +def test_commands_that_cannot_seal_their_output_refuse_in_strict_mode( + default_mode: Path, tmp_path: Path, monkeypatch, capsys +) -> None: + monkeypatch.setenv(at_rest.MODE_ENV, "strict") + assert main(["explain", str(tmp_path / "missing")]) == 2 + assert "strict" in capsys.readouterr().err + + +# -- run directories ----------------------------------------------------------- + + +def _mockmed_run(tmp_path: Path, bundle: Path, run_dir: Path): + 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, _vision + + url, _db, stop = serve() + url = url.rstrip("/") + try: + return Replayer( + MockMedBackend(url), + vision=_vision(), + effect_verifier=RestRecordVerifier(url), + poll_interval_s=0.01, + ).run(Workflow.load(bundle), bundle_dir=bundle, run_dir=run_dir) + finally: + stop() + + +def test_a_run_directory_is_plaintext_by_default_and_named( + default_mode: Path, tmp_path: Path, capsys +) -> None: + from tests.test_after_stop_mockmed import _workflow + + bundle = tmp_path / "bundle" + _workflow().save(bundle) + run_dir = tmp_path / "run" + _mockmed_run(tmp_path, bundle, run_dir) + assert (run_dir / "report.json").is_file() + assert str(run_dir) in capsys.readouterr().err + assert any(e.get("path") == str(run_dir) for e in _ledger(default_mode)) + + +def test_strict_mode_seals_the_run_directory_of_a_plaintext_bundle( + default_mode: Path, tmp_path: Path, monkeypatch +) -> None: + from tests.test_after_stop_mockmed import _workflow + + monkeypatch.setenv(at_rest.MODE_ENV, "strict") + bundle = tmp_path / "bundle" + with monkeypatch.context() as m: + m.setenv(at_rest.OPT_OUT_ENV, "") + _workflow().save(bundle) + run_dir = tmp_path / "run" + with watching_disk() as watch: + _mockmed_run(tmp_path, bundle, run_dir) + assert sealed_set.is_sealed(run_dir) + _assert_ciphertext_only(run_dir) + assert watch.leaks([b'"execution_outcome"', PNG_SIGNATURE]) == [] + assert _ledger(default_mode) == [] diff --git a/tests/conftest.py b/tests/conftest.py index 9187e88f39..564ba987b4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -56,3 +56,17 @@ def pytest_configure(config): config.addinivalue_line( "markers", "real_quickstart: runs the real quickstart in a browser" ) + + +@pytest.fixture(autouse=True) +def _isolated_flow_at_rest(tmp_path_factory, monkeypatch): + """Keep Flow's at-rest key and plaintext ledger out of the real home. + + Flow encrypts at rest by default (openadapt_flow.at_rest); these tests + use its recorded opt-out, as the Flow suite does. + """ + monkeypatch.setenv( + "OPENADAPT_FLOW_AT_REST_HOME", str(tmp_path_factory.mktemp("at-rest-home")) + ) + monkeypatch.setenv("OPENADAPT_FLOW_PLAINTEXT_AT_REST", "test suite: synthetic data") + monkeypatch.delenv("OPENADAPT_FLOW_AT_REST", raising=False) From 9a0b2f485650f7fa1c7c9fba72ad4eca8d0eee8a Mon Sep 17 00:00:00 2001 From: abrichr Date: Tue, 3 Nov 2026 13:28:03 -0500 Subject: [PATCH 02/21] chore(desktop): release 0.26.0 --- packages/desktop/CHANGELOG.md | 8 ++++++++ packages/desktop/openadapt_desktop/__init__.py | 2 +- packages/desktop/package-lock.json | 4 ++-- packages/desktop/package.json | 2 +- packages/desktop/pyproject.toml | 2 +- packages/desktop/src-tauri/Cargo.lock | 2 +- packages/desktop/src-tauri/Cargo.toml | 2 +- packages/desktop/src-tauri/tauri.conf.json | 2 +- packages/desktop/uv.lock | 2 +- 9 files changed, 17 insertions(+), 9 deletions(-) diff --git a/packages/desktop/CHANGELOG.md b/packages/desktop/CHANGELOG.md index 20b2fa8210..f264be5485 100644 --- a/packages/desktop/CHANGELOG.md +++ b/packages/desktop/CHANGELOG.md @@ -1,6 +1,14 @@ # CHANGELOG +## v0.26.0 (2026-11-03) + +### Features + +- encrypt recordings, bundles and checkpoints at rest by default + + + ## v0.25.0 (2026-10-28) ### Features diff --git a/packages/desktop/openadapt_desktop/__init__.py b/packages/desktop/openadapt_desktop/__init__.py index 3699b9c587..9b331ec781 100644 --- a/packages/desktop/openadapt_desktop/__init__.py +++ b/packages/desktop/openadapt_desktop/__init__.py @@ -21,4 +21,4 @@ +-- backends/ Storage backend plugins """ -__version__ = "0.25.0" +__version__ = "0.26.0" diff --git a/packages/desktop/package-lock.json b/packages/desktop/package-lock.json index 412a435735..11266b949b 100644 --- a/packages/desktop/package-lock.json +++ b/packages/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "openadapt-desktop", - "version": "0.25.0", + "version": "0.26.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openadapt-desktop", - "version": "0.25.0", + "version": "0.26.0", "dependencies": { "@tauri-apps/api": "^2", "@tauri-apps/plugin-notification": "^2", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 3581bf7b30..63f7a85dfc 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,6 +1,6 @@ { "name": "openadapt-desktop", - "version": "0.25.0", + "version": "0.26.0", "description": "Installed companion for OpenAdapt authoring, teaching, and local pairing", "private": true, "type": "module", diff --git a/packages/desktop/pyproject.toml b/packages/desktop/pyproject.toml index 1071fde669..298fc3b437 100644 --- a/packages/desktop/pyproject.toml +++ b/packages/desktop/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "openadapt-desktop" -version = "0.25.0" +version = "0.26.0" description = "The OpenAdapt desktop app and local engine: record a data-entry task, run it on a list, and check each saved record" readme = "README.md" requires-python = ">=3.11" diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index d85fd9c81f..03ca273687 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -2502,7 +2502,7 @@ dependencies = [ [[package]] name = "openadapt-desktop" -version = "0.25.0" +version = "0.26.0" dependencies = [ "hex", "reqwest", diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml index 126b911b78..70466b7491 100644 --- a/packages/desktop/src-tauri/Cargo.toml +++ b/packages/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openadapt-desktop" -version = "0.25.0" +version = "0.26.0" description = "Installed companion for OpenAdapt authoring, teaching, and local pairing" authors = ["OpenAdapt AI"] license = "MIT" diff --git a/packages/desktop/src-tauri/tauri.conf.json b/packages/desktop/src-tauri/tauri.conf.json index bd7ea1bbfb..6d50423bca 100644 --- a/packages/desktop/src-tauri/tauri.conf.json +++ b/packages/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicoverbruggen/tauri-v2-schema/main/schema.json", "productName": "OpenAdapt Desktop", - "version": "0.25.0", + "version": "0.26.0", "identifier": "ai.openadapt.desktop", "build": { "frontendDist": "../dist", diff --git a/packages/desktop/uv.lock b/packages/desktop/uv.lock index ed0f7313c9..f6852f7ad4 100644 --- a/packages/desktop/uv.lock +++ b/packages/desktop/uv.lock @@ -1172,7 +1172,7 @@ wheels = [ [[package]] name = "openadapt-desktop" -version = "0.25.0" +version = "0.26.0" source = { editable = "." } dependencies = [ { name = "cryptography" }, From 8ba9b14538bd474c4b88200e9f85090fecbff133 Mon Sep 17 00:00:00 2001 From: abrichr Date: Tue, 3 Nov 2026 13:38:02 -0500 Subject: [PATCH 03/21] fix: at-rest review fixes: recording key, missing-key error, mode check, strict gates --- packages/flow/openadapt_flow/__main__.py | 26 +++++++----- .../flow/openadapt_flow/admit_pipeline.py | 8 ++-- packages/flow/openadapt_flow/at_rest.py | 40 ++++++++++++++++++- .../flow/openadapt_flow/compiler/compile.py | 4 +- packages/flow/openadapt_flow/sealed_set.py | 32 ++++++++++++++- 5 files changed, 92 insertions(+), 18 deletions(-) diff --git a/packages/flow/openadapt_flow/__main__.py b/packages/flow/openadapt_flow/__main__.py index aaabc96495..103fa1f914 100644 --- a/packages/flow/openadapt_flow/__main__.py +++ b/packages/flow/openadapt_flow/__main__.py @@ -679,10 +679,12 @@ def _recording_encryption_key(args: argparse.Namespace, backend: str) -> Optiona return None except at_rest.AtRestRefused as exc: raise SystemExit(f"record: {exc}") from exc - key = explicit or crypto.resolve_key(None) - if key is None: - at_rest.plaintext_allowed("a recording") - return key + if explicit or os.environ.get(crypto.ENV_KEY): + return explicit or crypto.resolve_key(None) + try: + return crypto.resolve_key(None) if at_rest.seal_new("a recording", out) else None + except at_rest.AtRestRefused as exc: + raise SystemExit(f"record: {exc}") from exc def _stamp_recording_surface(recording_dir: Path, surface: str) -> None: @@ -2181,7 +2183,7 @@ def _cmd_induce(args: argparse.Namespace) -> int: # Encrypted demonstrations induce an encrypted program, and so do # plaintext ones unless plaintext at rest is explicitly allowed. - seal_output = sealed_inputs or not at_rest.plaintext_allowed("an induced program") + seal_output = sealed_inputs or at_rest.seal_new("an induced program", args.out) if seal_output and (args.report or args.probe_on): # Both write the induced program (or probe bundles) outside any # sealed set; refuse rather than write plaintext. @@ -2227,7 +2229,7 @@ def _cmd_induce(args: argparse.Namespace) -> int: workflow = workflow.model_copy(update={"name": args.name}) out = Path(args.out) if seal_output: - passphrase = sealed_set.passphrase_for("bundle") or sealed_set.recording_key() + passphrase = sealed_set.output_bundle_key() assert passphrase is not None # resolves to the local key by default with sealed_set.writing(out, "bundle", passphrase): workflow.save(out) @@ -12606,6 +12608,7 @@ def _qualify_default_form(argv: list[str]) -> list[str]: "openadapt_flow.__main__:_cmd_process": "the process report", "openadapt_flow.__main__:_cmd_variants": "the variant report", "openadapt_flow.__main__:_cmd_route": "the variant report", + "openadapt_flow.__main__:_cmd_for_each": "the looped bundle", } @@ -12615,9 +12618,14 @@ def _unsealed_output_check(args: argparse.Namespace) -> None: from openadapt_flow import at_rest at_rest.strict() # raises on an unknown mode before anything runs - healed = getattr(args, "save_healed_to", None) - if healed and at_rest.strict(): - at_rest.unsealed_write("a healed bundle (--save-healed-to)", healed) + if at_rest.strict(): + # Written after records change, so refused before the run starts. + healed = getattr(args, "save_healed_to", None) + if healed: + at_rest.unsealed_write("a healed bundle (--save-healed-to)", healed) + workers = getattr(args, "workers", None) + if isinstance(workers, int) and workers > 0: + at_rest.unsealed_write("the worker pool report (--workers)", "the run directory") func = getattr(args, "func", None) command = f"{getattr(func, '__module__', '')}:{getattr(func, '__name__', '')}" artifact = _UNSEALED_OUTPUT_COMMANDS.get(command) diff --git a/packages/flow/openadapt_flow/admit_pipeline.py b/packages/flow/openadapt_flow/admit_pipeline.py index c7d7ee9805..17e950208b 100644 --- a/packages/flow/openadapt_flow/admit_pipeline.py +++ b/packages/flow/openadapt_flow/admit_pipeline.py @@ -382,13 +382,15 @@ def stop(status: PendingStatus, reason: str, **extra: Any) -> PendingAdmission: workflow = result.workflow induced = True workflow = workflow.model_copy(update={"name": name}) - from openadapt_flow import sealed_set + from openadapt_flow import at_rest, sealed_set - sealed = any(sealed_set.is_sealed(path) for path in compiled) + sealed = any(sealed_set.is_sealed(path) for path in compiled) or at_rest.seal_new( + "an admitted bundle", bundle_dir + ) 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() + passphrase = sealed_set.output_bundle_key() if not passphrase: raise AdmissionError(sealed_set.missing_key_message(compiled[0], "bundle")) templates: dict[str, bytes] = {} diff --git a/packages/flow/openadapt_flow/at_rest.py b/packages/flow/openadapt_flow/at_rest.py index 3f6dc99fc9..2d0c4e16c0 100644 --- a/packages/flow/openadapt_flow/at_rest.py +++ b/packages/flow/openadapt_flow/at_rest.py @@ -154,7 +154,7 @@ def opt_out_reason() -> Optional[str]: None in strict mode, which ignores the opt-out. """ reason = (os.environ.get(OPT_OUT_ENV) or "").strip() - if reason and strict(): + if strict() and reason: with _lock: first = "strict-ignores-opt-out" not in _warned _warned.add("strict-ignores-opt-out") @@ -199,6 +199,7 @@ def record_plaintext(purpose: str, reason: str) -> None: def _record( purpose: str, reason: str, *, warning: str, path: Optional[str] = None ) -> None: + strict() # an unknown mode stops here too, not only at the CLI with _lock: if purpose in _warned: return @@ -264,6 +265,17 @@ def _read(path: Path) -> str: def local_key() -> str: """Return this machine's at-rest key, creating it on first use.""" path = local_key_path() + try: + return _local_key(path) + except OSError as exc: + raise LocalKeyError( + f"Flow could not create or read its local encryption key at {path} " + f"({exc}). Set OPENADAPT_BUNDLE_KEY, or {HOME_ENV} to a writable " + "private directory. Nothing was written." + ) from exc + + +def _local_key(path: Path) -> str: with _lock: if path.exists(): return _read(path) @@ -295,12 +307,36 @@ def local_key() -> str: return text +#: Whether the default mode seals new recordings and bundles. Strict mode +#: always does. See docs/phi_at_rest.md. +DEFAULT_SEALS_ARTIFACTS: Final[bool] = True + + +def seal_new(artifact: str, path: Path | str) -> bool: + """Whether a new bundle or recording at ``path`` is written sealed. + + True in strict mode, and by default. Otherwise (the opt-out, or a default + mode that doesn't seal it) the plaintext write is named and recorded. + """ + if strict(): + return True + if opt_out_reason() is not None: + plaintext_allowed(artifact) + return False + if DEFAULT_SEALS_ARTIFACTS: + return True + unsealed_write(artifact, path) + return False + + def default_key() -> Optional[str]: """The key to use when none is configured: the local key, or None under the opt-out (plaintext, the behaviour before encryption was the default). Strict mode ignores the opt-out. """ - if opt_out_reason() is not None: + reason = opt_out_reason() + if reason is not None: + record_plaintext("files Flow encrypts when a key is set", reason) return None return local_key() diff --git a/packages/flow/openadapt_flow/compiler/compile.py b/packages/flow/openadapt_flow/compiler/compile.py index c63725362e..64890c69b2 100644 --- a/packages/flow/openadapt_flow/compiler/compile.py +++ b/packages/flow/openadapt_flow/compiler/compile.py @@ -2060,12 +2060,12 @@ def compile_recording( # A plaintext recording (one made before encryption was the default, # or under the opt-out) still compiles to a sealed bundle unless # plaintext is explicitly allowed. - if at_rest.plaintext_allowed("a compiled bundle"): + if not at_rest.seal_new("a compiled bundle", bundle): 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() + passphrase = _sealed.output_bundle_key() if not passphrase: raise _sealed.SealedSetKeyError(_sealed.missing_key_message(bundle, "bundle")) if not sealed_output and bundle.exists() and any(bundle.iterdir()): diff --git a/packages/flow/openadapt_flow/sealed_set.py b/packages/flow/openadapt_flow/sealed_set.py index 07c1c0f9ce..1f06e68315 100644 --- a/packages/flow/openadapt_flow/sealed_set.py +++ b/packages/flow/openadapt_flow/sealed_set.py @@ -285,7 +285,8 @@ def classify(kind: Kind, rel: str) -> Optional[FileKind]: 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.""" + then the bundle variable, then the local key. ``None`` only under the + plaintext opt-out.""" if explicit: return explicit return os.environ.get(RECORDING_ENV_KEY) or crypto.resolve_key(None) @@ -297,6 +298,17 @@ def passphrase_for(kind: str, explicit: Optional[str] = None) -> Optional[str]: return crypto.resolve_key(explicit) +def output_bundle_key() -> Optional[str]: + """The key for a bundle Flow writes: ``OPENADAPT_BUNDLE_KEY``, else + ``OPENADAPT_RECORDING_KEY`` (a deployment that sets only the recording + key keeps its bundles under it), else the local key.""" + return ( + os.environ.get(crypto.ENV_KEY) + or os.environ.get(RECORDING_ENV_KEY) + or crypto.resolve_key(None) + ) + + def missing_key_message(root: Path | str, kind: str) -> str: variable = ( f"{RECORDING_ENV_KEY} or {crypto.ENV_KEY}" @@ -890,12 +902,28 @@ def open_set(root: Path | str, key: Optional[str] = None) -> SealedSet: cached[0].encode("utf-8"), passphrase.encode("utf-8") ): return cached[1] - opened = SealedSet.open(base, passphrase) + try: + opened = SealedSet.open(base, passphrase) + except crypto.CryptoError as exc: + if key is None and not _configured(str(header["kind"])): + # Opened with the local key because nothing was configured: the + # set was most likely sealed with a deployment key that isn't set. + raise SealedSetKeyError( + missing_key_message(Path(base), str(header["kind"])) + + " (The local key did not open it.)" + ) from exc + raise with _STATE_LOCK: _OPENED[cache_key] = (passphrase, opened) return opened +def _configured(kind: str) -> bool: + if os.environ.get(crypto.ENV_KEY): + return True + return kind == "recording" and bool(os.environ.get(RECORDING_ENV_KEY)) + + def _reader(root: str) -> SealedSet | SealedSetWriter: with _STATE_LOCK: writer = _WRITERS.get(root) From 9b5ff4807f18def04198084b3b01ffc147a6e66a Mon Sep 17 00:00:00 2001 From: abrichr Date: Tue, 3 Nov 2026 13:40:12 -0500 Subject: [PATCH 04/21] fix: interim at-rest policy: checkpoints by default, everything in strict mode --- CAPABILITIES.md | 2 +- llms-full.txt | 2 +- packages/desktop/CHANGELOG.md | 2 +- packages/flow/docs/PRIVACY.md | 9 +- packages/flow/docs/phi_at_rest.md | 85 +++++++++---------- packages/flow/openadapt_flow/at_rest.py | 53 ++++++------ .../flow/openadapt_flow/compiler/compile.py | 8 +- packages/flow/tests/test_at_rest_default.py | 58 +++++++++++-- 8 files changed, 124 insertions(+), 95 deletions(-) diff --git a/CAPABILITIES.md b/CAPABILITIES.md index 23736b547c..69d4c95729 100644 --- a/CAPABILITIES.md +++ b/CAPABILITIES.md @@ -302,7 +302,7 @@ improvising or retrying when something is off. | See a compiled program as a map (every step, exact edge, loop and stop rule, with each step's checks) and play a recorded run over it; one React package that Desktop, app.openadapt.ai and openadapt.ai share | On main ([packages/program-graph](packages/program-graph/README.md)) | Real compiled OpenEMR referral program and a recorded scoreboard run, both on synthetic data | `@openadapt/program-graph`; `openadapt-flow visualize --format json` | | Named sections for every compiled program ("Who: title, first name, last name", "Contact", "Create New Patient"), found without a model from the save, the recorded form structure, the fields and any narration; a person can rename, merge or split them on the review screen | On main | Unit tests; the certified OpenEMR referral bundle and a real recording of it on a local test copy with made-up patients | `openadapt-flow sections`; [SECTIONS.md](packages/flow/docs/SECTIONS.md) | | 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 | On main | 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) | -| Encrypt at rest by default: with no key configured, a locally generated key (0600) encrypts web recordings, compiled and induced bundles (also from a plaintext recording) and durable checkpoints. Artifacts that can't be encrypted yet (run directories by default, desktop recordings, approval requests, `visualize`/`explain`/`teach`/`emit-skill`/`push` output) are named on stderr and in a ledger that Desktop shows; `OPENADAPT_FLOW_AT_REST=strict` refuses them and encrypts every run directory. `OPENADAPT_FLOW_PLAINTEXT_AT_REST` opts out, recorded. Remaining work: [#1751](https://github.com/OpenAdaptAI/OpenAdapt/issues/1751) | On main | Synthetic (key, opt-out, strict and run-directory tests) | `openadapt_flow.at_rest`; [phi_at_rest.md](packages/flow/docs/phi_at_rest.md#default-opt-out-and-strict-mode) | +| Encryption at rest under a configured or locally generated key (0600): durable checkpoints and attended state by default; with `OPENADAPT_FLOW_AT_REST=strict`, every recording, bundle and run directory too. Each plaintext write is logged on stderr and in a ledger that Desktop shows as a notice | On main | Synthetic (key, opt-out, strict-mode and run-directory tests) | `openadapt_flow.at_rest`; [phi_at_rest.md](packages/flow/docs/phi_at_rest.md#default-opt-out-and-strict-mode) | | Signed receipts (Seals), countersigned and timestamped, offline by default | Released | Unit tests, real TSA tokens | `openadapt-flow seal`, `countersign`, `local-seal` | | Offline Seals signed with a key the customer holds: a key file, the OS keychain, or a PKCS#11 token or HSM; pinned keys with rotation and revocation; no fallback to a generated key | On main ([#1392](https://github.com/OpenAdaptAI/OpenAdapt/pull/1392)) | Unit tests with synthetic data (keychain and PKCS#11 through stand-ins) | `openadapt-flow local-seal --key`, `local-seal key create`, `pin`, `revoke`; `openadapt seal verify FOLDER` checks every sealed run in a folder; [OFFLINE_SEALS.md](packages/flow/docs/OFFLINE_SEALS.md#customer-held-keys) | | Anchor an offline Seal in the public Sigstore Rekor transparency log (opt-in; sends one digest and the public key), and verify the anchor offline from the stored inclusion proof | On main ([#1394](https://github.com/OpenAdaptAI/OpenAdapt/pull/1394)) | Unit tests against a simulated Rekor log; real public Rekor entries and Sigstore TSA tokens for the landing demo's referral runs, verified offline in tests; Ed25519ph matches the RFC 8032 test vector | `openadapt-flow local-seal anchor`, `local-seal verify --require-anchor`; [OFFLINE_SEALS.md](packages/flow/docs/OFFLINE_SEALS.md#optional-anchor-a-seal-in-a-public-transparency-log) | diff --git a/llms-full.txt b/llms-full.txt index 6976494ea9..af71bba2cb 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -699,7 +699,7 @@ improvising or retrying when something is off. | See a compiled program as a map (every step, exact edge, loop and stop rule, with each step's checks) and play a recorded run over it; one React package that Desktop, app.openadapt.ai and openadapt.ai share | On main ([packages/program-graph](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/program-graph/README.md)) | Real compiled OpenEMR referral program and a recorded scoreboard run, both on synthetic data | `@openadapt/program-graph`; `openadapt-flow visualize --format json` | | Named sections for every compiled program ("Who: title, first name, last name", "Contact", "Create New Patient"), found without a model from the save, the recorded form structure, the fields and any narration; a person can rename, merge or split them on the review screen | On main | Unit tests; the certified OpenEMR referral bundle and a real recording of it on a local test copy with made-up patients | `openadapt-flow sections`; [SECTIONS.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/SECTIONS.md) | | 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 | On main | 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) | -| Encrypt at rest by default: with no key configured, a locally generated key (0600) encrypts web recordings, compiled and induced bundles (also from a plaintext recording) and durable checkpoints. Artifacts that can't be encrypted yet (run directories by default, desktop recordings, approval requests, `visualize`/`explain`/`teach`/`emit-skill`/`push` output) are named on stderr and in a ledger that Desktop shows; `OPENADAPT_FLOW_AT_REST=strict` refuses them and encrypts every run directory. `OPENADAPT_FLOW_PLAINTEXT_AT_REST` opts out, recorded. Remaining work: [#1751](https://github.com/OpenAdaptAI/OpenAdapt/issues/1751) | On main | Synthetic (key, opt-out, strict and run-directory tests) | `openadapt_flow.at_rest`; [phi_at_rest.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/phi_at_rest.md#default-opt-out-and-strict-mode) | +| Encryption at rest under a configured or locally generated key (0600): durable checkpoints and attended state by default; with `OPENADAPT_FLOW_AT_REST=strict`, every recording, bundle and run directory too. Each plaintext write is logged on stderr and in a ledger that Desktop shows as a notice | On main | Synthetic (key, opt-out, strict-mode and run-directory tests) | `openadapt_flow.at_rest`; [phi_at_rest.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/phi_at_rest.md#default-opt-out-and-strict-mode) | | Signed receipts (Seals), countersigned and timestamped, offline by default | Released | Unit tests, real TSA tokens | `openadapt-flow seal`, `countersign`, `local-seal` | | Offline Seals signed with a key the customer holds: a key file, the OS keychain, or a PKCS#11 token or HSM; pinned keys with rotation and revocation; no fallback to a generated key | On main ([#1392](https://github.com/OpenAdaptAI/OpenAdapt/pull/1392)) | Unit tests with synthetic data (keychain and PKCS#11 through stand-ins) | `openadapt-flow local-seal --key`, `local-seal key create`, `pin`, `revoke`; `openadapt seal verify FOLDER` checks every sealed run in a folder; [OFFLINE_SEALS.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/OFFLINE_SEALS.md#customer-held-keys) | | Anchor an offline Seal in the public Sigstore Rekor transparency log (opt-in; sends one digest and the public key), and verify the anchor offline from the stored inclusion proof | On main ([#1394](https://github.com/OpenAdaptAI/OpenAdapt/pull/1394)) | Unit tests against a simulated Rekor log; real public Rekor entries and Sigstore TSA tokens for the landing demo's referral runs, verified offline in tests; Ed25519ph matches the RFC 8032 test vector | `openadapt-flow local-seal anchor`, `local-seal verify --require-anchor`; [OFFLINE_SEALS.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/OFFLINE_SEALS.md#optional-anchor-a-seal-in-a-public-transparency-log) | diff --git a/packages/desktop/CHANGELOG.md b/packages/desktop/CHANGELOG.md index f264be5485..e4cdca0acb 100644 --- a/packages/desktop/CHANGELOG.md +++ b/packages/desktop/CHANGELOG.md @@ -5,7 +5,7 @@ ### Features -- encrypt recordings, bundles and checkpoints at rest by default +- show a notice when Flow logs a recording or run record stored without encryption diff --git a/packages/flow/docs/PRIVACY.md b/packages/flow/docs/PRIVACY.md index 7f36dacbe2..6ca57a390d 100644 --- a/packages/flow/docs/PRIVACY.md +++ b/packages/flow/docs/PRIVACY.md @@ -53,11 +53,10 @@ Paths this rule does not cover yet are listed under ## Local posture -Scrubbing and encryption are separate controls. Encryption at rest is on by -default: recordings, compiled bundles and durable checkpoints are encrypted -under a configured key or a locally generated one, and -`OPENADAPT_FLOW_AT_REST=strict` refuses every artifact that can't be -encrypted yet ([phi_at_rest.md](phi_at_rest.md#default-opt-out-and-strict-mode)). +Scrubbing and encryption are separate controls. Durable checkpoints are +encrypted by default, under a configured key or a locally generated one, and +`OPENADAPT_FLOW_AT_REST=strict` encrypts every recording, bundle and run +directory ([phi_at_rest.md](phi_at_rest.md#default-opt-out-and-strict-mode)). Two environment variables control scrubbing of what is written locally. The default is **predictable, not maximally private**: `auto` writes unscrubbed diff --git a/packages/flow/docs/phi_at_rest.md b/packages/flow/docs/phi_at_rest.md index 33a163ee31..1fcb18d002 100644 --- a/packages/flow/docs/phi_at_rest.md +++ b/packages/flow/docs/phi_at_rest.md @@ -13,46 +13,40 @@ machine), [phi_in_transit.md](phi_in_transit.md) (PHI on the wire) and ## Default, opt-out and strict mode -Encryption at rest is the default. When no key is configured, Flow creates -one on first use at `~/.openadapt/keys/at-rest.key` (`$OPENADAPT_HOME`, or -`$OPENADAPT_FLOW_AT_REST_HOME`, moves it), mode 0600 in a 0700 directory, and -refuses it if anyone else can read it. Back it up: what it encrypts can't be -read without it. A configured `OPENADAPT_BUNDLE_KEY` or -`OPENADAPT_RECORDING_KEY` always wins. - -| | Default | `OPENADAPT_FLOW_PLAINTEXT_AT_REST=''` | `OPENADAPT_FLOW_AT_REST=strict` | -| --- | --- | --- | --- | -| Web recordings | Encrypted | Plaintext | Encrypted | -| Bundles from `compile`, `induce`, `admit` | Encrypted, also from a plaintext recording | Plaintext | Encrypted | -| Durable checkpoints | Encrypted | Plaintext | Encrypted | -| Run directories | Plaintext, named | Plaintext | Encrypted, also for a plaintext bundle | -| Desktop recordings | Plaintext, named | Plaintext | Refused | -| Approval requests and frames, attended drafts, business decisions | Plaintext, named | Plaintext | Refused | -| `visualize`, `explain`, `teach`, `emit-skill`, `push`, `compose`, `process`, `variants`, `route`, `--save-healed-to`, `induce --report` | Plaintext, named | Plaintext | Refused before they run | - -**Named** means stderr names the path and one line goes to -`~/.openadapt/plaintext-at-rest.jsonl` (the ledger). OpenAdapt Desktop shows -a notice in its window from the same ledger. - -Run directories stay plaintext by default because the offline Seal, `undo`, -`attach-seal` and Desktop don't read encrypted runs yet. With a configured -key, a sealed bundle's run directory is encrypted as before. Issue -[#1751](https://github.com/OpenAdaptAI/OpenAdapt/issues/1751) tracks every -artifact that can't be encrypted yet. When the last one can, strict becomes -the default. - -The opt-out is for development and already de-identified data. Each process -that uses it prints a warning and adds a ledger line with the reason; -`record --no-encrypt` does the same for one web recording. Strict mode -ignores the opt-out, and an unknown value of `OPENADAPT_FLOW_AT_REST` stops -the command. +Flow encrypts with a configured key (`OPENADAPT_BUNDLE_KEY`, or +`OPENADAPT_RECORDING_KEY` for recordings) or, when none is set, with a key it +creates on first use at `~/.openadapt/keys/at-rest.key` (`$OPENADAPT_HOME`, +or `$OPENADAPT_FLOW_AT_REST_HOME`, moves it). The local key is mode 0600 in a +0700 directory, and Flow checks that only its owner can read it. Back it up +with your other secrets. + +| | Default | `OPENADAPT_FLOW_AT_REST=strict` | +| --- | --- | --- | +| Durable checkpoints and attended state | Encrypted | Encrypted | +| Web recordings | Encrypted with a configured key; otherwise logged | Encrypted | +| Bundles from `compile`, `induce`, `admit` | Encrypted from encrypted input; otherwise logged | Encrypted | +| Run directories | Encrypted for a sealed bundle with a configured key; otherwise logged | Encrypted | +| Desktop recordings, approval requests, and the output of `visualize`, `explain`, `teach`, `emit-skill`, `push`, `compose`, `process`, `variants`, `route`, `for-each`, `--save-healed-to`, `--workers` and `induce --report` | Logged | Held back before the command runs | + +**Logged** means stderr names the path and one line goes to +`~/.openadapt/plaintext-at-rest.jsonl`. OpenAdapt Desktop shows a notice in +its window from the same ledger. Issue +[#1751](https://github.com/OpenAdaptAI/OpenAdapt/issues/1751) tracks moving +each logged artifact to encrypted by default. **Healthcare and other regulated deployments:** set -`OPENADAPT_FLOW_AT_REST=strict` and a key from your secret manager. +`OPENADAPT_FLOW_AT_REST=strict` and a key from your secret manager. Strict +mode encrypts every recording, bundle and run directory, and an unknown value +of `OPENADAPT_FLOW_AT_REST` stops the command. + +`OPENADAPT_FLOW_PLAINTEXT_AT_REST=''` is for development and +already de-identified data: it turns encryption off, and each process that +uses it prints a warning and adds a ledger line with the reason. `record +--no-encrypt` does the same for one web recording. Strict mode ignores both. -OpenAdapt Desktop runs Flow under the opt-out, recorded with its own reason: -it encrypts certified workflows with per-workflow keys in the system -keychain, and its other files follow the table's opt-out column. +OpenAdapt Desktop encrypts certified workflows with per-workflow keys in the +system keychain, and runs Flow with the opt-out recorded under its own +reason. ## Summary @@ -182,13 +176,12 @@ complete ciphertext. The recording is finalized (indexed) when it finishes. `record --with-check` says it cannot mark a check in an encrypted recording yet and marks nothing. -Web recordings are encrypted by default, under the configured key or the -local one. `--no-encrypt` opts out for one recording, with a warning and a -ledger line; strict mode refuses it. 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. -By default they record in plaintext and name the directory; with -`OPENADAPT_RECORDING_KEY` set, or in strict mode, they refuse. +Web recordings are encrypted under a configured key, and under the local key +in strict mode. `--no-encrypt` records one in plaintext, with a warning and a +ledger line. The desktop recorders (`--backend +windows/macos/linux/rdp/citrix`) write a capture session (a SQLite database +and video) through openadapt-capture, and Flow logs its directory; with +`OPENADAPT_RECORDING_KEY` set, or in strict mode, use the web recorder. ### Cost @@ -240,8 +233,8 @@ that lists the commands above, rather than decrypt to disk. A bare ## What is not encrypted -Everything in the [default table](#default-opt-out-and-strict-mode) marked -"Plaintext, named", outside strict mode. Also: +Outside strict mode, the artifacts the [table](#default-opt-out-and-strict-mode) +marks as logged. Also: - 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 diff --git a/packages/flow/openadapt_flow/at_rest.py b/packages/flow/openadapt_flow/at_rest.py index 2d0c4e16c0..4ca3a38294 100644 --- a/packages/flow/openadapt_flow/at_rest.py +++ b/packages/flow/openadapt_flow/at_rest.py @@ -1,33 +1,29 @@ -"""Encryption at rest by default: a local key, or a loud, recorded opt-out. +"""Encryption at rest: the key, the strict mode, and the plaintext ledger. Recordings, bundles, run directories (screenshots, reports, evidence) and -durable checkpoints can hold patient data. Flow encrypts them whenever a key -is configured (:mod:`openadapt_flow.sealed_set`, :mod:`openadapt_flow.crypto`). -This module decides what happens when none is: Flow does not fall back to -plaintext. - -* With ``OPENADAPT_BUNDLE_KEY`` (or ``OPENADAPT_RECORDING_KEY``) set, that key - is used, as before. -* Otherwise Flow uses a key it generated on this machine, +durable checkpoints can hold patient data. Flow encrypts them with +:mod:`openadapt_flow.sealed_set` and :mod:`openadapt_flow.crypto`. + +* The key is ``OPENADAPT_BUNDLE_KEY`` (or ``OPENADAPT_RECORDING_KEY``) when + set. Otherwise it is a key Flow generates on this machine, ``$OPENADAPT_HOME/keys/at-rest.key`` (default ``~/.openadapt``; - ``OPENADAPT_FLOW_AT_REST_HOME`` overrides the directory). It is - created on first use with mode 0600 in a 0700 directory, and refused if - anyone but the owner can read it. Losing it loses every artifact sealed - with it, so back it up like any other secret. + ``OPENADAPT_FLOW_AT_REST_HOME`` moves it), created on first use with mode + 0600 in a 0700 directory and refused if anyone but the owner can read it. + Back it up: what it encrypts can't be read without it. +* Durable checkpoints and attended state are encrypted under that key by + default. +* ``OPENADAPT_FLOW_AT_REST=strict``, the setting for healthcare and regulated + deployments, also encrypts every recording, bundle and run directory, and + refuses the outputs that aren't encrypted yet (:func:`unsealed_write`). +* In the default mode, each recording, bundle, run directory or other output + written in plaintext goes through :func:`unsealed_write` or :func:`seal_new`: + stderr names the path and a ledger line (``plaintext-at-rest.jsonl``) + records it, and Desktop shows the ledger as a notice. Once every consumer + reads encrypted artifacts (#1751), the default encrypts them too + (:data:`DEFAULT_SEALS_ARTIFACTS`). * ``OPENADAPT_FLOW_PLAINTEXT_AT_REST=''`` turns encryption off for - development or for data that is already de-identified. Each process that - writes plaintext prints a warning to stderr and appends one line to - ``$OPENADAPT_HOME/plaintext-at-rest.jsonl`` with the reason. - -Some artifacts can't be sealed yet: run directories (the offline Seal, undo -and other readers don't read sealed runs yet), approval and attended state, -desktop recordings, and the output of ``visualize``, ``explain``, ``teach``, -``emit-skill`` and ``push``. By default they are still written in plaintext, -and each write goes through :func:`unsealed_write`, which names the path on -stderr and adds a ledger line that Desktop shows as a notice. -``OPENADAPT_FLOW_AT_REST=strict``, the setting for healthcare and regulated -deployments, refuses them instead, seals every run directory, and ignores the -opt-out. ``docs/phi_at_rest.md`` lists them and links the tracking issue. + development or de-identified data, with a warning and a ledger line. Strict + mode ignores it. Stdlib only, so :mod:`openadapt_flow.crypto` can import it on every load. """ @@ -308,8 +304,9 @@ def _local_key(path: Path) -> str: #: Whether the default mode seals new recordings and bundles. Strict mode -#: always does. See docs/phi_at_rest.md. -DEFAULT_SEALS_ARTIFACTS: Final[bool] = True +#: always does. It turns on once every consumer reads sealed artifacts +#: (#1751); until then the default names and records each plaintext write. +DEFAULT_SEALS_ARTIFACTS: Final[bool] = False def seal_new(artifact: str, path: Path | str) -> bool: diff --git a/packages/flow/openadapt_flow/compiler/compile.py b/packages/flow/openadapt_flow/compiler/compile.py index 64890c69b2..f01830b9f0 100644 --- a/packages/flow/openadapt_flow/compiler/compile.py +++ b/packages/flow/openadapt_flow/compiler/compile.py @@ -2043,10 +2043,10 @@ def compile_recording( 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. A plaintext recording - compiles into a sealed bundle too, under the configured or local key - (:mod:`openadapt_flow.at_rest`), unless ``OPENADAPT_FLOW_PLAINTEXT_AT_REST`` - allows plaintext. + when that is unset, the recording key. A plaintext recording compiles + into a sealed bundle when :func:`openadapt_flow.at_rest.seal_new` says so + (``OPENADAPT_FLOW_AT_REST=strict``); otherwise the plaintext bundle is + named on stderr and in the ledger. """ recording = Path(recording_dir) bundle = Path(out_bundle_dir) diff --git a/packages/flow/tests/test_at_rest_default.py b/packages/flow/tests/test_at_rest_default.py index 729c545720..8b04e02118 100644 --- a/packages/flow/tests/test_at_rest_default.py +++ b/packages/flow/tests/test_at_rest_default.py @@ -134,17 +134,29 @@ def test_unsealed_write_names_the_path_or_refuses( assert len(_ledger(default_mode)) == 1 -# -- what is sealed by default --------------------------------------------------- +# -- recordings and bundles ------------------------------------------------------- -def test_a_plaintext_recording_compiles_to_a_sealed_bundle( - default_mode: Path, tmp_path: Path +def test_a_plaintext_bundle_is_named_by_default( + default_mode: Path, tmp_path: Path, capsys ) -> None: - rec = _recording(tmp_path / "rec", key=None) # made before the default + rec = _recording(tmp_path / "rec", key=None) + assert main(["compile", str(rec), "--out", str(tmp_path / "b"), "--name", "x"]) == 0 + assert (tmp_path / "b" / "workflow.json").is_file() + assert str(tmp_path / "b") in capsys.readouterr().err + assert _ledger(default_mode)[0]["path"] == str(tmp_path / "b") + + +def test_strict_mode_compiles_a_plaintext_recording_to_a_sealed_bundle( + default_mode: Path, tmp_path: Path, monkeypatch +) -> None: + rec = _recording(tmp_path / "rec", key=None) # made before strict mode + monkeypatch.setenv(at_rest.MODE_ENV, "strict") assert main(["compile", str(rec), "--out", str(tmp_path / "b"), "--name", "x"]) == 0 _assert_ciphertext_only(tmp_path / "b") assert Workflow.load(tmp_path / "b").encrypted assert sealed_set.SealedSet.open(tmp_path / "b", crypto.resolve_key(None)) + assert _ledger(default_mode) == [] def test_compile_under_the_opt_out_stays_plaintext( @@ -154,14 +166,24 @@ def test_compile_under_the_opt_out_stays_plaintext( rec = _recording(tmp_path / "rec", key=None) assert main(["compile", str(rec), "--out", str(tmp_path / "b"), "--name", "x"]) == 0 assert (tmp_path / "b" / "workflow.json").is_file() - assert _ledger(default_mode)[0]["purpose"] == "a compiled bundle" + assert any(e["purpose"] == "a compiled bundle" for e in _ledger(default_mode)) + + +def test_a_recording_only_key_seals_bundles_under_that_key( + default_mode: Path, tmp_path: Path, monkeypatch +) -> None: + monkeypatch.setenv(sealed_set.RECORDING_ENV_KEY, "recording-only-key") + rec = _recording(tmp_path / "rec", key="recording-only-key") + assert main(["compile", str(rec), "--out", str(tmp_path / "b"), "--name", "x"]) == 0 + assert sealed_set.SealedSet.open(tmp_path / "b", "recording-only-key") -def test_induce_seals_by_default_and_refuses_a_plaintext_report( - default_mode: Path, tmp_path: Path +def test_strict_induce_seals_and_refuses_a_plaintext_report( + default_mode: Path, tmp_path: Path, monkeypatch ) -> None: first = _recording(tmp_path / "rec1", key=None) second = _recording(tmp_path / "rec2", key=None) + monkeypatch.setenv(at_rest.MODE_ENV, "strict") report = tmp_path / "induce.json" refused = main( [ @@ -179,14 +201,32 @@ def test_induce_seals_by_default_and_refuses_a_plaintext_report( _assert_ciphertext_only(tmp_path / "i") +def test_a_key_from_another_deployment_gets_a_clear_message( + default_mode: Path, tmp_path: Path, monkeypatch +) -> None: + rec = _recording(tmp_path / "rec", key="deployment-key") + with pytest.raises(sealed_set.SealedSetKeyError, match="OPENADAPT_RECORDING_KEY"): + sealed_set.open_set(rec) + + def _record_args(out: Path, *, no_encrypt: bool = False) -> argparse.Namespace: return argparse.Namespace(out=str(out), no_encrypt=no_encrypt) -def test_web_recordings_get_the_local_key(default_mode: Path, tmp_path: Path) -> None: - assert _recording_encryption_key(_record_args(tmp_path / "r"), "web") == ( +def test_web_recordings_are_named_by_default_and_sealed_in_strict_mode( + default_mode: Path, tmp_path: Path, monkeypatch +) -> None: + assert _recording_encryption_key(_record_args(tmp_path / "r"), "web") is None + assert _ledger(default_mode)[0]["path"] == str(tmp_path / "r") + monkeypatch.setenv(at_rest.MODE_ENV, "strict") + assert _recording_encryption_key(_record_args(tmp_path / "r2"), "web") == ( crypto.resolve_key(None) ) + monkeypatch.delenv(at_rest.MODE_ENV) + monkeypatch.setenv(crypto.ENV_KEY, "deployment-key") + assert _recording_encryption_key(_record_args(tmp_path / "r3"), "web") == ( + "deployment-key" + ) def test_no_encrypt_is_recorded_and_strict_refuses_it( From 89af03970fe6fcac5f613295164028e6c40a532f Mon Sep 17 00:00:00 2001 From: abrichr Date: Tue, 3 Nov 2026 14:46:36 -0500 Subject: [PATCH 05/21] fix: bundle reads use the recording-only key; tests for redacted agent screenshots --- packages/flow/openadapt_flow/sealed_set.py | 8 +++---- packages/flow/tests/test_at_rest_default.py | 3 +-- packages/flow/tests/test_compile_agent.py | 23 ++++++++++++++++++++- 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/packages/flow/openadapt_flow/sealed_set.py b/packages/flow/openadapt_flow/sealed_set.py index 1f06e68315..dc3734fc22 100644 --- a/packages/flow/openadapt_flow/sealed_set.py +++ b/packages/flow/openadapt_flow/sealed_set.py @@ -295,7 +295,9 @@ def recording_key(explicit: Optional[str] = None) -> Optional[str]: def passphrase_for(kind: str, explicit: Optional[str] = None) -> Optional[str]: if kind == "recording": return recording_key(explicit) - return crypto.resolve_key(explicit) + if explicit: + return explicit + return output_bundle_key() def output_bundle_key() -> Optional[str]: @@ -919,9 +921,7 @@ def open_set(root: Path | str, key: Optional[str] = None) -> SealedSet: def _configured(kind: str) -> bool: - if os.environ.get(crypto.ENV_KEY): - return True - return kind == "recording" and bool(os.environ.get(RECORDING_ENV_KEY)) + return bool(os.environ.get(crypto.ENV_KEY) or os.environ.get(RECORDING_ENV_KEY)) def _reader(root: str) -> SealedSet | SealedSetWriter: diff --git a/packages/flow/tests/test_at_rest_default.py b/packages/flow/tests/test_at_rest_default.py index 8b04e02118..d6026faa52 100644 --- a/packages/flow/tests/test_at_rest_default.py +++ b/packages/flow/tests/test_at_rest_default.py @@ -98,8 +98,7 @@ def test_opt_out_restores_plaintext_loudly_and_records_it( assert at_rest.plaintext_allowed("a compiled bundle") # warns once err = capsys.readouterr().err assert err.count("WARNING: writing a compiled bundle in plaintext") == 1 - [line] = _ledger(default_mode) - assert line["purpose"] == "a compiled bundle" + [line] = [e for e in _ledger(default_mode) if e["purpose"] == "a compiled bundle"] assert line["reason"] == "de-identified fixtures" diff --git a/packages/flow/tests/test_compile_agent.py b/packages/flow/tests/test_compile_agent.py index 9ab412a0c3..37d3902e89 100644 --- a/packages/flow/tests/test_compile_agent.py +++ b/packages/flow/tests/test_compile_agent.py @@ -158,6 +158,26 @@ def test_scrubbing_keeps_the_values_the_operator_named(monkeypatch) -> None: # -- the Claude loop ------------------------------------------------------------- +class _OutboundScrubber(_Scrubber): + """Text and image scrubbing for the model-facing screenshots.""" + + def __init__(self) -> None: + self.images = 0 + + def scrub_image(self, image, fill_color=None): + self.images += 1 + return image + + +@pytest.fixture(autouse=True) +def _outbound_scrubber(): + """Screenshots sent to the model are redacted first (agents._image).""" + scrubber = _OutboundScrubber() + privacy.set_text_scrubber(scrubber) + yield scrubber + privacy.reset_scrubbers() + + class _Harness: """Stands in for the browser harness: a fixed listing and counters.""" @@ -216,7 +236,7 @@ def create(self, **kw: Any) -> Any: REQUEST = TaskRequest(task_id="t", instruction="Save it", inputs={}) -def test_claude_loop_counts_tokens_and_the_gate_screenshot() -> None: +def test_claude_loop_counts_tokens_and_the_gate_screenshot(_outbound_scrubber) -> None: client = _Client( [ ("click", {"ref": "e9"}), @@ -229,6 +249,7 @@ def test_claude_loop_counts_tokens_and_the_gate_screenshot() -> None: assert ledger.finished and ledger.summary == "done" assert ledger.model_calls == 3 and ledger.tool_errors == 1 # the stale ref assert harness.clicks == ["e1"] + assert _outbound_scrubber.images >= 1 # redacted before the model saw it # The save's result carried a screenshot: the verification gate. messages = client.requests[2]["messages"] # the loop appends to one list third = messages[4]["content"][0]["content"] From b06810559ace557851b33b4e21c4e20cff4ba925 Mon Sep 17 00:00:00 2001 From: abrichr Date: Tue, 3 Nov 2026 15:32:14 -0500 Subject: [PATCH 06/21] style: ruff format --- packages/flow/openadapt_flow/__main__.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/flow/openadapt_flow/__main__.py b/packages/flow/openadapt_flow/__main__.py index 103fa1f914..ab0d9fae7b 100644 --- a/packages/flow/openadapt_flow/__main__.py +++ b/packages/flow/openadapt_flow/__main__.py @@ -682,7 +682,9 @@ def _recording_encryption_key(args: argparse.Namespace, backend: str) -> Optiona if explicit or os.environ.get(crypto.ENV_KEY): return explicit or crypto.resolve_key(None) try: - return crypto.resolve_key(None) if at_rest.seal_new("a recording", out) else None + return ( + crypto.resolve_key(None) if at_rest.seal_new("a recording", out) else None + ) except at_rest.AtRestRefused as exc: raise SystemExit(f"record: {exc}") from exc @@ -12625,7 +12627,9 @@ def _unsealed_output_check(args: argparse.Namespace) -> None: at_rest.unsealed_write("a healed bundle (--save-healed-to)", healed) workers = getattr(args, "workers", None) if isinstance(workers, int) and workers > 0: - at_rest.unsealed_write("the worker pool report (--workers)", "the run directory") + at_rest.unsealed_write( + "the worker pool report (--workers)", "the run directory" + ) func = getattr(args, "func", None) command = f"{getattr(func, '__module__', '')}:{getattr(func, '__name__', '')}" artifact = _UNSEALED_OUTPUT_COMMANDS.get(command) From 1def79310481be0f2c937ce5c1f8b2e842cd293a Mon Sep 17 00:00:00 2001 From: abrichr Date: Tue, 3 Nov 2026 14:58:17 -0500 Subject: [PATCH 07/21] feat: one artifact reader; visualize, explain, emit-skill, for-each, sections and check mark read sealed input Add openadapt_flow.artifacts (thin wrapper over the sealed_set path helpers) and route the artifact reads of these commands through it. for-each of a sealed body writes a sealed loop bundle; emit-skill copies a sealed bundle as ciphertext; record --with-check marks a check in an encrypted recording. A lint test fails when a converted function reads an artifact with pathlib or open(). Refs #1751. --- CAPABILITIES.md | 2 +- llms-full.txt | 2 +- packages/flow/docs/phi_at_rest.md | 19 +- packages/flow/openadapt_flow/__main__.py | 82 ++++-- .../openadapt_flow/admitted_composition.py | 4 +- packages/flow/openadapt_flow/artifacts.py | 64 +++++ packages/flow/openadapt_flow/cli_check.py | 4 +- packages/flow/openadapt_flow/composition.py | 4 +- packages/flow/openadapt_flow/narration.py | 12 +- .../flow/openadapt_flow/scaffold_verifier.py | 13 +- packages/flow/openadapt_flow/skills.py | 12 +- packages/flow/tests/test_artifact_reads.py | 239 ++++++++++++++++++ packages/flow/tests/test_sealed_at_rest.py | 7 +- 13 files changed, 413 insertions(+), 51 deletions(-) create mode 100644 packages/flow/openadapt_flow/artifacts.py create mode 100644 packages/flow/tests/test_artifact_reads.py diff --git a/CAPABILITIES.md b/CAPABILITIES.md index 69d4c95729..3053a30ac1 100644 --- a/CAPABILITIES.md +++ b/CAPABILITIES.md @@ -301,7 +301,7 @@ improvising or retrying when something is off. | 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` | | See a compiled program as a map (every step, exact edge, loop and stop rule, with each step's checks) and play a recorded run over it; one React package that Desktop, app.openadapt.ai and openadapt.ai share | On main ([packages/program-graph](packages/program-graph/README.md)) | Real compiled OpenEMR referral program and a recorded scoreboard run, both on synthetic data | `@openadapt/program-graph`; `openadapt-flow visualize --format json` | | Named sections for every compiled program ("Who: title, first name, last name", "Contact", "Create New Patient"), found without a model from the save, the recorded form structure, the fields and any narration; a person can rename, merge or split them on the review screen | On main | Unit tests; the certified OpenEMR referral bundle and a real recording of it on a local test copy with made-up patients | `openadapt-flow sections`; [SECTIONS.md](packages/flow/docs/SECTIONS.md) | -| 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 | On main | 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) | +| 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, undo, visualize, explain, emit-skill, for-each, sections and check mark read them in memory through one artifact reader and refuse without the key | On main | 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) | | Encryption at rest under a configured or locally generated key (0600): durable checkpoints and attended state by default; with `OPENADAPT_FLOW_AT_REST=strict`, every recording, bundle and run directory too. Each plaintext write is logged on stderr and in a ledger that Desktop shows as a notice | On main | Synthetic (key, opt-out, strict-mode and run-directory tests) | `openadapt_flow.at_rest`; [phi_at_rest.md](packages/flow/docs/phi_at_rest.md#default-opt-out-and-strict-mode) | | Signed receipts (Seals), countersigned and timestamped, offline by default | Released | Unit tests, real TSA tokens | `openadapt-flow seal`, `countersign`, `local-seal` | | Offline Seals signed with a key the customer holds: a key file, the OS keychain, or a PKCS#11 token or HSM; pinned keys with rotation and revocation; no fallback to a generated key | On main ([#1392](https://github.com/OpenAdaptAI/OpenAdapt/pull/1392)) | Unit tests with synthetic data (keychain and PKCS#11 through stand-ins) | `openadapt-flow local-seal --key`, `local-seal key create`, `pin`, `revoke`; `openadapt seal verify FOLDER` checks every sealed run in a folder; [OFFLINE_SEALS.md](packages/flow/docs/OFFLINE_SEALS.md#customer-held-keys) | diff --git a/llms-full.txt b/llms-full.txt index af71bba2cb..502a379cc4 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -698,7 +698,7 @@ improvising or retrying when something is off. | 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` | | See a compiled program as a map (every step, exact edge, loop and stop rule, with each step's checks) and play a recorded run over it; one React package that Desktop, app.openadapt.ai and openadapt.ai share | On main ([packages/program-graph](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/program-graph/README.md)) | Real compiled OpenEMR referral program and a recorded scoreboard run, both on synthetic data | `@openadapt/program-graph`; `openadapt-flow visualize --format json` | | Named sections for every compiled program ("Who: title, first name, last name", "Contact", "Create New Patient"), found without a model from the save, the recorded form structure, the fields and any narration; a person can rename, merge or split them on the review screen | On main | Unit tests; the certified OpenEMR referral bundle and a real recording of it on a local test copy with made-up patients | `openadapt-flow sections`; [SECTIONS.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/SECTIONS.md) | -| 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 | On main | 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) | +| 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, undo, visualize, explain, emit-skill, for-each, sections and check mark read them in memory through one artifact reader and refuse without the key | On main | 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) | | Encryption at rest under a configured or locally generated key (0600): durable checkpoints and attended state by default; with `OPENADAPT_FLOW_AT_REST=strict`, every recording, bundle and run directory too. Each plaintext write is logged on stderr and in a ledger that Desktop shows as a notice | On main | Synthetic (key, opt-out, strict-mode and run-directory tests) | `openadapt_flow.at_rest`; [phi_at_rest.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/phi_at_rest.md#default-opt-out-and-strict-mode) | | Signed receipts (Seals), countersigned and timestamped, offline by default | Released | Unit tests, real TSA tokens | `openadapt-flow seal`, `countersign`, `local-seal` | | Offline Seals signed with a key the customer holds: a key file, the OS keychain, or a PKCS#11 token or HSM; pinned keys with rotation and revocation; no fallback to a generated key | On main ([#1392](https://github.com/OpenAdaptAI/OpenAdapt/pull/1392)) | Unit tests with synthetic data (keychain and PKCS#11 through stand-ins) | `openadapt-flow local-seal --key`, `local-seal key create`, `pin`, `revoke`; `openadapt seal verify FOLDER` checks every sealed run in a folder; [OFFLINE_SEALS.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/OFFLINE_SEALS.md#customer-held-keys) | diff --git a/packages/flow/docs/phi_at_rest.md b/packages/flow/docs/phi_at_rest.md index 1fcb18d002..066625f2ca 100644 --- a/packages/flow/docs/phi_at_rest.md +++ b/packages/flow/docs/phi_at_rest.md @@ -203,10 +203,13 @@ 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`, `approve`, `resume` and `undo` read -sealed bundles and encrypted recordings in memory, given the key, and keep -what they write sealed: +`record` (including `--with-check`), `compile`, `induce`, +`scaffold-verifier`, `seal`, `certify`, `qualify`, `admit`, `run`, `replay`, +`approve`, `resume`, `undo`, `visualize`, `explain`, `emit-skill`, +`for-each`, `sections` and `check mark` read sealed bundles, run directories +and encrypted recordings in memory, given the key, and keep what they write +inside a sealed set sealed. Each reads artifact files through one reader, +`openadapt_flow.artifacts`: - `compile` of an encrypted recording writes a sealed bundle, under `OPENADAPT_BUNDLE_KEY` or, when that is unset, the recording's key. It @@ -225,6 +228,14 @@ what they write sealed: 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. +- `for-each` of a sealed bundle writes the looped bundle sealed, under the + same key rules as `compile`. +- `emit-skill` copies a sealed bundle into the skill folder as ciphertext. + `visualize` and `emit-skill` write their page and `SKILL.md` outside the + sealed set: by default they name it on stderr and in the ledger, and strict + mode refuses them before they run. +- `sections` and `check mark` write `sections.json` and `check.json` back + into the sealed bundle or recording, encrypted. 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 diff --git a/packages/flow/openadapt_flow/__main__.py b/packages/flow/openadapt_flow/__main__.py index ab0d9fae7b..5dfb3f1809 100644 --- a/packages/flow/openadapt_flow/__main__.py +++ b/packages/flow/openadapt_flow/__main__.py @@ -1394,17 +1394,12 @@ def _cmd_record(args: argparse.Namespace) -> int: if code == 0 and (Path(args.out) / "events.jsonl").is_file(): _propose_lookup_check(args, from_lookup=from_lookup) if code == 0 and with_check: - from openadapt_flow import sealed_set + from openadapt_flow import artifacts from openadapt_flow.cli_check import guided_check from openadapt_flow.cli_hints import command recording = Path(args.out) - 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(): + if not artifacts.is_file(recording / "events.jsonl"): print("record: no events.jsonl to mark a check in; skipped") elif not sys.stdin.isatty(): mark = command( @@ -2404,8 +2399,9 @@ def _cmd_narration_show(args: argparse.Namespace) -> int: def _cmd_for_each(args: argparse.Namespace) -> int: - import shutil + import os + from openadapt_flow import artifacts, sealed_set from openadapt_flow.compiler.loop_authoring import ( LoopAuthoringError, author_data_driven_loop, @@ -2413,9 +2409,8 @@ def _cmd_for_each(args: argparse.Namespace) -> int: from openadapt_flow.ir import Workflow src = Path(args.bundle) - if ( - not (src / "workflow.json").is_file() - and not (src / "workflow.json.enc").is_file() + if not artifacts.is_file(src / "workflow.json") and not artifacts.is_file( + src / "workflow.json.enc" ): raise SystemExit(f"{src} is not a workflow bundle (no workflow.json)") body = Workflow.load(src) @@ -2451,23 +2446,38 @@ def _cmd_for_each(args: argparse.Namespace) -> int: out = Path(args.out) out.mkdir(parents=True, exist_ok=True) - # Copy the demonstrated body's templates so the looped bundle is a complete, - # self-verifying artifact (the loop body reuses the body's anchors/crops). - src_templates = src / "templates" - if src_templates.is_dir(): - shutil.copytree(src_templates, out / "templates", dirs_exist_ok=True) - looped.save(out) - # The body's named sections (display metadata) follow it into the loop: - # its recorded form structure and review edits apply to the same steps. from openadapt_flow import sections as program_sections body_sections = program_sections.load(src, body) - program_sections.write( - out, - program_sections.build( - looped, context=body_sections.context, edits=body_sections.edits - ), - ) + + def write_looped() -> None: + # Copy the demonstrated body's templates so the looped bundle is a + # complete, self-verifying artifact (the loop body reuses its crops). + templates = Path(os.path.abspath(src / "templates")) + for template in artifacts.list_files(templates): + target = out / "templates" / template.relative_to(templates) + if not sealed_set.is_sealed_path(out): + target.parent.mkdir(parents=True, exist_ok=True) + sealed_set.write_bytes(target, artifacts.open_read(template)) + looped.save(out) + # The body's named sections (display metadata) follow it into the + # loop: its recorded form structure and review edits apply to the + # same steps. + program_sections.write( + out, + program_sections.build( + looped, context=body_sections.context, edits=body_sections.edits + ), + ) + + if artifacts.is_sealed(src): + # A sealed body makes a sealed loop: nothing it holds lands in plaintext. + passphrase = sealed_set.output_bundle_key() + assert passphrase is not None # resolves to the local key by default + with sealed_set.writing(out, "bundle", passphrase): + write_looped() + else: + write_looped() relation = args.relation rel = looped.data_sources[relation] @@ -12583,6 +12593,13 @@ def _qualify_default_form(argv: list[str]) -> list[str]: "openadapt_flow.__main__:_cmd_resume", "openadapt_flow.__main__:_cmd_approve", "openadapt_flow.__main__:_cmd_qualify", + # Read through openadapt_flow.artifacts (tests/test_artifact_reads.py). + "openadapt_flow.__main__:_cmd_visualize", + "openadapt_flow.__main__:_cmd_explain", + "openadapt_flow.__main__:_cmd_emit_skill", + "openadapt_flow.__main__:_cmd_for_each", + "openadapt_flow.cli_sections:cmd_sections", + "openadapt_flow.cli_check:cmd_check_mark", "openadapt_flow.cli_admit:cmd_admit_run", "openadapt_flow.cli_admit:cmd_admit_decide", "openadapt_flow.cli_admit:cmd_admit_status", @@ -12614,6 +12631,12 @@ def _qualify_default_form(argv: list[str]) -> list[str]: } +#: Commands that write a new bundle sealed when their input bundle is sealed. +_SEALED_OUTPUT_FROM_SEALED_INPUT: frozenset[str] = frozenset( + {"openadapt_flow.__main__:_cmd_for_each"} +) + + def _unsealed_output_check(args: argparse.Namespace) -> None: """Warn and record, or refuse in strict mode, a command whose output can't be encrypted yet. Also rejects a mistyped OPENADAPT_FLOW_AT_REST.""" @@ -12635,6 +12658,12 @@ def _unsealed_output_check(args: argparse.Namespace) -> None: artifact = _UNSEALED_OUTPUT_COMMANDS.get(command) if artifact is None: return + if command in _SEALED_OUTPUT_FROM_SEALED_INPUT: + from openadapt_flow import sealed_set + + source = getattr(args, "bundle", None) + if source and sealed_set.is_sealed_path(source): + return # a sealed input makes a sealed output target = next( ( getattr(args, name) @@ -12685,7 +12714,8 @@ 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, approve, resume and undo." + "replay, approve, resume, undo, visualize, explain, emit-skill, " + "for-each, sections and check mark." ) for root in sealed: kind = sealed_set.header_kind(root) diff --git a/packages/flow/openadapt_flow/admitted_composition.py b/packages/flow/openadapt_flow/admitted_composition.py index 45fb67d883..2bfe27e1c2 100644 --- a/packages/flow/openadapt_flow/admitted_composition.py +++ b/packages/flow/openadapt_flow/admitted_composition.py @@ -380,7 +380,9 @@ class ProcessContractV1(ProcessContract): def is_process_contract_artifact(path: Path | str) -> bool: """True when ``path`` is a process-contract parent directory.""" - return (Path(path) / PROCESS_CONTRACT_FILENAME).is_file() + from openadapt_flow import artifacts + + return artifacts.is_file(Path(path) / PROCESS_CONTRACT_FILENAME) def resolve_pointer(parent_dir: Path | str, stored: str) -> Path: diff --git a/packages/flow/openadapt_flow/artifacts.py b/packages/flow/openadapt_flow/artifacts.py new file mode 100644 index 0000000000..b054c61084 --- /dev/null +++ b/packages/flow/openadapt_flow/artifacts.py @@ -0,0 +1,64 @@ +"""The one way a command reads a recording, bundle or run-directory file. + +A recording, a bundle and a run directory can each be a plain directory or a +sealed set (docs/phi_at_rest.md). These helpers read either: a plain path +reads like :mod:`pathlib`, a path inside a sealed set decrypts in memory with +the configured key (or the local key). Nothing is decrypted to disk. + +They delegate to :mod:`openadapt_flow.sealed_set`; there is no crypto here. +Read a workflow through :meth:`openadapt_flow.ir.Workflow.load`, which +verifies a sealed bundle the same way. Files a person passes in that are not +part of an artifact (configs, CSVs, ``deploy.yaml``) are read directly. + +``tests/test_artifact_reads.py`` fails when a converted command module reads +an artifact file with ``pathlib`` or ``open()`` instead. +""" + +from __future__ import annotations + +from pathlib import Path + +from openadapt_flow import sealed_set + + +def open_read(path: Path | str) -> bytes: + """The bytes of an artifact file, decrypted in memory when sealed.""" + return sealed_set.read_bytes(path) + + +def read_text(path: Path | str, encoding: str = "utf-8") -> str: + return sealed_set.read_text(path, encoding) + + +def is_file(path: Path | str) -> bool: + return sealed_set.is_file(path) + + +def is_dir(path: Path | str) -> bool: + return sealed_set.is_dir(path) + + +def exists(path: Path | str) -> bool: + return sealed_set.exists(path) + + +def list_files(directory: Path | str, pattern: str = "*") -> list[Path]: + """Artifact files under ``directory`` whose relative path matches + ``pattern`` (``fnmatch``), by their logical names.""" + return sealed_set.list_files(directory, pattern) + + +def is_sealed(path: Path | str) -> bool: + """Whether ``path`` is, or is inside, a sealed set.""" + return sealed_set.is_sealed_path(path) + + +__all__ = [ + "exists", + "is_dir", + "is_file", + "is_sealed", + "list_files", + "open_read", + "read_text", +] diff --git a/packages/flow/openadapt_flow/cli_check.py b/packages/flow/openadapt_flow/cli_check.py index 2febf71e6c..eef52297b4 100644 --- a/packages/flow/openadapt_flow/cli_check.py +++ b/packages/flow/openadapt_flow/cli_check.py @@ -321,7 +321,9 @@ def guided_check( Returns the written ``check.json``, or None when the person skipped it. """ - lines = (recording / "events.jsonl").read_text(encoding="utf-8").splitlines() + from openadapt_flow import artifacts + + lines = artifacts.read_text(recording / "events.jsonl").splitlines() events = [json.loads(line) for line in lines if line.strip()] echo(CHECK_PROMPT) echo("Recorded events:") diff --git a/packages/flow/openadapt_flow/composition.py b/packages/flow/openadapt_flow/composition.py index 9fc0950507..b7e142ba8e 100644 --- a/packages/flow/openadapt_flow/composition.py +++ b/packages/flow/openadapt_flow/composition.py @@ -195,7 +195,9 @@ def load(cls, parent_dir: Path | str) -> "Composition": def is_composition_artifact(path: Path | str) -> bool: """True when ``path`` is a composition parent directory.""" - return (Path(path) / COMPOSITION_FILENAME).is_file() + from openadapt_flow import artifacts + + return artifacts.is_file(Path(path) / COMPOSITION_FILENAME) def child_bundle_path(parent_dir: Path | str, child: ChildSpec) -> Path: diff --git a/packages/flow/openadapt_flow/narration.py b/packages/flow/openadapt_flow/narration.py index 9936c73d99..8213d1c687 100644 --- a/packages/flow/openadapt_flow/narration.py +++ b/packages/flow/openadapt_flow/narration.py @@ -87,12 +87,14 @@ def step_id_for_event(index: int) -> str: def read_events(recording_dir: Path) -> list[dict[str, Any]]: + from openadapt_flow import artifacts + path = Path(recording_dir) / "events.jsonl" - if not path.is_file(): + if not artifacts.is_file(path): raise FileNotFoundError(f"no events.jsonl in {recording_dir}") return [ json.loads(line) - for line in path.read_text(encoding="utf-8").splitlines() + for line in artifacts.read_text(path).splitlines() if line.strip() ] @@ -158,10 +160,12 @@ def write_narration(directory: Path, narration: Narration) -> Path: def load_narration(path: Path | str) -> Optional[Narration]: """Read ``narration.json`` from a recording or bundle directory, or None.""" + from openadapt_flow import artifacts + file = Path(path) / NARRATION_FILE - if not file.is_file(): + if not artifacts.is_file(file): return None - data = json.loads(file.read_text(encoding="utf-8")) + data = json.loads(artifacts.read_text(file)) if data.get("schema") != NARRATION_SCHEMA: raise NarrationRefused(f"{file} is not a narration file") if data.get("scrubbed") is not True: diff --git a/packages/flow/openadapt_flow/scaffold_verifier.py b/packages/flow/openadapt_flow/scaffold_verifier.py index 200b9b8050..b52e5c8306 100644 --- a/packages/flow/openadapt_flow/scaffold_verifier.py +++ b/packages/flow/openadapt_flow/scaffold_verifier.py @@ -43,6 +43,7 @@ from pathlib import Path from typing import Any, Optional +from openadapt_flow import artifacts as _artifacts from openadapt_flow import sealed_set as _sealed BUNDLE_MARKERS = ("workflow.json", "workflow.json.enc") @@ -675,9 +676,9 @@ def _pending_decision(run_dir: Path) -> tuple[Optional[Path], Optional[str]]: encrypted_path = run_dir / (PENDING_FILENAME + ENC_SUFFIX) path = ( plain_path - if plain_path.is_file() + if _artifacts.is_file(plain_path) else encrypted_path - if encrypted_path.is_file() + if _artifacts.is_file(encrypted_path) else None ) if path is None: @@ -707,13 +708,13 @@ def explain_run(run_dir: Path) -> str: from openadapt_flow.plain_outcome import plain_result report_path = run_dir / "report.json" - if not report_path.is_file(): + if not _artifacts.is_file(report_path): raise SystemExit( f"explain: {run_dir} holds no report.json -- nothing to explain " "(is this a completed run directory?)" ) try: - report = RunReport.model_validate_json(report_path.read_text(encoding="utf-8")) + report = RunReport.model_validate_json(_artifacts.read_text(report_path)) except (OSError, ValueError) as exc: raise SystemExit( f"explain: {report_path} could not be read as a run report: {exc}" @@ -740,7 +741,7 @@ def explain_run(run_dir: Path) -> str: "Why this is safe: every declared effect was independently confirmed " "in a system of record before the run claimed success." ) - if receipt.is_file(): + if _artifacts.is_file(receipt): lines.append(f"Shareable receipt: {receipt}") elif outcome == "HALTED": lines.append(f"Why this is safe: {_halt_check_line(report)}.") @@ -818,6 +819,6 @@ def explain_run(run_dir: Path) -> str: f"record is checked, approve with {command('approve ' + quoted)}, " f"then continue with {command('resume ' + quoted)}." ) - if report_md.is_file(): + if _artifacts.is_file(report_md): lines.append(f"Plain-language evidence: {report_md}") return "\n".join(lines) diff --git a/packages/flow/openadapt_flow/skills.py b/packages/flow/openadapt_flow/skills.py index f31ad7205e..37447b9527 100644 --- a/packages/flow/openadapt_flow/skills.py +++ b/packages/flow/openadapt_flow/skills.py @@ -75,11 +75,13 @@ def _write_json(path: Path, value: Any, *, private: bool = False) -> None: def read_lock(bundle_dir: Path | str) -> Optional[dict[str, Any]]: + from openadapt_flow import artifacts + path = Path(bundle_dir) / LOCK - if not path.is_file(): + if not artifacts.is_file(path): return None try: - lock = json.loads(path.read_text()) + lock = json.loads(artifacts.read_text(path)) except json.JSONDecodeError as exc: raise SkillError(f"{path} is not JSON: {exc}") from exc if not isinstance(lock, dict) or lock.get("schema") != LOCK_SCHEMA: @@ -88,10 +90,12 @@ def read_lock(bundle_dir: Path | str) -> Optional[dict[str, Any]]: def read_binding(bundle_dir: Path | str) -> dict[str, str]: + from openadapt_flow import artifacts + path = Path(bundle_dir) / BINDING - if not path.is_file(): + if not artifacts.is_file(path): return {} - data = json.loads(path.read_text()) + data = json.loads(artifacts.read_text(path)) if not isinstance(data, dict) or data.get("schema") != BINDING_SCHEMA: raise SkillError(f"{path} is not a skill binding ({BINDING_SCHEMA})") return {str(k): str(v) for k, v in (data.get("values") or {}).items()} diff --git a/packages/flow/tests/test_artifact_reads.py b/packages/flow/tests/test_artifact_reads.py new file mode 100644 index 0000000000..e775f7de01 --- /dev/null +++ b/packages/flow/tests/test_artifact_reads.py @@ -0,0 +1,239 @@ +"""Commands that read sealed input read every artifact file through +:mod:`openadapt_flow.artifacts`. + +Two parts: + +- One test per command added to ``__main__._SEALED_READY_COMMANDS`` here: + it runs on a sealed bundle, run directory or recording, succeeds, and leaves + the sealed set ciphertext only (docs/phi_at_rest.md). +- A lint over the functions those commands use to read artifacts. Inside + them, a call to ``.read_text()``, ``.read_bytes()``, ``.is_file()``, + ``.exists()``, ``.open()`` or the builtin ``open()`` must go through + ``artifacts`` (or the ``sealed_set`` helpers it wraps). ``pathlib`` reads a + sealed file as ciphertext, or misses it, so a direct read is a bug the day + the input is sealed. A read of a file that is not part of an artifact (a + config or CSV the person passes in) goes on :data:`ALLOWED`, with the reason. + +Everything here is synthetic and runs without a browser. +""" + +from __future__ import annotations + +import ast +import json +from pathlib import Path + +import pytest + +from openadapt_flow import crypto, sealed_set +from openadapt_flow.__main__ import _SEALED_READY_COMMANDS, main +from openadapt_flow.ir import ActionKind, Step, Workflow +from tests.test_sealed_at_rest import ( + KEY, + _assert_ciphertext_only, + _files, + _plain_bundle, +) + +_PKG = Path(__file__).resolve().parent.parent / "openadapt_flow" + +#: The functions that read artifacts for the converted commands, by module. +CONVERTED: dict[str, tuple[str, ...]] = { + "__main__.py": ( + "_cmd_visualize", + "_cmd_explain", + "_cmd_emit_skill", + "_cmd_for_each", + "_cmd_record", + ), + "admitted_composition.py": ("is_process_contract_artifact",), + "composition.py": ("is_composition_artifact",), + "scaffold_verifier.py": ("explain_run", "_pending_decision"), + "emit/skill.py": ("emit_skill",), + "skills.py": ("read_lock", "read_binding"), + "sections.py": ("read_file", "load"), + "narration.py": ("read_events", "load_narration"), + "cli_sections.py": ("cmd_sections",), + "cli_check.py": ("cmd_check_mark", "guided_check"), +} + +#: Receivers whose reads are sealed-aware. +_READERS = frozenset({"artifacts", "_artifacts", "sealed_set", "_sealed"}) +_READ_CALLS = frozenset({"read_text", "read_bytes", "is_file", "exists", "open"}) + +#: (module, function, source of the call) -> why a direct read is right. +ALLOWED: dict[tuple[str, str, str], str] = { + ( + "__main__.py", + "_cmd_record", + '(Path(args.out) / "events.jsonl").is_file()', + ): "gates the look-up proposal, which runs on plaintext recordings only", +} + + +def _direct_reads() -> list[tuple[str, str, str]]: + found: list[tuple[str, str, str]] = [] + for module, names in CONVERTED.items(): + source = (_PKG / module).read_text(encoding="utf-8") + tree = ast.parse(source) + functions = { + node.name: node + for node in ast.walk(tree) + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + } + for name in names: + assert name in functions, f"{module}:{name} is gone; update CONVERTED" + for node in ast.walk(functions[name]): + if not isinstance(node, ast.Call): + continue + func = node.func + if isinstance(func, ast.Name) and func.id == "open": + pass + elif isinstance(func, ast.Attribute) and func.attr in _READ_CALLS: + receiver = func.value + if isinstance(receiver, ast.Name) and receiver.id in _READERS: + continue + else: + continue + call = ast.get_source_segment(source, node) or "" + found.append((module, name, call)) + return found + + +def test_converted_commands_read_artifacts_through_the_reader() -> None: + direct = [read for read in _direct_reads() if read not in ALLOWED] + assert direct == [], ( + "Read these artifact files through openadapt_flow.artifacts, or add " + f"them to ALLOWED with the reason: {direct}" + ) + + +def test_allowlist_has_no_stale_entries() -> None: + assert set(ALLOWED) <= set(_direct_reads()) + + +# -- each converted command on sealed input ----------------------------------- + + +@pytest.fixture +def sealed(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + monkeypatch.setenv(crypto.ENV_KEY, KEY) + out = tmp_path / "sealed" + assert main(["seal", str(_plain_bundle(tmp_path)), "--out", str(out)]) == 0 + return out + + +def _ready(command: str) -> None: + assert command in _SEALED_READY_COMMANDS + + +def test_visualize_reads_a_sealed_bundle(sealed: Path, tmp_path: Path) -> None: + _ready("openadapt_flow.__main__:_cmd_visualize") + before = _files(sealed) + out = tmp_path / "graph.json" + assert main(["visualize", str(sealed), "--format", "json", "--out", str(out)]) == 0 + assert "synthetic-intake" in out.read_text() + assert _files(sealed) == before + + +def test_emit_skill_copies_a_sealed_bundle_sealed(sealed: Path, tmp_path: Path) -> None: + _ready("openadapt_flow.__main__:_cmd_emit_skill") + assert main(["emit-skill", str(sealed), "--out", str(tmp_path / "skills")]) == 0 + copy = tmp_path / "skills" / "synthetic-intake" / "bundle" + assert sealed_set.is_sealed(copy) + _assert_ciphertext_only(copy) + assert Workflow.load(copy, key=KEY).name == "synthetic-intake" + + +def test_for_each_writes_a_sealed_loop_from_a_sealed_body( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _ready("openadapt_flow.__main__:_cmd_for_each") + monkeypatch.setenv(crypto.ENV_KEY, KEY) + plain = _plain_bundle(tmp_path) + body = Workflow.load(plain) + body.steps.append( + Step(id="step_2", intent="type note", action=ActionKind.TYPE, param="note") + ) + body.params = {"note": "x"} + body.save(plain) + src = tmp_path / "sealed" + assert main(["seal", str(plain), "--out", str(src)]) == 0 + records = tmp_path / "records.csv" + records.write_text("note\nfirst\nsecond\n") + out = tmp_path / "loop" + args = ["for-each", str(src), "--records", str(records), "--out", str(out)] + assert main(args) == 0 + assert sealed_set.is_sealed(out) + _assert_ciphertext_only(out) + assert sealed_set.is_file(out / "templates" / "open.png") + assert Workflow.load(out, key=KEY).data_sources + + +def test_sections_reads_and_edits_a_sealed_bundle( + sealed: Path, capsys: pytest.CaptureFixture[str] +) -> None: + _ready("openadapt_flow.cli_sections:cmd_sections") + assert main(["sections", str(sealed), "--json"]) == 0 + first = json.loads(capsys.readouterr().out)["sections"][0]["id"] + assert main(["sections", str(sealed), "--rename", first, "Intake"]) == 0 + assert "Intake" in capsys.readouterr().out + _assert_ciphertext_only(sealed) + + +def test_explain_reads_a_sealed_run( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + from tests.test_explain_transaction import _halted_run + + _ready("openadapt_flow.__main__:_cmd_explain") + monkeypatch.setenv(crypto.ENV_KEY, KEY) + with sealed_set.writing(tmp_path / "run", "run", KEY): + run_dir = _halted_run(tmp_path, "HALTED_BEFORE_EFFECT", pending_status=None) + _assert_ciphertext_only(run_dir) + assert main(["explain", str(run_dir)]) == 0 + assert "local-quickstart" in capsys.readouterr().out + + +def _sealed_recording(root: Path) -> Path: + events = [{"kind": "click", "x": 5, "y": 5}, {"kind": "type", "text": "x"}] + with sealed_set.writing(root, "recording", KEY): + sealed_set.write_text( + root / "events.jsonl", "".join(json.dumps(e) + "\n" for e in events) + ) + sealed_set.write_text(root / "meta.json", json.dumps({"surface": "web"})) + return root + + +def test_check_mark_writes_into_a_sealed_recording( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from openadapt_flow.compiler.readback_authoring import load_check + + _ready("openadapt_flow.cli_check:cmd_check_mark") + monkeypatch.setenv(crypto.ENV_KEY, KEY) + rec = _sealed_recording(tmp_path / "rec") + mark = ["check", "mark", str(rec), "--start-event", "1", "--field", "note=1,2,3,4"] + assert main(mark) == 0 + _assert_ciphertext_only(rec) + marks = load_check(rec) + assert marks is not None and marks.start_event == 1 + + +def test_record_with_check_marks_a_sealed_recording( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from openadapt_flow.cli_check import guided_check + from openadapt_flow.compiler.readback_authoring import load_check + + _ready("openadapt_flow.__main__:_cmd_record") + monkeypatch.setenv(crypto.ENV_KEY, KEY) + rec = _sealed_recording(tmp_path / "rec") + shown: list[str] = [] + answers = iter(["1", "note=1,2,3,4", "", ""]) + assert guided_check(rec, ask=lambda _q: next(answers), echo=shown.append) + assert any("type" in line for line in shown) + _assert_ciphertext_only(rec) + assert load_check(rec) is not None diff --git a/packages/flow/tests/test_sealed_at_rest.py b/packages/flow/tests/test_sealed_at_rest.py index 7324a6956e..d6cc063835 100644 --- a/packages/flow/tests/test_sealed_at_rest.py +++ b/packages/flow/tests/test_sealed_at_rest.py @@ -304,11 +304,14 @@ def test_sealed_bundle_fails_closed_without_or_with_a_wrong_key( def test_command_that_cannot_read_sealed_input_refuses( - sealed_pair: tuple[Path, Path], capsys: pytest.CaptureFixture[str] + sealed_pair: tuple[Path, Path], + tmp_path: Path, + capsys: pytest.CaptureFixture[str], ) -> None: sealed, _ = sealed_pair before = _files(sealed) - assert main(["visualize", str(sealed)]) == 2 + out = tmp_path / "server.py" + assert main(["emit-mcp", str(sealed), "--out", str(out)]) == 2 err = capsys.readouterr().err assert "does not read encrypted input" in err assert _files(sealed) == before From b87ee2844b9484ebd7709773a1da68311dd89b8a Mon Sep 17 00:00:00 2001 From: abrichr Date: Tue, 3 Nov 2026 15:30:28 -0500 Subject: [PATCH 08/21] feat: compose, process, variants, route and process-contract visualize read sealed children Route the composition, process-contract and variant-set reads (parent files, admission envelopes, child bundles, repair-candidate records) through openadapt_flow.artifacts. compose copies sealed children as ciphertext; process, variants and route read sealed children in place. Each command has a sealed-input test, and the lint in test_artifact_reads covers the converted functions. Refs #1751. --- CAPABILITIES.md | 2 +- llms-full.txt | 2 +- packages/flow/docs/phi_at_rest.md | 15 +- packages/flow/openadapt_flow/__main__.py | 8 +- .../openadapt_flow/admitted_composition.py | 25 ++- .../compiler/compose_authoring.py | 6 +- packages/flow/openadapt_flow/composition.py | 6 +- packages/flow/openadapt_flow/variants.py | 26 ++- packages/flow/tests/test_artifact_reads.py | 192 ++++++++++++++++++ 9 files changed, 252 insertions(+), 30 deletions(-) diff --git a/CAPABILITIES.md b/CAPABILITIES.md index 3053a30ac1..10d012e9f9 100644 --- a/CAPABILITIES.md +++ b/CAPABILITIES.md @@ -301,7 +301,7 @@ improvising or retrying when something is off. | 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` | | See a compiled program as a map (every step, exact edge, loop and stop rule, with each step's checks) and play a recorded run over it; one React package that Desktop, app.openadapt.ai and openadapt.ai share | On main ([packages/program-graph](packages/program-graph/README.md)) | Real compiled OpenEMR referral program and a recorded scoreboard run, both on synthetic data | `@openadapt/program-graph`; `openadapt-flow visualize --format json` | | Named sections for every compiled program ("Who: title, first name, last name", "Contact", "Create New Patient"), found without a model from the save, the recorded form structure, the fields and any narration; a person can rename, merge or split them on the review screen | On main | Unit tests; the certified OpenEMR referral bundle and a real recording of it on a local test copy with made-up patients | `openadapt-flow sections`; [SECTIONS.md](packages/flow/docs/SECTIONS.md) | -| 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, undo, visualize, explain, emit-skill, for-each, sections and check mark read them in memory through one artifact reader and refuse without the key | On main | 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) | +| 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, undo, visualize, explain, emit-skill, for-each, sections, check mark, compose, process, variants and route read them in memory through one artifact reader and refuse without the key | On main | 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) | | Encryption at rest under a configured or locally generated key (0600): durable checkpoints and attended state by default; with `OPENADAPT_FLOW_AT_REST=strict`, every recording, bundle and run directory too. Each plaintext write is logged on stderr and in a ledger that Desktop shows as a notice | On main | Synthetic (key, opt-out, strict-mode and run-directory tests) | `openadapt_flow.at_rest`; [phi_at_rest.md](packages/flow/docs/phi_at_rest.md#default-opt-out-and-strict-mode) | | Signed receipts (Seals), countersigned and timestamped, offline by default | Released | Unit tests, real TSA tokens | `openadapt-flow seal`, `countersign`, `local-seal` | | Offline Seals signed with a key the customer holds: a key file, the OS keychain, or a PKCS#11 token or HSM; pinned keys with rotation and revocation; no fallback to a generated key | On main ([#1392](https://github.com/OpenAdaptAI/OpenAdapt/pull/1392)) | Unit tests with synthetic data (keychain and PKCS#11 through stand-ins) | `openadapt-flow local-seal --key`, `local-seal key create`, `pin`, `revoke`; `openadapt seal verify FOLDER` checks every sealed run in a folder; [OFFLINE_SEALS.md](packages/flow/docs/OFFLINE_SEALS.md#customer-held-keys) | diff --git a/llms-full.txt b/llms-full.txt index 502a379cc4..908c4168dd 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -698,7 +698,7 @@ improvising or retrying when something is off. | 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` | | See a compiled program as a map (every step, exact edge, loop and stop rule, with each step's checks) and play a recorded run over it; one React package that Desktop, app.openadapt.ai and openadapt.ai share | On main ([packages/program-graph](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/program-graph/README.md)) | Real compiled OpenEMR referral program and a recorded scoreboard run, both on synthetic data | `@openadapt/program-graph`; `openadapt-flow visualize --format json` | | Named sections for every compiled program ("Who: title, first name, last name", "Contact", "Create New Patient"), found without a model from the save, the recorded form structure, the fields and any narration; a person can rename, merge or split them on the review screen | On main | Unit tests; the certified OpenEMR referral bundle and a real recording of it on a local test copy with made-up patients | `openadapt-flow sections`; [SECTIONS.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/SECTIONS.md) | -| 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, undo, visualize, explain, emit-skill, for-each, sections and check mark read them in memory through one artifact reader and refuse without the key | On main | 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) | +| 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, undo, visualize, explain, emit-skill, for-each, sections, check mark, compose, process, variants and route read them in memory through one artifact reader and refuse without the key | On main | 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) | | Encryption at rest under a configured or locally generated key (0600): durable checkpoints and attended state by default; with `OPENADAPT_FLOW_AT_REST=strict`, every recording, bundle and run directory too. Each plaintext write is logged on stderr and in a ledger that Desktop shows as a notice | On main | Synthetic (key, opt-out, strict-mode and run-directory tests) | `openadapt_flow.at_rest`; [phi_at_rest.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/phi_at_rest.md#default-opt-out-and-strict-mode) | | Signed receipts (Seals), countersigned and timestamped, offline by default | Released | Unit tests, real TSA tokens | `openadapt-flow seal`, `countersign`, `local-seal` | | Offline Seals signed with a key the customer holds: a key file, the OS keychain, or a PKCS#11 token or HSM; pinned keys with rotation and revocation; no fallback to a generated key | On main ([#1392](https://github.com/OpenAdaptAI/OpenAdapt/pull/1392)) | Unit tests with synthetic data (keychain and PKCS#11 through stand-ins) | `openadapt-flow local-seal --key`, `local-seal key create`, `pin`, `revoke`; `openadapt seal verify FOLDER` checks every sealed run in a folder; [OFFLINE_SEALS.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/OFFLINE_SEALS.md#customer-held-keys) | diff --git a/packages/flow/docs/phi_at_rest.md b/packages/flow/docs/phi_at_rest.md index 066625f2ca..9050808852 100644 --- a/packages/flow/docs/phi_at_rest.md +++ b/packages/flow/docs/phi_at_rest.md @@ -205,10 +205,11 @@ takes tens to hundreds of milliseconds, so encryption adds about 1% or less. `record` (including `--with-check`), `compile`, `induce`, `scaffold-verifier`, `seal`, `certify`, `qualify`, `admit`, `run`, `replay`, -`approve`, `resume`, `undo`, `visualize`, `explain`, `emit-skill`, -`for-each`, `sections` and `check mark` read sealed bundles, run directories -and encrypted recordings in memory, given the key, and keep what they write -inside a sealed set sealed. Each reads artifact files through one reader, +`approve`, `resume`, `undo`, `visualize` (including a process contract), +`explain`, `emit-skill`, `for-each`, `sections`, `check mark`, `compose`, +`process`, `variants` and `route` read sealed bundles, run directories and +encrypted recordings in memory, given the key, and keep what they write inside +a sealed set sealed. Each reads artifact files through one reader, `openadapt_flow.artifacts`: - `compile` of an encrypted recording writes a sealed bundle, under @@ -236,6 +237,12 @@ inside a sealed set sealed. Each reads artifact files through one reader, mode refuses them before they run. - `sections` and `check mark` write `sections.json` and `check.json` back into the sealed bundle or recording, encrypted. +- `compose` copies sealed child bundles into the composition as ciphertext. + `process`, `variants` and `route` read sealed child bundles and their + admission envelopes in place. The parent file each writes + (`composition.json`, `process-contract.json`, `variant-set.json`) holds + names, digests and pointers; by default it is named on stderr and in the + ledger, and strict mode refuses the command before it runs. 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 diff --git a/packages/flow/openadapt_flow/__main__.py b/packages/flow/openadapt_flow/__main__.py index 5dfb3f1809..5ce5815b5a 100644 --- a/packages/flow/openadapt_flow/__main__.py +++ b/packages/flow/openadapt_flow/__main__.py @@ -12598,6 +12598,11 @@ def _qualify_default_form(argv: list[str]) -> list[str]: "openadapt_flow.__main__:_cmd_explain", "openadapt_flow.__main__:_cmd_emit_skill", "openadapt_flow.__main__:_cmd_for_each", + "openadapt_flow.__main__:_cmd_visualize_process", + "openadapt_flow.__main__:_cmd_compose", + "openadapt_flow.__main__:_cmd_process", + "openadapt_flow.__main__:_cmd_variants", + "openadapt_flow.__main__:_cmd_route", "openadapt_flow.cli_sections:cmd_sections", "openadapt_flow.cli_check:cmd_check_mark", "openadapt_flow.cli_admit:cmd_admit_run", @@ -12715,7 +12720,8 @@ def _sealed_input_refusal(args: argparse.Namespace) -> Optional[str]: "command. Commands that read sealed input: record, compile, " "induce, scaffold-verifier, seal, certify, qualify, admit, run, " "replay, approve, resume, undo, visualize, explain, emit-skill, " - "for-each, sections and check mark." + "for-each, sections, check mark, compose, process, variants and " + "route." ) for root in sealed: kind = sealed_set.header_kind(root) diff --git a/packages/flow/openadapt_flow/admitted_composition.py b/packages/flow/openadapt_flow/admitted_composition.py index 2bfe27e1c2..ed716e825d 100644 --- a/packages/flow/openadapt_flow/admitted_composition.py +++ b/packages/flow/openadapt_flow/admitted_composition.py @@ -359,14 +359,16 @@ def save(self, parent_dir: Path | str) -> Path: @classmethod def load(cls, parent_dir: Path | str) -> "ProcessContract": + from openadapt_flow import artifacts + parent = Path(parent_dir) path = parent / PROCESS_CONTRACT_FILENAME - if not path.is_file(): + if not artifacts.is_file(path): raise ProcessContractError( f"{parent} is not a process-contract artifact " f"(no {PROCESS_CONTRACT_FILENAME})" ) - return cls.model_validate_json(path.read_text(encoding="utf-8")) + return cls.model_validate_json(artifacts.read_text(path)) class ProcessContractV1(ProcessContract): @@ -416,8 +418,10 @@ def load_child_envelope( ) -> QualificationAdmissionEnvelope: """Load the child's v1 admission envelope from its pointer.""" + from openadapt_flow import artifacts + path = resolve_pointer(parent_dir, child.envelope) - if not path.is_file(): + if not artifacts.is_file(path): raise ProcessContractError( f"child {child.name!r} has no QualificationAdmissionEnvelope at " f"{path}; a compiled recording (including a compose child under " @@ -425,7 +429,7 @@ def load_child_envelope( ) try: return QualificationAdmissionEnvelope.model_validate_json( - path.read_text(encoding="utf-8") + artifacts.read_text(path) ) except Exception as exc: raise ProcessContractError( @@ -500,9 +504,10 @@ def _pointer_for(src: Path, parent: Path) -> str: def _load_admitted_bundle(name: str, bundle: Path) -> Workflow: - if ( - not (bundle / "workflow.json").is_file() - and not (bundle / "workflow.json.enc").is_file() + from openadapt_flow import artifacts + + if not artifacts.is_file(bundle / "workflow.json") and not artifacts.is_file( + bundle / "workflow.json.enc" ): raise ProcessContractError( f"child {name!r} at {bundle} is not an admitted workflow bundle" @@ -531,6 +536,8 @@ def author_process_contract( The parent does not copy recordings. Default sequencing is that order. """ + from openadapt_flow import artifacts + if len(children) < 2: raise ProcessContractError( "process needs at least two independently admitted children" @@ -560,7 +567,7 @@ def author_process_contract( for child_name, envelope_src, bundle_src in children: envelope_path = Path(envelope_src) bundle_path = Path(bundle_src) - if not envelope_path.is_file(): + if not artifacts.is_file(envelope_path): errors.append( f"child {child_name!r} has no QualificationAdmissionEnvelope at " f"{envelope_path}; a compiled recording (including a compose " @@ -570,7 +577,7 @@ def author_process_contract( continue try: envelope = QualificationAdmissionEnvelope.model_validate_json( - envelope_path.read_text(encoding="utf-8") + artifacts.read_text(envelope_path) ) except Exception as exc: errors.append( diff --git a/packages/flow/openadapt_flow/compiler/compose_authoring.py b/packages/flow/openadapt_flow/compiler/compose_authoring.py index b245532c3f..a2c75e9eb5 100644 --- a/packages/flow/openadapt_flow/compiler/compose_authoring.py +++ b/packages/flow/openadapt_flow/compiler/compose_authoring.py @@ -16,6 +16,7 @@ from pathlib import Path from typing import Mapping, Optional, Sequence +from openadapt_flow import artifacts from openadapt_flow.composition import ( CHILDREN_DIR, TERMINAL_OUTCOMES, @@ -123,9 +124,8 @@ def author_composition( for child_name, src in children: src_path = Path(src) - if ( - not (src_path / "workflow.json").is_file() - and not (src_path / "workflow.json.enc").is_file() + if not artifacts.is_file(src_path / "workflow.json") and not artifacts.is_file( + src_path / "workflow.json.enc" ): errors.append( f"child {child_name!r} at {src_path} is not a workflow bundle" diff --git a/packages/flow/openadapt_flow/composition.py b/packages/flow/openadapt_flow/composition.py index b7e142ba8e..7ce2cbd67f 100644 --- a/packages/flow/openadapt_flow/composition.py +++ b/packages/flow/openadapt_flow/composition.py @@ -183,13 +183,15 @@ def save(self, parent_dir: Path | str) -> Path: @classmethod def load(cls, parent_dir: Path | str) -> "Composition": + from openadapt_flow import artifacts + parent = Path(parent_dir) path = parent / COMPOSITION_FILENAME - if not path.is_file(): + if not artifacts.is_file(path): raise CompositionError( f"{parent} is not a composition artifact (no {COMPOSITION_FILENAME})" ) - return cls.model_validate_json(path.read_text(encoding="utf-8")) + return cls.model_validate_json(artifacts.read_text(path)) def is_composition_artifact(path: Path | str) -> bool: diff --git a/packages/flow/openadapt_flow/variants.py b/packages/flow/openadapt_flow/variants.py index 642d4b4f9d..7e3f06ef1f 100644 --- a/packages/flow/openadapt_flow/variants.py +++ b/packages/flow/openadapt_flow/variants.py @@ -257,14 +257,16 @@ def save(self, parent_dir: Path | str) -> Path: @classmethod def load(cls, parent_dir: Path | str) -> "VariantSet": + from openadapt_flow import artifacts + parent = Path(parent_dir) path = parent / VARIANT_SET_FILENAME - if not path.is_file(): + if not artifacts.is_file(path): raise VariantSetError( f"{parent} is not a variant-set artifact (no {VARIANT_SET_FILENAME})" ) try: - return cls.model_validate_json(path.read_text(encoding="utf-8")) + return cls.model_validate_json(artifacts.read_text(path)) except ValueError as exc: raise VariantSetError(f"variant set at {path} is invalid: {exc}") from exc @@ -272,7 +274,9 @@ def load(cls, parent_dir: Path | str) -> "VariantSet": def is_variant_set_artifact(path: Path | str) -> bool: """True when ``path`` is a variant-set directory.""" - return (Path(path) / VARIANT_SET_FILENAME).is_file() + from openadapt_flow import artifacts + + return artifacts.is_file(Path(path) / VARIANT_SET_FILENAME) class VariantInput(BaseModel): @@ -290,14 +294,16 @@ class VariantInput(BaseModel): def _admitted_spec(item: VariantInput, out_dir: Path) -> VariantSpec: """Build a variant spec, refusing anything that is not admitted as is.""" - if not item.admission.is_file(): + from openadapt_flow import artifacts + + if not artifacts.is_file(item.admission): raise VariantSetError( f"variant {item.name!r} has no QualificationAdmissionEnvelope at " f"{item.admission}; each variant keeps its own admission" ) try: envelope = QualificationAdmissionEnvelope.model_validate_json( - item.admission.read_text(encoding="utf-8") + artifacts.read_text(item.admission) ) except Exception as exc: raise VariantSetError( @@ -340,13 +346,14 @@ def _admitted_spec(item: VariantInput, out_dir: Path) -> VariantSpec: def _repair_candidate(bundle: Path) -> tuple[Optional[str], Optional[str]]: """Read the detached repair-candidate record a healed bundle carries.""" + from openadapt_flow import artifacts from openadapt_flow.repair.registration import detached_candidate_path path = detached_candidate_path(bundle) - if not path.is_file(): + if not artifacts.is_file(path): return None, None try: - record = json.loads(path.read_text(encoding="utf-8")) + record = json.loads(artifacts.read_text(path)) except (OSError, ValueError) as exc: raise VariantSetError( f"repair candidate record at {path} is unreadable" @@ -415,16 +422,17 @@ def add_variant(parent_dir: Path | str, item: VariantInput) -> VariantSet: def _check_lineage(bundle: Path, base: VariantSpec, spec: VariantSpec) -> None: """A promoted variant must carry the repair candidate that produced it.""" + from openadapt_flow import artifacts from openadapt_flow.repair.registration import detached_candidate_path path = detached_candidate_path(bundle) - if not path.is_file(): + if not artifacts.is_file(path): raise VariantSetError( f"--derived-from needs the repair candidate record the heal or teach " f"wrote ({path}); without it the lineage is unproven" ) try: - record = json.loads(path.read_text(encoding="utf-8")) + record = json.loads(artifacts.read_text(path)) except (OSError, ValueError) as exc: raise VariantSetError( f"repair candidate record at {path} is unreadable" diff --git a/packages/flow/tests/test_artifact_reads.py b/packages/flow/tests/test_artifact_reads.py index e775f7de01..99f302cc8e 100644 --- a/packages/flow/tests/test_artifact_reads.py +++ b/packages/flow/tests/test_artifact_reads.py @@ -55,7 +55,36 @@ "narration.py": ("read_events", "load_narration"), "cli_sections.py": ("cmd_sections",), "cli_check.py": ("cmd_check_mark", "guided_check"), + # compose, process, variants, route and visualize of a process contract. + "cli_compose.py": ("cmd_compose",), + "compiler/compose_authoring.py": ("author_composition",), + "cli_process.py": ("cmd_process",), + "cli_variants.py": ("cmd_variants", "cmd_route", "first_screen_probe"), + "variants.py": ( + "load", + "is_variant_set_artifact", + "_admitted_spec", + "_repair_candidate", + "_check_lineage", + "author_variant_set", + ), + "runtime/variant_router.py": ("route_variant", "_admitted_variant"), + "visualize/admitted_composition.py": ("build_process_graph",), } +CONVERTED["__main__.py"] += ( + "_cmd_visualize_process", + "_cmd_compose", + "_cmd_process", + "_cmd_variants", + "_cmd_route", +) +CONVERTED["composition.py"] += ("load",) +CONVERTED["admitted_composition.py"] += ( + "load", + "load_child_envelope", + "_load_admitted_bundle", + "author_process_contract", +) #: Receivers whose reads are sealed-aware. _READERS = frozenset({"artifacts", "_artifacts", "sealed_set", "_sealed"}) @@ -68,6 +97,37 @@ "_cmd_record", '(Path(args.out) / "events.jsonl").is_file()', ): "gates the look-up proposal, which runs on plaintext recordings only", + ( + "compiler/compose_authoring.py", + "author_composition", + "out_dir.exists()", + ): "the new composition directory, not an artifact", + ( + "admitted_composition.py", + "author_process_contract", + "out_dir.exists()", + ): "the new process-contract directory, not an artifact", + ( + "variants.py", + "author_variant_set", + "out_dir.exists()", + ): "the new variant-set directory, not an artifact", + ( + "cli_process.py", + "cmd_process", + 'Path(spec_path).read_text(encoding="utf-8")', + ): "the v1 contract spec a person passes in", + ("cli_process.py", "cmd_process", "output.exists()"): "the new output directory", + ( + "cli_variants.py", + "cmd_variants", + 'spec_path.read_text(encoding="utf-8")', + ): "the variant spec a person passes in", + ( + "cli_variants.py", + "first_screen_probe", + "Path(explicit).read_bytes()", + ): "the --first-screen PNG a person passes in", } @@ -237,3 +297,135 @@ def test_record_with_check_marks_a_sealed_recording( assert any("type" in line for line in shown) _assert_ciphertext_only(rec) assert load_check(rec) is not None + + +# -- compose, process, variants and route over sealed children ---------------- + + +def _sealed_admitted( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, *, same_surface: bool = False +) -> dict[str, tuple[Path, Path]]: + """Two sealed child bundles, each with its admission envelope beside it.""" + from openadapt_flow.admitted_composition import live_bundle_content_digest + from openadapt_flow.qualification_admission import sign_qualification_admission + from tests.test_admitted_composition_authoring import ( + INTAKE_ADMISSION, + INTAKE_VERSION, + POSTING_ADMISSION, + POSTING_VERSION, + _reader, + _save, + _writer, + ) + from tests.test_qualification_admission import _payload, _private_key + + monkeypatch.setenv(crypto.ENV_KEY, KEY) + children: dict[str, tuple[Path, Path]] = {} + for name, workflow, admission, version in ( + ("intake", _writer(), INTAKE_ADMISSION, INTAKE_VERSION), + ( + "posting", + _writer("posting") if same_surface else _reader(), + POSTING_ADMISSION, + POSTING_VERSION, + ), + ): + plain = _save(tmp_path, workflow, f"{name}-plain") + bundle = tmp_path / name + assert main(["seal", str(plain), "--out", str(bundle)]) == 0 + digest = live_bundle_content_digest(Workflow.load(bundle), bundle) + envelope = sign_qualification_admission( + _payload( + admission_id=admission, + workflow_version_id=version, + bundle_content_digest=digest, + ), + _private_key(), + ) + path = tmp_path / f"{name}.admission.json" + path.write_text(envelope.model_dump_json(indent=2), encoding="utf-8") + children[name] = (bundle, path) + return children + + +def test_compose_copies_sealed_children_sealed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from openadapt_flow.composition import Composition, child_bundle_path + + _ready("openadapt_flow.__main__:_cmd_compose") + children = _sealed_admitted(tmp_path, monkeypatch) + before = {name: _files(bundle) for name, (bundle, _) in children.items()} + out = tmp_path / "composed" + args = ["compose", "--out", str(out)] + for name, (bundle, _) in children.items(): + args += ["--child", f"{name}={bundle}"] + args += ["--handoff", "intake.patient_id=posting.patient_id"] + assert main(args) == 0 + composition = Composition.load(out) + for child in composition.children: + copy = child_bundle_path(out, child) + assert sealed_set.is_sealed(copy) + _assert_ciphertext_only(copy) + assert Workflow.load(copy, key=KEY).name == child.name + assert {name: _files(b) for name, (b, _) in children.items()} == before + + +def test_process_and_its_visualization_read_sealed_children( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from openadapt_flow.admitted_composition import ProcessContract + + _ready("openadapt_flow.__main__:_cmd_process") + _ready("openadapt_flow.__main__:_cmd_visualize_process") + children = _sealed_admitted(tmp_path, monkeypatch) + out = tmp_path / "process" + args = ["process", "--out", str(out), "--name", "two-step"] + for name, (bundle, envelope) in children.items(): + args += ["--child", f"{name}={bundle}", "--admission", f"{name}={envelope}"] + args += ["--handoff", "intake.patient_id=posting.patient_id"] + assert main(args) == 0 + assert [c.name for c in ProcessContract.load(out).children] == [ + "intake", + "posting", + ] + graph = tmp_path / "process.json" + assert main(["visualize", str(out), "--format", "json", "--out", str(graph)]) == 0 + assert "two-step" in graph.read_text() + for bundle, _ in children.values(): + _assert_ciphertext_only(bundle) + + +def test_variants_and_route_read_sealed_variants( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + _ready("openadapt_flow.__main__:_cmd_variants") + _ready("openadapt_flow.__main__:_cmd_route") + children = _sealed_admitted(tmp_path, monkeypatch, same_surface=True) + payers = {"intake": "commercial", "posting": "medicare"} + spec = tmp_path / "variants.json" + spec.write_text( + json.dumps( + { + "name": "by-payer", + "variants": [ + { + "name": name, + "bundle": str(bundle), + "admission": str(envelope), + "when": {"inputs": {"payer": [payers[name]]}}, + } + for name, (bundle, envelope) in children.items() + ], + } + ) + ) + out = tmp_path / "set" + assert main(["variants", "author", "--spec", str(spec), "--out", str(out)]) == 0 + capsys.readouterr() + assert main(["route", str(out), "--param", "payer=medicare"]) == 0 + assert json.loads(capsys.readouterr().out)["variant"] == "posting" + for bundle, _ in children.values(): + _assert_ciphertext_only(bundle) From db1b1815785be186b3c3355777dedb38b8da2187 Mon Sep 17 00:00:00 2001 From: abrichr Date: Tue, 3 Nov 2026 15:45:19 -0500 Subject: [PATCH 09/21] fix: sealed-input gate reads NAME=PATH arguments and catches a missing key under the local key --- packages/flow/openadapt_flow/__main__.py | 24 ++++++++++++++++++---- packages/flow/tests/test_artifact_reads.py | 16 +++++++++++++++ 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/packages/flow/openadapt_flow/__main__.py b/packages/flow/openadapt_flow/__main__.py index 5ce5815b5a..2d59739f2d 100644 --- a/packages/flow/openadapt_flow/__main__.py +++ b/packages/flow/openadapt_flow/__main__.py @@ -12694,10 +12694,20 @@ def _sealed_paths_in(args: argparse.Namespace) -> list[Path]: 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) + # NAME=PATH arguments (--child, --admission, --variant) name a + # path after the first "="; check both readings. + candidates = [text] + if isinstance(item, str) and "=" in text: + candidates.append(text.split("=", 1)[1]) + for candidate in candidates: + if not candidate: + continue + path = Path(candidate) + 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 @@ -12727,6 +12737,12 @@ def _sealed_input_refusal(args: argparse.Namespace) -> Optional[str]: kind = sealed_set.header_kind(root) if not sealed_set.passphrase_for(kind): return sealed_set.missing_key_message(root, kind) + try: + # With no key configured the local key resolves; opening the set + # tells "sealed with a key that isn't set" apart before any work. + sealed_set.open_set(root) + except sealed_set.SealedSetKeyError as exc: + return str(exc) return None diff --git a/packages/flow/tests/test_artifact_reads.py b/packages/flow/tests/test_artifact_reads.py index 99f302cc8e..f9f4848028 100644 --- a/packages/flow/tests/test_artifact_reads.py +++ b/packages/flow/tests/test_artifact_reads.py @@ -429,3 +429,19 @@ def test_variants_and_route_read_sealed_variants( assert json.loads(capsys.readouterr().out)["variant"] == "posting" for bundle, _ in children.values(): _assert_ciphertext_only(bundle) + + +def test_name_path_arguments_go_through_the_missing_key_check( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys +) -> None: + children = _sealed_admitted(tmp_path, monkeypatch) + monkeypatch.delenv(crypto.ENV_KEY) + monkeypatch.delenv("OPENADAPT_FLOW_PLAINTEXT_AT_REST", raising=False) + args = ["compose", "--out", str(tmp_path / "composed")] + for name, (bundle, _) in children.items(): + args += ["--child", f"{name}={bundle}"] + args += ["--handoff", "intake.patient_id=posting.patient_id"] + assert main(args) == 2 + err = capsys.readouterr().err + assert "OPENADAPT_BUNDLE_KEY" in err and "Nothing was read" in err + assert not (tmp_path / "composed").exists() From cd941aa966973a78fc6ad13b854770f4f2ba7441 Mon Sep 17 00:00:00 2001 From: abrichr Date: Tue, 3 Nov 2026 16:06:53 -0500 Subject: [PATCH 10/21] fix: at-rest review: strict allow-list, sealed runs for sealed bundles, deployment-key commands, ledger failures stop writes --- CAPABILITIES.md | 2 +- llms-full.txt | 2 +- packages/flow/docs/phi_at_rest.md | 20 ++- packages/flow/openadapt_flow/__main__.py | 61 ++++++- packages/flow/openadapt_flow/at_rest.py | 47 ++++-- .../flow/openadapt_flow/bundle_sealing.py | 2 +- .../openadapt_flow/console/halt_detail.py | 8 +- packages/flow/openadapt_flow/crypto.py | 16 ++ .../openadapt_flow/record_check_binding.py | 2 +- .../openadapt_flow/runtime/composition.py | 3 + .../flow/openadapt_flow/runtime/process_v1.py | 3 + .../flow/openadapt_flow/runtime/replayer.py | 27 ++-- .../openadapt_flow/runtime/variant_router.py | 3 + .../openadapt_flow/runtime/worklist_pool.py | 3 + packages/flow/tests/test_at_rest_default.py | 150 +++++++++++++++++- packages/flow/tests/test_compile_agent.py | 19 ++- 16 files changed, 324 insertions(+), 44 deletions(-) diff --git a/CAPABILITIES.md b/CAPABILITIES.md index 69d4c95729..e9ccee710f 100644 --- a/CAPABILITIES.md +++ b/CAPABILITIES.md @@ -302,7 +302,7 @@ improvising or retrying when something is off. | See a compiled program as a map (every step, exact edge, loop and stop rule, with each step's checks) and play a recorded run over it; one React package that Desktop, app.openadapt.ai and openadapt.ai share | On main ([packages/program-graph](packages/program-graph/README.md)) | Real compiled OpenEMR referral program and a recorded scoreboard run, both on synthetic data | `@openadapt/program-graph`; `openadapt-flow visualize --format json` | | Named sections for every compiled program ("Who: title, first name, last name", "Contact", "Create New Patient"), found without a model from the save, the recorded form structure, the fields and any narration; a person can rename, merge or split them on the review screen | On main | Unit tests; the certified OpenEMR referral bundle and a real recording of it on a local test copy with made-up patients | `openadapt-flow sections`; [SECTIONS.md](packages/flow/docs/SECTIONS.md) | | 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 | On main | 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) | -| Encryption at rest under a configured or locally generated key (0600): durable checkpoints and attended state by default; with `OPENADAPT_FLOW_AT_REST=strict`, every recording, bundle and run directory too. Each plaintext write is logged on stderr and in a ledger that Desktop shows as a notice | On main | Synthetic (key, opt-out, strict-mode and run-directory tests) | `openadapt_flow.at_rest`; [phi_at_rest.md](packages/flow/docs/phi_at_rest.md#default-opt-out-and-strict-mode) | +| Encryption at rest under a configured or locally generated key (0600): durable checkpoints by default; with `OPENADAPT_FLOW_AT_REST=strict`, every recording, bundle and run directory too. Each plaintext write is logged on stderr and in a ledger that Desktop shows as a notice | On main | Synthetic (key, opt-out, strict-mode and run-directory tests) | `openadapt_flow.at_rest`; [phi_at_rest.md](packages/flow/docs/phi_at_rest.md#default-opt-out-and-strict-mode) | | Signed receipts (Seals), countersigned and timestamped, offline by default | Released | Unit tests, real TSA tokens | `openadapt-flow seal`, `countersign`, `local-seal` | | Offline Seals signed with a key the customer holds: a key file, the OS keychain, or a PKCS#11 token or HSM; pinned keys with rotation and revocation; no fallback to a generated key | On main ([#1392](https://github.com/OpenAdaptAI/OpenAdapt/pull/1392)) | Unit tests with synthetic data (keychain and PKCS#11 through stand-ins) | `openadapt-flow local-seal --key`, `local-seal key create`, `pin`, `revoke`; `openadapt seal verify FOLDER` checks every sealed run in a folder; [OFFLINE_SEALS.md](packages/flow/docs/OFFLINE_SEALS.md#customer-held-keys) | | Anchor an offline Seal in the public Sigstore Rekor transparency log (opt-in; sends one digest and the public key), and verify the anchor offline from the stored inclusion proof | On main ([#1394](https://github.com/OpenAdaptAI/OpenAdapt/pull/1394)) | Unit tests against a simulated Rekor log; real public Rekor entries and Sigstore TSA tokens for the landing demo's referral runs, verified offline in tests; Ed25519ph matches the RFC 8032 test vector | `openadapt-flow local-seal anchor`, `local-seal verify --require-anchor`; [OFFLINE_SEALS.md](packages/flow/docs/OFFLINE_SEALS.md#optional-anchor-a-seal-in-a-public-transparency-log) | diff --git a/llms-full.txt b/llms-full.txt index af71bba2cb..3fa66f79c0 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -699,7 +699,7 @@ improvising or retrying when something is off. | See a compiled program as a map (every step, exact edge, loop and stop rule, with each step's checks) and play a recorded run over it; one React package that Desktop, app.openadapt.ai and openadapt.ai share | On main ([packages/program-graph](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/program-graph/README.md)) | Real compiled OpenEMR referral program and a recorded scoreboard run, both on synthetic data | `@openadapt/program-graph`; `openadapt-flow visualize --format json` | | Named sections for every compiled program ("Who: title, first name, last name", "Contact", "Create New Patient"), found without a model from the save, the recorded form structure, the fields and any narration; a person can rename, merge or split them on the review screen | On main | Unit tests; the certified OpenEMR referral bundle and a real recording of it on a local test copy with made-up patients | `openadapt-flow sections`; [SECTIONS.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/SECTIONS.md) | | 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 | On main | 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) | -| Encryption at rest under a configured or locally generated key (0600): durable checkpoints and attended state by default; with `OPENADAPT_FLOW_AT_REST=strict`, every recording, bundle and run directory too. Each plaintext write is logged on stderr and in a ledger that Desktop shows as a notice | On main | Synthetic (key, opt-out, strict-mode and run-directory tests) | `openadapt_flow.at_rest`; [phi_at_rest.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/phi_at_rest.md#default-opt-out-and-strict-mode) | +| Encryption at rest under a configured or locally generated key (0600): durable checkpoints by default; with `OPENADAPT_FLOW_AT_REST=strict`, every recording, bundle and run directory too. Each plaintext write is logged on stderr and in a ledger that Desktop shows as a notice | On main | Synthetic (key, opt-out, strict-mode and run-directory tests) | `openadapt_flow.at_rest`; [phi_at_rest.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/phi_at_rest.md#default-opt-out-and-strict-mode) | | Signed receipts (Seals), countersigned and timestamped, offline by default | Released | Unit tests, real TSA tokens | `openadapt-flow seal`, `countersign`, `local-seal` | | Offline Seals signed with a key the customer holds: a key file, the OS keychain, or a PKCS#11 token or HSM; pinned keys with rotation and revocation; no fallback to a generated key | On main ([#1392](https://github.com/OpenAdaptAI/OpenAdapt/pull/1392)) | Unit tests with synthetic data (keychain and PKCS#11 through stand-ins) | `openadapt-flow local-seal --key`, `local-seal key create`, `pin`, `revoke`; `openadapt seal verify FOLDER` checks every sealed run in a folder; [OFFLINE_SEALS.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/OFFLINE_SEALS.md#customer-held-keys) | | Anchor an offline Seal in the public Sigstore Rekor transparency log (opt-in; sends one digest and the public key), and verify the anchor offline from the stored inclusion proof | On main ([#1394](https://github.com/OpenAdaptAI/OpenAdapt/pull/1394)) | Unit tests against a simulated Rekor log; real public Rekor entries and Sigstore TSA tokens for the landing demo's referral runs, verified offline in tests; Ed25519ph matches the RFC 8032 test vector | `openadapt-flow local-seal anchor`, `local-seal verify --require-anchor`; [OFFLINE_SEALS.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/OFFLINE_SEALS.md#optional-anchor-a-seal-in-a-public-transparency-log) | diff --git a/packages/flow/docs/phi_at_rest.md b/packages/flow/docs/phi_at_rest.md index 1fcb18d002..d626c01145 100644 --- a/packages/flow/docs/phi_at_rest.md +++ b/packages/flow/docs/phi_at_rest.md @@ -22,22 +22,30 @@ with your other secrets. | | Default | `OPENADAPT_FLOW_AT_REST=strict` | | --- | --- | --- | -| Durable checkpoints and attended state | Encrypted | Encrypted | +| Durable checkpoints | Encrypted | Encrypted | | Web recordings | Encrypted with a configured key; otherwise logged | Encrypted | | Bundles from `compile`, `induce`, `admit` | Encrypted from encrypted input; otherwise logged | Encrypted | -| Run directories | Encrypted for a sealed bundle with a configured key; otherwise logged | Encrypted | -| Desktop recordings, approval requests, and the output of `visualize`, `explain`, `teach`, `emit-skill`, `push`, `compose`, `process`, `variants`, `route`, `for-each`, `--save-healed-to`, `--workers` and `induce --report` | Logged | Held back before the command runs | +| Run directories | Encrypted for a sealed bundle; otherwise logged | Encrypted | +| Desktop recordings, approval requests, `--record-video`, and the output of `visualize`, `explain`, `teach`, `emit-skill`, `push`, `compose`, `process`, `variants`, `route`, `for-each`, `shadow`, `compile-agent`, `guard`, `bench`, `sanitize`, `redact-report`, `jit`, `--save-healed-to`, `--workers` and `induce --report` | Logged | Held back before the command runs | **Logged** means stderr names the path and one line goes to -`~/.openadapt/plaintext-at-rest.jsonl`. OpenAdapt Desktop shows a notice in +`~/.openadapt/plaintext-at-rest.jsonl`; when that line can't be written, the +write stops. OpenAdapt Desktop shows a notice in its window from the same ledger. Issue [#1751](https://github.com/OpenAdaptAI/OpenAdapt/issues/1751) tracks moving each logged artifact to encrypted by default. +A sealed bundle always runs into a sealed run directory, in every mode. + **Healthcare and other regulated deployments:** set `OPENADAPT_FLOW_AT_REST=strict` and a key from your secret manager. Strict -mode encrypts every recording, bundle and run directory, and an unknown value -of `OPENADAPT_FLOW_AT_REST` stops the command. +mode runs the commands that keep their files encrypted (the ones listed under +[Commands on sealed input](#commands-on-sealed-input)) and the ones that +write no recorded data (service health and control, the no-outbound allow +list, key and Seal verification), and holds back every other command before +it starts. It encrypts every recording, bundle and run directory, and an +unknown value of `OPENADAPT_FLOW_AT_REST` stops the command. `seal` and +record checks on encrypted bundles use `OPENADAPT_BUNDLE_KEY` only. `OPENADAPT_FLOW_PLAINTEXT_AT_REST=''` is for development and already de-identified data: it turns encryption off, and each process that diff --git a/packages/flow/openadapt_flow/__main__.py b/packages/flow/openadapt_flow/__main__.py index ab0d9fae7b..ab5d301b3b 100644 --- a/packages/flow/openadapt_flow/__main__.py +++ b/packages/flow/openadapt_flow/__main__.py @@ -645,8 +645,9 @@ def _recording_encryption_key(args: argparse.Namespace, backend: str) -> Optiona """The passphrase that encrypts this recording, or ``None`` for plaintext. The web recorder encrypts every file from the first frame under - ``OPENADAPT_RECORDING_KEY``, else ``OPENADAPT_BUNDLE_KEY``, else the local - key (:mod:`openadapt_flow.at_rest`). ``--no-encrypt`` and + ``OPENADAPT_RECORDING_KEY`` or ``OPENADAPT_BUNDLE_KEY``, and under the + local key in strict mode (:mod:`openadapt_flow.at_rest`); otherwise the + plaintext recording is logged. ``--no-encrypt`` and ``OPENADAPT_FLOW_PLAINTEXT_AT_REST`` opt out, with a warning and a ledger line. The desktop recorders write a raw capture session through openadapt-capture that Flow cannot encrypt yet: by default they record in @@ -12611,16 +12612,62 @@ def _qualify_default_form(argv: list[str]) -> list[str]: "openadapt_flow.__main__:_cmd_variants": "the variant report", "openadapt_flow.__main__:_cmd_route": "the variant report", "openadapt_flow.__main__:_cmd_for_each": "the looped bundle", + "openadapt_flow.__main__:_cmd_bench": "the bench report", + "openadapt_flow.__main__:_cmd_sanitize": "the sanitized copy", + "openadapt_flow.cli_redact_report:_cmd_redact_report": "the redacted report", + "openadapt_flow.cli_shadow:cmd_shadow": "the shadow snapshots and report", + "openadapt_flow.compile_agent.cli:cmd_compile_agent": ( + "the compile-agent screenshots and traces" + ), + "openadapt_flow.agent_guard.cli:cmd_guard": "the guard's run record", + "openadapt_flow.jit.cli:cmd_jit": "the compiled agent run", } +#: Commands that write no recorded screen, record or report data (service +#: health and control, the no-outbound allow list, key and Seal verification). +#: Under OPENADAPT_FLOW_AT_REST=strict only these and the sealed-ready commands +#: run; every other command is held back before it starts. +_STRICT_SAFE_COMMANDS: frozenset[str] = frozenset( + { + "openadapt_flow.cli_service:_cmd_boot_check", + "openadapt_flow.cli_service:_cmd_egress_audit", + "openadapt_flow.cli_service:_cmd_health", + "openadapt_flow.cli_service:_cmd_notices", + "openadapt_flow.cli_service:_cmd_pf_check", + "openadapt_flow.cli_service:_cmd_pf_render", + "openadapt_flow.cli_service:_cmd_restart", + "openadapt_flow.cli_service:_cmd_run", + "openadapt_flow.cli_no_outbound:_cmd_add", + "openadapt_flow.cli_no_outbound:_cmd_derive", + "openadapt_flow.cli_no_outbound:_cmd_list", + "openadapt_flow.cli_no_outbound:_cmd_remove", + "openadapt_flow.cli_local_seal:_guarded", + "openadapt_flow.cli_receipt:cmd_anchor", + "openadapt_flow.cli_receipt:cmd_keys_export", + "openadapt_flow.cli_receipt:cmd_verify", + "openadapt_flow.agent.seal_cli:cmd_keys_pin", + "openadapt_flow.agent.seal_cli:cmd_keys_show", + "openadapt_flow.agent.seal_cli:cmd_verify", + } +) + def _unsealed_output_check(args: argparse.Namespace) -> None: """Warn and record, or refuse in strict mode, a command whose output - can't be encrypted yet. Also rejects a mistyped OPENADAPT_FLOW_AT_REST.""" + can't be encrypted yet. Strict mode runs only the sealed-ready and + strict-safe commands. Also rejects a mistyped OPENADAPT_FLOW_AT_REST.""" from openadapt_flow import at_rest - at_rest.strict() # raises on an unknown mode before anything runs - if at_rest.strict(): + func = getattr(args, "func", None) + command = f"{getattr(func, '__module__', '')}:{getattr(func, '__name__', '')}" + video = getattr(args, "record_video", None) + if at_rest.strict(): # raises on an unknown mode before anything runs + if command not in _SEALED_READY_COMMANDS | _STRICT_SAFE_COMMANDS: + raise at_rest.AtRestRefused( + f"{at_rest.MODE_ENV}=strict runs only the commands that keep " + "patient data encrypted, and this one writes files outside an " + "encrypted set. Nothing was run." + ) # Written after records change, so refused before the run starts. healed = getattr(args, "save_healed_to", None) if healed: @@ -12630,8 +12677,8 @@ def _unsealed_output_check(args: argparse.Namespace) -> None: at_rest.unsealed_write( "the worker pool report (--workers)", "the run directory" ) - func = getattr(args, "func", None) - command = f"{getattr(func, '__module__', '')}:{getattr(func, '__name__', '')}" + if video: + at_rest.unsealed_write("a screen video (--record-video)", video) artifact = _UNSEALED_OUTPUT_COMMANDS.get(command) if artifact is None: return diff --git a/packages/flow/openadapt_flow/at_rest.py b/packages/flow/openadapt_flow/at_rest.py index 4ca3a38294..4b574650e4 100644 --- a/packages/flow/openadapt_flow/at_rest.py +++ b/packages/flow/openadapt_flow/at_rest.py @@ -10,7 +10,7 @@ ``OPENADAPT_FLOW_AT_REST_HOME`` moves it), created on first use with mode 0600 in a 0700 directory and refused if anyone but the owner can read it. Back it up: what it encrypts can't be read without it. -* Durable checkpoints and attended state are encrypted under that key by +* Durable checkpoints are encrypted under that key by default. * ``OPENADAPT_FLOW_AT_REST=strict``, the setting for healthcare and regulated deployments, also encrypts every recording, bundle and run directory, and @@ -78,6 +78,16 @@ def explicit_key() -> bool: ) +def hold_back_in_strict(artifact: str) -> None: + """Refuse, before any record changes, a library entry point whose output + can't be encrypted yet, when strict mode is on.""" + if strict(): + raise AtRestRefused( + f"Not starting: {artifact} is written in plaintext, and " + f"{MODE_ENV}=strict holds it back. Nothing was executed." + ) + + def unsealed_write(artifact: str, path: Path | str, *, refuse: bool = True) -> None: """Allow a plaintext write of an artifact Flow can't seal yet, or refuse it. @@ -221,9 +231,15 @@ def _record( finally: os.close(fd) except OSError as exc: - print( - f"WARNING: could not record the plaintext opt-out: {exc}", file=sys.stderr - ) + # An unrecorded plaintext write is the silent case this ledger exists + # to prevent, so the write stops here. + with _lock: + _warned.discard(purpose) + raise AtRestRefused( + f"Not writing {purpose}: the plaintext ledger at {ledger_path()} " + f"can't be written ({exc}). Set {HOME_ENV} to a writable private " + "directory. Nothing was written." + ) from exc def refusal(purpose: str, why: str) -> str: @@ -238,12 +254,23 @@ def refusal(purpose: str, why: str) -> str: def _check_mode(path: Path) -> None: if os.name != "posix": return - mode = path.stat().st_mode - if mode & (stat.S_IRWXG | stat.S_IRWXO): - raise LocalKeyError( - f"{path} is readable by group or others (mode {oct(mode & 0o777)}); " - f"run chmod 600 {path}. Nothing was read or written." - ) + info = path.lstat() + if not stat.S_ISREG(info.st_mode): + raise LocalKeyError(f"{path} is not a regular file. Nothing was read.") + for item, label, fix in ( + (info, str(path), f"chmod 600 {path}"), + (path.parent.stat(), str(path.parent), f"chmod 700 {path.parent}"), + ): + if item.st_uid != os.getuid(): + raise LocalKeyError( + f"{label} belongs to another user. Nothing was read or written." + ) + if item.st_mode & (stat.S_IRWXG | stat.S_IRWXO): + raise LocalKeyError( + f"{label} is open to group or others (mode " + f"{oct(item.st_mode & 0o777)}); run {fix}. Nothing was read or " + "written." + ) def _read(path: Path) -> str: diff --git a/packages/flow/openadapt_flow/bundle_sealing.py b/packages/flow/openadapt_flow/bundle_sealing.py index cf58f151db..68a84cd4ce 100644 --- a/packages/flow/openadapt_flow/bundle_sealing.py +++ b/packages/flow/openadapt_flow/bundle_sealing.py @@ -278,7 +278,7 @@ def seal_bundle(source: Path | str, destination: Path | str) -> SealedBundle: staging: Path | None = None try: try: - key = crypto.require_key(None) + key = crypto.require_configured_key() except crypto.MissingKeyError as exc: raise BundleSealingError(str(exc)) from exc diff --git a/packages/flow/openadapt_flow/console/halt_detail.py b/packages/flow/openadapt_flow/console/halt_detail.py index c171882e0a..ce3df7e9d8 100644 --- a/packages/flow/openadapt_flow/console/halt_detail.py +++ b/packages/flow/openadapt_flow/console/halt_detail.py @@ -394,10 +394,16 @@ def _paused_step( bundle directory is used for reads only and is never projected -- a filesystem path is protected content on this boundary. """ + from openadapt_flow import crypto + try: - store = CheckpointStore(run_dir) + store = CheckpointStore(run_dir, key=crypto.resolve_key(None)) pending = store.read_pending() manifest = store.read_manifest() + except crypto.CryptoError: + # An encrypted pause that this key can't open is an error to show, + # never "no pending halt". + raise except Exception: # noqa: BLE001 - a locked or corrupt run stays answerable return None, None, None, 0 if pending is None or manifest is None or not manifest.bundle_dir: diff --git a/packages/flow/openadapt_flow/crypto.py b/packages/flow/openadapt_flow/crypto.py index 4f2f5fcbcc..68c25f074a 100644 --- a/packages/flow/openadapt_flow/crypto.py +++ b/packages/flow/openadapt_flow/crypto.py @@ -130,6 +130,22 @@ def require_key(key: Optional[str]) -> str: return resolved +def require_configured_key(key: Optional[str] = None) -> str: + """The explicit ``key`` or ``OPENADAPT_BUNDLE_KEY``, never the local key. + + For commands whose contract is the deployment key (``seal``, a record + check bound to an encrypted bundle): a missing key raises + :class:`MissingKeyError` instead of silently using this machine's key. + """ + resolved = key or os.environ.get(ENV_KEY) + if not resolved: + raise MissingKeyError( + f"no encryption key configured: set the {ENV_KEY} environment " + "variable to the deployment key." + ) + return resolved + + def is_encrypted(data: bytes) -> bool: """Whether ``data`` is one of this module's encrypted containers. diff --git a/packages/flow/openadapt_flow/record_check_binding.py b/packages/flow/openadapt_flow/record_check_binding.py index 0a18a0f8e5..c35d1d9692 100644 --- a/packages/flow/openadapt_flow/record_check_binding.py +++ b/packages/flow/openadapt_flow/record_check_binding.py @@ -477,7 +477,7 @@ def _bundle_key(bundle: Path) -> tuple[bool, Optional[str]]: if not (bundle / "workflow.json.enc").is_file(): return False, None try: - return True, crypto.require_key(None) + return True, crypto.require_configured_key() except crypto.MissingKeyError as exc: raise RecordCheckBindingError( [f"the bundle is encrypted and {crypto.ENV_KEY} is not set: {exc}"] diff --git a/packages/flow/openadapt_flow/runtime/composition.py b/packages/flow/openadapt_flow/runtime/composition.py index 76db8edca7..720899d0b0 100644 --- a/packages/flow/openadapt_flow/runtime/composition.py +++ b/packages/flow/openadapt_flow/runtime/composition.py @@ -128,6 +128,9 @@ def execute_composition( ) -> CompositionReport: """Sequence children fail-closed. Missing handoff evidence HALTs.""" + from openadapt_flow import at_rest as _at_rest + + _at_rest.hold_back_in_strict("a composition report") parent = Path(parent_dir) root_run = Path(run_dir) root_run.mkdir(parents=True, exist_ok=True) diff --git a/packages/flow/openadapt_flow/runtime/process_v1.py b/packages/flow/openadapt_flow/runtime/process_v1.py index f7279bba7e..25753c3ca5 100644 --- a/packages/flow/openadapt_flow/runtime/process_v1.py +++ b/packages/flow/openadapt_flow/runtime/process_v1.py @@ -977,6 +977,9 @@ def execute_process_contract_v1( ) -> ProcessV1Result: """Run or resume one ProcessContract v1 without a planner.""" + from openadapt_flow import at_rest as _at_rest + + _at_rest.hold_back_in_strict("a code child's output") if contract.schema_version != "openadapt.process-contract/v1": raise ProcessV1Error("the v1 runtime requires ProcessContract v1") if len(receipt_private_key) != 32: diff --git a/packages/flow/openadapt_flow/runtime/replayer.py b/packages/flow/openadapt_flow/runtime/replayer.py index 7672d367ec..353641b600 100644 --- a/packages/flow/openadapt_flow/runtime/replayer.py +++ b/packages/flow/openadapt_flow/runtime/replayer.py @@ -1624,12 +1624,19 @@ def run( _sealed.is_sealed(bundle_path) or _sealed.active_writer(bundle_path) is not None ) - # A run directory is sealed under strict mode, or when the deployment - # configured a key and the bundle is sealed. Otherwise it is one of the - # artifacts that can't be sealed yet by default (the offline Seal and - # other readers don't read sealed runs): it is written in plaintext, - # named on stderr and in the ledger (docs/phi_at_rest.md). - seal_run = at_rest.strict() or (bundle_sealed and at_rest.explicit_key()) + # A sealed bundle always runs into a sealed run directory, whatever + # the mode or opt-out: encrypted input never yields plaintext output. + # Strict mode seals the run of a plaintext bundle too. Otherwise the + # plaintext run directory is named on stderr and in the ledger + # (docs/phi_at_rest.md). + seal_run = at_rest.strict() or bundle_sealed + if save_healed_to is not None and seal_run: + # The healed copy is written as a plaintext bundle: hold it back + # before anything runs rather than decrypt a sealed bundle to disk. + raise at_rest.AtRestRefused( + f"Not writing a healed bundle at {save_healed_to}: it would be a " + "plaintext copy of an encrypted run. Nothing was executed." + ) if not seal_run or _sealed.active_writer(Path(run_dir)) is not None: if _sealed.active_writer(Path(run_dir)) is None: if not at_rest.plaintext_allowed("a run directory"): @@ -1669,11 +1676,9 @@ def run( # 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 run cannot use {unsupported} yet, because that " - "state is written to the run directory in plaintext. Nothing " - "was executed. Outside OPENADAPT_FLOW_AT_REST=strict, and " - "without a configured key, the run directory is written in " - "plaintext (recorded) and this works." + f"a sealed run keeps {unsupported} outside the sealed set, so " + "it is held back. Nothing was executed. Run a plaintext bundle " + "outside OPENADAPT_FLOW_AT_REST=strict to use it (recorded)." ) if self.checkpoint_key is None: # A sealed bundle's run keeps its durable state under the same key. diff --git a/packages/flow/openadapt_flow/runtime/variant_router.py b/packages/flow/openadapt_flow/runtime/variant_router.py index 0ece43e959..be8f317c99 100644 --- a/packages/flow/openadapt_flow/runtime/variant_router.py +++ b/packages/flow/openadapt_flow/runtime/variant_router.py @@ -437,6 +437,9 @@ def execute_variant_set( outcome ``DRY_RUN``; nothing is executed. """ + from openadapt_flow import at_rest as _at_rest + + _at_rest.hold_back_in_strict("a variant report") parent = Path(parent_dir) root = Path(run_dir) started = _now() diff --git a/packages/flow/openadapt_flow/runtime/worklist_pool.py b/packages/flow/openadapt_flow/runtime/worklist_pool.py index 42c2e45a34..c9e4b7d110 100644 --- a/packages/flow/openadapt_flow/runtime/worklist_pool.py +++ b/packages/flow/openadapt_flow/runtime/worklist_pool.py @@ -612,6 +612,9 @@ def run_worklist_pool( workers share a session. """ + from openadapt_flow import at_rest as _at_rest + + _at_rest.hold_back_in_strict("a worklist pool report") refusal = policy.refusal() if refusal is None and len(workers) != policy.workers: refusal = f"{policy.workers} workers requested but {len(workers)} configured" diff --git a/packages/flow/tests/test_at_rest_default.py b/packages/flow/tests/test_at_rest_default.py index d6026faa52..6688957f71 100644 --- a/packages/flow/tests/test_at_rest_default.py +++ b/packages/flow/tests/test_at_rest_default.py @@ -264,7 +264,7 @@ def test_commands_that_cannot_seal_their_output_refuse_in_strict_mode( # -- run directories ----------------------------------------------------------- -def _mockmed_run(tmp_path: Path, bundle: Path, run_dir: Path): +def _mockmed_run(tmp_path: Path, bundle: Path, run_dir: Path, **kw): from openadapt_flow.mockmed.fault_server import serve from openadapt_flow.runtime.effects import RestRecordVerifier from openadapt_flow.runtime.replayer import Replayer @@ -278,7 +278,7 @@ def _mockmed_run(tmp_path: Path, bundle: Path, run_dir: Path): vision=_vision(), effect_verifier=RestRecordVerifier(url), poll_interval_s=0.01, - ).run(Workflow.load(bundle), bundle_dir=bundle, run_dir=run_dir) + ).run(Workflow.load(bundle), bundle_dir=bundle, run_dir=run_dir, **kw) finally: stop() @@ -314,3 +314,149 @@ def test_strict_mode_seals_the_run_directory_of_a_plaintext_bundle( _assert_ciphertext_only(run_dir) assert watch.leaks([b'"execution_outcome"', PNG_SIGNATURE]) == [] assert _ledger(default_mode) == [] + + +# -- review fixes (#1755) -------------------------------------------------------- + + +def test_a_sealed_bundle_runs_sealed_in_every_mode( + default_mode: Path, tmp_path: Path, monkeypatch +) -> None: + """Encrypted input never yields a plaintext run directory, even under the + opt-out Desktop uses.""" + from tests.test_after_stop_mockmed import _workflow + + bundle = tmp_path / "bundle" + with sealed_set.writing(bundle, "bundle", crypto.resolve_key(None)): + _workflow().save(bundle) + monkeypatch.setenv(at_rest.OPT_OUT_ENV, "desktop") + monkeypatch.setenv(at_rest.HOME_ENV, str(default_mode)) + # The bundle opens with the local key, which the opt-out leaves unread. + monkeypatch.setenv(crypto.ENV_KEY, at_rest.local_key()) + run_dir = tmp_path / "run" + with watching_disk() as watch: + _mockmed_run(tmp_path, bundle, run_dir) + assert sealed_set.is_sealed(run_dir) + _assert_ciphertext_only(run_dir) + assert watch.leaks([b'"execution_outcome"', PNG_SIGNATURE]) == [] + + +def test_a_healed_copy_of_a_sealed_run_is_held_back( + default_mode: Path, tmp_path: Path +) -> None: + from tests.test_after_stop_mockmed import _workflow + + bundle = tmp_path / "bundle" + with sealed_set.writing(bundle, "bundle", crypto.resolve_key(None)): + _workflow().save(bundle) + with pytest.raises(at_rest.AtRestRefused, match="healed bundle"): + _mockmed_run( + tmp_path, bundle, tmp_path / "run", save_healed_to=tmp_path / "healed" + ) + assert not (tmp_path / "healed").exists() + assert not (tmp_path / "run").exists() + + +def test_default_mode_checkpoints_are_encrypted_on_disk( + default_mode: Path, tmp_path: Path +) -> None: + from openadapt_flow.runtime.durable.checkpoint import CheckpointStore + from openadapt_flow.runtime.replayer import Replayer + from tests.test_after_stop_mockmed import MockMedBackend, _vision, _workflow + + bundle = tmp_path / "bundle" + _workflow().save(bundle) + run_dir = tmp_path / "run" + replayer = Replayer(MockMedBackend("http://127.0.0.1:9"), vision=_vision()) + assert replayer.checkpoint_key is None + try: + replayer.run(Workflow.load(bundle), bundle_dir=bundle, run_dir=run_dir) + except Exception: # noqa: BLE001 - no app is listening; the key is what counts + pass + assert replayer.checkpoint_key == at_rest.local_key() + store = CheckpointStore(run_dir, key=replayer.checkpoint_key) + store._write_model(run_dir / "checkpoints" / "probe.json", Workflow(name="x")) + on_disk = sorted(p.name for p in (run_dir / "checkpoints").iterdir()) + assert on_disk == ["probe.json.enc"] + assert b"probe" not in (run_dir / "checkpoints" / "probe.json.enc").read_bytes() + + +def test_a_recording_only_key_reads_back_from_the_environment( + default_mode: Path, tmp_path: Path, monkeypatch +) -> None: + monkeypatch.setenv(sealed_set.RECORDING_ENV_KEY, "recording-only-key") + rec = _recording(tmp_path / "rec", key="recording-only-key") + assert main(["compile", str(rec), "--out", str(tmp_path / "b"), "--name", "x"]) == 0 + assert Workflow.load(tmp_path / "b").name == "x" + + +def test_strict_mode_runs_only_listed_commands(default_mode: Path, monkeypatch) -> None: + from openadapt_flow import __main__ as cli + from openadapt_flow.cli_service import _cmd_health + from openadapt_flow.cli_shadow import cmd_shadow + + monkeypatch.setenv(at_rest.MODE_ENV, "strict") + with pytest.raises(at_rest.AtRestRefused, match="strict"): + cli._unsealed_output_check(argparse.Namespace(func=cmd_shadow, out="o")) + cli._unsealed_output_check(argparse.Namespace(func=_cmd_health)) + with pytest.raises(at_rest.AtRestRefused, match="record-video"): + cli._unsealed_output_check( + argparse.Namespace(func=cli._cmd_replay, record_video="video") + ) + + +def test_default_mode_logs_the_newly_listed_writers(default_mode: Path, capsys) -> None: + from openadapt_flow import __main__ as cli + from openadapt_flow.cli_shadow import cmd_shadow + + cli._unsealed_output_check(argparse.Namespace(func=cmd_shadow, out="shadow-out")) + cli._unsealed_output_check( + argparse.Namespace(func=cli._cmd_replay, record_video="video-out") + ) + paths = {e["path"] for e in _ledger(default_mode)} + assert {"shadow-out", "video-out"} <= paths + + +def test_strict_holds_back_library_reports_before_they_start( + default_mode: Path, monkeypatch +) -> None: + from openadapt_flow.runtime.composition import execute_composition + + monkeypatch.setenv(at_rest.MODE_ENV, "strict") + with pytest.raises(at_rest.AtRestRefused, match="Nothing was executed"): + execute_composition( + None, # type: ignore[arg-type] + parent_dir="p", + run_dir="r", + child_run=None, # type: ignore[arg-type] + ) + + +@pytest.mark.skipif(os.name != "posix" or os.geteuid() == 0, reason="POSIX perms") +def test_an_unwritable_ledger_stops_the_write( + default_mode: Path, tmp_path: Path, monkeypatch +) -> None: + locked = tmp_path / "locked" + locked.mkdir() + (locked / at_rest.LEDGER_NAME).mkdir() # a directory where the file goes + monkeypatch.setenv(at_rest.HOME_ENV, str(locked)) + with pytest.raises(at_rest.AtRestRefused, match="ledger"): + at_rest.unsealed_write("a run directory", "/data/run-9") + + +def test_seal_requires_the_deployment_key(default_mode: Path, tmp_path: Path) -> None: + from tests.test_sealed_at_rest import _plain_bundle + + code = main(["seal", str(_plain_bundle(tmp_path)), "--out", str(tmp_path / "s")]) + assert code != 0 and not (tmp_path / "s").exists() + + +@pytest.mark.skipif(os.name != "posix", reason="POSIX permissions") +def test_an_open_keys_directory_is_refused(default_mode: Path) -> None: + crypto.resolve_key(None) + (default_mode / "keys").chmod(0o777) + try: + with pytest.raises(at_rest.LocalKeyError, match="chmod 700"): + crypto.resolve_key(None) + finally: + (default_mode / "keys").chmod(0o700) diff --git a/packages/flow/tests/test_compile_agent.py b/packages/flow/tests/test_compile_agent.py index 37d3902e89..8e86e7c2b2 100644 --- a/packages/flow/tests/test_compile_agent.py +++ b/packages/flow/tests/test_compile_agent.py @@ -158,6 +158,19 @@ def test_scrubbing_keeps_the_values_the_operator_named(monkeypatch) -> None: # -- the Claude loop ------------------------------------------------------------- +def _png() -> bytes: + import io + + from PIL import Image + + out = io.BytesIO() + Image.new("RGB", (4, 4), (255, 255, 255)).save(out, format="PNG") + return out.getvalue() + + +_TINY_PNG = _png() + + class _OutboundScrubber(_Scrubber): """Text and image scrubbing for the model-facing screenshots.""" @@ -191,7 +204,7 @@ def observe(self) -> Any: def screenshot(self, why: str = "requested") -> bytes: self.stats.screenshots_requested += 1 - return b"\x89PNG fake" + return _TINY_PNG def click(self, ref: str) -> None: if ref == "e9": @@ -351,11 +364,11 @@ def __init__(self, screen: str, **page: Any) -> None: self.executed: list[dict[str, Any]] = [] def screenshot(self) -> bytes: - return b"png" + return _TINY_PNG def execute(self, native: dict[str, Any]) -> Any: self.executed.append(native) - return SimpleNamespace(png=b"png", notes=[]) + return SimpleNamespace(png=_TINY_PNG, notes=[]) def _refused(computer: _Computer, native: dict[str, Any], tmp_path: Path) -> Any: From e838e687acea43116f390029e9b54bf2608669b5 Mon Sep 17 00:00:00 2001 From: abrichr Date: Tue, 3 Nov 2026 17:46:20 -0500 Subject: [PATCH 11/21] feat: teach and push read sealed input and keep the library and review copy sealed teach of a sealed run, fix recording or bundle (and every teach in strict mode) writes the taught bundle under the output bundle key, its skill library as a sealed set of the new kind 'library', and the compiled fix as a sealed bundle inside the recording. push, sanitize, review-sanitized and approve-sanitized of a sealed source keep the sanitized review copy as a sealed set of the new kind 'sanitized', staged 0700 and removed on failure, with the approved archive encrypted beside it and decrypted in memory for the upload. Scrubbing, the second pass and the approval rescan are unchanged. Sealed-input tests for each, and the artifact-read lint covers the new functions. Refs #1751. --- CAPABILITIES.md | 2 +- llms-full.txt | 2 +- packages/flow/docs/phi_at_rest.md | 60 +++- packages/flow/openadapt_flow/__main__.py | 33 +- packages/flow/openadapt_flow/hosted.py | 37 ++- .../flow/openadapt_flow/learning/library.py | 46 ++- .../flow/openadapt_flow/learning/teach.py | 144 ++++++--- .../openadapt_flow/repair/registration.py | 23 +- .../flow/openadapt_flow/sanitized_artifact.py | 236 +++++++++++---- packages/flow/openadapt_flow/sealed_set.py | 53 +++- packages/flow/tests/test_artifact_reads.py | 283 ++++++++++++++++++ packages/flow/tests/test_sealed_at_rest.py | 7 +- 12 files changed, 776 insertions(+), 150 deletions(-) diff --git a/CAPABILITIES.md b/CAPABILITIES.md index 10d012e9f9..c83b7f7a90 100644 --- a/CAPABILITIES.md +++ b/CAPABILITIES.md @@ -301,7 +301,7 @@ improvising or retrying when something is off. | 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` | | See a compiled program as a map (every step, exact edge, loop and stop rule, with each step's checks) and play a recorded run over it; one React package that Desktop, app.openadapt.ai and openadapt.ai share | On main ([packages/program-graph](packages/program-graph/README.md)) | Real compiled OpenEMR referral program and a recorded scoreboard run, both on synthetic data | `@openadapt/program-graph`; `openadapt-flow visualize --format json` | | Named sections for every compiled program ("Who: title, first name, last name", "Contact", "Create New Patient"), found without a model from the save, the recorded form structure, the fields and any narration; a person can rename, merge or split them on the review screen | On main | Unit tests; the certified OpenEMR referral bundle and a real recording of it on a local test copy with made-up patients | `openadapt-flow sections`; [SECTIONS.md](packages/flow/docs/SECTIONS.md) | -| 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, undo, visualize, explain, emit-skill, for-each, sections, check mark, compose, process, variants and route read them in memory through one artifact reader and refuse without the key | On main | 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) | +| 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, undo, visualize, explain, emit-skill, for-each, sections, check mark, compose, process, variants, route, teach and push read them in memory through one artifact reader and refuse without the key; teach keeps its skill library and taught bundle sealed, and push keeps its sanitized review copy and approved archive encrypted until the upload | On main | 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) | | Encryption at rest under a configured or locally generated key (0600): durable checkpoints and attended state by default; with `OPENADAPT_FLOW_AT_REST=strict`, every recording, bundle and run directory too. Each plaintext write is logged on stderr and in a ledger that Desktop shows as a notice | On main | Synthetic (key, opt-out, strict-mode and run-directory tests) | `openadapt_flow.at_rest`; [phi_at_rest.md](packages/flow/docs/phi_at_rest.md#default-opt-out-and-strict-mode) | | Signed receipts (Seals), countersigned and timestamped, offline by default | Released | Unit tests, real TSA tokens | `openadapt-flow seal`, `countersign`, `local-seal` | | Offline Seals signed with a key the customer holds: a key file, the OS keychain, or a PKCS#11 token or HSM; pinned keys with rotation and revocation; no fallback to a generated key | On main ([#1392](https://github.com/OpenAdaptAI/OpenAdapt/pull/1392)) | Unit tests with synthetic data (keychain and PKCS#11 through stand-ins) | `openadapt-flow local-seal --key`, `local-seal key create`, `pin`, `revoke`; `openadapt seal verify FOLDER` checks every sealed run in a folder; [OFFLINE_SEALS.md](packages/flow/docs/OFFLINE_SEALS.md#customer-held-keys) | diff --git a/llms-full.txt b/llms-full.txt index 908c4168dd..48c2352609 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -698,7 +698,7 @@ improvising or retrying when something is off. | 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` | | See a compiled program as a map (every step, exact edge, loop and stop rule, with each step's checks) and play a recorded run over it; one React package that Desktop, app.openadapt.ai and openadapt.ai share | On main ([packages/program-graph](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/program-graph/README.md)) | Real compiled OpenEMR referral program and a recorded scoreboard run, both on synthetic data | `@openadapt/program-graph`; `openadapt-flow visualize --format json` | | Named sections for every compiled program ("Who: title, first name, last name", "Contact", "Create New Patient"), found without a model from the save, the recorded form structure, the fields and any narration; a person can rename, merge or split them on the review screen | On main | Unit tests; the certified OpenEMR referral bundle and a real recording of it on a local test copy with made-up patients | `openadapt-flow sections`; [SECTIONS.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/SECTIONS.md) | -| 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, undo, visualize, explain, emit-skill, for-each, sections, check mark, compose, process, variants and route read them in memory through one artifact reader and refuse without the key | On main | 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) | +| 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, undo, visualize, explain, emit-skill, for-each, sections, check mark, compose, process, variants, route, teach and push read them in memory through one artifact reader and refuse without the key; teach keeps its skill library and taught bundle sealed, and push keeps its sanitized review copy and approved archive encrypted until the upload | On main | 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) | | Encryption at rest under a configured or locally generated key (0600): durable checkpoints and attended state by default; with `OPENADAPT_FLOW_AT_REST=strict`, every recording, bundle and run directory too. Each plaintext write is logged on stderr and in a ledger that Desktop shows as a notice | On main | Synthetic (key, opt-out, strict-mode and run-directory tests) | `openadapt_flow.at_rest`; [phi_at_rest.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/phi_at_rest.md#default-opt-out-and-strict-mode) | | Signed receipts (Seals), countersigned and timestamped, offline by default | Released | Unit tests, real TSA tokens | `openadapt-flow seal`, `countersign`, `local-seal` | | Offline Seals signed with a key the customer holds: a key file, the OS keychain, or a PKCS#11 token or HSM; pinned keys with rotation and revocation; no fallback to a generated key | On main ([#1392](https://github.com/OpenAdaptAI/OpenAdapt/pull/1392)) | Unit tests with synthetic data (keychain and PKCS#11 through stand-ins) | `openadapt-flow local-seal --key`, `local-seal key create`, `pin`, `revoke`; `openadapt seal verify FOLDER` checks every sealed run in a folder; [OFFLINE_SEALS.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/OFFLINE_SEALS.md#customer-held-keys) | diff --git a/packages/flow/docs/phi_at_rest.md b/packages/flow/docs/phi_at_rest.md index 9050808852..f0f7fbaf53 100644 --- a/packages/flow/docs/phi_at_rest.md +++ b/packages/flow/docs/phi_at_rest.md @@ -26,7 +26,9 @@ with your other secrets. | Web recordings | Encrypted with a configured key; otherwise logged | Encrypted | | Bundles from `compile`, `induce`, `admit` | Encrypted from encrypted input; otherwise logged | Encrypted | | Run directories | Encrypted for a sealed bundle with a configured key; otherwise logged | Encrypted | -| Desktop recordings, approval requests, and the output of `visualize`, `explain`, `teach`, `emit-skill`, `push`, `compose`, `process`, `variants`, `route`, `for-each`, `--save-healed-to`, `--workers` and `induce --report` | Logged | Held back before the command runs | +| The bundle, skill library and compiled fix of `teach` | Encrypted from encrypted input; otherwise logged | Encrypted | +| The sanitized review copy and approved archive of `push` and `sanitize` | Encrypted from encrypted input; otherwise logged | Encrypted | +| Desktop recordings, approval requests, and the output of `visualize`, `explain`, `emit-skill`, `compose`, `process`, `variants`, `route`, `for-each`, `--save-healed-to`, `--workers` and `induce --report` | Logged | Held back before the command runs | **Logged** means stderr names the path and one line goes to `~/.openadapt/plaintext-at-rest.jsonl`. OpenAdapt Desktop shows a notice in @@ -119,8 +121,9 @@ 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. +`openadapt_flow.sealed_set.BUNDLE_FILES`, `RECORDING_FILES`, `LIBRARY_FILES` +and `SANITIZED_FILES` are the source of truth; a test keeps these tables in +step with them. ### Bundle @@ -151,6 +154,24 @@ of truth; a test keeps this table in step with them. | `distill.json`, `.openadapt-approval.json`, `.openadapt-sanitization.json` | Derived reports and approvals | Encrypted | | Anything else | Unknown | Refused | +### Skill library + +| File | Holds | At rest | +| --- | --- | --- | +| `skills.json` | Every learned program version (target labels, typed literals, screen text, crop paths) and the execution traces it was learned from | Encrypted | +| Anything else | Unknown | Refused | + +### Sanitized review copy + +| File | Holds | At rest | +| --- | --- | --- | +| `.openadapt-sanitization.json` | The redaction manifest: file names, hashes and redaction records | Encrypted | +| `.openadapt-approval.json` | The reviewer's approval | Encrypted | +| `*` | The scrubbed copy of each recording or bundle file, before review | Encrypted | + +The approved archive beside it (`.approved.zip`) is one AES-256-GCM +container under the same key. + ### Run directory of a sealed bundle Every file is encrypted except these structural ones: `.durable_run.claim` @@ -172,9 +193,8 @@ 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. +`record --verify` keeps its system-of-record reads inside it, encrypted, and +`record --with-check` marks its check inside it, encrypted. Web recordings are encrypted under a configured key, and under the local key in strict mode. `--no-encrypt` records one in plaintext, with a warning and a @@ -207,9 +227,10 @@ takes tens to hundreds of milliseconds, so encryption adds about 1% or less. `scaffold-verifier`, `seal`, `certify`, `qualify`, `admit`, `run`, `replay`, `approve`, `resume`, `undo`, `visualize` (including a process contract), `explain`, `emit-skill`, `for-each`, `sections`, `check mark`, `compose`, -`process`, `variants` and `route` read sealed bundles, run directories and -encrypted recordings in memory, given the key, and keep what they write inside -a sealed set sealed. Each reads artifact files through one reader, +`process`, `variants`, `route`, `teach`, `push`, `sanitize`, +`review-sanitized` and `approve-sanitized` read sealed bundles, run +directories and encrypted recordings in memory, given the key, and keep what +they write inside a sealed set sealed. Each reads artifact files through one reader, `openadapt_flow.artifacts`: - `compile` of an encrypted recording writes a sealed bundle, under @@ -243,6 +264,23 @@ a sealed set sealed. Each reads artifact files through one reader, (`composition.json`, `process-contract.json`, `variant-set.json`) holds names, digests and pointers; by default it is named on stderr and in the ledger, and strict mode refuses the command before it runs. +- `teach` of a sealed run, fix recording or bundle, and every `teach` in + strict mode, writes the taught bundle sealed (crops, program and repair + candidate), under the same key rules as `compile`. Its skill library + (`.skills`, or `--library`) is a sealed set of its own, and the fix + recording compiles into `_teach_compiled`, a sealed bundle inside the + recording that the recording's index skips. A library or `--out` directory + that already holds plaintext is refused, so nothing learned from encrypted + input lands beside plaintext. +- `push` and `sanitize` of an encrypted recording or bundle, and every + `push` in strict mode, write the sanitized review copy as a sealed set of + kind `sanitized`, staged in a private (0700) directory beside it and removed + if anything fails. Scrubbing, the second-pass check and the approval rescan + run on the decrypted bytes in memory and are exactly as strict as for + plaintext. `review-sanitized` shows the original and the copy from memory + and writes a reviewer's redactions back encrypted; `approve-sanitized` + encrypts the approved archive; the upload decrypts that archive in memory and + sends the exact approved bytes. 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 @@ -273,8 +311,8 @@ marks as logged. Also: 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. +- Commands outside the list above refuse sealed input rather than decrypt it + to disk. ## The identity template is not encryption diff --git a/packages/flow/openadapt_flow/__main__.py b/packages/flow/openadapt_flow/__main__.py index 2d59739f2d..ef4c3475dd 100644 --- a/packages/flow/openadapt_flow/__main__.py +++ b/packages/flow/openadapt_flow/__main__.py @@ -12603,6 +12603,11 @@ def _qualify_default_form(argv: list[str]) -> list[str]: "openadapt_flow.__main__:_cmd_process", "openadapt_flow.__main__:_cmd_variants", "openadapt_flow.__main__:_cmd_route", + "openadapt_flow.__main__:_cmd_teach", + "openadapt_flow.__main__:_cmd_push", + "openadapt_flow.__main__:_cmd_sanitize", + "openadapt_flow.__main__:_cmd_review_sanitized", + "openadapt_flow.__main__:_cmd_approve_sanitized", "openadapt_flow.cli_sections:cmd_sections", "openadapt_flow.cli_check:cmd_check_mark", "openadapt_flow.cli_admit:cmd_admit_run", @@ -12636,9 +12641,17 @@ def _qualify_default_form(argv: list[str]) -> list[str]: } -#: Commands that write a new bundle sealed when their input bundle is sealed. -_SEALED_OUTPUT_FROM_SEALED_INPUT: frozenset[str] = frozenset( - {"openadapt_flow.__main__:_cmd_for_each"} +#: Commands that write their output sealed when an input is sealed, by the +#: arguments that name those inputs. +_SEALED_OUTPUT_FROM_SEALED_INPUT: dict[str, tuple[str, ...]] = { + "openadapt_flow.__main__:_cmd_for_each": ("bundle",), + "openadapt_flow.__main__:_cmd_teach": ("run_dir", "fix", "bundle"), + "openadapt_flow.__main__:_cmd_push": ("path",), +} + +#: Commands that write their output sealed in strict mode. +_SEALED_OUTPUT_IN_STRICT: frozenset[str] = frozenset( + {"openadapt_flow.__main__:_cmd_teach", "openadapt_flow.__main__:_cmd_push"} ) @@ -12663,12 +12676,15 @@ def _unsealed_output_check(args: argparse.Namespace) -> None: artifact = _UNSEALED_OUTPUT_COMMANDS.get(command) if artifact is None: return + if command in _SEALED_OUTPUT_IN_STRICT and at_rest.strict(): + return # strict mode makes a sealed output if command in _SEALED_OUTPUT_FROM_SEALED_INPUT: from openadapt_flow import sealed_set - source = getattr(args, "bundle", None) - if source and sealed_set.is_sealed_path(source): - return # a sealed input makes a sealed output + for name in _SEALED_OUTPUT_FROM_SEALED_INPUT[command]: + source = getattr(args, name, None) + if source and sealed_set.is_sealed_path(source): + return # a sealed input makes a sealed output target = next( ( getattr(args, name) @@ -12730,8 +12746,9 @@ def _sealed_input_refusal(args: argparse.Namespace) -> Optional[str]: "command. Commands that read sealed input: record, compile, " "induce, scaffold-verifier, seal, certify, qualify, admit, run, " "replay, approve, resume, undo, visualize, explain, emit-skill, " - "for-each, sections, check mark, compose, process, variants and " - "route." + "for-each, sections, check mark, compose, process, variants, " + "route, teach, push, sanitize, review-sanitized and " + "approve-sanitized." ) for root in sealed: kind = sealed_set.header_kind(root) diff --git a/packages/flow/openadapt_flow/hosted.py b/packages/flow/openadapt_flow/hosted.py index 4a0f111fdb..ee8e840d9c 100644 --- a/packages/flow/openadapt_flow/hosted.py +++ b/packages/flow/openadapt_flow/hosted.py @@ -38,6 +38,7 @@ from __future__ import annotations import hashlib +import io import ipaddress import json import os @@ -899,7 +900,11 @@ def _is_recording_dir(path: Path) -> bool: def _is_bundle_dir(path: Path) -> bool: - return (path / "workflow.json").is_file() or (path / "workflow.json.enc").is_file() + from openadapt_flow import artifacts + + return artifacts.is_file(path / "workflow.json") or artifacts.is_file( + path / "workflow.json.enc" + ) def find_latest_recording(base: Optional[Path] = None) -> Path: @@ -1221,6 +1226,15 @@ def _verified_archive_snapshot(archive_path: Path, *, expected_sha256: str) -> A raise +def _verified_archive_bytes(data: bytes, *, expected_sha256: str) -> Any: + """An in-memory upload body for an encrypted derivative's archive.""" + if hashlib.sha256(data).hexdigest() != expected_sha256: + raise HostedError( + "Approved immutable archive changed before upload; refusing egress" + ) + return io.BytesIO(data) + + def push( path: Optional[Any] = None, *, @@ -1303,6 +1317,7 @@ def push( raise HostedError(f"PATH is not a directory: {src}") _assert_upload_tree_safe(src) + from openadapt_flow import artifacts from openadapt_flow.sanitized_artifact import ( MANIFEST_NAME, SanitizationError, @@ -1311,11 +1326,12 @@ def push( build_ingest_manifest, load_and_verify_derivative, load_valid_approval, + read_approved_archive, sanitize_artifact, source_tree_sha256, ) - is_derivative = (src / MANIFEST_NAME).is_file() + is_derivative = artifacts.is_file(src / MANIFEST_NAME) if not is_derivative: actual_kind = ( "bundle" if kind == "bundle" and _is_bundle_dir(src) else "recording" @@ -1497,10 +1513,19 @@ def push( ), } try: - archive_snapshot = _verified_archive_snapshot( - archive_path, - expected_sha256=approval["approved_derivative_sha256"], - ) + if artifacts.is_sealed(src): + # An encrypted derivative's archive is decrypted in memory only. + archive_snapshot = _verified_archive_bytes( + read_approved_archive(src), + expected_sha256=approval["approved_derivative_sha256"], + ) + else: + archive_snapshot = _verified_archive_snapshot( + archive_path, + expected_sha256=approval["approved_derivative_sha256"], + ) + except SanitizationError as exc: + raise HostedError(f"Sanitized artifact is not uploadable: {exc}") from exc except HostedError: raise except OSError as exc: diff --git a/packages/flow/openadapt_flow/learning/library.py b/packages/flow/openadapt_flow/learning/library.py index 84cf40627f..bca1661744 100644 --- a/packages/flow/openadapt_flow/learning/library.py +++ b/packages/flow/openadapt_flow/learning/library.py @@ -13,6 +13,11 @@ Persistence is a single JSON file per library root (``skills.json``): the schema is Pydantic, so a version round-trips a real ``ProgramGraph`` verbatim. No model calls, no I/O beyond the JSON file. + +A learned program can carry patient data, so a library can also be a sealed set +of kind ``library`` (:mod:`openadapt_flow.sealed_set`): ``seal=True`` creates +one, and a root that is already sealed stays sealed. ``skills.json`` is then +encrypted on disk and read back in memory. """ from __future__ import annotations @@ -24,6 +29,7 @@ from pydantic import BaseModel, Field +from openadapt_flow import artifacts, sealed_set from openadapt_flow.ir import ProgramGraph from openadapt_flow.learning.trace import ExecutionTrace @@ -101,12 +107,37 @@ class SkillLibrary: the next). """ - def __init__(self, root: Path | str) -> None: + def __init__( + self, root: Path | str, *, seal: bool = False, key: Optional[str] = None + ) -> None: self.root = Path(root) - self.root.mkdir(parents=True, exist_ok=True) self._skills: dict[str, Skill] = {} + self._writer: Optional[sealed_set.SealedSetWriter] = None + existing = sealed_set.is_sealed(self.root) + if seal or existing: + if not existing and self.root.is_dir() and any(self.root.iterdir()): + raise sealed_set.SealedSetError( + f"{self.root} holds an unencrypted skill library; pass a new " + "library directory so learned programs stay encrypted" + ) + passphrase = sealed_set.passphrase_for("library", key) + if not passphrase: + raise sealed_set.SealedSetKeyError( + sealed_set.missing_key_message(self.root, "library") + ) + self._writer = sealed_set.SealedSetWriter( + self.root, "library", passphrase, resume=True + ) + if not existing: + self._writer.finalize() # an empty library is a complete set + else: + self.root.mkdir(parents=True, exist_ok=True) self._load() + @property + def sealed(self) -> bool: + return self._writer is not None + # -- persistence -------------------------------------------------------- @property @@ -114,15 +145,20 @@ def path(self) -> Path: return self.root / _LIBRARY_FILE def _load(self) -> None: - if not self.path.is_file(): + if not artifacts.is_file(self.path): return - raw = json.loads(self.path.read_text()) + raw = json.loads(artifacts.read_text(self.path)) for sid, data in raw.get("skills", {}).items(): self._skills[sid] = Skill.model_validate(data) def save(self) -> Path: payload = {"skills": {sid: s.model_dump() for sid, s in self._skills.items()}} - self.path.write_text(json.dumps(payload, indent=2, default=str)) + text = json.dumps(payload, indent=2, default=str) + if self._writer is not None: + self._writer.write(_LIBRARY_FILE, text.encode("utf-8")) + self._writer.finalize() + else: + self.path.write_text(text) return self.path # -- read --------------------------------------------------------------- diff --git a/packages/flow/openadapt_flow/learning/teach.py b/packages/flow/openadapt_flow/learning/teach.py index fdca14a554..4c16d3f180 100644 --- a/packages/flow/openadapt_flow/learning/teach.py +++ b/packages/flow/openadapt_flow/learning/teach.py @@ -61,13 +61,14 @@ from __future__ import annotations import difflib +import os import re -import shutil from pathlib import Path from typing import Optional from pydantic import BaseModel, Field +from openadapt_flow import artifacts, sealed_set from openadapt_flow.ir import ( ActionKind, Anchor, @@ -146,12 +147,12 @@ def load_halt_report(run_dir: Path | str) -> RunReport: """ run = Path(run_dir) report_path = run / "report.json" - if not report_path.is_file(): + if not artifacts.is_file(report_path): raise TeachError( f"no run report at {report_path} -- teach needs the directory of a " "run that HALTED (holding report.json)" ) - report = RunReport.model_validate_json(report_path.read_text(encoding="utf-8")) + report = RunReport.model_validate_json(artifacts.read_text(report_path)) if report.halt is None: raise TeachError( f"the run at {run} did not halt (report.json has no halt observation) " @@ -168,7 +169,9 @@ def load_base_program(bundle_dir: Path | str) -> tuple[Workflow, ProgramGraph]: same governed learn path applies to both. """ bundle = Path(bundle_dir) - if not (bundle / "workflow.json").is_file(): + if not artifacts.is_file(bundle / "workflow.json") and not artifacts.is_file( + bundle / "workflow.json.enc" + ): raise TeachError( f"no bundle at {bundle} -- teach needs the base bundle that halted " "(the one whose replay produced the run report), via --bundle" @@ -413,7 +416,7 @@ def _texts_seen_before_halt( completed = result.ok and not result.skipped if completed or (delivered and result is halted): frames.append(run_dir / result.before_png) - readable = [frame for frame in frames if frame.is_file()] + readable = [frame for frame in frames if artifacts.is_file(frame)] if not readable: return None try: @@ -422,7 +425,7 @@ def _texts_seen_before_halt( seen: list[str] = [] for frame in readable: - for line in ocr(frame.read_bytes()): + for line in ocr(artifacts.open_read(frame)): text = (scrub_text(line.text) or "").strip() if text: seen.append(text) @@ -680,12 +683,14 @@ def _spec_from_recording( from openadapt_flow.compiler.compile import compile_recording recording = Path(recording_dir) - if not (recording / "events.jsonl").is_file(): + if not artifacts.is_file(recording / "events.jsonl"): raise TeachError( f"{recording} is not a recording directory (no events.jsonl). Pass a " "recording produced by `openadapt-flow record`, or a .json " "correction spec." ) + # Inside an encrypted recording this is a sealed bundle of its own, which + # the recording's set skips (compile_recording seals it). compiled_dir = recording / "_teach_compiled" compiled = compile_recording( recording, compiled_dir, name=f"{report.workflow_name}-fix" @@ -838,7 +843,7 @@ def canary(ctx: CanaryContext) -> tuple[bool, str]: continue if anchor is not None and ( anchor.template in carried - or (bundle_dir / anchor.template).is_file() + or artifacts.is_file(bundle_dir / anchor.template) ): continue return False, ( @@ -852,24 +857,84 @@ def canary(ctx: CanaryContext) -> tuple[bool, str]: return canary +def _write_crop(destination: Path, data: bytes) -> None: + """Write a crop into the taught bundle: encrypted when it is being sealed.""" + if not sealed_set.is_sealed_path(destination): + destination.parent.mkdir(parents=True, exist_ok=True) + sealed_set.write_bytes(destination, data) + + def _copy_templates(src_bundle: Path, out_bundle: Path) -> None: """Carry the base bundle's template crops (subfolders included, such as identifier crops) into the updated bundle so the promoted program can - resolve its anchors on re-run.""" - src_templates = src_bundle / "templates" - if not src_templates.is_dir(): - return - shutil.copytree(src_templates, out_bundle / "templates", dirs_exist_ok=True) + resolve its anchors on re-run. Sealed crops are decrypted in memory.""" + src_templates = Path(os.path.abspath(src_bundle / "templates")) + for template in artifacts.list_files(src_templates): + _write_crop( + out_bundle / "templates" / template.relative_to(src_templates), + artifacts.open_read(template), + ) def _copy_carried(carried: dict[str, Path], out_bundle: Path) -> None: """Copy the fix recording's crops to the paths the spliced step uses.""" for rel, source in carried.items(): - if not source.is_file(): + if not artifacts.is_file(source): raise TeachError(f"the fix recording's crop {source} is missing") - destination = out_bundle / rel - destination.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(source, destination) + _write_crop(out_bundle / rel, artifacts.open_read(source)) + + +def _seal_outputs(*inputs: Path | str) -> bool: + """Whether teach writes its library and bundle sealed: any input is sealed, + or strict mode (docs/phi_at_rest.md).""" + from openadapt_flow import at_rest + + return at_rest.strict() or any(artifacts.is_sealed(p) for p in inputs) + + +def _check_sealed_out(out_bundle: Path) -> None: + """A sealed taught bundle goes into a new or sealed directory, never over + a plaintext one.""" + if sealed_set.is_sealed(out_bundle): + return + if out_bundle.is_dir() and any(out_bundle.iterdir()): + raise TeachError( + f"--out {out_bundle} already holds an unencrypted bundle; pass a new " + "directory so the taught bundle stays encrypted" + ) + + +def _write_taught_bundle( + updated: Workflow, + bundle: Path, + out_bundle: Path, + carried: dict[str, Path], + run_dir: Path | str, +) -> None: + # Crops go in first: saving seals a manifest over the files present. + _copy_templates(bundle, out_bundle) + _copy_carried(carried, out_bundle) + updated.save(out_bundle) + # Governed repair promotion (Section 9): the taught bundle is a lifecycle + # CANDIDATE, never an implicitly active bundle. A detached + # repair/candidate.json is written into it; promotion to active requires + # the explicit lifecycle (campaigns, human approval, staged canary) via + # `openadapt-flow repair`. Registration failure fails CLOSED by absence: + # without the record the taught bundle cannot enter the lifecycle at all. + from openadapt_flow.repair.registration import register_bundle_candidate + + try: + register_bundle_candidate( + bundle, + out_bundle, + source="teach", + evidence_run_dir=Path(run_dir), + ) + except Exception as exc: + print( + "repair candidate registration failed (the taught bundle stays " + f"ungoverned and non-promotable): {exc}" + ) def teach( @@ -908,6 +973,9 @@ def teach( fix, a run that did not come from ``bundle``, or a library at ``library_dir`` seeded from a different bundle). """ + seal = _seal_outputs(run_dir, fix, bundle) + if seal: + _check_sealed_out(Path(out)) report = load_halt_report(run_dir) base_workflow, program = load_base_program(bundle) check_provenance(report, base_workflow, program, run_dir=run_dir, bundle_dir=bundle) @@ -931,7 +999,10 @@ def teach( if library_dir is not None else Path(out).parent / f"{Path(out).name}.skills" ) - library = SkillLibrary(lib_root) + try: + library = SkillLibrary(lib_root, seal=seal) + except sealed_set.SealedSetError as exc: + raise TeachError(str(exc)) from exc _seed_or_check_library( library, sid, program, dict(base_workflow.subflows), bundle_dir=bundle ) @@ -964,30 +1035,21 @@ def teach( } ) out_bundle = Path(out) - # Crops go in first: saving seals a manifest over the files present. - _copy_templates(Path(bundle), out_bundle) - _copy_carried(carried, out_bundle) - updated.save(out_bundle) - # Governed repair promotion (Section 9): the taught bundle is a lifecycle - # CANDIDATE, never an implicitly active bundle. A detached - # repair/candidate.json is written into it; promotion to active requires - # the explicit lifecycle (campaigns, human approval, staged canary) via - # `openadapt-flow repair`. Registration failure fails CLOSED by absence: - # without the record the taught bundle cannot enter the lifecycle at all. - from openadapt_flow.repair.registration import register_bundle_candidate - - try: - register_bundle_candidate( - Path(bundle), + if seal: + # Every crop, the program and the candidate record are encrypted + # before they reach the disk (the same key rules as compile). + passphrase = sealed_set.output_bundle_key() + if not passphrase: + raise TeachError(sealed_set.missing_key_message(out_bundle, "bundle")) + with sealed_set.writing( out_bundle, - source="teach", - evidence_run_dir=Path(run_dir), - ) - except Exception as exc: - print( - "repair candidate registration failed (the taught bundle stays " - f"ungoverned and non-promotable): {exc}" - ) + "bundle", + passphrase, + resume=sealed_set.is_sealed(out_bundle), + ): + _write_taught_bundle(updated, Path(bundle), out_bundle, carried, run_dir) + else: + _write_taught_bundle(updated, Path(bundle), out_bundle, carried, run_dir) return TeachResult( skill_id=sid, promoted=True, outcome=outcome, out_bundle=out_bundle ) diff --git a/packages/flow/openadapt_flow/repair/registration.py b/packages/flow/openadapt_flow/repair/registration.py index d59839e4a2..87f3c9a021 100644 --- a/packages/flow/openadapt_flow/repair/registration.py +++ b/packages/flow/openadapt_flow/repair/registration.py @@ -19,11 +19,13 @@ from __future__ import annotations import json +import os from pathlib import Path from typing import Optional from pydantic import BaseModel +from openadapt_flow import artifacts, sealed_set from openadapt_flow.ir import Workflow from openadapt_flow.qualification import EvidenceRef from openadapt_flow.repair.candidate import ( @@ -104,10 +106,11 @@ def _evidence_from_run_dir( """Collect heal-patch evidence refs + privacy-safe failure fingerprints.""" evidence: list[EvidenceRef] = [] fingerprints: list[FailureFingerprint] = [] + run_dir = Path(os.path.abspath(run_dir)) # list_files names absolute paths heals_dir = run_dir / "heals" - if heals_dir.is_dir(): - for patch_path in sorted(heals_dir.glob("*/patch.json")): - raw = patch_path.read_bytes() + if artifacts.is_dir(heals_dir): + for patch_path in artifacts.list_files(heals_dir, "*/patch.json"): + raw = artifacts.open_read(patch_path) rel = patch_path.relative_to(run_dir).as_posix() evidence.append( EvidenceRef(kind="other", sha256=sha256_hex(raw), relative_path=rel) @@ -126,11 +129,11 @@ def _evidence_from_run_dir( ) ) report_path = run_dir / "report.json" - if report_path.is_file(): + if artifacts.is_file(report_path): evidence.append( EvidenceRef( kind="run_report", - sha256=sha256_hex(report_path.read_bytes()), + sha256=sha256_hex(artifacts.open_read(report_path)), relative_path="report.json", ) ) @@ -223,17 +226,19 @@ def register_bundle_candidate( evidence_run_dir=evidence_run_dir, ) path = detached_candidate_path(proposed_bundle) - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(candidate.model_dump_json(indent=2)) + if not sealed_set.is_sealed_path(proposed_bundle): + path.parent.mkdir(parents=True, exist_ok=True) + # Encrypted when the proposed bundle is sealed (it names anchor text). + sealed_set.write_text(path, candidate.model_dump_json(indent=2)) return path def load_detached_candidate(proposed_bundle: Path | str) -> RepairCandidate: """Load the detached candidate record from a proposed bundle.""" path = detached_candidate_path(proposed_bundle) - if not path.is_file(): + if not artifacts.is_file(path): raise FileNotFoundError( f"no detached repair candidate at {path}; register the bundle " "pair first (openadapt-flow repair register)" ) - return RepairCandidate.model_validate_json(path.read_text()) + return RepairCandidate.model_validate_json(artifacts.read_text(path)) diff --git a/packages/flow/openadapt_flow/sanitized_artifact.py b/packages/flow/openadapt_flow/sanitized_artifact.py index 3a9f998c21..a5114a6a1f 100644 --- a/packages/flow/openadapt_flow/sanitized_artifact.py +++ b/packages/flow/openadapt_flow/sanitized_artifact.py @@ -8,6 +8,12 @@ The built-in local reviewer binds only to loopback and serves no remote assets. It is intentionally small: deployments can replace it with a richer desktop UI while retaining the manifest and approval contracts in this module. + +A sealed source (an encrypted recording or bundle), or any source in strict +mode, gets a sealed derivative: a sealed set of kind ``sanitized`` +(:mod:`openadapt_flow.sealed_set`) whose files are decrypted in memory for the +rescan, the review page and the upload, and an approved archive encrypted +beside it. The scrubbing and approval checks are the same for both forms. """ from __future__ import annotations @@ -31,6 +37,7 @@ from datetime import datetime, timezone from functools import lru_cache from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from io import BytesIO from pathlib import Path, PurePosixPath from typing import Any, Optional from urllib.parse import parse_qs @@ -40,7 +47,7 @@ except ModuleNotFoundError: # pragma: no cover - exercised on 3.10 CI import tomli as tomllib -from openadapt_flow import privacy +from openadapt_flow import artifacts, crypto, privacy, sealed_set MANIFEST_NAME = ".openadapt-sanitization.json" APPROVAL_NAME = ".openadapt-approval.json" @@ -82,7 +89,50 @@ def _sha256_bytes(data: bytes) -> str: return hashlib.sha256(data).hexdigest() +#: Associated data of a sealed derivative's encrypted approved archive. +_ARCHIVE_AAD = b"openadapt-flow/sanitized-archive" + + +def _read(path: Path) -> bytes: + """An artifact file's bytes, decrypted in memory when it is sealed.""" + return artifacts.open_read(path) + + +def _read_text(path: Path) -> str: + """An artifact file's text, with the newline translation of + :meth:`pathlib.Path.read_text` for sealed files too.""" + if not artifacts.is_sealed(path): + return path.read_text(encoding="utf-8") + text = artifacts.read_text(path) + return text.replace("\r\n", "\n").replace("\r", "\n") + + +def _write(path: Path, data: bytes) -> None: + """Write a derivative file: encrypted inside a sealed derivative.""" + if not artifacts.is_sealed(path): + path.parent.mkdir(parents=True, exist_ok=True) + sealed_set.write_bytes(path, data) + + +def _seals_derivative(source: Path) -> bool: + """A sealed source, or strict mode, makes a sealed derivative.""" + from openadapt_flow import at_rest + + return artifacts.is_sealed(source) or at_rest.strict() + + +def _derivative_key(destination: Path) -> str: + passphrase = sealed_set.passphrase_for("sanitized") + if not passphrase: + raise SanitizationError( + sealed_set.missing_key_message(destination, "sanitized") + ) + return passphrase + + def _sha256_file(path: Path) -> str: + if artifacts.is_sealed(path): + return _sha256_bytes(_read(path)) digest = hashlib.sha256() with path.open("rb") as fh: for chunk in iter(lambda: fh.read(1024 * 1024), b""): @@ -111,6 +161,13 @@ def _scrubber_version() -> str: def _source_files(src: Path) -> list[Path]: if not src.is_dir(): raise SanitizationError(f"Artifact is not a directory: {src}") + if sealed_set.is_sealed(src): + # Logical names from the authenticated index; content decrypts later. + base = Path(os.path.abspath(src)) + sealed_files = [src / p.relative_to(base) for p in artifacts.list_files(base)] + if not sealed_files: + raise SanitizationError(f"Artifact has no files: {src}") + return sealed_files files: list[Path] = [] for path in sorted(src.rglob("*")): if path.is_symlink(): @@ -548,7 +605,7 @@ def _scrub_text_file( known_paths: set[str], ) -> tuple[bytes, list[dict[str, Any]]]: try: - raw = source.read_text(encoding="utf-8") + raw = _read_text(source) except UnicodeDecodeError as exc: raise SanitizationError(f"Text artifact is not valid UTF-8: {rel}") from exc if "\x00" in raw: @@ -617,8 +674,11 @@ def _validate_structured_text(suffix: str, text: str, rel: str) -> None: def _forbidden_binary_type(path: Path) -> Optional[str]: - with path.open("rb") as fh: - head = fh.read(512) + if artifacts.is_sealed(path): + head = _read(path)[:512] + else: + with path.open("rb") as fh: + head = fh.read(512) signatures = ( (b"SQLite format 3\x00", "SQLite database"), (b"PK\x03\x04", "ZIP/archive"), @@ -648,7 +708,7 @@ def _scrub_image_file( *, preserve_stable_bytes: bool = False, ) -> tuple[bytes, list[dict[str, Any]]]: - original = source.read_bytes() + original = _read(source) first = privacy.scrub_image_bytes(original, force=True) first, applied = _redact_image_rectangles(first, redactions, rel) second = privacy.scrub_image_bytes(first, force=True) @@ -706,16 +766,25 @@ def _image_metadata_free(data: bytes) -> bool: def _write_manifest(dest: Path, manifest: dict[str, Any]) -> None: - (dest / MANIFEST_NAME).write_text( - json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8" + _write( + dest / MANIFEST_NAME, + (json.dumps(manifest, indent=2, sort_keys=True) + "\n").encode("utf-8"), ) +def _derivative_files(dest: Path) -> list[Path]: + """Every file of a derivative, sorted; logical names when it is sealed.""" + if artifacts.is_sealed(dest): + base = Path(os.path.abspath(dest)) + return sorted(dest / p.relative_to(base) for p in artifacts.list_files(base)) + return sorted(path for path in dest.rglob("*") if path.is_file()) + + def _tree_rows(dest: Path) -> list[tuple[str, str]]: return [ (str(path.relative_to(dest)), _sha256_file(path)) - for path in sorted(dest.rglob("*")) - if path.is_file() and path.name not in _SYSTEM_FILES + for path in _derivative_files(dest) + if path.name not in _SYSTEM_FILES ] @@ -735,8 +804,8 @@ def _reseal_sanitized_bundle(bundle: Path) -> None: provenance.expires_at = None workflow.save(bundle) workflow_py = bundle / "workflow.py" - if workflow_py.is_file(): - workflow_py.write_text(render_workflow_py(workflow), encoding="utf-8") + if artifacts.is_file(workflow_py): + _write(workflow_py, render_workflow_py(workflow).encode("utf-8")) _RECORDING_FRAME_PATH = re.compile( @@ -781,13 +850,13 @@ def sanitize_artifact( if source == destination or source in destination.parents: raise SanitizationError("Destination must not be the source or inside it") files = _source_files(source) - if kind == "bundle" and (source / "workflow.py").is_file(): + if kind == "bundle" and artifacts.is_file(source / "workflow.py"): try: from openadapt_flow.compiler.codegen import render_workflow_py from openadapt_flow.ir import Workflow expected = render_workflow_py(Workflow.load(source)) - actual = (source / "workflow.py").read_text(encoding="utf-8") + actual = _read_text(source / "workflow.py") except Exception as exc: # noqa: BLE001 - normalize bundle validation raise SanitizationError( f"Cannot validate generated workflow.py: {exc}" @@ -797,7 +866,7 @@ def sanitize_artifact( "workflow.py is not the deterministic rendering of workflow.json" ) canonical_bundle = kind == "bundle" and all( - (source / name).is_file() + artifacts.is_file(source / name) for name in ("workflow.json", "workflow.py", "manifest.json") ) scrubber = privacy.get_scrubber() @@ -811,8 +880,26 @@ def sanitize_artifact( raise SanitizationError(f"Destination already exists: {destination}") shutil.rmtree(destination) redactions = _load_redactions(redactions_file) - staging = Path(tempfile.mkdtemp(prefix="openadapt-sanitize-")) / "artifact" - staging.mkdir(parents=True) + sealed = _seals_derivative(source) + writer: Optional[sealed_set.SealedSetWriter] = None + if sealed: + # A sealed derivative is staged beside its destination as a sealed + # set (0700), so only ciphertext reaches the disk, then renamed. + destination.parent.mkdir(parents=True, exist_ok=True) + staging_root = Path( + tempfile.mkdtemp( + prefix=f".{destination.name}.", dir=str(destination.parent) + ) + ) + staging = staging_root / "artifact" + writer = sealed_set.SealedSetWriter( + staging, "sanitized", _derivative_key(destination) + ) + sealed_set.register_writer(writer) + else: + staging_root = Path(tempfile.mkdtemp(prefix="openadapt-sanitize-")) + staging = staging_root / "artifact" + staging.mkdir(parents=True) entries: list[dict[str, Any]] = [] semantics = "preserved" try: @@ -852,7 +939,7 @@ def sanitize_artifact( # Both files are deterministic derivatives of workflow.json. # Preserve them byte-for-byte when workflow data is unchanged; # a load-bearing transform reseals/regenerates them below. - output, manual = path.read_bytes(), [] + output, manual = _read(path), [] handler = "utf8-text" elif suffix in _TEXT_SUFFIXES: output, manual = _scrub_text_file( @@ -879,9 +966,7 @@ def sanitize_artifact( "archive, encrypted, executable, and unknown files require a " "deployment-specific sanitizer and remain local." ) - target = staging / safe_rel - target.parent.mkdir(parents=True, exist_ok=True) - target.write_bytes(output) + _write(staging / safe_rel, output) source_hash = _sha256_file(path) derivative_hash = _sha256_bytes(output) changed = renamed or source_hash != derivative_hash @@ -946,14 +1031,19 @@ def sanitize_artifact( ], } _write_manifest(staging, manifest) - destination.parent.mkdir(parents=True, exist_ok=True) - shutil.move(str(staging), str(destination)) + if writer is not None: + writer.finalize() + sealed_set.unregister_writer(writer) + writer = None + os.rename(staging, destination) + else: + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(staging), str(destination)) return manifest - except Exception: - shutil.rmtree(staging.parent, ignore_errors=True) - raise finally: - shutil.rmtree(staging.parent, ignore_errors=True) + if writer is not None: + sealed_set.unregister_writer(writer) + shutil.rmtree(staging_root, ignore_errors=True) def load_and_verify_derivative(destination: Path) -> dict[str, Any]: @@ -965,10 +1055,10 @@ def load_and_verify_derivative(destination: Path) -> dict[str, Any]: f"Derivative contains an unreviewed symlink: {path.relative_to(destination)}" ) manifest_path = destination / MANIFEST_NAME - if not manifest_path.is_file(): + if not artifacts.is_file(manifest_path): raise SanitizationError(f"No {MANIFEST_NAME} in {destination}") try: - manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest = json.loads(_read_text(manifest_path)) except json.JSONDecodeError as exc: raise SanitizationError(f"Invalid sanitization manifest: {exc}") from exc if manifest.get("schema_version") != SCHEMA_VERSION: @@ -1006,7 +1096,7 @@ def _rescan_derivative(destination: Path) -> dict[str, Any]: for entry in manifest["files"]: path = destination / entry["path"] if entry["handler"] == "utf8-text": - current_text = path.read_text(encoding="utf-8") + current_text = _read_text(path) if manifest["kind"] == "bundle" and entry["path"] in { "workflow.py", "manifest.json", @@ -1051,7 +1141,7 @@ def _rescan_derivative(destination: Path) -> dict[str, Any]: f"Approval rescan found a remaining identifier in {entry['path']}" ) elif entry["handler"] == "image-ocr-redaction": - current_bytes = path.read_bytes() + current_bytes = _read(path) rescanned_bytes = privacy.scrub_image_bytes(current_bytes, force=True) if not _image_pixels_equal(rescanned_bytes, current_bytes): raise SanitizationError( @@ -1069,15 +1159,35 @@ def approved_archive_path(destination: Path) -> Path: return destination.parent / f"{destination.name}.approved.zip" -def _write_deterministic_archive(destination: Path) -> Path: - """Freeze the reviewed bytes with stable names, order, metadata, and time.""" +def read_approved_archive(destination: Path) -> bytes: + """The approved archive's bytes, decrypted in memory for a sealed + derivative. These are the bytes the approval hashes and push uploads.""" + destination = Path(destination) + raw = approved_archive_path(destination).read_bytes() + if not sealed_set.is_sealed(destination): + return raw + if not crypto.is_encrypted(raw): + raise SanitizationError( + "The approved archive of an encrypted derivative is not encrypted" + ) + try: + return crypto.decrypt_bytes(raw, _derivative_key(destination), aad=_ARCHIVE_AAD) + except crypto.CryptoError as exc: + raise SanitizationError(f"Approved archive cannot be opened: {exc}") from exc + + +def _write_deterministic_archive(destination: Path) -> bytes: + """Freeze the reviewed bytes with stable names, order, metadata, and time. + + The zip is built in memory. A sealed derivative's archive is encrypted + before it is written; the approval hashes the zip bytes either way.""" archive = approved_archive_path(destination) - tmp = archive.with_suffix(".zip.tmp") + buffer = BytesIO() with zipfile.ZipFile( - tmp, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9 + buffer, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9 ) as zf: - for path in sorted(destination.rglob("*")): - if not path.is_file() or path.name == APPROVAL_NAME: + for path in _derivative_files(destination): + if path.name == APPROVAL_NAME: continue rel = path.relative_to(destination).as_posix() info = zipfile.ZipInfo(rel, date_time=(1980, 1, 1, 0, 0, 0)) @@ -1086,12 +1196,20 @@ def _write_deterministic_archive(destination: Path) -> Path: info.create_system = 3 zf.writestr( info, - path.read_bytes(), + _read(path), compress_type=zipfile.ZIP_DEFLATED, compresslevel=9, ) + data = buffer.getvalue() + stored = data + if sealed_set.is_sealed(destination): + stored = crypto.encrypt_bytes( + data, _derivative_key(destination), aad=_ARCHIVE_AAD + ) + tmp = archive.with_suffix(".zip.tmp") + tmp.write_bytes(stored) os.replace(tmp, archive) - return archive + return data def approve_derivative( @@ -1131,8 +1249,8 @@ def approve_derivative( } manifest["approval_verification"] = existing_verification _write_manifest(destination, manifest) - archive = _write_deterministic_archive(destination) - archive_hash = _sha256_file(archive) + archive_bytes = _write_deterministic_archive(destination) + archive_hash = _sha256_bytes(archive_bytes) approval = { "schema_version": SCHEMA_VERSION, "approved_at": _utc_now(), @@ -1142,7 +1260,7 @@ def approve_derivative( "manifest_sha256": _manifest_hash(manifest), "derivative_tree_sha256": manifest["derivative_tree_sha256"], "approved_derivative_sha256": archive_hash, - "approved_archive_size_bytes": archive.stat().st_size, + "approved_archive_size_bytes": len(archive_bytes), "verification": { "verified_at": existing_verification["verified_at"], "method": "full-stable-rescan", @@ -1151,8 +1269,9 @@ def approve_derivative( "unresolved": 0, }, } - approval_path(destination).write_text( - json.dumps(approval, indent=2, sort_keys=True) + "\n", encoding="utf-8" + _write( + approval_path(destination), + (json.dumps(approval, indent=2, sort_keys=True) + "\n").encode("utf-8"), ) return approval @@ -1161,7 +1280,7 @@ def load_valid_approval(destination: Path) -> dict[str, Any]: destination = Path(destination) manifest = load_and_verify_derivative(destination) path = approval_path(destination) - if not path.is_file(): + if not artifacts.is_file(path): archive = approved_archive_path(destination) if archive.is_file(): # The approval binds the archive's SHA-256, so `approve-sanitized` @@ -1180,7 +1299,7 @@ def load_valid_approval(destination: Path) -> dict[str, Any]: "locally, then approve it." ) try: - approval = json.loads(path.read_text(encoding="utf-8")) + approval = json.loads(_read_text(path)) except json.JSONDecodeError as exc: raise SanitizationError(f"Invalid approval: {exc}") from exc if approval.get("manifest_sha256") != _manifest_hash(manifest): @@ -1190,9 +1309,10 @@ def load_valid_approval(destination: Path) -> dict[str, Any]: archive = approved_archive_path(destination) if not archive.is_file(): raise SanitizationError("Approved immutable archive is missing") - if approval.get("approved_derivative_sha256") != _sha256_file(archive): + archive_bytes = read_approved_archive(destination) + if approval.get("approved_derivative_sha256") != _sha256_bytes(archive_bytes): raise SanitizationError("Approved immutable archive changed") - if approval.get("approved_archive_size_bytes") != archive.stat().st_size: + if approval.get("approved_archive_size_bytes") != len(archive_bytes): raise SanitizationError("Approved immutable archive size changed") return approval @@ -1541,7 +1661,7 @@ def build_ingest_manifest(destination: Path) -> dict[str, Any]: def _update_entry(destination: Path, rel: str, redaction: dict[str, Any]) -> None: manifest_path = destination / MANIFEST_NAME try: - manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest = json.loads(_read_text(manifest_path)) except (OSError, json.JSONDecodeError) as exc: raise SanitizationError(f"Cannot update sanitization manifest: {exc}") from exc entry = next((item for item in manifest["files"] if item["path"] == rel), None) @@ -1561,7 +1681,7 @@ def _update_entry(destination: Path, rel: str, redaction: dict[str, Any]) -> Non manifest["runtime_semantics_validated"] = False manifest["trusted_boundary_required_at_runtime"] = True manifest.pop("approval_verification", None) - approval_path(destination).unlink(missing_ok=True) + sealed_set.unlink(approval_path(destination), missing_ok=True) approved_archive_path(destination).unlink(missing_ok=True) _write_manifest(destination, manifest) @@ -1583,7 +1703,7 @@ def _authorized_review_path( if entry is None: raise SanitizationError(f"File is not an authorized {handler} artifact: {rel}") path = (destination / entry["path"]).resolve() - if destination not in path.parents or not path.is_file(): + if destination not in path.parents or not artifacts.is_file(path): raise SanitizationError(f"Review path escapes the derivative: {rel}") return path, entry @@ -1595,11 +1715,11 @@ def add_manual_text_redaction( raise SanitizationError("Manual text redaction cannot be empty") destination = Path(destination) path, _ = _authorized_review_path(destination, rel, handler="utf8-text") - text = path.read_text(encoding="utf-8") + text = _read_text(path) count = text.count(literal) if count == 0: raise SanitizationError(f"Manual redaction text was not found in {rel}") - path.write_text(text.replace(literal, replacement), encoding="utf-8") + _write(path, text.replace(literal, replacement).encode("utf-8")) _update_entry( destination, rel, @@ -1619,11 +1739,11 @@ def add_manual_image_redaction( destination = Path(destination) path, _ = _authorized_review_path(destination, rel, handler="image-ocr-redaction") output, applied = _redact_image_rectangles( - path.read_bytes(), + _read(path), [{"file": rel, "x": x, "y": y, "width": width, "height": height}], rel, ) - path.write_bytes(output) + _write(path, output) redaction = dict(applied[0]) redaction["reviewer_added"] = True _update_entry(destination, rel, redaction) @@ -1648,8 +1768,8 @@ def render_review_html(source: Path, destination: Path, csrf_token: str) -> str: sanitized = destination / entry["path"] rel = html.escape(entry["path"]) if entry["handler"] == "utf8-text": - before = html.escape(original.read_text(encoding="utf-8")[:200_000]) - after = html.escape(sanitized.read_text(encoding="utf-8")[:200_000]) + before = html.escape(_read_text(original)[:200_000]) + after = html.escape(_read_text(sanitized)[:200_000]) preview = f"
{before}
{after}
" form = f"""
@@ -1661,8 +1781,8 @@ def render_review_html(source: Path, destination: Path, csrf_token: str) -> str:
""" else: mime = mimetypes.guess_type(sanitized.name)[0] or "image/png" - before = base64.b64encode(original.read_bytes()).decode("ascii") - after = base64.b64encode(sanitized.read_bytes()).decode("ascii") + before = base64.b64encode(_read(original)).decode("ascii") + after = base64.b64encode(_read(sanitized)).decode("ascii") preview = ( "
" f"original {rel}" diff --git a/packages/flow/openadapt_flow/sealed_set.py b/packages/flow/openadapt_flow/sealed_set.py index dc3734fc22..2646ab717a 100644 --- a/packages/flow/openadapt_flow/sealed_set.py +++ b/packages/flow/openadapt_flow/sealed_set.py @@ -23,8 +23,9 @@ 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 +Every file kind is classified in :data:`BUNDLE_FILES`, +:data:`RECORDING_FILES`, :data:`RUN_FILES`, :data:`LIBRARY_FILES` and +:data:`SANITIZED_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. @@ -63,7 +64,7 @@ FORMAT_VERSION: Final[int] = 1 SEALED_SUFFIX: Final[str] = ".enc" -Kind = Literal["bundle", "recording", "run"] +Kind = Literal["bundle", "recording", "run", "library", "sanitized"] Codec = Literal["set", "plain", "external"] Classification = Literal["phi", "structural"] @@ -264,12 +265,44 @@ class FileKind: ) +#: A skill library written by ``teach`` (:mod:`openadapt_flow.learning.library`). +LIBRARY_FILES: Final[tuple[FileKind, ...]] = ( + FileKind( + "skills.json", + "phi", + "every learned program version (target labels, typed literals, screen " + "text, crop paths) and the execution traces it was learned from", + ), +) + +#: The sanitized review copy ``push`` makes of a sealed source +#: (:mod:`openadapt_flow.sanitized_artifact`). Scrubbing is best effort until a +#: person approves it, so every file stays encrypted. +SANITIZED_FILES: Final[tuple[FileKind, ...]] = ( + FileKind( + ".openadapt-sanitization.json", + "phi", + "the redaction manifest: file names, hashes and redaction records", + ), + FileKind(".openadapt-approval.json", "phi", "the reviewer's approval"), + FileKind( + "*", + "phi", + "the scrubbed copy of each recording or bundle file, before review", + ), +) + +_INVENTORIES: Final[dict[str, tuple[FileKind, ...]]] = { + "bundle": BUNDLE_FILES, + "recording": RECORDING_FILES, + "run": RUN_FILES, + "library": LIBRARY_FILES, + "sanitized": SANITIZED_FILES, +} + + def inventory(kind: Kind) -> tuple[FileKind, ...]: - if kind == "bundle": - return BUNDLE_FILES - if kind == "recording": - return RECORDING_FILES - return RUN_FILES + return _INVENTORIES[kind] def classify(kind: Kind, rel: str) -> Optional[FileKind]: @@ -415,7 +448,7 @@ def read_header(root: Path | str) -> dict[str, object]: raise SealedSetError( f"sealed-set format version {header.get('version')!r} is not supported" ) - if header.get("kind") not in ("bundle", "recording", "run"): + if header.get("kind") not in _INVENTORIES: raise SealedSetError("sealed-set header has no valid kind") return header @@ -1098,8 +1131,10 @@ def require_readable(path: Path | str, what: str) -> None: __all__ = [ "BUNDLE_FILES", "HEADER", + "LIBRARY_FILES", "RECORDING_ENV_KEY", "RECORDING_FILES", + "SANITIZED_FILES", "SealedSet", "SealedSetError", "SealedSetKeyError", diff --git a/packages/flow/tests/test_artifact_reads.py b/packages/flow/tests/test_artifact_reads.py index f9f4848028..50cf0720f6 100644 --- a/packages/flow/tests/test_artifact_reads.py +++ b/packages/flow/tests/test_artifact_reads.py @@ -79,6 +79,43 @@ "_cmd_route", ) CONVERTED["composition.py"] += ("load",) +# teach: the halted run, the fix recording, the base bundle, the library. +CONVERTED["__main__.py"] += ("_cmd_teach",) +CONVERTED["learning/teach.py"] = ( + "load_halt_report", + "load_base_program", + "_texts_seen_before_halt", + "_spec_from_recording", + "_real_target_canary", + "_copy_templates", + "_copy_carried", + "teach", +) +CONVERTED["learning/library.py"] = ("_load",) +# push, and the review and approval of its sanitized copy. +CONVERTED["hosted.py"] = ("push", "_is_bundle_dir") +CONVERTED["sanitized_artifact.py"] = ( + "_source_files", + "_scrub_text_file", + "_scrub_image_file", + "_tree_rows", + "_reseal_sanitized_bundle", + "sanitize_artifact", + "load_and_verify_derivative", + "_rescan_derivative", + "_write_deterministic_archive", + "approve_derivative", + "load_valid_approval", + "_update_entry", + "_authorized_review_path", + "add_manual_text_redaction", + "add_manual_image_redaction", + "render_review_html", +) +CONVERTED["repair/registration.py"] = ( + "_evidence_from_run_dir", + "load_detached_candidate", +) CONVERTED["admitted_composition.py"] += ( "load", "load_child_envelope", @@ -128,6 +165,22 @@ "first_screen_probe", "Path(explicit).read_bytes()", ): "the --first-screen PNG a person passes in", + ("hosted.py", "push", "derivative.exists()"): "the review copy's directory", + ( + "sanitized_artifact.py", + "_source_files", + "path.is_file()", + ): "walks a plain source; a sealed one is listed from its index above", + ( + "sanitized_artifact.py", + "sanitize_artifact", + "destination.exists()", + ): "the new derivative directory, not an artifact", + ( + "sanitized_artifact.py", + "load_valid_approval", + "archive.is_file()", + ): "the archive file beside the derivative, read by read_approved_archive", } @@ -445,3 +498,233 @@ def test_name_path_arguments_go_through_the_missing_key_check( err = capsys.readouterr().err assert "OPENADAPT_BUNDLE_KEY" in err and "Nothing was read" in err assert not (tmp_path / "composed").exists() + + +# -- teach on sealed input ---------------------------------------------------- + + +def _seal_dir(plain: Path, root: Path, kind: str) -> Path: + """Copy a plaintext recording or run directory into a sealed set.""" + with sealed_set.writing(root, kind, KEY): # type: ignore[arg-type] + for path in sorted(plain.rglob("*")): + if path.is_file(): + rel = path.relative_to(plain).as_posix() + sealed_set.write_bytes(root / rel, path.read_bytes()) + return root + + +def _teach_inputs(tmp_path: Path) -> tuple[Path, Path, Path]: + """A halted run, a fix recording and the bundle that halted, all sealed.""" + from tests.test_teach_cli import _base_bundle, _dismiss_recording, _halt_run + + plain = tmp_path / "plain" + plain.mkdir() + base = _base_bundle(plain) + _halt_run(base, plain / "run") + _dismiss_recording(plain / "fix") + bundle = tmp_path / "bundle" + assert main(["seal", str(base), "--out", str(bundle)]) == 0 + run = _seal_dir(plain / "run", tmp_path / "run", "run") + fix = _seal_dir(plain / "fix", tmp_path / "fix", "recording") + return run, fix, bundle + + +def _assert_taught_sealed(out: Path, fix: Path) -> None: + for root in (out, out.parent / f"{out.name}.skills", fix / "_teach_compiled"): + assert sealed_set.is_sealed(root), root + _assert_ciphertext_only(root) + assert sealed_set.header_kind(out.parent / f"{out.name}.skills") == "library" + if sealed_set.is_sealed(fix): + # The fix recording still opens: its compiled copy is a separate set. + sealed_set.SealedSet.open(fix, KEY).verify_all() + assert sealed_set.is_file(out / "repair" / "candidate.json") + learned = Workflow.load(out, key=KEY) + clicks = [ + state.step.anchor.click_point + for state in learned.program.states.values() # type: ignore[union-attr] + if state.step is not None and state.step.anchor is not None + ] + assert (140, 108) in clicks # the recorded dismiss click was spliced in + + +def test_teach_reads_sealed_inputs_and_writes_sealed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys +) -> None: + from openadapt_flow.learning.library import SkillLibrary + + _ready("openadapt_flow.__main__:_cmd_teach") + monkeypatch.setenv(crypto.ENV_KEY, KEY) + run, fix, bundle = _teach_inputs(tmp_path) + before = {p: _files(p) for p in (run, bundle)} + out = tmp_path / "taught" + args = ["teach", str(run), "--fix", str(fix), "--bundle", str(bundle)] + assert main([*args, "--out", str(out)]) == 0 + _assert_taught_sealed(out, fix) + assert {p: _files(p) for p in (run, bundle)} == before + library = SkillLibrary(tmp_path / "taught.skills") + assert library.sealed and library.skill_ids() + # A second teach reads the sealed library back: the fix is learned already. + capsys.readouterr() + assert main([*args, "--out", str(out)]) == 1 + assert "no_change" in capsys.readouterr().out + _assert_taught_sealed(out, fix) + + +def test_teach_in_strict_mode_seals_plain_inputs_outputs( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from tests.test_teach_cli import _base_bundle, _dismiss_recording, _halt_run + + monkeypatch.setenv(crypto.ENV_KEY, KEY) + base = _base_bundle(tmp_path) + _halt_run(base, tmp_path / "run") + fix = _dismiss_recording(tmp_path / "fix") + monkeypatch.setenv("OPENADAPT_FLOW_AT_REST", "strict") + out = tmp_path / "taught" + args = ["teach", str(tmp_path / "run"), "--fix", str(fix)] + assert main([*args, "--bundle", str(base), "--out", str(out)]) == 0 + _assert_taught_sealed(out, fix) + + +def test_teach_refuses_a_plaintext_library_for_sealed_input( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys +) -> None: + monkeypatch.setenv(crypto.ENV_KEY, KEY) + run, fix, bundle = _teach_inputs(tmp_path) + library = tmp_path / "plain-library" + library.mkdir() + (library / "skills.json").write_text('{"skills": {}}') + args = ["teach", str(run), "--fix", str(fix), "--bundle", str(bundle)] + out = tmp_path / "taught" + assert main([*args, "--out", str(out), "--library", str(library)]) == 2 + assert "unencrypted skill library" in capsys.readouterr().out + assert not out.exists() + + +# -- push of a sealed source -------------------------------------------------- + + +@pytest.fixture +def push_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + import base64 + + from openadapt_flow import hosted, privacy + from tests.test_hosted import _FakeScrubber + + monkeypatch.setenv(crypto.ENV_KEY, KEY) + monkeypatch.setenv("OPENADAPT_HOME", str(tmp_path / "home")) + monkeypatch.delenv(hosted.TOKEN_ENV, raising=False) + monkeypatch.setenv(hosted.DESTINATION_KIND_ENV, "customer-managed") + monkeypatch.setenv(hosted.TRUSTED_HOSTS_ENV, "https://h.test") + monkeypatch.setenv("OPENADAPT_SANITIZATION_POLICY_KEY_ID", "test-policy-key") + monkeypatch.setenv( + "OPENADAPT_SANITIZATION_POLICY_KEY", + base64.b64encode(b"test-policy-secret-material-32-bytes!!").decode(), + ) + monkeypatch.setattr(hosted, "_keyring_token", lambda host: None) + privacy.set_text_scrubber(_FakeScrubber()) + yield + privacy.reset_scrubbers() + + +def _sealed_phi_recording(root: Path) -> Path: + with sealed_set.writing(root, "recording", KEY): + sealed_set.write_text( + root / "events.jsonl", + json.dumps({"kind": "type", "text": "Jane Doe 12345"}) + "\n", + ) + sealed_set.write_text(root / "meta.json", json.dumps({"task": "Jane Doe"})) + return root + + +def _uploaded(monkeypatch: pytest.MonkeyPatch) -> dict[str, bytes]: + import httpx + + from tests.test_hosted import _accepted_recording_post + + sent: dict[str, bytes] = {} + accept = _accepted_recording_post() + + def post(url, **kw): + sent["body"] = kw["files"]["file"][1].read() + return accept(url, **kw) + + monkeypatch.setattr(httpx, "post", post) + return sent + + +def _no_plaintext_under(root: Path) -> None: + for path in root.rglob("*"): + if path.is_file(): + data = path.read_bytes() + assert b"Jane Doe" not in data and b"" not in data, path + + +def test_push_of_a_sealed_recording_keeps_the_review_copy_sealed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, push_env, capsys +) -> None: + import io + import zipfile + + from openadapt_flow import hosted + from openadapt_flow.sanitized_artifact import ( + add_manual_text_redaction, + approved_archive_path, + render_review_html, + ) + + for name in ("push", "review_sanitized", "approve_sanitized"): + _ready(f"openadapt_flow.__main__:_cmd_{name}") + monkeypatch.setenv(hosted.AUTO_APPROVE_ENV, "false") + rec = _sealed_phi_recording(tmp_path / "rec") + before = _files(rec) + push = ["push", str(rec), "--host", "https://h.test", "--token", "tok"] + assert main(push) == 0 + out = capsys.readouterr().out + derivative = Path(out.split("Sanitized derivative: ")[1].splitlines()[0]) + assert sealed_set.header_kind(derivative) == "sanitized" + _assert_ciphertext_only(derivative) + # Review decrypts in memory; a reviewer's redaction stays encrypted. + page = render_review_html(rec, derivative, "csrf") + assert "Jane Doe" in page and "<PERSON>" in page + add_manual_text_redaction(derivative, "meta.json", "task", "field") + _assert_ciphertext_only(derivative) + approve = ["approve-sanitized", str(derivative), "--original", str(rec)] + assert main([*approve, "--reviewer", "reviewer@example.test"]) == 0 + assert crypto.is_encrypted(approved_archive_path(derivative).read_bytes()) + sent = _uploaded(monkeypatch) + upload = ["push", str(derivative), "--host", "https://h.test", "--token", "tok"] + assert main(upload) == 0 + with zipfile.ZipFile(io.BytesIO(sent["body"])) as zf: + uploaded = {name: zf.read(name) for name in zf.namelist()} + assert b"" in uploaded["events.jsonl"] + assert all(b"Jane Doe" not in data for data in uploaded.values()) + assert b"field" in uploaded["meta.json"] + assert _files(rec) == before + _no_plaintext_under(tmp_path) + + +def test_push_in_strict_mode_seals_the_review_copy_of_a_plain_recording( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, push_env +) -> None: + from openadapt_flow import hosted + + monkeypatch.setenv(hosted.AUTO_APPROVE_ENV, "true") + monkeypatch.setenv("OPENADAPT_FLOW_AT_REST", "strict") + rec = tmp_path / "rec" + rec.mkdir() + (rec / "events.jsonl").write_text(json.dumps({"text": "Jane Doe"}) + "\n") + (rec / "meta.json").write_text("{}") + sent = _uploaded(monkeypatch) + out = tmp_path / "derivative" + push = ["push", str(rec), "--host", "https://h.test", "--token", "tok"] + assert main([*push, "--sanitized-out", str(out)]) == 0 + assert sealed_set.header_kind(out) == "sanitized" + _assert_ciphertext_only(out) + import io + import zipfile + + with zipfile.ZipFile(io.BytesIO(sent["body"])) as zf: + assert b"" in zf.read("events.jsonl") + (rec / "events.jsonl").unlink() + _no_plaintext_under(tmp_path) diff --git a/packages/flow/tests/test_sealed_at_rest.py b/packages/flow/tests/test_sealed_at_rest.py index d6cc063835..c2b97b20f1 100644 --- a/packages/flow/tests/test_sealed_at_rest.py +++ b/packages/flow/tests/test_sealed_at_rest.py @@ -749,5 +749,10 @@ def test_sealed_run_pauses_and_resumes_without_plaintext( 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): + for entry in ( + *sealed_set.BUNDLE_FILES, + *sealed_set.RECORDING_FILES, + *sealed_set.LIBRARY_FILES, + *sealed_set.SANITIZED_FILES, + ): assert f"`{entry.pattern}`" in doc, entry.pattern From 11750831bfa90cbf091feabb32f5e8e6274b8be6 Mon Sep 17 00:00:00 2001 From: abrichr Date: Tue, 3 Nov 2026 18:47:21 -0500 Subject: [PATCH 12/21] fix: the encrypted flag holds only with an encrypted envelope on disk --- packages/flow/openadapt_flow/ir.py | 7 +++++++ packages/flow/tests/test_artifact_reads.py | 17 +++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/packages/flow/openadapt_flow/ir.py b/packages/flow/openadapt_flow/ir.py index fd235efbeb..8bc3907d61 100644 --- a/packages/flow/openadapt_flow/ir.py +++ b/packages/flow/openadapt_flow/ir.py @@ -3603,6 +3603,13 @@ def load( report = _bv.validate_workflow(wf) report.raise_if(categories=("structure",)) + if not bundle_encrypted and wf.encrypted: + # The flag is authoritative only with an encrypted envelope on + # disk: a plaintext copy (for example a decrypted upload) that + # still says encrypted is plaintext, and loads as plaintext. + wf.encrypted = False + if wf.manifest is not None: + wf.manifest = wf.manifest.model_copy(update={"encrypted": False}) return wf def stamp_certification( diff --git a/packages/flow/tests/test_artifact_reads.py b/packages/flow/tests/test_artifact_reads.py index 50cf0720f6..4eee080737 100644 --- a/packages/flow/tests/test_artifact_reads.py +++ b/packages/flow/tests/test_artifact_reads.py @@ -728,3 +728,20 @@ def test_push_in_strict_mode_seals_the_review_copy_of_a_plain_recording( assert b"" in zf.read("events.jsonl") (rec / "events.jsonl").unlink() _no_plaintext_under(tmp_path) + + +def test_a_plaintext_copy_of_a_sealed_bundle_never_loads_as_encrypted( + sealed: Path, tmp_path: Path +) -> None: + """A decrypted copy (an upload, an export) keeps encrypted: true in its + JSON, but the flag holds only with an encrypted envelope on disk.""" + opened = sealed_set.open_set(sealed, KEY) + copy = tmp_path / "plain-copy" + for rel in opened.files(""): + target = copy / rel + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(opened.read_bytes(rel)) + assert json.loads((copy / "workflow.json").read_text())["encrypted"] is True + loaded = Workflow.load(copy) + assert loaded.encrypted is False + assert loaded.manifest is not None and loaded.manifest.encrypted is False From 36e72721dd160b77b557994b1d90120b09ea5a6a Mon Sep 17 00:00:00 2001 From: abrichr Date: Tue, 3 Nov 2026 19:24:01 -0500 Subject: [PATCH 13/21] feat: encrypt attended state and journal snapshots at rest by default --- .../flow/openadapt_flow/execution_profiles.py | 45 ++- .../runtime/durable/attended.py | 156 +++++++--- .../runtime/durable/attended_state.py | 271 ++++++++++++++++++ .../runtime/durable/authority.py | 46 ++- .../runtime/durable/checkpoint.py | 31 +- .../openadapt_flow/runtime/durable/resume.py | 8 +- .../flow/openadapt_flow/runtime/replayer.py | 30 +- .../scripts/export_public_demo_evidence.py | 4 + packages/flow/tests/test_attended_at_rest.py | 271 ++++++++++++++++++ 9 files changed, 789 insertions(+), 73 deletions(-) create mode 100644 packages/flow/openadapt_flow/runtime/durable/attended_state.py create mode 100644 packages/flow/tests/test_attended_at_rest.py diff --git a/packages/flow/openadapt_flow/execution_profiles.py b/packages/flow/openadapt_flow/execution_profiles.py index 0ebfe37c9e..e05bfec663 100644 --- a/packages/flow/openadapt_flow/execution_profiles.py +++ b/packages/flow/openadapt_flow/execution_profiles.py @@ -652,7 +652,11 @@ def _matching_evidence_group( raise ValueError("transition evidence decision rows disagree") return group - def _verified_inventory_bytes(ref: str, expected_sha256: str) -> bytes: + def _verified_inventory_bytes( + ref: str, expected_sha256: str, *, attended: bool = False + ) -> bytes: + if attended: + return _verified_attended_bytes(ref, expected_sha256) if transition_evidence_root is None: raise ValueError("visual transition evidence has no local evidence root") root = transition_evidence_root.resolve() @@ -683,6 +687,43 @@ def _verified_inventory_bytes(ref: str, expected_sha256: str) -> bytes: raise ValueError("transition evidence digest does not match") return payload + def _verified_attended_bytes(ref: str, expected_sha256: str) -> bytes: + """Plaintext of an attended receipt or control file, which may be + encrypted at rest as ``.enc`` (``attended_state``).""" + from openadapt_flow import crypto as _crypto + from openadapt_flow.runtime.durable import attended_state as _state + + if transition_evidence_root is None: + raise ValueError("visual transition evidence has no local evidence root") + root = transition_evidence_root.resolve() + encrypted = root.joinpath(*PurePosixPath(ref + _state.ENC_SUFFIX).parts) + if not (encrypted.exists() or encrypted.is_symlink()): + if (root / _state.MARKER_FILENAME).exists(): + raise ValueError("attended evidence is plaintext where encrypted") + return _verified_inventory_bytes(ref, expected_sha256) + parts = PurePosixPath(ref + _state.ENC_SUFFIX).parts + cursor_path = root + for part in parts: + cursor_path /= part + if cursor_path.is_symlink(): + raise ValueError("transition frame path contains a symlink") + try: + path = encrypted.resolve(strict=True) + path.relative_to(root) + container = path.read_bytes() + except (OSError, ValueError) as exc: + raise ValueError("attended transition evidence is unreadable") from exc + payload = _state.open_sealed( + container, + _crypto.resolve_key(None), + _state.FILE_AAD_PREFIX + ref.encode("utf-8"), + ) + import hashlib + + if hashlib.sha256(payload).hexdigest() != expected_sha256: + raise ValueError("transition evidence digest does not match") + return payload + def _validated_evidence_target( *, graph_id: str, @@ -835,6 +876,7 @@ def _validated_attended_target( payload = _verified_inventory_bytes( item.receipt_inventory_ref, item.receipt_sha256, + attended=True, ) from openadapt_flow.runtime.durable.attended import ( AttendedActionRefused, @@ -895,6 +937,7 @@ def _validated_attended_target( control_payload = _verified_inventory_bytes( item.control_frames_inventory_ref, item.control_frames_sha256, + attended=True, ) try: control_frames = [ diff --git a/packages/flow/openadapt_flow/runtime/durable/attended.py b/packages/flow/openadapt_flow/runtime/durable/attended.py index 7068a3f78e..c79fca5a01 100644 --- a/packages/flow/openadapt_flow/runtime/durable/attended.py +++ b/packages/flow/openadapt_flow/runtime/durable/attended.py @@ -51,6 +51,7 @@ project_step_safety, ) from openadapt_flow.runtime.authorization import RuntimeParamScalar +from openadapt_flow.runtime.durable import attended_state as _state from openadapt_flow.runtime.durable.approval import ( ApprovalRecord, ApprovalRequired, @@ -794,8 +795,14 @@ def _attended_step_safety( class AttendedActionStore: """Capability, single-flight lease, and append-only decision persistence.""" - def __init__(self, run_dir: Path | str) -> None: + def __init__(self, run_dir: Path | str, *, key: Optional[str] = None) -> None: self.run_dir = Path(run_dir) + # The at-rest key for this state: the explicit one, else the configured + # key, else the local key (crypto.resolve_key), resolved on first use. + # None under the recorded plaintext opt-out. + self._explicit_key = key + self._resolved_key: Optional[str] = None + self._key_resolved = False self.capability_path = self.run_dir / CAPABILITY_FILENAME self.capability_history_path = self.run_dir / CAPABILITY_HISTORY_FILENAME self.key_path = self.run_dir / CAPABILITY_KEY_FILENAME @@ -837,6 +844,60 @@ def _atomic_write(path: Path, payload: bytes, *, mode: int = 0o600) -> None: except FileNotFoundError: pass + @property + def key(self) -> Optional[str]: + """The at-rest key the attended state is encrypted under.""" + if not self._key_resolved: + from openadapt_flow import crypto as _crypto + + self._resolved_key = _crypto.resolve_key(self._explicit_key) + self._key_resolved = True + return self._resolved_key + + # -- attended state on disk ---------------------------------------------- + # + # A sealed run directory encrypts these files through its sealed-set + # writer (``_atomic_write``). Elsewhere each file is written as + # ``.enc`` under ``key`` (``attended_state``); older plaintext files + # still read until the run's first encrypted write converts them. + + def _state_is_file(self, path: Path) -> bool: + if _sealed.is_sealed_path(path): + return _sealed.is_file(path) + return _state.exists(path) + + def _state_read_bytes(self, path: Path) -> bytes: + if _sealed.is_sealed_path(path): + return _sealed.read_bytes(path) + # An older plaintext file needs no key; don't resolve one for it. + key = self.key if _state.enc_path(path).is_file() else None + return _state.read_bytes(self.run_dir, path, key) + + def _state_read_text(self, path: Path) -> str: + return self._state_read_bytes(path).decode("utf-8") + + def _state_write(self, path: Path, payload: bytes) -> None: + if _sealed.is_sealed_path(path): + self._atomic_write(path, payload) + return + _state.write_bytes( + self.run_dir, + path, + payload, + self.key, + lambda target, data: self._atomic_write(target, data), + ) + + def _state_unlink(self, path: Path) -> None: + if _sealed.is_sealed_path(path): + _sealed.unlink(path) + return + _state.unlink(path) + + def read_state_bytes(self, rel: str) -> bytes: + """Plaintext of one run-relative attended file, in either form.""" + return self._state_read_bytes(self.run_dir.joinpath(*Path(rel).parts)) + def _key(self, *, create: bool) -> bytes: try: key = self.key_path.read_bytes() @@ -998,21 +1059,21 @@ def _signed_capabilities(self) -> list[AttendedPauseCapability]: "the attended capability path must not be a symlink" ) capabilities: list[AttendedPauseCapability] = [] - if _sealed.is_file(self.capability_path): + if self._state_is_file(self.capability_path): try: capabilities.append( AttendedPauseCapability.model_validate_json( - _sealed.read_text(self.capability_path) + self._state_read_text(self.capability_path) ) ) except ValueError as exc: raise AttendedActionRefused( "the current attended capability is invalid" ) from exc - if _sealed.is_file(self.capability_history_path): + if self._state_is_file(self.capability_history_path): try: raw_history = json.loads( - _sealed.read_text(self.capability_history_path) + self._state_read_text(self.capability_history_path) ) if not isinstance(raw_history, list): raise ValueError("history is not a list") @@ -1214,14 +1275,14 @@ def write_reconciliation_receipt( path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) if os.name != "nt": os.chmod(path.parent, 0o700) - if _sealed.is_file(path): + if self._state_is_file(path): existing = self.read_reconciliation_receipt(sealed.pause_id) if existing != sealed: raise AttendedActionRefused( "a different reconciliation receipt already exists for this pause" ) return existing - self._atomic_write(path, sealed.model_dump_json(indent=2).encode("utf-8")) + self._state_write(path, sealed.model_dump_json(indent=2).encode("utf-8")) return sealed def read_reconciliation_receipt( @@ -1234,7 +1295,7 @@ def read_reconciliation_receipt( ) try: receipt = AttendedReconciliationReceipt.model_validate_json( - _sealed.read_text(path) + self._state_read_text(path) ) except (FileNotFoundError, ValueError) as exc: raise AttendedActionRefused( @@ -1279,7 +1340,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 _sealed.is_file(path): + if self._state_is_file(path): existing = self.read_program_receipt(sealed.pause_id) if existing != sealed: raise AttendedActionRefused( @@ -1287,7 +1348,7 @@ def write_program_receipt( "this pause" ) return existing - self._atomic_write(path, sealed.model_dump_json(indent=2).encode("utf-8")) + self._state_write(path, sealed.model_dump_json(indent=2).encode("utf-8")) return sealed def read_program_receipt(self, pause_id: str) -> ProgramTransitionReceipt: @@ -1299,7 +1360,7 @@ def read_program_receipt(self, pause_id: str) -> ProgramTransitionReceipt: ) try: receipt = ProgramTransitionReceipt.model_validate_json( - _sealed.read_text(path) + self._state_read_text(path) ) except (FileNotFoundError, ValueError) as exc: raise AttendedActionRefused( @@ -1383,7 +1444,7 @@ def issue( delivery_state, ) event_sequence = 1 - if _sealed.is_file(self.capability_path): + if self._state_is_file(self.capability_path): existing = self.read() if ( existing.pause_digest == pause_digest @@ -1407,10 +1468,10 @@ 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 _sealed.is_file(self.capability_history_path): + if self._state_is_file(self.capability_history_path): try: raw_history = json.loads( - _sealed.read_text(self.capability_history_path) + self._state_read_text(self.capability_history_path) ) if isinstance(raw_history, list): history = [ @@ -1421,12 +1482,12 @@ def issue( "the attended capability history is invalid" ) from None history.append(existing.model_dump(mode="json")) - self._atomic_write( + self._state_write( self.capability_history_path, json.dumps(history, indent=2, sort_keys=True).encode("utf-8"), ) event_sequence = existing.event_sequence + 1 - elif _sealed.is_file(self.capability_history_path): + elif self._state_is_file(self.capability_history_path): historical = self._signed_capabilities() event_sequence = max(item.event_sequence for item in historical) + 1 now = _now() @@ -1462,7 +1523,7 @@ def issue( allowed_actions=allowed_actions, ) capability.signature = self._sign(capability, create_key=False) - self._atomic_write( + self._state_write( self.capability_path, capability.model_dump_json(indent=2).encode("utf-8"), ) @@ -1476,14 +1537,14 @@ def retire_current(self) -> None: must not remain visible as the current operator action surface. """ - if not _sealed.is_file(self.capability_path): + if not self._state_is_file(self.capability_path): return existing = self.read() history: list[dict[str, Any]] = [] - if _sealed.is_file(self.capability_history_path): + if self._state_is_file(self.capability_history_path): try: raw_history = json.loads( - _sealed.read_text(self.capability_history_path) + self._state_read_text(self.capability_history_path) ) if not isinstance(raw_history, list) or not all( isinstance(item, dict) for item in raw_history @@ -1497,17 +1558,17 @@ def retire_current(self) -> None: existing_payload = existing.model_dump(mode="json") if existing_payload not in history: history.append(existing_payload) - self._atomic_write( + self._state_write( self.capability_history_path, json.dumps(history, indent=2, sort_keys=True).encode("utf-8"), ) - _sealed.unlink(self.capability_path) + self._state_unlink(self.capability_path) self._fsync_parent(self.capability_path) def read(self) -> AttendedPauseCapability: try: capability = AttendedPauseCapability.model_validate_json( - _sealed.read_text(self.capability_path) + self._state_read_text(self.capability_path) ) except (FileNotFoundError, ValueError) as exc: raise AttendedActionRefused( @@ -1595,17 +1656,17 @@ def _read_log_with_head(self) -> tuple[AttendedDecisionLog, str]: CheckpointStore(self.run_dir), ) snapshot, head = authority.read_attended_snapshot( - expected_run_id=capability.run_id + expected_run_id=capability.run_id, key=self.key ) except DurableAuthorityBusy as exc: raise AttendedActionRefused( "the external attended decision journal is unavailable or invalid" ) from exc if snapshot is None: - if _sealed.is_file(self.decisions_path): + if self._state_is_file(self.decisions_path): try: local = AttendedDecisionLog.model_validate_json( - _sealed.read_text(self.decisions_path) + self._state_read_text(self.decisions_path) ) except (OSError, ValueError) as exc: raise AttendedActionRefused( @@ -1626,16 +1687,16 @@ def _read_log_with_head(self) -> tuple[AttendedDecisionLog, str]: payload = snapshot.encode("utf-8") try: local_matches = ( - _sealed.is_file(self.decisions_path) + self._state_is_file(self.decisions_path) and not self.decisions_path.is_symlink() - and _sealed.read_bytes(self.decisions_path) == payload + and self._state_read_bytes(self.decisions_path) == payload ) except OSError: local_matches = False if not local_matches: # The JSON file is a recoverable local projection. The append-only, # HMAC-chained SQLite journal outside the run directory is authority. - self._atomic_write(self.decisions_path, payload) + self._state_write(self.decisions_path, payload) return log, head def _append_log_snapshot( @@ -1661,12 +1722,13 @@ def _append_log_snapshot( expected_run_id=capability.run_id, expected_head_digest=expected_head, snapshot_json=payload, + key=self.key, ) except DurableAuthorityBusy as exc: raise AttendedActionRefused( "the external attended decision journal changed or is unavailable" ) from exc - self._atomic_write(self.decisions_path, payload.encode("utf-8")) + self._state_write(self.decisions_path, payload.encode("utf-8")) def prior(self, request: AttendedActionRequest) -> Optional[AttendedDecision]: request_digest = _digest(request) @@ -2165,7 +2227,7 @@ def issue_attended_capability( "the durable manifest has no stable run identity; cannot issue " "an attended mutation capability" ) - return AttendedActionStore(run_dir).issue( + return AttendedActionStore(run_dir, key=store.key).issue( manifest=manifest, pending=pending, workflow=workflow, @@ -2386,7 +2448,7 @@ def execute_attended_action( "the durable run identity or canonical path changed" ) from exc - actions = AttendedActionStore(run_dir) + actions = AttendedActionStore(run_dir, key=key) prior = actions.prior(request) if prior is not None: if prior.status in {"delivery_started", "delivery_uncertain"}: @@ -2893,7 +2955,7 @@ def checkpoint_human_completed_step( raise AttendedActionRefused( "the human-completed step's independent effect was not confirmed" ) - authenticated = AttendedActionStore(run_dir).signed_capability_for_digest( + authenticated = AttendedActionStore(run_dir, key=key).signed_capability_for_digest( capability.digest ) if authenticated != capability: @@ -3060,9 +3122,9 @@ def _load( raise AttendedActionRefused( "workflow identity changed after attended capability issuance" ) - authenticated = AttendedActionStore(run_dir).signed_capability_for_digest( - capability.digest - ) + authenticated = AttendedActionStore( + run_dir, key=self.key + ).signed_capability_for_digest(capability.digest) if authenticated != capability: raise AttendedActionRefused( "the attended executor did not receive the signed pause capability" @@ -3152,7 +3214,9 @@ def recover_reconciliation_receipt( "workflow identity changed after attended capability issuance" ) if ( - AttendedActionStore(run_dir).signed_capability_for_digest(capability.digest) + AttendedActionStore(run_dir, key=self.key).signed_capability_for_digest( + capability.digest + ) != capability ): raise AttendedActionRefused( @@ -3196,7 +3260,7 @@ def recover_reconciliation_receipt( raise AttendedActionRefused( "the completed durable transition does not bind this reconciliation" ) - AttendedActionStore(run_dir).write_reconciliation_receipt( + AttendedActionStore(run_dir, key=self.key).write_reconciliation_receipt( AttendedReconciliationReceipt( pause_id=capability.pause_id, capability_digest=capability.digest, @@ -3468,7 +3532,7 @@ def _resume_program( cursor_digest=cursor_digest, created_at=capability.issued_at, ) - action_store = AttendedActionStore(run_dir) + action_store = AttendedActionStore(run_dir, key=self.key) receipt = action_store.seal_program_receipt(receipt) attended_effects = effects_for_actuation(state.step, "gui") retained_result = _validated_attended_result( @@ -3673,7 +3737,9 @@ 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(_sealed.read_bytes(receipt_path)).hexdigest() + receipt_sha256 = hashlib.sha256( + action_store._state_read_bytes(receipt_path) + ).hexdigest() control_payload = json.dumps( [frame.model_dump(mode="json") for frame in pending.program_frames], sort_keys=True, @@ -3696,13 +3762,13 @@ def _resume_program( raise AttendedActionRefused( "the attended transition control path leaves the run directory" ) - if _sealed.is_file(control_path): - if _sealed.read_bytes(control_path) != control_payload: + if action_store._state_is_file(control_path): + if action_store._state_read_bytes(control_path) != control_payload: raise AttendedActionRefused( "the attended transition control digest has other bytes" ) else: - action_store._atomic_write(control_path, control_payload) + action_store._state_write(control_path, control_payload) scope = [ ProgramExecutionScopeFrame( graph_id=frame.graph_id, @@ -3831,7 +3897,7 @@ def _continue_run_locked( store, manifest, workflow = self._load(run_dir, capability) replayer = self.replayer_factory(manifest) self._bind_authorization(replayer, manifest) - attended_store = AttendedActionStore(run_dir) + attended_store = AttendedActionStore(run_dir, key=self.key) if workflow.program is not None: pending, state, params = self._program_context( store, workflow, capability @@ -4005,7 +4071,7 @@ def _reconcile_run_locked( ) replayer = self.replayer_factory(manifest) self._bind_authorization(replayer, manifest) - attended_store = AttendedActionStore(run_dir) + attended_store = AttendedActionStore(run_dir, key=self.key) if workflow.program is not None: pending, state, params = self._program_context( store, workflow, capability diff --git a/packages/flow/openadapt_flow/runtime/durable/attended_state.py b/packages/flow/openadapt_flow/runtime/durable/attended_state.py new file mode 100644 index 0000000000..0e05a0a621 --- /dev/null +++ b/packages/flow/openadapt_flow/runtime/durable/attended_state.py @@ -0,0 +1,271 @@ +"""Encryption at rest for attended state. + +The attended store (:mod:`openadapt_flow.runtime.durable.attended`) keeps the +pause capability, its history, the decision log (operator and free-text +message), program and reconciliation receipts and the program-transition +control frames in the run directory. With a key configured (the default: the +configured key, else this machine's local key), each file is written as +``.enc``: the same AES-256-GCM container durable checkpoints use, with +associated data that names the format version and the file's run-relative +path, so a ciphertext copied over another path does not decrypt. + +Older runs wrote these files in plaintext. A keyed store still reads a +plaintext file when no ``.enc`` form exists, until the run's first encrypted +write. That write converts every plaintext attended file to its encrypted form +and then leaves a marker; from then on a plaintext file standing in for an +encrypted one is refused. + +The external attended journal (:mod:`openadapt_flow.runtime.durable.authority`) +stores each decision-log snapshot with :func:`seal_journal_snapshot`. A sealed +run directory (a sealed bundle's run, or strict mode) encrypts these files +through its sealed-set writer instead, and never reaches this module's writer. +""" + +from __future__ import annotations + +import hashlib +import hmac +import os +import secrets +import threading +from collections import OrderedDict +from pathlib import Path +from typing import Callable, Optional + +from openadapt_flow import crypto as _crypto + +#: Suffix of an encrypted attended file (the checkpoint store uses the same). +ENC_SUFFIX = ".enc" +#: Associated-data prefix for an attended file: format version, then the +#: run-relative path. +FILE_AAD_PREFIX = b"openadapt-flow/attended-state/v1\0" +#: Associated-data prefix for one external journal snapshot. +JOURNAL_AAD_PREFIX = b"openadapt-flow/attended-journal/v1\0" +#: Prefix of an encrypted ``snapshot_json`` value. A stored value without it +#: is a plaintext snapshot from an older run. +JOURNAL_MARKER = "openadapt-flow/attended-journal/v1:" +#: Present once a run directory's attended state is encrypted. It holds no +#: recorded data. +MARKER_FILENAME = ".attended_state.encrypted" +MARKER_PAYLOAD = b'{"schema":"openadapt.flow.attended-state-encryption/v1"}\n' + +#: Plaintext files converted on a run's first encrypted write. +CONVERTED_FILES = ( + "attended_capability.json", + "attended_capability_history.json", + "attended_decisions.json", +) +CONVERTED_DIRS = ( + ".attended_program_receipts", + ".attended_reconciliation_receipts", + "private/program-transition-controls", +) + +Writer = Callable[[Path, bytes], None] + + +class AttendedStateUnreadable(ValueError): + """An attended file can't be authenticated, decrypted or trusted.""" + + +# Decrypting runs scrypt, and the attended store reads the same file several +# times per action. Keep a few authenticated plaintexts, keyed by the exact +# container and associated data, and reuse one only for the same passphrase. +_CACHE: "OrderedDict[tuple[bytes, bytes], tuple[str, bytes]]" = OrderedDict() +_CACHE_LIMIT = 64 +_CACHE_LOCK = threading.Lock() + + +def enc_path(path: Path) -> Path: + return path.with_name(path.name + ENC_SUFFIX) + + +def file_aad(root: Path, path: Path) -> bytes: + """Associated data binding a file to its run-relative path.""" + try: + rel = Path(os.path.abspath(path)).relative_to(os.path.abspath(root)) + except ValueError as exc: + raise AttendedStateUnreadable( + "an attended file lies outside its run directory" + ) from exc + return FILE_AAD_PREFIX + rel.as_posix().encode("utf-8") + + +def seal(plaintext: bytes, key: str, aad: bytes) -> bytes: + return _crypto.encrypt_bytes(plaintext, key, aad=aad) + + +def open_sealed(container: bytes, key: Optional[str], aad: bytes) -> bytes: + """Authenticate and decrypt one container, or raise + :class:`AttendedStateUnreadable`.""" + if not key: + raise AttendedStateUnreadable( + "attended state is encrypted and no key is configured; set " + "OPENADAPT_BUNDLE_KEY or use the key it was written with" + ) + cache_key = (hashlib.sha256(container).digest(), aad) + with _CACHE_LOCK: + cached = _CACHE.get(cache_key) + if cached is not None: + _CACHE.move_to_end(cache_key) + if cached is not None and hmac.compare_digest( + cached[0].encode("utf-8"), key.encode("utf-8") + ): + return cached[1] + try: + plaintext = _crypto.decrypt_bytes(container, key, aad=aad) + except _crypto.CryptoError as exc: + raise AttendedStateUnreadable( + "attended state does not decrypt: wrong key, or the file was " + "changed or moved from another path" + ) from exc + with _CACHE_LOCK: + _CACHE[cache_key] = (key, plaintext) + while len(_CACHE) > _CACHE_LIMIT: + _CACHE.popitem(last=False) + return plaintext + + +def _refuse_symlink(path: Path) -> None: + if path.is_symlink() or enc_path(path).is_symlink(): + raise AttendedStateUnreadable("an attended file must not be a symlink") + + +def exists(path: Path) -> bool: + """Whether ``path`` exists in either form.""" + return enc_path(path).is_file() or path.is_file() + + +def read_bytes(root: Path, path: Path, key: Optional[str]) -> bytes: + """Plaintext of ``path``: its ``.enc`` form decrypted, else an older + plaintext file. Raises ``FileNotFoundError`` when neither exists.""" + _refuse_symlink(path) + enc = enc_path(path) + if enc.is_file(): + return open_sealed(enc.read_bytes(), key, file_aad(root, path)) + if path.is_file(): + if (Path(root) / MARKER_FILENAME).exists(): + raise AttendedStateUnreadable( + "a plaintext attended file stands in for encrypted state" + ) + return path.read_bytes() + raise FileNotFoundError(str(path)) + + +def digest_bytes(root: Path, path: Path, key: Optional[str]) -> Optional[bytes]: + """Plaintext for a state digest, or None when the file is missing. The + digest is the same before and after a file is encrypted.""" + enc = enc_path(path) + if enc.is_file(): + return open_sealed(enc.read_bytes(), key, file_aad(root, path)) + if path.is_file(): + return path.read_bytes() + return None + + +def logical_names(directory: Path, suffix: str = ".json") -> set[str]: + """Names of the files in ``directory`` ending in ``suffix``, in either + form (``x.json`` and ``x.json.enc`` both give ``x.json``).""" + if not directory.is_dir(): + return set() + names: set[str] = set() + for entry in directory.iterdir(): + name = entry.name + if name.endswith(suffix + ENC_SUFFIX): + names.add(name[: -len(ENC_SUFFIX)]) + elif name.endswith(suffix): + names.add(name) + return names + + +def _link_new(path: Path, data: bytes) -> None: + """Create ``path`` with ``data`` unless it already exists.""" + 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, 0o600) + try: + with os.fdopen(fd, "wb") as handle: + handle.write(data) + handle.flush() + os.fsync(handle.fileno()) + try: + os.link(tmp, path) + except FileExistsError: + pass + finally: + tmp.unlink(missing_ok=True) + + +def _convert_plaintext(root: Path, key: str) -> None: + """Encrypt every plaintext attended file of an older run, then mark the + run. A file whose ``.enc`` form already exists keeps that form: a + concurrent writer may have just replaced it.""" + marker = root / MARKER_FILENAME + if marker.exists(): + return + candidates = [root / name for name in CONVERTED_FILES] + for dirname in CONVERTED_DIRS: + directory = root / dirname + if directory.is_dir() and not directory.is_symlink(): + candidates.extend(directory / name for name in logical_names(directory)) + for path in candidates: + if path.is_symlink() or not path.is_file(): + continue + enc = enc_path(path) + if not enc.exists(): + _link_new(enc, seal(path.read_bytes(), key, file_aad(root, path))) + path.unlink(missing_ok=True) + root.mkdir(parents=True, exist_ok=True) + _link_new(marker, MARKER_PAYLOAD) + + +def write_bytes( + root: Path, path: Path, data: bytes, key: Optional[str], writer: Writer +) -> None: + """Write ``path`` encrypted under ``key`` (as ``.enc``), or in plaintext + when no key resolves (the recorded opt-out). ``writer`` performs the + atomic replace.""" + _refuse_symlink(path) + root = Path(root) + if key: + _convert_plaintext(root, key) + writer(enc_path(path), seal(data, key, file_aad(root, path))) + path.unlink(missing_ok=True) + return + writer(path, data) + enc_path(path).unlink(missing_ok=True) + (root / MARKER_FILENAME).unlink(missing_ok=True) + + +def unlink(path: Path) -> None: + path.unlink(missing_ok=True) + enc_path(path).unlink(missing_ok=True) + + +# -- the external journal ---------------------------------------------------- + + +def _journal_aad(path_key: str, sequence: int) -> bytes: + return JOURNAL_AAD_PREFIX + f"{path_key}\0{sequence}".encode("utf-8") + + +def seal_journal_snapshot( + snapshot_json: str, key: Optional[str], *, path_key: str, sequence: int +) -> str: + """The stored form of one snapshot: encrypted and marked when a key is + set, else the plaintext (the recorded opt-out).""" + if not key: + return snapshot_json + container = seal( + snapshot_json.encode("utf-8"), key, _journal_aad(path_key, sequence) + ) + return JOURNAL_MARKER + container.decode("utf-8") + + +def open_journal_snapshot( + stored: str, key: Optional[str], *, path_key: str, sequence: int +) -> str: + """The plaintext of one stored snapshot (older rows are plaintext).""" + if not stored.startswith(JOURNAL_MARKER): + return stored + container = stored[len(JOURNAL_MARKER) :].encode("utf-8") + return open_sealed(container, key, _journal_aad(path_key, sequence)).decode("utf-8") diff --git a/packages/flow/openadapt_flow/runtime/durable/authority.py b/packages/flow/openadapt_flow/runtime/durable/authority.py index c5183fb44f..fac61224ec 100644 --- a/packages/flow/openadapt_flow/runtime/durable/authority.py +++ b/packages/flow/openadapt_flow/runtime/durable/authority.py @@ -1056,8 +1056,15 @@ def _validated_journal_head( *, expected_run_id: str, authority_record: DurableAuthorityRecord, + key: Optional[str] = None, ) -> tuple[Optional[str], str]: - """Verify the full external hash chain and return its latest snapshot.""" + """Verify the full external hash chain and return its latest snapshot. + + A snapshot stored encrypted (``attended_state.JOURNAL_MARKER``) is + decrypted under ``key``; an older plaintext row reads as it is. + """ + + from openadapt_flow.runtime.durable import attended_state as _state rows = connection.execute( "SELECT * FROM attended_journal WHERE path_key = ? ORDER BY sequence", @@ -1085,7 +1092,17 @@ def _validated_journal_head( raise DurableAuthorityBusy( "the external attended-decision journal chain is invalid" ) - snapshot_json = str(values["snapshot_json"]) + try: + snapshot_json = _state.open_journal_snapshot( + str(values["snapshot_json"]), + key, + path_key=self.path_key, + sequence=expected_sequence, + ) + except _state.AttendedStateUnreadable as exc: + raise DurableAuthorityBusy( + "the external attended-decision snapshot does not decrypt" + ) from exc snapshot_digest = ( "sha256:" + hashlib.sha256(snapshot_json.encode("utf-8")).hexdigest() ) @@ -1216,9 +1233,12 @@ def _validate_snapshot_extension( ) def read_attended_snapshot( - self, *, expected_run_id: str + self, *, expected_run_id: str, key: Optional[str] = None ) -> tuple[Optional[str], str]: - """Read the authenticated journal snapshot and its monotonic head.""" + """Read the authenticated journal snapshot and its monotonic head. + + ``key`` decrypts snapshots stored encrypted. + """ with self._transaction() as connection: record = self._read(connection) @@ -1230,6 +1250,7 @@ def read_attended_snapshot( connection, expected_run_id=expected_run_id, authority_record=record, + key=key, ) def append_attended_snapshot( @@ -1238,8 +1259,15 @@ def append_attended_snapshot( expected_run_id: str, expected_head_digest: str, snapshot_json: str, + key: Optional[str] = None, ) -> str: - """Append one authenticated journal revision in the external authority.""" + """Append one authenticated journal revision in the external authority. + + With ``key`` the snapshot is stored encrypted, bound to this run path + and sequence. The digest and HMAC cover the plaintext snapshot. + """ + + from openadapt_flow.runtime.durable import attended_state as _state # Parse before the transaction so invalid JSON never enters authority. self._validate_snapshot_extension(None, snapshot_json) @@ -1253,6 +1281,7 @@ def append_attended_snapshot( connection, expected_run_id=expected_run_id, authority_record=record, + key=key, ) if head != expected_head_digest: raise DurableAuthorityBusy( @@ -1299,7 +1328,12 @@ def append_attended_snapshot( expected_run_id, head, snapshot_digest, - snapshot_json, + _state.seal_journal_snapshot( + snapshot_json, + key, + path_key=self.path_key, + sequence=sequence, + ), record_digest, record_mac, created_at, diff --git a/packages/flow/openadapt_flow/runtime/durable/checkpoint.py b/packages/flow/openadapt_flow/runtime/durable/checkpoint.py index bd78532a52..8495cce365 100644 --- a/packages/flow/openadapt_flow/runtime/durable/checkpoint.py +++ b/packages/flow/openadapt_flow/runtime/durable/checkpoint.py @@ -519,6 +519,15 @@ def _read_json(self, path: Path) -> Optional[dict]: return json.loads(path.read_text()) # type: ignore[no-any-return] return None + def _attended_key(self) -> Optional[str]: + """The key attended state is read with: this store's key, else the + one the attended store resolves by default.""" + if self.key: + return self.key + from openadapt_flow import crypto as _crypto + + return _crypto.resolve_key(None) + @staticmethod def model_digest(model: Optional[BaseModel]) -> str: """Return one stable digest for an exact logical durable artifact.""" @@ -556,18 +565,23 @@ def _continuation_state_digest_unlocked(self) -> str: except (OSError, ValueError): claim = None + from openadapt_flow.runtime.durable import attended_state as _state + def file_digest(path: Path) -> Optional[str]: - if not path.is_file(): + # Attended state may be encrypted (``.enc``); the digest is + # over its plaintext, so it doesn't change when a file is sealed. + sealed = _state.enc_path(path).is_file() + key = self._attended_key() if sealed else None + data = _state.digest_bytes(self.run_dir, path, key) + if data is None: return None - return "sha256:" + hashlib.sha256(path.read_bytes()).hexdigest() + return "sha256:" + hashlib.sha256(data).hexdigest() receipts_dir = self.run_dir / ".attended_program_receipts" - receipts = [] - if receipts_dir.is_dir(): - receipts = [ - {"name": path.name, "sha256": file_digest(path)} - for path in sorted(receipts_dir.glob("*.json")) - ] + receipts = [ + {"name": name, "sha256": file_digest(receipts_dir / name)} + for name in sorted(_state.logical_names(receipts_dir)) + ] payload = { "manifest": ( manifest.model_dump(mode="json") if manifest is not None else None @@ -663,6 +677,7 @@ def has_durable_artifacts(self) -> bool: self.run_dir / ".attended_decisions.lock", self.run_dir / ".attended_action.lease", self.run_dir / ".attended_program_receipts", + self.run_dir / ".attended_state.encrypted", ) for path in candidates: if path.exists() or path.with_name(path.name + ENC_SUFFIX).exists(): diff --git a/packages/flow/openadapt_flow/runtime/durable/resume.py b/packages/flow/openadapt_flow/runtime/durable/resume.py index bea335e783..ef38160bb9 100644 --- a/packages/flow/openadapt_flow/runtime/durable/resume.py +++ b/packages/flow/openadapt_flow/runtime/durable/resume.py @@ -1236,7 +1236,9 @@ def _resume_under_lease( checkpoint.model_copy(deep=True) for checkpoint in linear_checkpoints ], "program_checkpoints": [], - "auxiliary_digest": replayer._durable_auxiliary_digest(run_dir, []), + "auxiliary_digest": replayer._durable_auxiliary_digest( + run_dir, [], key=getattr(replayer, "checkpoint_key", None) + ), }, authorizing_approval=approved.model_copy(deep=True), ) @@ -1383,7 +1385,9 @@ def _resume_program( "linear_checkpoints": [], "program_checkpoints": [item.model_copy(deep=True) for item in checkpoints], "auxiliary_digest": replayer._durable_auxiliary_digest( - store.run_dir, checkpoints + store.run_dir, + checkpoints, + key=getattr(replayer, "checkpoint_key", None), ), }, authorizing_approval=approval.model_copy(deep=True), diff --git a/packages/flow/openadapt_flow/runtime/replayer.py b/packages/flow/openadapt_flow/runtime/replayer.py index 7672d367ec..9b1f4d31e0 100644 --- a/packages/flow/openadapt_flow/runtime/replayer.py +++ b/packages/flow/openadapt_flow/runtime/replayer.py @@ -1375,7 +1375,7 @@ def _durable_resume_payload( "linear_checkpoints": store.checkpoints(), "program_checkpoints": store.program_checkpoints(), "auxiliary_digest": self._durable_auxiliary_digest( - run_dir, store.program_checkpoints() + run_dir, store.program_checkpoints(), key=self.checkpoint_key ), } return { @@ -1407,8 +1407,13 @@ def _durable_resume_payload( def _durable_auxiliary_digest( run_dir: Path, program_checkpoints: list[ProgramCheckpoint], + key: Optional[str] = None, ) -> str: - """Bind namespace, active capability, and every attended receipt.""" + """Bind namespace, active capability, and every attended receipt. + + ``key`` reads attended state encrypted at rest (default: the key + ``crypto.resolve_key`` gives). + """ receipts_dir = Path(run_dir) / ".attended_program_receipts" expected = { @@ -1416,11 +1421,10 @@ def _durable_auxiliary_digest( for checkpoint in program_checkpoints if checkpoint.attended_transition is not None } - actual = ( - {path.name for path in receipts_dir.glob("*.json")} - if receipts_dir.is_dir() - else set() - ) + from openadapt_flow.runtime.durable import attended_state as _state + + # Either form counts: ``.json`` or its encrypted ``.json.enc``. + actual = _state.logical_names(receipts_dir) if actual != expected: from openadapt_flow.runtime.durable.approval import StateDiverged @@ -1437,10 +1441,14 @@ def _durable_auxiliary_digest( for path in paths: digest.update(path.name.encode("utf-8")) digest.update(b"\0") - if path.is_file(): - digest.update(path.read_bytes()) - else: - digest.update(b"") + if _state.enc_path(path).is_file() and not key: + from openadapt_flow import crypto as _crypto + + key = _crypto.resolve_key(None) + # The plaintext, so the digest doesn't change when a file is + # encrypted at rest. + data = _state.digest_bytes(Path(run_dir), path, key) + digest.update(b"" if data is None else data) digest.update(b"\0") return "sha256:" + digest.hexdigest() diff --git a/packages/flow/scripts/export_public_demo_evidence.py b/packages/flow/scripts/export_public_demo_evidence.py index 3814f5b26a..8554e5b226 100644 --- a/packages/flow/scripts/export_public_demo_evidence.py +++ b/packages/flow/scripts/export_public_demo_evidence.py @@ -113,8 +113,12 @@ "approval.json", "approval.json.enc", "attended_capability.json", + "attended_capability.json.enc", "attended_capability_history.json", + "attended_capability_history.json.enc", "attended_decisions.json", + "attended_decisions.json.enc", + ".attended_state.encrypted", } ) REPO_ROOT = Path(__file__).resolve().parents[1] diff --git a/packages/flow/tests/test_attended_at_rest.py b/packages/flow/tests/test_attended_at_rest.py new file mode 100644 index 0000000000..2ab8454a19 --- /dev/null +++ b/packages/flow/tests/test_attended_at_rest.py @@ -0,0 +1,271 @@ +"""Attended state is ciphertext at rest in the default mode. + +Contract (docs/phi_at_rest.md, "Durable checkpoints and attended state"): + +- With a key resolving (the configured key, else the local key), the pause + capability, its history, the decision log, program receipts and control + frames are written as ``.enc`` and the external journal stores each + snapshot encrypted. No plaintext copy reaches the disk, temporary files + included. +- A run written before this (plaintext attended files and journal rows) still + resumes; its first encrypted write converts the plaintext files. +- Each file is bound to its run-relative path: a ciphertext copied over + another path, or a plaintext file standing in for an encrypted one, is + refused. + +Everything here is synthetic. +""" + +from __future__ import annotations + +import os +import shutil +import sqlite3 +from pathlib import Path + +import pytest + +from openadapt_flow import at_rest, crypto +from openadapt_flow.runtime.durable import attended_state +from openadapt_flow.runtime.durable.attended import ( + AttendedActionRefused, + AttendedActionRequest, + AttendedActionStore, + BoundAttendedExecutor, + execute_attended_action, +) +from openadapt_flow.runtime.durable.checkpoint import CheckpointStore +from openadapt_flow.runtime.replayer import Replayer +from tests.test_attended_actions import ( + _attended_program, + _paused, + _request, + _run_attended_program_to_pause, +) +from tests.test_replayer import FakeBackend, FakeVision, Match +from tests.test_sealed_at_rest import watching_disk + +OPERATOR = "Synthetic Operator Quillfeather" +PLAINTEXT_NAMES = attended_state.CONVERTED_FILES + + +@pytest.fixture +def default_mode(monkeypatch): + """The default at-rest mode: no opt-out, the local key under tmp.""" + monkeypatch.delenv("OPENADAPT_FLOW_PLAINTEXT_AT_REST", raising=False) + monkeypatch.delenv("OPENADAPT_BUNDLE_KEY", raising=False) + at_rest._reset_for_tests() + yield + at_rest._reset_for_tests() + + +def _continue_executor() -> BoundAttendedExecutor: + def factory(_manifest): + vision = FakeVision() + vision.text_results = { + "DONE": Match(point=(10, 10), region=(0, 0, 20, 20), confidence=1.0) + } + return Replayer(FakeBackend(), vision=vision, poll_interval_s=0.0) + + return BoundAttendedExecutor(factory) + + +def _journal_rows() -> list[str]: + db = os.environ["OPENADAPT_DURABLE_AUTHORITY_DB"] + connection = sqlite3.connect(db) + try: + return [ + str(row[0]) + for row in connection.execute( + "SELECT snapshot_json FROM attended_journal ORDER BY sequence" + ) + ] + finally: + connection.close() + + +def _journal_bytes() -> bytes: + db = Path(os.environ["OPENADAPT_DURABLE_AUTHORITY_DB"]) + return b"".join( + path.read_bytes() + for path in (db, Path(f"{db}-wal"), Path(f"{db}-journal")) + if path.is_file() + ) + + +def _attended_leaks(watch, run: Path, markers: list[bytes]) -> list[str]: + """Leaks in attended-state files. The run report and step evidence are + plaintext in the default mode by design (recorded in the ledger), so this + looks only at the attended store's own files.""" + attended = ( + "attended_", + ".attended_", + "private/program-transition-controls", + ) + found = [] + for leak in watch.leaks(markers): + path = leak.split(":", 1)[0].split(" (transient)", 1)[0] + try: + rel = Path(path).resolve().relative_to(run.resolve()).as_posix() + except ValueError: + continue + name = rel.rsplit("/", 1)[-1] + if rel.startswith(attended) or name.startswith(attended): + found.append(leak) + return found + + +def test_default_mode_attended_run_leaves_no_plaintext_attended_state( + tmp_path, default_mode +): + with watching_disk() as watch: + _workflow, _bundle, run, _store, capability = _paused(tmp_path) + decision = execute_attended_action( + run, + _request(capability), + operator=OPERATOR, + decided_by="human", + executor=_continue_executor(), + ) + assert decision.status == "completed" + markers = [ + OPERATOR.encode(), + capability.pause_id.encode(), + capability.signature.split(":", 1)[1].encode(), + ] + assert _attended_leaks(watch, run, markers) == [] + + for name in PLAINTEXT_NAMES: + assert not (run / name).exists(), name + for name in ("attended_capability_history.json", "attended_decisions.json"): + sealed = run / (name + attended_state.ENC_SUFFIX) + assert sealed.is_file(), name + assert crypto.is_encrypted(sealed.read_bytes()) + assert (run / attended_state.MARKER_FILENAME).is_file() + + rows = _journal_rows() + assert rows and all(row.startswith(attended_state.JOURNAL_MARKER) for row in rows) + journal = _journal_bytes() + assert all(marker not in journal for marker in markers) + + # The store and a fresh reader still see the decision. + log = AttendedActionStore(run)._read_log() + assert [item.operator for item in log.decisions] == [OPERATOR] + + +def test_default_mode_program_continue_encrypts_receipts_and_controls( + tmp_path, default_mode +): + workflow = _attended_program() + _bundle, run, _backend, _store, capability = _run_attended_program_to_pause( + tmp_path, workflow + ) + vision = FakeVision() + vision.text_results["DONE"] = Match( + point=(10, 10), region=(0, 0, 20, 20), confidence=1.0 + ) + with watching_disk() as watch: + decision = execute_attended_action( + run, + _request(capability, key="program-continue-at-rest"), + operator=OPERATOR, + executor=BoundAttendedExecutor( + lambda _manifest: Replayer( + FakeBackend(), vision=vision, poll_interval_s=0.0 + ) + ), + ) + assert decision.status == "completed" + markers = [OPERATOR.encode(), capability.pause_id.encode()] + assert _attended_leaks(watch, run, markers) == [] + + for dirname in ( + ".attended_program_receipts", + "private/program-transition-controls", + ): + names = sorted(path.name for path in (run / dirname).iterdir()) + assert names and all( + name.endswith(".json" + attended_state.ENC_SUFFIX) for name in names + ), names + checkpoint = CheckpointStore( + run, key=crypto.resolve_key(None) + ).program_checkpoints()[0] + assert AttendedActionStore(run).read_program_receipt(capability.pause_id) == ( + checkpoint.attended_transition + ) + + +def test_older_plaintext_attended_run_still_resumes(tmp_path, monkeypatch): + # Written under the recorded opt-out: plaintext files and journal rows, + # as every run was before attended state was encrypted. + _workflow, _bundle, run, _store, capability = _paused(tmp_path) + escalated = execute_attended_action( + run, + AttendedActionRequest( + capability_digest=capability.digest, + idempotency_key="older-run-escalate-0001", + action="escalate", + disposition="needs_assistance", + ), + operator="earlier operator", + ) + assert escalated.status == "escalated" + assert (run / "attended_capability.json").is_file() + assert (run / "attended_decisions.json").is_file() + assert not any( + row.startswith(attended_state.JOURNAL_MARKER) for row in _journal_rows() + ) + + monkeypatch.delenv("OPENADAPT_FLOW_PLAINTEXT_AT_REST") + at_rest._reset_for_tests() + decision = execute_attended_action( + run, + _request(capability, key="older-run-continue-0001"), + operator=OPERATOR, + executor=_continue_executor(), + ) + assert decision.status == "completed" + for name in PLAINTEXT_NAMES: + assert not (run / name).exists(), name + assert (run / "attended_decisions.json.enc").is_file() + rows = _journal_rows() + assert not rows[0].startswith(attended_state.JOURNAL_MARKER) + assert rows[-1].startswith(attended_state.JOURNAL_MARKER) + log = AttendedActionStore(run)._read_log() + assert [item.operator for item in log.decisions] == ["earlier operator", OPERATOR] + + +def test_encrypted_file_moved_to_another_path_or_replaced_is_refused( + tmp_path, default_mode +): + _workflow, _bundle, run, _store, capability = _paused(tmp_path) + execute_attended_action( + run, + AttendedActionRequest( + capability_digest=capability.digest, + idempotency_key="swap-test-escalate-0001", + action="escalate", + disposition="needs_assistance", + ), + operator=OPERATOR, + ) + store = AttendedActionStore(run) + assert store.read() == capability + capability_enc = run / "attended_capability.json.enc" + decisions_enc = run / "attended_decisions.json.enc" + original = capability_enc.read_bytes() + + # A valid ciphertext of another attended file does not open here. + shutil.copyfile(decisions_enc, capability_enc) + with pytest.raises(AttendedActionRefused): + AttendedActionStore(run).read() + + # A plaintext file standing in for the encrypted one is refused. + capability_enc.unlink() + (run / "attended_capability.json").write_text(capability.model_dump_json(indent=2)) + with pytest.raises(AttendedActionRefused): + AttendedActionStore(run).read() + + capability_enc.write_bytes(original) + (run / "attended_capability.json").unlink() + assert AttendedActionStore(run).read() == capability From eac4ce82756fe618971f47a97297ad0a1a0964ae Mon Sep 17 00:00:00 2001 From: abrichr Date: Tue, 3 Nov 2026 19:33:33 -0500 Subject: [PATCH 14/21] docs: durable checkpoints and attended state are encrypted at rest --- CAPABILITIES.md | 2 +- llms-full.txt | 2 +- packages/flow/docs/phi_at_rest.md | 16 ++++++++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/CAPABILITIES.md b/CAPABILITIES.md index c83b7f7a90..fd7b311346 100644 --- a/CAPABILITIES.md +++ b/CAPABILITIES.md @@ -302,7 +302,7 @@ improvising or retrying when something is off. | See a compiled program as a map (every step, exact edge, loop and stop rule, with each step's checks) and play a recorded run over it; one React package that Desktop, app.openadapt.ai and openadapt.ai share | On main ([packages/program-graph](packages/program-graph/README.md)) | Real compiled OpenEMR referral program and a recorded scoreboard run, both on synthetic data | `@openadapt/program-graph`; `openadapt-flow visualize --format json` | | Named sections for every compiled program ("Who: title, first name, last name", "Contact", "Create New Patient"), found without a model from the save, the recorded form structure, the fields and any narration; a person can rename, merge or split them on the review screen | On main | Unit tests; the certified OpenEMR referral bundle and a real recording of it on a local test copy with made-up patients | `openadapt-flow sections`; [SECTIONS.md](packages/flow/docs/SECTIONS.md) | | 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, undo, visualize, explain, emit-skill, for-each, sections, check mark, compose, process, variants, route, teach and push read them in memory through one artifact reader and refuse without the key; teach keeps its skill library and taught bundle sealed, and push keeps its sanitized review copy and approved archive encrypted until the upload | On main | 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) | -| Encryption at rest under a configured or locally generated key (0600): durable checkpoints and attended state by default; with `OPENADAPT_FLOW_AT_REST=strict`, every recording, bundle and run directory too. Each plaintext write is logged on stderr and in a ledger that Desktop shows as a notice | On main | Synthetic (key, opt-out, strict-mode and run-directory tests) | `openadapt_flow.at_rest`; [phi_at_rest.md](packages/flow/docs/phi_at_rest.md#default-opt-out-and-strict-mode) | +| Encryption at rest under a configured or locally generated key (0600): durable checkpoints and attended state by default; with `OPENADAPT_FLOW_AT_REST=strict`, every recording, bundle and run directory too. Each plaintext write is logged on stderr and in a ledger that Desktop shows as a notice | On main | Synthetic (key, opt-out, strict-mode, run-directory and attended-state audit-hook tests) | `openadapt_flow.at_rest`; [phi_at_rest.md](packages/flow/docs/phi_at_rest.md#default-opt-out-and-strict-mode) | | Signed receipts (Seals), countersigned and timestamped, offline by default | Released | Unit tests, real TSA tokens | `openadapt-flow seal`, `countersign`, `local-seal` | | Offline Seals signed with a key the customer holds: a key file, the OS keychain, or a PKCS#11 token or HSM; pinned keys with rotation and revocation; no fallback to a generated key | On main ([#1392](https://github.com/OpenAdaptAI/OpenAdapt/pull/1392)) | Unit tests with synthetic data (keychain and PKCS#11 through stand-ins) | `openadapt-flow local-seal --key`, `local-seal key create`, `pin`, `revoke`; `openadapt seal verify FOLDER` checks every sealed run in a folder; [OFFLINE_SEALS.md](packages/flow/docs/OFFLINE_SEALS.md#customer-held-keys) | | Anchor an offline Seal in the public Sigstore Rekor transparency log (opt-in; sends one digest and the public key), and verify the anchor offline from the stored inclusion proof | On main ([#1394](https://github.com/OpenAdaptAI/OpenAdapt/pull/1394)) | Unit tests against a simulated Rekor log; real public Rekor entries and Sigstore TSA tokens for the landing demo's referral runs, verified offline in tests; Ed25519ph matches the RFC 8032 test vector | `openadapt-flow local-seal anchor`, `local-seal verify --require-anchor`; [OFFLINE_SEALS.md](packages/flow/docs/OFFLINE_SEALS.md#optional-anchor-a-seal-in-a-public-transparency-log) | diff --git a/llms-full.txt b/llms-full.txt index 48c2352609..3be5c0679e 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -699,7 +699,7 @@ improvising or retrying when something is off. | See a compiled program as a map (every step, exact edge, loop and stop rule, with each step's checks) and play a recorded run over it; one React package that Desktop, app.openadapt.ai and openadapt.ai share | On main ([packages/program-graph](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/program-graph/README.md)) | Real compiled OpenEMR referral program and a recorded scoreboard run, both on synthetic data | `@openadapt/program-graph`; `openadapt-flow visualize --format json` | | Named sections for every compiled program ("Who: title, first name, last name", "Contact", "Create New Patient"), found without a model from the save, the recorded form structure, the fields and any narration; a person can rename, merge or split them on the review screen | On main | Unit tests; the certified OpenEMR referral bundle and a real recording of it on a local test copy with made-up patients | `openadapt-flow sections`; [SECTIONS.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/SECTIONS.md) | | 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, undo, visualize, explain, emit-skill, for-each, sections, check mark, compose, process, variants, route, teach and push read them in memory through one artifact reader and refuse without the key; teach keeps its skill library and taught bundle sealed, and push keeps its sanitized review copy and approved archive encrypted until the upload | On main | 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) | -| Encryption at rest under a configured or locally generated key (0600): durable checkpoints and attended state by default; with `OPENADAPT_FLOW_AT_REST=strict`, every recording, bundle and run directory too. Each plaintext write is logged on stderr and in a ledger that Desktop shows as a notice | On main | Synthetic (key, opt-out, strict-mode and run-directory tests) | `openadapt_flow.at_rest`; [phi_at_rest.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/phi_at_rest.md#default-opt-out-and-strict-mode) | +| Encryption at rest under a configured or locally generated key (0600): durable checkpoints and attended state by default; with `OPENADAPT_FLOW_AT_REST=strict`, every recording, bundle and run directory too. Each plaintext write is logged on stderr and in a ledger that Desktop shows as a notice | On main | Synthetic (key, opt-out, strict-mode, run-directory and attended-state audit-hook tests) | `openadapt_flow.at_rest`; [phi_at_rest.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/phi_at_rest.md#default-opt-out-and-strict-mode) | | Signed receipts (Seals), countersigned and timestamped, offline by default | Released | Unit tests, real TSA tokens | `openadapt-flow seal`, `countersign`, `local-seal` | | Offline Seals signed with a key the customer holds: a key file, the OS keychain, or a PKCS#11 token or HSM; pinned keys with rotation and revocation; no fallback to a generated key | On main ([#1392](https://github.com/OpenAdaptAI/OpenAdapt/pull/1392)) | Unit tests with synthetic data (keychain and PKCS#11 through stand-ins) | `openadapt-flow local-seal --key`, `local-seal key create`, `pin`, `revoke`; `openadapt seal verify FOLDER` checks every sealed run in a folder; [OFFLINE_SEALS.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/OFFLINE_SEALS.md#customer-held-keys) | | Anchor an offline Seal in the public Sigstore Rekor transparency log (opt-in; sends one digest and the public key), and verify the anchor offline from the stored inclusion proof | On main ([#1394](https://github.com/OpenAdaptAI/OpenAdapt/pull/1394)) | Unit tests against a simulated Rekor log; real public Rekor entries and Sigstore TSA tokens for the landing demo's referral runs, verified offline in tests; Ed25519ph matches the RFC 8032 test vector | `openadapt-flow local-seal anchor`, `local-seal verify --require-anchor`; [OFFLINE_SEALS.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/OFFLINE_SEALS.md#optional-anchor-a-seal-in-a-public-transparency-log) | diff --git a/packages/flow/docs/phi_at_rest.md b/packages/flow/docs/phi_at_rest.md index f0f7fbaf53..eb16ea86b8 100644 --- a/packages/flow/docs/phi_at_rest.md +++ b/packages/flow/docs/phi_at_rest.md @@ -172,6 +172,22 @@ step with them. The approved archive beside it (`.approved.zip`) is one AES-256-GCM container under the same key. +### Durable checkpoints and attended state + +In the default mode, under the configured or local key, the durable store +writes its checkpoints, pauses and approvals as `.enc` AES-256-GCM +containers. The attended store does the same for the pause capability and its +history, the decision log (operator and message), program and reconciliation +receipts, and program-transition control frames. Each file's associated data +names its run-relative path, so a file copied over another path does not +decrypt, and once a run's attended state is encrypted a plaintext file in its +place is refused. The external attended journal stores each decision-log +snapshot encrypted and bound to its run and sequence; its hash chain and HMAC +cover the plaintext. A run whose attended files are plaintext from an earlier +version still resumes, and its first encrypted write converts them. +`tests/test_attended_at_rest.py` runs attended decisions under the same disk +audit hook and checks that no attended file or journal row holds plaintext. + ### Run directory of a sealed bundle Every file is encrypted except these structural ones: `.durable_run.claim` From 78cd2447636ec2554e875a0586b1c91121ff22ee Mon Sep 17 00:00:00 2001 From: abrichr Date: Tue, 3 Nov 2026 20:02:41 -0500 Subject: [PATCH 15/21] fix: strict mode runs other sealed-ready commands on encrypted input only; read-only key resolver; post-run reports survive a ledger failure --- packages/flow/docs/phi_at_rest.md | 19 ++-- packages/flow/openadapt_flow/__main__.py | 65 ++++++++++++++ packages/flow/openadapt_flow/at_rest.py | 39 +++++--- .../openadapt_flow/console/halt_detail.py | 6 +- packages/flow/openadapt_flow/crypto.py | 14 +++ packages/flow/tests/test_at_rest_default.py | 88 +++++++++++++++++++ 6 files changed, 213 insertions(+), 18 deletions(-) diff --git a/packages/flow/docs/phi_at_rest.md b/packages/flow/docs/phi_at_rest.md index d626c01145..983d0a97ea 100644 --- a/packages/flow/docs/phi_at_rest.md +++ b/packages/flow/docs/phi_at_rest.md @@ -39,11 +39,20 @@ A sealed bundle always runs into a sealed run directory, in every mode. **Healthcare and other regulated deployments:** set `OPENADAPT_FLOW_AT_REST=strict` and a key from your secret manager. Strict -mode runs the commands that keep their files encrypted (the ones listed under -[Commands on sealed input](#commands-on-sealed-input)) and the ones that -write no recorded data (service health and control, the no-outbound allow -list, key and Seal verification), and holds back every other command before -it starts. It encrypts every recording, bundle and run directory, and an +mode runs: + +- `record`, `compile`, `induce`, `admit run`, `run`, `replay` and `resume`, + which encrypt what they write, plaintext input included; +- the other commands listed under + [Commands on sealed input](#commands-on-sealed-input), on encrypted input + (every directory they are given must be encrypted); +- the commands that write no recorded data: service health and control, the + no-outbound allow list, key and Seal verification. `service run` runs the + queue script it is given; the Flow commands the script starts inherit + strict mode, and the script's other actions are the operator's. + +Every other command, and qualification trials (`qualify campaign`, +`qualify run-case`, `qualify drills`), is held back before it starts. It encrypts every recording, bundle and run directory, and an unknown value of `OPENADAPT_FLOW_AT_REST` stops the command. `seal` and record checks on encrypted bundles use `OPENADAPT_BUNDLE_KEY` only. diff --git a/packages/flow/openadapt_flow/__main__.py b/packages/flow/openadapt_flow/__main__.py index ab5d301b3b..340564ea33 100644 --- a/packages/flow/openadapt_flow/__main__.py +++ b/packages/flow/openadapt_flow/__main__.py @@ -12652,6 +12652,51 @@ def _qualify_default_form(argv: list[str]) -> list[str]: ) +#: Sealed-ready commands that seal plaintext input themselves under strict +#: mode (a recording, a compiled, induced or admitted bundle, a run directory). +_STRICT_SELF_SEALING: frozenset[str] = frozenset( + { + "openadapt_flow.__main__:_cmd_record", + "openadapt_flow.__main__:_cmd_compile", + "openadapt_flow.__main__:_cmd_induce", + "openadapt_flow.cli_admit:cmd_admit_run", + "openadapt_flow.__main__:_cmd_run", + "openadapt_flow.__main__:_cmd_replay", + "openadapt_flow.__main__:_cmd_resume", + } +) + + +def _plaintext_directories_in(args: argparse.Namespace) -> list[Path]: + """Existing directories among the arguments that are not inside a sealed + set. Files a person passes (a config, a results JSON) are not artifacts + and are not checked.""" + from openadapt_flow import sealed_set + + found: list[Path] = [] + for name, value in vars(args).items(): + if name == "func": + continue + for item in value if isinstance(value, (list, tuple)) else [value]: + 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 + candidates = [text] + if isinstance(item, str) and "=" in text: + candidates.append(text.split("=", 1)[1]) + for candidate in candidates: + path = Path(candidate) + if not path.is_dir(): + continue + if sealed_set.is_sealed(path) or sealed_set.sealed_root(path): + continue + if path not in found: + found.append(path) + return found + + def _unsealed_output_check(args: argparse.Namespace) -> None: """Warn and record, or refuse in strict mode, a command whose output can't be encrypted yet. Strict mode runs only the sealed-ready and @@ -12668,6 +12713,26 @@ def _unsealed_output_check(args: argparse.Namespace) -> None: "patient data encrypted, and this one writes files outside an " "encrypted set. Nothing was run." ) + if command in _SEALED_READY_COMMANDS and command not in _STRICT_SELF_SEALING: + # These keep sealed input sealed; given plaintext input they + # re-save or write beside it in plaintext. So every directory + # they are given must already be encrypted. + plain = _plaintext_directories_in(args) + if plain: + raise at_rest.AtRestRefused( + f"{at_rest.MODE_ENV}=strict runs this command on encrypted " + f"input only, and {plain[0]} is not encrypted. Seal it first " + "(openadapt-flow seal, or compile/run under strict mode). " + "Nothing was run." + ) + if command == "openadapt_flow.__main__:_cmd_qualify" and getattr( + args, "qualify_cmd", None + ) in {"run-case", "drills"}: + raise at_rest.AtRestRefused( + f"{at_rest.MODE_ENV}=strict holds back qualification trials: " + "their case inputs and fault files are written beside the " + "sealed run. Nothing was run." + ) # Written after records change, so refused before the run starts. healed = getattr(args, "save_healed_to", None) if healed: diff --git a/packages/flow/openadapt_flow/at_rest.py b/packages/flow/openadapt_flow/at_rest.py index 4b574650e4..12fdadcec8 100644 --- a/packages/flow/openadapt_flow/at_rest.py +++ b/packages/flow/openadapt_flow/at_rest.py @@ -105,16 +105,23 @@ def unsealed_write(artifact: str, path: Path | str, *, refuse: bool = True) -> N f"Not writing {artifact} at {path}: it can't be encrypted yet, and " f"{MODE_ENV}=strict refuses plaintext. Nothing was written." ) - _record( - f"{artifact} at {path}", - "can't be encrypted yet", - path=str(path), - warning=( - f"WARNING: writing {artifact} in plaintext at {path}: it can't be " - "encrypted yet and can hold patient data. Set " - f"{MODE_ENV}=strict to refuse instead." - ), - ) + try: + _record( + f"{artifact} at {path}", + "can't be encrypted yet", + path=str(path), + warning=( + f"WARNING: writing {artifact} in plaintext at {path}: it can't be " + "encrypted yet and can hold patient data. Set " + f"{MODE_ENV}=strict to refuse instead." + ), + ) + except AtRestRefused as exc: + if refuse: + raise + # After records changed, the report is the only account of what was + # written: keep it, and say loudly that the ledger missed it. + print(f"ERROR: {exc} The report is written anyway.", file=sys.stderr) HOME_ENV: Final[str] = "OPENADAPT_FLOW_AT_REST_HOME" @@ -353,6 +360,18 @@ def seal_new(artifact: str, path: Path | str) -> bool: return False +def existing_key() -> Optional[str]: + """The local key if it already exists, for read paths: creates nothing + and records nothing. None when there is no local key yet.""" + path = local_key_path() + try: + if not path.exists(): + return None + return _read(path) + except OSError as exc: + raise LocalKeyError(f"{path} can't be read ({exc}).") from exc + + def default_key() -> Optional[str]: """The key to use when none is configured: the local key, or None under the opt-out (plaintext, the behaviour before encryption was the default). diff --git a/packages/flow/openadapt_flow/console/halt_detail.py b/packages/flow/openadapt_flow/console/halt_detail.py index ce3df7e9d8..28f3127dc6 100644 --- a/packages/flow/openadapt_flow/console/halt_detail.py +++ b/packages/flow/openadapt_flow/console/halt_detail.py @@ -394,13 +394,13 @@ def _paused_step( bundle directory is used for reads only and is never projected -- a filesystem path is protected content on this boundary. """ - from openadapt_flow import crypto + from openadapt_flow import at_rest, crypto try: - store = CheckpointStore(run_dir, key=crypto.resolve_key(None)) + store = CheckpointStore(run_dir, key=crypto.resolve_read_key(None)) pending = store.read_pending() manifest = store.read_manifest() - except crypto.CryptoError: + except (crypto.CryptoError, at_rest.LocalKeyError): # An encrypted pause that this key can't open is an error to show, # never "no pending halt". raise diff --git a/packages/flow/openadapt_flow/crypto.py b/packages/flow/openadapt_flow/crypto.py index 68c25f074a..5c81dd276a 100644 --- a/packages/flow/openadapt_flow/crypto.py +++ b/packages/flow/openadapt_flow/crypto.py @@ -130,6 +130,20 @@ def require_key(key: Optional[str]) -> str: return resolved +def resolve_read_key(key: Optional[str]) -> Optional[str]: + """Like :func:`resolve_key`, for read-only paths: the explicit key, else + ``OPENADAPT_BUNDLE_KEY``, else the local key only if it already exists. + Creates no key and adds no ledger line.""" + if key: + return key + env = os.environ.get(ENV_KEY) + if env: + return env + from openadapt_flow import at_rest + + return at_rest.existing_key() + + def require_configured_key(key: Optional[str] = None) -> str: """The explicit ``key`` or ``OPENADAPT_BUNDLE_KEY``, never the local key. diff --git a/packages/flow/tests/test_at_rest_default.py b/packages/flow/tests/test_at_rest_default.py index 6688957f71..a758a3fa4a 100644 --- a/packages/flow/tests/test_at_rest_default.py +++ b/packages/flow/tests/test_at_rest_default.py @@ -460,3 +460,91 @@ def test_an_open_keys_directory_is_refused(default_mode: Path) -> None: crypto.resolve_key(None) finally: (default_mode / "keys").chmod(0o700) + + +# -- review round 2 (#1755) ----------------------------------------------------- + + +def test_strict_runs_other_sealed_ready_commands_on_encrypted_input_only( + default_mode: Path, tmp_path: Path, monkeypatch +) -> None: + from openadapt_flow import __main__ as cli + from tests.test_after_stop_mockmed import _workflow + + plain = tmp_path / "plain-bundle" + _workflow().save(plain) + sealed = tmp_path / "sealed-bundle" + with sealed_set.writing(sealed, "bundle", crypto.resolve_key(None)): + _workflow().save(sealed) + monkeypatch.setenv(at_rest.MODE_ENV, "strict") + for func in (cli._cmd_scaffold_verifier, cli._cmd_certify): + with pytest.raises(at_rest.AtRestRefused, match="not encrypted"): + cli._unsealed_output_check(argparse.Namespace(func=func, bundle=str(plain))) + cli._unsealed_output_check(argparse.Namespace(func=func, bundle=str(sealed))) + # The commands that seal plaintext input themselves still take it. + cli._unsealed_output_check( + argparse.Namespace(func=cli._cmd_compile, recording=str(plain)) + ) + + +def test_strict_holds_back_qualification_trials( + default_mode: Path, tmp_path: Path, monkeypatch +) -> None: + from openadapt_flow import __main__ as cli + + sealed = tmp_path / "sealed-bundle" + from tests.test_after_stop_mockmed import _workflow + + with sealed_set.writing(sealed, "bundle", crypto.resolve_key(None)): + _workflow().save(sealed) + monkeypatch.setenv(at_rest.MODE_ENV, "strict") + for verb in ("run-case", "drills"): + with pytest.raises(at_rest.AtRestRefused, match="qualification trials"): + cli._unsealed_output_check( + argparse.Namespace( + func=cli._cmd_qualify, qualify_cmd=verb, bundle=str(sealed) + ) + ) + with pytest.raises(at_rest.AtRestRefused, match="strict"): + cli._unsealed_output_check( + argparse.Namespace(func=cli._cmd_qualify_campaign, bundle=str(sealed)) + ) + + +def test_a_local_key_bundle_under_the_opt_out_refuses_to_run( + default_mode: Path, tmp_path: Path, monkeypatch +) -> None: + from tests.test_after_stop_mockmed import _workflow + + bundle = tmp_path / "bundle" + with sealed_set.writing(bundle, "bundle", crypto.resolve_key(None)): + _workflow().save(bundle) + monkeypatch.setenv(at_rest.OPT_OUT_ENV, "desktop") + with pytest.raises(sealed_set.SealedSetError): + _mockmed_run(tmp_path, bundle, tmp_path / "run") + assert not (tmp_path / "run").exists() + + +@pytest.mark.skipif(os.name != "posix" or os.geteuid() == 0, reason="POSIX perms") +def test_a_report_after_records_changed_survives_a_ledger_failure( + default_mode: Path, tmp_path: Path, monkeypatch, capsys +) -> None: + locked = tmp_path / "locked" + locked.mkdir() + (locked / at_rest.LEDGER_NAME).mkdir() + monkeypatch.setenv(at_rest.HOME_ENV, str(locked)) + at_rest.unsealed_write("a worklist report", "/data/pool", refuse=False) + assert "ERROR" in capsys.readouterr().err + + +def test_read_paths_create_no_key_and_no_ledger_line( + default_mode: Path, monkeypatch +) -> None: + assert crypto.resolve_read_key(None) is None + assert not (default_mode / "keys").exists() + monkeypatch.setenv(at_rest.OPT_OUT_ENV, "desktop") + assert crypto.resolve_read_key(None) is None + assert _ledger(default_mode) == [] + monkeypatch.delenv(at_rest.OPT_OUT_ENV) + key = crypto.resolve_key(None) + assert crypto.resolve_read_key(None) == key From 446f246f76e028ceb854d3ee7a46617943f8d4d6 Mon Sep 17 00:00:00 2001 From: abrichr Date: Tue, 3 Nov 2026 20:30:01 -0500 Subject: [PATCH 16/21] fix: keep the at-rest key apart from the journal HMAC key; test expectations --- .../runtime/durable/authority.py | 10 +++++++--- packages/flow/tests/test_attended_at_rest.py | 20 ++++++++++++------- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/packages/flow/openadapt_flow/runtime/durable/authority.py b/packages/flow/openadapt_flow/runtime/durable/authority.py index fac61224ec..be9b7e7bb9 100644 --- a/packages/flow/openadapt_flow/runtime/durable/authority.py +++ b/packages/flow/openadapt_flow/runtime/durable/authority.py @@ -1066,6 +1066,8 @@ def _validated_journal_head( from openadapt_flow.runtime.durable import attended_state as _state + snapshot_key = key # ``key`` is reused below for the journal HMAC key + rows = connection.execute( "SELECT * FROM attended_journal WHERE path_key = ? ORDER BY sequence", (self.path_key,), @@ -1095,7 +1097,7 @@ def _validated_journal_head( try: snapshot_json = _state.open_journal_snapshot( str(values["snapshot_json"]), - key, + snapshot_key, path_key=self.path_key, sequence=expected_sequence, ) @@ -1269,6 +1271,8 @@ def append_attended_snapshot( from openadapt_flow.runtime.durable import attended_state as _state + snapshot_key = key # ``key`` is reused below for the journal HMAC key + # Parse before the transaction so invalid JSON never enters authority. self._validate_snapshot_extension(None, snapshot_json) with self._transaction() as connection: @@ -1281,7 +1285,7 @@ def append_attended_snapshot( connection, expected_run_id=expected_run_id, authority_record=record, - key=key, + key=snapshot_key, ) if head != expected_head_digest: raise DurableAuthorityBusy( @@ -1330,7 +1334,7 @@ def append_attended_snapshot( snapshot_digest, _state.seal_journal_snapshot( snapshot_json, - key, + snapshot_key, path_key=self.path_key, sequence=sequence, ), diff --git a/packages/flow/tests/test_attended_at_rest.py b/packages/flow/tests/test_attended_at_rest.py index 2ab8454a19..ac5171bf5f 100644 --- a/packages/flow/tests/test_attended_at_rest.py +++ b/packages/flow/tests/test_attended_at_rest.py @@ -137,10 +137,13 @@ def test_default_mode_attended_run_leaves_no_plaintext_attended_state( for name in PLAINTEXT_NAMES: assert not (run / name).exists(), name - for name in ("attended_capability_history.json", "attended_decisions.json"): - sealed = run / (name + attended_state.ENC_SUFFIX) - assert sealed.is_file(), name - assert crypto.is_encrypted(sealed.read_bytes()) + attended_files = [ + path for path in run.iterdir() if path.name.startswith("attended_") + ] + assert run / "attended_decisions.json.enc" in attended_files + for path in attended_files: + assert path.name.endswith(attended_state.ENC_SUFFIX), path.name + assert crypto.is_encrypted(path.read_bytes()) assert (run / attended_state.MARKER_FILENAME).is_file() rows = _journal_rows() @@ -150,7 +153,7 @@ def test_default_mode_attended_run_leaves_no_plaintext_attended_state( # The store and a fresh reader still see the decision. log = AttendedActionStore(run)._read_log() - assert [item.operator for item in log.decisions] == [OPERATOR] + assert {item.operator for item in log.decisions} == {OPERATOR} def test_default_mode_program_continue_encrypts_receipts_and_controls( @@ -176,7 +179,9 @@ def test_default_mode_program_continue_encrypts_receipts_and_controls( ), ) assert decision.status == "completed" - markers = [OPERATOR.encode(), capability.pause_id.encode()] + # The pause id names the receipt file (and so its associated data); it + # is a random identifier, not recorded data. + markers = [OPERATOR.encode(), capability.signature.split(":", 1)[1].encode()] assert _attended_leaks(watch, run, markers) == [] for dirname in ( @@ -232,7 +237,8 @@ def test_older_plaintext_attended_run_still_resumes(tmp_path, monkeypatch): assert not rows[0].startswith(attended_state.JOURNAL_MARKER) assert rows[-1].startswith(attended_state.JOURNAL_MARKER) log = AttendedActionStore(run)._read_log() - assert [item.operator for item in log.decisions] == ["earlier operator", OPERATOR] + operators = [item.operator for item in log.decisions] + assert operators[0] == "earlier operator" and operators[-1] == OPERATOR def test_encrypted_file_moved_to_another_path_or_replaced_is_refused( From 38259372e1239125dd7f46ad63179e6ef00a7f6f Mon Sep 17 00:00:00 2001 From: abrichr Date: Tue, 3 Nov 2026 21:16:01 -0500 Subject: [PATCH 17/21] fix: sealed run directories keep their own reader for attended state digests --- packages/flow/openadapt_flow/execution_profiles.py | 5 +++++ .../flow/openadapt_flow/runtime/durable/attended_state.py | 6 +++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/flow/openadapt_flow/execution_profiles.py b/packages/flow/openadapt_flow/execution_profiles.py index e05bfec663..94f03eb5a6 100644 --- a/packages/flow/openadapt_flow/execution_profiles.py +++ b/packages/flow/openadapt_flow/execution_profiles.py @@ -695,8 +695,13 @@ def _verified_attended_bytes(ref: str, expected_sha256: str) -> bytes: if transition_evidence_root is None: raise ValueError("visual transition evidence has no local evidence root") + from openadapt_flow import sealed_set as _sealed + root = transition_evidence_root.resolve() encrypted = root.joinpath(*PurePosixPath(ref + _state.ENC_SUFFIX).parts) + if _sealed.is_sealed_path(root): + # A sealed run directory's files are the sealed-set writer's. + return _verified_inventory_bytes(ref, expected_sha256) if not (encrypted.exists() or encrypted.is_symlink()): if (root / _state.MARKER_FILENAME).exists(): raise ValueError("attended evidence is plaintext where encrypted") diff --git a/packages/flow/openadapt_flow/runtime/durable/attended_state.py b/packages/flow/openadapt_flow/runtime/durable/attended_state.py index 0e05a0a621..47141f2a79 100644 --- a/packages/flow/openadapt_flow/runtime/durable/attended_state.py +++ b/packages/flow/openadapt_flow/runtime/durable/attended_state.py @@ -33,6 +33,7 @@ from typing import Callable, Optional from openadapt_flow import crypto as _crypto +from openadapt_flow import sealed_set as _sealed #: Suffix of an encrypted attended file (the checkpoint store uses the same). ENC_SUFFIX = ".enc" @@ -154,7 +155,10 @@ def read_bytes(root: Path, path: Path, key: Optional[str]) -> bytes: def digest_bytes(root: Path, path: Path, key: Optional[str]) -> Optional[bytes]: """Plaintext for a state digest, or None when the file is missing. The - digest is the same before and after a file is encrypted.""" + digest is the same before and after a file is encrypted. A sealed run + directory's files are the sealed-set writer's, read as they are.""" + if _sealed.is_sealed_path(path): + return path.read_bytes() if path.is_file() else None enc = enc_path(path) if enc.is_file(): return open_sealed(enc.read_bytes(), key, file_aad(root, path)) From 03a34a8338ac4e533d9c17f6c37f453350a94d99 Mon Sep 17 00:00:00 2001 From: abrichr Date: Tue, 3 Nov 2026 21:40:21 -0500 Subject: [PATCH 18/21] test: Desktop notice queries and the compile-agent e2e use a test scrubber --- .../desktop/src/App.atRestNotice.test.tsx | 11 ++++--- packages/desktop/src/App.tsx | 4 --- .../flow/tests/e2e/test_compile_agent_e2e.py | 29 ++++++++++++++++++- 3 files changed, 35 insertions(+), 9 deletions(-) diff --git a/packages/desktop/src/App.atRestNotice.test.tsx b/packages/desktop/src/App.atRestNotice.test.tsx index f7ee14c353..5d2d0d0a4b 100644 --- a/packages/desktop/src/App.atRestNotice.test.tsx +++ b/packages/desktop/src/App.atRestNotice.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, render, screen } from "@testing-library/react"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; import { afterEach, expect, it, vi } from "vitest"; // Flow records each write of patient data it can't encrypt yet in a local @@ -49,7 +49,8 @@ it("names unencrypted writes and where they are listed", async () => { ledger: "/Users/me/.openadapt/plaintext-at-rest.jsonl", }); render(); - const notice = await screen.findByText(/without encryption/); + const [notice, ...others] = await screen.findAllByText(/without encryption/); + expect(others).toHaveLength(0); expect(notice.textContent).toContain("3 recordings or run records"); expect(notice.textContent).toContain("/Users/me/.openadapt/plaintext-at-rest.jsonl"); }); @@ -57,6 +58,8 @@ it("names unencrypted writes and where they are listed", async () => { it("shows nothing when nothing was written unencrypted", async () => { engineWith({ strict: false, unencrypted_writes: 0, latest_purpose: "", ledger: "x" }); render(); - await screen.findByText(/./); - expect(screen.queryByText(/without encryption/)).toBeNull(); + await waitFor(() => + expect(vi.mocked(engineTry)).toHaveBeenCalledWith(CMD.AT_REST_STATUS, {}, null), + ); + expect(screen.queryAllByText(/without encryption/)).toHaveLength(0); }); diff --git a/packages/desktop/src/App.tsx b/packages/desktop/src/App.tsx index 430cc3ee97..69625f4739 100644 --- a/packages/desktop/src/App.tsx +++ b/packages/desktop/src/App.tsx @@ -757,7 +757,6 @@ export default function App() { <> {pairingNotice} {authoringNotice} - {atRestNotice} {atRestNotice} {firstWorkflowStageNotice} @@ -772,7 +771,6 @@ export default function App() { <> {pairingNotice} {authoringNotice} - {atRestNotice} {atRestNotice} {firstWorkflowStageNotice} @@ -796,7 +794,6 @@ export default function App() { <> {pairingNotice} {authoringNotice} - {atRestNotice} {atRestNotice} {firstWorkflowStageNotice} @@ -813,7 +810,6 @@ export default function App() { <> {pairingNotice} {authoringNotice} - {atRestNotice} {atRestNotice} {firstWorkflowStageNotice} diff --git a/packages/flow/tests/e2e/test_compile_agent_e2e.py b/packages/flow/tests/e2e/test_compile_agent_e2e.py index 10ce9b9022..ab6e15dd39 100644 --- a/packages/flow/tests/e2e/test_compile_agent_e2e.py +++ b/packages/flow/tests/e2e/test_compile_agent_e2e.py @@ -76,6 +76,32 @@ def _verify_yaml(tmp_path: Path, base: str) -> Path: return path +class _ImageScrubber: + """Redacts nothing. The model here is scripted, so no screenshot leaves + the machine, but compile-agent redacts every screenshot it would send + and refuses without a scrubber (agents._image).""" + + def __init__(self) -> None: + self.images = 0 + + def scrub_text(self, text: str, is_separated: bool = False) -> str: + return text + + def scrub_image(self, image: Any, fill_color: Any = None) -> Any: + self.images += 1 + return image + + +@pytest.fixture(autouse=True) +def _scrubber() -> Any: + from openadapt_flow import privacy + + scrubber = _ImageScrubber() + privacy.set_text_scrubber(scrubber) + yield scrubber + privacy.reset_scrubbers() + + def _policy_agent(key_dir: Path, base: str, monkeypatch: pytest.MonkeyPatch) -> Any: """A registered agent, under a policy a person signed that lets it admit what it recorded itself.""" @@ -208,7 +234,7 @@ def _common(tmp_path: Path, base: str) -> dict[str, Any]: def test_compile_agent_then_replay_with_no_model_calls( - mockmed: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + mockmed: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, _scrubber: Any ) -> None: admitter = _policy_agent(tmp_path / "keys", mockmed, monkeypatch) fake = FakeClaude("Jane Sample", NOTE) @@ -232,6 +258,7 @@ def test_compile_agent_then_replay_with_no_model_calls( # Selective vision: no screenshot asked for; one at the save gate. assert harness["screenshots_requested"] == 0 assert harness["screenshots_at_gate"] == 1 + assert _scrubber.images == 1 # the gate screenshot was redacted first assert harness["screenshots_every_turn"] == 0 assert harness["gate_checks"] == 1 and harness["gate_refusals"] == 0 assert result.run1["gates"][0]["decision"] == "passed_no_hold" From 3caa91dd97f358cbe862ab322fe2cd53e9f72960 Mon Sep 17 00:00:00 2001 From: Richard Abrich Date: Wed, 4 Nov 2026 00:58:15 -0500 Subject: [PATCH 19/21] flow: one key derivation per attended run, and no plaintext downgrade (fixes #1812 review blockers) (#1821) * flow: derive the attended-state key once per run and refuse plaintext downgrade Attended files and journal rows each ran scrypt when sealed and opened, so a long run did O(n^2) key stretching. They are now sealed under AES-GCM subkeys derived once per run from a random salt kept in the run's encryption marker. Older per-container files and journal rows still read, and an older marker is upgraded on the next encrypted write. With the marker present, a write with no key (the plaintext opt-out) is now refused instead of deleting the marker and the encrypted file. digest_bytes refuses a plaintext attended file in an encrypted run, like read_bytes. Execution-profile evidence reads use the same container dispatch. Fix the mypy errors in authority.py by naming the journal HMAC key mac_key. * fix(at-rest): refuse attended files sealed for another run and pass the checkpoint key to outcome classification A salted attended file whose salt is not the run marker's salt is refused. A conversion that loses the race to write the marker reseals its files under the winning salt. The replayer passes its checkpoint key through stamp_execution_outcome to the stores it opens. * fix(at-rest): key the run-key cache by salt and compare the passphrase in constant time * fix(at-rest): pick one run salt when writers race to upgrade an older marker Each writer proposes a salt by creating .attended_state.encrypted.v2 without replacement. Only the first succeeds; every writer copies that file into the marker, so no writer can install a second salt and strand a file another writer has already sealed. Adds a stale-read regression and a two-thread regression that read both files afterwards; both fail on the previous code. --- packages/flow/docs/phi_at_rest.md | 14 + packages/flow/openadapt_flow/crypto.py | 21 + .../flow/openadapt_flow/execution_profiles.py | 42 +- .../runtime/durable/attended_state.py | 373 ++++++++++++++-- .../runtime/durable/authority.py | 15 +- .../flow/openadapt_flow/runtime/replayer.py | 1 + packages/flow/tests/test_attended_at_rest.py | 397 +++++++++++++++++- 7 files changed, 807 insertions(+), 56 deletions(-) diff --git a/packages/flow/docs/phi_at_rest.md b/packages/flow/docs/phi_at_rest.md index eb16ea86b8..c7f26d17ea 100644 --- a/packages/flow/docs/phi_at_rest.md +++ b/packages/flow/docs/phi_at_rest.md @@ -185,6 +185,20 @@ place is refused. The external attended journal stores each decision-log snapshot encrypted and bound to its run and sequence; its hash chain and HMAC cover the plaintext. A run whose attended files are plaintext from an earlier version still resumes, and its first encrypted write converts them. +The key is stretched with scrypt once per run, under a random salt kept in the +run's `.attended_state.encrypted` marker, and each file and journal row gets +its own nonce under keys derived from it, so the cost doesn't grow with the +number of files or journal rows. Each file names the salt it was sealed +under, and a file whose salt is not the one in the run's marker is refused, so +a file copied in from another run under the same key does not read. If two +processes convert the same run at once, the one that loses the race to write +the marker reseals its files under the winner's salt. The replayer classifies +a run's outcome with its own checkpoint key, so a run whose key was passed in +code rather than through the environment still reads its sealed evidence. +Once a run's attended state is encrypted, +`OPENADAPT_FLOW_PLAINTEXT_AT_REST` does not turn it back to plaintext: a write +with no key is refused. Containers written by the earlier per-file format +still read. `tests/test_attended_at_rest.py` runs attended decisions under the same disk audit hook and checks that no attended file or journal row holds plaintext. diff --git a/packages/flow/openadapt_flow/crypto.py b/packages/flow/openadapt_flow/crypto.py index 4f2f5fcbcc..178e523366 100644 --- a/packages/flow/openadapt_flow/crypto.py +++ b/packages/flow/openadapt_flow/crypto.py @@ -140,6 +140,8 @@ def is_encrypted(data: bytes) -> bool: """ if data[: len(SET_FILE_MAGIC)] == SET_FILE_MAGIC: return True + if data[: len(SALTED_FILE_MAGIC)] == SALTED_FILE_MAGIC: + return True try: obj = json.loads(data) except (ValueError, TypeError): @@ -275,6 +277,25 @@ def open(self, container: bytes, *, aad: bytes) -> bytes: ) from exc +#: Prefix of a container sealed under a key derived once per run (see +#: :mod:`openadapt_flow.runtime.durable.attended_state`): this prefix, the +#: run's 16-byte salt, a random 96-bit nonce, then the AES-256-GCM ciphertext. +SALTED_FILE_MAGIC: Final[bytes] = b"OAFLOW-SALTED-1\n" +SALT_LEN: Final[int] = _SALT_LEN +NONCE_LEN: Final[int] = _NONCE_LEN + + +def derive_data_key(passphrase: str, salt: bytes) -> bytes: + """The 256-bit scrypt key for ``passphrase`` and ``salt``, at the work + factors new containers record. A caller that derives once and caches the + key must keep it in memory only.""" + if not passphrase: + raise MissingKeyError("no encryption passphrase configured") + if len(salt) != _SALT_LEN: + raise CryptoError("a data-key salt must be 16 bytes") + return _derive(passphrase, salt, n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P) + + 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/execution_profiles.py b/packages/flow/openadapt_flow/execution_profiles.py index 94f03eb5a6..ceeb706779 100644 --- a/packages/flow/openadapt_flow/execution_profiles.py +++ b/packages/flow/openadapt_flow/execution_profiles.py @@ -486,6 +486,7 @@ def _program_action_trace( attended_transition_evidence: list[Any] | None = None, business_decision_evidence: list[Any] | None = None, transition_evidence_root: Path | None = None, + transition_evidence_key: str | None = None, transition_predicate_vision: Any | None = None, governed_runtime_inputs_digest: str | None = None, run_id_sha256: str | None = None, @@ -549,7 +550,9 @@ def _program_action_trace( return None from openadapt_flow.runtime.durable.checkpoint import CheckpointStore - manifest = CheckpointStore(transition_evidence_root).read_manifest() + manifest = CheckpointStore( + transition_evidence_root, key=transition_evidence_key + ).read_manifest() if manifest is None: return None trace_base_params = dict(manifest.params) @@ -718,11 +721,15 @@ def _verified_attended_bytes(ref: str, expected_sha256: str) -> bytes: container = path.read_bytes() except (OSError, ValueError) as exc: raise ValueError("attended transition evidence is unreadable") from exc - payload = _state.open_sealed( - container, - _crypto.resolve_key(None), - _state.FILE_AAD_PREFIX + ref.encode("utf-8"), - ) + try: + payload = _state.open_file( + root, + root.joinpath(*PurePosixPath(ref).parts), + container, + _crypto.resolve_key(transition_evidence_key), + ) + except _state.AttendedStateUnreadable as exc: + raise ValueError("attended transition evidence does not decrypt") from exc import hashlib if hashlib.sha256(payload).hexdigest() != expected_sha256: @@ -901,9 +908,11 @@ def _validated_attended_target( try: parsed = ProgramTransitionReceipt.model_validate_json(payload) authenticated = AttendedActionStore( - transition_evidence_root + transition_evidence_root, key=transition_evidence_key ).read_program_receipt(item.receipt_pause_id) - manifest = CheckpointStore(transition_evidence_root).read_manifest() + manifest = CheckpointStore( + transition_evidence_root, key=transition_evidence_key + ).read_manifest() except (OSError, ValueError, AttendedActionRefused) as exc: raise ValueError("attended transition receipt does not verify") from exc if ( @@ -1018,8 +1027,12 @@ def _validated_business_decision( ) try: - store = BusinessDecisionStore(transition_evidence_root) - manifest = CheckpointStore(transition_evidence_root).read_manifest() + store = BusinessDecisionStore( + transition_evidence_root, checkpoint_key=transition_evidence_key + ) + manifest = CheckpointStore( + transition_evidence_root, key=transition_evidence_key + ).read_manifest() if manifest is None: raise ValueError("business decision has no durable manifest") expected_bundle = bundle_version(manifest.bundle_dir) @@ -1497,6 +1510,7 @@ def classify_execution_outcome( transition_evidence_root: Path | None = None, transition_predicate_vision: Any | None = None, _qualification_fault_target_step_id: str | None = None, + transition_evidence_key: str | None = None, ) -> ExecutionOutcome: """Classify a completed report without changing legacy ``success``. @@ -1699,6 +1713,7 @@ def classify_execution_outcome( attended_transition_evidence=(report.attended_program_transition_evidence), business_decision_evidence=report.business_decision_evidence, transition_evidence_root=transition_evidence_root, + transition_evidence_key=transition_evidence_key, transition_predicate_vision=transition_predicate_vision, governed_runtime_inputs_digest=report.governed_runtime_inputs_digest, run_id_sha256=report.run_id_sha256, @@ -2255,8 +2270,12 @@ def stamp_execution_outcome( runtime_worklists: Mapping[str, list[dict[str, str]]] | None = None, transition_evidence_root: Path | None = None, transition_predicate_vision: Any | None = None, + transition_evidence_key: str | None = None, ) -> ExecutionOutcome: - """Write the profile and precise outcome into ``report``.""" + """Write the profile and precise outcome into ``report``. + + ``transition_evidence_key`` is the passphrase the run sealed its attended + state under; when it is ``None`` the usual key resolution applies.""" resolved = resolve_execution_profile(profile) report.external_network_calls = _external_network_call_state(report) @@ -2272,6 +2291,7 @@ def stamp_execution_outcome( runtime_worklists=runtime_worklists, transition_evidence_root=transition_evidence_root, transition_predicate_vision=transition_predicate_vision, + transition_evidence_key=transition_evidence_key, ) report.execution_profile = resolved.value report.execution_outcome = outcome.value diff --git a/packages/flow/openadapt_flow/runtime/durable/attended_state.py b/packages/flow/openadapt_flow/runtime/durable/attended_state.py index 47141f2a79..515c2ba1da 100644 --- a/packages/flow/openadapt_flow/runtime/durable/attended_state.py +++ b/packages/flow/openadapt_flow/runtime/durable/attended_state.py @@ -9,6 +9,13 @@ associated data that names the format version and the file's run-relative path, so a ciphertext copied over another path does not decrypt. +scrypt runs once per run, not once per file. The run's marker holds one random +salt; the key scrypt derives from the passphrase and that salt is kept in +memory, and HKDF gives one subkey for files and one for journal rows. Each +container carries the salt it was sealed under, so a reader needs only the +passphrase. Containers written before this (one scrypt salt each) still +decrypt. + Older runs wrote these files in plaintext. A keyed store still reads a plaintext file when no ``.enc`` form exists, until the run's first encrypted write. That write converts every plaintext attended file to its encrypted form @@ -23,8 +30,10 @@ from __future__ import annotations +import base64 import hashlib import hmac +import json import os import secrets import threading @@ -32,6 +41,11 @@ from pathlib import Path from typing import Callable, Optional +from cryptography.exceptions import InvalidTag +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives.kdf.hkdf import HKDF + from openadapt_flow import crypto as _crypto from openadapt_flow import sealed_set as _sealed @@ -49,6 +63,21 @@ #: recorded data. MARKER_FILENAME = ".attended_state.encrypted" MARKER_PAYLOAD = b'{"schema":"openadapt.flow.attended-state-encryption/v1"}\n' +#: The marker schema that carries the run salt. +MARKER_SCHEMA_V2 = "openadapt.flow.attended-state-encryption/v2" +#: Created once, without replacement, when an older marker is upgraded. The +#: first writer to create it picks the run salt, and every writer copies it +#: into the marker. It is never removed: a writer that read the older marker +#: before the upgrade must still find the salt that was picked. +MARKER_UPGRADE_FILENAME = ".attended_state.encrypted.v2" +#: Associated-data prefixes and the stored-row prefix for containers sealed +#: under the run key. Older (per-container scrypt) forms keep the v1 values. +FILE_AAD_PREFIX_V2 = b"openadapt-flow/attended-state/v2\0" +JOURNAL_AAD_PREFIX_V2 = b"openadapt-flow/attended-journal/v2\0" +JOURNAL_MARKER_V2 = "openadapt-flow/attended-journal/v2:" +#: HKDF ``info`` for the two subkeys of a run key. +FILE_SUBKEY_INFO = b"attended-state/v1" +JOURNAL_SUBKEY_INFO = b"attended-journal/v1" #: Plaintext files converted on a run's first encrypted write. CONVERTED_FILES = ( @@ -69,19 +98,120 @@ class AttendedStateUnreadable(ValueError): """An attended file can't be authenticated, decrypted or trusted.""" -# Decrypting runs scrypt, and the attended store reads the same file several -# times per action. Keep a few authenticated plaintexts, keyed by the exact -# container and associated data, and reuse one only for the same passphrase. +# Older containers each need their own scrypt run, and the attended store +# reads the same file several times per action. Keep a few of their +# authenticated plaintexts, keyed by the exact container and associated data, +# and reuse one only for the same passphrase. Containers sealed under a run +# key never use this cache. _CACHE: "OrderedDict[tuple[bytes, bytes], tuple[str, bytes]]" = OrderedDict() _CACHE_LIMIT = 64 _CACHE_LOCK = threading.Lock() +# One scrypt per (passphrase, run salt) per process. Keyed by the run salt; +# an entry is reused only for the same passphrase (compared in constant time, +# as the container cache above does), so a different passphrase derives again. +_RUN_KEYS: "OrderedDict[bytes, tuple[str, tuple[AESGCM, AESGCM]]]" = OrderedDict() +_RUN_KEY_LIMIT = 32 +_RUN_KEY_LOCK = threading.Lock() +# A journal salt for a run directory with no salted marker (a sealed run +# directory, or a run whose first journal row comes before any file write). +_JOURNAL_SALTS: dict[str, bytes] = {} + + +def _run_keys(passphrase: str, salt: bytes) -> tuple[AESGCM, AESGCM]: + """The (file, journal) AEADs for one run salt, deriving them only once.""" + with _RUN_KEY_LOCK: + cached = _RUN_KEYS.get(salt) + if cached is not None: + _RUN_KEYS.move_to_end(salt) + if cached is not None and hmac.compare_digest( + cached[0].encode("utf-8"), passphrase.encode("utf-8") + ): + return cached[1] + master = _crypto.derive_data_key(passphrase, salt) + pair = tuple( + AESGCM( + HKDF(algorithm=hashes.SHA256(), length=32, salt=None, info=info).derive( + master + ) + ) + for info in (FILE_SUBKEY_INFO, JOURNAL_SUBKEY_INFO) + ) + keys = (pair[0], pair[1]) + with _RUN_KEY_LOCK: + _RUN_KEYS[salt] = (passphrase, keys) + while len(_RUN_KEYS) > _RUN_KEY_LIMIT: + _RUN_KEYS.popitem(last=False) + return keys + + +def _seal_salted(aead: AESGCM, salt: bytes, plaintext: bytes, aad: bytes) -> bytes: + nonce = secrets.token_bytes(_crypto.NONCE_LEN) + return ( + _crypto.SALTED_FILE_MAGIC + salt + nonce + aead.encrypt(nonce, plaintext, aad) + ) + + +def _split_salted(container: bytes) -> tuple[bytes, bytes, bytes]: + body = container[len(_crypto.SALTED_FILE_MAGIC) :] + salt_len, nonce_len = _crypto.SALT_LEN, _crypto.NONCE_LEN + salt, nonce = body[:salt_len], body[salt_len : salt_len + nonce_len] + ciphertext = body[salt_len + nonce_len :] + if len(salt) != salt_len or len(nonce) != nonce_len or not ciphertext: + raise AttendedStateUnreadable("a truncated attended container") + return salt, nonce, ciphertext + + +def _open_salted(container: bytes, key: str, aad: bytes, *, journal: bool) -> bytes: + salt, nonce, ciphertext = _split_salted(container) + aead = _run_keys(key, salt)[1 if journal else 0] + try: + return aead.decrypt(nonce, ciphertext, aad) + except InvalidTag as exc: + raise AttendedStateUnreadable( + "attended state does not decrypt: wrong key, or the file was " + "changed or moved from another path" + ) from exc + + +def _read_marker(root: Path) -> tuple[bool, Optional[bytes]]: + """(marker present, its run salt). An older marker has no salt.""" + marker = Path(root) / MARKER_FILENAME + if marker.is_symlink(): + raise AttendedStateUnreadable("the attended-state marker is a symlink") + if not marker.is_file(): + return False, None + data = marker.read_bytes() + if data == MARKER_PAYLOAD: + return True, None + return True, _marker_salt(data) + + +def _marker_salt(data: bytes) -> bytes: + """The run salt in a salted marker payload, or raise.""" + try: + obj = json.loads(data) + if obj.get("schema") != MARKER_SCHEMA_V2: + raise ValueError("unknown marker schema") + salt = base64.b64decode(obj["salt"], validate=True) + except (ValueError, TypeError, KeyError, AttributeError) as exc: + raise AttendedStateUnreadable("the attended-state marker is invalid") from exc + if len(salt) != _crypto.SALT_LEN: + raise AttendedStateUnreadable("the attended-state marker is invalid") + return salt + + +def _marker_payload(salt: bytes) -> bytes: + body = {"schema": MARKER_SCHEMA_V2, "salt": base64.b64encode(salt).decode()} + return json.dumps(body, sort_keys=True, separators=(",", ":")).encode() + b"\n" + + def enc_path(path: Path) -> Path: return path.with_name(path.name + ENC_SUFFIX) -def file_aad(root: Path, path: Path) -> bytes: +def file_aad(root: Path, path: Path, prefix: bytes = FILE_AAD_PREFIX) -> bytes: """Associated data binding a file to its run-relative path.""" try: rel = Path(os.path.abspath(path)).relative_to(os.path.abspath(root)) @@ -89,21 +219,51 @@ def file_aad(root: Path, path: Path) -> bytes: raise AttendedStateUnreadable( "an attended file lies outside its run directory" ) from exc - return FILE_AAD_PREFIX + rel.as_posix().encode("utf-8") + return prefix + rel.as_posix().encode("utf-8") def seal(plaintext: bytes, key: str, aad: bytes) -> bytes: return _crypto.encrypt_bytes(plaintext, key, aad=aad) -def open_sealed(container: bytes, key: Optional[str], aad: bytes) -> bytes: - """Authenticate and decrypt one container, or raise - :class:`AttendedStateUnreadable`.""" +def _require_key(key: Optional[str]) -> str: if not key: raise AttendedStateUnreadable( "attended state is encrypted and no key is configured; set " "OPENADAPT_BUNDLE_KEY or use the key it was written with" ) + return key + + +def open_file(root: Path, path: Path, container: bytes, key: Optional[str]) -> bytes: + """Plaintext of the encrypted form of ``path``, in either container + format, or raise :class:`AttendedStateUnreadable`.""" + key = _require_key(key) + if container.startswith(_crypto.SALTED_FILE_MAGIC): + _refuse_other_run(root, container) + return _open_salted( + container, key, file_aad(root, path, FILE_AAD_PREFIX_V2), journal=False + ) + return open_sealed(container, key, file_aad(root, path)) + + +def _refuse_other_run(root: Path, container: bytes) -> None: + """A salted file belongs to the run whose marker holds its salt. One + copied in from another run under the same key and relative path would + otherwise decrypt, so it is refused, as is a salted file whose run has no + salted marker.""" + _present, run_salt = _read_marker(root) + if run_salt is None or _split_salted(container)[0] != run_salt: + raise AttendedStateUnreadable( + "attended state was not sealed for this run: its salt does not " + "match the run's marker" + ) + + +def open_sealed(container: bytes, key: Optional[str], aad: bytes) -> bytes: + """Authenticate and decrypt one older (per-container scrypt) container, + or raise :class:`AttendedStateUnreadable`.""" + key = _require_key(key) cache_key = (hashlib.sha256(container).digest(), aad) with _CACHE_LOCK: cached = _CACHE.get(cache_key) @@ -143,16 +303,36 @@ def read_bytes(root: Path, path: Path, key: Optional[str]) -> bytes: _refuse_symlink(path) enc = enc_path(path) if enc.is_file(): - return open_sealed(enc.read_bytes(), key, file_aad(root, path)) + return open_file(root, path, enc.read_bytes(), key) if path.is_file(): - if (Path(root) / MARKER_FILENAME).exists(): - raise AttendedStateUnreadable( - "a plaintext attended file stands in for encrypted state" - ) + _refuse_plaintext_in_encrypted_run(root, path) return path.read_bytes() raise FileNotFoundError(str(path)) +def _is_attended_file(root: Path, path: Path) -> bool: + """Whether ``path`` is one of the files a run encrypts at rest.""" + try: + rel = Path(path).relative_to(Path(root)) + except ValueError: + return False + parent = rel.parent.as_posix() + if parent == ".": + return rel.name in CONVERTED_FILES + return parent in CONVERTED_DIRS + + +def _refuse_plaintext_in_encrypted_run(root: Path, path: Path) -> None: + if not _is_attended_file(root, path): + return + if (Path(root) / MARKER_FILENAME).exists() or ( + Path(root) / MARKER_FILENAME + ).is_symlink(): + raise AttendedStateUnreadable( + "a plaintext attended file stands in for encrypted state" + ) + + def digest_bytes(root: Path, path: Path, key: Optional[str]) -> Optional[bytes]: """Plaintext for a state digest, or None when the file is missing. The digest is the same before and after a file is encrypted. A sealed run @@ -161,8 +341,9 @@ def digest_bytes(root: Path, path: Path, key: Optional[str]) -> Optional[bytes]: return path.read_bytes() if path.is_file() else None enc = enc_path(path) if enc.is_file(): - return open_sealed(enc.read_bytes(), key, file_aad(root, path)) + return open_file(root, path, enc.read_bytes(), key) if path.is_file(): + _refuse_plaintext_in_encrypted_run(root, path) return path.read_bytes() return None @@ -199,45 +380,121 @@ def _link_new(path: Path, data: bytes) -> None: tmp.unlink(missing_ok=True) -def _convert_plaintext(root: Path, key: str) -> None: - """Encrypt every plaintext attended file of an older run, then mark the - run. A file whose ``.enc`` form already exists keeps that form: a - concurrent writer may have just replaced it.""" +def _ensure_encrypted(root: Path, key: str) -> bytes: + """Make the run's attended state encrypted and return its run salt. + + A run with no marker gets a new salt: every plaintext attended file of an + older run is encrypted under it, then the marker is created holding it. + A file whose ``.enc`` form already exists keeps that form: a concurrent + writer may have just replaced it. An older marker without a salt is + replaced by one with a salt, chosen once through the upgrade file even + when writers race; its files keep their older containers.""" marker = root / MARKER_FILENAME - if marker.exists(): - return + present, salt = _read_marker(root) + if present and salt is not None: + return salt + if present: + # Two writers can both read the older marker. Each proposes a salt by + # creating the upgrade file without replacement; only the first + # succeeds, and every writer installs that one salt in the marker. + # A writer that picked its own salt and replaced the marker could + # otherwise strand files another writer had just sealed. + upgrade = root / MARKER_UPGRADE_FILENAME + if upgrade.is_symlink(): + raise AttendedStateUnreadable("the attended-state marker is a symlink") + _link_new(upgrade, _marker_payload(_crypto.SetKey.new_salt())) + if upgrade.is_symlink() or not upgrade.is_file(): + raise AttendedStateUnreadable("the attended-state marker is invalid") + payload = upgrade.read_bytes() + chosen = _marker_salt(payload) + tmp = marker.with_name(f".{marker.name}.{secrets.token_hex(8)}.tmp") + _link_new(tmp, payload) + os.replace(tmp, marker) + present, salt = _read_marker(root) + if salt != chosen: + raise AttendedStateUnreadable( + "the attended-state marker changed while it was upgraded" + ) + return chosen + new_salt = _crypto.SetKey.new_salt() + file_key = _run_keys(key, new_salt)[0] candidates = [root / name for name in CONVERTED_FILES] for dirname in CONVERTED_DIRS: directory = root / dirname if directory.is_dir() and not directory.is_symlink(): candidates.extend(directory / name for name in logical_names(directory)) + converted: list[tuple[Path, Path, bytes, bytes]] = [] for path in candidates: if path.is_symlink() or not path.is_file(): continue enc = enc_path(path) if not enc.exists(): - _link_new(enc, seal(path.read_bytes(), key, file_aad(root, path))) + plaintext = path.read_bytes() + container = _seal_salted( + file_key, + new_salt, + plaintext, + file_aad(root, path, FILE_AAD_PREFIX_V2), + ) + _link_new(enc, container) + converted.append((path, enc, plaintext, container)) path.unlink(missing_ok=True) root.mkdir(parents=True, exist_ok=True) - _link_new(marker, MARKER_PAYLOAD) + _link_new(marker, _marker_payload(new_salt)) + # A concurrent first write may have created the marker first; its salt + # is the run's. Files this call sealed under its own salt are sealed + # again under the run's, unless another writer has replaced them since. + present, salt = _read_marker(root) + if salt is None: + raise AttendedStateUnreadable("the attended-state marker is invalid") + if salt != new_salt: + run_key = _run_keys(key, salt)[0] + for path, enc, plaintext, container in converted: + if enc.is_symlink() or not enc.is_file() or enc.read_bytes() != container: + continue + tmp = enc.with_name(f".{enc.name}.{secrets.token_hex(8)}.tmp") + _link_new( + tmp, + _seal_salted( + run_key, salt, plaintext, file_aad(root, path, FILE_AAD_PREFIX_V2) + ), + ) + os.replace(tmp, enc) + return salt def write_bytes( root: Path, path: Path, data: bytes, key: Optional[str], writer: Writer ) -> None: """Write ``path`` encrypted under ``key`` (as ``.enc``), or in plaintext - when no key resolves (the recorded opt-out). ``writer`` performs the - atomic replace.""" + when no key resolves (the recorded opt-out). A run whose attended state is + already encrypted stays encrypted: with no key, the write is refused. + ``writer`` performs the atomic replace.""" _refuse_symlink(path) root = Path(root) if key: - _convert_plaintext(root, key) - writer(enc_path(path), seal(data, key, file_aad(root, path))) + salt = _ensure_encrypted(root, key) + container = _seal_salted( + _run_keys(key, salt)[0], + salt, + data, + file_aad(root, path, FILE_AAD_PREFIX_V2), + ) + writer(enc_path(path), container) path.unlink(missing_ok=True) return + if (root / MARKER_FILENAME).exists() or (root / MARKER_FILENAME).is_symlink(): + raise AttendedStateUnreadable( + "this run's attended state is encrypted and no key is configured; " + "refusing to write it in plaintext (OPENADAPT_FLOW_PLAINTEXT_AT_REST " + "does not downgrade an encrypted run)" + ) + if enc_path(path).exists(): + raise AttendedStateUnreadable( + "an encrypted attended file exists and no key is configured; " + "refusing to replace it with plaintext" + ) writer(path, data) - enc_path(path).unlink(missing_ok=True) - (root / MARKER_FILENAME).unlink(missing_ok=True) def unlink(path: Path) -> None: @@ -248,27 +505,75 @@ def unlink(path: Path) -> None: # -- the external journal ---------------------------------------------------- -def _journal_aad(path_key: str, sequence: int) -> bytes: - return JOURNAL_AAD_PREFIX + f"{path_key}\0{sequence}".encode("utf-8") +def _journal_aad( + path_key: str, sequence: int, prefix: bytes = JOURNAL_AAD_PREFIX +) -> bytes: + return prefix + f"{path_key}\0{sequence}".encode("utf-8") + + +def _journal_salt(run_dir: Optional[Path], path_key: str) -> bytes: + """The run salt from the run's marker when it has one, else one salt per + run path for this process. Reading the marker never writes the run.""" + if run_dir is not None: + _present, salt = _read_marker(Path(run_dir)) + if salt is not None: + return salt + with _RUN_KEY_LOCK: + salt = _JOURNAL_SALTS.get(path_key) + if salt is None: + salt = _crypto.SetKey.new_salt() + _JOURNAL_SALTS[path_key] = salt + return salt def seal_journal_snapshot( - snapshot_json: str, key: Optional[str], *, path_key: str, sequence: int + snapshot_json: str, + key: Optional[str], + *, + path_key: str, + sequence: int, + run_dir: Optional[Path] = None, ) -> str: """The stored form of one snapshot: encrypted and marked when a key is set, else the plaintext (the recorded opt-out).""" if not key: return snapshot_json - container = seal( - snapshot_json.encode("utf-8"), key, _journal_aad(path_key, sequence) + salt = _journal_salt(run_dir, path_key) + container = _seal_salted( + _run_keys(key, salt)[1], + salt, + snapshot_json.encode("utf-8"), + _journal_aad(path_key, sequence, JOURNAL_AAD_PREFIX_V2), ) - return JOURNAL_MARKER + container.decode("utf-8") + return JOURNAL_MARKER_V2 + base64.b64encode(container).decode("ascii") + + +def is_sealed_journal_snapshot(stored: str) -> bool: + """Whether a stored snapshot is encrypted (either format).""" + return stored.startswith((JOURNAL_MARKER, JOURNAL_MARKER_V2)) def open_journal_snapshot( stored: str, key: Optional[str], *, path_key: str, sequence: int ) -> str: """The plaintext of one stored snapshot (older rows are plaintext).""" + if stored.startswith(JOURNAL_MARKER_V2): + try: + container = base64.b64decode( + stored[len(JOURNAL_MARKER_V2) :], validate=True + ) + except ValueError as exc: + raise AttendedStateUnreadable( + "an attended journal row is malformed" + ) from exc + if not container.startswith(_crypto.SALTED_FILE_MAGIC): + raise AttendedStateUnreadable("an attended journal row is malformed") + return _open_salted( + container, + _require_key(key), + _journal_aad(path_key, sequence, JOURNAL_AAD_PREFIX_V2), + journal=True, + ).decode("utf-8") if not stored.startswith(JOURNAL_MARKER): return stored container = stored[len(JOURNAL_MARKER) :].encode("utf-8") diff --git a/packages/flow/openadapt_flow/runtime/durable/authority.py b/packages/flow/openadapt_flow/runtime/durable/authority.py index be9b7e7bb9..75f07adeb4 100644 --- a/packages/flow/openadapt_flow/runtime/durable/authority.py +++ b/packages/flow/openadapt_flow/runtime/durable/authority.py @@ -1060,13 +1060,13 @@ def _validated_journal_head( ) -> tuple[Optional[str], str]: """Verify the full external hash chain and return its latest snapshot. - A snapshot stored encrypted (``attended_state.JOURNAL_MARKER``) is + A snapshot stored encrypted (``attended_state.is_sealed_journal_snapshot``) is decrypted under ``key``; an older plaintext row reads as it is. """ from openadapt_flow.runtime.durable import attended_state as _state - snapshot_key = key # ``key`` is reused below for the journal HMAC key + snapshot_key = key rows = connection.execute( "SELECT * FROM attended_journal WHERE path_key = ? ORDER BY sequence", @@ -1081,7 +1081,7 @@ def _validated_journal_head( "the external attended-decision journal tail was restored" ) return None, JOURNAL_GENESIS_DIGEST - key = self._journal_key(create=False) + mac_key = self._journal_key(create=False) previous = JOURNAL_GENESIS_DIGEST latest_snapshot: Optional[str] = None for expected_sequence, row in enumerate(rows, start=1): @@ -1127,7 +1127,7 @@ def _validated_journal_head( record_mac = ( "hmac-sha256:" + hmac.new( - key, + mac_key, JOURNAL_MAC_DOMAIN + record_digest.encode("ascii"), hashlib.sha256, ).hexdigest() @@ -1271,7 +1271,7 @@ def append_attended_snapshot( from openadapt_flow.runtime.durable import attended_state as _state - snapshot_key = key # ``key`` is reused below for the journal HMAC key + snapshot_key = key # Parse before the transaction so invalid JSON never enters authority. self._validate_snapshot_extension(None, snapshot_json) @@ -1309,11 +1309,11 @@ def append_attended_snapshot( record_digest = ( "sha256:" + hashlib.sha256(self._canonical(unsigned)).hexdigest() ) - key = self._journal_key(create=True) + mac_key = self._journal_key(create=True) record_mac = ( "hmac-sha256:" + hmac.new( - key, + mac_key, JOURNAL_MAC_DOMAIN + record_digest.encode("ascii"), hashlib.sha256, ).hexdigest() @@ -1337,6 +1337,7 @@ def append_attended_snapshot( snapshot_key, path_key=self.path_key, sequence=sequence, + run_dir=self.run_dir, ), record_digest, record_mac, diff --git a/packages/flow/openadapt_flow/runtime/replayer.py b/packages/flow/openadapt_flow/runtime/replayer.py index 9b1f4d31e0..8f515dd741 100644 --- a/packages/flow/openadapt_flow/runtime/replayer.py +++ b/packages/flow/openadapt_flow/runtime/replayer.py @@ -3141,6 +3141,7 @@ def _stamp_execution_outcome( runtime_worklists=self._outcome_worklists, transition_evidence_root=run_dir, transition_predicate_vision=self.vision, + transition_evidence_key=self.checkpoint_key, ) def _begin_control_overlay(self, profile: Optional[str]) -> None: diff --git a/packages/flow/tests/test_attended_at_rest.py b/packages/flow/tests/test_attended_at_rest.py index ac5171bf5f..458528f753 100644 --- a/packages/flow/tests/test_attended_at_rest.py +++ b/packages/flow/tests/test_attended_at_rest.py @@ -24,6 +24,7 @@ from pathlib import Path import pytest +from cryptography.exceptions import InvalidTag from openadapt_flow import at_rest, crypto from openadapt_flow.runtime.durable import attended_state @@ -147,7 +148,9 @@ def test_default_mode_attended_run_leaves_no_plaintext_attended_state( assert (run / attended_state.MARKER_FILENAME).is_file() rows = _journal_rows() - assert rows and all(row.startswith(attended_state.JOURNAL_MARKER) for row in rows) + assert rows and all( + row.startswith(attended_state.JOURNAL_MARKER_V2) for row in rows + ) journal = _journal_bytes() assert all(marker not in journal for marker in markers) @@ -218,7 +221,7 @@ def test_older_plaintext_attended_run_still_resumes(tmp_path, monkeypatch): assert (run / "attended_capability.json").is_file() assert (run / "attended_decisions.json").is_file() assert not any( - row.startswith(attended_state.JOURNAL_MARKER) for row in _journal_rows() + attended_state.is_sealed_journal_snapshot(row) for row in _journal_rows() ) monkeypatch.delenv("OPENADAPT_FLOW_PLAINTEXT_AT_REST") @@ -234,8 +237,8 @@ def test_older_plaintext_attended_run_still_resumes(tmp_path, monkeypatch): assert not (run / name).exists(), name assert (run / "attended_decisions.json.enc").is_file() rows = _journal_rows() - assert not rows[0].startswith(attended_state.JOURNAL_MARKER) - assert rows[-1].startswith(attended_state.JOURNAL_MARKER) + assert not attended_state.is_sealed_journal_snapshot(rows[0]) + assert rows[-1].startswith(attended_state.JOURNAL_MARKER_V2) log = AttendedActionStore(run)._read_log() operators = [item.operator for item in log.decisions] assert operators[0] == "earlier operator" and operators[-1] == OPERATOR @@ -275,3 +278,389 @@ def test_encrypted_file_moved_to_another_path_or_replaced_is_refused( capability_enc.write_bytes(original) (run / "attended_capability.json").unlink() assert AttendedActionStore(run).read() == capability + + +# -- one key derivation per run, and no downgrade (real filesystem) ---------- + +SYNTHETIC_KEY = "synthetic-attended-test-passphrase" + + +def _write(path: Path, data: bytes) -> None: + tmp = path.with_name(f".{path.name}.tmp") + tmp.write_bytes(data) + os.replace(tmp, path) + + +@pytest.fixture +def counted_scrypt(monkeypatch): + """Count every scrypt run, with the per-run key cache cleared.""" + attended_state._RUN_KEYS.clear() + attended_state._JOURNAL_SALTS.clear() + attended_state._CACHE.clear() + calls: list[int] = [] + real = crypto._derive + + def counting(*args, **kwargs): + calls.append(1) + return real(*args, **kwargs) + + monkeypatch.setattr(crypto, "_derive", counting) + yield calls + attended_state._RUN_KEYS.clear() + attended_state._JOURNAL_SALTS.clear() + attended_state._CACHE.clear() + + +def test_scrypt_runs_once_per_run_not_per_file_or_row(tmp_path, counted_scrypt): + root = tmp_path / "run" + receipts = root / ".attended_program_receipts" + receipts.mkdir(parents=True) + paths = [receipts / f"pause-{index}.json" for index in range(30)] + for index, path in enumerate(paths): + attended_state.write_bytes( + root, path, f'{{"n":{index}}}'.encode(), SYNTHETIC_KEY, _write + ) + rows = [ + attended_state.seal_journal_snapshot( + f'{{"seq":{seq}}}', + SYNTHETIC_KEY, + path_key="synthetic-run", + sequence=seq, + run_dir=root, + ) + for seq in range(30) + ] + for _ in range(3): + for index, path in enumerate(paths): + assert attended_state.read_bytes(root, path, SYNTHETIC_KEY) == ( + f'{{"n":{index}}}'.encode() + ) + assert attended_state.digest_bytes(root, path, SYNTHETIC_KEY) == ( + f'{{"n":{index}}}'.encode() + ) + for seq, row in enumerate(rows): + assert row.startswith(attended_state.JOURNAL_MARKER_V2) + assert ( + attended_state.open_journal_snapshot( + row, SYNTHETIC_KEY, path_key="synthetic-run", sequence=seq + ) + == f'{{"seq":{seq}}}' + ) + assert len(counted_scrypt) == 1 + for path in paths: + container = attended_state.enc_path(path).read_bytes() + assert container.startswith(crypto.SALTED_FILE_MAGIC) + assert crypto.is_encrypted(container) + assert not path.exists() + + +def test_salted_file_is_bound_to_its_path(tmp_path, counted_scrypt): + root = tmp_path / "run" + receipts = root / ".attended_program_receipts" + receipts.mkdir(parents=True) + first, second = receipts / "a.json", receipts / "b.json" + attended_state.write_bytes(root, first, b'{"a":1}', SYNTHETIC_KEY, _write) + attended_state.write_bytes(root, second, b'{"b":2}', SYNTHETIC_KEY, _write) + shutil.copyfile(attended_state.enc_path(first), attended_state.enc_path(second)) + with pytest.raises(attended_state.AttendedStateUnreadable): + attended_state.read_bytes(root, second, SYNTHETIC_KEY) + with pytest.raises(attended_state.AttendedStateUnreadable): + attended_state.read_bytes(root, first, "a-different-synthetic-key") + + +def test_salted_journal_row_is_bound_to_path_and_sequence(counted_scrypt): + row = attended_state.seal_journal_snapshot( + '{"seq":1}', SYNTHETIC_KEY, path_key="synthetic-run", sequence=1 + ) + with pytest.raises(attended_state.AttendedStateUnreadable): + attended_state.open_journal_snapshot( + row, SYNTHETIC_KEY, path_key="synthetic-run", sequence=2 + ) + with pytest.raises(attended_state.AttendedStateUnreadable): + attended_state.open_journal_snapshot( + row, SYNTHETIC_KEY, path_key="other-run", sequence=1 + ) + with pytest.raises(attended_state.AttendedStateUnreadable): + attended_state.open_journal_snapshot( + row, None, path_key="synthetic-run", sequence=1 + ) + + +def test_opt_out_write_does_not_downgrade_an_encrypted_run(tmp_path): + root = tmp_path / "run" + root.mkdir() + path = root / "attended_decisions.json" + attended_state.write_bytes(root, path, b'{"v":1}', SYNTHETIC_KEY, _write) + marker = root / attended_state.MARKER_FILENAME + assert marker.is_file() + with pytest.raises(attended_state.AttendedStateUnreadable, match="encrypted"): + attended_state.write_bytes(root, path, b'{"v":2}', None, _write) + other = root / "attended_capability.json" + with pytest.raises(attended_state.AttendedStateUnreadable, match="encrypted"): + attended_state.write_bytes(root, other, b'{"v":2}', None, _write) + assert marker.is_file() + assert attended_state.enc_path(path).is_file() + assert not path.exists() and not other.exists() + assert attended_state.read_bytes(root, path, SYNTHETIC_KEY) == b'{"v":1}' + + +def test_plaintext_stand_in_is_refused_by_read_and_digest(tmp_path): + root = tmp_path / "run" + receipts = root / ".attended_program_receipts" + receipts.mkdir(parents=True) + path = receipts / "pause.json" + attended_state.write_bytes(root, path, b'{"v":1}', SYNTHETIC_KEY, _write) + attended_state.enc_path(path).unlink() + path.write_bytes(b'{"v":"forged"}') + with pytest.raises(attended_state.AttendedStateUnreadable): + attended_state.read_bytes(root, path, SYNTHETIC_KEY) + with pytest.raises(attended_state.AttendedStateUnreadable): + attended_state.digest_bytes(root, path, SYNTHETIC_KEY) + # A file the run never encrypts (the run claim) is still read as is. + claim = root / ".durable_run.claim" + claim.write_bytes(b"synthetic-claim") + assert attended_state.digest_bytes(root, claim, SYNTHETIC_KEY) == ( + b"synthetic-claim" + ) + + +def test_older_containers_rows_and_marker_still_read(tmp_path, counted_scrypt): + """A run encrypted by the older per-container format still reads, and its + next write moves the marker to the salted format without rewriting the + older files.""" + root = tmp_path / "run" + receipts = root / ".attended_program_receipts" + receipts.mkdir(parents=True) + old = receipts / "old.json" + attended_state.enc_path(old).write_bytes( + attended_state.seal( + b'{"old":true}', SYNTHETIC_KEY, attended_state.file_aad(root, old) + ) + ) + marker = root / attended_state.MARKER_FILENAME + marker.write_bytes(attended_state.MARKER_PAYLOAD) + old_row = attended_state.JOURNAL_MARKER + attended_state.seal( + b'{"seq":0}', + SYNTHETIC_KEY, + attended_state._journal_aad("synthetic-run", 0), + ).decode("utf-8") + + assert attended_state.read_bytes(root, old, SYNTHETIC_KEY) == b'{"old":true}' + assert attended_state.is_sealed_journal_snapshot(old_row) + assert ( + attended_state.open_journal_snapshot( + old_row, SYNTHETIC_KEY, path_key="synthetic-run", sequence=0 + ) + == '{"seq":0}' + ) + + new = receipts / "new.json" + attended_state.write_bytes(root, new, b'{"new":true}', SYNTHETIC_KEY, _write) + present, salt = attended_state._read_marker(root) + assert present and salt is not None + assert ( + attended_state.enc_path(new).read_bytes().startswith(crypto.SALTED_FILE_MAGIC) + ) + assert ( + not attended_state.enc_path(old) + .read_bytes() + .startswith(crypto.SALTED_FILE_MAGIC) + ) + assert attended_state.read_bytes(root, old, SYNTHETIC_KEY) == b'{"old":true}' + assert attended_state.read_bytes(root, new, SYNTHETIC_KEY) == b'{"new":true}' + + +def test_older_plaintext_run_converts_under_one_salt(tmp_path, counted_scrypt): + root = tmp_path / "run" + receipts = root / ".attended_program_receipts" + receipts.mkdir(parents=True) + (root / "attended_decisions.json").write_bytes(b'{"decisions":[]}') + for index in range(10): + (receipts / f"p{index}.json").write_bytes(f'{{"p":{index}}}'.encode()) + attended_state.write_bytes( + root, root / "attended_capability.json", b"{}", SYNTHETIC_KEY, _write + ) + assert not (root / "attended_decisions.json").exists() + for index in range(10): + path = receipts / f"p{index}.json" + assert not path.exists() + assert attended_state.read_bytes(root, path, SYNTHETIC_KEY) == ( + f'{{"p":{index}}}'.encode() + ) + assert len(counted_scrypt) == 1 + + +def test_salted_file_from_another_run_is_refused(tmp_path, counted_scrypt): + """Same key and relative path, but another run's salt: refused.""" + run_a, run_b = tmp_path / "a", tmp_path / "b" + name = ".attended_program_receipts/r.json" + for root, body in ((run_a, b'{"run":"a"}'), (run_b, b'{"run":"b"}')): + (root / ".attended_program_receipts").mkdir(parents=True) + attended_state.write_bytes(root, root / name, body, SYNTHETIC_KEY, _write) + shutil.copyfile( + attended_state.enc_path(run_a / name), attended_state.enc_path(run_b / name) + ) + with pytest.raises(attended_state.AttendedStateUnreadable, match="this run"): + attended_state.read_bytes(run_b, run_b / name, SYNTHETIC_KEY) + with pytest.raises(attended_state.AttendedStateUnreadable, match="this run"): + attended_state.digest_bytes(run_b, run_b / name, SYNTHETIC_KEY) + assert attended_state.read_bytes(run_a, run_a / name, SYNTHETIC_KEY) == ( + b'{"run":"a"}' + ) + # A salted file whose run lost its marker is refused too. + (run_a / attended_state.MARKER_FILENAME).unlink() + with pytest.raises(attended_state.AttendedStateUnreadable, match="this run"): + attended_state.read_bytes(run_a, run_a / name, SYNTHETIC_KEY) + + +def test_conversion_that_loses_the_marker_race_reseals_under_the_run_salt( + tmp_path, counted_scrypt, monkeypatch +): + root = tmp_path / "run" + receipts = root / ".attended_program_receipts" + receipts.mkdir(parents=True) + old = receipts / "old.json" + old.write_bytes(b'{"older":"plaintext"}') + winner_salt = crypto.SetKey.new_salt() + original = attended_state._link_new + marker = root / attended_state.MARKER_FILENAME + + def link_new(path, data): + # Another writer creates the marker just before this one does. + if path == marker and not marker.exists(): + original(path, attended_state._marker_payload(winner_salt)) + original(path, data) + + monkeypatch.setattr(attended_state, "_link_new", link_new) + attended_state.write_bytes( + root, receipts / "new.json", b'{"new":1}', SYNTHETIC_KEY, _write + ) + assert attended_state._read_marker(root) == (True, winner_salt) + assert attended_state.read_bytes(root, old, SYNTHETIC_KEY) == ( + b'{"older":"plaintext"}' + ) + assert attended_state.read_bytes(root, receipts / "new.json", SYNTHETIC_KEY) == ( + b'{"new":1}' + ) + + +def test_concurrent_upgrade_of_an_older_marker_keeps_one_salt( + tmp_path, counted_scrypt, monkeypatch +): + """Two writers both read the older marker. The slower one finishes its + upgrade after the faster one has already sealed a file; both files must + still read, under the one salt the marker ends with.""" + root = tmp_path / "run" + receipts = root / ".attended_program_receipts" + receipts.mkdir(parents=True) + marker = root / attended_state.MARKER_FILENAME + marker.write_bytes(attended_state.MARKER_PAYLOAD) + first = receipts / "first.json" + second = receipts / "second.json" + + attended_state.write_bytes(root, first, b'{"writer":"a"}', SYNTHETIC_KEY, _write) + present, salt_after_first = attended_state._read_marker(root) + assert present and salt_after_first is not None + + # The second writer read the older marker before the first one upgraded it. + original = attended_state._read_marker + stale = {"pending": True} + + def read_marker(run_root): + if stale.pop("pending", False): + return True, None + return original(run_root) + + monkeypatch.setattr(attended_state, "_read_marker", read_marker) + attended_state.write_bytes(root, second, b'{"writer":"b"}', SYNTHETIC_KEY, _write) + monkeypatch.setattr(attended_state, "_read_marker", original) + + assert not stale + assert attended_state._read_marker(root) == (True, salt_after_first) + assert attended_state.read_bytes(root, first, SYNTHETIC_KEY) == b'{"writer":"a"}' + assert attended_state.read_bytes(root, second, SYNTHETIC_KEY) == (b'{"writer":"b"}') + + +def test_racing_writers_on_an_older_marker_both_stay_readable( + tmp_path, counted_scrypt, monkeypatch +): + """Two threads read the older marker at the same moment, then both upgrade + it and seal a file. Every file reads afterwards.""" + import threading + + root = tmp_path / "run" + receipts = root / ".attended_program_receipts" + receipts.mkdir(parents=True) + (root / attended_state.MARKER_FILENAME).write_bytes(attended_state.MARKER_PAYLOAD) + original = attended_state._read_marker + barrier = threading.Barrier(2, timeout=30) + seen: set[int] = set() + + def read_marker(run_root): + result = original(run_root) + ident = threading.get_ident() + if ident not in seen: + seen.add(ident) + barrier.wait() + return result + + monkeypatch.setattr(attended_state, "_read_marker", read_marker) + errors: list[BaseException] = [] + + def writer(name): + try: + attended_state.write_bytes( + root, receipts / f"{name}.json", name.encode(), SYNTHETIC_KEY, _write + ) + except BaseException as exc: # noqa: BLE001 - surfaced below + errors.append(exc) + + threads = [threading.Thread(target=writer, args=(n,)) for n in ("a", "b")] + for thread in threads: + thread.start() + for thread in threads: + thread.join(60) + monkeypatch.setattr(attended_state, "_read_marker", original) + + assert not errors + present, salt = attended_state._read_marker(root) + assert present and salt is not None + for name in ("a", "b"): + path = receipts / f"{name}.json" + assert attended_state.read_bytes(root, path, SYNTHETIC_KEY) == name.encode() + + +def test_replayer_classifies_with_its_own_checkpoint_key(monkeypatch, tmp_path): + """The outcome reader opens attended evidence with the key the run sealed + it under, not only the environment's key.""" + from types import SimpleNamespace + + from openadapt_flow import execution_profiles + + seen: dict = {} + + def stamp(report, workflow, profile, **kwargs): + seen.update(kwargs) + + monkeypatch.setattr(execution_profiles, "stamp_execution_outcome", stamp) + replayer = Replayer(FakeBackend(), vision=FakeVision(), checkpoint_key="k-run") + replayer._outcome_worklists = None + replayer._stamp_execution_outcome( + SimpleNamespace(execution_profile="standard"), object(), tmp_path + ) + assert seen["transition_evidence_key"] == "k-run" + assert seen["transition_evidence_root"] == tmp_path + + +def test_run_key_cache_never_reuses_a_key_for_another_passphrase(counted_scrypt): + salt = b"\x07" * crypto.SALT_LEN + first = attended_state._run_keys("passphrase-one", salt) + assert attended_state._run_keys("passphrase-one", salt) is first + assert len(counted_scrypt) == 1 + other = attended_state._run_keys("passphrase-two", salt) + assert other is not first + assert len(counted_scrypt) == 2 + nonce = b"\x00" * crypto.NONCE_LEN + sealed = first[0].encrypt(nonce, b"attended", b"aad") + with pytest.raises(InvalidTag): + other[0].decrypt(nonce, sealed, b"aad") From 1d7a3aa1267aeedb42e054b79ffb80b3ada624f0 Mon Sep 17 00:00:00 2001 From: abrichr Date: Wed, 4 Nov 2026 03:32:26 -0500 Subject: [PATCH 20/21] fix: teach and push seal plain input themselves under strict mode Main's strict gate requires encrypted input for sealed-ready commands that do not seal their own input. teach and push do seal it under strict mode (their strict tests check no plaintext is left), so they join the self-sealing set. sanitize stays gated: under strict it still needs encrypted input and its output check still refuses. --- packages/flow/openadapt_flow/__main__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/flow/openadapt_flow/__main__.py b/packages/flow/openadapt_flow/__main__.py index 2f4664e935..68ad0f2a4e 100644 --- a/packages/flow/openadapt_flow/__main__.py +++ b/packages/flow/openadapt_flow/__main__.py @@ -13205,7 +13205,8 @@ def _qualify_default_form(argv: list[str]) -> list[str]: #: Sealed-ready commands that seal plaintext input themselves under strict -#: mode (a recording, a compiled, induced or admitted bundle, a run directory). +#: mode (a recording, a compiled, induced or admitted bundle, a run directory, +#: the taught bundle and skill library, the sanitized review copy of a push). _STRICT_SELF_SEALING: frozenset[str] = frozenset( { "openadapt_flow.__main__:_cmd_record", @@ -13215,6 +13216,8 @@ 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_teach", + "openadapt_flow.__main__:_cmd_push", } ) From 6bd36bfbcd34415fccbf45fd07dd89045874ed5f Mon Sep 17 00:00:00 2001 From: abrichr Date: Wed, 4 Nov 2026 04:25:23 -0500 Subject: [PATCH 21/21] Restore main's CAPABILITIES rows lost in the main merge The merge of main took this branch's CAPABILITIES.md whole, which dropped rows and the teach-authorization command main had added. Take main's file and keep only this PR's change to the encryption-at-rest row. --- CAPABILITIES.md | 59 +++++++++++++++++++++++++++++++------------------ llms-full.txt | 59 +++++++++++++++++++++++++++++++------------------ 2 files changed, 76 insertions(+), 42 deletions(-) diff --git a/CAPABILITIES.md b/CAPABILITIES.md index fd7b311346..6068a996cf 100644 --- a/CAPABILITIES.md +++ b/CAPABILITIES.md @@ -2,7 +2,7 @@ OpenAdapt turns work that people or computer-use agents do in applications with no usable API (EMRs, payer portals, desktop and remote-desktop apps) into -programs that make the same entry every time with no model calls, check that +programs that make the same entry every time with no calls to a hosted AI model, check that the entry landed in the right record, and stop instead of guessing. This page indexes what OpenAdapt implements, with the status, the evidence and @@ -28,8 +28,9 @@ monorepo and installable from source. 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`, on main; the agent can act through code - instead of screenshots, [#1305](https://github.com/OpenAdaptAI/OpenAdapt/pull/1305)). Agents can drive the recording + prompt-to-program `compile-agent`, on main; the agent acts through code by + default, not screenshots, and every save the gate detects in its run is read + back, [#1305](https://github.com/OpenAdaptAI/OpenAdapt/pull/1305)). Agents can drive the recording through the web, native Windows, RDP and Citrix backends. An agent connected over MCP can teach itself a new workflow and then call it as a tool (`openadapt-agent serve --teach`, @@ -69,9 +70,15 @@ monorepo and installable from source. Person-signed grants let named agents answer. Released. 8. **Repairs itself, with a person approving.** Governed repair (propose, review, campaign, canary, rollback), agent-demonstrated fixes for a halted - run, and fleet drift patches shared with no patient data are released. + run, and fleet drift patches shared with no patient data (moved as files + or through a local reference hub) are released. Self-repair, where an agent proposes a fix and Flow probes it up to the save without saving, is on main, and so are fixes of up to three new steps. + Self-repair is qualified on browser runs ([benchmark/auto_repair](packages/flow/benchmark/auto_repair/README.md)) + and end to end on native macOS AX ([#1715](https://github.com/OpenAdaptAI/OpenAdapt/pull/1715)). Outside a browser it + runs with `repair.session` ([#1717](https://github.com/OpenAdaptAI/OpenAdapt/pull/1717)), and native and pixel backends + admit its new clicks by element identity ([#1709](https://github.com/OpenAdaptAI/OpenAdapt/pull/1709)). Teaching the fix + for a halted run (`--hold-on-halt`) and rollback are tested on browser runs. 9. **Is a safety layer underneath any agent.** An agent's MCP calls, a task ledger that keeps a long job's rules, items, questions and checked saves on the server ([#1332](https://github.com/OpenAdaptAI/OpenAdapt/pull/1332)), a guarded save around any agent's write @@ -80,8 +87,8 @@ monorepo and installable from source. the agent ([#1357](https://github.com/OpenAdaptAI/OpenAdapt/pull/1357)), `verify-run` to judge any executor's write from the system of record, and Agent Skills and a CLI for coding agents. Released except where a PR is named. -10. **Runs locally and keeps data private.** Healthy runs make no model calls - and send nothing out. Personal and health data are redacted from text, +10. **Runs locally and keeps data private.** Healthy runs make no calls to a + hosted AI model and send nothing out. Personal and health data are redacted from text, screenshots and structure (scrub-by-default for anything that leaves the machine is on main); a strict no-outbound mode, an unattended on-prem service, and signed, timestamped receipts are released. @@ -101,12 +108,12 @@ monorepo and installable from source. | Repairs itself when the app changes: governed repair, agent-demonstrated fixes, fleet patches, and self-repair probed up to the save. | [REPAIR_LIFECYCLE.md](packages/flow/docs/REPAIR_LIFECYCLE.md) | | Repairs a change that needs new steps, such as a new required dialog or a control moved into a menu: up to three new steps, each with its own record check, probed up to the save before one person approves. | [SELF_REPAIR.md](packages/flow/docs/SELF_REPAIR.md), [#1325](https://github.com/OpenAdaptAI/OpenAdapt/pull/1325) | | Compiles a computer-use agent's verified run, with no human demonstration needed. | [JIT.md](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. | `CODE_AS_ACTION.md` in [#1305](https://github.com/OpenAdaptAI/OpenAdapt/pull/1305) | +| The agent that does a task once acts through code by default, 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. Every save the save gate detects is read back from the system of record before the agent goes on. Scoreboard arm 7, MockMed items 21 to 40: 18 of 18 normal items correct at $0.0093 an item; the wrong-patient save landed and the read-back then stopped the run; the scoreboard counts 1 silent wrong save by its word rule, a safe stop on hand review ([HAND_REVIEW.md](packages/flow/benchmark/scoreboard/raw/mockmed/code_agent/2026-10-25-rb/HAND_REVIEW.md)). The program compiled from it: 18 of 18 at $0. | `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](packages/flow/docs/APPROVAL_MODES.md) | | Has a written threat model for prompt injection against the agent that teaches or repairs a program, with tests. | [THREAT_MODEL.md](packages/flow/docs/THREAT_MODEL.md) | | Fills inputs from a referral PDF, fax image or email, and holds back anything unclear for a person. | `intake`, [INTAKE.md](packages/flow/docs/INTAKE.md) | | Ships as one monorepo with separately installable packages (flow, agent, types, capture, privacy, desktop and tray), each released from the monorepo. | [README](README.md) | -| Makes no model calls on healthy runs. Model training (openadapt-ml) is separate research. | [README](README.md) | +| Makes no calls to a hosted AI model on healthy runs. Model training (openadapt-ml) is separate research. | [README](README.md) | ## The full map @@ -137,6 +144,9 @@ 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](packages/flow/README.md) | | Classify common local edits without a person (select-all, Enter in a field or dropdown, an in-form tab, an informational dialog's dismiss), only when the recording's network capture shows no write and a runtime check confirms only the expected change | On main ([#1366](https://github.com/OpenAdaptAI/OpenAdapt/pull/1366)) | Synthetic; OpenEMR referral recording re-admitted offline (violations 12 to 1 under `permissive`) | [LOCAL_EDIT_CLASSIFICATION.md](packages/flow/docs/design/LOCAL_EDIT_CLASSIFICATION.md), `compiler/local_edit.py` | +| Keep the empty search result a person confirmed through (a duplicate check's "No matches were found.") as an exact check after the click that opens the dialog and again right before Confirm, so a run whose dialog lists a match stops before the save; only fixed-vocabulary search-result statements shown once, never text with a name or search term, and a confirmation dialog without one is noted in `compile_notes.json` | On main | Real app frames (an OpenEMR referral recording, compiled offline) | `compiler/empty_result_guard.py` | +| Mine that check in any language: induction keeps the one line a named dialog adds (not its title or buttons, not a question, no typed value or number) when two or more demonstrations whose typed values all differ show the same line, refuses when the demonstrations disagree on a mined check instead of copying the first one's, and qualification then needs a business-rule run that halts at it | On main | Synthetic (a search dialog recorded in Chromium by the real recorder, German statement, real OCR) | `compiler/empty_result_guard.py`, `compiler/induction.py` | +| Qualify against a rule the app itself enforces: verify.yaml `business_rules` seed the conflicting record (an existing chart for the same person, checked in the read-back), and the certified bundle must stop at the rule's check before the save with nothing written on every trial, or certification is withdrawn | On main | Unit tests; the OpenEMR fixture's verify.yaml declares the existing-duplicate rule | [QUALIFY_A_WORKFLOW.md](packages/flow/docs/QUALIFY_A_WORKFLOW.md) | | Right-record check before any write (structure, then OCR, then pixels) | Released | Real app and synthetic | [RESOLUTION_LADDER.md](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](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/` | @@ -148,6 +158,7 @@ improvising or retrying when something is off. | Generate an ensure-record contract from three demonstrations, the server's CapabilityStatement and its SearchParameter definitions: field paths and `[system=…]` filters, comparison kinds, value maps, identity, search parameters, the closed record and the change feed. It refuses when unsure, asks a person to confirm an identity for a type with no reviewed template, and self-checks every demonstration plus a perturbation per field | On main ([#1653](https://github.com/OpenAdaptAI/OpenAdapt/pull/1653)) | Real app (Medplum 5.2.8: all 16 hand-written Patient lines regenerated; one blind write type, Immunization: 78 s of person time, 2 lines edited after the person declined the proposed identity; blind suite 23/24, no fault passed) | `openadapt_flow/generate/ensure_contract.py`, [RESULTS.md](packages/flow/benchmark/contract_autogen/results/RESULTS.md) | | A stop card for a stopped write: the exact record difference and who else wrote, with Undo or Fix that reads back, or Open in the app; cost modelled from published operators and timed machine steps, as pre-registered | On main ([#1653](https://github.com/OpenAdaptAI/OpenAdapt/pull/1653)) | Real app (Medplum 5.2.8, blind suite: 16 stops, 14 one-tap with no failed read-back; modelled 42 s per caught harm against 420; one wrong category on a false stop, gone in the re-measure with `void`: 24/24, 15 stops, no wrong category, no failed read-back) | `benchmark/stop_card/`, [PREREG_REVIEW.md](packages/flow/benchmark/stop_card/PREREG_REVIEW.md) | | Generate `openadapt.verify/v1` contracts from a MySQL, MariaDB or PostgreSQL schema, the kinds of write with their parameter names, and one demonstration write per example: one contract per field set (an update gets one for each set of fields it may change), and `choose` builds any other set of the kind's fields the same way from the same pinned inputs, list parameters by length, a `found` twin for a record already in place, the whole-database frame on every dialect (it compares the contract's own tables too, so an excused column holds only on the rows the write declares, and the recheck sees a late change to a look-alike row), rows the app adds on its own declared by count, a recheck window the person confirms, and `frame.versions` (a lost update) from a version column or the app's audit trail of saves. Equal ids are never taken for a join (a row's own key, the column a path arrived by), a join only equal ids support is a question when a supported place also fits, and a pin needs two rows that agree. Every undecided point is one numbered question; every contract is loaded and dry-read against the live store before it is written; a needed `TRIGGER` grant or view is applied with a flag or the kind is refused. Not yet: OpenAPI metadata | [#1672](https://github.com/OpenAdaptAI/OpenAdapt/pull/1672) | Real app (first study, instance copies; questions for a person: Dolibarr 4, Redmine 2, EspoCRM 3, against 11 hand decisions each; every in-scope planted fault stopped (22/22, 19/19, 21/21); correct writes confirmed 32/43, 32/38, 27/40; evidence in the private study repository) | `python -m openadapt_flow.generate.verify_contract`, [GENERATE_CONTRACTS.md](packages/flow/docs/GENERATE_CONTRACTS.md) | +| Set up generated contracts with no person answers: answer the generator's questions by fixed rules (its own bookkeeping proposal with time stamps checked against the clock, nothing added), validate them in a sandbox copy of the store beside the app's own (a database or schema the setup makes and drops) by replaying held-out correct writes and planting faults built from each write's recorded diff, never from the contract (missing, wrong value, wrong or look-alike record, duplicate, unrelated edit, a row in another table, a delayed change, a changed bookkeeping cell), and admit a kind only when every held-out write is confirmed and every fault stopped; a kind not admitted has no contract and is listed in `EXCEPTIONS.md`. Not yet: log tables, counters and derived columns, learned excuses, value maps | (this PR) | Unit (real PostgreSQL: one kind admitted with every planted fault stopped, one listed as an exception; a control without the clock check lets the time-stamp fault through). Development apps pending | `python -m openadapt_flow.generate.zero_touch`, [ZERO_TOUCH_DESIGN.md](packages/flow/docs/research/ZERO_TOUCH_DESIGN.md) | | `void` in an ensure-record contract: values that make a record assert nothing (`status="entered-in-error"`). A record holding one is never the person, is not a duplicate, and a created one needs review; it names values to leave out, never values to keep, and can't name an identity field | On main ([#1657](https://github.com/OpenAdaptAI/OpenAdapt/pull/1657)) | Real app (Medplum 5.2.8: 8 live cases written in advance, 8/8 with the line, 5/8 and one silent pass without it; frozen blind Immunization suite 24/24); adversarial review recorded | `openadapt_flow/ensure_record.py`, [RESULTS.md](packages/flow/benchmark/fhir_medplum/void/results/RESULTS.md), [REVIEW_VOID.md](packages/flow/benchmark/fhir_medplum/void/REVIEW_VOID.md) | | Check a patient registration on a commercial EHR through its public FHIR sandbox: Epic (open.epic, R4), read through FHIR search and read with SMART backend auth; the frame is the search set, since the sandbox has no AuditEvent or `_history` | On main ([#1656](https://github.com/OpenAdaptAI/OpenAdapt/pull/1656)) | Real app (Epic public sandbox, August 2026 release; blind round 12/13 with none passed as verified, the miss a false refusal fixed in the contract; caught Epic dropping the phone on a 201 create) | `benchmark/commercial_ehr/`, [FINDINGS.md](packages/flow/benchmark/commercial_ehr/FINDINGS.md) | | A public benchmark a vendor reruns with their own agent: `openadapt bench medplum --agent ` runs 37 written-for-it cases (Patient, AllergyIntolerance, Appointment) against a local Medplum through a fault proxy, and scores the agent's claim, a read-back and OpenAdapt's verifier against an end-state oracle | On main ([#1652](https://github.com/OpenAdaptAI/OpenAdapt/pull/1652)) | Real app (Medplum 5.2.8; Claude Sonnet 5.5: OpenAdapt missed 1 of 9 harms with 4 false stops, all corrections that changed the record's identity, against 8 missed by the claim and the read-back; cold run from clone to scorecard 5 min) | [README](packages/flow/benchmark/fhir_medplum/public/README.md), [RESULTS.md](packages/flow/benchmark/fhir_medplum/public/RESULTS.md) | @@ -173,7 +184,7 @@ improvising or retrying when something is off. | Keep the server's own answer to a save as lower-level evidence on the receipt | On main ([#1298](https://github.com/OpenAdaptAI/OpenAdapt/pull/1298)) | Synthetic | receipts | | A ledger of what changed before a stop, a write-ordering lint, and undo steps a person approves | On main ([#1263](https://github.com/OpenAdaptAI/OpenAdapt/pull/1263)) | Synthetic (MockMed) | change ledger | | Propose an undo for each saving step (another recording, captured traffic, or the old value), keep it only if a test copy shows every record restored, and offer it after a stop once a person approves it | On main ([#1346](https://github.com/OpenAdaptAI/OpenAdapt/pull/1346)) | Synthetic (MockMed, MockLoan, live browser) | `openadapt-flow undo propose`, `undo accept`; [AFTER_A_STOP.md](packages/flow/docs/AFTER_A_STOP.md) | -| Roll a stopped run back on its own: the approved compensation for each change, latest first, an uncertain write only when a fresh read shows it landed, then a read of the whole database: every row the run touched back as it was and nothing else changed (`ROLLED_BACK`), or the exact rows a person repairs (`RECONCILIATION_REQUIRED`). A compensation runs only while every change in the store is positively the run's own by the app's audit log, so another user's work is never compensated away, and a hard delete runs only where the deployment allows it (void, restore or discard otherwise). `rollback qualify` stops the run before and after every save on a test copy and keeps only compensations that restore the whole store | On main ([#1634](https://github.com/OpenAdaptAI/OpenAdapt/pull/1634)) | MockMed, live browser through the CLI; SQLite whole-database tests | `openadapt-flow rollback qualify`, `rollback:` in deployment.yaml; [ROLLBACK.md](packages/flow/docs/ROLLBACK.md) | +| Roll a stopped run back on its own: the approved compensation for each change, latest first, an uncertain write only when a fresh read shows it landed, then a read of the whole database: every row the run touched back as it was and nothing else changed (`ROLLED_BACK`), or the exact rows a person repairs (`RECONCILIATION_REQUIRED`). A compensation runs only while every change in the store is positively the run's own by the app's audit log, so another user's work is never compensated away, and a hard delete runs only where the deployment allows it (void, restore or discard otherwise). `rollback qualify` stops the run before and after every save on a test copy and keeps only compensations that restore the whole store. On a desktop or remote-display backend a screen compensation runs only after the run's and the compensation's start screens are checked on the same backend | On main ([#1634](https://github.com/OpenAdaptAI/OpenAdapt/pull/1634)); desktop in [#1771](https://github.com/OpenAdaptAI/OpenAdapt/pull/1771) | MockMed, live browser through the CLI; SQLite whole-database tests (web and desktop paths) | `openadapt-flow rollback qualify`, `rollback:` in deployment.yaml; [ROLLBACK.md](packages/flow/docs/ROLLBACK.md) | | Leave the screen clean after an item that halted before any write: close dialogs and cancel forms with the recordings' own controls, re-enter, confirm the start screen and read the records back before the session takes another item. A session that doesn't come back is marked dirty and takes no more items; a cleanup that changed a record makes the item's write uncertain and stops the run. `cleanup qualify` tries the cleanup after every step on a test copy and refuses one that changes a record | On main ([#1607](https://github.com/OpenAdaptAI/OpenAdapt/pull/1607)) | Synthetic (scripted three-screen app through the real replayer and pool) and real app (pinned OpenEMR: a halted New Patient item left the confirmation dialog open; re-entry closed it, the form was empty, no patient row changed and the next item's first steps ran with no model calls; a signed-out screen was marked dirty; [results](packages/flow/benchmark/halt_cleanup/results/openemr.json)) | `openadapt-flow cleanup qualify`; [HALT_CLEANUP.md](packages/flow/docs/HALT_CLEANUP.md) | ## Approvals @@ -207,11 +218,12 @@ improvising or retrying when something is off. | Surface | Status | Evidence | Where | | --- | --- | --- | --- | -| Web (Playwright, Chromium) | Released | Real app, every CI build | [SURFACES.md](packages/flow/docs/SURFACES.md) | -| Native Windows (UI Automation) | Released | Synthetic (WinForms, 3 of 3 trials); real Windows 11 ARM machine: record, compile and replay of a WinForms form with an owned confirm dialog, 3 of 3 trials, each also refusing a target covered by another app's window and one blocked by a modal dialog before any input | [SURFACES.md](packages/flow/docs/SURFACES.md) | -| Native macOS (Accessibility) | Released | Synthetic (TextEdit, 3 of 3 trials) | [SURFACES.md](packages/flow/docs/SURFACES.md) | -| RDP (pixels; Aardwolf or headless FreeRDP with a session pool) | Released | Real Windows 11 host, 3 of 3, plus a FreeRDP batch; through the stock Remote Desktop client on a real H.264/AVC444 session, 105 runs on two apps: 55 saves confirmed in the database, 0 wrong-record saves, 40 false stops ranked by cause | [SURFACES.md](packages/flow/docs/SURFACES.md), `backends/xfreerdp.py`, [benchmark/rdp_avc_real](packages/flow/benchmark/rdp_avc_real/README.md) | -| Citrix and other virtual desktops, with OpenAdapt installed inside the session (`in_session`): the same structure-first path as a local desktop | Released | Same evidence as the Windows path | [SURFACES.md](packages/flow/docs/SURFACES.md) | +| Web (Playwright, Chromium) | Released | Real apps on local test copies (OpenEMR, Odoo, Frappe Lending, openIMIS) and Medplum; CI runs in-tree apps on every build | [SURFACES.md](packages/flow/docs/SURFACES.md) | +| Native Windows (UI Automation) | Released | Synthetic (a WinForms test app in a Windows 11 VM, 3 of 3 trials); CI on a GitHub-hosted Windows runner on every change to the Windows path and nightly: record, compile and replay of the WinForms Patient Notes app through the real win_agent, judged from its SQLite record, with a near-identical sibling roster that must not be written ([desktop-replay.yml](.github/workflows/desktop-replay.yml)); on a real Windows 11 ARM machine, record, compile and replay of the same kind of WinForms test form with an owned confirm dialog, 3 of 3 trials, each also refusing a target covered by another app's window and one blocked by a modal dialog before any input | [SURFACES.md](packages/flow/docs/SURFACES.md) | +| Native macOS (Accessibility) | Released | Synthetic (TextEdit, 3 of 3 file effects, driving the backend directly); a real third-party app, calibre (Qt, SQLite library): record, compile (certified under `clinical-write`) and a `standard` run checked against the library's database, `VERIFIED` with only the target book changed; a same-title book by another author stopped at the identity check and two identical books were refused as ambiguous, each with nothing written ([benchmark/macos_native](packages/flow/benchmark/macos_native/README.md)) | [SURFACES.md](packages/flow/docs/SURFACES.md) | +| Native Linux (AT-SPI, X11) | Released | Real AT-SPI in CI (`flow-linux-atspi`, Xvfb with a window manager): a GTK3 fixture (3 verified effects, 3 ambiguous and 3 stale targets refused) and a GTK3 chart app with SQLite: record, compile (certified under `clinical-write`), a `standard` run `VERIFIED` against the database, another patient's chart and a look-alike chart stopped before the save, and a repaired notice admitted on its AT-SPI element identity ([benchmark/repair_every_backend](packages/flow/benchmark/repair_every_backend/README.md)) | [LINUX_NATIVE.md](packages/flow/docs/desktop/LINUX_NATIVE.md) | +| RDP (pixels; Aardwolf or headless FreeRDP with a session pool) | Released | A Windows 11 VM over Aardwolf RDP, 3 of 3 (transport and input), plus a FreeRDP batch; through the stock Remote Desktop client on a real H.264/AVC444 session, 105 scripted runs on two apps (OpenEMR in Edge and a WinForms test app): 55 saves confirmed in the database, 0 wrong-record saves, 40 false stops ranked by cause | [SURFACES.md](packages/flow/docs/SURFACES.md), `backends/xfreerdp.py`, [benchmark/rdp_avc_real](packages/flow/benchmark/rdp_avc_real/README.md) | +| Citrix and other virtual desktops, with OpenAdapt installed inside the session (`in_session`): the same structure-first path as a local desktop | Released | The Windows path's evidence: a WinForms test app in a Windows 11 VM, 3 of 3 (synthetic) | [SURFACES.md](packages/flow/docs/SURFACES.md) | | Citrix from outside the session, pixels only (`remote_display`), the fallback when nothing can be installed inside | Exploratory | A Citrix stand-in with no DOM: 3 healthy runs, 3 stops on drift | [LIMITS.md](packages/flow/docs/LIMITS.md) | | Chain programs across surfaces, passing confirmed values | Released | Synthetic | `openadapt-flow compose`, `process`; [PROCESS_CONTRACT.md](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) | @@ -225,10 +237,12 @@ improvising or retrying when something is off. | Capability | Status | Evidence | Where | | --- | --- | --- | --- | -| Page structure (DOM, UI Automation, Accessibility) first, then template, OCR and geometry | Released | Real app | [RESOLUTION_LADDER.md](packages/flow/docs/RESOLUTION_LADDER.md) | +| Page structure (DOM, UI Automation, Accessibility) first, then template, OCR and geometry | Released | Real apps on the web; synthetic on Windows and macOS | [RESOLUTION_LADDER.md](packages/flow/docs/RESOLUTION_LADDER.md) | | 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 | On main ([#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 | On main ([#1269](https://github.com/OpenAdaptAI/OpenAdapt/pull/1269)) | Synthetic | [#1269](https://github.com/OpenAdaptAI/OpenAdapt/pull/1269) | +| Run a form's own script before an API save instead of refusing the form: field handlers, `onsubmit`, submit listeners and custom validate.js rules (OpenEMR's `pastDate`) run on the run's values in a scratch copy of the session with every write blocked, and the save is sent only when the page lets the submit through and would send the same body | On main | Synthetic (headless Chromium against a local test page) | [API_TIER.md](packages/flow/docs/API_TIER.md#what-happens-at-run-time), `runtime/actuators/form_eval.py` | +| Declare the other writes in an API step's span as an ordered sequence: the page's own background requests (another document's script, loaded before the form, to an endpoint the form doesn't name) are listed and not sent; a write a step caused is sent in its recorded place before the save, read back by that step's record check, and a failure stops the run with nothing after it sent and nothing retried | On main | Synthetic | [API_TIER.md](packages/flow/docs/API_TIER.md#other-writes-in-the-span) | | Approve an API step's exact request under per-item approval: one digest binds the request bytes, the record and the record check, and a changed request is not sent | On main ([#1355](https://github.com/OpenAdaptAI/OpenAdapt/pull/1355)) | Synthetic | [API_TIER.md](packages/flow/docs/API_TIER.md#under-an-approval-mode) | | Run an API step in a durable run: checkpoint the span after the save is sent, never resend or redo a save that may have landed, and reconcile it by reading the record back | On main ([#1358](https://github.com/OpenAdaptAI/OpenAdapt/pull/1358)) | Synthetic | [API_TIER.md](packages/flow/docs/API_TIER.md#in-a-durable-run) | | Run an admitted API step under a governed run authorization and the Standard or Regulated profile: the authorization names the API step by digest, the person who admits it names the right-record parameter, and the outcome reads `VERIFIED` | On main ([#1361](https://github.com/OpenAdaptAI/OpenAdapt/pull/1361)) | Synthetic | [API_TIER.md](packages/flow/docs/API_TIER.md#under-governance) | @@ -254,16 +268,16 @@ improvising or retrying when something is off. | Self-repair: an agent proposes a fix for one halted click, Flow probes it up to the save without saving, a person approves | On main ([#1265](https://github.com/OpenAdaptAI/OpenAdapt/pull/1265)) | Synthetic (MockMed redesign) | `run --self-repair`, [#1265](https://github.com/OpenAdaptAI/OpenAdapt/pull/1265) | | A scroll that never finds the next click's target halts on that click, so self-repair can take a relabelled control behind a scroll; the refusal for unreviewed-risk steps names them | On main ([#1337](https://github.com/OpenAdaptAI/OpenAdapt/pull/1337)) | Synthetic; openIMIS drift row checked offline on its recorded frames | `runtime/replayer.py`, `qualify set-risk` | | Self-repair across several new steps: a bridge of up to three actions for a new required dialog or a control moved into a menu, each new step armed with a record check and probed up to the save, one person approving | On main ([#1325](https://github.com/OpenAdaptAI/OpenAdapt/pull/1325)) | Synthetic (MockMed menu and provider drifts, scripted agent; [benchmark/auto_repair](packages/flow/benchmark/auto_repair/README.md)) | `run --self-repair`, [SELF_REPAIR.md](packages/flow/docs/SELF_REPAIR.md) | -| Self-repair's new clicks outside a browser: on a native app a click is admitted by its accessibility element (role, name, ancestry, window; unique in the pinned window), on pixels only by the record check, and either way only when a system-of-record verifier covers every save under `standard` or `regulated`; the new step carries the dialog around it as a screen pre-check, and only a person approves the fix | On main ([#1709](https://github.com/OpenAdaptAI/OpenAdapt/pull/1709)) | Synthetic, partly live: on OpenAdapt's native Cocoa test app with SQLite, through live macOS AX, the stop, the element identity read, the admission and the refusals without a record check ran with no wrong write ([benchmark/repair_every_backend](packages/flow/benchmark/repair_every_backend/README.md)) | `openadapt_flow/repair/element_identity.py`, [REPAIR_ON_EVERY_BACKEND.md](packages/flow/docs/REPAIR_ON_EVERY_BACKEND.md) | +| Self-repair's new clicks outside a browser: on a native app a click is admitted by its accessibility element (role, name, ancestry, window; unique in the pinned window), on pixels only by the record check, and either way only when a system-of-record verifier covers every save under `standard` or `regulated`; the new step carries the dialog around it as a screen pre-check, and only a person approves the fix | On main ([#1709](https://github.com/OpenAdaptAI/OpenAdapt/pull/1709)) | Synthetic, partly live: on OpenAdapt's native Cocoa test app with SQLite, through live macOS AX, the stop, the element identity read, the admission and the refusals without a record check ran with no wrong write; qualified end to end on macOS AX ([#1715](https://github.com/OpenAdaptAI/OpenAdapt/pull/1715)): stop, admitted click, probe, replay and fault campaigns, one approval, resumed run `VERIFIED`; and on its GTK3 counterpart through live Linux AT-SPI, in CI, the same end to end. `run --self-repair` and repair by default start the app for each probe from `repair.session` ([#1717](https://github.com/OpenAdaptAI/OpenAdapt/pull/1717); [benchmark/repair_every_backend](packages/flow/benchmark/repair_every_backend/README.md)) | `openadapt_flow/repair/element_identity.py`, [REPAIR_ON_EVERY_BACKEND.md](packages/flow/docs/REPAIR_ON_EVERY_BACKEND.md) | | 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](packages/flow/benchmark/pixel_scale/README.md) | | Find icons again on Citrix, RDP and VNC after a display-scale, zoom, font, theme or codec change, with a local model that counts look-alikes and stops on a tie (`--local-grounding`, off by default) | On main ([#1596](https://github.com/OpenAdaptAI/OpenAdapt/pull/1596)) | Synthetic (Chromium-drawn clinic screen, pixels only: icons found under 21 of 26 conditions, template matching 8; 0 wrong targets in 65; whole runs unchanged, 4 of 39 in both arms, because the layout, identity and census checks stop first). Held out on real AVC444 RDP frames: 0 wrong picks, 6 right, 75 abstentions in 81 | [benchmark/pixel_grounding](packages/flow/benchmark/pixel_grounding/README.md), `runtime/local_grounder.py` | | 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 | On main ([#1294](https://github.com/OpenAdaptAI/OpenAdapt/pull/1294)) | Synthetic | standard changes | | Repair a pinned bundle: the fix runs the pinned bundle's own qualification campaign on a test copy, keeps every write, effect, identity rule and record check, and is re-admitted with signed lineage (at most three repairs, no revoked key) that the pin verifies | On main | Synthetic | [SELF_REPAIR.md](packages/flow/docs/SELF_REPAIR.md#a-pinned-bundle-re-qualify-and-re-admit) | -| Repair by default: once a repair agent is configured, every stop that wrote nothing goes to it without anyone starting it; a stop after a possible write goes to reconciliation; every stop is a row in a ledger with the funnel from stop to resumed item | On main ([#1360](https://github.com/OpenAdaptAI/OpenAdapt/pull/1360)) | Synthetic (MockMed, 22 drift modes, real Claude agent: 6 of 21 repairable stops resumed and verified, 0 wrong saves in 66 runs); routing over recorded OpenEMR stops; live openIMIS relabel: 3 of 3 reached repair and stopped with no wrong save; silent-saves 1.1.0 UI-change blocks: 18 of 28 items that always stopped now correct, 0 wrong | `run` (on by default, `--no-auto-repair`), `repair:` in `deploy.yaml`, [benchmark/auto_repair](packages/flow/benchmark/auto_repair/README.md) | +| Repair by default: once a repair agent is configured (outside a browser, with `repair.session`, [#1717](https://github.com/OpenAdaptAI/OpenAdapt/pull/1717)), every stop that wrote nothing goes to it without anyone starting it; a stop after a possible write goes to reconciliation; every stop is a row in a ledger with the funnel from stop to resumed item | On main ([#1360](https://github.com/OpenAdaptAI/OpenAdapt/pull/1360)) | Synthetic (MockMed, 22 drift modes, real Claude agent: 6 of 21 repairable stops resumed and verified, 0 wrong saves in 66 runs); routing over recorded OpenEMR stops; live openIMIS relabel: 3 of 3 reached repair and stopped with no wrong save; silent-saves 1.1.0 UI-change blocks: 18 of 28 items that always stopped now correct, 0 wrong | `run` (on by default, `--no-auto-repair`), `repair:` in `deploy.yaml`, [benchmark/auto_repair](packages/flow/benchmark/auto_repair/README.md) | | Repair a relabeled save button: prove from the recorded identity hash that only the label changed (same control id, kind and form), re-qualify the fix on the new release, and have a person admit the certified copy afresh, since a save button's identity is part of the record check | On main | Real app (local OpenEMR, synthetic data): after the save button was relabeled, the stopped item re-ran and saved once after 8.3 operator minutes and 763 s of re-qualification; 896 s from stop to done ([#1513](https://github.com/OpenAdaptAI/OpenAdapt/pull/1513)) | `openadapt_flow/repair/relabel.py`, [SELF_REPAIR.md](packages/flow/docs/SELF_REPAIR.md#a-relabeled-control-proof-that-only-the-label-changed) | | A duplicate check that reads the app's match list (`no_duplicate_candidate`): stop only on a listed record with the item's last name or date of birth, fail closed on an unreadable or unloaded list | On main | Real app (local OpenEMR, synthetic data): false stops per 100 items 70.4 before, 16.4 after; 0 silent wrongs in 30 saves ([#1513](https://github.com/OpenAdaptAI/OpenAdapt/pull/1513)) | [DUPLICATE_CHECK.md](packages/flow/docs/DUPLICATE_CHECK.md) | | A switched-off `problems_only` gate asks a person before the save instead of stopping the item | On main | Synthetic; real app in [#1513](https://github.com/OpenAdaptAI/OpenAdapt/pull/1513) | [APPROVAL_MODES.md](packages/flow/docs/APPROVAL_MODES.md#when-the-mode-turns-itself-off) | -| 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](packages/flow/docs/FLEET_DRIFT_PATCHES.md) | +| Fleet drift patches: a verified fix with no patient data, shared across sites under policy, moved as files or through a local reference hub | Released, local reference hub | Simulator | `openadapt-flow fleet`, [FLEET_DRIFT_PATCHES.md](packages/flow/docs/FLEET_DRIFT_PATCHES.md) | ## Computer-use agents @@ -271,7 +285,7 @@ improvising or retrying when something is off. | --- | --- | --- | --- | | 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](packages/flow/docs/JIT.md) | | Reuse a compiled program only for the record check and app environment it was admitted under (a changed record check or a new app version at the same origin is refused) | On main ([#1360](https://github.com/OpenAdaptAI/OpenAdapt/pull/1360)) | Unit tests | `TaskSignature(record_check=, environment=)`, `WebApp(environment_id=)`, [JIT.md](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 | On main ([#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)) | +| Code as action is the default run-1 agent (compile-agent, and `teach_run` in the MCP teach tools): 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, and every save the gate detects is read back from the system of record | On main ([#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](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](packages/agent/README.md) | | Teach over MCP: an agent does a new task once through OpenAdapt; the run is read back, compiled and admitted (a person confirms the record check; the agent admits only under a person-signed policy), and becomes a tool it calls with no model calls. Web, and Windows, macOS, Linux, RDP and Citrix with a person approving each save | On main ([#1335](https://github.com/OpenAdaptAI/OpenAdapt/pull/1335)) | Synthetic (MockMed over MCP, and through the real RDP and Citrix backends) | `openadapt-agent serve --teach`, [packages/agent](packages/agent/README.md#teach-a-new-workflow-from-your-agent) | @@ -280,12 +294,13 @@ improvising or retrying when something is off. | See what your own agent gets wrong, with no risk: `openadapt shadow` is a loopback pass-through proxy beside the agent. It checks each write against the system of record (FHIR through `ensure_record`; SQL through the verifier's whole-database frame), checks each `CONFIRMED` again later, and writes `report.html` and `report.json`. It never blocks, changes or resends a write; the only delay is the read before a write (at most `hold_ms`) | [#1678](https://github.com/OpenAdaptAI/OpenAdapt/pull/1678) | Real app (Medplum 5.2.8, 37 cases through a fault proxy: scripted agent 18 of 18 harms caught, 0 correct writes refuted; Claude Sonnet 5.5 9 of 9 caught, 4 correct but corrected writes refuted; 0 writes by the shadow; 15 to 25 ms median added per write; setup 76.5 s machine time, 3 decisions); real app, SQL mode (Dolibarr 21 on MariaDB 11.4, 13 cases: 8 of 8 harms caught, 5 of 5 correct writes confirmed, 292 ms median added); synthetic (SQLite) | `openadapt shadow`, [SHADOW_MODE.md](packages/flow/docs/SHADOW_MODE.md), [SHADOW_RESULTS.md](packages/flow/benchmark/fhir_medplum/public/SHADOW_RESULTS.md) | | Skill cache for framework agents: an agent in any framework does a browser task through compile-agent's harness as plain tools (`BrowserTools`); when the system of record shows exactly the task's record and the operator confirms the checks, the run is canaried, admitted and emitted as an MCP server. A repeat of the same task (instruction, record, app, contract, environment) calls that tool instead of the agent: no model calls, the program's checks and this task's record check on every run, eviction after a failed run | On main | Synthetic (MockMed, LangGraph on `claude-sonnet-5-5`, 3 trials, repeats on the same record: run 1 6 model calls and $0.022; runs 2 to 10 27/27 VERIFIED, 0 model calls, 7.8 s median); Trac 1.6: run 1 verified, not admitted | `openadapt_flow.integrations.agents.skills`, [benchmark/skill_cache](packages/flow/benchmark/skill_cache/README.md) | | Verified write tools for agent frameworks: `verified(tool, contract=...)` wraps a Claude Agent SDK, OpenAI Agents SDK, LangGraph or CrewAI write tool in one line. The contract and any values the operator pins come from the operator, never the agent's arguments; the store is read before and after; the agent gets a receipt or a refusal, and a refused write is held instead of retried | On main | Synthetic (each framework's own agent loop with a scripted model, writing through the example app's order form over real HTTP: verified save, a save the app drops, a save it keeps twice); one real-model run per framework, opt-in | `openadapt_flow.integrations.agents`, extras `claude-agent-sdk`, `openai-agents`, `langgraph` (CrewAI: install `crewai` beside it); [AGENT_FRAMEWORKS.md](packages/flow/docs/AGENT_FRAMEWORKS.md) | -| Teach a browser write as a verified tool: with write contracts (`serve --teach-browser --contracts DIR`), the agent teaches a save once; run 1 is verified in the system of record before the `taught_` tool exists, and every call makes the write and returns a verified receipt or a refusal. Sign-in values come from the server's environment and never reach the agent or the program. Controls in named iframes are found through their frame | On main ([#1589](https://github.com/OpenAdaptAI/OpenAdapt/pull/1589)) | Synthetic (the example app's sign-in and order form in headless Chromium over MCP: verified save, a save the app drops, a save it keeps twice) | `openadapt-agent serve --teach-browser --contracts`, [VERIFY_WRITES.md](packages/flow/docs/VERIFY_WRITES.md#teach-a-write-as-a-tool) | +| Browser teach refuses taught writes: a write an agent teaches needs a person's signed teach authorization, and this lane can't bind one, so `teach_start` with a contract is refused and an existing write tool doesn't run. Writes are taught through `serve --teach --teach-authorization`. The read-only lane (downloads) is unchanged | On main ([#1589](https://github.com/OpenAdaptAI/OpenAdapt/pull/1589), [#1436](https://github.com/OpenAdaptAI/OpenAdapt/pull/1436)) | Unit test of the refusal | `openadapt-agent serve --teach-browser`, [TEACH_AUTHORIZATION.md](packages/flow/docs/TEACH_AUTHORIZATION.md) | | Guard an agent you already run: attach to its own Chromium over CDP (one added line), hold each save until the record on screen is the one the task names, block it and tell the agent why when it isn't, read the save back; a run that reads back correctly becomes a draft program with a record check from scaffold-verifier, which a person confirms and admits, then a `taught_*` tool with no model calls. `--observe-only` never blocks | On main ([#1357](https://github.com/OpenAdaptAI/OpenAdapt/pull/1357), stacked on [#1335](https://github.com/OpenAdaptAI/OpenAdapt/pull/1335)) | Synthetic (MockMed, scripted Playwright agent, `effect_e2e` faults) | `openadapt-flow guard`, [GUARD_AN_AGENT.md](packages/flow/docs/GUARD_AN_AGENT.md) | | Task ledger per job: rules locked when the job opens and enforced on every run, item status set only from OpenAdapt's own results, and yes-or-no questions a person answers on the phone or in Desktop, including when a run stops | On main ([#1332](https://github.com/OpenAdaptAI/OpenAdapt/pull/1332)) | Synthetic (30-item MockMed worklist, scripted agent, no model calls) | MCP tools `open_job`, `get_job`, `add_items`, `record_evidence`, `ask_user`, `get_answer`, `change_constraint`, `close_job`; [Track a long job](packages/agent/README.md#track-a-long-job) | | Limit a teaching agent to the declared task on every teach path (`compile_agent`, `compile-agent`, teach over MCP, the airbag guard): typed text must be a declared value, links and browser requests to other origins are refused, writes are checked before they are sent, and the record is read back before a draft exists. Injected instructions in record notes, banners, tooltips, alt text and server responses are blocked or caught on MockMed through each path (scripted agent; a live-model mode is off by default). Self-repair refuses a control that leaves the app | On main ([#1359](https://github.com/OpenAdaptAI/OpenAdapt/pull/1359), [#1386](https://github.com/OpenAdaptAI/OpenAdapt/pull/1386)) | Synthetic | `openadapt_flow.teach_scope`, [THREAT_MODEL.md](packages/flow/docs/THREAT_MODEL.md) | +| Teach authorization: a person signs what a teaching agent may do (task, origins, inputs, the one write, forbidden actions and controls) with their approver key, and every teach path is bound to it or refused. Verified with the approver roster's own verifier, so an unknown, removed or wrong key, an unpinned root, a changed statement or another task refuses. Admission of every JIT draft needs one (`teach_auth_not_recorded` otherwise), as do an agent-demonstrated fix's approval and self-repair of an agent-taught program; the airbag handoff's drafts and browser-teach writes are refused | On main ([#1436](https://github.com/OpenAdaptAI/OpenAdapt/pull/1436)) | Synthetic (real approver keys, real roster, real admission) | `openadapt-flow teach-authorization`, [TEACH_AUTHORIZATION.md](packages/flow/docs/TEACH_AUTHORIZATION.md) | | Compile an agent's first verified run from a prompt (`compile-agent`), and a code-as-action authoring agent | On main ([#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/` | +| 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) | `benchmark/harness/harness2/` (source checkout) | | One scoreboard comparing the agent alone with OpenAdapt across tasks (correctness by read-back, cost, time, items per hour) | On main ([#1303](https://github.com/OpenAdaptAI/OpenAdapt/pull/1303)) | Synthetic and local OpenEMR and openIMIS test copies | `benchmark/scoreboard/` | | Pull a skill from the registry (`pull app/task@version`) and run it only after qualifying it on your own instance: `pull` marks it and expires any certification it carries, `skill bind` records your addresses and the names of the variables that hold credentials (never their values), `skill qualify` runs its bundled record check there, and `run`, `replay`, `emit-skill` and `emit-mcp` refuse it until then, and on any other instance. The index and its archives carry a keyless Sigstore attestation from the registry release workflow, checked offline against a pinned identity, and expire and can't be rolled back; a shipped record check runs no code and reads only your binding | On main | Unit tests | `openadapt-flow skill`, [SKILLS_REGISTRY.md](packages/flow/docs/design/SKILLS_REGISTRY.md) | @@ -307,6 +322,7 @@ improvising or retrying when something is off. | Offline Seals signed with a key the customer holds: a key file, the OS keychain, or a PKCS#11 token or HSM; pinned keys with rotation and revocation; no fallback to a generated key | On main ([#1392](https://github.com/OpenAdaptAI/OpenAdapt/pull/1392)) | Unit tests with synthetic data (keychain and PKCS#11 through stand-ins) | `openadapt-flow local-seal --key`, `local-seal key create`, `pin`, `revoke`; `openadapt seal verify FOLDER` checks every sealed run in a folder; [OFFLINE_SEALS.md](packages/flow/docs/OFFLINE_SEALS.md#customer-held-keys) | | Anchor an offline Seal in the public Sigstore Rekor transparency log (opt-in; sends one digest and the public key), and verify the anchor offline from the stored inclusion proof | On main ([#1394](https://github.com/OpenAdaptAI/OpenAdapt/pull/1394)) | Unit tests against a simulated Rekor log; real public Rekor entries and Sigstore TSA tokens for the landing demo's referral runs, verified offline in tests; Ed25519ph matches the RFC 8032 test vector | `openadapt-flow local-seal anchor`, `local-seal verify --require-anchor`; [OFFLINE_SEALS.md](packages/flow/docs/OFFLINE_SEALS.md#optional-anchor-a-seal-in-a-public-transparency-log) | | Signed write receipts: a verifier verdict with its scope (stores read, window, strength) and a plain not-checked list, signed as a COSE_Sign1 (IETF SCITT shape) and logged in a local Merkle transparency log; checked offline against pinned keys; a receipt that claims more than its scope is neither issued nor accepted; optional Rekor anchor | On main | Unit tests on the real verifier (SQLite) and shadow proxy; COSE checked through python-cwt | `openadapt-flow receipt verify`, `receipt keys export`, `receipt anchor`; `verify_write(..., write_receipt=True)`; `shadow --receipts`; [WRITE_RECEIPTS.md](packages/flow/docs/WRITE_RECEIPTS.md) | +| See a run's Seal offline: one HTML page that checks the Ed25519 signature over the run's exact report in the browser, draws the Seal from it, replays the run over its program map, and lets the reader tamper with a copy; no network requests; the quickstart ends on the person's own | On main ([#1566](https://github.com/OpenAdaptAI/OpenAdapt/pull/1566)) | A real quickstart run's Seal opened from `file://` in Chromium (valid, tampered, no requests); RFC 8032 test vectors; a published OpenEMR Seal's Rekor entry and timestamp | `openadapt seal view RUN_DIR`; `openadapt quickstart`; [`@openadapt/seal-view`](packages/seal-view/README.md) | | Run authorizations signed with the authorizer's GitHub SSH key (`ssh-keygen -Y sign`, after a summary of what it allows), naming the signing key and bound to the bundle digest, items, profile and Seal keys, and referenced by each Seal; the identity checked against github.com (`--live`) or pinned allowed signers; a pseudonymous operator profile counted from anchored Seals only | On main ([#1401](https://github.com/OpenAdaptAI/OpenAdapt/pull/1401)) | Unit tests with synthetic data; signatures checked against OpenSSH's `ssh-keygen` both ways | `openadapt-flow local-seal authorize`, `local-seal run --authorization`, `local-seal profile`; [OFFLINE_SEALS.md](packages/flow/docs/OFFLINE_SEALS.md#run-authorizations-who-authorized-the-runs) | | Run reports redacted before they are sealed: session and CSRF tokens, OAuth grants and credential headers become `` and are listed in `redactions`; a Seal refuses a report that still holds one; per-app policy | On main ([#1407](https://github.com/OpenAdaptAI/OpenAdapt/pull/1407)) | Unit tests with generated tokens; 7,773 local reports scanned, only OpenEMR `token_main` matched | `openadapt-flow redact-report`, `local-seal run --allow-unredacted`; [REPORT_REDACTION.md](packages/flow/docs/REPORT_REDACTION.md) | | 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) | @@ -355,6 +371,7 @@ A test fails when a command has no file or this list is out of date. - `propose-qualification` - `qualify` - `scaffold-verifier` +- `teach-authorization` **Run and answer:** diff --git a/llms-full.txt b/llms-full.txt index 957d4d6e6f..9c66cf8478 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -405,7 +405,7 @@ Maintained by [OpenAdaptAI](https://github.com/OpenAdaptAI) under the OpenAdapt turns work that people or computer-use agents do in applications with no usable API (EMRs, payer portals, desktop and remote-desktop apps) into -programs that make the same entry every time with no model calls, check that +programs that make the same entry every time with no calls to a hosted AI model, check that the entry landed in the right record, and stop instead of guessing. This page indexes what OpenAdapt implements, with the status, the evidence and @@ -431,8 +431,9 @@ monorepo and installable from source. 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`, on main; the agent can act through code - instead of screenshots, [#1305](https://github.com/OpenAdaptAI/OpenAdapt/pull/1305)). Agents can drive the recording + prompt-to-program `compile-agent`, on main; the agent acts through code by + default, not screenshots, and every save the gate detects in its run is read + back, [#1305](https://github.com/OpenAdaptAI/OpenAdapt/pull/1305)). Agents can drive the recording through the web, native Windows, RDP and Citrix backends. An agent connected over MCP can teach itself a new workflow and then call it as a tool (`openadapt-agent serve --teach`, @@ -472,9 +473,15 @@ monorepo and installable from source. Person-signed grants let named agents answer. Released. 8. **Repairs itself, with a person approving.** Governed repair (propose, review, campaign, canary, rollback), agent-demonstrated fixes for a halted - run, and fleet drift patches shared with no patient data are released. + run, and fleet drift patches shared with no patient data (moved as files + or through a local reference hub) are released. Self-repair, where an agent proposes a fix and Flow probes it up to the save without saving, is on main, and so are fixes of up to three new steps. + Self-repair is qualified on browser runs ([benchmark/auto_repair](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/benchmark/auto_repair/README.md)) + and end to end on native macOS AX ([#1715](https://github.com/OpenAdaptAI/OpenAdapt/pull/1715)). Outside a browser it + runs with `repair.session` ([#1717](https://github.com/OpenAdaptAI/OpenAdapt/pull/1717)), and native and pixel backends + admit its new clicks by element identity ([#1709](https://github.com/OpenAdaptAI/OpenAdapt/pull/1709)). Teaching the fix + for a halted run (`--hold-on-halt`) and rollback are tested on browser runs. 9. **Is a safety layer underneath any agent.** An agent's MCP calls, a task ledger that keeps a long job's rules, items, questions and checked saves on the server ([#1332](https://github.com/OpenAdaptAI/OpenAdapt/pull/1332)), a guarded save around any agent's write @@ -483,8 +490,8 @@ monorepo and installable from source. the agent ([#1357](https://github.com/OpenAdaptAI/OpenAdapt/pull/1357)), `verify-run` to judge any executor's write from the system of record, and Agent Skills and a CLI for coding agents. Released except where a PR is named. -10. **Runs locally and keeps data private.** Healthy runs make no model calls - and send nothing out. Personal and health data are redacted from text, +10. **Runs locally and keeps data private.** Healthy runs make no calls to a + hosted AI model and send nothing out. Personal and health data are redacted from text, screenshots and structure (scrub-by-default for anything that leaves the machine is on main); a strict no-outbound mode, an unattended on-prem service, and signed, timestamped receipts are released. @@ -504,12 +511,12 @@ monorepo and installable from source. | 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) | | Repairs a change that needs new steps, such as a new required dialog or a control moved into a menu: up to three new steps, each with its own record check, probed up to the save before one person approves. | [SELF_REPAIR.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/SELF_REPAIR.md), [#1325](https://github.com/OpenAdaptAI/OpenAdapt/pull/1325) | | 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. | `CODE_AS_ACTION.md` in [#1305](https://github.com/OpenAdaptAI/OpenAdapt/pull/1305) | +| The agent that does a task once acts through code by default, 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. Every save the save gate detects is read back from the system of record before the agent goes on. Scoreboard arm 7, MockMed items 21 to 40: 18 of 18 normal items correct at $0.0093 an item; the wrong-patient save landed and the read-back then stopped the run; the scoreboard counts 1 silent wrong save by its word rule, a safe stop on hand review ([HAND_REVIEW.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/benchmark/scoreboard/raw/mockmed/code_agent/2026-10-25-rb/HAND_REVIEW.md)). The program compiled from it: 18 of 18 at $0. | `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) | | Has a written threat model for prompt injection against the agent that teaches or repairs a program, with tests. | [THREAT_MODEL.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/THREAT_MODEL.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, privacy, desktop and tray), 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) | +| Makes no calls to a hosted AI model on healthy runs. Model training (openadapt-ml) is separate research. | [README](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/README.md) | ## The full map @@ -540,6 +547,9 @@ 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) | | Classify common local edits without a person (select-all, Enter in a field or dropdown, an in-form tab, an informational dialog's dismiss), only when the recording's network capture shows no write and a runtime check confirms only the expected change | On main ([#1366](https://github.com/OpenAdaptAI/OpenAdapt/pull/1366)) | Synthetic; OpenEMR referral recording re-admitted offline (violations 12 to 1 under `permissive`) | [LOCAL_EDIT_CLASSIFICATION.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/design/LOCAL_EDIT_CLASSIFICATION.md), `compiler/local_edit.py` | +| Keep the empty search result a person confirmed through (a duplicate check's "No matches were found.") as an exact check after the click that opens the dialog and again right before Confirm, so a run whose dialog lists a match stops before the save; only fixed-vocabulary search-result statements shown once, never text with a name or search term, and a confirmation dialog without one is noted in `compile_notes.json` | On main | Real app frames (an OpenEMR referral recording, compiled offline) | `compiler/empty_result_guard.py` | +| Mine that check in any language: induction keeps the one line a named dialog adds (not its title or buttons, not a question, no typed value or number) when two or more demonstrations whose typed values all differ show the same line, refuses when the demonstrations disagree on a mined check instead of copying the first one's, and qualification then needs a business-rule run that halts at it | On main | Synthetic (a search dialog recorded in Chromium by the real recorder, German statement, real OCR) | `compiler/empty_result_guard.py`, `compiler/induction.py` | +| Qualify against a rule the app itself enforces: verify.yaml `business_rules` seed the conflicting record (an existing chart for the same person, checked in the read-back), and the certified bundle must stop at the rule's check before the save with nothing written on every trial, or certification is withdrawn | On main | Unit tests; the OpenEMR fixture's verify.yaml declares the existing-duplicate rule | [QUALIFY_A_WORKFLOW.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/QUALIFY_A_WORKFLOW.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/` | @@ -551,6 +561,7 @@ improvising or retrying when something is off. | Generate an ensure-record contract from three demonstrations, the server's CapabilityStatement and its SearchParameter definitions: field paths and `[system=…]` filters, comparison kinds, value maps, identity, search parameters, the closed record and the change feed. It refuses when unsure, asks a person to confirm an identity for a type with no reviewed template, and self-checks every demonstration plus a perturbation per field | On main ([#1653](https://github.com/OpenAdaptAI/OpenAdapt/pull/1653)) | Real app (Medplum 5.2.8: all 16 hand-written Patient lines regenerated; one blind write type, Immunization: 78 s of person time, 2 lines edited after the person declined the proposed identity; blind suite 23/24, no fault passed) | `openadapt_flow/generate/ensure_contract.py`, [RESULTS.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/benchmark/contract_autogen/results/RESULTS.md) | | A stop card for a stopped write: the exact record difference and who else wrote, with Undo or Fix that reads back, or Open in the app; cost modelled from published operators and timed machine steps, as pre-registered | On main ([#1653](https://github.com/OpenAdaptAI/OpenAdapt/pull/1653)) | Real app (Medplum 5.2.8, blind suite: 16 stops, 14 one-tap with no failed read-back; modelled 42 s per caught harm against 420; one wrong category on a false stop, gone in the re-measure with `void`: 24/24, 15 stops, no wrong category, no failed read-back) | `benchmark/stop_card/`, [PREREG_REVIEW.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/benchmark/stop_card/PREREG_REVIEW.md) | | Generate `openadapt.verify/v1` contracts from a MySQL, MariaDB or PostgreSQL schema, the kinds of write with their parameter names, and one demonstration write per example: one contract per field set (an update gets one for each set of fields it may change), and `choose` builds any other set of the kind's fields the same way from the same pinned inputs, list parameters by length, a `found` twin for a record already in place, the whole-database frame on every dialect (it compares the contract's own tables too, so an excused column holds only on the rows the write declares, and the recheck sees a late change to a look-alike row), rows the app adds on its own declared by count, a recheck window the person confirms, and `frame.versions` (a lost update) from a version column or the app's audit trail of saves. Equal ids are never taken for a join (a row's own key, the column a path arrived by), a join only equal ids support is a question when a supported place also fits, and a pin needs two rows that agree. Every undecided point is one numbered question; every contract is loaded and dry-read against the live store before it is written; a needed `TRIGGER` grant or view is applied with a flag or the kind is refused. Not yet: OpenAPI metadata | [#1672](https://github.com/OpenAdaptAI/OpenAdapt/pull/1672) | Real app (first study, instance copies; questions for a person: Dolibarr 4, Redmine 2, EspoCRM 3, against 11 hand decisions each; every in-scope planted fault stopped (22/22, 19/19, 21/21); correct writes confirmed 32/43, 32/38, 27/40; evidence in the private study repository) | `python -m openadapt_flow.generate.verify_contract`, [GENERATE_CONTRACTS.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/GENERATE_CONTRACTS.md) | +| Set up generated contracts with no person answers: answer the generator's questions by fixed rules (its own bookkeeping proposal with time stamps checked against the clock, nothing added), validate them in a sandbox copy of the store beside the app's own (a database or schema the setup makes and drops) by replaying held-out correct writes and planting faults built from each write's recorded diff, never from the contract (missing, wrong value, wrong or look-alike record, duplicate, unrelated edit, a row in another table, a delayed change, a changed bookkeeping cell), and admit a kind only when every held-out write is confirmed and every fault stopped; a kind not admitted has no contract and is listed in `EXCEPTIONS.md`. Not yet: log tables, counters and derived columns, learned excuses, value maps | (this PR) | Unit (real PostgreSQL: one kind admitted with every planted fault stopped, one listed as an exception; a control without the clock check lets the time-stamp fault through). Development apps pending | `python -m openadapt_flow.generate.zero_touch`, [ZERO_TOUCH_DESIGN.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/research/ZERO_TOUCH_DESIGN.md) | | `void` in an ensure-record contract: values that make a record assert nothing (`status="entered-in-error"`). A record holding one is never the person, is not a duplicate, and a created one needs review; it names values to leave out, never values to keep, and can't name an identity field | On main ([#1657](https://github.com/OpenAdaptAI/OpenAdapt/pull/1657)) | Real app (Medplum 5.2.8: 8 live cases written in advance, 8/8 with the line, 5/8 and one silent pass without it; frozen blind Immunization suite 24/24); adversarial review recorded | `openadapt_flow/ensure_record.py`, [RESULTS.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/benchmark/fhir_medplum/void/results/RESULTS.md), [REVIEW_VOID.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/benchmark/fhir_medplum/void/REVIEW_VOID.md) | | Check a patient registration on a commercial EHR through its public FHIR sandbox: Epic (open.epic, R4), read through FHIR search and read with SMART backend auth; the frame is the search set, since the sandbox has no AuditEvent or `_history` | On main ([#1656](https://github.com/OpenAdaptAI/OpenAdapt/pull/1656)) | Real app (Epic public sandbox, August 2026 release; blind round 12/13 with none passed as verified, the miss a false refusal fixed in the contract; caught Epic dropping the phone on a 201 create) | `benchmark/commercial_ehr/`, [FINDINGS.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/benchmark/commercial_ehr/FINDINGS.md) | | A public benchmark a vendor reruns with their own agent: `openadapt bench medplum --agent ` runs 37 written-for-it cases (Patient, AllergyIntolerance, Appointment) against a local Medplum through a fault proxy, and scores the agent's claim, a read-back and OpenAdapt's verifier against an end-state oracle | On main ([#1652](https://github.com/OpenAdaptAI/OpenAdapt/pull/1652)) | Real app (Medplum 5.2.8; Claude Sonnet 5.5: OpenAdapt missed 1 of 9 harms with 4 false stops, all corrections that changed the record's identity, against 8 missed by the claim and the read-back; cold run from clone to scorecard 5 min) | [README](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/benchmark/fhir_medplum/public/README.md), [RESULTS.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/benchmark/fhir_medplum/public/RESULTS.md) | @@ -576,7 +587,7 @@ improvising or retrying when something is off. | Keep the server's own answer to a save as lower-level evidence on the receipt | On main ([#1298](https://github.com/OpenAdaptAI/OpenAdapt/pull/1298)) | Synthetic | receipts | | A ledger of what changed before a stop, a write-ordering lint, and undo steps a person approves | On main ([#1263](https://github.com/OpenAdaptAI/OpenAdapt/pull/1263)) | Synthetic (MockMed) | change ledger | | Propose an undo for each saving step (another recording, captured traffic, or the old value), keep it only if a test copy shows every record restored, and offer it after a stop once a person approves it | On main ([#1346](https://github.com/OpenAdaptAI/OpenAdapt/pull/1346)) | Synthetic (MockMed, MockLoan, live browser) | `openadapt-flow undo propose`, `undo accept`; [AFTER_A_STOP.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/AFTER_A_STOP.md) | -| Roll a stopped run back on its own: the approved compensation for each change, latest first, an uncertain write only when a fresh read shows it landed, then a read of the whole database: every row the run touched back as it was and nothing else changed (`ROLLED_BACK`), or the exact rows a person repairs (`RECONCILIATION_REQUIRED`). A compensation runs only while every change in the store is positively the run's own by the app's audit log, so another user's work is never compensated away, and a hard delete runs only where the deployment allows it (void, restore or discard otherwise). `rollback qualify` stops the run before and after every save on a test copy and keeps only compensations that restore the whole store | On main ([#1634](https://github.com/OpenAdaptAI/OpenAdapt/pull/1634)) | MockMed, live browser through the CLI; SQLite whole-database tests | `openadapt-flow rollback qualify`, `rollback:` in deployment.yaml; [ROLLBACK.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/ROLLBACK.md) | +| Roll a stopped run back on its own: the approved compensation for each change, latest first, an uncertain write only when a fresh read shows it landed, then a read of the whole database: every row the run touched back as it was and nothing else changed (`ROLLED_BACK`), or the exact rows a person repairs (`RECONCILIATION_REQUIRED`). A compensation runs only while every change in the store is positively the run's own by the app's audit log, so another user's work is never compensated away, and a hard delete runs only where the deployment allows it (void, restore or discard otherwise). `rollback qualify` stops the run before and after every save on a test copy and keeps only compensations that restore the whole store. On a desktop or remote-display backend a screen compensation runs only after the run's and the compensation's start screens are checked on the same backend | On main ([#1634](https://github.com/OpenAdaptAI/OpenAdapt/pull/1634)); desktop in [#1771](https://github.com/OpenAdaptAI/OpenAdapt/pull/1771) | MockMed, live browser through the CLI; SQLite whole-database tests (web and desktop paths) | `openadapt-flow rollback qualify`, `rollback:` in deployment.yaml; [ROLLBACK.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/ROLLBACK.md) | | Leave the screen clean after an item that halted before any write: close dialogs and cancel forms with the recordings' own controls, re-enter, confirm the start screen and read the records back before the session takes another item. A session that doesn't come back is marked dirty and takes no more items; a cleanup that changed a record makes the item's write uncertain and stops the run. `cleanup qualify` tries the cleanup after every step on a test copy and refuses one that changes a record | On main ([#1607](https://github.com/OpenAdaptAI/OpenAdapt/pull/1607)) | Synthetic (scripted three-screen app through the real replayer and pool) and real app (pinned OpenEMR: a halted New Patient item left the confirmation dialog open; re-entry closed it, the form was empty, no patient row changed and the next item's first steps ran with no model calls; a signed-out screen was marked dirty; [results](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/benchmark/halt_cleanup/results/openemr.json)) | `openadapt-flow cleanup qualify`; [HALT_CLEANUP.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/HALT_CLEANUP.md) | ## Approvals @@ -610,11 +621,12 @@ improvising or retrying when something is off. | Surface | Status | Evidence | Where | | --- | --- | --- | --- | -| Web (Playwright, Chromium) | Released | Real app, every CI build | [SURFACES.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/SURFACES.md) | -| Native Windows (UI Automation) | Released | Synthetic (WinForms, 3 of 3 trials); real Windows 11 ARM machine: record, compile and replay of a WinForms form with an owned confirm dialog, 3 of 3 trials, each also refusing a target covered by another app's window and one blocked by a modal dialog before any input | [SURFACES.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/SURFACES.md) | -| Native macOS (Accessibility) | Released | 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 | Real Windows 11 host, 3 of 3, plus a FreeRDP batch; through the stock Remote Desktop client on a real H.264/AVC444 session, 105 runs on two apps: 55 saves confirmed in the database, 0 wrong-record saves, 40 false stops ranked by cause | [SURFACES.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/SURFACES.md), `backends/xfreerdp.py`, [benchmark/rdp_avc_real](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/benchmark/rdp_avc_real/README.md) | -| Citrix and other virtual desktops, with OpenAdapt installed inside the session (`in_session`): the same structure-first path as a local desktop | Released | Same evidence as the Windows path | [SURFACES.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/SURFACES.md) | +| Web (Playwright, Chromium) | Released | Real apps on local test copies (OpenEMR, Odoo, Frappe Lending, openIMIS) and Medplum; CI runs in-tree apps on every build | [SURFACES.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/SURFACES.md) | +| Native Windows (UI Automation) | Released | Synthetic (a WinForms test app in a Windows 11 VM, 3 of 3 trials); CI on a GitHub-hosted Windows runner on every change to the Windows path and nightly: record, compile and replay of the WinForms Patient Notes app through the real win_agent, judged from its SQLite record, with a near-identical sibling roster that must not be written ([desktop-replay.yml](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/.github/workflows/desktop-replay.yml)); on a real Windows 11 ARM machine, record, compile and replay of the same kind of WinForms test form with an owned confirm dialog, 3 of 3 trials, each also refusing a target covered by another app's window and one blocked by a modal dialog before any input | [SURFACES.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/SURFACES.md) | +| Native macOS (Accessibility) | Released | Synthetic (TextEdit, 3 of 3 file effects, driving the backend directly); a real third-party app, calibre (Qt, SQLite library): record, compile (certified under `clinical-write`) and a `standard` run checked against the library's database, `VERIFIED` with only the target book changed; a same-title book by another author stopped at the identity check and two identical books were refused as ambiguous, each with nothing written ([benchmark/macos_native](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/benchmark/macos_native/README.md)) | [SURFACES.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/SURFACES.md) | +| Native Linux (AT-SPI, X11) | Released | Real AT-SPI in CI (`flow-linux-atspi`, Xvfb with a window manager): a GTK3 fixture (3 verified effects, 3 ambiguous and 3 stale targets refused) and a GTK3 chart app with SQLite: record, compile (certified under `clinical-write`), a `standard` run `VERIFIED` against the database, another patient's chart and a look-alike chart stopped before the save, and a repaired notice admitted on its AT-SPI element identity ([benchmark/repair_every_backend](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/benchmark/repair_every_backend/README.md)) | [LINUX_NATIVE.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/desktop/LINUX_NATIVE.md) | +| RDP (pixels; Aardwolf or headless FreeRDP with a session pool) | Released | A Windows 11 VM over Aardwolf RDP, 3 of 3 (transport and input), plus a FreeRDP batch; through the stock Remote Desktop client on a real H.264/AVC444 session, 105 scripted runs on two apps (OpenEMR in Edge and a WinForms test app): 55 saves confirmed in the database, 0 wrong-record saves, 40 false stops ranked by cause | [SURFACES.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/SURFACES.md), `backends/xfreerdp.py`, [benchmark/rdp_avc_real](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/benchmark/rdp_avc_real/README.md) | +| Citrix and other virtual desktops, with OpenAdapt installed inside the session (`in_session`): the same structure-first path as a local desktop | Released | The Windows path's evidence: a WinForms test app in a Windows 11 VM, 3 of 3 (synthetic) | [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 Citrix stand-in with no DOM: 3 healthy runs, 3 stops on drift | [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) | @@ -628,10 +640,12 @@ improvising or retrying when something is off. | Capability | Status | Evidence | Where | | --- | --- | --- | --- | -| Page structure (DOM, UI Automation, Accessibility) first, then template, OCR and geometry | Released | Real app | [RESOLUTION_LADDER.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/RESOLUTION_LADDER.md) | +| Page structure (DOM, UI Automation, Accessibility) first, then template, OCR and geometry | Released | Real apps on the web; synthetic on Windows and macOS | [RESOLUTION_LADDER.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/RESOLUTION_LADDER.md) | | 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 | On main ([#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 | On main ([#1269](https://github.com/OpenAdaptAI/OpenAdapt/pull/1269)) | Synthetic | [#1269](https://github.com/OpenAdaptAI/OpenAdapt/pull/1269) | +| Run a form's own script before an API save instead of refusing the form: field handlers, `onsubmit`, submit listeners and custom validate.js rules (OpenEMR's `pastDate`) run on the run's values in a scratch copy of the session with every write blocked, and the save is sent only when the page lets the submit through and would send the same body | On main | Synthetic (headless Chromium against a local test page) | [API_TIER.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/API_TIER.md#what-happens-at-run-time), `runtime/actuators/form_eval.py` | +| Declare the other writes in an API step's span as an ordered sequence: the page's own background requests (another document's script, loaded before the form, to an endpoint the form doesn't name) are listed and not sent; a write a step caused is sent in its recorded place before the save, read back by that step's record check, and a failure stops the run with nothing after it sent and nothing retried | On main | Synthetic | [API_TIER.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/API_TIER.md#other-writes-in-the-span) | | Approve an API step's exact request under per-item approval: one digest binds the request bytes, the record and the record check, and a changed request is not sent | On main ([#1355](https://github.com/OpenAdaptAI/OpenAdapt/pull/1355)) | Synthetic | [API_TIER.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/API_TIER.md#under-an-approval-mode) | | Run an API step in a durable run: checkpoint the span after the save is sent, never resend or redo a save that may have landed, and reconcile it by reading the record back | On main ([#1358](https://github.com/OpenAdaptAI/OpenAdapt/pull/1358)) | Synthetic | [API_TIER.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/API_TIER.md#in-a-durable-run) | | Run an admitted API step under a governed run authorization and the Standard or Regulated profile: the authorization names the API step by digest, the person who admits it names the right-record parameter, and the outcome reads `VERIFIED` | On main ([#1361](https://github.com/OpenAdaptAI/OpenAdapt/pull/1361)) | Synthetic | [API_TIER.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/API_TIER.md#under-governance) | @@ -657,16 +671,16 @@ improvising or retrying when something is off. | Self-repair: an agent proposes a fix for one halted click, Flow probes it up to the save without saving, a person approves | On main ([#1265](https://github.com/OpenAdaptAI/OpenAdapt/pull/1265)) | Synthetic (MockMed redesign) | `run --self-repair`, [#1265](https://github.com/OpenAdaptAI/OpenAdapt/pull/1265) | | A scroll that never finds the next click's target halts on that click, so self-repair can take a relabelled control behind a scroll; the refusal for unreviewed-risk steps names them | On main ([#1337](https://github.com/OpenAdaptAI/OpenAdapt/pull/1337)) | Synthetic; openIMIS drift row checked offline on its recorded frames | `runtime/replayer.py`, `qualify set-risk` | | Self-repair across several new steps: a bridge of up to three actions for a new required dialog or a control moved into a menu, each new step armed with a record check and probed up to the save, one person approving | On main ([#1325](https://github.com/OpenAdaptAI/OpenAdapt/pull/1325)) | Synthetic (MockMed menu and provider drifts, scripted agent; [benchmark/auto_repair](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/benchmark/auto_repair/README.md)) | `run --self-repair`, [SELF_REPAIR.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/SELF_REPAIR.md) | -| Self-repair's new clicks outside a browser: on a native app a click is admitted by its accessibility element (role, name, ancestry, window; unique in the pinned window), on pixels only by the record check, and either way only when a system-of-record verifier covers every save under `standard` or `regulated`; the new step carries the dialog around it as a screen pre-check, and only a person approves the fix | On main ([#1709](https://github.com/OpenAdaptAI/OpenAdapt/pull/1709)) | Synthetic, partly live: on OpenAdapt's native Cocoa test app with SQLite, through live macOS AX, the stop, the element identity read, the admission and the refusals without a record check ran with no wrong write ([benchmark/repair_every_backend](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/benchmark/repair_every_backend/README.md)) | `openadapt_flow/repair/element_identity.py`, [REPAIR_ON_EVERY_BACKEND.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/REPAIR_ON_EVERY_BACKEND.md) | +| Self-repair's new clicks outside a browser: on a native app a click is admitted by its accessibility element (role, name, ancestry, window; unique in the pinned window), on pixels only by the record check, and either way only when a system-of-record verifier covers every save under `standard` or `regulated`; the new step carries the dialog around it as a screen pre-check, and only a person approves the fix | On main ([#1709](https://github.com/OpenAdaptAI/OpenAdapt/pull/1709)) | Synthetic, partly live: on OpenAdapt's native Cocoa test app with SQLite, through live macOS AX, the stop, the element identity read, the admission and the refusals without a record check ran with no wrong write; qualified end to end on macOS AX ([#1715](https://github.com/OpenAdaptAI/OpenAdapt/pull/1715)): stop, admitted click, probe, replay and fault campaigns, one approval, resumed run `VERIFIED`; and on its GTK3 counterpart through live Linux AT-SPI, in CI, the same end to end. `run --self-repair` and repair by default start the app for each probe from `repair.session` ([#1717](https://github.com/OpenAdaptAI/OpenAdapt/pull/1717); [benchmark/repair_every_backend](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/benchmark/repair_every_backend/README.md)) | `openadapt_flow/repair/element_identity.py`, [REPAIR_ON_EVERY_BACKEND.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/REPAIR_ON_EVERY_BACKEND.md) | | 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) | | Find icons again on Citrix, RDP and VNC after a display-scale, zoom, font, theme or codec change, with a local model that counts look-alikes and stops on a tie (`--local-grounding`, off by default) | On main ([#1596](https://github.com/OpenAdaptAI/OpenAdapt/pull/1596)) | Synthetic (Chromium-drawn clinic screen, pixels only: icons found under 21 of 26 conditions, template matching 8; 0 wrong targets in 65; whole runs unchanged, 4 of 39 in both arms, because the layout, identity and census checks stop first). Held out on real AVC444 RDP frames: 0 wrong picks, 6 right, 75 abstentions in 81 | [benchmark/pixel_grounding](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/benchmark/pixel_grounding/README.md), `runtime/local_grounder.py` | | 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 | On main ([#1294](https://github.com/OpenAdaptAI/OpenAdapt/pull/1294)) | Synthetic | standard changes | | Repair a pinned bundle: the fix runs the pinned bundle's own qualification campaign on a test copy, keeps every write, effect, identity rule and record check, and is re-admitted with signed lineage (at most three repairs, no revoked key) that the pin verifies | On main | Synthetic | [SELF_REPAIR.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/SELF_REPAIR.md#a-pinned-bundle-re-qualify-and-re-admit) | -| Repair by default: once a repair agent is configured, every stop that wrote nothing goes to it without anyone starting it; a stop after a possible write goes to reconciliation; every stop is a row in a ledger with the funnel from stop to resumed item | On main ([#1360](https://github.com/OpenAdaptAI/OpenAdapt/pull/1360)) | Synthetic (MockMed, 22 drift modes, real Claude agent: 6 of 21 repairable stops resumed and verified, 0 wrong saves in 66 runs); routing over recorded OpenEMR stops; live openIMIS relabel: 3 of 3 reached repair and stopped with no wrong save; silent-saves 1.1.0 UI-change blocks: 18 of 28 items that always stopped now correct, 0 wrong | `run` (on by default, `--no-auto-repair`), `repair:` in `deploy.yaml`, [benchmark/auto_repair](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/benchmark/auto_repair/README.md) | +| Repair by default: once a repair agent is configured (outside a browser, with `repair.session`, [#1717](https://github.com/OpenAdaptAI/OpenAdapt/pull/1717)), every stop that wrote nothing goes to it without anyone starting it; a stop after a possible write goes to reconciliation; every stop is a row in a ledger with the funnel from stop to resumed item | On main ([#1360](https://github.com/OpenAdaptAI/OpenAdapt/pull/1360)) | Synthetic (MockMed, 22 drift modes, real Claude agent: 6 of 21 repairable stops resumed and verified, 0 wrong saves in 66 runs); routing over recorded OpenEMR stops; live openIMIS relabel: 3 of 3 reached repair and stopped with no wrong save; silent-saves 1.1.0 UI-change blocks: 18 of 28 items that always stopped now correct, 0 wrong | `run` (on by default, `--no-auto-repair`), `repair:` in `deploy.yaml`, [benchmark/auto_repair](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/benchmark/auto_repair/README.md) | | Repair a relabeled save button: prove from the recorded identity hash that only the label changed (same control id, kind and form), re-qualify the fix on the new release, and have a person admit the certified copy afresh, since a save button's identity is part of the record check | On main | Real app (local OpenEMR, synthetic data): after the save button was relabeled, the stopped item re-ran and saved once after 8.3 operator minutes and 763 s of re-qualification; 896 s from stop to done ([#1513](https://github.com/OpenAdaptAI/OpenAdapt/pull/1513)) | `openadapt_flow/repair/relabel.py`, [SELF_REPAIR.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/SELF_REPAIR.md#a-relabeled-control-proof-that-only-the-label-changed) | | A duplicate check that reads the app's match list (`no_duplicate_candidate`): stop only on a listed record with the item's last name or date of birth, fail closed on an unreadable or unloaded list | On main | Real app (local OpenEMR, synthetic data): false stops per 100 items 70.4 before, 16.4 after; 0 silent wrongs in 30 saves ([#1513](https://github.com/OpenAdaptAI/OpenAdapt/pull/1513)) | [DUPLICATE_CHECK.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/DUPLICATE_CHECK.md) | | A switched-off `problems_only` gate asks a person before the save instead of stopping the item | On main | Synthetic; real app in [#1513](https://github.com/OpenAdaptAI/OpenAdapt/pull/1513) | [APPROVAL_MODES.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/APPROVAL_MODES.md#when-the-mode-turns-itself-off) | -| 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) | +| Fleet drift patches: a verified fix with no patient data, shared across sites under policy, moved as files or through a local reference hub | 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) | ## Computer-use agents @@ -674,7 +688,7 @@ improvising or retrying when something is off. | --- | --- | --- | --- | | 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) | | Reuse a compiled program only for the record check and app environment it was admitted under (a changed record check or a new app version at the same origin is refused) | On main ([#1360](https://github.com/OpenAdaptAI/OpenAdapt/pull/1360)) | Unit tests | `TaskSignature(record_check=, environment=)`, `WebApp(environment_id=)`, [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 | On main ([#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)) | +| Code as action is the default run-1 agent (compile-agent, and `teach_run` in the MCP teach tools): 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, and every save the gate detects is read back from the system of record | On main ([#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) | | Teach over MCP: an agent does a new task once through OpenAdapt; the run is read back, compiled and admitted (a person confirms the record check; the agent admits only under a person-signed policy), and becomes a tool it calls with no model calls. Web, and Windows, macOS, Linux, RDP and Citrix with a person approving each save | On main ([#1335](https://github.com/OpenAdaptAI/OpenAdapt/pull/1335)) | Synthetic (MockMed over MCP, and through the real RDP and Citrix backends) | `openadapt-agent serve --teach`, [packages/agent](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/agent/README.md#teach-a-new-workflow-from-your-agent) | @@ -683,12 +697,13 @@ improvising or retrying when something is off. | See what your own agent gets wrong, with no risk: `openadapt shadow` is a loopback pass-through proxy beside the agent. It checks each write against the system of record (FHIR through `ensure_record`; SQL through the verifier's whole-database frame), checks each `CONFIRMED` again later, and writes `report.html` and `report.json`. It never blocks, changes or resends a write; the only delay is the read before a write (at most `hold_ms`) | [#1678](https://github.com/OpenAdaptAI/OpenAdapt/pull/1678) | Real app (Medplum 5.2.8, 37 cases through a fault proxy: scripted agent 18 of 18 harms caught, 0 correct writes refuted; Claude Sonnet 5.5 9 of 9 caught, 4 correct but corrected writes refuted; 0 writes by the shadow; 15 to 25 ms median added per write; setup 76.5 s machine time, 3 decisions); real app, SQL mode (Dolibarr 21 on MariaDB 11.4, 13 cases: 8 of 8 harms caught, 5 of 5 correct writes confirmed, 292 ms median added); synthetic (SQLite) | `openadapt shadow`, [SHADOW_MODE.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/SHADOW_MODE.md), [SHADOW_RESULTS.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/benchmark/fhir_medplum/public/SHADOW_RESULTS.md) | | Skill cache for framework agents: an agent in any framework does a browser task through compile-agent's harness as plain tools (`BrowserTools`); when the system of record shows exactly the task's record and the operator confirms the checks, the run is canaried, admitted and emitted as an MCP server. A repeat of the same task (instruction, record, app, contract, environment) calls that tool instead of the agent: no model calls, the program's checks and this task's record check on every run, eviction after a failed run | On main | Synthetic (MockMed, LangGraph on `claude-sonnet-5-5`, 3 trials, repeats on the same record: run 1 6 model calls and $0.022; runs 2 to 10 27/27 VERIFIED, 0 model calls, 7.8 s median); Trac 1.6: run 1 verified, not admitted | `openadapt_flow.integrations.agents.skills`, [benchmark/skill_cache](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/benchmark/skill_cache/README.md) | | Verified write tools for agent frameworks: `verified(tool, contract=...)` wraps a Claude Agent SDK, OpenAI Agents SDK, LangGraph or CrewAI write tool in one line. The contract and any values the operator pins come from the operator, never the agent's arguments; the store is read before and after; the agent gets a receipt or a refusal, and a refused write is held instead of retried | On main | Synthetic (each framework's own agent loop with a scripted model, writing through the example app's order form over real HTTP: verified save, a save the app drops, a save it keeps twice); one real-model run per framework, opt-in | `openadapt_flow.integrations.agents`, extras `claude-agent-sdk`, `openai-agents`, `langgraph` (CrewAI: install `crewai` beside it); [AGENT_FRAMEWORKS.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/AGENT_FRAMEWORKS.md) | -| Teach a browser write as a verified tool: with write contracts (`serve --teach-browser --contracts DIR`), the agent teaches a save once; run 1 is verified in the system of record before the `taught_` tool exists, and every call makes the write and returns a verified receipt or a refusal. Sign-in values come from the server's environment and never reach the agent or the program. Controls in named iframes are found through their frame | On main ([#1589](https://github.com/OpenAdaptAI/OpenAdapt/pull/1589)) | Synthetic (the example app's sign-in and order form in headless Chromium over MCP: verified save, a save the app drops, a save it keeps twice) | `openadapt-agent serve --teach-browser --contracts`, [VERIFY_WRITES.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/VERIFY_WRITES.md#teach-a-write-as-a-tool) | +| Browser teach refuses taught writes: a write an agent teaches needs a person's signed teach authorization, and this lane can't bind one, so `teach_start` with a contract is refused and an existing write tool doesn't run. Writes are taught through `serve --teach --teach-authorization`. The read-only lane (downloads) is unchanged | On main ([#1589](https://github.com/OpenAdaptAI/OpenAdapt/pull/1589), [#1436](https://github.com/OpenAdaptAI/OpenAdapt/pull/1436)) | Unit test of the refusal | `openadapt-agent serve --teach-browser`, [TEACH_AUTHORIZATION.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/TEACH_AUTHORIZATION.md) | | Guard an agent you already run: attach to its own Chromium over CDP (one added line), hold each save until the record on screen is the one the task names, block it and tell the agent why when it isn't, read the save back; a run that reads back correctly becomes a draft program with a record check from scaffold-verifier, which a person confirms and admits, then a `taught_*` tool with no model calls. `--observe-only` never blocks | On main ([#1357](https://github.com/OpenAdaptAI/OpenAdapt/pull/1357), stacked on [#1335](https://github.com/OpenAdaptAI/OpenAdapt/pull/1335)) | Synthetic (MockMed, scripted Playwright agent, `effect_e2e` faults) | `openadapt-flow guard`, [GUARD_AN_AGENT.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/GUARD_AN_AGENT.md) | | Task ledger per job: rules locked when the job opens and enforced on every run, item status set only from OpenAdapt's own results, and yes-or-no questions a person answers on the phone or in Desktop, including when a run stops | On main ([#1332](https://github.com/OpenAdaptAI/OpenAdapt/pull/1332)) | Synthetic (30-item MockMed worklist, scripted agent, no model calls) | MCP tools `open_job`, `get_job`, `add_items`, `record_evidence`, `ask_user`, `get_answer`, `change_constraint`, `close_job`; [Track a long job](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/agent/README.md#track-a-long-job) | | Limit a teaching agent to the declared task on every teach path (`compile_agent`, `compile-agent`, teach over MCP, the airbag guard): typed text must be a declared value, links and browser requests to other origins are refused, writes are checked before they are sent, and the record is read back before a draft exists. Injected instructions in record notes, banners, tooltips, alt text and server responses are blocked or caught on MockMed through each path (scripted agent; a live-model mode is off by default). Self-repair refuses a control that leaves the app | On main ([#1359](https://github.com/OpenAdaptAI/OpenAdapt/pull/1359), [#1386](https://github.com/OpenAdaptAI/OpenAdapt/pull/1386)) | Synthetic | `openadapt_flow.teach_scope`, [THREAT_MODEL.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/THREAT_MODEL.md) | +| Teach authorization: a person signs what a teaching agent may do (task, origins, inputs, the one write, forbidden actions and controls) with their approver key, and every teach path is bound to it or refused. Verified with the approver roster's own verifier, so an unknown, removed or wrong key, an unpinned root, a changed statement or another task refuses. Admission of every JIT draft needs one (`teach_auth_not_recorded` otherwise), as do an agent-demonstrated fix's approval and self-repair of an agent-taught program; the airbag handoff's drafts and browser-teach writes are refused | On main ([#1436](https://github.com/OpenAdaptAI/OpenAdapt/pull/1436)) | Synthetic (real approver keys, real roster, real admission) | `openadapt-flow teach-authorization`, [TEACH_AUTHORIZATION.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/TEACH_AUTHORIZATION.md) | | Compile an agent's first verified run from a prompt (`compile-agent`), and a code-as-action authoring agent | On main ([#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/` | +| 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) | `benchmark/harness/harness2/` (source checkout) | | One scoreboard comparing the agent alone with OpenAdapt across tasks (correctness by read-back, cost, time, items per hour) | On main ([#1303](https://github.com/OpenAdaptAI/OpenAdapt/pull/1303)) | Synthetic and local OpenEMR and openIMIS test copies | `benchmark/scoreboard/` | | Pull a skill from the registry (`pull app/task@version`) and run it only after qualifying it on your own instance: `pull` marks it and expires any certification it carries, `skill bind` records your addresses and the names of the variables that hold credentials (never their values), `skill qualify` runs its bundled record check there, and `run`, `replay`, `emit-skill` and `emit-mcp` refuse it until then, and on any other instance. The index and its archives carry a keyless Sigstore attestation from the registry release workflow, checked offline against a pinned identity, and expire and can't be rolled back; a shipped record check runs no code and reads only your binding | On main | Unit tests | `openadapt-flow skill`, [SKILLS_REGISTRY.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/design/SKILLS_REGISTRY.md) | @@ -710,6 +725,7 @@ improvising or retrying when something is off. | Offline Seals signed with a key the customer holds: a key file, the OS keychain, or a PKCS#11 token or HSM; pinned keys with rotation and revocation; no fallback to a generated key | On main ([#1392](https://github.com/OpenAdaptAI/OpenAdapt/pull/1392)) | Unit tests with synthetic data (keychain and PKCS#11 through stand-ins) | `openadapt-flow local-seal --key`, `local-seal key create`, `pin`, `revoke`; `openadapt seal verify FOLDER` checks every sealed run in a folder; [OFFLINE_SEALS.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/OFFLINE_SEALS.md#customer-held-keys) | | Anchor an offline Seal in the public Sigstore Rekor transparency log (opt-in; sends one digest and the public key), and verify the anchor offline from the stored inclusion proof | On main ([#1394](https://github.com/OpenAdaptAI/OpenAdapt/pull/1394)) | Unit tests against a simulated Rekor log; real public Rekor entries and Sigstore TSA tokens for the landing demo's referral runs, verified offline in tests; Ed25519ph matches the RFC 8032 test vector | `openadapt-flow local-seal anchor`, `local-seal verify --require-anchor`; [OFFLINE_SEALS.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/OFFLINE_SEALS.md#optional-anchor-a-seal-in-a-public-transparency-log) | | Signed write receipts: a verifier verdict with its scope (stores read, window, strength) and a plain not-checked list, signed as a COSE_Sign1 (IETF SCITT shape) and logged in a local Merkle transparency log; checked offline against pinned keys; a receipt that claims more than its scope is neither issued nor accepted; optional Rekor anchor | On main | Unit tests on the real verifier (SQLite) and shadow proxy; COSE checked through python-cwt | `openadapt-flow receipt verify`, `receipt keys export`, `receipt anchor`; `verify_write(..., write_receipt=True)`; `shadow --receipts`; [WRITE_RECEIPTS.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/WRITE_RECEIPTS.md) | +| See a run's Seal offline: one HTML page that checks the Ed25519 signature over the run's exact report in the browser, draws the Seal from it, replays the run over its program map, and lets the reader tamper with a copy; no network requests; the quickstart ends on the person's own | On main ([#1566](https://github.com/OpenAdaptAI/OpenAdapt/pull/1566)) | A real quickstart run's Seal opened from `file://` in Chromium (valid, tampered, no requests); RFC 8032 test vectors; a published OpenEMR Seal's Rekor entry and timestamp | `openadapt seal view RUN_DIR`; `openadapt quickstart`; [`@openadapt/seal-view`](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/seal-view/README.md) | | Run authorizations signed with the authorizer's GitHub SSH key (`ssh-keygen -Y sign`, after a summary of what it allows), naming the signing key and bound to the bundle digest, items, profile and Seal keys, and referenced by each Seal; the identity checked against github.com (`--live`) or pinned allowed signers; a pseudonymous operator profile counted from anchored Seals only | On main ([#1401](https://github.com/OpenAdaptAI/OpenAdapt/pull/1401)) | Unit tests with synthetic data; signatures checked against OpenSSH's `ssh-keygen` both ways | `openadapt-flow local-seal authorize`, `local-seal run --authorization`, `local-seal profile`; [OFFLINE_SEALS.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/OFFLINE_SEALS.md#run-authorizations-who-authorized-the-runs) | | Run reports redacted before they are sealed: session and CSRF tokens, OAuth grants and credential headers become `` and are listed in `redactions`; a Seal refuses a report that still holds one; per-app policy | On main ([#1407](https://github.com/OpenAdaptAI/OpenAdapt/pull/1407)) | Unit tests with generated tokens; 7,773 local reports scanned, only OpenEMR `token_main` matched | `openadapt-flow redact-report`, `local-seal run --allow-unredacted`; [REPORT_REDACTION.md](https://github.com/OpenAdaptAI/OpenAdapt/blob/main/packages/flow/docs/REPORT_REDACTION.md) | | 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) | @@ -758,6 +774,7 @@ A test fails when a command has no file or this list is out of date. - `propose-qualification` - `qualify` - `scaffold-verifier` +- `teach-authorization` **Run and answer:**