diff --git a/packages/flow/docs/showcase-openemr/bundle/manifest.json b/packages/flow/docs/showcase-openemr/bundle/manifest.json new file mode 100644 index 0000000000..0078685fb1 --- /dev/null +++ b/packages/flow/docs/showcase-openemr/bundle/manifest.json @@ -0,0 +1,47 @@ +{ + "schema_version": 2, + "content_digest": "6b41860d6e3b78cdba8511ffceabd0aeb11c6571ea870820b8f6cfa352aacfe3", + "file_hashes": { + "templates/identifiers/step_005.png": "ab6795dd012009449c7bc346ffec33db8a2c54c53cfbf47f5172b9d02e50f167", + "templates/identifiers/step_008.png": "8143294215bc1fc9fc09674c15e76c57b3949992e14b4d156f06c3daf2313c3f", + "templates/identifiers/step_014.png": "883eb0b5d10bf27fbf5b4a2892560787f89f811b06f00c0c9d6d15ec795a6afb", + "templates/layout_fingerprint.json": "240259c63576a467d974ea547f37b5c02ae955ff11e8ec5ffb9d88de2ef58446", + "templates/step_000.png": "f1828b7d1644fbe316c20d5e85904149f582bc851516a4f57ab87854e09d91f6", + "templates/step_000_expect.png": "a191ba28eaf2691ea2bdbde6043f87eeea0bda377ed1dbd92ed8f99e4645c38e", + "templates/step_001_expect.png": "a368fb929154c409069b8529c4159e95009de1cd90847ef8c9e6e7217383112e", + "templates/step_002.png": "edc6f6ff053c14c201f17cca8b46ae4b91b8e80858c203f8f47425552f4a2aea", + "templates/step_002_expect.png": "b588f9b3a2ac3c47e9f3b5482cb73e841bb1fceda2dcec1f25d76639c2d7885b", + "templates/step_003_expect.png": "5d0d7095f8556037dca2486a9f65b811afc778e56424c60596f2701e749d8d20", + "templates/step_004.png": "e92c4f9a9641f85ab09240761747f296a259514da6f0fa5dcb553f4cd24973c3", + "templates/step_005.png": "e21c6ca4622199cd03c4ec569d79992e275321dd633a532a8e95f9839692cd7c", + "templates/step_005_expect.png": "c5667abd0a4634138fe14c45fb9e9db24915730f9833b380a468c192b9705553", + "templates/step_006_expect.png": "f7e7bea1e80633fdfb6d59c0fe8a21e555258467771a4aae77e04e0ae5638237", + "templates/step_007_expect.png": "b00f01ef23bb84596f854e770197f88b4a573dc595dec10f4641d5853dfae8f6", + "templates/step_008.png": "daa4ed0af4be54f6c834124d428c3cc53782bf47f078e2aed3ac66b1821cc001", + "templates/step_008_expect.png": "eb616c03c24974233e5926db0ce325efd28ba09bf7d7294b2654182056a64001", + "templates/step_013.png": "44bcea01c29ffce089359a39defec0e9e54083af01410e9c4a3f7c0d25eb3cab", + "templates/step_013_expect.png": "49573416a7ebe7aff262d82ee44dd54ee21cb4a76ba99a07e666e46b5a721446", + "templates/step_014.png": "7feb81b1126ebf4da47b3d3cde66852d45df9c5c0de92271fd5967aa2191befb", + "templates/step_014_expect.png": "4b82d961d0ab7706325dc0aaae70e0432eb7f6130871cd9bbd48fe8b26a11774", + "templates/step_015.png": "a525c1b81d24176746463b435cfa870f27ae8851918ace8d667a91cbd429c58e", + "templates/step_015_expect.png": "5e7fba0ba58b1d27497f4a1148934be51886a92c94edaa50582ac3045642f74f", + "templates/step_017.png": "1952c2c78b8fbea7f97276e5ceb9a80a1f559deadca13ebb8e1526f272c8935f", + "templates/step_017_expect.png": "3bb8ad7cd2002f295c44a7d5e57a268d7dc2c712519bccb169cf8ca8f50392b7" + }, + "provenance": { + "compiler_version": "2.6.0", + "source_recording_sha256": null, + "sanitized_from_digest": null, + "compiler_config_sha256": "d3748d5c9304e9880719b005ec461ddfd6c1ad586139bb7ae870333099d7ae86", + "created_at": "2026-11-06T04:51:24.260972+00:00", + "policy_name": null, + "certified": false, + "certification_status": null, + "certified_at": null, + "expires_at": null, + "certification_invalidated_at": null, + "certification_invalidation_reason": null, + "governed_authorization_template": null + }, + "encrypted": false +} \ No newline at end of file diff --git a/packages/flow/docs/showcase-openemr/bundle/sections.json b/packages/flow/docs/showcase-openemr/bundle/sections.json new file mode 100644 index 0000000000..71d0fe9337 --- /dev/null +++ b/packages/flow/docs/showcase-openemr/bundle/sections.json @@ -0,0 +1,139 @@ +{ + "schema": "openadapt.flow.sections/v1", + "note": "Display metadata: named groups of steps for program maps. Execution and the outcome classifier ignore this file, and it is outside the content digest and the workflow contract hash.", + "workflow_contract_sha256": "93afd9d58a95ecbe1a09a99b822a110f7be535a182759887d1b6e9d3dc3beef8", + "sections": [ + { + "id": "fields-step_000", + "title": "1 field: text", + "title_source": "fields", + "kind": "fields", + "steps": [ + "step_000", + "step_001", + "step_002", + "step_003", + "step_004", + "step_005", + "step_006", + "step_007" + ], + "fields": [ + "text" + ], + "params": [] + }, + { + "id": "save-step_008", + "title": "+ Add", + "title_source": "ui", + "kind": "save", + "steps": [ + "step_008", + "step_009", + "step_010", + "step_011", + "step_012", + "step_013", + "step_014" + ], + "fields": [], + "params": [] + }, + { + "id": "fields-step_015", + "title": "1 field: note", + "title_source": "fields", + "kind": "fields", + "steps": [ + "step_015", + "step_016" + ], + "fields": [ + "note" + ], + "params": [ + "note" + ] + }, + { + "id": "save-step_017", + "title": "Save as new message", + "title_source": "ui", + "kind": "save", + "steps": [ + "step_017" + ], + "fields": [], + "params": [] + } + ], + "compiled": [ + { + "id": "fields-step_000", + "title": "1 field: text", + "title_source": "fields", + "kind": "fields", + "steps": [ + "step_000", + "step_001", + "step_002", + "step_003", + "step_004", + "step_005", + "step_006", + "step_007" + ], + "fields": [ + "text" + ], + "params": [] + }, + { + "id": "save-step_008", + "title": "+ Add", + "title_source": "ui", + "kind": "save", + "steps": [ + "step_008", + "step_009", + "step_010", + "step_011", + "step_012", + "step_013", + "step_014" + ], + "fields": [], + "params": [] + }, + { + "id": "fields-step_015", + "title": "1 field: note", + "title_source": "fields", + "kind": "fields", + "steps": [ + "step_015", + "step_016" + ], + "fields": [ + "note" + ], + "params": [ + "note" + ] + }, + { + "id": "save-step_017", + "title": "Save as new message", + "title_source": "ui", + "kind": "save", + "steps": [ + "step_017" + ], + "fields": [], + "params": [] + } + ], + "edits": [], + "context": {} +} diff --git a/packages/flow/docs/showcase-openemr/bundle/templates/identifiers/step_005.png b/packages/flow/docs/showcase-openemr/bundle/templates/identifiers/step_005.png new file mode 100644 index 0000000000..00f680ad61 Binary files /dev/null and b/packages/flow/docs/showcase-openemr/bundle/templates/identifiers/step_005.png differ diff --git a/packages/flow/docs/showcase-openemr/bundle/templates/identifiers/step_008.png b/packages/flow/docs/showcase-openemr/bundle/templates/identifiers/step_008.png new file mode 100644 index 0000000000..bf04a13952 Binary files /dev/null and b/packages/flow/docs/showcase-openemr/bundle/templates/identifiers/step_008.png differ diff --git a/packages/flow/docs/showcase-openemr/bundle/templates/identifiers/step_014.png b/packages/flow/docs/showcase-openemr/bundle/templates/identifiers/step_014.png new file mode 100644 index 0000000000..5364f29398 Binary files /dev/null and b/packages/flow/docs/showcase-openemr/bundle/templates/identifiers/step_014.png differ diff --git a/packages/flow/docs/showcase-openemr/bundle/templates/layout_fingerprint.json b/packages/flow/docs/showcase-openemr/bundle/templates/layout_fingerprint.json new file mode 100644 index 0000000000..7efc0cd79e --- /dev/null +++ b/packages/flow/docs/showcase-openemr/bundle/templates/layout_fingerprint.json @@ -0,0 +1,1373 @@ +{ + "scale_landmarks": { + "salt": "bb3807cda1af2c351335c9a37ae817c9", + "steps": { + "step_000": [ + [ + "53a1804f7dbd272e", + 659.5, + 197.0, + 117, + 36 + ], + [ + "7b7c2bec3951786f", + 640.0, + 294.0, + 282, + 28 + ], + [ + "7d60863fb47efc3f", + 641.0, + 323.5, + 340, + 23 + ], + [ + "054335a855f21b42", + 640.0, + 354.5, + 278, + 23 + ], + [ + "7adb465e50e1788d", + 493.5, + 513.5, + 77, + 25 + ], + [ + "10936cd3d9d7275a", + 708.0, + 509.0, + 210, + 24 + ], + [ + "1da31ede23d9a4f4", + 640.0, + 567.0, + 48, + 24 + ], + [ + "220ab5efc43935cc", + 641.0, + 611.5, + 264, + 17 + ] + ], + "step_002": [ + [ + "53a1804f7dbd272e", + 659.5, + 197.0, + 117, + 36 + ], + [ + "7b7c2bec3951786f", + 640.0, + 294.0, + 282, + 28 + ], + [ + "7d60863fb47efc3f", + 641.0, + 323.5, + 340, + 23 + ], + [ + "054335a855f21b42", + 639.5, + 354.5, + 277, + 23 + ], + [ + "b015c5882dd86535", + 494.5, + 404.5, + 81, + 25 + ], + [ + "70f57380a411a4d8", + 625.0, + 404.0, + 50, + 22 + ], + [ + "7adb465e50e1788d", + 493.5, + 513.5, + 77, + 25 + ], + [ + "10936cd3d9d7275a", + 708.0, + 509.0, + 210, + 24 + ], + [ + "1da31ede23d9a4f4", + 640.0, + 567.0, + 48, + 24 + ], + [ + "220ab5efc43935cc", + 641.0, + 611.5, + 264, + 17 + ] + ], + "step_004": [ + [ + "53a1804f7dbd272e", + 659.5, + 197.0, + 117, + 36 + ], + [ + "7b7c2bec3951786f", + 640.0, + 294.0, + 282, + 28 + ], + [ + "7d60863fb47efc3f", + 641.0, + 323.5, + 340, + 23 + ], + [ + "054335a855f21b42", + 639.5, + 354.5, + 277, + 23 + ], + [ + "b015c5882dd86535", + 494.5, + 404.5, + 81, + 25 + ], + [ + "70f57380a411a4d8", + 624.5, + 404.0, + 51, + 22 + ], + [ + "657e119c8311a639", + 493.0, + 458.0, + 76, + 20 + ], + [ + "7adb465e50e1788d", + 493.0, + 513.5, + 78, + 25 + ], + [ + "10936cd3d9d7275a", + 708.5, + 510.0, + 207, + 20 + ], + [ + "1da31ede23d9a4f4", + 640.0, + 567.0, + 48, + 24 + ], + [ + "220ab5efc43935cc", + 641.0, + 611.5, + 264, + 17 + ] + ], + "step_005": [ + [ + "1677f5e2c754311c", + 544.5, + 31.0, + 991, + 22 + ], + [ + "7cd7b8195acab3ae", + 1121.5, + 32.0, + 153, + 22 + ], + [ + "80869c7ff9366539", + 69.0, + 126.5, + 64, + 17 + ], + [ + "956f52f28fd1c1e2", + 259.0, + 126.5, + 156, + 17 + ], + [ + "69f2e8851af4f2ce", + 517.0, + 170.5, + 274, + 29 + ], + [ + "0185b8f808058a34", + 1223.0, + 170.5, + 48, + 23 + ], + [ + "64f0aed699ebdb79", + 819.5, + 213.0, + 81, + 20 + ], + [ + "f32e4f3eda3a5e29", + 292.5, + 393.5, + 35, + 15 + ], + [ + "306e615b3acd952f", + 402.0, + 398.5, + 174, + 25 + ], + [ + "e0c114688c9dadbd", + 292.5, + 413.0, + 37, + 18 + ], + [ + "40e4bca9bc444d9b", + 47.5, + 425.5, + 73, + 21 + ], + [ + "170de2352353716a", + 292.0, + 433.0, + 38, + 18 + ], + [ + "8495f9b08dcfdb54", + 60.5, + 453.5, + 69, + 21 + ], + [ + "8b574777a644e62b", + 292.5, + 453.0, + 37, + 18 + ], + [ + "682a3db0e6b6fc3a", + 70.5, + 473.5, + 89, + 21 + ], + [ + "9fd9094faab8b3d3", + 292.5, + 473.0, + 37, + 18 + ], + [ + "a6ac9c15d4eda0a9", + 402.0, + 479.5, + 172, + 25 + ], + [ + "7ef3675f1a74d196", + 68.5, + 492.5, + 85, + 21 + ], + [ + "468103917a121ed6", + 292.5, + 493.5, + 37, + 19 + ], + [ + "4fe15e048482f544", + 71.0, + 511.5, + 90, + 21 + ], + [ + "9f2fa0ba578fb3ef", + 292.5, + 514.5, + 37, + 19 + ], + [ + "ffee08e56fdcb485", + 292.5, + 534.0, + 37, + 18 + ], + [ + "ed83e9ca9462e5da", + 292.5, + 554.0, + 37, + 18 + ], + [ + "492950966cf06387", + 378.5, + 558.0, + 127, + 22 + ], + [ + "bee99d679f35404b", + 111.0, + 576.5, + 90, + 23 + ], + [ + "cfb07034e7ada233", + 292.5, + 574.0, + 37, + 18 + ], + [ + "d48473165b31ca81", + 356.0, + 575.0, + 78, + 20 + ], + [ + "e37c384c207c6fbe", + 292.5, + 594.0, + 37, + 18 + ], + [ + "17a53554383d5b25", + 292.5, + 615.5, + 35, + 15 + ], + [ + "56510da7830225f6", + 292.5, + 635.5, + 39, + 17 + ], + [ + "755b381e21e23ecc", + 292.5, + 655.5, + 39, + 17 + ], + [ + "26ecb99ae5c1ce04", + 292.5, + 675.5, + 39, + 17 + ] + ], + "step_008": [ + [ + "1677f5e2c754311c", + 497.0, + 31.0, + 992, + 22 + ], + [ + "cabf57ed43dd0dfd", + 1005.0, + 31.0, + 36, + 18 + ], + [ + "b497fc64468ee106", + 142.5, + 126.5, + 281, + 17 + ], + [ + "4f2b5a9008c22648", + 383.0, + 126.5, + 128, + 17 + ], + [ + "c7dd287d91934bfd", + 54.5, + 191.5, + 109, + 23 + ], + [ + "042fbd9f2e640116", + 210.0, + 191.0, + 148, + 20 + ], + [ + "8671ff7cf8316860", + 32.5, + 256.0, + 65, + 26 + ], + [ + "6192e224859b4ebb", + 151.0, + 257.0, + 118, + 20 + ], + [ + "fa8df429b3a4f4fe", + 88.0, + 293.0, + 54, + 22 + ], + [ + "28929be4d4e862e1", + 1088.0, + 293.0, + 62, + 24 + ], + [ + "d327d2e506cd7cb9", + 54.0, + 346.0, + 108, + 20 + ], + [ + "a584933894add8d6", + 337.5, + 347.0, + 173, + 20 + ], + [ + "e7ffbcef37172935", + 569.5, + 345.5, + 115, + 23 + ], + [ + "48c5a443e3139ea6", + 863.0, + 346.0, + 168, + 20 + ], + [ + "a93939858215ef46", + 1120.5, + 346.0, + 157, + 20 + ], + [ + "0cb8400a1aae2ae0", + 27.5, + 399.0, + 55, + 24 + ], + [ + "f83063f16fd132a2", + 288.0, + 399.5, + 106, + 23 + ], + [ + "d2126cc4b4c4b150", + 815.0, + 399.5, + 104, + 23 + ], + [ + "f4bf983b8713beef", + 1072.0, + 399.5, + 88, + 21 + ], + [ + "b763bbc04b6c5a5f", + 31.5, + 443.5, + 63, + 21 + ], + [ + "6924139615e397e4", + 288.0, + 443.5, + 102, + 17 + ], + [ + "a6776f7d8b03b4e6", + 542.5, + 444.0, + 85, + 20 + ], + [ + "2487c0eed7cb17dd", + 808.0, + 444.0, + 86, + 20 + ], + [ + "4d2129a88f7a3447", + 192.5, + 482.5, + 385, + 23 + ], + [ + "245c7b8ccc09a1c8", + 182.0, + 521.0, + 362, + 20 + ], + [ + "0823239da9ef6759", + 1145.0, + 520.0, + 70, + 24 + ], + [ + "0559598e813a7649", + 1251.5, + 520.0, + 41, + 22 + ] + ], + "step_013": [ + [ + "1677f5e2c754311c", + 497.5, + 31.0, + 993, + 22 + ], + [ + "cabf57ed43dd0dfd", + 1005.5, + 31.5, + 35, + 15 + ], + [ + "6ae712120248d437", + 149.0, + 88.5, + 208, + 33 + ], + [ + "ce346f06146e6042", + 721.0, + 87.0, + 150, + 20 + ], + [ + "b91f05b117d4027d", + 140.0, + 126.0, + 188, + 20 + ], + [ + "196076afbb94116b", + 691.5, + 123.0, + 167, + 20 + ], + [ + "b07e907f5614d8fd", + 62.0, + 184.5, + 120, + 17 + ], + [ + "ec4c770f0b0e9abe", + 291.0, + 183.5, + 338, + 21 + ], + [ + "d1a90e829751aa69", + 546.0, + 184.5, + 106, + 17 + ], + [ + "2c95cf0317f658cc", + 721.5, + 185.0, + 145, + 20 + ], + [ + "2a1f3799edbb93c3", + 47.0, + 215.5, + 94, + 23 + ], + [ + "a49d0f73d0a258c7", + 477.5, + 216.0, + 167, + 20 + ], + [ + "82d1da02d03cc78f", + 935.0, + 235.5, + 138, + 23 + ], + [ + "d7984f36e8ad711d", + 48.0, + 248.0, + 96, + 24 + ], + [ + "54905652996bb3b5", + 902.5, + 282.0, + 49, + 26 + ], + [ + "09f0df70eb032fd3", + 24.5, + 299.5, + 49, + 21 + ], + [ + "bc1441321b8dc29b", + 36.5, + 369.5, + 73, + 31 + ], + [ + "c051f72945e356ba", + 253.0, + 424.5, + 506, + 19 + ], + [ + "49e69c2e8fe609ca", + 68.0, + 608.5, + 136, + 25 + ], + [ + "7ec328752484b51c", + 83.0, + 635.5, + 164, + 17 + ], + [ + "dedde2680264e1ed", + 44.0, + 681.5, + 88, + 27 + ], + [ + "723a1506fa904dd8", + 186.0, + 734.0, + 62, + 20 + ], + [ + "07458fc788a34e28", + 502.0, + 735.5, + 68, + 21 + ], + [ + "5a452bd749085368", + 277.0, + 761.5, + 554, + 21 + ] + ], + "step_014": [ + [ + "1677f5e2c754311c", + 497.0, + 31.0, + 992, + 22 + ], + [ + "cabf57ed43dd0dfd", + 1005.5, + 31.5, + 37, + 19 + ], + [ + "6ae712120248d437", + 149.0, + 88.5, + 208, + 33 + ], + [ + "ce346f06146e6042", + 721.5, + 87.5, + 151, + 25 + ], + [ + "b91f05b117d4027d", + 139.5, + 126.0, + 187, + 20 + ], + [ + "196076afbb94116b", + 691.5, + 123.0, + 167, + 20 + ], + [ + "aa21e82e662b79b9", + 58.0, + 184.0, + 114, + 18 + ], + [ + "bcd2c5c62281b1b1", + 220.0, + 184.0, + 190, + 22 + ], + [ + "4f2b5a9008c22648", + 378.5, + 184.0, + 131, + 22 + ], + [ + "028b32f23c157e38", + 536.0, + 184.5, + 86, + 17 + ], + [ + "2c95cf0317f658cc", + 729.0, + 185.0, + 146, + 20 + ], + [ + "58e84578636ab263", + 278.5, + 236.5, + 425, + 29 + ], + [ + "4bf92fd089f77839", + 204.0, + 281.5, + 134, + 23 + ], + [ + "d7f882ec077611a6", + 333.0, + 281.5, + 84, + 21 + ], + [ + "fa23e64b79fcf4fa", + 446.0, + 281.0, + 106, + 26 + ], + [ + "d5a523154c8f7de7", + 584.0, + 289.5, + 132, + 17 + ], + [ + "b4746fa52617f48f", + 111.5, + 377.5, + 51, + 21 + ], + [ + "96c259549419c5e4", + 205.5, + 376.5, + 103, + 23 + ], + [ + "db906066370a68c6", + 306.0, + 376.0, + 76, + 20 + ], + [ + "d99b3c17eafd995b", + 115.5, + 431.5, + 59, + 21 + ], + [ + "ccb0ed7897c7a2a3", + 256.0, + 431.0, + 52, + 24 + ], + [ + "8ee0d075242b8329", + 433.0, + 433.5, + 42, + 23 + ], + [ + "ec3c8e7c62a98d10", + 551.0, + 431.5, + 62, + 21 + ], + [ + "d076cc2712b27ebc", + 696.0, + 431.5, + 52, + 21 + ], + [ + "c47d214198902180", + 843.0, + 432.0, + 90, + 26 + ], + [ + "53a0aa59157a51e3", + 1037.5, + 432.0, + 77, + 24 + ] + ], + "step_015": [ + [ + "252c77e2758550e5", + 147.5, + 30.5, + 293, + 21 + ], + [ + "cabf57ed43dd0dfd", + 1010.0, + 31.5, + 30, + 19 + ], + [ + "cd8745403afc1970", + 433.5, + 48.0, + 77, + 24 + ], + [ + "6ae712120248d437", + 149.0, + 88.5, + 208, + 33 + ], + [ + "7e329305c7f5bb7d", + 453.0, + 94.5, + 140, + 25 + ], + [ + "b91f05b117d4027d", + 140.0, + 126.0, + 188, + 20 + ], + [ + "8ee0d075242b8329", + 406.5, + 144.5, + 51, + 27 + ], + [ + "aa21e82e662b79b9", + 58.5, + 184.5, + 115, + 17 + ], + [ + "dfa69328a1178e6a", + 204.0, + 185.0, + 158, + 20 + ], + [ + "40efc8161d928af4", + 445.0, + 182.0, + 92, + 24 + ], + [ + "404e38e1bab62c12", + 178.0, + 236.5, + 224, + 29 + ], + [ + "7ef3675f1a74d196", + 442.0, + 268.0, + 86, + 24 + ], + [ + "4bf92fd089f77839", + 203.5, + 281.5, + 133, + 25 + ], + [ + "b4746fa52617f48f", + 111.5, + 377.0, + 49, + 18 + ], + [ + "96c259549419c5e4", + 204.5, + 376.5, + 101, + 23 + ], + [ + "d99b3c17eafd995b", + 116.0, + 432.0, + 58, + 18 + ], + [ + "ccb0ed7897c7a2a3", + 257.0, + 431.0, + 52, + 24 + ], + [ + "53a0aa59157a51e3", + 1036.0, + 431.5, + 76, + 23 + ], + [ + "2e01bf8d564a620d", + 500.0, + 454.0, + 168, + 24 + ], + [ + "087a9545562fe021", + 947.0, + 577.0, + 44, + 22 + ] + ], + "step_017": [ + [ + "252c77e2758550e5", + 147.5, + 30.5, + 293, + 21 + ], + [ + "cabf57ed43dd0dfd", + 1010.0, + 31.5, + 30, + 19 + ], + [ + "cd8745403afc1970", + 433.5, + 48.0, + 77, + 24 + ], + [ + "6ae712120248d437", + 149.0, + 88.5, + 208, + 33 + ], + [ + "7e329305c7f5bb7d", + 453.0, + 95.0, + 140, + 24 + ], + [ + "b91f05b117d4027d", + 140.0, + 126.0, + 188, + 20 + ], + [ + "8ee0d075242b8329", + 406.5, + 144.0, + 49, + 22 + ], + [ + "aa21e82e662b79b9", + 58.5, + 184.5, + 115, + 17 + ], + [ + "dfa69328a1178e6a", + 204.0, + 185.0, + 158, + 20 + ], + [ + "40efc8161d928af4", + 445.0, + 182.0, + 92, + 24 + ], + [ + "404e38e1bab62c12", + 178.0, + 236.5, + 224, + 29 + ], + [ + "7ef3675f1a74d196", + 441.5, + 267.0, + 87, + 26 + ], + [ + "4bf92fd089f77839", + 203.5, + 281.5, + 133, + 25 + ], + [ + "b4746fa52617f48f", + 112.0, + 377.0, + 50, + 18 + ], + [ + "96c259549419c5e4", + 204.0, + 376.5, + 102, + 23 + ], + [ + "d99b3c17eafd995b", + 116.0, + 432.0, + 58, + 18 + ], + [ + "ccb0ed7897c7a2a3", + 256.5, + 431.0, + 53, + 24 + ], + [ + "53a0aa59157a51e3", + 1036.0, + 431.5, + 76, + 23 + ], + [ + "2e01bf8d564a620d", + 500.0, + 454.0, + 168, + 24 + ], + [ + "087a9545562fe021", + 947.0, + 577.0, + 44, + 22 + ] + ] + } + }, + "schema": "openadapt.layout-fingerprints/v1", + "steps": { + "step_000": { + "char_width": 9.875, + "landmarks": {}, + "lines": 12, + "profile_sha256": null, + "text_height": 24.0, + "viewport": [ + 1280, + 800 + ] + }, + "step_002": { + "char_width": 9.8125, + "landmarks": {}, + "lines": 12, + "profile_sha256": null, + "text_height": 23.5, + "viewport": [ + 1280, + 800 + ] + }, + "step_004": { + "char_width": 10.125, + "landmarks": {}, + "lines": 11, + "profile_sha256": null, + "text_height": 23, + "viewport": [ + 1280, + 800 + ] + }, + "step_005": { + "char_width": 7.8, + "landmarks": {}, + "lines": 48, + "profile_sha256": null, + "text_height": 18.0, + "viewport": [ + 1280, + 800 + ] + }, + "step_008": { + "char_width": 9.26388888888889, + "landmarks": {}, + "lines": 28, + "profile_sha256": null, + "text_height": 21.0, + "viewport": [ + 1280, + 800 + ] + }, + "step_013": { + "char_width": 9.831683168316832, + "landmarks": {}, + "lines": 27, + "profile_sha256": null, + "text_height": 21, + "viewport": [ + 1280, + 800 + ] + }, + "step_014": { + "char_width": 9.35, + "landmarks": {}, + "lines": 27, + "profile_sha256": null, + "text_height": 22, + "viewport": [ + 1280, + 800 + ] + }, + "step_015": { + "char_width": 9.5, + "landmarks": {}, + "lines": 23, + "profile_sha256": null, + "text_height": 23, + "viewport": [ + 1280, + 800 + ] + }, + "step_017": { + "char_width": 9.5, + "landmarks": {}, + "lines": 24, + "profile_sha256": null, + "text_height": 23.0, + "viewport": [ + 1280, + 800 + ] + } + } +} diff --git a/packages/flow/docs/showcase-openemr/bundle/workflow.json b/packages/flow/docs/showcase-openemr/bundle/workflow.json index a313c3ac7d..5d3f2621ce 100644 --- a/packages/flow/docs/showcase-openemr/bundle/workflow.json +++ b/packages/flow/docs/showcase-openemr/bundle/workflow.json @@ -1,11 +1,11 @@ { - "schema_version": 1, + "schema_version": 2, "name": "openemr-showcase", "recording_id": "50758ec282e94c4a9f99f69ab7415678", "contains_phi": false, "phi_scrubbed": false, "encrypted": false, - "created_at": "2026-07-14T00:51:06.482608+00:00", + "created_at": "2026-11-06T04:51:24.259258+00:00", "viewport": [ 1280, 800 @@ -47,6 +47,7 @@ "identity_template": null, "identifier_crop": null, "identifier_region": null, + "form_identity": null, "landmarks": [ { "relation": "left_of", @@ -68,7 +69,12 @@ "text": null, "param": null, "secret": false, + "field_label": null, + "selection_commit_key": null, + "selection_region": null, "key": null, + "modifiers": [], + "drag_end_anchor": null, "scroll_dx": null, "scroll_dy": null, "expect": [ @@ -90,11 +96,24 @@ "effects": [], "api_binding": null, "risk": "reversible", + "risk_explanation": "control label has no retained consequential-write signal", + "risk_review_required": false, "wait_until": null, "guard": null, + "read": null, + "read_gate": null, + "surface": null, + "opens": [], + "surface_origin": null, + "closes_surface": false, + "dialog": null, "timeout_s": 10.0, "identity_armed": false, - "identity_unarmed_reason": "row text outside the target's label is too generic (< 12 squashed chars after volatile-line filtering)" + "identity_unarmed_reason": "row text outside the target's label is too generic (< 12 squashed chars after volatile-line filtering)", + "identifier_crop_missing_reason": "identity is not armed on this step (see identity_unarmed_reason) — there is no identity evidence to crop", + "hover": null, + "submit_binding": null, + "submit_binding_unbound_reason": null }, { "id": "step_001", @@ -104,7 +123,12 @@ "text": "admin", "param": null, "secret": false, + "field_label": null, + "selection_commit_key": null, + "selection_region": null, "key": null, + "modifiers": [], + "drag_end_anchor": null, "scroll_dx": null, "scroll_dy": null, "expect": [ @@ -135,11 +159,24 @@ "effects": [], "api_binding": null, "risk": "reversible", + "risk_explanation": "action has no retained consequential commit signal", + "risk_review_required": false, "wait_until": null, "guard": null, + "read": null, + "read_gate": null, + "surface": null, + "opens": [], + "surface_origin": null, + "closes_surface": false, + "dialog": null, "timeout_s": 10.0, "identity_armed": null, - "identity_unarmed_reason": null + "identity_unarmed_reason": null, + "identifier_crop_missing_reason": null, + "hover": null, + "submit_binding": null, + "submit_binding_unbound_reason": null }, { "id": "step_002", @@ -164,6 +201,7 @@ "identity_template": null, "identifier_crop": null, "identifier_region": null, + "form_identity": null, "landmarks": [ { "relation": "below", @@ -185,7 +223,12 @@ "text": null, "param": null, "secret": false, + "field_label": null, + "selection_commit_key": null, + "selection_region": null, "key": null, + "modifiers": [], + "drag_end_anchor": null, "scroll_dx": null, "scroll_dy": null, "expect": [ @@ -207,11 +250,24 @@ "effects": [], "api_binding": null, "risk": "reversible", + "risk_explanation": "control label has no retained consequential-write signal", + "risk_review_required": false, "wait_until": null, "guard": null, + "read": null, + "read_gate": null, + "surface": null, + "opens": [], + "surface_origin": null, + "closes_surface": false, + "dialog": null, "timeout_s": 10.0, "identity_armed": false, - "identity_unarmed_reason": "row text outside the target's label is too generic (< 12 squashed chars after volatile-line filtering)" + "identity_unarmed_reason": "row text outside the target's label is too generic (< 12 squashed chars after volatile-line filtering)", + "identifier_crop_missing_reason": "identity is not armed on this step (see identity_unarmed_reason) — there is no identity evidence to crop", + "hover": null, + "submit_binding": null, + "submit_binding_unbound_reason": null }, { "id": "step_003", @@ -221,7 +277,12 @@ "text": "pass", "param": null, "secret": false, + "field_label": null, + "selection_commit_key": null, + "selection_region": null, "key": null, + "modifiers": [], + "drag_end_anchor": null, "scroll_dx": null, "scroll_dy": null, "expect": [ @@ -243,11 +304,24 @@ "effects": [], "api_binding": null, "risk": "reversible", + "risk_explanation": "action has no retained consequential commit signal", + "risk_review_required": false, "wait_until": null, "guard": null, + "read": null, + "read_gate": null, + "surface": null, + "opens": [], + "surface_origin": null, + "closes_surface": false, + "dialog": null, "timeout_s": 10.0, "identity_armed": null, - "identity_unarmed_reason": null + "identity_unarmed_reason": null, + "identifier_crop_missing_reason": null, + "hover": null, + "submit_binding": null, + "submit_binding_unbound_reason": null }, { "id": "step_004", @@ -272,6 +346,7 @@ "identity_template": null, "identifier_crop": null, "identifier_region": null, + "form_identity": null, "landmarks": [ { "relation": "below", @@ -293,7 +368,12 @@ "text": null, "param": null, "secret": false, + "field_label": null, + "selection_commit_key": null, + "selection_region": null, "key": null, + "modifiers": [], + "drag_end_anchor": null, "scroll_dx": null, "scroll_dy": null, "expect": [ @@ -310,11 +390,24 @@ "effects": [], "api_binding": null, "risk": "reversible", + "risk_explanation": "control label has no retained consequential-write signal", + "risk_review_required": false, "wait_until": null, "guard": null, + "read": null, + "read_gate": null, + "surface": null, + "opens": [], + "surface_origin": null, + "closes_surface": false, + "dialog": null, "timeout_s": 10.0, "identity_armed": false, - "identity_unarmed_reason": "the only readable row text is the target's own label (mutable evidence, excluded from identity)" + "identity_unarmed_reason": "the only readable row text is the target's own label (mutable evidence, excluded from identity)", + "identifier_crop_missing_reason": "identity is not armed on this step (see identity_unarmed_reason) — there is no identity evidence to crop", + "hover": null, + "submit_binding": null, + "submit_binding_unbound_reason": null }, { "id": "step_005", @@ -338,12 +431,12 @@ "structured_identity": null, "identity_template": { "schema_version": 1, - "salt": "076a9e81b90d6ddda88595bd2dd19e90", - "band_len": 102, + "salt": "d87570c6fc466f1ecb27166c1678853c", + "band_len": 120, "tokens": [ { - "c": "f31da5157c6a4025", - "r": "54173ac4de425e17", + "c": "604fe9a6f95fafdc", + "r": "7efb5b032480dcef", "n": 101, "alpha": true, "name": true, @@ -353,8 +446,41 @@ "gen": false }, { - "c": "c908362266024318", - "r": "c908362266024318", + "c": "d5c99e759afd1d93", + "r": "d5c99e759afd1d93", + "n": 8, + "alpha": true, + "name": true, + "digit": false, + "idsh": false, + "glyph": false, + "gen": false + }, + { + "c": "8ddc1a63df6fb2c4", + "r": "8ddc1a63df6fb2c4", + "n": 3, + "alpha": true, + "name": true, + "digit": false, + "idsh": false, + "glyph": false, + "gen": false + }, + { + "c": "85e2b1bbd8f7ad80", + "r": "85e2b1bbd8f7ad80", + "n": 7, + "alpha": true, + "name": true, + "digit": false, + "idsh": false, + "glyph": false, + "gen": false + }, + { + "c": "04b52a257d1c57ba", + "r": "04b52a257d1c57ba", "n": 1, "alpha": true, "name": true, @@ -368,19 +494,115 @@ { "i": 0, "size": 2, - "c": "c6686c7b4153485a", - "r": "e9642945cf74a524", + "c": "32437f3d54107399", + "r": "e0407dfe77e42213", "digit": false, "name": true, - "n": 102 + "n": 109 + }, + { + "i": 0, + "size": 3, + "c": "5443d007cbe9ec72", + "r": "ef18332e11c267df", + "digit": false, + "name": true, + "n": 112 + }, + { + "i": 0, + "size": 4, + "c": "9c8baec45ba1d6dd", + "r": "e69647c80eb6b0f9", + "digit": false, + "name": true, + "n": 119 + }, + { + "i": 1, + "size": 2, + "c": "067be73eab766c11", + "r": "067be73eab766c11", + "digit": false, + "name": true, + "n": 11 + }, + { + "i": 1, + "size": 3, + "c": "7bd66617954ee928", + "r": "7bd66617954ee928", + "digit": false, + "name": true, + "n": 18 + }, + { + "i": 1, + "size": 4, + "c": "cd5694fae4015b86", + "r": "cd5694fae4015b86", + "digit": false, + "name": true, + "n": 19 + }, + { + "i": 2, + "size": 2, + "c": "6f828a356d7e2f3e", + "r": "6f828a356d7e2f3e", + "digit": false, + "name": true, + "n": 10 + }, + { + "i": 2, + "size": 3, + "c": "be1455d31c15d2f9", + "r": "be1455d31c15d2f9", + "digit": false, + "name": true, + "n": 11 + }, + { + "i": 3, + "size": 2, + "c": "114752e88efa76b7", + "r": "114752e88efa76b7", + "digit": false, + "name": true, + "n": 8 } ], "structured": null, + "signal_hashes": { + "captured_context|exact|": "39e71418e93e27657a515b9a6dd0a75664d4622c485e86f1b2a6366a6bc0943b", + "captured_context|normalized|unicode_nfkc": "39e71418e93e27657a515b9a6dd0a75664d4622c485e86f1b2a6366a6bc0943b", + "captured_context|normalized|casefold": "3d304ebc4a0599802644f4db7003aef58689498b7ce6888a48c2ff4a327739fa", + "captured_context|normalized|collapse_whitespace": "39e71418e93e27657a515b9a6dd0a75664d4622c485e86f1b2a6366a6bc0943b", + "captured_context|normalized|strip_punctuation": "39e71418e93e27657a515b9a6dd0a75664d4622c485e86f1b2a6366a6bc0943b", + "captured_context|normalized|unicode_nfkc,casefold": "3d304ebc4a0599802644f4db7003aef58689498b7ce6888a48c2ff4a327739fa", + "captured_context|normalized|unicode_nfkc,collapse_whitespace": "39e71418e93e27657a515b9a6dd0a75664d4622c485e86f1b2a6366a6bc0943b", + "captured_context|normalized|unicode_nfkc,strip_punctuation": "39e71418e93e27657a515b9a6dd0a75664d4622c485e86f1b2a6366a6bc0943b", + "captured_context|normalized|casefold,collapse_whitespace": "3d304ebc4a0599802644f4db7003aef58689498b7ce6888a48c2ff4a327739fa", + "captured_context|normalized|casefold,strip_punctuation": "3d304ebc4a0599802644f4db7003aef58689498b7ce6888a48c2ff4a327739fa", + "captured_context|normalized|collapse_whitespace,strip_punctuation": "39e71418e93e27657a515b9a6dd0a75664d4622c485e86f1b2a6366a6bc0943b", + "captured_context|normalized|unicode_nfkc,casefold,collapse_whitespace": "3d304ebc4a0599802644f4db7003aef58689498b7ce6888a48c2ff4a327739fa", + "captured_context|normalized|unicode_nfkc,casefold,strip_punctuation": "3d304ebc4a0599802644f4db7003aef58689498b7ce6888a48c2ff4a327739fa", + "captured_context|normalized|unicode_nfkc,collapse_whitespace,strip_punctuation": "39e71418e93e27657a515b9a6dd0a75664d4622c485e86f1b2a6366a6bc0943b", + "captured_context|normalized|casefold,collapse_whitespace,strip_punctuation": "3d304ebc4a0599802644f4db7003aef58689498b7ce6888a48c2ff4a327739fa", + "captured_context|normalized|unicode_nfkc,casefold,collapse_whitespace,strip_punctuation": "3d304ebc4a0599802644f4db7003aef58689498b7ce6888a48c2ff4a327739fa" + }, "param_token_indices": {}, "rests_on_confusable_identifier": false }, - "identifier_crop": null, - "identifier_region": null, + "identifier_crop": "templates/identifiers/step_005.png", + "identifier_region": [ + 49, + 20, + 1176, + 22 + ], + "form_identity": null, "landmarks": [ { "relation": "below", @@ -402,7 +624,12 @@ "text": null, "param": null, "secret": false, + "field_label": null, + "selection_commit_key": null, + "selection_region": null, "key": null, + "modifiers": [], + "drag_end_anchor": null, "scroll_dx": null, "scroll_dy": null, "expect": [ @@ -424,11 +651,24 @@ "effects": [], "api_binding": null, "risk": "reversible", + "risk_explanation": "control label has no retained consequential-write signal", + "risk_review_required": false, "wait_until": null, "guard": null, + "read": null, + "read_gate": null, + "surface": null, + "opens": [], + "surface_origin": null, + "closes_surface": false, + "dialog": null, "timeout_s": 10.0, "identity_armed": true, - "identity_unarmed_reason": null + "identity_unarmed_reason": null, + "identifier_crop_missing_reason": null, + "hover": null, + "submit_binding": null, + "submit_binding_unbound_reason": null }, { "id": "step_006", @@ -438,7 +678,12 @@ "text": "Phil", "param": null, "secret": false, + "field_label": null, + "selection_commit_key": null, + "selection_region": null, "key": null, + "modifiers": [], + "drag_end_anchor": null, "scroll_dx": null, "scroll_dy": null, "expect": [ @@ -469,11 +714,24 @@ "effects": [], "api_binding": null, "risk": "reversible", + "risk_explanation": "action has no retained consequential commit signal", + "risk_review_required": false, "wait_until": null, "guard": null, + "read": null, + "read_gate": null, + "surface": null, + "opens": [], + "surface_origin": null, + "closes_surface": false, + "dialog": null, "timeout_s": 10.0, "identity_armed": null, - "identity_unarmed_reason": null + "identity_unarmed_reason": null, + "identifier_crop_missing_reason": null, + "hover": null, + "submit_binding": null, + "submit_binding_unbound_reason": null }, { "id": "step_007", @@ -483,7 +741,12 @@ "text": null, "param": null, "secret": false, + "field_label": null, + "selection_commit_key": null, + "selection_region": null, "key": "Enter", + "modifiers": [], + "drag_end_anchor": null, "scroll_dx": null, "scroll_dy": null, "expect": [ @@ -514,11 +777,24 @@ "effects": [], "api_binding": null, "risk": "reversible", + "risk_explanation": "submission/deletion key may edit locally or commit application state", + "risk_review_required": true, "wait_until": null, "guard": null, + "read": null, + "read_gate": null, + "surface": null, + "opens": [], + "surface_origin": null, + "closes_surface": false, + "dialog": null, "timeout_s": 10.0, "identity_armed": null, - "identity_unarmed_reason": null + "identity_unarmed_reason": null, + "identifier_crop_missing_reason": null, + "hover": null, + "submit_binding": null, + "submit_binding_unbound_reason": null }, { "id": "step_008", @@ -542,13 +818,13 @@ "structured_identity": null, "identity_template": { "schema_version": 1, - "salt": "076a9e81b90d6ddda88595bd2dd19e90", + "salt": "d87570c6fc466f1ecb27166c1678853c", "band_len": 32, "tokens": [ { - "c": "a2929a25be2ec74e", - "r": "1a87697ed567a355", - "n": 12, + "c": "8238994f99152d25", + "r": "16c1a02262b5e5a3", + "n": 9, "alpha": false, "name": false, "digit": true, @@ -557,30 +833,30 @@ "gen": false }, { - "c": "8bd929d424f946de", - "r": "c14f3daa06b03b31", - "n": 9, + "c": "ec4c7c568e6c565a", + "r": "5235ee148f0a33f6", + "n": 10, "alpha": false, "name": false, "digit": true, - "idsh": true, + "idsh": false, "glyph": false, "gen": false }, { - "c": "3eb2a6e306ae66eb", - "r": "960f3c575917b914", - "n": 10, + "c": "850b3075b37d3799", + "r": "ce31d93bb38d3937", + "n": 12, "alpha": false, "name": false, "digit": true, - "idsh": false, + "idsh": true, "glyph": false, "gen": false }, { - "c": "75e7d6b0c8b31579", - "r": "bc89869273be9f2d", + "c": "b3240eb701d23d10", + "r": "79f11b95b96b715f", "n": 1, "alpha": false, "name": false, @@ -594,17 +870,17 @@ { "i": 0, "size": 2, - "c": "ec85fa0ac01cf590", - "r": "d7f444202dcda5ec", + "c": "21fddfe16cb62e5c", + "r": "6ea0d85015b84512", "digit": true, "name": false, - "n": 21 + "n": 19 }, { "i": 0, "size": 3, - "c": "15a4b9c9025d0962", - "r": "99ea03dca99dacd3", + "c": "217c8ea66bb48bc4", + "r": "7c27aa3ef4eb4d22", "digit": true, "name": false, "n": 31 @@ -612,8 +888,8 @@ { "i": 0, "size": 4, - "c": "d2f0b3f456a67420", - "r": "e8810bee45734b17", + "c": "5c5462b5052b2fd2", + "r": "63c7142316050a72", "digit": true, "name": false, "n": 32 @@ -621,37 +897,61 @@ { "i": 1, "size": 2, - "c": "c5acf03e788c6228", - "r": "32163c0ce6b28d4d", + "c": "4c9321292b55fe4d", + "r": "5f33343017a60ec5", "digit": true, "name": false, - "n": 19 + "n": 22 }, { "i": 1, "size": 3, - "c": "8f267bdbe912dc67", - "r": "1697925105c49d5e", + "c": "2426277988080a7d", + "r": "cb48f440843d9bff", "digit": true, "name": false, - "n": 20 + "n": 23 }, { "i": 2, "size": 2, - "c": "c9dd5262c3073063", - "r": "51b932b9fa3730ef", + "c": "fb2023f8f609db56", + "r": "4efe9b2c679592f8", "digit": true, "name": false, - "n": 11 + "n": 13 } ], "structured": null, + "signal_hashes": { + "captured_context|exact|": "3fc70f1278c0dadc98235d310ccafd6bcc75a11adc8a0cf4fae5238c8dbb0a21", + "captured_context|normalized|unicode_nfkc": "3fc70f1278c0dadc98235d310ccafd6bcc75a11adc8a0cf4fae5238c8dbb0a21", + "captured_context|normalized|casefold": "3fc70f1278c0dadc98235d310ccafd6bcc75a11adc8a0cf4fae5238c8dbb0a21", + "captured_context|normalized|collapse_whitespace": "3fc70f1278c0dadc98235d310ccafd6bcc75a11adc8a0cf4fae5238c8dbb0a21", + "captured_context|normalized|strip_punctuation": "b1316243c93e96a96f76597b7b4ee225549d6ef578eb8564e1900acebbff50cc", + "captured_context|normalized|unicode_nfkc,casefold": "3fc70f1278c0dadc98235d310ccafd6bcc75a11adc8a0cf4fae5238c8dbb0a21", + "captured_context|normalized|unicode_nfkc,collapse_whitespace": "3fc70f1278c0dadc98235d310ccafd6bcc75a11adc8a0cf4fae5238c8dbb0a21", + "captured_context|normalized|unicode_nfkc,strip_punctuation": "b1316243c93e96a96f76597b7b4ee225549d6ef578eb8564e1900acebbff50cc", + "captured_context|normalized|casefold,collapse_whitespace": "3fc70f1278c0dadc98235d310ccafd6bcc75a11adc8a0cf4fae5238c8dbb0a21", + "captured_context|normalized|casefold,strip_punctuation": "b1316243c93e96a96f76597b7b4ee225549d6ef578eb8564e1900acebbff50cc", + "captured_context|normalized|collapse_whitespace,strip_punctuation": "b1316243c93e96a96f76597b7b4ee225549d6ef578eb8564e1900acebbff50cc", + "captured_context|normalized|unicode_nfkc,casefold,collapse_whitespace": "3fc70f1278c0dadc98235d310ccafd6bcc75a11adc8a0cf4fae5238c8dbb0a21", + "captured_context|normalized|unicode_nfkc,casefold,strip_punctuation": "b1316243c93e96a96f76597b7b4ee225549d6ef578eb8564e1900acebbff50cc", + "captured_context|normalized|unicode_nfkc,collapse_whitespace,strip_punctuation": "b1316243c93e96a96f76597b7b4ee225549d6ef578eb8564e1900acebbff50cc", + "captured_context|normalized|casefold,collapse_whitespace,strip_punctuation": "b1316243c93e96a96f76597b7b4ee225549d6ef578eb8564e1900acebbff50cc", + "captured_context|normalized|unicode_nfkc,casefold,collapse_whitespace,strip_punctuation": "b1316243c93e96a96f76597b7b4ee225549d6ef578eb8564e1900acebbff50cc" + }, "param_token_indices": {}, "rests_on_confusable_identifier": false }, - "identifier_crop": null, - "identifier_region": null, + "identifier_crop": "templates/identifiers/step_008.png", + "identifier_region": [ + 237, + 434, + 802, + 20 + ], + "form_identity": null, "landmarks": [ { "relation": "above", @@ -673,7 +973,12 @@ "text": null, "param": null, "secret": false, + "field_label": null, + "selection_commit_key": null, + "selection_region": null, "key": null, + "modifiers": [], + "drag_end_anchor": null, "scroll_dx": null, "scroll_dy": null, "expect": [ @@ -704,11 +1009,24 @@ "effects": [], "api_binding": null, "risk": "reversible", + "risk_explanation": "control label has no retained consequential-write signal", + "risk_review_required": false, "wait_until": null, "guard": null, + "read": null, + "read_gate": null, + "surface": null, + "opens": [], + "surface_origin": null, + "closes_surface": false, + "dialog": null, "timeout_s": 10.0, "identity_armed": true, - "identity_unarmed_reason": null + "identity_unarmed_reason": null, + "identifier_crop_missing_reason": null, + "hover": null, + "submit_binding": null, + "submit_binding_unbound_reason": null }, { "id": "step_009", @@ -718,18 +1036,36 @@ "text": null, "param": null, "secret": false, + "field_label": null, + "selection_commit_key": null, + "selection_region": null, "key": null, + "modifiers": [], + "drag_end_anchor": null, "scroll_dx": 0, "scroll_dy": 400, "expect": [], "effects": [], "api_binding": null, "risk": "reversible", + "risk_explanation": "action has no retained consequential commit signal", + "risk_review_required": false, "wait_until": null, "guard": null, + "read": null, + "read_gate": null, + "surface": null, + "opens": [], + "surface_origin": null, + "closes_surface": false, + "dialog": null, "timeout_s": 10.0, "identity_armed": null, - "identity_unarmed_reason": null + "identity_unarmed_reason": null, + "identifier_crop_missing_reason": null, + "hover": null, + "submit_binding": null, + "submit_binding_unbound_reason": null }, { "id": "step_010", @@ -739,18 +1075,36 @@ "text": null, "param": null, "secret": false, + "field_label": null, + "selection_commit_key": null, + "selection_region": null, "key": null, + "modifiers": [], + "drag_end_anchor": null, "scroll_dx": 0, "scroll_dy": 400, "expect": [], "effects": [], "api_binding": null, "risk": "reversible", + "risk_explanation": "action has no retained consequential commit signal", + "risk_review_required": false, "wait_until": null, "guard": null, + "read": null, + "read_gate": null, + "surface": null, + "opens": [], + "surface_origin": null, + "closes_surface": false, + "dialog": null, "timeout_s": 10.0, "identity_armed": null, - "identity_unarmed_reason": null + "identity_unarmed_reason": null, + "identifier_crop_missing_reason": null, + "hover": null, + "submit_binding": null, + "submit_binding_unbound_reason": null }, { "id": "step_011", @@ -760,18 +1114,36 @@ "text": null, "param": null, "secret": false, + "field_label": null, + "selection_commit_key": null, + "selection_region": null, "key": null, + "modifiers": [], + "drag_end_anchor": null, "scroll_dx": 0, "scroll_dy": 400, "expect": [], "effects": [], "api_binding": null, "risk": "reversible", + "risk_explanation": "action has no retained consequential commit signal", + "risk_review_required": false, "wait_until": null, "guard": null, + "read": null, + "read_gate": null, + "surface": null, + "opens": [], + "surface_origin": null, + "closes_surface": false, + "dialog": null, "timeout_s": 10.0, "identity_armed": null, - "identity_unarmed_reason": null + "identity_unarmed_reason": null, + "identifier_crop_missing_reason": null, + "hover": null, + "submit_binding": null, + "submit_binding_unbound_reason": null }, { "id": "step_012", @@ -781,22 +1153,40 @@ "text": null, "param": null, "secret": false, + "field_label": null, + "selection_commit_key": null, + "selection_region": null, "key": null, + "modifiers": [], + "drag_end_anchor": null, "scroll_dx": 0, "scroll_dy": 400, "expect": [], "effects": [], "api_binding": null, "risk": "reversible", + "risk_explanation": "action has no retained consequential commit signal", + "risk_review_required": false, "wait_until": null, "guard": null, + "read": null, + "read_gate": null, + "surface": null, + "opens": [], + "surface_origin": null, + "closes_surface": false, + "dialog": null, "timeout_s": 10.0, "identity_armed": null, - "identity_unarmed_reason": null + "identity_unarmed_reason": null, + "identifier_crop_missing_reason": null, + "hover": null, + "submit_binding": null, + "submit_binding_unbound_reason": null }, { "id": "step_013", - "intent": "click at (815, 369)", + "intent": "click recorded visual target", "action": "click", "anchor": { "template": "templates/step_013.png", @@ -817,6 +1207,7 @@ "identity_template": null, "identifier_crop": null, "identifier_region": null, + "form_identity": null, "landmarks": [ { "relation": "right_of", @@ -838,7 +1229,12 @@ "text": null, "param": null, "secret": false, + "field_label": null, + "selection_commit_key": null, + "selection_region": null, "key": null, + "modifiers": [], + "drag_end_anchor": null, "scroll_dx": null, "scroll_dy": null, "expect": [ @@ -869,15 +1265,28 @@ "effects": [], "api_binding": null, "risk": "reversible", + "risk_explanation": "unlabelled pointer control may be navigation, focus, or a state change", + "risk_review_required": true, "wait_until": null, "guard": null, + "read": null, + "read_gate": null, + "surface": null, + "opens": [], + "surface_origin": null, + "closes_surface": false, + "dialog": null, "timeout_s": 10.0, "identity_armed": false, - "identity_unarmed_reason": "row text outside the target's label is too generic (< 12 squashed chars after volatile-line filtering)" + "identity_unarmed_reason": "row text outside the target's label is too generic (< 12 squashed chars after volatile-line filtering)", + "identifier_crop_missing_reason": "identity is not armed on this step (see identity_unarmed_reason) — there is no identity evidence to crop", + "hover": null, + "submit_binding": null, + "submit_binding_unbound_reason": null }, { "id": "step_014", - "intent": "click '+Add [reversible] flow.type_text(params["note"]) - # step_017: click 'Save as new messag' [irreversible] + # step_017: click 'Save as new message' [irreversible] # landmark: 'Smith, Billy' (above, 191px) # landmark: 'Active' (left_of, 234px) flow.click( template="templates/step_017.png", click_point=(489, 452), region=(409, 420, 160, 64), - ocr_text="Save as new messag", + ocr_text="Save as new message", ) # expect region_stable region=(0, 0, 1280, 800) phash='c0d91b9db162c6a7' (tol 16) # expect text_present text='CalendarFinderFlowRecallsMessagesPatientFeesModulesInventoryProceduresAdminReportsMiscellaneousPopups' diff --git a/packages/flow/openadapt_flow/agent/cli.py b/packages/flow/openadapt_flow/agent/cli.py index caf1d279a4..66e9f6c048 100644 --- a/packages/flow/openadapt_flow/agent/cli.py +++ b/packages/flow/openadapt_flow/agent/cli.py @@ -766,10 +766,14 @@ def _legacy_runs_dir(args: argparse.Namespace) -> Optional[Path]: def _cmd_serve(args: argparse.Namespace) -> int: from openadapt_flow.agent.bridge import AgentBridge from openadapt_flow.agent.flow_service import open_attended_service - from openadapt_flow.agent.mcp import serve from openadapt_flow.agent.runner import default_flow_cli from openadapt_flow.agent.tutorial import TutorialError + def serve(*bridges: Any, **options: Any) -> None: + from openadapt_flow.agent.mcp import serve as serve_mcp + + serve_mcp(*bridges, **options) + if args.guard: return _serve_guard(args, serve) if args.verify: diff --git a/packages/flow/openadapt_flow/authorization_summary.py b/packages/flow/openadapt_flow/authorization_summary.py index 6ce5648b25..cb73791b38 100644 --- a/packages/flow/openadapt_flow/authorization_summary.py +++ b/packages/flow/openadapt_flow/authorization_summary.py @@ -53,13 +53,27 @@ def __init__(self, code: str, detail: str = "") -> None: def bundle_digest(bundle_dir: Path) -> str: """Verify content identity against the original seal and current asset bytes.""" from openadapt_flow.bundle_validation import ( + BundleIntegrityError, compute_file_hashes, content_identity_digest, ) from openadapt_flow.ir import Workflow - workflow = Workflow.load(bundle_dir) - return content_identity_digest(workflow, compute_file_hashes(workflow, bundle_dir)) + try: + workflow = Workflow.load(bundle_dir) + except BundleIntegrityError as exc: + raise BundleIntegrityError( + "authorization summary requires a persisted bundle integrity seal" + ) from exc + if workflow._loaded_seal is None or not workflow._loaded_seal.content_digest: + raise BundleIntegrityError( + "authorization summary requires a persisted bundle integrity seal" + ) + assets = workflow.decrypted_templates() if workflow.encrypted else None + return content_identity_digest( + workflow, + compute_file_hashes(workflow, bundle_dir, assets=assets), + ) def matching_bundle(digest: str, candidates: Sequence[Path]) -> Optional[Path]: @@ -115,9 +129,7 @@ def outline(bundle_dir: Path) -> ProgramOutline: """Sections (compiled ones if present, else one per field filled), writes, stops.""" from openadapt_flow.ir import Workflow - wf = Workflow.model_validate_json( - (Path(bundle_dir) / "workflow.json").read_text(encoding="utf-8") - ) + wf = Workflow.load(bundle_dir) steps = list(wf.steps) env = getattr(getattr(wf, "qualification", None), "environment", None) app = str(getattr(env, "application", "") or "") or wf.name.split("-", 1)[0] @@ -187,16 +199,21 @@ def _covered_by_digest(bundle_dir: Path, report: Path) -> bool: """True only when the report's exact bytes are a hashed file of the bundle.""" import hashlib - from openadapt_flow.bundle_validation import compute_file_hashes + from openadapt_flow.bundle_validation import ( + BundleIntegrityError, + compute_file_hashes, + ) from openadapt_flow.ir import Workflow try: rel = report.resolve().relative_to(bundle_dir.resolve()).as_posix() - wf = Workflow.model_validate_json( - (bundle_dir / "workflow.json").read_text("utf-8") + wf = Workflow.load(bundle_dir) + hashes = compute_file_hashes( + wf, + bundle_dir, + assets=wf.decrypted_templates() if wf.encrypted else None, ) - hashes = compute_file_hashes(wf, bundle_dir) - except (ValueError, OSError): + except (BundleIntegrityError, OSError, ValueError): return False return hashes.get(rel) == hashlib.sha256(report.read_bytes()).hexdigest() diff --git a/packages/flow/openadapt_flow/bundle_validation.py b/packages/flow/openadapt_flow/bundle_validation.py index 115f9ee8db..abb62405e9 100644 --- a/packages/flow/openadapt_flow/bundle_validation.py +++ b/packages/flow/openadapt_flow/bundle_validation.py @@ -18,9 +18,8 @@ a certified bundle -- the policy it was certified against, its certification status, and an optional expiry. * **Migration** (:func:`migrate_bundle_dict`): a v1 bundle (or one with no - ``schema_version``) migrates cleanly to v2 in memory on read. v2 is a strict - superset of v1, so migration only stamps the new version and back-fills the - manifest if it is absent -- an existing bundle keeps replaying byte-for-byte. + ``schema_version``) migrates cleanly to v2 in memory when a caller explicitly + opts into unsealed legacy migration. Ordinary loads require a persisted seal. * **Load-time structural validation** (:func:`validate_workflow`): reject a malformed or uncertifiable graph with clear, per-issue errors BEFORE the replayer touches it. Rules span graph well-formedness (entry exists, every @@ -42,8 +41,9 @@ import copy import hashlib import json +import weakref from dataclasses import dataclass -from pathlib import Path +from pathlib import Path, PurePosixPath from typing import TYPE_CHECKING, Any, Iterable, Iterator, Literal, Optional from pydantic import BaseModel, Field @@ -88,6 +88,25 @@ def _sha256_file(path: Path) -> str: return h.hexdigest() +def bundle_asset_path(bundle_dir: Path | str, rel: str) -> Path: + """Resolve a manifest asset only when it stays inside the bundle.""" + posix = PurePosixPath(rel) + if ( + not rel + or "\\" in rel + or posix.is_absolute() + or posix.as_posix() != rel + or any(part in {"", ".", ".."} for part in posix.parts) + ): + raise BundleIntegrityError(f"unsafe bundle asset path {rel!r}") + root = Path(bundle_dir).resolve() + candidate = root.joinpath(*posix.parts) + resolved = candidate.resolve() + if not resolved.is_relative_to(root) or candidate.is_symlink(): + raise BundleIntegrityError(f"bundle asset path escapes its directory: {rel!r}") + return candidate + + @dataclass(frozen=True) class _SealedCanonicalOmission: """One reviewed compatibility rule for a historical digest schema. @@ -469,6 +488,75 @@ class _LoadedSeal: file_hashes: tuple[tuple[str, str], ...] schema_version: int encrypted: bool + source_bundle_dir: Path + + +# Load authority is process-local and bound to the exact object. Pydantic +# private attributes are omitted from dumps but copied by model_copy, so the +# registry prevents a clone or model_validate(model_dump()) from inheriting it. +_verified_loads: dict[int, tuple[weakref.ReferenceType, object]] = {} + + +def has_verified_load_capability(workflow: "Workflow") -> bool: + entry = _verified_loads.get(id(workflow)) + return ( + entry is not None + and entry[0]() is workflow + and getattr(workflow, "_verification_capability", None) is entry[1] + ) + + +def _grant_verified_load_capability(workflow: "Workflow") -> None: + identity = id(workflow) + + def discard(reference: weakref.ReferenceType, *, identity: int = identity) -> None: + current = _verified_loads.get(identity) + if current is not None and current[0] is reference: + _verified_loads.pop(identity, None) + + reference = weakref.ref(workflow, discard) + token = object() + workflow._verification_capability = token + _verified_loads[identity] = (reference, token) + + +def transfer_verified_load_capability(source: "Workflow", target: "Workflow") -> None: + """Keep verified authority across an explicit in-memory model_copy only.""" + if has_verified_load_capability(source): + _grant_verified_load_capability(target) + + +def authorize_sanitized_derivative( + workflow: "Workflow", verified_source: "Workflow", source_bundle_dir: Path | str +) -> str: + """Authorize the sanitizer's verified-source → scrub → reseal transition.""" + source_manifest = verified_source.manifest + if ( + source_manifest is None + or not source_manifest.content_digest + or verified_source._loaded_seal is None + or not has_verified_load_capability(verified_source) + ): + raise BundleIntegrityError( + "sanitized bundle reseal requires a verified sealed source bundle" + ) + verify_loaded_source_integrity( + verified_source, + source_bundle_dir, + source_manifest, + decrypted_assets=( + verified_source.decrypted_templates() if verified_source.encrypted else None + ), + ) + if workflow.manifest is None: + raise BundleIntegrityError( + "sanitized bundle reseal requires the source manifest" + ) + workflow.manifest.provenance.sanitized_from_digest = source_manifest.content_digest + workflow._loaded_seal = None + workflow._unverified_load = False + _grant_verified_load_capability(workflow) + return source_manifest.content_digest def verify_original_content(raw: dict, stored: BundleManifest) -> bytes: @@ -481,7 +569,11 @@ def verify_original_content(raw: dict, stored: BundleManifest) -> bytes: def remember_original_content( - workflow: "Workflow", payload: bytes, stored: BundleManifest + workflow: "Workflow", + payload: bytes, + stored: BundleManifest, + *, + source_bundle_dir: Path | str, ) -> None: # The original payload is immutable; this second fingerprint detects later # mutation of the upgraded runtime object without reserializing the source. @@ -492,7 +584,9 @@ def remember_original_content( tuple(sorted(stored.file_hashes.items())), stored.schema_version, stored.encrypted, + Path(source_bundle_dir).resolve(), ) + _grant_verified_load_capability(workflow) def _verify_workflow_content(workflow: "Workflow", stored: BundleManifest) -> None: @@ -517,6 +611,46 @@ def _verify_workflow_content(workflow: "Workflow", stored: BundleManifest) -> No ) +def verify_loaded_source_integrity( + workflow: "Workflow", + bundle_dir: Path | str, + stored: BundleManifest, + *, + decrypted_assets: Optional[dict[str, bytes]] = None, +) -> None: + """Verify the immutable source seal before a loaded workflow is resealed. + + Unlike :func:`verify_integrity`, this intentionally does not compare the + current runtime model fingerprint. A caller may have deliberately edited a + previously verified workflow in memory and then save that as new content. + The original payload, manifest identity, and original assets must still + verify first, so an unchecked or tampered load cannot be laundered by that + new seal. + """ + loaded = workflow._loaded_seal + if loaded is None or not loaded.content_digest: + raise BundleIntegrityError( + "cannot reseal a loaded bundle without a persisted integrity seal" + ) + if ( + stored.content_digest != loaded.content_digest + or tuple(sorted(stored.file_hashes.items())) != loaded.file_hashes + or stored.schema_version != loaded.schema_version + or stored.encrypted != loaded.encrypted + or _sha256_bytes(loaded.original_payload) != stored.content_digest + ): + raise BundleIntegrityError( + "bundle original manifest integrity changed after loading" + ) + if not has_verified_load_capability(workflow): + raise BundleIntegrityError( + "loaded bundle verification capability is missing or was not preserved" + ) + _verify_asset_hashes( + loaded.source_bundle_dir, stored, decrypted_assets=decrypted_assets + ) + + def _referenced_asset_paths(workflow: "Workflow") -> set[str]: """Every bundle-relative image path the IR references (templates, identifier crops, postcondition template crops), across the linear steps AND every @@ -610,11 +744,11 @@ def compute_file_hashes( paths.add(f.relative_to(bundle).as_posix()) for rel in _referenced_asset_paths(workflow): - candidate = bundle / rel + candidate = bundle_asset_path(bundle, rel) if candidate.is_file(): - paths.add(Path(rel).as_posix()) + paths.add(rel) - return {rel: _sha256_file(bundle / rel) for rel in sorted(paths)} + return {rel: _sha256_file(bundle_asset_path(bundle, rel)) for rel in sorted(paths)} def _workflow_content(workflow: "Workflow") -> dict: @@ -655,6 +789,10 @@ def content_identity_digest(workflow: "Workflow", file_hashes: dict[str, str]) - or separately call verify_integrity. New serialization/build_manifest always uses compute_content_digest instead, so a new seal covers its new format. """ + if workflow._unverified_load: + raise BundleIntegrityError( + "an unchecked bundle load has no verified content identity" + ) loaded = workflow._loaded_seal if loaded is None: return compute_content_digest(workflow, file_hashes) @@ -750,6 +888,9 @@ def build_manifest( governed_authorization_template=( prior_prov.governed_authorization_template if prior_prov else None ), + sanitized_from_digest=( + prior_prov.sanitized_from_digest if prior_prov else None + ), ) return BundleManifest( content_digest=digest, @@ -819,6 +960,38 @@ class BundleIntegrityError(Exception): written.""" +def _verify_asset_hashes( + bundle_dir: Path | str, + stored: BundleManifest, + *, + decrypted_assets: Optional[dict[str, bytes]] = None, +) -> None: + bundle = Path(bundle_dir) + for rel, expected in stored.file_hashes.items(): + path = bundle_asset_path(bundle, rel) + if decrypted_assets is not None: + data = decrypted_assets.get(rel) + if data is None: + raise BundleIntegrityError( + f"manifest lists sealed asset {rel!r} but its authenticated " + "plaintext is unavailable" + ) + if _sha256_bytes(data) != expected: + raise BundleIntegrityError( + f"sealed asset {rel!r} plaintext hash mismatch " + "(tampered or corrupted)" + ) + continue + if not path.is_file(): + raise BundleIntegrityError( + f"manifest lists asset {rel!r} but it is missing from the bundle" + ) + if _sha256_file(path) != expected: + raise BundleIntegrityError( + f"asset {rel!r} hash mismatch (tampered or corrupted)" + ) + + def verify_integrity( workflow: "Workflow", bundle_dir: Path | str, @@ -843,8 +1016,8 @@ def verify_integrity( (re-saving reseals them). Only meaningful for a bundle that carries a persisted manifest with a - non-empty digest; legacy (pre-v2) bundles have no sealed digest to compare - against, and the caller skips this check for them.""" + non-empty digest. An explicit legacy migration opt-in creates a new seal + before the caller can save it.""" if not stored.content_digest and workflow._loaded_seal is None: return # 1. Recompute the digest over the loaded workflow content using the SEALED @@ -853,30 +1026,7 @@ def verify_integrity( # the sealed asset set) breaks this. _verify_workflow_content(workflow, stored) # 2. Each sealed asset must still hash to its recorded value. - bundle = Path(bundle_dir) - for rel, expected in stored.file_hashes.items(): - if decrypted_assets is not None: - data = decrypted_assets.get(rel) - if data is None: - raise BundleIntegrityError( - f"manifest lists sealed asset {rel!r} but its authenticated " - "plaintext is unavailable" - ) - if _sha256_bytes(data) != expected: - raise BundleIntegrityError( - f"sealed asset {rel!r} plaintext hash mismatch " - "(tampered or corrupted)" - ) - continue - path = bundle / rel - if not path.is_file(): - raise BundleIntegrityError( - f"manifest lists asset {rel!r} but it is missing from the bundle" - ) - if _sha256_file(path) != expected: - raise BundleIntegrityError( - f"asset {rel!r} hash mismatch (tampered or corrupted)" - ) + _verify_asset_hashes(bundle_dir, stored, decrypted_assets=decrypted_assets) # --------------------------------------------------------------------------- diff --git a/packages/flow/openadapt_flow/compiler/loop_authoring.py b/packages/flow/openadapt_flow/compiler/loop_authoring.py index 67f037b3f3..b4aa47e373 100644 --- a/packages/flow/openadapt_flow/compiler/loop_authoring.py +++ b/packages/flow/openadapt_flow/compiler/loop_authoring.py @@ -263,6 +263,8 @@ def author_data_driven_loop( }, ) + # Keep the verified source manifest until Workflow.save verifies the + # original bundle, then reseals the authored derivative. return body.model_copy( deep=True, update={ @@ -279,8 +281,5 @@ def author_data_driven_loop( ), ), }, - # Drop the source bundle's sealed manifest -- ``save`` reseals a - # fresh integrity/provenance manifest over the looped bundle. - "manifest": None, }, ) diff --git a/packages/flow/openadapt_flow/ir.py b/packages/flow/openadapt_flow/ir.py index 151d6fd0bf..811551461a 100644 --- a/packages/flow/openadapt_flow/ir.py +++ b/packages/flow/openadapt_flow/ir.py @@ -3009,6 +3009,11 @@ class BundleProvenance(BaseModel): description="Exact approved sanitized recording archive used for compilation", pattern="^[a-f0-9]{64}$", ) + sanitized_from_digest: Optional[str] = Field( + default=None, + description="Verified source bundle content digest for a sanitized derivative", + pattern="^[a-f0-9]{64}$", + ) compiler_config_sha256: Optional[str] = Field( default=None, description="Canonical digest of the compiler options used for this bundle", @@ -3097,7 +3102,8 @@ class BundleManifest(BaseModel): AES-256-GCM -- both ``workflow.json`` and every ``templates/*.png`` crop. The ``file_hashes`` are always digests over the PLAINTEXT asset (sealed BEFORE encryption), so integrity re-verifies against the decrypted crops. - Additive: a v1 bundle carries no manifest and one is computed on read. + Additive: a v1 bundle carries no manifest; computing a replacement seal + requires the caller to opt into legacy migration explicitly. """ schema_version: int = SCHEMA_VERSION @@ -3211,7 +3217,10 @@ def _decrypt_template_assets(bundle: Path, key: Optional[str]) -> dict[str, byte def _verify_sealed_template_integrity( - workflow: "Workflow", stored: "BundleManifest", decrypted: dict[str, bytes] + workflow: "Workflow", + bundle: Path, + stored: "BundleManifest", + decrypted: dict[str, bytes], ) -> None: """Integrity check for an ENCRYPTED bundle, run against the DECRYPTED crops in memory (the on-disk assets are ciphertext, so the disk-based @@ -3228,6 +3237,7 @@ def _verify_sealed_template_integrity( return _bv._verify_workflow_content(workflow, stored) for rel, expected in stored.file_hashes.items(): + _bv.bundle_asset_path(bundle, rel) data = decrypted.get(rel) if data is None: raise _bv.BundleIntegrityError( @@ -3380,8 +3390,8 @@ class Workflow(BaseModel): # Sealed on ``save`` (per-asset hashes, a whole-bundle content digest, the # compiler version, and -- for a certified bundle -- the certifying policy + # status + optional expiry) and re-verified on ``load``. Additive and - # backward-compatible: a v1 bundle carries no manifest, so one is computed - # on read (see ``openadapt_flow.bundle_validation``). Excluded from the + # backward-compatible: a v1 bundle carries no manifest, and callers must + # explicitly opt into the migration path to compute one on read. Excluded from the # content digest itself (the digest lives INSIDE it). manifest: Optional["BundleManifest"] = None @@ -3394,8 +3404,24 @@ class Workflow(BaseModel): # from ``model_dump`` / the content digest (a private attribute). The # resolver consumes a decrypted crop via :meth:`decrypted_template`. _decrypted_templates: dict[str, bytes] = PrivateAttr(default_factory=dict) + # Authorized in-memory edits to existing template assets. The verified + # source crop cache above remains unchanged until the destination is saved. + _pending_template_replacements: dict[str, bytes] = PrivateAttr(default_factory=dict) # Immutable source seal + upgraded-model fingerprint; never serialized. _loaded_seal: Any = PrivateAttr(default=None) + # Process-local authority granted only after integrity verification or by + # the sanitizer's verified-source transformation. Never serialized. + _verification_capability: Any = PrivateAttr(default=None) + # Explicitly unchecked loads remain un-saveable even if a caller attaches a + # fresh, empty manifest by hand. + _unverified_load: bool = PrivateAttr(default=False) + + def model_copy(self, *, update: Any = None, deep: bool = False) -> "Workflow": + clone = super().model_copy(update=update, deep=deep) + from openadapt_flow.bundle_validation import transfer_verified_load_capability + + transfer_verified_load_capability(self, clone) + return clone @model_validator(mode="after") def _undo_ids_are_unique(self) -> "Workflow": @@ -3463,6 +3489,91 @@ def decrypted_templates(self) -> dict[str, bytes]: -> PNG bytes); empty for a plaintext bundle.""" return dict(self._decrypted_templates) + def stage_verified_template_replacement(self, rel: str, data: bytes) -> None: + """Stage bytes for an existing template on a verified loaded bundle. + + The original bundle and its in-memory decrypted asset cache are never + changed here. ``save`` verifies the source again before applying the + staged bytes to the destination and sealing the new content. + """ + from openadapt_flow import bundle_validation as _bv + + if not isinstance(data, bytes): + raise _bv.BundleIntegrityError("template replacement data must be bytes") + if ( + self._unverified_load + or self.manifest is None + or not self.manifest.content_digest + or self._loaded_seal is None + or not _bv.has_verified_load_capability(self) + ): + raise _bv.BundleIntegrityError( + "template replacement requires a verified loaded bundle" + ) + if not isinstance(rel, str) or not rel.startswith("templates/"): + raise _bv.BundleIntegrityError( + "template replacement must name a manifest-listed template asset" + ) + source = self._loaded_seal.source_bundle_dir + source_asset = _bv.bundle_asset_path(source, rel) + source_has_asset = ( + rel in self._decrypted_templates + if self._loaded_seal.encrypted + else source_asset.is_file() + ) + if rel not in self.manifest.file_hashes or not source_has_asset: + raise _bv.BundleIntegrityError( + "template replacement must name an existing manifest-listed asset" + ) + _bv.verify_loaded_source_integrity( + self, + source, + self.manifest, + decrypted_assets=( + self._decrypted_templates if self._loaded_seal.encrypted else None + ), + ) + self._pending_template_replacements = { + **self._pending_template_replacements, + rel: bytes(data), + } + + def _checked_template_replacements(self) -> dict[str, bytes]: + """Return staged replacements only while their loaded source is valid.""" + from openadapt_flow import bundle_validation as _bv + + replacements = dict(self._pending_template_replacements) + if not replacements: + return {} + if ( + self._unverified_load + or self.manifest is None + or not self.manifest.content_digest + or self._loaded_seal is None + or not _bv.has_verified_load_capability(self) + ): + raise _bv.BundleIntegrityError( + "staged template replacements require a verified loaded bundle" + ) + for rel in replacements: + if not isinstance(rel, str) or not rel.startswith("templates/"): + raise _bv.BundleIntegrityError( + "template replacement must name a manifest-listed template asset" + ) + source_asset = _bv.bundle_asset_path( + self._loaded_seal.source_bundle_dir, rel + ) + source_has_asset = ( + rel in self._decrypted_templates + if self._loaded_seal.encrypted + else source_asset.is_file() + ) + if rel not in self.manifest.file_hashes or not source_has_asset: + raise _bv.BundleIntegrityError( + "template replacement must name an existing manifest-listed asset" + ) + return replacements + def _sync_disk_templates(self, bundle: Path) -> None: """Materialize any in-memory plaintext crops to disk (removing a stale ``.enc`` sibling) BEFORE the manifest is (re)sealed, so a re-save hashes @@ -3470,10 +3581,12 @@ def _sync_disk_templates(self, bundle: Path) -> None: encrypted save removed from disk. A no-op for a freshly-compiled bundle (no in-memory crops; the compiler already wrote the plaintext PNGs).""" for rel, data in self._decrypted_templates.items(): - path = bundle / rel + from openadapt_flow.bundle_validation import bundle_asset_path + + path = bundle_asset_path(bundle, rel) path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(data) - enc = bundle / f"{rel}.enc" + enc = bundle_asset_path(bundle, f"{rel}.enc") if enc.exists(): enc.unlink() @@ -3521,6 +3634,24 @@ def save( from openadapt_flow import bundle_validation as _bv _bv.bundle_schema_version({"schema_version": self.schema_version}) + template_replacements = self._checked_template_replacements() + if template_replacements and not seal_manifest: + raise _bv.BundleIntegrityError( + "template replacements require a newly sealed manifest" + ) + if self._unverified_load: + raise _bv.BundleIntegrityError( + "an unchecked bundle load cannot be saved or resealed" + ) + if ( + self.manifest is not None + and self.manifest.content_digest + and not _bv.has_verified_load_capability(self) + ): + raise _bv.BundleIntegrityError( + "a manifest-bearing workflow needs its non-serializable " + "verified-load capability before it can be saved" + ) if self._loaded_seal is not None and not seal_manifest: raise _bv.BundleIntegrityError( "a loaded sealed bundle requires explicit resealing before serialization" @@ -3529,17 +3660,61 @@ def save( bundle = Path(bundle_dir) from openadapt_flow import sealed_set as _sealed + source_assets: dict[str, bytes] = {} + if self._loaded_seal is not None: + if self.manifest is None: + raise _bv.BundleIntegrityError( + "loaded bundle lost its original integrity manifest" + ) + source = self._loaded_seal.source_bundle_dir + _bv.verify_loaded_source_integrity( + self, + source, + self.manifest, + decrypted_assets=( + self._decrypted_templates if self.encrypted else None + ), + ) + if source != bundle.resolve() and not self._loaded_seal.encrypted: + for rel, expected in self._loaded_seal.file_hashes: + source_asset = _bv.bundle_asset_path(source, rel) + if ( + not source_asset.is_file() + or _bv._sha256_file(source_asset) != expected + ): + raise _bv.BundleIntegrityError( + f"verified source asset {rel!r} changed before save-as" + ) + source_assets[rel] = source_asset.read_bytes() + if _sealed.active_writer(bundle) is not None or _sealed.is_sealed(bundle): # A sealed bundle stays sealed whatever ``encrypt`` says: many # commands re-save a bundle with a bare ``save(bundle)``, and that # must never turn a sealed bundle back into plaintext. - return self._save_sealed_set(bundle, key, seal_manifest=seal_manifest) + path = self._save_sealed_set( + bundle, + key, + seal_manifest=seal_manifest, + source_assets=source_assets, + template_replacements=template_replacements, + ) + self._pending_template_replacements = {} + return path bundle.mkdir(parents=True, exist_ok=True) (bundle / "templates").mkdir(exist_ok=True) + for rel, data in source_assets.items(): + target_asset = _bv.bundle_asset_path(bundle, rel) + if not target_asset.exists(): + target_asset.parent.mkdir(parents=True, exist_ok=True) + target_asset.write_bytes(data) # Restore any in-memory crops (from a prior encrypted load/save) to disk # as plaintext BEFORE the manifest hashes them; harmless no-op for a # freshly-compiled bundle whose crops are already on disk. self._sync_disk_templates(bundle) + for rel, data in template_replacements.items(): + target_asset = _bv.bundle_asset_path(bundle, rel) + target_asset.parent.mkdir(parents=True, exist_ok=True) + target_asset.write_bytes(data) if self.schema_version < SCHEMA_VERSION: self.schema_version = SCHEMA_VERSION # Reflect the at-rest state in the workflow BEFORE the manifest is sealed, @@ -3552,11 +3727,7 @@ def save( self.manifest = _bv.build_manifest(self, bundle) serialized = self.model_dump_json(indent=2) resealed_payload = None - if ( - self._loaded_seal is not None - and seal_manifest - and self.manifest is not None - ): + if seal_manifest and self.manifest is not None: resealed_payload = _bv.verify_original_content( json.loads(serialized), self.manifest ) @@ -3591,11 +3762,23 @@ def save( self.manifest.model_dump_json(indent=2).encode("utf-8") ) if resealed_payload is not None and self.manifest is not None: - _bv.remember_original_content(self, resealed_payload, self.manifest) + _bv.remember_original_content( + self, + resealed_payload, + self.manifest, + source_bundle_dir=bundle, + ) + self._pending_template_replacements = {} return path def _save_sealed_set( - self, bundle: Path, key: Optional[str], *, seal_manifest: bool + self, + bundle: Path, + key: Optional[str], + *, + seal_manifest: bool, + source_assets: Optional[dict[str, bytes]] = None, + template_replacements: Optional[dict[str, bytes]] = None, ) -> Path: """Save into a sealed bundle (see :mod:`openadapt_flow.sealed_set`). @@ -3615,10 +3798,18 @@ def _save_sealed_set( ) writer = _sealed.SealedSetWriter(bundle, "bundle", passphrase, resume=True) assets = {rel: writer.read_bytes(rel) for rel in writer.files("templates/")} + for rel, data in (source_assets or {}).items(): + if assets.get(rel) != data: + writer.write(rel, data) + assets[rel] = data for rel, data in self._decrypted_templates.items(): if assets.get(rel) != data: writer.write(rel, data) assets[rel] = data + for rel, data in (template_replacements or {}).items(): + if assets.get(rel) != data: + writer.write(rel, data) + assets[rel] = data if self.schema_version < SCHEMA_VERSION: self.schema_version = SCHEMA_VERSION self.encrypted = True @@ -3628,11 +3819,7 @@ def _save_sealed_set( self.manifest = _bv.build_manifest(self, bundle, assets=assets) serialized = self.model_dump_json(indent=2).encode("utf-8") resealed_payload = None - if ( - self._loaded_seal is not None - and seal_manifest - and self.manifest is not None - ): + if seal_manifest and self.manifest is not None: from openadapt_flow import bundle_validation as _bv resealed_payload = _bv.verify_original_content( @@ -3647,7 +3834,12 @@ def _save_sealed_set( if owned: writer.finalize() if resealed_payload is not None and self.manifest is not None: - _bv.remember_original_content(self, resealed_payload, self.manifest) + _bv.remember_original_content( + self, + resealed_payload, + self.manifest, + source_bundle_dir=bundle, + ) return bundle / ("workflow.json" + _sealed.SEALED_SUFFIX) @classmethod @@ -3669,10 +3861,10 @@ def load( the *structural* category raises here; the effect-verification safety finding is surfaced by lint/certify, not the load path, so an existing uncertified-but-well-formed bundle still loads. - - ``verify_integrity`` (default True): if the bundle carries a sealed - manifest digest, recompute it and reject a tampered bundle. A legacy - (pre-v2) bundle has no sealed digest, so its manifest is computed - fresh and nothing is rejected. + - ``verify_integrity`` (default True): recompute a persisted sealed + digest and reject tampered content. Bundles without a persisted + digest are refused by default. ``verify_integrity=False`` permits + read-only inspection without granting verified-source capability. - ``key`` (default None): decryption passphrase for an ENCRYPTED bundle (one saved with ``save(encrypt=True)``, present on disk as ``workflow.json.enc`` + ``templates/*.png.enc``). Resolved from ``key`` @@ -3731,12 +3923,16 @@ def load( if verify_integrity else _bv.source_canonical_payload(raw, stored.file_hashes) ) + if verify_integrity and (stored is None or not stored.content_digest): + raise _bv.BundleIntegrityError("bundle has no persisted integrity seal") wf = cls.model_validate(_bv.migrate_bundle_dict(raw)) + wf._unverified_load = not verify_integrity if stored is not None: wf.manifest = stored - if original_payload is not None and stored is not None: - _bv.remember_original_content(wf, original_payload, stored) - + if original_payload is not None and stored is not None and verify_integrity: + _bv.remember_original_content( + wf, original_payload, stored, source_bundle_dir=bundle + ) if sealed_assets is not None: wf._decrypted_templates = sealed_assets elif bundle_encrypted: @@ -3750,7 +3946,7 @@ def load( # On-disk crops are ciphertext, so verify the sealed asset # digests against the decrypted plaintext held in memory. _verify_sealed_template_integrity( - wf, wf.manifest, wf._decrypted_templates + wf, bundle, wf.manifest, wf._decrypted_templates ) else: _bv.verify_integrity(wf, bundle, wf.manifest) diff --git a/packages/flow/openadapt_flow/qualification.py b/packages/flow/openadapt_flow/qualification.py index 9c660f640d..4920db33e4 100644 --- a/packages/flow/openadapt_flow/qualification.py +++ b/packages/flow/openadapt_flow/qualification.py @@ -1956,6 +1956,13 @@ def set_business_decision( raise QualificationError( f"the authored business decision makes the workflow invalid: {exc}" ) from exc + # This authoring API starts from a verified source, makes a deliberate + # in-memory derivative, and validates its complete serialized shape. Keep + # that source authority on the validated derivative; arbitrary callers of + # model_validate(model_dump()) remain unverified. + from openadapt_flow.bundle_validation import transfer_verified_load_capability + + transfer_verified_load_capability(candidate, validated) validation = validate_workflow(validated) if not validation.structural_ok: details = "; ".join( diff --git a/packages/flow/openadapt_flow/qualification_proposal.py b/packages/flow/openadapt_flow/qualification_proposal.py index f5fab34c79..d934fd22d8 100644 --- a/packages/flow/openadapt_flow/qualification_proposal.py +++ b/packages/flow/openadapt_flow/qualification_proposal.py @@ -433,17 +433,56 @@ def admit_local_dev( raise QualificationProposalError( "initialize qualification before local-dev admission" ) + if workflow._unverified_load: + raise QualificationProposalError( + "local-dev admission requires a verified persisted bundle seal" + ) + stored_manifest = workflow.manifest + if stored_manifest is None: + raise QualificationProposalError( + "local-dev admission requires a persisted bundle integrity seal" + ) + from openadapt_flow import sealed_set from openadapt_flow.bundle_validation import ( + BundleIntegrityError, compute_content_digest, compute_file_hashes, + verify_loaded_source_integrity, ) bundle = Path(bundle_dir) + try: + verify_loaded_source_integrity( + workflow, + bundle, + stored_manifest, + decrypted_assets=( + workflow.decrypted_templates() + if workflow.encrypted or sealed_set.is_sealed(bundle) + else None + ), + ) + except BundleIntegrityError as exc: + raise QualificationProposalError( + "local-dev admission requires a verified persisted bundle seal" + ) from exc file_hashes = ( - dict(workflow.manifest.file_hashes) - if workflow.manifest is not None and workflow.manifest.file_hashes - else compute_file_hashes(workflow, bundle) + dict(stored_manifest.file_hashes) + if stored_manifest.file_hashes + else compute_file_hashes( + workflow, + bundle, + assets=( + workflow.decrypted_templates() + if workflow.encrypted or sealed_set.is_sealed(bundle) + else None + ), + ) ) + # The accepted proposal has intentionally changed qualification fields in + # memory. Hash that proposed new content only after the original on-disk + # seal and its assets have been verified above; save_accepted_bundle then + # reseals the same content before it is used. digest = compute_content_digest(workflow, file_hashes) project = workflow.qualification identity_contract = hashlib.sha256( diff --git a/packages/flow/openadapt_flow/sanitized_artifact.py b/packages/flow/openadapt_flow/sanitized_artifact.py index a5114a6a1f..d1d7c49587 100644 --- a/packages/flow/openadapt_flow/sanitized_artifact.py +++ b/packages/flow/openadapt_flow/sanitized_artifact.py @@ -788,13 +788,18 @@ def _tree_rows(dest: Path) -> list[tuple[str, str]]: ] -def _reseal_sanitized_bundle(bundle: Path) -> None: +def _reseal_sanitized_bundle( + bundle: Path, verified_source: Any, source_bundle: Path +) -> None: """Rebuild generated bundle surfaces after a load-bearing transformation.""" from openadapt_flow.compiler.codegen import render_workflow_py from openadapt_flow.ir import Workflow workflow = Workflow.load(bundle, verify_integrity=False) + from openadapt_flow.bundle_validation import authorize_sanitized_derivative + + authorize_sanitized_derivative(workflow, verified_source, source_bundle) if workflow.manifest is not None: provenance = workflow.manifest.provenance provenance.policy_name = None @@ -869,6 +874,17 @@ def sanitize_artifact( artifacts.is_file(source / name) for name in ("workflow.json", "workflow.py", "manifest.json") ) + verified_source = None + if canonical_bundle: + from openadapt_flow.bundle_validation import BundleIntegrityError + from openadapt_flow.ir import Workflow + + try: + verified_source = Workflow.load(source) + except (BundleIntegrityError, OSError, ValueError, TypeError) as exc: + raise SanitizationError( + f"Cannot verify source bundle before sanitizing: {exc}" + ) from exc scrubber = privacy.get_scrubber() if scrubber is None: raise SanitizationError( @@ -992,7 +1008,7 @@ def sanitize_artifact( } ) if canonical_bundle and semantics == "not-preserved": - _reseal_sanitized_bundle(staging) + _reseal_sanitized_bundle(staging, verified_source, source) for entry in entries: derivative = staging / entry["path"] entry["derivative_sha256"] = _sha256_file(derivative) diff --git a/packages/flow/tests/test_agent_serve_imports.py b/packages/flow/tests/test_agent_serve_imports.py new file mode 100644 index 0000000000..cc4ef45477 --- /dev/null +++ b/packages/flow/tests/test_agent_serve_imports.py @@ -0,0 +1,133 @@ +"""Invalid serve modes need no optional transport; valid modes still delegate.""" + +from __future__ import annotations + +import builtins +import sys +from pathlib import Path +from types import ModuleType +from typing import Any + +import pytest + +from openadapt_flow.agent.cli import main + + +@pytest.mark.parametrize( + "argv", + [ + ["serve", "--teach-browser", "--url", "http://127.0.0.1:1"], + ["serve", "--teach-browser", "--bundles", "x"], + ["serve", "--tools-dir", "x"], + ], +) +def test_invalid_modes_never_attempt_optional_transport_import( + monkeypatch: pytest.MonkeyPatch, capsys: Any, argv: list[str] +) -> None: + original = builtins.__import__ + attempts: list[str] = [] + + def without_mcp(name: str, *args: Any, **kwargs: Any) -> Any: + if ( + name == "openadapt_flow.agent.mcp" + or name == "mcp" + or name.startswith("mcp.") + ): + attempts.append(name) + raise ModuleNotFoundError("synthetic optional transport unavailable") + return original(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", without_mcp) + assert main(argv) == 2 + assert "--teach-browser" in capsys.readouterr().err + assert attempts == [] + + +@pytest.fixture() +def browser_bridge(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> tuple: + events: list[str] = [] + instances: list[Any] = [] + + class BrowserBridge: + def __init__(self, *_args: Any, **_kwargs: Any) -> None: + self.programs = {} + self.tools_dir = tmp_path / "tools" + self.files_dir = tmp_path / "files" + instances.append(self) + events.append("bridge") + + def close(self) -> None: + events.append("closed") + + monkeypatch.setattr( + "openadapt_flow.agent.browser_teach.BrowserTeachBridge", BrowserBridge + ) + return events, instances + + +def _serve_args(tmp_path: Path) -> list[str]: + return [ + "serve", + "--teach-browser", + "--tools-dir", + str(tmp_path / "tools"), + "--files-dir", + str(tmp_path / "files"), + ] + + +def test_valid_mode_delegates_exact_bridges_to_transport( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, browser_bridge: tuple +) -> None: + events, instances = browser_bridge + calls: list[tuple] = [] + transport = ModuleType("openadapt_flow.agent.mcp") + + def serve(*bridges: Any, **options: Any) -> None: + events.append("transport") + calls.append((bridges, options)) + + transport.serve = serve + monkeypatch.setitem(sys.modules, transport.__name__, transport) + assert main(_serve_args(tmp_path)) == 0 + assert calls == [((None, None, None, instances[0]), {})] + assert events == ["bridge", "transport", "closed"] + + +def test_valid_mode_preserves_missing_transport_failure_and_closes_bridge( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, browser_bridge: tuple +) -> None: + events, _instances = browser_bridge + original = builtins.__import__ + + def without_mcp(name: str, *args: Any, **kwargs: Any) -> Any: + if name == "openadapt_flow.agent.mcp": + events.append("import") + raise ModuleNotFoundError("synthetic optional transport unavailable") + return original(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", without_mcp) + with pytest.raises( + ModuleNotFoundError, match="synthetic optional transport unavailable" + ): + main(_serve_args(tmp_path)) + assert events == ["bridge", "import", "closed"] + + +def test_valid_mode_preserves_transport_error_and_closes_bridge( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, browser_bridge: tuple +) -> None: + events, instances = browser_bridge + transport = ModuleType("openadapt_flow.agent.mcp") + + def serve(*bridges: Any, **options: Any) -> None: + assert bridges == (None, None, None, instances[0]) + assert options == {} + events.append("transport") + raise RuntimeError("synthetic transport failure") + + transport.serve = serve + monkeypatch.setitem(sys.modules, transport.__name__, transport) + with pytest.raises(RuntimeError, match="synthetic transport failure"): + main(_serve_args(tmp_path)) + assert events == ["bridge", "transport", "closed"] diff --git a/packages/flow/tests/test_authorization_sign.py b/packages/flow/tests/test_authorization_sign.py index 5f001bc3fb..057030d7bf 100644 --- a/packages/flow/tests/test_authorization_sign.py +++ b/packages/flow/tests/test_authorization_sign.py @@ -89,7 +89,10 @@ def _bundle(root: Path) -> Path: ), ] wf = Workflow(name="clinic-intake-referral", steps=steps) - (bundle / "workflow.json").write_text(wf.model_dump_json(), encoding="utf-8") + wf.save(bundle) + verified = Workflow.load(bundle) + assert verified._loaded_seal is not None + assert verified.manifest is not None and verified.manifest.content_digest (bundle.parent / "QUALIFICATION.md").write_text(QUALIFICATION, encoding="utf-8") return bundle diff --git a/packages/flow/tests/test_bundle_original_format.py b/packages/flow/tests/test_bundle_original_format.py index 59d868a919..6bf70d0854 100644 --- a/packages/flow/tests/test_bundle_original_format.py +++ b/packages/flow/tests/test_bundle_original_format.py @@ -9,6 +9,8 @@ from PIL import Image from openadapt_flow import bundle_validation as bundles +from openadapt_flow import crypto, sealed_set +from openadapt_flow.bundle_sealing import seal_bundle from openadapt_flow.ir import ActionKind, Anchor, Step, Workflow @@ -57,6 +59,25 @@ def test_upgrade_is_explicit_deterministic_and_does_not_mutate_source(monkeypatc bundles.migrate_bundle_dict(original) +@pytest.mark.parametrize( + "asset", ["../outside.png", "/outside.png", "templates\\x.png"] +) +def test_manifest_asset_paths_cannot_escape_bundle(tmp_path, asset): + with pytest.raises(bundles.BundleIntegrityError, match="unsafe bundle asset path"): + bundles.bundle_asset_path(tmp_path / "bundle", asset) + + +def test_manifest_asset_paths_reject_symlink_escape(tmp_path): + bundle = tmp_path / "bundle" + bundle.mkdir() + outside = tmp_path / "outside" + outside.mkdir() + (bundle / "templates").symlink_to(outside, target_is_directory=True) + + with pytest.raises(bundles.BundleIntegrityError, match="escapes its directory"): + bundles.bundle_asset_path(bundle, "templates/crop.png") + + def test_original_seal_is_checked_before_current_model_construction( tmp_path, monkeypatch ): @@ -190,6 +211,113 @@ def test_loaded_asset_tamper_refuses(tmp_path): bundles.verify_integrity(loaded, bundle, loaded.manifest) +def _bundle_files(bundle): + return { + path.relative_to(bundle): path.read_bytes() + for path in bundle.rglob("*") + if path.is_file() + } + + +def test_verified_template_replacement_saves_plaintext_derivative(tmp_path): + source = saved_bundle(tmp_path) + original_asset = (source / "templates/button.png").read_bytes() + replacement = b"replacement crop bytes" + loaded = Workflow.load(source) + derivative = loaded.model_copy() + derivative.stage_verified_template_replacement("templates/button.png", replacement) + + assert (source / "templates/button.png").read_bytes() == original_asset + assert "_pending_template_replacements" not in derivative.model_dump() + derivative.save(tmp_path / "derivative") + loaded.save(tmp_path / "unchanged") + + assert (source / "templates/button.png").read_bytes() == original_asset + assert (tmp_path / "derivative/templates/button.png").read_bytes() == replacement + assert (tmp_path / "unchanged/templates/button.png").read_bytes() == original_asset + assert Workflow.load(tmp_path / "derivative").manifest.content_digest + + +def test_verified_template_replacement_saves_encrypted_derivative( + tmp_path, monkeypatch +): + monkeypatch.setenv(crypto.ENV_KEY, "correct horse battery staple") + source = saved_bundle(tmp_path) + encrypted_source = tmp_path / "encrypted-source" + seal_bundle(source, encrypted_source) + source_snapshot = _bundle_files(encrypted_source) + replacement = b"encrypted replacement crop bytes" + loaded = Workflow.load(encrypted_source) + loaded.stage_verified_template_replacement("templates/button.png", replacement) + + destination = tmp_path / "encrypted-derivative" + with sealed_set.writing(destination, "bundle", "correct horse battery staple"): + loaded.save(destination, key="correct horse battery staple") + + assert _bundle_files(encrypted_source) == source_snapshot + assert not (destination / "templates/button.png").exists() + sealed_set.open_set(destination, "correct horse battery staple").verify_all() + reopened = Workflow.load(destination, key="correct horse battery staple") + assert reopened.decrypted_template("templates/button.png") == replacement + assert reopened.manifest.content_digest + + +def test_staged_template_replacement_refuses_changed_source(tmp_path): + source = saved_bundle(tmp_path) + loaded = Workflow.load(source) + loaded.stage_verified_template_replacement("templates/button.png", b"replacement") + (source / "templates/button.png").write_bytes(b"tampered after staging") + + destination = tmp_path / "must-not-be-created" + with pytest.raises(bundles.BundleIntegrityError): + loaded.save(destination) + assert not destination.exists() + + +@pytest.mark.parametrize("data", [7, "replacement", bytearray(b"replacement")]) +def test_template_replacement_requires_bytes(tmp_path, data): + source = saved_bundle(tmp_path) + loaded = Workflow.load(source) + with pytest.raises(bundles.BundleIntegrityError, match="data must be bytes"): + loaded.stage_verified_template_replacement("templates/button.png", data) + + +def test_template_replacement_refuses_save_without_new_seal(tmp_path): + source = saved_bundle(tmp_path) + loaded = Workflow.load(source) + loaded.stage_verified_template_replacement("templates/button.png", b"replacement") + destination = tmp_path / "must-not-be-created" + + with pytest.raises( + bundles.BundleIntegrityError, match="require a newly sealed manifest" + ): + loaded.save(destination, seal_manifest=False) + assert not destination.exists() + + +@pytest.mark.parametrize( + "rel", ["workflow.json", "templates/missing.png", "templates/../workflow.json"] +) +def test_template_replacement_requires_existing_safe_template(tmp_path, rel): + source = saved_bundle(tmp_path) + loaded = Workflow.load(source) + with pytest.raises(bundles.BundleIntegrityError): + loaded.stage_verified_template_replacement(rel, b"replacement") + + +def test_template_replacement_refuses_new_and_unchecked_workflows(tmp_path): + source = saved_bundle(tmp_path) + with pytest.raises(bundles.BundleIntegrityError): + Workflow(name="new", steps=[]).stage_verified_template_replacement( + "templates/button.png", b"replacement" + ) + unchecked = Workflow.load(source, verify_integrity=False) + with pytest.raises(bundles.BundleIntegrityError): + unchecked.stage_verified_template_replacement( + "templates/button.png", b"replacement" + ) + + def test_loaded_serialization_requires_explicit_reseal(tmp_path): bundle = saved_bundle(tmp_path) loaded = Workflow.load(bundle) @@ -229,10 +357,107 @@ def test_unverified_load_cannot_launder_a_broken_original_seal(tmp_path): loaded = Workflow.load(bundle, verify_integrity=False) with pytest.raises(bundles.BundleIntegrityError): bundles.verify_integrity(loaded, bundle, loaded.manifest) + with pytest.raises(bundles.BundleIntegrityError, match="unchecked bundle load"): + loaded.save(bundle) with pytest.raises(bundles.BundleIntegrityError): bundles.content_identity_digest(loaded, loaded.manifest.file_hashes) +@pytest.mark.parametrize("attack", ["workflow", "asset"]) +@pytest.mark.parametrize("copy_loaded", [False, True]) +def test_unverified_load_cannot_reseal_tampered_source(tmp_path, attack, copy_loaded): + bundle = saved_bundle(tmp_path) + if attack == "workflow": + raw = json.loads((bundle / "workflow.json").read_bytes()) + raw["name"] = "tampered before unchecked load" + (bundle / "workflow.json").write_text(json.dumps(raw)) + else: + (bundle / "templates/button.png").write_bytes(b"tampered asset") + before = { + path.relative_to(bundle): path.read_bytes() + for path in bundle.rglob("*") + if path.is_file() + } + + loaded = Workflow.load(bundle, verify_integrity=False) + candidate = loaded.model_copy(deep=True) if copy_loaded else loaded + with pytest.raises(bundles.BundleIntegrityError): + candidate.save(bundle) + + after = { + path.relative_to(bundle): path.read_bytes() + for path in bundle.rglob("*") + if path.is_file() + } + assert after == before + + +def test_authorization_summary_refuses_manifestless_bundle(tmp_path): + bundle = saved_bundle(tmp_path) + raw = json.loads((bundle / "workflow.json").read_bytes()) + raw.pop("manifest") + (bundle / "workflow.json").write_text(json.dumps(raw)) + (bundle / "manifest.json").unlink() + + from openadapt_flow.authorization_summary import bundle_digest + + with pytest.raises( + bundles.BundleIntegrityError, match="persisted bundle integrity seal" + ): + bundle_digest(bundle) + + +def test_missing_seal_cannot_be_migrated_by_loading_and_resaving(tmp_path): + bundle = saved_bundle(tmp_path) + raw = json.loads((bundle / "workflow.json").read_bytes()) + raw.pop("manifest") + (bundle / "workflow.json").write_text(json.dumps(raw)) + (bundle / "manifest.json").unlink() + + with pytest.raises( + bundles.BundleIntegrityError, match="no persisted integrity seal" + ): + Workflow.load(bundle) + + +def test_model_validate_dump_does_not_recover_verified_load_capability(tmp_path): + bundle = saved_bundle(tmp_path) + loaded = Workflow.load(bundle) + clone = Workflow.model_validate(loaded.model_dump()) + + with pytest.raises(bundles.BundleIntegrityError, match="non-serializable"): + clone.save(bundle) + + +def test_authorization_display_paths_verify_bundle_before_reading(tmp_path): + bundle = saved_bundle(tmp_path) + raw = json.loads((bundle / "workflow.json").read_bytes()) + raw["name"] = "tampered display input" + (bundle / "workflow.json").write_text(json.dumps(raw)) + + from openadapt_flow.authorization_summary import _covered_by_digest, outline + + with pytest.raises(bundles.BundleIntegrityError): + outline(bundle) + report = bundle / "QUALIFICATION.md" + report.write_text("synthetic report") + assert not _covered_by_digest(bundle, report) + + +def test_verified_load_can_save_as_and_carries_its_assets(tmp_path): + source = saved_bundle(tmp_path) + loaded = Workflow.load(source) + target = tmp_path / "save-as" + + loaded.save(target) + + assert (target / "templates/button.png").read_bytes() == ( + source / "templates/button.png" + ).read_bytes() + reloaded = Workflow.load(target) + bundles.verify_integrity(reloaded, target, reloaded.manifest) + + def test_loaded_identity_refuses_replaced_asset_hashes_or_missing_manifest(tmp_path): bundle = saved_bundle(tmp_path) loaded = Workflow.load(bundle) diff --git a/packages/flow/tests/test_bundle_release_compatibility.py b/packages/flow/tests/test_bundle_release_compatibility.py index 1e85ef2050..5afb5a59db 100644 --- a/packages/flow/tests/test_bundle_release_compatibility.py +++ b/packages/flow/tests/test_bundle_release_compatibility.py @@ -57,6 +57,16 @@ def test_frozen_bundle_original_seal_loads_without_rewriting(frozen_bundle): assert hashes(bundle) == before +def test_verified_historical_bundle_can_be_resaved(frozen_bundle): + bundle, _expected = frozen_bundle + workflow = Workflow.load(bundle) + + workflow.save(bundle) + + reloaded = Workflow.load(bundle) + bundles.verify_integrity(reloaded, bundle, reloaded.manifest) + + @pytest.mark.parametrize("attack", ["workflow", "version", "asset"]) def test_frozen_bundle_tampering_refuses(frozen_bundle, attack): bundle, _ = frozen_bundle diff --git a/packages/flow/tests/test_bundle_schema_v2.py b/packages/flow/tests/test_bundle_schema_v2.py index ae254c499e..b7183e428b 100644 --- a/packages/flow/tests/test_bundle_schema_v2.py +++ b/packages/flow/tests/test_bundle_schema_v2.py @@ -317,9 +317,8 @@ def test_fresh_workflow_defaults_to_v2(): assert Workflow(name="x").schema_version == 2 -def test_v1_bundle_loads_and_migrates(tmp_path): - """A raw v1 workflow.json (schema_version 1, no manifest) loads, migrates to - v2 in memory, and gets a manifest computed on read -- without breaking.""" +def test_v1_bundle_without_a_seal_is_refused(tmp_path): + """Legacy bundles without a persisted seal are refused closed.""" b = _write_bundle_dir(tmp_path) (b / "workflow.json").write_text( json.dumps( @@ -341,12 +340,8 @@ def test_v1_bundle_loads_and_migrates(tmp_path): } ) ) - wf = Workflow.load(b) - assert wf.schema_version == 2 # migrated on read - assert wf.name == "legacy" - assert wf.manifest is not None # computed because absent - assert wf.manifest.content_digest # non-empty - assert "templates/btn.png" in wf.manifest.file_hashes + with pytest.raises(bv.BundleIntegrityError, match="no persisted integrity seal"): + Workflow.load(b) def test_migrate_bundle_dict_is_additive_noop_on_fields(): diff --git a/packages/flow/tests/test_business_decision.py b/packages/flow/tests/test_business_decision.py index b99c56d161..09227d6de6 100644 --- a/packages/flow/tests/test_business_decision.py +++ b/packages/flow/tests/test_business_decision.py @@ -301,6 +301,27 @@ def test_qualification_authoring_adds_one_typed_decision_and_advances_revision() assert authored.qualification.last_certification is None +def test_business_decision_authoring_preserves_verified_source_for_save(tmp_path): + workflow = _authoring_workflow() + init_project(workflow, environment=_qualification_environment()) + bundle = tmp_path / "bundle" + workflow.save(bundle) + verified = Workflow.load(bundle) + + authored = set_business_decision( + verified, + graph_id="__program__", + state_id="review", + decision=_authoring_spec(), + insert_before_state_id="accept", + ) + authored.save(bundle) + + reloaded = Workflow.load(bundle) + assert reloaded.program is not None + assert "review" in reloaded.program.states + + def test_qualification_cli_authors_and_inspects_one_exact_decision( tmp_path: Path, capsys: pytest.CaptureFixture[str], diff --git a/packages/flow/tests/test_loop_authoring.py b/packages/flow/tests/test_loop_authoring.py index bf7f5949bb..56c7eafefd 100644 --- a/packages/flow/tests/test_loop_authoring.py +++ b/packages/flow/tests/test_loop_authoring.py @@ -354,6 +354,28 @@ def test_authored_bundle_round_trips_through_save_load(dirs): assert len(reloaded.data_sources["worklist"].rows) == 2 +def test_verified_loaded_body_can_be_saved_as_loop_derivative(tmp_path): + from openadapt_flow import bundle_validation + + source = tmp_path / "source" + original = _typed_note_body() + original.save(source) + body = Workflow.load(source) + assert body.manifest is not None and body.manifest.content_digest + + looped = author_data_driven_loop(body, [{"note": "a"}]) + destination = tmp_path / "loop" + looped.save(destination) + + saved = Workflow.load(destination) + assert saved.manifest is not None + assert saved.manifest.content_digest != body.manifest.content_digest + assert saved.program is not None + assert len(saved.data_sources["worklist"].rows) == 1 + bundle_validation.verify_integrity(saved, destination, saved.manifest) + assert Workflow.load(source).program is None + + # =========================================================================== # 2. real-Replayer evidence: healthy multi-record run + poisoned-record HALT # =========================================================================== diff --git a/packages/flow/tests/test_qualification_proposal.py b/packages/flow/tests/test_qualification_proposal.py index e759b4c00d..fb3d8e50b1 100644 --- a/packages/flow/tests/test_qualification_proposal.py +++ b/packages/flow/tests/test_qualification_proposal.py @@ -16,6 +16,7 @@ from PIL import Image from openadapt_flow.__main__ import main +from openadapt_flow.bundle_validation import BundleIntegrityError from openadapt_flow.ir import ( ActionKind, Anchor, @@ -204,6 +205,34 @@ def test_accept_confirms_pins_and_keeps_break_it(tmp_path: Path) -> None: assert local.issuer_workflow == LOCAL_DEV_ISSUER_WORKFLOW +@pytest.mark.parametrize("source", ["unchecked_tamper", "manifestless"]) +def test_local_dev_admission_requires_a_verified_persisted_seal( + tmp_path: Path, source: str +) -> None: + workflow, recording, bundle = _write_workflow(tmp_path, _workflow()) + if source == "unchecked_tamper": + raw = json.loads((bundle / "workflow.json").read_bytes()) + raw["name"] = "tampered before unchecked load" + (bundle / "workflow.json").write_text(json.dumps(raw), encoding="utf-8") + workflow = Workflow.load(bundle, verify_integrity=False) + else: + raw = json.loads((bundle / "workflow.json").read_bytes()) + raw.pop("manifest") + (bundle / "workflow.json").write_text(json.dumps(raw), encoding="utf-8") + (bundle / "manifest.json").unlink() + with pytest.raises(BundleIntegrityError, match="no persisted integrity seal"): + Workflow.load(bundle) + workflow = Workflow.load(bundle, verify_integrity=False) + + proposal = propose_qualification(workflow, recording_dir=recording) + accepted = accept_proposal(workflow, proposal) + with pytest.raises( + QualificationProposalError, + match="requires a verified persisted bundle seal", + ): + admit_local_dev(workflow, accepted, bundle_dir=bundle) + + def test_local_dev_key_cannot_enter_a_production_trust_map() -> None: registry = { LOCAL_DEV_KEY_ID: { diff --git a/packages/flow/tests/test_sanitized_artifact.py b/packages/flow/tests/test_sanitized_artifact.py index e4ab761b5e..691d182a8f 100644 --- a/packages/flow/tests/test_sanitized_artifact.py +++ b/packages/flow/tests/test_sanitized_artifact.py @@ -349,6 +349,7 @@ def test_phi_bearing_canonical_bundle_is_scrubbed_resealed_and_loadable(tmp_path ) workflow.save(bundle) (bundle / "workflow.py").write_text(render_workflow_py(workflow), encoding="utf-8") + source_digest = Workflow.load(bundle).manifest.content_digest destination = tmp_path / "sanitized" manifest = sanitize_artifact(bundle, destination, kind="bundle") @@ -362,6 +363,10 @@ def test_phi_bearing_canonical_bundle_is_scrubbed_resealed_and_loadable(tmp_path assert b"Jane Doe" not in text_bytes assert b"MRN-123" not in text_bytes assert Workflow.load(destination).steps[0].anchor is not None + assert ( + Workflow.load(destination).manifest.provenance.sanitized_from_digest + == source_digest + ) sanitized_hints = Workflow.load(destination).backend_hints assert sanitized_hints is not None assert sanitized_hints.rdp_window_title == "[PERSON] - Appointments" diff --git a/packages/flow/tests/test_standard_change.py b/packages/flow/tests/test_standard_change.py index 546e662263..25766acd15 100644 --- a/packages/flow/tests/test_standard_change.py +++ b/packages/flow/tests/test_standard_change.py @@ -517,7 +517,9 @@ def test_a_changed_save_template_goes_to_a_person(mockmed, tmp_path) -> None: def edit(workflow: Workflow) -> None: _relabel(workflow) - (out / template).write_bytes((out / template).read_bytes() + b"\0") + workflow.stage_verified_template_replacement( + template, (out / template).read_bytes() + b"\0" + ) patched = _copy(bundle, out, edit) verdict = classify_change( @@ -900,7 +902,9 @@ def test_a_changed_file_of_another_step_goes_to_a_person(mockmed, tmp_path) -> N def edit(workflow: Workflow) -> None: _relabel(workflow) - (out / template).write_bytes((out / template).read_bytes() + b"\0") + workflow.stage_verified_template_replacement( + template, (out / template).read_bytes() + b"\0" + ) patched = _copy(bundle, out, edit) verdict = classify_change(