From 1191e11c0b622e7acb70abf4be2a250dd0256468 Mon Sep 17 00:00:00 2001 From: abrichr Date: Tue, 3 Nov 2026 13:27:29 -0500 Subject: [PATCH 01/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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 78cd2447636ec2554e875a0586b1c91121ff22ee Mon Sep 17 00:00:00 2001 From: abrichr Date: Tue, 3 Nov 2026 20:02:41 -0500 Subject: [PATCH 13/23] 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 03a34a8338ac4e533d9c17f6c37f453350a94d99 Mon Sep 17 00:00:00 2001 From: abrichr Date: Tue, 3 Nov 2026 21:40:21 -0500 Subject: [PATCH 14/23] 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 1d7a3aa1267aeedb42e054b79ffb80b3ada624f0 Mon Sep 17 00:00:00 2001 From: abrichr Date: Wed, 4 Nov 2026 03:32:26 -0500 Subject: [PATCH 15/23] 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 bb478b54bb54f64d05bb6e32be6ea8a95e4630a1 Mon Sep 17 00:00:00 2001 From: abrichr Date: Wed, 4 Nov 2026 04:38:51 -0500 Subject: [PATCH 16/23] Keep sealed run contents out of teach refusals and refuse an --out that overlaps an input --- .../openadapt_flow/learning/branch_teach.py | 8 ++ .../flow/openadapt_flow/learning/teach.py | 80 ++++++++++++++++-- packages/flow/tests/test_artifact_reads.py | 82 +++++++++++++++++++ 3 files changed, 163 insertions(+), 7 deletions(-) diff --git a/packages/flow/openadapt_flow/learning/branch_teach.py b/packages/flow/openadapt_flow/learning/branch_teach.py index 811ac469b5..505f75b0c6 100644 --- a/packages/flow/openadapt_flow/learning/branch_teach.py +++ b/packages/flow/openadapt_flow/learning/branch_teach.py @@ -89,6 +89,7 @@ _spec_from_recording, _texts_seen_before_halt, _was_delivered, + check_out_is_new, check_provenance, load_base_program, load_halt_report, @@ -544,6 +545,13 @@ def teach_branch( "teach-branch needs at least two resolved halts. For one, use " "`openadapt-flow teach`" ) + inputs: dict[str, Path | str] = {"base bundle": bundle} + for i, ex in enumerate(examples): + inputs[f"halted run {i + 1}"] = ex.run_dir + inputs[f"fix {i + 1}"] = ex.fix + for i, negative in enumerate(negatives): + inputs[f"normal run {i + 1}"] = negative + check_out_is_new(out, library=library_dir, **inputs) base, program = load_base_program(bundle) loaded = [_load_example(ex, base, program, Path(bundle)) for ex in examples] n_neg = len(negatives) diff --git a/packages/flow/openadapt_flow/learning/teach.py b/packages/flow/openadapt_flow/learning/teach.py index 648c0c37b4..79b4780036 100644 --- a/packages/flow/openadapt_flow/learning/teach.py +++ b/packages/flow/openadapt_flow/learning/teach.py @@ -188,22 +188,35 @@ def check_provenance( *, run_dir: Path | str, bundle_dir: Path | str, + private: bool = False, ) -> None: """Require that the halted run came from the bundle passed as ``--bundle``. A mismatched pair can't be taught: the correction would be spliced into a program that never ran, and the learn loop would only report a generic refusal. Raises :class:`TeachError` before anything is written. + + With ``private`` (sealed input or strict mode) the message names the halt + by its state id only, never a workflow name or step intent read from the + encrypted report. """ + halt = report.halt + assert halt is not None # guaranteed by load_halt_report + if private: + stale = ( + f"the run at {run_dir} (halt {halt.state_id!r}) did not stop in a " + f"program --bundle {bundle_dir} holds. Pass the bundle whose replay " + "stopped." + ) if report.workflow_name != base_workflow.name: + if private: + raise TeachError(stale) raise TeachError( f"the run at {run_dir} came from the workflow " f"{report.workflow_name!r}, but --bundle {bundle_dir} is the " f"workflow {base_workflow.name!r}. Pass the bundle whose replay " "stopped." ) - halt = report.halt - assert halt is not None # guaranteed by load_halt_report graphs = [program, *base_workflow.subflows.values()] if any(halt.state_id in graph.states for graph in graphs): return @@ -215,6 +228,8 @@ def check_provenance( for state in graph.states.values() ): return + if private: + raise TeachError(stale) raise TeachError( f"the step where the run at {run_dir} stopped ({halt.intent!r}) is not " f"in --bundle {bundle_dir}. Pass the bundle whose replay stopped." @@ -488,9 +503,13 @@ def _plan_correction( *, run_dir: Path, recorded: dict[str, Step], + private: bool = False, ) -> _Plan: """Build the ``(correction, baseline)`` traces, or a refusal reason. + With ``private`` a refusal names the halted step by its state id, never + by the intent read from the encrypted report. + Places the fix where the obstruction appeared. ``tail`` is the halted step and the remainder of the program. @@ -514,6 +533,7 @@ def _plan_correction( ) halt = report.halt assert halt is not None + step = f"halt {halt.state_id!r}" if private else repr(halt.intent) halted = _halted_result(report) delivered = _was_delivered(halted) halted_step = _halted_step(program, report) @@ -540,7 +560,7 @@ def _plan_correction( if halted_step is None: return _Plan( refusal=( - f"the run stopped after {halt.intent!r} acted, and teach " + f"the run stopped after {step} acted, and teach " "can't find that step in --bundle, so it can't place the " "fix after it" ) @@ -548,7 +568,7 @@ def _plan_correction( if repeats_halted and _can_write(halted_step): return _Plan( refusal=( - f"the run stopped after {halt.intent!r} was delivered, and " + f"the run stopped after {step} was delivered, and " "that step can write to the record. The fix repeats it, " "and nothing shows that the first delivery wrote nothing, " "so the learned workflow could save twice. Check the " @@ -558,7 +578,7 @@ def _plan_correction( if not repeats_halted and halted_step.effects: return _Plan( refusal=( - f"the run stopped after {halt.intent!r} was delivered, and " + f"the run stopped after {step} was delivered, and " "its write is checked against the system of record only " "when its screen check passes. After the fix nothing would " "check that write, so teach won't learn this fix" @@ -904,6 +924,40 @@ def _check_sealed_out(out_bundle: Path) -> None: ) +def _overlap(a: Path, b: Path) -> bool: + """Is ``a`` the same path as ``b`` (through symlinks, or the same inode), + or inside it, or around it?""" + ra, rb = Path(os.path.realpath(a)), Path(os.path.realpath(b)) + if ra == rb or ra.is_relative_to(rb) or rb.is_relative_to(ra): + return True + try: + return os.path.samefile(a, b) + except OSError: + return False + + +def check_out_is_new( + out: Path | str, *, library: Optional[Path | str] = None, **inputs: Path | str +) -> None: + """Refuse an ``--out`` (or its skill library) that is, holds or sits + inside an input: the base bundle, the halted run or the fix. Teach writes + the taught bundle there, so the base program would be overwritten. Checked + before anything is read or written; a symlink alias counts as the input.""" + out_path = Path(out) + lib = ( + Path(library) + if library is not None + else out_path.parent / f"{out_path.name}.skills" + ) + for target, flag in ((out_path, "--out"), (lib, "the skill library")): + for name, path in inputs.items(): + if _overlap(target, Path(path)): + raise TeachError( + f"{flag} {target} is or overlaps the {name} input {path}. " + "Pass a new directory: teach never writes over its inputs" + ) + + def _write_taught_bundle( updated: Workflow, bundle: Path, @@ -973,12 +1027,24 @@ def teach( fix, a run that did not come from ``bundle``, or a library at ``library_dir`` seeded from a different bundle). """ + check_out_is_new( + out, + library=library_dir, + **{"halted run": run_dir, "fix": fix, "base bundle": 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) + check_provenance( + report, + base_workflow, + program, + run_dir=run_dir, + bundle_dir=bundle, + private=seal, + ) sid = skill_id or report.workflow_name spec = _load_fix(fix) @@ -987,7 +1053,7 @@ def teach( if spec is None: spec, recorded, carried = _spec_from_recording(Path(fix), report) plan = _plan_correction( - spec, program, report, run_dir=Path(run_dir), recorded=recorded + spec, program, report, run_dir=Path(run_dir), recorded=recorded, private=seal ) if plan.refusal: # Refused before learning: nothing is written, not even a library. diff --git a/packages/flow/tests/test_artifact_reads.py b/packages/flow/tests/test_artifact_reads.py index 4eee080737..e7509ae144 100644 --- a/packages/flow/tests/test_artifact_reads.py +++ b/packages/flow/tests/test_artifact_reads.py @@ -601,6 +601,88 @@ def test_teach_refuses_a_plaintext_library_for_sealed_input( assert not out.exists() +_MARKER = "PRIVATE-MARKER-4f1c" + + +@pytest.mark.parametrize("stale", ["state", "workflow"]) +def test_teach_refusal_on_a_sealed_run_never_prints_its_contents( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capfd, stale: str +) -> None: + """A provenance refusal names the halt by its state id, never by a step + intent or workflow name read from the encrypted halt report.""" + from tests.test_teach_cli import _base_bundle, _dismiss_recording, _halt_run + + monkeypatch.setenv(crypto.ENV_KEY, KEY) + plain = tmp_path / "plain" + plain.mkdir() + base = _base_bundle(plain) + _halt_run(base, plain / "run") + fix = _dismiss_recording(plain / "fix") + report = json.loads((plain / "run" / "report.json").read_text()) + if stale == "state": + report["halt"]["state_id"] = "s::gone" + report["halt"]["intent"] = _MARKER + else: + report["workflow_name"] = _MARKER + (plain / "run" / "report.json").write_text(json.dumps(report)) + run = _seal_dir(plain / "run", tmp_path / "run", "run") + for path in (plain / "run").rglob("*"): + if path.is_file(): + path.unlink() + monkeypatch.setenv("OPENADAPT_FLOW_AT_REST", "strict") + out = tmp_path / "taught" + args = ["teach", str(run), "--fix", str(fix), "--bundle", str(base)] + capfd.readouterr() + assert main([*args, "--out", str(out)]) == 2 + printed = capfd.readouterr() + assert _MARKER not in printed.out + printed.err + assert "teach cannot run" in printed.out + assert repr(report["halt"]["state_id"]) in printed.out + assert not out.exists() + for path in tmp_path.rglob("*"): + if path.is_file(): + assert _MARKER.encode() not in path.read_bytes(), path + + +@pytest.mark.parametrize("alias", ["same path", "symlink", "inside"]) +def test_teach_never_writes_over_the_sealed_base( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys, alias: str +) -> None: + monkeypatch.setenv(crypto.ENV_KEY, KEY) + run, fix, bundle = _teach_inputs(tmp_path) + if alias == "same path": + out = bundle + elif alias == "symlink": + out = tmp_path / "alias" + out.symlink_to(bundle, target_is_directory=True) + else: + out = bundle / "taught" + before = {p: _files(p) for p in (run, fix, bundle)} + args = ["teach", str(run), "--fix", str(fix), "--bundle", str(bundle)] + capsys.readouterr() + assert main([*args, "--out", str(out)]) == 2 + printed = capsys.readouterr().out + assert "teach cannot run" in printed and "overlaps the base bundle" in printed + assert {p: _files(p) for p in (run, fix, bundle)} == before + assert not (out.parent / f"{out.name}.skills").exists() + assert not (fix / "_teach_compiled").exists() + assert Workflow.load(bundle, key=KEY).name + + +@pytest.mark.parametrize("which", ["run", "fix"]) +def test_teach_refuses_an_out_that_is_another_input( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys, which: str +) -> None: + monkeypatch.setenv(crypto.ENV_KEY, KEY) + run, fix, bundle = _teach_inputs(tmp_path) + out = {"run": run, "fix": fix}[which] + before = {p: _files(p) for p in (run, fix, bundle)} + args = ["teach", str(run), "--fix", str(fix), "--bundle", str(bundle)] + assert main([*args, "--out", str(out)]) == 2 + assert "overlaps the" in capsys.readouterr().out + assert {p: _files(p) for p in (run, fix, bundle)} == before + + # -- push of a sealed source -------------------------------------------------- From 017b1fddcfb436e29c181b033652781393a3ee8f Mon Sep 17 00:00:00 2001 From: abrichr Date: Wed, 4 Nov 2026 04:48:20 -0500 Subject: [PATCH 17/23] Withhold the details of a push refusal caused by sealed content --- packages/flow/openadapt_flow/__main__.py | 26 ++++++++- packages/flow/tests/test_artifact_reads.py | 64 ++++++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/packages/flow/openadapt_flow/__main__.py b/packages/flow/openadapt_flow/__main__.py index 68ad0f2a4e..6dfaa4003a 100644 --- a/packages/flow/openadapt_flow/__main__.py +++ b/packages/flow/openadapt_flow/__main__.py @@ -7821,6 +7821,30 @@ def _print_push_json(document: dict[str, Any]) -> None: print(json.dumps(document, sort_keys=True, separators=(",", ":"))) +def _push_failure_reason(args: argparse.Namespace, error: Exception) -> str: + """What ``push`` prints when it fails. + + A failure caused by another error (a validation error, a scrub or + sanitization failure) can quote what it read. When the input is sealed or + strict mode is on, that text came out of encrypted content, so push names + only the kind of error. A failure push raised itself prints as it is. + """ + from openadapt_flow import at_rest, sealed_set + + cause = error.__cause__ or error.__context__ + if cause is None: + return str(error) + path = getattr(args, "path", None) + if not at_rest.strict() and not (path and sealed_set.is_sealed_path(path)): + return str(error) + while cause.__cause__ is not None or cause.__context__ is not None: + cause = cause.__cause__ or cause.__context__ + return ( + f"the input was refused ({type(cause).__name__}). The details are " + "withheld because the input is sealed or strict mode is on." + ) + + def _cmd_push(args: argparse.Namespace) -> int: """Upload the exact approved sanitized archive to ``/api/ingest``. @@ -7865,7 +7889,7 @@ def _cmd_push(args: argparse.Namespace) -> int: if args.json: _print_push_json(_push_json_failure()) return 1 - print(f"push failed: {e}") + print(f"push failed: {_push_failure_reason(args, e)}") return 1 if args.json: try: diff --git a/packages/flow/tests/test_artifact_reads.py b/packages/flow/tests/test_artifact_reads.py index e7509ae144..131eb27949 100644 --- a/packages/flow/tests/test_artifact_reads.py +++ b/packages/flow/tests/test_artifact_reads.py @@ -812,6 +812,70 @@ def test_push_in_strict_mode_seals_the_review_copy_of_a_plain_recording( _no_plaintext_under(tmp_path) +def _bundle_with_a_bad_action(source: Path, out: Path, *, key=None) -> Path: + """A copy of ``source`` whose first action is the private marker.""" + opened = sealed_set.open_set(source, KEY) + files = {rel: opened.read_bytes(rel) for rel in opened.files("")} + doc = json.loads(files["workflow.json"]) + doc["steps"][0]["action"] = _MARKER + files["workflow.json"] = json.dumps(doc).encode() + if key is None: + for rel, data in files.items(): + (out / rel).parent.mkdir(parents=True, exist_ok=True) + (out / rel).write_bytes(data) + return out + with sealed_set.writing(out, "bundle", key): + for rel, data in files.items(): + sealed_set.write_bytes(out / rel, data) + return out + + +@pytest.mark.parametrize("strict", [True, False], ids=["strict", "sealed input"]) +def test_push_refusal_on_a_sealed_bundle_never_prints_its_contents( + sealed: Path, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + push_env, + capsys, + strict: bool, +) -> None: + from openadapt_flow import hosted + + monkeypatch.setenv(hosted.AUTO_APPROVE_ENV, "false") + if strict: + monkeypatch.setenv("OPENADAPT_FLOW_AT_REST", "strict") + bad = _bundle_with_a_bad_action(sealed, tmp_path / "bad", key=KEY) + assert sealed_set.is_sealed(bad) + push = ["push", str(bad), "--kind", "bundle", "--host", "https://h.test"] + assert main([*push, "--token", "tok"]) == 1 + captured = capsys.readouterr() + assert _MARKER not in captured.out + captured.err + assert "push failed: the input was refused (ValidationError)" in captured.out + assert "withheld" in captured.out + for path in tmp_path.rglob("*"): + if path.is_file(): + assert _MARKER.encode() not in path.read_bytes(), path + + +def test_push_refusal_on_a_plain_bundle_keeps_its_details( + sealed: Path, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + push_env, + capsys, +) -> None: + """Outside strict mode a plaintext input's refusal says what is wrong.""" + from openadapt_flow import hosted + + monkeypatch.setenv(hosted.AUTO_APPROVE_ENV, "false") + bad = _bundle_with_a_bad_action(sealed, tmp_path / "bad") + assert not sealed_set.is_sealed(bad) + push = ["push", str(bad), "--kind", "bundle", "--host", "https://h.test"] + assert main([*push, "--token", "tok"]) == 1 + out = capsys.readouterr().out + assert "Cannot validate generated workflow.py" in out and _MARKER in out + + def test_a_plaintext_copy_of_a_sealed_bundle_never_loads_as_encrypted( sealed: Path, tmp_path: Path ) -> None: From 20b420ae2cdc7ee3d87cca3784fcfc21a26f3ff2 Mon Sep 17 00:00:00 2001 From: abrichr Date: Wed, 4 Nov 2026 05:21:09 -0500 Subject: [PATCH 18/23] Withhold decrypted input from every refusal Validation errors are reported through one shared formatter that drops the input value. Teach summaries on sealed or strict input give only the verdict and a withheld reason, and an unexpected failure on such input prints no traceback. A sealed skill library now reads back with its own key. --- packages/flow/docs/phi_at_rest.md | 10 +- packages/flow/openadapt_flow/__main__.py | 53 +++++---- .../flow/openadapt_flow/learning/library.py | 12 +- .../flow/openadapt_flow/learning/teach.py | 41 +++++-- packages/flow/openadapt_flow/safe_errors.py | 44 +++++++ packages/flow/tests/test_artifact_reads.py | 109 +++++++++++++++--- 6 files changed, 220 insertions(+), 49 deletions(-) create mode 100644 packages/flow/openadapt_flow/safe_errors.py diff --git a/packages/flow/docs/phi_at_rest.md b/packages/flow/docs/phi_at_rest.md index 1302312033..1c8773ea43 100644 --- a/packages/flow/docs/phi_at_rest.md +++ b/packages/flow/docs/phi_at_rest.md @@ -289,7 +289,9 @@ they write inside a sealed set sealed. Each reads artifact files through one rea 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. + input lands beside plaintext. An `--out` or library that is, contains, or + is inside any input (by real path or by inode, so a symlink alias counts) + is refused before anything is written. - `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 @@ -300,6 +302,12 @@ they write inside a sealed set sealed. Each reads artifact files through one rea encrypts the approved archive; the upload decrypts that archive in memory and sends the exact approved bytes. +Refusals never quote what they read. A validation error's quoted input value +is dropped from every refusal the CLI prints. With sealed input or in strict +mode, `teach` names a stopped run by its halt id instead of the step intent or +workflow name, a `push` failure caused by another error names only that +error's type, and an unexpected error prints its type instead of a traceback. + Without the key, each of these refuses before it reads anything and names the variable to set. Any other command given a sealed path refuses with a message that lists the commands above, rather than decrypt to disk. A bare diff --git a/packages/flow/openadapt_flow/__main__.py b/packages/flow/openadapt_flow/__main__.py index 6dfaa4003a..a987c1af89 100644 --- a/packages/flow/openadapt_flow/__main__.py +++ b/packages/flow/openadapt_flow/__main__.py @@ -7827,22 +7827,16 @@ def _push_failure_reason(args: argparse.Namespace, error: Exception) -> str: A failure caused by another error (a validation error, a scrub or sanitization failure) can quote what it read. When the input is sealed or strict mode is on, that text came out of encrypted content, so push names - only the kind of error. A failure push raised itself prints as it is. + only the kind of error. A failure push raised itself prints as it is, + without any quoted input value. """ - from openadapt_flow import at_rest, sealed_set + from openadapt_flow import at_rest, safe_errors, sealed_set - cause = error.__cause__ or error.__context__ - if cause is None: - return str(error) - path = getattr(args, "path", None) - if not at_rest.strict() and not (path and sealed_set.is_sealed_path(path)): - return str(error) - while cause.__cause__ is not None or cause.__context__ is not None: - cause = cause.__cause__ or cause.__context__ - return ( - f"the input was refused ({type(cause).__name__}). The details are " - "withheld because the input is sealed or strict mode is on." - ) + if error.__cause__ is not None or error.__context__ is not None: + path = getattr(args, "path", None) + if at_rest.strict() or (path and sealed_set.is_sealed_path(path)): + return safe_errors.withheld(error) + return safe_errors.message(error) def _cmd_push(args: argparse.Namespace) -> int: @@ -8355,7 +8349,9 @@ def _cmd_teach(args: argparse.Namespace) -> int: library_dir=Path(args.library) if args.library else None, ) except TeachError as e: - print(f"teach cannot run: {e}") + from openadapt_flow import safe_errors + + print(f"teach cannot run: {safe_errors.message(e)}") return 2 print(result.summary()) @@ -8390,7 +8386,9 @@ def _cmd_teach_branch(args: argparse.Namespace) -> int: library_dir=Path(args.library) if args.library else None, ) except TeachError as e: - print(f"teach-branch cannot run: {e}") + from openadapt_flow import safe_errors + + print(f"teach-branch cannot run: {safe_errors.message(e)}") return 2 print(result.summary()) return 0 if result.promoted else 1 @@ -13391,6 +13389,16 @@ def _sealed_paths_in(args: argparse.Namespace) -> list[Path]: return found +def _private_input(args: argparse.Namespace) -> bool: + """Whether the command read sealed input or runs in strict mode.""" + from openadapt_flow import at_rest, sealed_set + + try: + return at_rest.strict() or bool(_sealed_paths_in(args)) + except (sealed_set.SealedSetError, ValueError, OSError): + return True # cannot tell: treat it as private + + def _sealed_input_refusal(args: argparse.Namespace) -> Optional[str]: """Refuse a sealed path in a command that cannot keep it sealed, and a sealed path whose key is not configured, before the command runs.""" @@ -13471,21 +13479,21 @@ def main(argv: Sequence[str] | None = None) -> int: except (OSError, ValueError) as exc: print(f"REFUSED (deployment_config_changed): {exc}", file=sys.stderr) return 2 - from openadapt_flow import sealed_set + from openadapt_flow import safe_errors, sealed_set try: refusal = _sealed_input_refusal(args) except sealed_set.SealedSetError as exc: refusal = str(exc) if refusal is not None: - print(f"REFUSED: {refusal}", file=sys.stderr) + print(f"REFUSED: {safe_errors.message(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) + print(f"REFUSED: {safe_errors.message(exc)}", file=sys.stderr) return 2 try: code = args.func(args) @@ -13503,7 +13511,7 @@ def main(argv: Sequence[str] | None = None) -> int: # A sealed bundle or encrypted recording that is missing its key, # was changed after sealing, or holds an unknown file, or a write # strict mode refuses: fail closed. - print(f"REFUSED: {exc}", file=sys.stderr) + print(f"REFUSED: {safe_errors.message(exc)}", file=sys.stderr) return 2 except BrowserSupportMissing as exc: # Missing optional browser support is a setup decision, not a crash. @@ -13513,6 +13521,11 @@ def main(argv: Sequence[str] | None = None) -> int: from openadapt_flow.runtime.known_ids import KnownIdAdmissionHalt if not isinstance(exc, KnownIdAdmissionHalt): + if _private_input(args): + # A traceback would quote what the command read from sealed + # input: name the kind of error only. + print(f"FAILED: {safe_errors.withheld(exc)}", file=sys.stderr) + return 1 raise # The configured known-ID list may not be used: halt before any step # runs, with the named reason. diff --git a/packages/flow/openadapt_flow/learning/library.py b/packages/flow/openadapt_flow/learning/library.py index bca1661744..0a8e43869f 100644 --- a/packages/flow/openadapt_flow/learning/library.py +++ b/packages/flow/openadapt_flow/learning/library.py @@ -145,9 +145,15 @@ def path(self) -> Path: return self.root / _LIBRARY_FILE def _load(self) -> None: - if not artifacts.is_file(self.path): - return - raw = json.loads(artifacts.read_text(self.path)) + if self._writer is not None: + # Read through the writer, which holds this library's own key. + if not self._writer.is_file(_LIBRARY_FILE): + return + raw = json.loads(self._writer.read_bytes(_LIBRARY_FILE).decode("utf-8")) + else: + if not artifacts.is_file(self.path): + return + raw = json.loads(artifacts.read_text(self.path)) for sid, data in raw.get("skills", {}).items(): self._skills[sid] = Skill.model_validate(data) diff --git a/packages/flow/openadapt_flow/learning/teach.py b/packages/flow/openadapt_flow/learning/teach.py index 79b4780036..ec0d3fd186 100644 --- a/packages/flow/openadapt_flow/learning/teach.py +++ b/packages/flow/openadapt_flow/learning/teach.py @@ -762,6 +762,10 @@ class TeachResult(BaseModel): promoted: bool outcome: LearnOutcome out_bundle: Optional[Path] = None + #: Sealed input or strict mode: the summary withholds what the learn loop + #: read (the skill id, which defaults to the workflow name, the reason, + #: clusters and gate failures). + private: bool = False @property def refused(self) -> bool: @@ -770,11 +774,17 @@ def refused(self) -> bool: def summary(self) -> str: """A short operator-facing verdict (what it learned / why it refused).""" verdict = "LEARNED" if self.promoted else "REFUSED" - lines = [ - f"{verdict}: {self.outcome.action} (skill {self.skill_id!r})", - f" reason: {self.outcome.reason}", - ] - if self.outcome.clusters: + if self.private: + lines = [ + f"{verdict}: {self.outcome.action}", + " reason: withheld because the input is sealed or strict mode is on", + ] + else: + lines = [ + f"{verdict}: {self.outcome.action} (skill {self.skill_id!r})", + f" reason: {self.outcome.reason}", + ] + if self.outcome.clusters and not self.private: lines.append(f" clusters: {self.outcome.clusters}") if self.outcome.action != _NOT_ATTEMPTED: lines.append( @@ -785,8 +795,12 @@ def summary(self) -> str: if self.outcome.gate is not None: gate = "passed" if self.outcome.gate.passed else "FAILED" lines.append(f" regression gate: {gate}") - for failure in self.outcome.gate.failures: - lines.append(f" - {failure}") + if self.private and self.outcome.gate.failures: + count = len(self.outcome.gate.failures) + lines.append(f" {count} failure(s), details withheld") + elif not self.private: + for failure in self.outcome.gate.failures: + lines.append(f" - {failure}") if self.promoted and self.out_bundle is not None: lines.append(f" updated bundle written to {self.out_bundle}") lines.append( @@ -803,9 +817,10 @@ def summary(self) -> str: _NOT_ATTEMPTED = "not_attempted" -def _refusal(skill_id: str, reason: str) -> TeachResult: +def _refusal(skill_id: str, reason: str, *, private: bool = False) -> TeachResult: return TeachResult( skill_id=skill_id, + private=private, promoted=False, outcome=LearnOutcome( skill_id=skill_id, @@ -1057,7 +1072,7 @@ def teach( ) if plan.refusal: # Refused before learning: nothing is written, not even a library. - return _refusal(sid, plan.refusal) + return _refusal(sid, plan.refusal, private=seal) assert plan.correction is not None and plan.baseline is not None lib_root = ( @@ -1087,7 +1102,7 @@ def teach( # Governed refusal: leave the bundle untouched -- the workflow stays # halting exactly as before the teach was attempted. return TeachResult( - skill_id=sid, promoted=False, outcome=outcome, out_bundle=None + skill_id=sid, promoted=False, outcome=outcome, out_bundle=None, private=seal ) updated = promoted_workflow(library, sid, name=base_workflow.name) @@ -1117,5 +1132,9 @@ def teach( 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 + skill_id=sid, + promoted=True, + outcome=outcome, + out_bundle=out_bundle, + private=seal, ) diff --git a/packages/flow/openadapt_flow/safe_errors.py b/packages/flow/openadapt_flow/safe_errors.py new file mode 100644 index 0000000000..5659fa24e5 --- /dev/null +++ b/packages/flow/openadapt_flow/safe_errors.py @@ -0,0 +1,44 @@ +"""Error text that never quotes the input it rejected. + +A Pydantic validation error quotes the offending value (``input_value=...``). +When that value was decrypted from a sealed artifact, printing the error +would write private content to stdout, stderr and any log that captures them +(docs/phi_at_rest.md). Every refusal the CLI prints goes through +:func:`message`, which drops those quoted values, and a failure caused by +sealed or strict-mode input prints :func:`withheld` instead of its text. +""" + +from __future__ import annotations + +import re + +# Pydantic renders ``[type=..., input_value=, input_type=...]`` on one +# line; a repr never spans lines. Greedy, so a value that itself contains +# ", input_type=" is dropped whole. +_INPUT_VALUE = re.compile(r"input_value=.*, input_type=") +_ANY_INPUT_VALUE = re.compile(r"input_value=[^\n]*") + + +def message(error: BaseException | str) -> str: + """``error`` as text, with every quoted input value removed.""" + text = _INPUT_VALUE.sub("input_type=", str(error)) + return _ANY_INPUT_VALUE.sub("input_value withheld]", text) + + +def root_cause(error: BaseException) -> BaseException: + """The first error in ``error``'s chain.""" + seen = {id(error)} + while True: + cause = error.__cause__ or error.__context__ + if cause is None or id(cause) in seen: + return error + seen.add(id(cause)) + error = cause + + +def withheld(error: BaseException) -> str: + """A refusal that names only the kind of the error that caused it.""" + return ( + f"the input was refused ({type(root_cause(error)).__name__}). The " + "details are withheld because the input is sealed or strict mode is on." + ) diff --git a/packages/flow/tests/test_artifact_reads.py b/packages/flow/tests/test_artifact_reads.py index 131eb27949..1a8869b220 100644 --- a/packages/flow/tests/test_artifact_reads.py +++ b/packages/flow/tests/test_artifact_reads.py @@ -129,6 +129,16 @@ #: (module, function, source of the call) -> why a direct read is right. ALLOWED: dict[tuple[str, str, str], str] = { + ( + "learning/library.py", + "_load", + "self._writer.is_file(_LIBRARY_FILE)", + ): "the sealed library's own writer, opened and hash-checked with its key", + ( + "learning/library.py", + "_load", + "self._writer.read_bytes(_LIBRARY_FILE)", + ): "the sealed library's own writer, opened and hash-checked with its key", ( "__main__.py", "_cmd_record", @@ -558,15 +568,21 @@ def test_teach_reads_sealed_inputs_and_writes_sealed( before = {p: _files(p) for p in (run, bundle)} out = tmp_path / "taught" args = ["teach", str(run), "--fix", str(fix), "--bundle", str(bundle)] + name = Workflow.load(bundle).name + assert name not in str(tmp_path) + capsys.readouterr() assert main([*args, "--out", str(out)]) == 0 + printed = capsys.readouterr().out + # The summary names no skill id (the workflow name) read from sealed input. + assert "LEARNED" in printed and name not in printed and "withheld" in printed _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 + printed = capsys.readouterr().out + assert "no_change" in printed and name not in printed _assert_taught_sealed(out, fix) @@ -604,9 +620,18 @@ def test_teach_refuses_a_plaintext_library_for_sealed_input( _MARKER = "PRIVATE-MARKER-4f1c" +def _marker_absent(root: Path, *printed: str) -> None: + """The marker is in no printed text, log record or file under ``root``.""" + for text in printed: + assert _MARKER not in text + for path in root.rglob("*"): + if path.is_file(): + assert _MARKER.encode() not in path.read_bytes(), path + + @pytest.mark.parametrize("stale", ["state", "workflow"]) def test_teach_refusal_on_a_sealed_run_never_prints_its_contents( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capfd, stale: str + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capfd, caplog, stale: str ) -> None: """A provenance refusal names the halt by its state id, never by a step intent or workflow name read from the encrypted halt report.""" @@ -633,15 +658,13 @@ def test_teach_refusal_on_a_sealed_run_never_prints_its_contents( out = tmp_path / "taught" args = ["teach", str(run), "--fix", str(fix), "--bundle", str(base)] capfd.readouterr() + caplog.set_level("DEBUG") assert main([*args, "--out", str(out)]) == 2 printed = capfd.readouterr() - assert _MARKER not in printed.out + printed.err + _marker_absent(tmp_path, printed.out, printed.err, caplog.text) assert "teach cannot run" in printed.out assert repr(report["halt"]["state_id"]) in printed.out assert not out.exists() - for path in tmp_path.rglob("*"): - if path.is_file(): - assert _MARKER.encode() not in path.read_bytes(), path @pytest.mark.parametrize("alias", ["same path", "symlink", "inside"]) @@ -836,7 +859,8 @@ def test_push_refusal_on_a_sealed_bundle_never_prints_its_contents( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, push_env, - capsys, + capfd, + caplog, strict: bool, ) -> None: from openadapt_flow import hosted @@ -847,14 +871,13 @@ def test_push_refusal_on_a_sealed_bundle_never_prints_its_contents( bad = _bundle_with_a_bad_action(sealed, tmp_path / "bad", key=KEY) assert sealed_set.is_sealed(bad) push = ["push", str(bad), "--kind", "bundle", "--host", "https://h.test"] + capfd.readouterr() + caplog.set_level("DEBUG") assert main([*push, "--token", "tok"]) == 1 - captured = capsys.readouterr() - assert _MARKER not in captured.out + captured.err + captured = capfd.readouterr() + _marker_absent(tmp_path, captured.out, captured.err, caplog.text) assert "push failed: the input was refused (ValidationError)" in captured.out assert "withheld" in captured.out - for path in tmp_path.rglob("*"): - if path.is_file(): - assert _MARKER.encode() not in path.read_bytes(), path def test_push_refusal_on_a_plain_bundle_keeps_its_details( @@ -873,7 +896,65 @@ def test_push_refusal_on_a_plain_bundle_keeps_its_details( push = ["push", str(bad), "--kind", "bundle", "--host", "https://h.test"] assert main([*push, "--token", "tok"]) == 1 out = capsys.readouterr().out - assert "Cannot validate generated workflow.py" in out and _MARKER in out + assert "Cannot validate generated workflow.py" in out + assert "type=enum" in out and _MARKER not in out # no quoted input value + + +def test_refusal_text_never_quotes_a_validation_input() -> None: + from pydantic import ValidationError + + from openadapt_flow import safe_errors + + try: + Step.model_validate({"id": "s", "intent": "x", "action": _MARKER}) + except ValidationError as exc: + error = exc + assert _MARKER in str(error) + wrapped = RuntimeError(f"Cannot validate: {error}") + for text in (safe_errors.message(error), safe_errors.message(wrapped)): + assert _MARKER not in text and "input_type=str" in text + assert "input_value withheld" in safe_errors.message(f"input_value={_MARKER}") + + +def test_an_unexpected_failure_on_sealed_input_prints_no_traceback( + sealed: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capfd, caplog +) -> None: + """An error no handler expects would print a traceback quoting what the + command read; with sealed input the CLI names its kind only.""" + import openadapt_flow.__main__ as cli + + def _cmd_visualize(args) -> int: + raise RuntimeError(f"read {_MARKER}") + + _cmd_visualize.__module__ = "openadapt_flow.__main__" + monkeypatch.setattr(cli, "_cmd_visualize", _cmd_visualize) + capfd.readouterr() + caplog.set_level("DEBUG") + out = tmp_path / "graph.json" + assert main(["visualize", str(sealed), "--format", "json", "--out", str(out)]) == 1 + printed = capfd.readouterr() + _marker_absent(tmp_path, printed.out, printed.err, caplog.text) + assert "FAILED: the input was refused (RuntimeError)" in printed.err + # Plain input still raises, so its traceback stays available. + with pytest.raises(RuntimeError, match=_MARKER): + main(["visualize", str(_plain_bundle(tmp_path / "p")), "--out", str(out)]) + + +def test_a_sealed_library_reads_back_with_its_own_key( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from openadapt_flow.ir import lift_to_program + from openadapt_flow.learning.library import SkillLibrary + + monkeypatch.setenv(crypto.ENV_KEY, KEY) + own_key = "library-only-test-passphrase-7d2e" + root = tmp_path / "own.skills" + graph = lift_to_program(Workflow.load(_plain_bundle(tmp_path))) + SkillLibrary(root, seal=True, key=own_key).create_skill("own", graph) + _assert_ciphertext_only(root) + assert SkillLibrary(root, key=own_key).skill_ids() == ["own"] + with pytest.raises((sealed_set.SealedSetError, crypto.CryptoError)): + SkillLibrary(root) # the global key does not open it def test_a_plaintext_copy_of_a_sealed_bundle_never_loads_as_encrypted( From 70a9cb235fa8fe3293c8e8ca2a77f8c04a732c63 Mon Sep 17 00:00:00 2001 From: abrichr Date: Wed, 4 Nov 2026 05:31:45 -0500 Subject: [PATCH 19/23] Show names from private input only as a short hash Teach and push success summaries name the skill and the workflow through one shared helper. For sealed or strict-mode input it prints a short sha256 instead of the name read from that input. --- packages/flow/openadapt_flow/__main__.py | 7 +- .../flow/openadapt_flow/learning/teach.py | 16 ++-- packages/flow/openadapt_flow/safe_errors.py | 14 +++ packages/flow/tests/test_artifact_reads.py | 88 +++++++++++++++++++ 4 files changed, 115 insertions(+), 10 deletions(-) diff --git a/packages/flow/openadapt_flow/__main__.py b/packages/flow/openadapt_flow/__main__.py index a987c1af89..1afea5911b 100644 --- a/packages/flow/openadapt_flow/__main__.py +++ b/packages/flow/openadapt_flow/__main__.py @@ -7912,9 +7912,12 @@ def _cmd_push(args: argparse.Namespace) -> int: if result.get("workflow_id"): version = result.get("version") version_number = version.get("version") if isinstance(version, dict) else None - detail = ( - f"name={result.get('workflow_name')!r}, kind={kind}, status={server_status}" + from openadapt_flow import safe_errors + + name = safe_errors.label( + result.get("workflow_name"), private=_private_input(args) ) + detail = f"name={name}, kind={kind}, status={server_status}" if version_number is not None: detail += f", version={version_number}" print(f"Pushed. workflow_id={result['workflow_id']} ({detail}).") diff --git a/packages/flow/openadapt_flow/learning/teach.py b/packages/flow/openadapt_flow/learning/teach.py index ec0d3fd186..cf4d500c61 100644 --- a/packages/flow/openadapt_flow/learning/teach.py +++ b/packages/flow/openadapt_flow/learning/teach.py @@ -774,16 +774,16 @@ def refused(self) -> bool: def summary(self) -> str: """A short operator-facing verdict (what it learned / why it refused).""" verdict = "LEARNED" if self.promoted else "REFUSED" + from openadapt_flow import safe_errors + + skill = safe_errors.label(self.skill_id, private=self.private) + lines = [f"{verdict}: {self.outcome.action} (skill {skill})"] if self.private: - lines = [ - f"{verdict}: {self.outcome.action}", - " reason: withheld because the input is sealed or strict mode is on", - ] + lines.append( + " reason: withheld because the input is sealed or strict mode is on" + ) else: - lines = [ - f"{verdict}: {self.outcome.action} (skill {self.skill_id!r})", - f" reason: {self.outcome.reason}", - ] + lines.append(f" reason: {self.outcome.reason}") if self.outcome.clusters and not self.private: lines.append(f" clusters: {self.outcome.clusters}") if self.outcome.action != _NOT_ATTEMPTED: diff --git a/packages/flow/openadapt_flow/safe_errors.py b/packages/flow/openadapt_flow/safe_errors.py index 5659fa24e5..ad6e72cfb4 100644 --- a/packages/flow/openadapt_flow/safe_errors.py +++ b/packages/flow/openadapt_flow/safe_errors.py @@ -6,10 +6,15 @@ (docs/phi_at_rest.md). Every refusal the CLI prints goes through :func:`message`, which drops those quoted values, and a failure caused by sealed or strict-mode input prints :func:`withheld` instead of its text. + +Success output follows the same rule: a name read from sealed or strict-mode +input (a workflow name, a skill id) is shown only as :func:`label` gives it, +a short hash that tells two runs apart without quoting the name. """ from __future__ import annotations +import hashlib import re # Pydantic renders ``[type=..., input_value=, input_type=...]`` on one @@ -42,3 +47,12 @@ def withheld(error: BaseException) -> str: f"the input was refused ({type(root_cause(error)).__name__}). The " "details are withheld because the input is sealed or strict mode is on." ) + + +def label(name: object, *, private: bool) -> str: + """``name`` for a summary line: quoted, or for private input only a short + hash of it.""" + if not private: + return repr(name) + digest = hashlib.sha256(str(name).encode("utf-8")).hexdigest()[:12] + return f"withheld, sha256 {digest}" diff --git a/packages/flow/tests/test_artifact_reads.py b/packages/flow/tests/test_artifact_reads.py index 1a8869b220..60d2b9d93c 100644 --- a/packages/flow/tests/test_artifact_reads.py +++ b/packages/flow/tests/test_artifact_reads.py @@ -972,3 +972,91 @@ def test_a_plaintext_copy_of_a_sealed_bundle_never_loads_as_encrypted( loaded = Workflow.load(copy) assert loaded.encrypted is False assert loaded.manifest is not None and loaded.manifest.encrypted is False + + +def _named_teach_inputs( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> tuple[Path, Path, Path]: + """Plain teach inputs whose workflow name (the default skill id) is the + private marker.""" + import tests.test_teach_cli as cli + + original = cli._base_workflow + + def named() -> Workflow: + workflow = original() + workflow.name = _MARKER + return workflow + + monkeypatch.setattr(cli, "_base_workflow", named) + base = cli._base_bundle(tmp_path / "in") + cli._halt_run(base, tmp_path / "in" / "run") + fix = cli._dismiss_recording(tmp_path / "in" / "fix") + assert Workflow.load(base).name == _MARKER + return tmp_path / "in" / "run", fix, base + + +def test_strict_teach_success_never_prints_a_name_from_its_input( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capfd, caplog +) -> None: + monkeypatch.setenv(crypto.ENV_KEY, KEY) + run, fix, base = _named_teach_inputs(tmp_path, monkeypatch) + monkeypatch.setenv("OPENADAPT_FLOW_AT_REST", "strict") + out = tmp_path / "taught" + args = ["teach", str(run), "--fix", str(fix), "--bundle", str(base)] + capfd.readouterr() + caplog.set_level("DEBUG") + assert main([*args, "--out", str(out)]) == 0 + learned = capfd.readouterr() + # A second teach reads the library back and refuses with no_change. + assert main([*args, "--out", str(out)]) == 1 + again = capfd.readouterr() + for text in (learned.out, learned.err, again.out, again.err, caplog.text): + assert _MARKER not in text + assert "LEARNED" in learned.out and "no_change" in again.out + assert "(skill withheld, sha256 " in learned.out + for root in (out, out.parent / f"{out.name}.skills", fix / "_teach_compiled"): + _marker_absent(root) + + +def test_teach_success_on_plain_input_names_its_skill( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys +) -> None: + """The control: outside strict mode a plaintext run's summary names it.""" + run, fix, base = _named_teach_inputs(tmp_path, monkeypatch) + out = tmp_path / "taught" + capsys.readouterr() + args = ["teach", str(run), "--fix", str(fix), "--bundle", str(base)] + assert main([*args, "--out", str(out)]) == 0 + assert f"(skill {_MARKER!r})" in capsys.readouterr().out + + +@pytest.mark.parametrize("strict", [True, False], ids=["strict", "plain"]) +def test_push_success_names_the_workflow_only_for_plain_input( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capfd, caplog, strict: bool +) -> None: + from openadapt_flow import hosted + + if strict: + monkeypatch.setenv("OPENADAPT_FLOW_AT_REST", "strict") + pushed = { + "workflow_id": "wf_1", + "workflow_name": _MARKER, + "kind": "bundle", + "status": "active", + } + monkeypatch.setattr(hosted, "push", lambda *a, **kw: pushed) + bundle = tmp_path / "bundle" + bundle.mkdir() + capfd.readouterr() + caplog.set_level("DEBUG") + push = ["push", str(bundle), "--kind", "bundle", "--host", "https://h.test"] + assert main([*push, "--token", "tok"]) == 0 + captured = capfd.readouterr() + assert "Pushed. workflow_id=wf_1" in captured.out + if strict: + for text in (captured.out, captured.err, caplog.text): + assert _MARKER not in text + assert "name=withheld, sha256 " in captured.out + else: + assert f"name={_MARKER!r}" in captured.out From fce3ec882fcd15bc8601ea7cc666904e19ca806b Mon Sep 17 00:00:00 2001 From: abrichr Date: Wed, 4 Nov 2026 05:48:20 -0500 Subject: [PATCH 20/23] Withhold teach and push refusals for private input unless built from the command line --- packages/flow/docs/phi_at_rest.md | 8 +- packages/flow/openadapt_flow/__main__.py | 44 ++++----- .../openadapt_flow/learning/branch_teach.py | 6 +- .../flow/openadapt_flow/learning/teach.py | 38 ++++--- packages/flow/tests/test_artifact_reads.py | 99 ++++++++++++++++++- 5 files changed, 147 insertions(+), 48 deletions(-) diff --git a/packages/flow/docs/phi_at_rest.md b/packages/flow/docs/phi_at_rest.md index 1c8773ea43..5b4e9dbfe0 100644 --- a/packages/flow/docs/phi_at_rest.md +++ b/packages/flow/docs/phi_at_rest.md @@ -304,9 +304,11 @@ they write inside a sealed set sealed. Each reads artifact files through one rea Refusals never quote what they read. A validation error's quoted input value is dropped from every refusal the CLI prints. With sealed input or in strict -mode, `teach` names a stopped run by its halt id instead of the step intent or -workflow name, a `push` failure caused by another error names only that -error's type, and an unexpected error prints its type instead of a traceback. +mode, a `teach` or `push` refusal names only the error's type, unless its text +was built only from the command line (the paths you passed, such as an `--out` +that overlaps an input or a run that came from another bundle). An unexpected +error prints its type instead of a traceback, and a success line names a skill +or workflow only by a short hash. 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 1afea5911b..b2bfe491a2 100644 --- a/packages/flow/openadapt_flow/__main__.py +++ b/packages/flow/openadapt_flow/__main__.py @@ -7821,24 +7821,6 @@ def _print_push_json(document: dict[str, Any]) -> None: print(json.dumps(document, sort_keys=True, separators=(",", ":"))) -def _push_failure_reason(args: argparse.Namespace, error: Exception) -> str: - """What ``push`` prints when it fails. - - A failure caused by another error (a validation error, a scrub or - sanitization failure) can quote what it read. When the input is sealed or - strict mode is on, that text came out of encrypted content, so push names - only the kind of error. A failure push raised itself prints as it is, - without any quoted input value. - """ - from openadapt_flow import at_rest, safe_errors, sealed_set - - if error.__cause__ is not None or error.__context__ is not None: - path = getattr(args, "path", None) - if at_rest.strict() or (path and sealed_set.is_sealed_path(path)): - return safe_errors.withheld(error) - return safe_errors.message(error) - - def _cmd_push(args: argparse.Namespace) -> int: """Upload the exact approved sanitized archive to ``/api/ingest``. @@ -7883,7 +7865,7 @@ def _cmd_push(args: argparse.Namespace) -> int: if args.json: _print_push_json(_push_json_failure()) return 1 - print(f"push failed: {_push_failure_reason(args, e)}") + print(f"push failed: {_refusal_text(args, e)}") return 1 if args.json: try: @@ -8352,9 +8334,7 @@ def _cmd_teach(args: argparse.Namespace) -> int: library_dir=Path(args.library) if args.library else None, ) except TeachError as e: - from openadapt_flow import safe_errors - - print(f"teach cannot run: {safe_errors.message(e)}") + print(f"teach cannot run: {_refusal_text(args, e)}") return 2 print(result.summary()) @@ -8389,9 +8369,7 @@ def _cmd_teach_branch(args: argparse.Namespace) -> int: library_dir=Path(args.library) if args.library else None, ) except TeachError as e: - from openadapt_flow import safe_errors - - print(f"teach-branch cannot run: {safe_errors.message(e)}") + print(f"teach-branch cannot run: {_refusal_text(args, e)}") return 2 print(result.summary()) return 0 if result.promoted else 1 @@ -13392,6 +13370,22 @@ def _sealed_paths_in(args: argparse.Namespace) -> list[Path]: return found +def _refusal_text(args: argparse.Namespace, error: BaseException) -> str: + """What a command prints when it refuses ``error``. + + When the command read sealed input or strict mode is on, any part of the + message may quote decrypted content (a skill id, a workflow name, a + path inside the artifact), so only the kind of error is printed, unless + the error is marked ``public`` (built only from the command line). + Otherwise the message prints without quoted input values. + """ + from openadapt_flow import safe_errors + + if _private_input(args) and getattr(error, "public", False) is not True: + return safe_errors.withheld(error) + return safe_errors.message(error) + + def _private_input(args: argparse.Namespace) -> bool: """Whether the command read sealed input or runs in strict mode.""" from openadapt_flow import at_rest, sealed_set diff --git a/packages/flow/openadapt_flow/learning/branch_teach.py b/packages/flow/openadapt_flow/learning/branch_teach.py index 505f75b0c6..555549e077 100644 --- a/packages/flow/openadapt_flow/learning/branch_teach.py +++ b/packages/flow/openadapt_flow/learning/branch_teach.py @@ -546,9 +546,9 @@ def teach_branch( "`openadapt-flow teach`" ) inputs: dict[str, Path | str] = {"base bundle": bundle} - for i, ex in enumerate(examples): - inputs[f"halted run {i + 1}"] = ex.run_dir - inputs[f"fix {i + 1}"] = ex.fix + for i, example in enumerate(examples): + inputs[f"halted run {i + 1}"] = example.run_dir + inputs[f"fix {i + 1}"] = example.fix for i, negative in enumerate(negatives): inputs[f"normal run {i + 1}"] = negative check_out_is_new(out, library=library_dir, **inputs) diff --git a/packages/flow/openadapt_flow/learning/teach.py b/packages/flow/openadapt_flow/learning/teach.py index cf4d500c61..230c5e9334 100644 --- a/packages/flow/openadapt_flow/learning/teach.py +++ b/packages/flow/openadapt_flow/learning/teach.py @@ -102,7 +102,15 @@ class TeachError(Exception): """A teach cannot even be attempted (bad inputs), as distinct from a governed REFUSAL to promote a correction (which is a normal, reported - outcome -- see :attr:`TeachResult.promoted`).""" + outcome -- see :attr:`TeachResult.promoted`). + + ``public`` marks a message built only from the command line (the paths + it was given). Any other message may quote what teach read, so the CLI + withholds it when the input is sealed or strict mode is on.""" + + def __init__(self, message: str, *, public: bool = False) -> None: + super().__init__(message) + self.public = public # -- fix demonstration: a scripted correction spec --------------------------- @@ -196,21 +204,20 @@ def check_provenance( program that never ran, and the learn loop would only report a generic refusal. Raises :class:`TeachError` before anything is written. - With ``private`` (sealed input or strict mode) the message names the halt - by its state id only, never a workflow name or step intent read from the - encrypted report. + With ``private`` (sealed input or strict mode) the message names only the + paths on the command line, never a state id, workflow name or step intent + read from the encrypted report, so the CLI may print it. """ halt = report.halt assert halt is not None # guaranteed by load_halt_report - if private: - stale = ( - f"the run at {run_dir} (halt {halt.state_id!r}) did not stop in a " - f"program --bundle {bundle_dir} holds. Pass the bundle whose replay " - "stopped." - ) + stale = TeachError( + f"the run at {run_dir} did not stop in a program --bundle {bundle_dir} " + "holds. Pass the bundle whose replay stopped.", + public=True, + ) if report.workflow_name != base_workflow.name: if private: - raise TeachError(stale) + raise stale raise TeachError( f"the run at {run_dir} came from the workflow " f"{report.workflow_name!r}, but --bundle {bundle_dir} is the " @@ -229,7 +236,7 @@ def check_provenance( ): return if private: - raise TeachError(stale) + raise stale raise TeachError( f"the step where the run at {run_dir} stopped ({halt.intent!r}) is not " f"in --bundle {bundle_dir}. Pass the bundle whose replay stopped." @@ -969,7 +976,8 @@ def check_out_is_new( if _overlap(target, Path(path)): raise TeachError( f"{flag} {target} is or overlaps the {name} input {path}. " - "Pass a new directory: teach never writes over its inputs" + "Pass a new directory: teach never writes over its inputs", + public=True, ) @@ -1083,7 +1091,9 @@ def teach( try: library = SkillLibrary(lib_root, seal=seal) except sealed_set.SealedSetError as exc: - raise TeachError(str(exc)) from exc + # Opening the library names only its directory (``--library`` or the + # one beside ``--out``) and the key it needs. + raise TeachError(str(exc), public=True) from exc _seed_or_check_library( library, sid, program, dict(base_workflow.subflows), bundle_dir=bundle ) diff --git a/packages/flow/tests/test_artifact_reads.py b/packages/flow/tests/test_artifact_reads.py index 60d2b9d93c..a809f42757 100644 --- a/packages/flow/tests/test_artifact_reads.py +++ b/packages/flow/tests/test_artifact_reads.py @@ -633,8 +633,8 @@ def _marker_absent(root: Path, *printed: str) -> None: def test_teach_refusal_on_a_sealed_run_never_prints_its_contents( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capfd, caplog, stale: str ) -> None: - """A provenance refusal names the halt by its state id, never by a step - intent or workflow name read from the encrypted halt report.""" + """A provenance refusal names only the paths on the command line, never a + state id, step intent or workflow name read from the encrypted report.""" from tests.test_teach_cli import _base_bundle, _dismiss_recording, _halt_run monkeypatch.setenv(crypto.ENV_KEY, KEY) @@ -663,7 +663,8 @@ def test_teach_refusal_on_a_sealed_run_never_prints_its_contents( printed = capfd.readouterr() _marker_absent(tmp_path, printed.out, printed.err, caplog.text) assert "teach cannot run" in printed.out - assert repr(report["halt"]["state_id"]) in printed.out + assert "did not stop in a program --bundle" in printed.out + assert repr(report["halt"]["state_id"]) not in printed.out assert not out.exists() @@ -1060,3 +1061,95 @@ def test_push_success_names_the_workflow_only_for_plain_input( assert "name=withheld, sha256 " in captured.out else: assert f"name={_MARKER!r}" in captured.out + + +def test_strict_library_mismatch_refusal_never_prints_the_skill_id( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capfd, caplog +) -> None: + """A skill library that already holds another program for the skill is + refused without naming the skill id teach read from the input.""" + monkeypatch.setenv(crypto.ENV_KEY, KEY) + run, fix, base = _named_teach_inputs(tmp_path, monkeypatch) + from openadapt_flow.learning.library import SkillLibrary + + monkeypatch.setenv("OPENADAPT_FLOW_AT_REST", "strict") + args = ["teach", str(run), "--fix", str(fix), "--bundle", str(base)] + first = tmp_path / "first" + assert main([*args, "--out", str(first)]) == 0 + # A sealed library that holds the taught program, not the base one, for + # the same skill id. + library = tmp_path / "library" + taught = Workflow.load(first, key=KEY) + SkillLibrary(library, seal=True).create_skill(_MARKER, taught.program) + capfd.readouterr() + caplog.set_level("DEBUG") + second = tmp_path / "second" + assert main([*args, "--library", str(library), "--out", str(second)]) == 2 + printed = capfd.readouterr() + for text in (printed.out, printed.err, caplog.text): + assert _MARKER not in text + assert "teach cannot run: the input was refused (TeachError)" in printed.out + assert not second.exists() + + +@pytest.mark.parametrize("public", [False, True], ids=["read", "command line"]) +def test_strict_teach_refusal_prints_only_command_line_details( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capfd, caplog, public: bool +) -> None: + """Strict teach prints a refusal's text only when the error says it was + built from the command line. (teach-branch is refused in strict mode and + for sealed input before it runs.)""" + from openadapt_flow.learning import teach + from openadapt_flow.learning.teach import TeachError + + def refuse(*_args, **_kwargs): + raise TeachError(f"refused {_MARKER!r}", public=public) + + monkeypatch.setattr(teach, "teach", refuse) + monkeypatch.setenv("OPENADAPT_FLOW_AT_REST", "strict") + for name in ("run", "fix", "bundle"): + (tmp_path / name).mkdir() + run, fix, bundle = (str(tmp_path / n) for n in ("run", "fix", "bundle")) + capfd.readouterr() + caplog.set_level("DEBUG") + out = str(tmp_path / "out") + args = ["teach", run, "--fix", fix, "--bundle", bundle, "--out", out] + assert main(args) == 2 + printed = capfd.readouterr() + assert "teach cannot run" in printed.out + if public: + assert f"refused {_MARKER!r}" in printed.out + else: + for text in (printed.out, printed.err, caplog.text): + assert _MARKER not in text + assert "the input was refused (TeachError)" in printed.out + + +@pytest.mark.parametrize("strict", [True, False], ids=["strict", "plain"]) +def test_push_failure_of_its_own_is_withheld_only_for_private_input( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capfd, caplog, strict: bool +) -> None: + """A push error with no cause can still name a path inside the artifact, + so strict mode withholds it too.""" + from openadapt_flow import hosted + + if strict: + monkeypatch.setenv("OPENADAPT_FLOW_AT_REST", "strict") + + def fail(*_args, **_kwargs): + raise hosted.HostedError(f"cannot read {_MARKER}/steps.json") + + monkeypatch.setattr(hosted, "push", fail) + bundle = tmp_path / "bundle" + bundle.mkdir() + capfd.readouterr() + caplog.set_level("DEBUG") + push = ["push", str(bundle), "--kind", "bundle", "--host", "https://h.test"] + assert main([*push, "--token", "tok"]) == 1 + captured = capfd.readouterr() + if strict: + for text in (captured.out, captured.err, caplog.text): + assert _MARKER not in text + assert "push failed: the input was refused (HostedError)" in captured.out + else: + assert f"push failed: cannot read {_MARKER}/steps.json" in captured.out From 1e13e500b1722c188b6c9ce5dffddd3f91889bf4 Mon Sep 17 00:00:00 2001 From: abrichr Date: Wed, 4 Nov 2026 06:55:43 -0500 Subject: [PATCH 21/23] Find sealed paths at any depth in arguments, and treat unknown values as private --- packages/flow/openadapt_flow/__main__.py | 82 ++++++++++++++++------ packages/flow/tests/test_artifact_reads.py | 50 +++++++++++++ 2 files changed, 109 insertions(+), 23 deletions(-) diff --git a/packages/flow/openadapt_flow/__main__.py b/packages/flow/openadapt_flow/__main__.py index b2bfe491a2..b4abec0b07 100644 --- a/packages/flow/openadapt_flow/__main__.py +++ b/packages/flow/openadapt_flow/__main__.py @@ -13339,37 +13339,73 @@ def _unsealed_output_check(args: argparse.Namespace) -> None: at_rest.unsealed_write(artifact, target) +class _UnclassifiedArgument(ValueError): + """An argument value that is neither a path, a scalar nor a container.""" + + def _sealed_paths_in(args: argparse.Namespace) -> list[Path]: - from openadapt_flow import sealed_set + """The sealed roots named anywhere in the command's arguments. + Values are walked at any depth (lists, tuples, sets, mappings, nested + pairs such as teach-branch's ``--example RUN FIX``). A value that is not + a path, a known scalar or a container raises ``_UnclassifiedArgument``; + ``_private_input`` treats that as private. + """ found: list[Path] = [] + unclassified: list[str] = [] for name, value in vars(args).items(): if name == "func": continue - values = value if isinstance(value, (list, tuple)) else [value] - for item in values: - if not isinstance(item, (str, Path)) or not str(item): - continue - text = str(item) - if "://" in text or "\n" in text or len(text) > 4096: - continue - # 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) + # Attributes named "_..." are set by the code (handlers, run state), + # not typed by the user: their paths are still read, but an object + # among them does not make the run private. + internal = name.startswith("_") + _collect_sealed(value, name, found, [] if internal else unclassified) + if unclassified: + raise _UnclassifiedArgument( + f"cannot tell whether {unclassified[0]} names sealed input" + ) return found +def _collect_sealed( + value: object, name: str, found: list[Path], unclassified: list[str] +) -> None: + import enum + + from openadapt_flow import sealed_set + + if value is None or isinstance(value, (bool, int, float, enum.Enum)): + return + if isinstance(value, Mapping): + for key, item in value.items(): + _collect_sealed(key, name, found, unclassified) + _collect_sealed(item, name, found, unclassified) + return + if isinstance(value, (list, tuple, set, frozenset)): + for item in value: + _collect_sealed(item, name, found, unclassified) + return + if not isinstance(value, (str, Path)): + unclassified.append(f"--{name.replace('_', '-')}") + return + text = str(value) + if not text or "://" in text or "\n" in text or len(text) > 4096: + return + # NAME=PATH arguments (--child, --admission, --variant) name a path after + # the first "="; check both readings. + candidates = [text] + if isinstance(value, 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) + + def _refusal_text(args: argparse.Namespace, error: BaseException) -> str: """What a command prints when it refuses ``error``. @@ -13480,7 +13516,7 @@ def main(argv: Sequence[str] | None = None) -> int: try: refusal = _sealed_input_refusal(args) - except sealed_set.SealedSetError as exc: + except (sealed_set.SealedSetError, _UnclassifiedArgument) as exc: refusal = str(exc) if refusal is not None: print(f"REFUSED: {safe_errors.message(refusal)}", file=sys.stderr) diff --git a/packages/flow/tests/test_artifact_reads.py b/packages/flow/tests/test_artifact_reads.py index a809f42757..da6906c6b5 100644 --- a/packages/flow/tests/test_artifact_reads.py +++ b/packages/flow/tests/test_artifact_reads.py @@ -1153,3 +1153,53 @@ def fail(*_args, **_kwargs): assert "push failed: the input was refused (HostedError)" in captured.out else: assert f"push failed: cannot read {_MARKER}/steps.json" in captured.out + + +def test_teach_branch_with_a_sealed_example_is_refused_before_it_runs( + sealed: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capfd, caplog +) -> None: + """--example RUN FIX arrives as a list of pairs; a sealed RUN inside it + is still found, so teach-branch is refused before it reads anything.""" + from openadapt_flow.learning import branch_teach + + calls: list[object] = [] + + def refuse(*args, **kwargs): + calls.append(args) + raise branch_teach.TeachError(f"workflow {_MARKER!r} did not stop here") + + monkeypatch.setattr(branch_teach, "teach_branch", refuse) + base = _plain_bundle(tmp_path / "base") + fix = tmp_path / "fix" + fix.mkdir() + capfd.readouterr() + caplog.set_level("DEBUG") + examples = ["--example", str(sealed), str(fix)] * 2 + out = str(tmp_path / "out") + args = ["teach-branch", *examples, "--bundle", str(base), "--out", out] + assert main(args) == 2 + printed = capfd.readouterr() + for text in (printed.out, printed.err, caplog.text): + assert _MARKER not in text + assert "REFUSED" in printed.err and "does not read encrypted input" in printed.err + assert not calls + + +def test_sealed_paths_are_found_at_any_depth_and_unknown_values_are_private( + sealed: Path, tmp_path: Path +) -> None: + import argparse + + from openadapt_flow.__main__ import _private_input, _sealed_paths_in + + plain = str(tmp_path / "plain") + nested = argparse.Namespace( + example=[[plain, plain], (plain, {"fix": [str(sealed)]})], out=plain + ) + assert _sealed_paths_in(nested) == [sealed] + assert _private_input(nested) + assert not _private_input(argparse.Namespace(example=[[plain, plain]])) + # A value that is neither a path, a scalar nor a container: fail closed. + assert _private_input(argparse.Namespace(example=[[plain, object()]])) + # Objects the code sets on the namespace do not make the run private. + assert not _private_input(argparse.Namespace(_handler=object(), out=plain)) From 3c27d048aeb3c67427a2ad8b823cd83d46bb0acd Mon Sep 17 00:00:00 2001 From: abrichr Date: Wed, 4 Nov 2026 07:24:51 -0500 Subject: [PATCH 22/23] Check every string argument against the filesystem for sealed input; treat an uncheckable path as private --- packages/flow/openadapt_flow/__main__.py | 55 ++++++++++++-- packages/flow/tests/test_artifact_reads.py | 87 ++++++++++++++++++++++ 2 files changed, 137 insertions(+), 5 deletions(-) diff --git a/packages/flow/openadapt_flow/__main__.py b/packages/flow/openadapt_flow/__main__.py index b4abec0b07..f7f775dfb9 100644 --- a/packages/flow/openadapt_flow/__main__.py +++ b/packages/flow/openadapt_flow/__main__.py @@ -13390,22 +13390,67 @@ def _collect_sealed( unclassified.append(f"--{name.replace('_', '-')}") return text = str(value) - if not text or "://" in text or "\n" in text or len(text) > 4096: + if not text: return - # NAME=PATH arguments (--child, --admission, --variant) name a path after - # the first "="; check both readings. + # Every string is checked against the filesystem, whatever it looks + # like: a newline, "://" or any length can spell a local path. NAME=PATH + # arguments (--child, --admission, --variant) name a path after the + # first "="; check both readings. candidates = [text] if isinstance(value, 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) + try: + root = _sealed_root_named(candidate) + except _UnclassifiedArgument: + unclassified.append(f"--{name.replace('_', '-')}") + continue + if root is None: + root = sealed_set.sealed_root(candidate) # an active writer if root is not None and root not in found: found.append(root) +def _sealed_root_named(text: str) -> Optional[Path]: + """The sealed set that ``text`` names or lies inside, if any. + + Raises ``_UnclassifiedArgument`` when the filesystem cannot tell (a + header that cannot be checked for a reason other than not existing). + """ + import errno + import os + import stat + + from openadapt_flow import sealed_set + + # The path cannot exist, so nothing sealed can be read through it. + no_such_path = (errno.ENOENT, errno.ENOTDIR, errno.ENAMETOOLONG) + try: + full = os.path.abspath(text) + except ValueError: # an embedded NUL: no such path + return None + current = full + while True: + try: + st = os.stat(os.path.join(current, sealed_set.HEADER)) + except ValueError: + return None + except OSError as exc: + if exc.errno not in no_such_path: + raise _UnclassifiedArgument( + "a path whose sealed header cannot be checked" + ) from exc + else: + if stat.S_ISREG(st.st_mode): + return Path(text) if current == full else Path(current) + parent = os.path.dirname(current) + if parent == current: + return None + current = parent + + def _refusal_text(args: argparse.Namespace, error: BaseException) -> str: """What a command prints when it refuses ``error``. diff --git a/packages/flow/tests/test_artifact_reads.py b/packages/flow/tests/test_artifact_reads.py index da6906c6b5..1bb45fca90 100644 --- a/packages/flow/tests/test_artifact_reads.py +++ b/packages/flow/tests/test_artifact_reads.py @@ -21,6 +21,7 @@ import ast import json +import os from pathlib import Path import pytest @@ -1203,3 +1204,89 @@ def test_sealed_paths_are_found_at_any_depth_and_unknown_values_are_private( assert _private_input(argparse.Namespace(example=[[plain, object()]])) # Objects the code sets on the namespace do not make the run private. assert not _private_input(argparse.Namespace(_handler=object(), out=plain)) + + +def _odd_spelling(sealed: Path, tmp_path: Path, spelling: str) -> str: + """Move ``sealed`` under a directory whose path is spelled oddly.""" + import shutil + + if spelling == "newline": + parent = tmp_path / "line\nbreak" + parent.mkdir() + shutil.move(str(sealed), str(parent / "sealed")) + return str(parent / "sealed") + parent = tmp_path / "url:" + parent.mkdir() + shutil.move(str(sealed), str(parent / "sealed")) + # "url://sealed" is the same local directory with a redundant slash. + return f"{tmp_path}/url://sealed" + + +@pytest.mark.parametrize("spelling", ["newline", "url-slash"]) +def test_a_sealed_example_under_any_path_spelling_is_refused( + sealed: Path, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capfd, + caplog, + spelling: str, +) -> None: + """A newline or "://" in a path is still a local path: the sealed example + is found and teach-branch is refused before it reads anything.""" + from openadapt_flow.learning import branch_teach + + calls: list[object] = [] + + def refuse(*args, **kwargs): + calls.append(args) + raise branch_teach.TeachError(f"workflow {_MARKER!r} did not stop here") + + monkeypatch.setattr(branch_teach, "teach_branch", refuse) + example = _odd_spelling(sealed, tmp_path, spelling) + base = _plain_bundle(tmp_path / "base") + fix = tmp_path / "fix" + fix.mkdir() + capfd.readouterr() + caplog.set_level("DEBUG") + out = tmp_path / "out" + args = ["teach-branch", "--example", example, str(fix), "--bundle", str(base)] + assert main([*args, "--out", str(out)]) == 2 + printed = capfd.readouterr() + for text in (printed.out, printed.err, caplog.text): + assert _MARKER not in text + assert "REFUSED" in printed.err and "does not read encrypted input" in printed.err + assert not calls + assert not out.exists() + + +def test_a_path_spelled_with_a_newline_or_url_slash_is_private( + sealed: Path, tmp_path: Path +) -> None: + import argparse + + from openadapt_flow.__main__ import _private_input, _sealed_paths_in + + example = _odd_spelling(sealed, tmp_path, "url-slash") + assert _sealed_paths_in(argparse.Namespace(example=[[example, "x"]])) + # Text that is no local path stays public, whatever it looks like. + public = argparse.Namespace( + url="https://example.invalid/a", note="two\nlines", long="x" * 5000 + ) + assert not _private_input(public) + + +@pytest.mark.skipif( + not hasattr(os, "geteuid") or os.geteuid() == 0, reason="root reads any dir" +) +def test_a_path_whose_header_cannot_be_checked_is_private(tmp_path: Path) -> None: + import argparse + + from openadapt_flow.__main__ import _private_input + + locked = tmp_path / "locked" + (locked / "inner").mkdir(parents=True) + locked.chmod(0) + try: + assert _private_input(argparse.Namespace(example=[str(locked / "inner")])) + finally: + locked.chmod(0o700) From ffec5676f193bf41208b192a85e268d22836afc4 Mon Sep 17 00:00:00 2001 From: abrichr Date: Wed, 4 Nov 2026 08:00:38 -0500 Subject: [PATCH 23/23] Treat a name Windows cannot hold as no such path in the sealed-input check --- packages/flow/openadapt_flow/__main__.py | 6 +++++- packages/flow/tests/test_artifact_reads.py | 24 ++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/flow/openadapt_flow/__main__.py b/packages/flow/openadapt_flow/__main__.py index a568d124fb..8f1aae14be 100644 --- a/packages/flow/openadapt_flow/__main__.py +++ b/packages/flow/openadapt_flow/__main__.py @@ -13435,6 +13435,9 @@ def _sealed_root_named(text: str) -> Optional[Path]: # The path cannot exist, so nothing sealed can be read through it. no_such_path = (errno.ENOENT, errno.ENOTDIR, errno.ENAMETOOLONG) + # Windows rejects a name it cannot hold, such as "http:" with a colon + # (ERROR_INVALID_NAME, ERROR_BAD_PATHNAME): no such path can exist either. + invalid_name = (123, 161) try: full = os.path.abspath(text) except ValueError: # an embedded NUL: no such path @@ -13446,7 +13449,8 @@ def _sealed_root_named(text: str) -> Optional[Path]: except ValueError: return None except OSError as exc: - if exc.errno not in no_such_path: + invalid = getattr(exc, "winerror", None) in invalid_name + if exc.errno not in no_such_path and not invalid: raise _UnclassifiedArgument( "a path whose sealed header cannot be checked" ) from exc diff --git a/packages/flow/tests/test_artifact_reads.py b/packages/flow/tests/test_artifact_reads.py index 1bb45fca90..ab6f10facc 100644 --- a/packages/flow/tests/test_artifact_reads.py +++ b/packages/flow/tests/test_artifact_reads.py @@ -1290,3 +1290,27 @@ def test_a_path_whose_header_cannot_be_checked_is_private(tmp_path: Path) -> Non assert _private_input(argparse.Namespace(example=[str(locked / "inner")])) finally: locked.chmod(0o700) + + +@pytest.mark.parametrize( + ("winerror", "private"), [(123, False), (161, False), (None, True), (5, True)] +) +def test_a_name_windows_cannot_hold_is_public( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, winerror, private: bool +) -> None: + """A URL argument on Windows (a colon in a name) cannot name a local + path, so it is public; any other error checking the header is private.""" + import argparse + import errno + + from openadapt_flow.__main__ import _private_input + + def stat(path, *args, **kwargs): + exc = OSError(errno.EINVAL, "The filename syntax is incorrect") + if winerror is not None: + exc.winerror = winerror # type: ignore[attr-defined] + raise exc + + monkeypatch.setattr(os, "stat", stat) + args = argparse.Namespace(effects_base_url="http://127.0.0.1:8080") + assert _private_input(args) is private