diff --git a/pyproject.toml b/pyproject.toml index 373c1c23..1172b979 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -83,6 +83,12 @@ dev = [ # `tests/data/junit-10.xsd`. JUnit XML has no normative schema, so the schema is a # de-facto one and the test asserts structurally as well; `xmlschema` is used by that # test and by nothing else, and `ctrlrun` never imports it. + # SPEC-v0.11 §13.2's corollary, one milestone on: the reader defect item 1 found -- one + # `UPDATE` reporting four breaks at three positions, two of them rows that do not exist -- + # is a property, and five milestones of example tests never hit it because every example + # tampered in a way its author had already thought of. `tests/test_properties.py` states + # those invariants directly. Pinned to a deterministic profile there; see that module. + "hypothesis>=6", "xmlschema>=3", # SPEC-v0.5 §6.1 - T136 builds the wheel and the sdist and asserts neither carries an # adapter. Without `build` here the test skipped, and it skipped in *both* CI jobs: the diff --git a/requirements/adapters.txt b/requirements/adapters.txt index 28c61fba..bb0a71b2 100644 --- a/requirements/adapters.txt +++ b/requirements/adapters.txt @@ -604,6 +604,89 @@ httpx2-jsfetch==1.0 ; python_full_version >= '3.12' and sys_platform == 'emscrip --hash=sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60 \ --hash=sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32 # via httpx2 +hypothesis==6.168.0 \ + --hash=sha256:046fe4bcfce2a2fa186ba9d96bbb62c25c2f6c2e4071f0783ed6b5cc481d0669 \ + --hash=sha256:076a2096c34448931c3cfeb2eb7a6b843a56ffdce5e4e3a025bfdf8f935666d9 \ + --hash=sha256:085c9aa246487c56a40ca89003d285cbffdbb5be4097ba6d0139f9c21003c04a \ + --hash=sha256:0ba3838c4a92e0b9730d1ed7e67e4950c152ad79d0a0c7594065262db84c55c4 \ + --hash=sha256:112b0900059bf9d7d6528ed729770629ab146e0d133c4143b9bd4a01dc002bcc \ + --hash=sha256:16864797de4b024e4c6cebd44598af932f870aad811341bc5bc24c738801ff76 \ + --hash=sha256:1894782fae5d9a7bb44e6dcf848ccb09ccb5babab48d8b5c31a0a7fc025b82a1 \ + --hash=sha256:1d1aa5b3484e329295d88488a5ba06243909e65c2ab616513c2d36721de4ed1d \ + --hash=sha256:1f4cd0ff11bd470a1a846296ed5fe55e84214194850370994fd1370fe73d3099 \ + --hash=sha256:2085ee74ac3ab6b70e2f7ffae9b4cb74c246da2f574b2de81a0818a8a30f659f \ + --hash=sha256:2264f15a1c80329e3ad48e39c44bd5c9429b7b04c9ee62cdd72f4b10aaac9f29 \ + --hash=sha256:24b52a2b1c8db6e1e516f9295c8e4ef7ef63303ff24fbbc5b35f4ff71dcd732c \ + --hash=sha256:283eda952bcb1987ccba1c8b634db0e8a960e1e92e2daa7003bc2392f19cea01 \ + --hash=sha256:2a380b521b5a76a9e8917d64adcf7f861a45a4360a34b1579af14c5df8eb0377 \ + --hash=sha256:2a838218ff1eab8d7b4bf66b96037fce0a802f61f2fa5fd4b784696cac365ce7 \ + --hash=sha256:348d9b93fd4129f67f9bab94f3d70709a9372bbe0e0d22731325ce85d5eb409f \ + --hash=sha256:34e3c8b66047ba92f8b8df5e427074058d92db58038f007da4bf9d14e934ad3c \ + --hash=sha256:35f1262831b5acc74ded15f629965daffcd657f6016ee04fc9605f6eb2b334c0 \ + --hash=sha256:3b3ce1cce70b25a37ed1a38a53ce7204785726c675c0f41a0f83c338a7e47b3d \ + --hash=sha256:3bc00fd8cda04b58e37a1163e8a65389b247b4f5ee547ae37d244a4960995517 \ + --hash=sha256:3f6dcf66270278d078bed01b401f47db4e26456cd909d8e23c6b9366a6c0b131 \ + --hash=sha256:3f7486bed33225d02f6aa78a4c4ba2b6f84992a82571cdda1bf08dce41d13507 \ + --hash=sha256:4085b61e25d3dcc6c9151d4115269870aee8cdb921611ee5c989b2786449be09 \ + --hash=sha256:45fcfa05f746e253350f55f216bcef59754f5f2b85745f1fc2bb8ba81dd517a9 \ + --hash=sha256:47b89491ff02e3ae9b302c440457938e87b47a45b9a1d98ff5575b6910d779e2 \ + --hash=sha256:489d5c060f49f495b64215cae627c71730cffd5ef59dc4d7f431932e6e6d2e67 \ + --hash=sha256:4d7d29dd63ad9fdc4aa1d65fa272449e14aaf6c6bb8451091818c2945533a43a \ + --hash=sha256:527452b43e79e6dfbf9cb69145a940547a3cd177c556698a3fc939ed2354c4b3 \ + --hash=sha256:53469a1a7c4861b12c9a8622f762d7d1fd7bcf171884e1018ed5a8f063a5c063 \ + --hash=sha256:5427a3c951080c18170486f775df6a82153882b819eca6b8e7ed77693634e5ab \ + --hash=sha256:5920d267f7d8cfd376672f2bde5905cdf284d47519582e41ce7c142d48ee46c4 \ + --hash=sha256:5b54769033b84477931d2072e7133a7555e0de5c53fd5ca3bbde960762d7d31b \ + --hash=sha256:5f099b1c8fc49ec2d9d7944e661addb97d7c38e818fb8d1f78073c43895a87f6 \ + --hash=sha256:6b750390dac4429da0cb70ab3fe758457f0cea3d9c843d48c59d0690d1189fda \ + --hash=sha256:6de30e559eb151de14a5f74bceb4d97792a9315ada2a1816b5da825cd7d28edc \ + --hash=sha256:6f0dd437ec01140676192422b61f2f833b3ce6a3213da9b7e196ad6b3777e795 \ + --hash=sha256:6ff259260015f9be3756dcd4bc11c08e007314dec6b43d9a89084c4f34f94475 \ + --hash=sha256:719b45b0512e3535a6a0077c2f7c6053b02ac0e72d60693f66f98790a33855b2 \ + --hash=sha256:72af51087b7b5ab21c49f0d502f803c20897678652835596bd2a8b169a39135e \ + --hash=sha256:73084b76e4a79cd0f7883ce80fc60c9f374ce7dcad8f520b39db40470ce1852f \ + --hash=sha256:732ae5d47482f99d8028cca096729625f05690a83f5e7ce31466e266155792f4 \ + --hash=sha256:754016594fe78cef91790e0922f60d183c52f531255fbfa30dac495b813e2128 \ + --hash=sha256:76d4d36ed2fd62de11382f1d608169c1ffa9a49d3b9351146d8ff87cb81a66f7 \ + --hash=sha256:7d55562bf8d41cfa18559c33f30cadf44ceac8e517509d7a022a9feace621f28 \ + --hash=sha256:8067e6b4b48e5cfdc849a1a20c9d4972b3f532b3e3edb5e2b5dfd106045a5236 \ + --hash=sha256:812a84c4cc7f7ae4fcb39a5647cc2698e6c18254f8423126425578f1dcdac782 \ + --hash=sha256:891b2d281ede45130e7fa0a22fd65336cc77ef2f780ec3792e8de6fc274a02c8 \ + --hash=sha256:8e4b2d434e0dd134f3d31ac1efc1825bf99730dfe70fec005ff66d7211836d79 \ + --hash=sha256:9018b20acdb061b2ef4b2fa7f558ca5db97ffea316e0a528bc003a24b2ac996e \ + --hash=sha256:91e3de666a6c4f7543000d1710e25055d63ef3032c98bd2ab338b3087bdaa780 \ + --hash=sha256:92cff497b92e2285ff6a94193fdee04aba483a4115d501c1f9a570bd103fcd20 \ + --hash=sha256:93413d1b0af50a7b165d66278c529174bf2fd1773c78027735dc0b50d1d3fd27 \ + --hash=sha256:990026952d5b2eca290c88f639ac639233f47e13dae338c6dfb6e4774bcab349 \ + --hash=sha256:9a2079cd09919956dd388f1a1f8ea5a79f2b2437650fbeda31d8661217ffefef \ + --hash=sha256:9a72ed7afa1f7e30488b8a5754fca0ad9755518bdb77d6f0b003cadf7437a5f9 \ + --hash=sha256:9ba679f183c67adcb6f4ad93694beafb6da99fe691757f4e57b04ae77e581ba8 \ + --hash=sha256:9d9a8574f80fc859313aee56167d202e8625c0eedd200971130f0839f06d1c93 \ + --hash=sha256:a0d28418c104d7268fdebcc09bc49f7b6569b5eb942430c6859f53ec8d4edf63 \ + --hash=sha256:a4956f41ab1ec6e6ef9262a35970e9f3e2caaaa1cdafe0d413156c6934dd99d8 \ + --hash=sha256:a74b0945acbbd552c7c2d0a99a3b5232962b8848c8eed1829451800a9bfcf00b \ + --hash=sha256:a9650c4882fdbdd8e90bdae602a8bfa8c6f09dc5d06afec5b9b23982e8f60a04 \ + --hash=sha256:b5449a64eb37d9a4aa6ac9cd2ab0fd1a24145adf421ef1536884f73f39824887 \ + --hash=sha256:bc935a5d5f86fd8f5af951b8fbe00307f6f7c596f82a9a27c17d974f6ab0a26c \ + --hash=sha256:bfef4d46dbf1704a7b8fa3a78778651a2cb18870ca0a70da19c381646822b149 \ + --hash=sha256:c3af200b322f710c76c2189866246cdcff2039165dd77edff1a7bf1157162fb0 \ + --hash=sha256:cb10aa59b0af45badca76911f5323f40d24fdbe00d01b7b67fef8648c99411b5 \ + --hash=sha256:cd0c1dcf308e919c8ae708054d0ad61921ae87634a9aea574a9851da584cebc1 \ + --hash=sha256:d0620fa320fa66649e6bfd71e94f3f86115fffebb7e3c6dcece19d1aaff8e07f \ + --hash=sha256:d0bdb77f976740b8cd5ec697327ea343d02d052b9916d213b5d4c65d823415cd \ + --hash=sha256:db2751c27bffc8491a96d72969649089d5400115e4b7c49bf7167ebbdcc84193 \ + --hash=sha256:deb02de608268928d779aa889b0a9d67794b1cc0c54a322cf19e386be8a46ca7 \ + --hash=sha256:e21e30b76b6d3adb87c550576132a3204f4c257ec43353f6c09b9d59bb762abc \ + --hash=sha256:e2df8afacf9261070795db36db4a394e3ccdbb663fd2d38c7a9fba0c836dcecc \ + --hash=sha256:e86820053afad84677f301c0b892a226be1df49790800a65668ae7cc8a1ac571 \ + --hash=sha256:ec0886fe0be9091669937989f9a662beca42ae14a4a6dab25491c2c63365f88d \ + --hash=sha256:ecf0ab13cef899efb816ffdd7963e0679f372520884ce06756c7642f3df94213 \ + --hash=sha256:f62bdabf278db9ff61df5f3203d608949f0d893d0e30cdac3f2330e67e41ae68 \ + --hash=sha256:f77af7721ff35a58fa8797decd14c932c350a2548686c6e9b844db710a3a2441 \ + --hash=sha256:f89d8e998d3c936ffbbd1c3686c96f0378f6558aecc5967a3035a857f2bab0ad \ + --hash=sha256:fb8cdf45361e259df86e19f8cd042ce2d6c7e6ad88fa631b78a4e3a83c2e572d \ + --hash=sha256:fcc5bad4300a751804ce41f0e10d77f85272668160708ce39ec579bca8984843 + # via ctrlrun (pyproject.toml) idna==3.19 \ --hash=sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15 \ --hash=sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4 @@ -1771,6 +1854,10 @@ sniffio==1.3.1 \ # via # langsmith # openai +sortedcontainers==2.4.0 \ + --hash=sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88 \ + --hash=sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0 + # via hypothesis sse-starlette==3.4.11 \ --hash=sha256:1bae716c02f3e6f294be41ff333220692dae7c3cbab077c900f159676719dade \ --hash=sha256:c7b2244bdff016fe7f64e10075e89a3e6bbf899649cc89b0fe884b5545042453 diff --git a/requirements/ci.txt b/requirements/ci.txt index 7d274bd7..5428303a 100644 --- a/requirements/ci.txt +++ b/requirements/ci.txt @@ -555,6 +555,89 @@ httpx==0.28.1 \ --hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \ --hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad # via ctrlrun (pyproject.toml) +hypothesis==6.168.0 \ + --hash=sha256:046fe4bcfce2a2fa186ba9d96bbb62c25c2f6c2e4071f0783ed6b5cc481d0669 \ + --hash=sha256:076a2096c34448931c3cfeb2eb7a6b843a56ffdce5e4e3a025bfdf8f935666d9 \ + --hash=sha256:085c9aa246487c56a40ca89003d285cbffdbb5be4097ba6d0139f9c21003c04a \ + --hash=sha256:0ba3838c4a92e0b9730d1ed7e67e4950c152ad79d0a0c7594065262db84c55c4 \ + --hash=sha256:112b0900059bf9d7d6528ed729770629ab146e0d133c4143b9bd4a01dc002bcc \ + --hash=sha256:16864797de4b024e4c6cebd44598af932f870aad811341bc5bc24c738801ff76 \ + --hash=sha256:1894782fae5d9a7bb44e6dcf848ccb09ccb5babab48d8b5c31a0a7fc025b82a1 \ + --hash=sha256:1d1aa5b3484e329295d88488a5ba06243909e65c2ab616513c2d36721de4ed1d \ + --hash=sha256:1f4cd0ff11bd470a1a846296ed5fe55e84214194850370994fd1370fe73d3099 \ + --hash=sha256:2085ee74ac3ab6b70e2f7ffae9b4cb74c246da2f574b2de81a0818a8a30f659f \ + --hash=sha256:2264f15a1c80329e3ad48e39c44bd5c9429b7b04c9ee62cdd72f4b10aaac9f29 \ + --hash=sha256:24b52a2b1c8db6e1e516f9295c8e4ef7ef63303ff24fbbc5b35f4ff71dcd732c \ + --hash=sha256:283eda952bcb1987ccba1c8b634db0e8a960e1e92e2daa7003bc2392f19cea01 \ + --hash=sha256:2a380b521b5a76a9e8917d64adcf7f861a45a4360a34b1579af14c5df8eb0377 \ + --hash=sha256:2a838218ff1eab8d7b4bf66b96037fce0a802f61f2fa5fd4b784696cac365ce7 \ + --hash=sha256:348d9b93fd4129f67f9bab94f3d70709a9372bbe0e0d22731325ce85d5eb409f \ + --hash=sha256:34e3c8b66047ba92f8b8df5e427074058d92db58038f007da4bf9d14e934ad3c \ + --hash=sha256:35f1262831b5acc74ded15f629965daffcd657f6016ee04fc9605f6eb2b334c0 \ + --hash=sha256:3b3ce1cce70b25a37ed1a38a53ce7204785726c675c0f41a0f83c338a7e47b3d \ + --hash=sha256:3bc00fd8cda04b58e37a1163e8a65389b247b4f5ee547ae37d244a4960995517 \ + --hash=sha256:3f6dcf66270278d078bed01b401f47db4e26456cd909d8e23c6b9366a6c0b131 \ + --hash=sha256:3f7486bed33225d02f6aa78a4c4ba2b6f84992a82571cdda1bf08dce41d13507 \ + --hash=sha256:4085b61e25d3dcc6c9151d4115269870aee8cdb921611ee5c989b2786449be09 \ + --hash=sha256:45fcfa05f746e253350f55f216bcef59754f5f2b85745f1fc2bb8ba81dd517a9 \ + --hash=sha256:47b89491ff02e3ae9b302c440457938e87b47a45b9a1d98ff5575b6910d779e2 \ + --hash=sha256:489d5c060f49f495b64215cae627c71730cffd5ef59dc4d7f431932e6e6d2e67 \ + --hash=sha256:4d7d29dd63ad9fdc4aa1d65fa272449e14aaf6c6bb8451091818c2945533a43a \ + --hash=sha256:527452b43e79e6dfbf9cb69145a940547a3cd177c556698a3fc939ed2354c4b3 \ + --hash=sha256:53469a1a7c4861b12c9a8622f762d7d1fd7bcf171884e1018ed5a8f063a5c063 \ + --hash=sha256:5427a3c951080c18170486f775df6a82153882b819eca6b8e7ed77693634e5ab \ + --hash=sha256:5920d267f7d8cfd376672f2bde5905cdf284d47519582e41ce7c142d48ee46c4 \ + --hash=sha256:5b54769033b84477931d2072e7133a7555e0de5c53fd5ca3bbde960762d7d31b \ + --hash=sha256:5f099b1c8fc49ec2d9d7944e661addb97d7c38e818fb8d1f78073c43895a87f6 \ + --hash=sha256:6b750390dac4429da0cb70ab3fe758457f0cea3d9c843d48c59d0690d1189fda \ + --hash=sha256:6de30e559eb151de14a5f74bceb4d97792a9315ada2a1816b5da825cd7d28edc \ + --hash=sha256:6f0dd437ec01140676192422b61f2f833b3ce6a3213da9b7e196ad6b3777e795 \ + --hash=sha256:6ff259260015f9be3756dcd4bc11c08e007314dec6b43d9a89084c4f34f94475 \ + --hash=sha256:719b45b0512e3535a6a0077c2f7c6053b02ac0e72d60693f66f98790a33855b2 \ + --hash=sha256:72af51087b7b5ab21c49f0d502f803c20897678652835596bd2a8b169a39135e \ + --hash=sha256:73084b76e4a79cd0f7883ce80fc60c9f374ce7dcad8f520b39db40470ce1852f \ + --hash=sha256:732ae5d47482f99d8028cca096729625f05690a83f5e7ce31466e266155792f4 \ + --hash=sha256:754016594fe78cef91790e0922f60d183c52f531255fbfa30dac495b813e2128 \ + --hash=sha256:76d4d36ed2fd62de11382f1d608169c1ffa9a49d3b9351146d8ff87cb81a66f7 \ + --hash=sha256:7d55562bf8d41cfa18559c33f30cadf44ceac8e517509d7a022a9feace621f28 \ + --hash=sha256:8067e6b4b48e5cfdc849a1a20c9d4972b3f532b3e3edb5e2b5dfd106045a5236 \ + --hash=sha256:812a84c4cc7f7ae4fcb39a5647cc2698e6c18254f8423126425578f1dcdac782 \ + --hash=sha256:891b2d281ede45130e7fa0a22fd65336cc77ef2f780ec3792e8de6fc274a02c8 \ + --hash=sha256:8e4b2d434e0dd134f3d31ac1efc1825bf99730dfe70fec005ff66d7211836d79 \ + --hash=sha256:9018b20acdb061b2ef4b2fa7f558ca5db97ffea316e0a528bc003a24b2ac996e \ + --hash=sha256:91e3de666a6c4f7543000d1710e25055d63ef3032c98bd2ab338b3087bdaa780 \ + --hash=sha256:92cff497b92e2285ff6a94193fdee04aba483a4115d501c1f9a570bd103fcd20 \ + --hash=sha256:93413d1b0af50a7b165d66278c529174bf2fd1773c78027735dc0b50d1d3fd27 \ + --hash=sha256:990026952d5b2eca290c88f639ac639233f47e13dae338c6dfb6e4774bcab349 \ + --hash=sha256:9a2079cd09919956dd388f1a1f8ea5a79f2b2437650fbeda31d8661217ffefef \ + --hash=sha256:9a72ed7afa1f7e30488b8a5754fca0ad9755518bdb77d6f0b003cadf7437a5f9 \ + --hash=sha256:9ba679f183c67adcb6f4ad93694beafb6da99fe691757f4e57b04ae77e581ba8 \ + --hash=sha256:9d9a8574f80fc859313aee56167d202e8625c0eedd200971130f0839f06d1c93 \ + --hash=sha256:a0d28418c104d7268fdebcc09bc49f7b6569b5eb942430c6859f53ec8d4edf63 \ + --hash=sha256:a4956f41ab1ec6e6ef9262a35970e9f3e2caaaa1cdafe0d413156c6934dd99d8 \ + --hash=sha256:a74b0945acbbd552c7c2d0a99a3b5232962b8848c8eed1829451800a9bfcf00b \ + --hash=sha256:a9650c4882fdbdd8e90bdae602a8bfa8c6f09dc5d06afec5b9b23982e8f60a04 \ + --hash=sha256:b5449a64eb37d9a4aa6ac9cd2ab0fd1a24145adf421ef1536884f73f39824887 \ + --hash=sha256:bc935a5d5f86fd8f5af951b8fbe00307f6f7c596f82a9a27c17d974f6ab0a26c \ + --hash=sha256:bfef4d46dbf1704a7b8fa3a78778651a2cb18870ca0a70da19c381646822b149 \ + --hash=sha256:c3af200b322f710c76c2189866246cdcff2039165dd77edff1a7bf1157162fb0 \ + --hash=sha256:cb10aa59b0af45badca76911f5323f40d24fdbe00d01b7b67fef8648c99411b5 \ + --hash=sha256:cd0c1dcf308e919c8ae708054d0ad61921ae87634a9aea574a9851da584cebc1 \ + --hash=sha256:d0620fa320fa66649e6bfd71e94f3f86115fffebb7e3c6dcece19d1aaff8e07f \ + --hash=sha256:d0bdb77f976740b8cd5ec697327ea343d02d052b9916d213b5d4c65d823415cd \ + --hash=sha256:db2751c27bffc8491a96d72969649089d5400115e4b7c49bf7167ebbdcc84193 \ + --hash=sha256:deb02de608268928d779aa889b0a9d67794b1cc0c54a322cf19e386be8a46ca7 \ + --hash=sha256:e21e30b76b6d3adb87c550576132a3204f4c257ec43353f6c09b9d59bb762abc \ + --hash=sha256:e2df8afacf9261070795db36db4a394e3ccdbb663fd2d38c7a9fba0c836dcecc \ + --hash=sha256:e86820053afad84677f301c0b892a226be1df49790800a65668ae7cc8a1ac571 \ + --hash=sha256:ec0886fe0be9091669937989f9a662beca42ae14a4a6dab25491c2c63365f88d \ + --hash=sha256:ecf0ab13cef899efb816ffdd7963e0679f372520884ce06756c7642f3df94213 \ + --hash=sha256:f62bdabf278db9ff61df5f3203d608949f0d893d0e30cdac3f2330e67e41ae68 \ + --hash=sha256:f77af7721ff35a58fa8797decd14c932c350a2548686c6e9b844db710a3a2441 \ + --hash=sha256:f89d8e998d3c936ffbbd1c3686c96f0378f6558aecc5967a3035a857f2bab0ad \ + --hash=sha256:fb8cdf45361e259df86e19f8cd042ce2d6c7e6ad88fa631b78a4e3a83c2e572d \ + --hash=sha256:fcc5bad4300a751804ce41f0e10d77f85272668160708ce39ec579bca8984843 + # via ctrlrun (pyproject.toml) idna==3.19 \ --hash=sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15 \ --hash=sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4 @@ -961,6 +1044,10 @@ setuptools==84.0.0 \ --hash=sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670 \ --hash=sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73 # via -r requirements/in/backend.in +sortedcontainers==2.4.0 \ + --hash=sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88 \ + --hash=sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0 + # via hypothesis tomli==2.4.1 ; python_full_version <= '3.11' \ --hash=sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853 \ --hash=sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe \ diff --git a/requirements/docs.txt b/requirements/docs.txt index e6126e8d..e4d2995c 100644 --- a/requirements/docs.txt +++ b/requirements/docs.txt @@ -570,6 +570,89 @@ httpx==0.28.1 \ --hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \ --hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad # via ctrlrun (pyproject.toml) +hypothesis==6.168.0 \ + --hash=sha256:046fe4bcfce2a2fa186ba9d96bbb62c25c2f6c2e4071f0783ed6b5cc481d0669 \ + --hash=sha256:076a2096c34448931c3cfeb2eb7a6b843a56ffdce5e4e3a025bfdf8f935666d9 \ + --hash=sha256:085c9aa246487c56a40ca89003d285cbffdbb5be4097ba6d0139f9c21003c04a \ + --hash=sha256:0ba3838c4a92e0b9730d1ed7e67e4950c152ad79d0a0c7594065262db84c55c4 \ + --hash=sha256:112b0900059bf9d7d6528ed729770629ab146e0d133c4143b9bd4a01dc002bcc \ + --hash=sha256:16864797de4b024e4c6cebd44598af932f870aad811341bc5bc24c738801ff76 \ + --hash=sha256:1894782fae5d9a7bb44e6dcf848ccb09ccb5babab48d8b5c31a0a7fc025b82a1 \ + --hash=sha256:1d1aa5b3484e329295d88488a5ba06243909e65c2ab616513c2d36721de4ed1d \ + --hash=sha256:1f4cd0ff11bd470a1a846296ed5fe55e84214194850370994fd1370fe73d3099 \ + --hash=sha256:2085ee74ac3ab6b70e2f7ffae9b4cb74c246da2f574b2de81a0818a8a30f659f \ + --hash=sha256:2264f15a1c80329e3ad48e39c44bd5c9429b7b04c9ee62cdd72f4b10aaac9f29 \ + --hash=sha256:24b52a2b1c8db6e1e516f9295c8e4ef7ef63303ff24fbbc5b35f4ff71dcd732c \ + --hash=sha256:283eda952bcb1987ccba1c8b634db0e8a960e1e92e2daa7003bc2392f19cea01 \ + --hash=sha256:2a380b521b5a76a9e8917d64adcf7f861a45a4360a34b1579af14c5df8eb0377 \ + --hash=sha256:2a838218ff1eab8d7b4bf66b96037fce0a802f61f2fa5fd4b784696cac365ce7 \ + --hash=sha256:348d9b93fd4129f67f9bab94f3d70709a9372bbe0e0d22731325ce85d5eb409f \ + --hash=sha256:34e3c8b66047ba92f8b8df5e427074058d92db58038f007da4bf9d14e934ad3c \ + --hash=sha256:35f1262831b5acc74ded15f629965daffcd657f6016ee04fc9605f6eb2b334c0 \ + --hash=sha256:3b3ce1cce70b25a37ed1a38a53ce7204785726c675c0f41a0f83c338a7e47b3d \ + --hash=sha256:3bc00fd8cda04b58e37a1163e8a65389b247b4f5ee547ae37d244a4960995517 \ + --hash=sha256:3f6dcf66270278d078bed01b401f47db4e26456cd909d8e23c6b9366a6c0b131 \ + --hash=sha256:3f7486bed33225d02f6aa78a4c4ba2b6f84992a82571cdda1bf08dce41d13507 \ + --hash=sha256:4085b61e25d3dcc6c9151d4115269870aee8cdb921611ee5c989b2786449be09 \ + --hash=sha256:45fcfa05f746e253350f55f216bcef59754f5f2b85745f1fc2bb8ba81dd517a9 \ + --hash=sha256:47b89491ff02e3ae9b302c440457938e87b47a45b9a1d98ff5575b6910d779e2 \ + --hash=sha256:489d5c060f49f495b64215cae627c71730cffd5ef59dc4d7f431932e6e6d2e67 \ + --hash=sha256:4d7d29dd63ad9fdc4aa1d65fa272449e14aaf6c6bb8451091818c2945533a43a \ + --hash=sha256:527452b43e79e6dfbf9cb69145a940547a3cd177c556698a3fc939ed2354c4b3 \ + --hash=sha256:53469a1a7c4861b12c9a8622f762d7d1fd7bcf171884e1018ed5a8f063a5c063 \ + --hash=sha256:5427a3c951080c18170486f775df6a82153882b819eca6b8e7ed77693634e5ab \ + --hash=sha256:5920d267f7d8cfd376672f2bde5905cdf284d47519582e41ce7c142d48ee46c4 \ + --hash=sha256:5b54769033b84477931d2072e7133a7555e0de5c53fd5ca3bbde960762d7d31b \ + --hash=sha256:5f099b1c8fc49ec2d9d7944e661addb97d7c38e818fb8d1f78073c43895a87f6 \ + --hash=sha256:6b750390dac4429da0cb70ab3fe758457f0cea3d9c843d48c59d0690d1189fda \ + --hash=sha256:6de30e559eb151de14a5f74bceb4d97792a9315ada2a1816b5da825cd7d28edc \ + --hash=sha256:6f0dd437ec01140676192422b61f2f833b3ce6a3213da9b7e196ad6b3777e795 \ + --hash=sha256:6ff259260015f9be3756dcd4bc11c08e007314dec6b43d9a89084c4f34f94475 \ + --hash=sha256:719b45b0512e3535a6a0077c2f7c6053b02ac0e72d60693f66f98790a33855b2 \ + --hash=sha256:72af51087b7b5ab21c49f0d502f803c20897678652835596bd2a8b169a39135e \ + --hash=sha256:73084b76e4a79cd0f7883ce80fc60c9f374ce7dcad8f520b39db40470ce1852f \ + --hash=sha256:732ae5d47482f99d8028cca096729625f05690a83f5e7ce31466e266155792f4 \ + --hash=sha256:754016594fe78cef91790e0922f60d183c52f531255fbfa30dac495b813e2128 \ + --hash=sha256:76d4d36ed2fd62de11382f1d608169c1ffa9a49d3b9351146d8ff87cb81a66f7 \ + --hash=sha256:7d55562bf8d41cfa18559c33f30cadf44ceac8e517509d7a022a9feace621f28 \ + --hash=sha256:8067e6b4b48e5cfdc849a1a20c9d4972b3f532b3e3edb5e2b5dfd106045a5236 \ + --hash=sha256:812a84c4cc7f7ae4fcb39a5647cc2698e6c18254f8423126425578f1dcdac782 \ + --hash=sha256:891b2d281ede45130e7fa0a22fd65336cc77ef2f780ec3792e8de6fc274a02c8 \ + --hash=sha256:8e4b2d434e0dd134f3d31ac1efc1825bf99730dfe70fec005ff66d7211836d79 \ + --hash=sha256:9018b20acdb061b2ef4b2fa7f558ca5db97ffea316e0a528bc003a24b2ac996e \ + --hash=sha256:91e3de666a6c4f7543000d1710e25055d63ef3032c98bd2ab338b3087bdaa780 \ + --hash=sha256:92cff497b92e2285ff6a94193fdee04aba483a4115d501c1f9a570bd103fcd20 \ + --hash=sha256:93413d1b0af50a7b165d66278c529174bf2fd1773c78027735dc0b50d1d3fd27 \ + --hash=sha256:990026952d5b2eca290c88f639ac639233f47e13dae338c6dfb6e4774bcab349 \ + --hash=sha256:9a2079cd09919956dd388f1a1f8ea5a79f2b2437650fbeda31d8661217ffefef \ + --hash=sha256:9a72ed7afa1f7e30488b8a5754fca0ad9755518bdb77d6f0b003cadf7437a5f9 \ + --hash=sha256:9ba679f183c67adcb6f4ad93694beafb6da99fe691757f4e57b04ae77e581ba8 \ + --hash=sha256:9d9a8574f80fc859313aee56167d202e8625c0eedd200971130f0839f06d1c93 \ + --hash=sha256:a0d28418c104d7268fdebcc09bc49f7b6569b5eb942430c6859f53ec8d4edf63 \ + --hash=sha256:a4956f41ab1ec6e6ef9262a35970e9f3e2caaaa1cdafe0d413156c6934dd99d8 \ + --hash=sha256:a74b0945acbbd552c7c2d0a99a3b5232962b8848c8eed1829451800a9bfcf00b \ + --hash=sha256:a9650c4882fdbdd8e90bdae602a8bfa8c6f09dc5d06afec5b9b23982e8f60a04 \ + --hash=sha256:b5449a64eb37d9a4aa6ac9cd2ab0fd1a24145adf421ef1536884f73f39824887 \ + --hash=sha256:bc935a5d5f86fd8f5af951b8fbe00307f6f7c596f82a9a27c17d974f6ab0a26c \ + --hash=sha256:bfef4d46dbf1704a7b8fa3a78778651a2cb18870ca0a70da19c381646822b149 \ + --hash=sha256:c3af200b322f710c76c2189866246cdcff2039165dd77edff1a7bf1157162fb0 \ + --hash=sha256:cb10aa59b0af45badca76911f5323f40d24fdbe00d01b7b67fef8648c99411b5 \ + --hash=sha256:cd0c1dcf308e919c8ae708054d0ad61921ae87634a9aea574a9851da584cebc1 \ + --hash=sha256:d0620fa320fa66649e6bfd71e94f3f86115fffebb7e3c6dcece19d1aaff8e07f \ + --hash=sha256:d0bdb77f976740b8cd5ec697327ea343d02d052b9916d213b5d4c65d823415cd \ + --hash=sha256:db2751c27bffc8491a96d72969649089d5400115e4b7c49bf7167ebbdcc84193 \ + --hash=sha256:deb02de608268928d779aa889b0a9d67794b1cc0c54a322cf19e386be8a46ca7 \ + --hash=sha256:e21e30b76b6d3adb87c550576132a3204f4c257ec43353f6c09b9d59bb762abc \ + --hash=sha256:e2df8afacf9261070795db36db4a394e3ccdbb663fd2d38c7a9fba0c836dcecc \ + --hash=sha256:e86820053afad84677f301c0b892a226be1df49790800a65668ae7cc8a1ac571 \ + --hash=sha256:ec0886fe0be9091669937989f9a662beca42ae14a4a6dab25491c2c63365f88d \ + --hash=sha256:ecf0ab13cef899efb816ffdd7963e0679f372520884ce06756c7642f3df94213 \ + --hash=sha256:f62bdabf278db9ff61df5f3203d608949f0d893d0e30cdac3f2330e67e41ae68 \ + --hash=sha256:f77af7721ff35a58fa8797decd14c932c350a2548686c6e9b844db710a3a2441 \ + --hash=sha256:f89d8e998d3c936ffbbd1c3686c96f0378f6558aecc5967a3035a857f2bab0ad \ + --hash=sha256:fb8cdf45361e259df86e19f8cd042ce2d6c7e6ad88fa631b78a4e3a83c2e572d \ + --hash=sha256:fcc5bad4300a751804ce41f0e10d77f85272668160708ce39ec579bca8984843 + # via ctrlrun (pyproject.toml) idna==3.19 \ --hash=sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15 \ --hash=sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4 @@ -978,6 +1061,10 @@ setuptools==84.0.0 \ --hash=sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670 \ --hash=sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73 # via -r requirements/in/backend.in +sortedcontainers==2.4.0 \ + --hash=sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88 \ + --hash=sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0 + # via hypothesis tomli==2.4.1 ; python_full_version <= '3.11' \ --hash=sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853 \ --hash=sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe \ diff --git a/src/ctrlrun/authority.py b/src/ctrlrun/authority.py index 90e20edd..80fbb19d 100644 --- a/src/ctrlrun/authority.py +++ b/src/ctrlrun/authority.py @@ -38,7 +38,11 @@ from .action import Action, PlainValue, Principal from .errors import AuthorityEscalation, IdentityError, InvalidArgument, PolicyError -from .policy import ( + +# `grammar.py` and not `policy.py`: SPEC-v0.3 §4.5 requires the two axes to share one condition +# evaluator, and it now lives below both rather than inside one of them. Importing it from +# `policy.py` was the last cycle ARCHITECTURE §6 carried an exception for. +from .grammar import ( SUPPORTED_SCHEMAS, Condition, parse_conditions, @@ -47,7 +51,7 @@ require_v7, strict_load, ) -from .policy import _equal as _type_strict_equal +from .grammar import _equal as _type_strict_equal from .state import Charge, DelegationRecord, StateStore #: SPEC-v0.3 §4.4 — an action name is dotted (`v0.1 §2.1`) and a resource is `type:id`. diff --git a/src/ctrlrun/grammar.py b/src/ctrlrun/grammar.py new file mode 100644 index 00000000..34993b2d --- /dev/null +++ b/src/ctrlrun/grammar.py @@ -0,0 +1,557 @@ +# SPDX-FileCopyrightText: 2026 The CTRLRun contributors +# SPDX-License-Identifier: Apache-2.0 +"""The policy document grammar: schemas, strict loading, and the condition evaluator. + +**Below `policy.py` and `authority.py`, and owned by neither.** `docs/ARCHITECTURE.md` §6 records +that `authority.py` imports the condition parser and evaluator from `policy.py` deliberately, +because a grant's `constraints:` is a rule's `when:` syntax and `SPEC-v0.3.md` §4.5 says the two +axes MUST share one evaluator: a second one would be a second place for `True` to start comparing +equal to `1`. That requirement is unchanged. What changed is where the one evaluator lives. + +It lived in `policy.py`, so `authority.py` imported upward, while `policy.py` reached back into +`authority.py` from `_canonical_authority` and `hash_with_authority`. That was a cycle, and the +last one §6 had to carry an exception for. Neither direction could be removed on its own: +`_from_section` constructs an `Authority` and `canonical_grants` consumes one, so neither moves +below `policy.py`, and moving their two callers up to `control.py` would change what +`policy_hash` is taken over, which is evidence in every receipt rather than an implementation +detail. + +Moving the *shared* half down removes the cycle without touching either. The evaluator is now +owned by neither axis, which is what §4.5 asks for more literally than the old arrangement did. + +**Nothing here changed but its address.** Every block was moved verbatim, comments included, and +`policy.py` re-exports all of it, so `from ctrlrun.policy import Condition, parse_conditions` +still resolves and `SPEC-v0.3.md` §8's frozen line stays literally true. +""" + +from __future__ import annotations + +import logging +import operator +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from typing import Any, Final + +import yaml + +from .errors import PolicyError + +#: Pinned to `ctrlrun.policy` rather than taken from `__name__`. These functions logged there +#: before the move, and a refactor that silently re-routes a log line is a refactor that loses +#: whatever handler or filter an operator pointed at it. +_LOG = logging.getLogger("ctrlrun.policy") + + +#: The schema `ctrlrun init` writes, and the one every v0.1 file declares. +POLICY_SCHEMA: Final = "ctrlrun.policy/v1" +#: SPEC-v0.2 §3.1 — required by any document using `effect:`, `resource:` or `mcp:`. A v2 +#: file fails to load on v0.1, correctly: v0.1 would ignore the effect template and execute +#: with no duplicate protection at all. The schema string is the only thing standing between +#: those two outcomes, so it is not optional and not inferred. +POLICY_SCHEMA_V2: Final = "ctrlrun.policy/v2" +#: SPEC-v0.3 §12.1 — required by any document using `environment:`, and later by `authority:` +#: and `mode:`. A v3 key in an older document is a load error naming the key and the schema, +#: for the reason v0.2 gives: a reader that ignored it would run with a guarantee switched off. +POLICY_SCHEMA_V3: Final = "ctrlrun.policy/v3" +#: SPEC-v0.6 §7.1, §9.5 — required by any document using `version:`, `controls:` or `data:`. +#: `v1`, `v2` and `v3` documents load unchanged and get a `policy_hash` like any other; only +#: those three keys need `v4`. +POLICY_SCHEMA_V4: Final = "ctrlrun.policy/v4" +#: SPEC-v0.7 §5.3 — required by any document using `max_attempts:`. An 0.6.1 reader refuses a +#: `v5` document outright, which is the fail-closed direction: a reader that ignored the key +#: would renew without a ceiling, which is the behaviour the key exists to bound. +POLICY_SCHEMA_V5: Final = "ctrlrun.policy/v5" +#: SPEC-v0.8 §11.3: `v6` adds `approver_role` on a control entry, and items 4 and 5 add +#: their keys under it. The version moves once, here, for the reason §11.4 gives: an older +#: reader must refuse a document whose keys it would otherwise ignore, and a reader that +#: ignored `approver_role` would run a deployment believing nobody was gated. +POLICY_SCHEMA_V6: Final = "ctrlrun.policy/v6" +#: SPEC-v0.9 §10.1: `v7` adds `tasks` on a grant (item 1) and `budgets` on one (item 3). The +#: version moves once, here, with item 1, and item 3 fills it under the version already in +#: place: two branches racing a schema bump is how a catalogue ends up with a stub row. +POLICY_SCHEMA_V7: Final = "ctrlrun.policy/v7" +#: SPEC-v0.10 §4.6 — `v8` adds one action-entry key, `upstream:`. Bumped once, by item 3. +POLICY_SCHEMA_V8: Final = "ctrlrun.policy/v8" +#: All of them, newest last, for the message an unknown schema produces. **In version order**, +#: which `_at_least` reads: a version added out of order would make every gate below lie. +SUPPORTED_SCHEMAS: Final = ( + POLICY_SCHEMA, + POLICY_SCHEMA_V2, + POLICY_SCHEMA_V3, + POLICY_SCHEMA_V4, + POLICY_SCHEMA_V5, + POLICY_SCHEMA_V6, + POLICY_SCHEMA_V7, + POLICY_SCHEMA_V8, +) + + +def _at_least(schema: str, minimum: str) -> bool: + """Whether `schema` is `minimum` or a later version (SPEC-v0.7 §5.3). + + Each version is a **superset** of the one before: a `v5` document may use every key any + earlier version allows. Three gates in this module compared for equality instead, which was + right while `v4` was the newest and became wrong the moment it was not; `require_v3`'s own + comment predicted it. An unknown schema is refused before any of them runs, so a name that + is not in `SUPPORTED_SCHEMAS` cannot reach here from `Policy._from_document`; where one does, + from `authority.py`'s standalone path, it is treated as too old, which is fail-closed. + """ + if schema not in SUPPORTED_SCHEMAS or minimum not in SUPPORTED_SCHEMAS: + return False + return SUPPORTED_SCHEMAS.index(schema) >= SUPPORTED_SCHEMAS.index(minimum) + + +#: SPEC-v0.3 §6.1 — the two values of the top-level `mode:` key, and nothing else. Absent +#: means `enforce`: the fail-closed default, so a document that predates the key enforces. +MODE_KEY: Final = "mode" +_NUMERIC_COMPARE: Final[Mapping[str, Callable[[int, int], bool]]] = { + "lt": operator.lt, + "lte": operator.le, + "gt": operator.gt, + "gte": operator.ge, +} +_OPERATORS: Final = ("eq", "neq", "in", *_NUMERIC_COMPARE) +#: Longest first, so `amount_neq` reads as (amount, neq) and never as (amount_n, eq). +_OPERATORS_BY_LENGTH: Final = tuple(sorted(_OPERATORS, key=len, reverse=True)) +#: SPEC-v0.3 §12.1 — the top-level keys that need `ctrlrun.policy/v3`, and what an older +#: reader would do with each if it ignored one. +_V3_TOP_LEVEL_KEYS: Final[Mapping[str, str]] = { + "environment": ("an older reader would ignore it and put every action in the wrong deployment"), + "authority": ( + "an older reader would ignore it and run every action with no authority check at all" + ), + "mode": ("an older reader would enforce a configuration that was deployed to observe"), +} +RESERVED_ARGUMENTS: Final = frozenset( + { + "action_id", + "agent", + "claims", + "data_scope", + "environment", + "expires_at", + "issuer", + "principal", + "resource", + "user", + } +) +#: SPEC-v0.6 §7.4 — names refused as **arguments** and permitted as **condition subjects**, +#: resolved at evaluation from something other than `action.canonical_arguments`. +#: +#: The distinction is what makes `data_scope` implementable at all. Today one check does both +#: jobs: the splitter refuses a condition whose subject is in `RESERVED_ARGUMENTS`, which is how +#: `claims_eq:` becomes a load error. Adding `data_scope` to that set unchanged would have made +#: `data_scope_in:` a load error too -- the very condition §7.4 asks operators to write. +#: +#: **A name here is still refused as an argument**, so one name never means two things in one +#: document. And this list is the *policy evaluator's*: authority `constraints:` do not consult +#: it, so a grant naming `data_scope` is refused exactly as it always was (§11 puts matching a +#: grant on a data label out of scope). +DERIVED_SUBJECTS: Final = frozenset({"data_scope"}) + + +def _is_int(value: object) -> bool: + """True for a real int. `bool` subclasses int in Python; SPEC-v0.1 §3.2 excludes it.""" + return isinstance(value, int) and not isinstance(value, bool) + + +def _type_name(value: object) -> str: + return type(value).__name__ + + +def _equal(value: object, operand: object) -> bool: + """Type-strict equality: `True` never equals `1`, and a list never equals a scalar. + + SPEC: §3.2 — equality is type-strict and applies recursively inside containers. + Canonical arguments distinguish bool from int (§2.3), so conditions must too, or a + policy written for `1` would match `True`. + """ + if isinstance(value, bool) or isinstance(operand, bool): + return value is operand + if isinstance(value, Mapping) and isinstance(operand, Mapping): + return value.keys() == operand.keys() and all( + _equal(value[key], operand[key]) for key in value + ) + if isinstance(value, list | tuple) and isinstance(operand, list | tuple): + return len(value) == len(operand) and all( + _equal(item, other) for item, other in zip(value, operand, strict=True) + ) + if _is_container(value) or _is_container(operand): + return False + return bool(value == operand) + + +def _is_container(value: object) -> bool: + return isinstance(value, Mapping | list | tuple) + + +@dataclass(frozen=True) +class Condition: + """One `_: operand` test against an action's arguments (SPEC-v0.1 §3.2). + + Public since SPEC-v0.3 §11, because a `Grant`'s constraints are made of them and the two + axes share one evaluator: a second implementation would be a second place for `True` to + start comparing equal to `1`. `key` is the raw condition key the author wrote. + """ + + key: str + argument: str + op: str + operand: Any + + def matches(self, action_name: str, arguments: Mapping[str, Any]) -> bool: + if self.argument not in arguments: + # SPEC §3.2 — still false, never an error, but never silent either. Defaults are + # applied when a call is bound (§8), so an argument is either always present or + # never: an absent one is a typo, and silence let a mistyped rule disappear into + # a catch-all below it. + _LOG.warning( + "%s: condition %s ignored: the action has no argument %r (it has: %s)", + action_name, + self.key, + self.argument, + ", ".join(sorted(arguments)) or "none", + ) + return False + value = arguments[self.argument] + if self.op in {"eq", "neq"} and self.argument in DERIVED_SUBJECTS: + # SPEC-v0.6 §7.4: *"`_eq` and `_neq` compare the whole set."* **The whole set, and + # a set has no order.** The derived value is a `sorted(...)` list, and `_equal` on + # lists is order-sensitive -- so an independent review found `data_scope_eq: [phi, + # internal]` never matching, silently, while `[internal, phi]` did. An operator + # writing the labels in the order their own `data:` map declares them gets a rule + # that never fires, with no warning: the key splits, the subject is present, and + # `matches` simply returns `False` and falls through to whatever is below. Where + # the rule was the `deny` or `approve`, that is fail-open. + # + # Narrowed to `DERIVED_SUBJECTS` for exactly the reason `_in` below is: an ordinary + # list-valued argument means *this list*, and `value_eq: [1, 2]` against `[2, 1]` + # must stay false. This branch is one line away from the one that regressed when it + # was written too wide, and it is written narrow for the same reason. + if not isinstance(value, list | tuple) or not isinstance(self.operand, list | tuple): + return ( + _equal(value, self.operand) + if self.op == "eq" + else not _equal(value, self.operand) + ) + same = frozenset(value) == frozenset(self.operand) + return same if self.op == "eq" else not same + if self.op == "eq": + return _equal(value, self.operand) + if self.op == "neq": + return not _equal(value, self.operand) + if self.op == "in": + if self.argument in DERIVED_SUBJECTS and isinstance(value, list | tuple): + # SPEC-v0.6 §7.4 — a **derived, set-valued** subject intersects the list, which + # is the membership `_in` already expresses one element at a time. §7.4 adds no + # operator for it: `contains` and `not_in` would land in `_OPERATORS`, which + # authority `constraints:` share (`v0.3 §4.5` -- one implementation, not two), + # and §11 puts matching a grant on a data label out of scope. + # + # **Narrowed to derived subjects, and the first version was not.** Testing every + # list-valued subject changed `_in` for ordinary arguments: `value_in: [[1, 2]]` + # against `value = [1, 2]` means *this exact list is one of the operands* and has + # since v0.1, and intersecting broke it. A rule about a new subject may not + # quietly re-mean an operator for the old ones. + return any(_equal(item, candidate) for item in value for candidate in self.operand) + return any(_equal(value, item) for item in self.operand) + if not _is_int(value): + _LOG.warning( + "%s: condition %s ignored: argument %r is %s, not int", + action_name, + self.key, + self.argument, + _type_name(value), + ) + return False + return _NUMERIC_COMPARE[self.op](value, self.operand) + + +class _StrictLoader(yaml.SafeLoader): # type: ignore[misc] # PyYAML ships no stubs + """`yaml.SafeLoader` that refuses a repeated mapping key instead of resolving it. + + YAML says a duplicated key is an error and PyYAML resolves it to the last one anyway, + silently. That is a fail-**open** in the authority document: a grant written as + + actions: ["payments.refund"] + actions: ["**"] + + -- the shape of a half-finished narrowing edit -- loads as `("**",)` with no warning, and + `ctrlrun verify` reads this same loader, so nothing downstream catches it either. Every + other mistake in these documents is refused, the key sets being closed at every level, so + a clean load reads as "the document is what I meant". + + The node carries the line, which is exactly what the message needs. + """ + + def construct_mapping( + self, + node: Any, # noqa: ANN401 - PyYAML's node type, and PyYAML ships no stubs + deep: bool = False, + ) -> dict[Any, Any]: + seen: set[Any] = set() + for key_node, _ in node.value: + key = self.construct_object(key_node, deep=True) + try: + duplicate = key in seen + except TypeError: # an unhashable key; the base class refuses it below + continue + if duplicate: + mark = key_node.start_mark + raise yaml.constructor.ConstructorError( + None, + None, + f"duplicate key {key!r} on line {mark.line + 1}, column {mark.column + 1}", + mark, + ) + seen.add(key) + return super().construct_mapping(node, deep) # type: ignore[no-any-return] + + +def strict_load(text: str, source: str) -> Any: # noqa: ANN401 - any YAML scalar or node + """`yaml.safe_load`, refusing a repeated key. The one loader for every CTRLRun document. + + **`yaml.YAMLError` is not the whole contract.** PyYAML converts a scalar before it has + decided the document is well formed, and three conversions raise the interpreter's own + exception rather than a `YAMLError`: + + - `"\\U0001f600"` with too many digits overflows converting the codepoint to a C int, + which is `OverflowError`. The fuzzer found this one after 174,380 executions; + - `"\\U00110000"` is a legal-looking escape above the Unicode maximum, and `chr()` says + `ValueError`; + - `2026-99-99` is a `ValueError` from `datetime`, and is the one that matters: a mistyped + date is a thing a person writes in a real policy, not a thing a fuzzer invents. + + Each was a crash where the caller was promised a refusal, and `Policy.from_yaml` says + "anything malformed raises `PolicyError`" without qualification. The document is refused + either way, so nothing unsafe was ever admitted; what leaked was the exception type, and a + caller that catches `PolicyError` around a policy load would not have caught these. + + `ValueError` and `OverflowError` are therefore refusals too. The catch is deliberately not + `Exception`: this function calls one thing, so a `MemoryError` or a `KeyboardInterrupt` + here is not the document's fault and must not be reported as one. `RecursionError` is left + uncaught for the same reason, and `fuzz/properties.py` says so where it excludes it. + """ + try: + # `_StrictLoader` derives from `SafeLoader`, so this constructs no arbitrary object. + return yaml.load(text, Loader=_StrictLoader) + except yaml.YAMLError as exc: + raise PolicyError(f"{source}: not valid YAML: {exc}") from exc + except (ValueError, OverflowError) as exc: + raise PolicyError(f"{source}: not valid YAML: {type(exc).__name__}: {exc}") from exc + + +def require_v3(document: Mapping[Any, Any], schema: str, source: str) -> None: + """Refuse a `ctrlrun.policy/v3` key in an older document (SPEC-v0.3 §12.1). + + Shared with `authority.py`, which reads the same key from a document the policy loader + may never see: SPEC-v0.3 §8.3's `--authority` file carries `schema` and `authority` and + nothing else, so the check has to exist on both paths rather than on whichever runs first. + """ + # v4 is a superset: a `v4` document may use every `v3` key. Comparing for equality here was + # right while v3 was the newest and becomes a bug the moment it is not. SPEC-v0.7 §5.3: the + # membership test that replaced it had the same shape, so `v5` reads it through `_at_least`. + if _at_least(schema, POLICY_SCHEMA_V3): + return + for key, consequence in _V3_TOP_LEVEL_KEYS.items(): + if key in document: + raise PolicyError( + f"{source}: {key!r} needs 'schema: {POLICY_SCHEMA_V3}'; this document " + f"declares {schema!r}, and {consequence}" + ) + + +#: SPEC-v0.9 §10.1 — the grant-entry keys that need `ctrlrun.policy/v7`, and what an older +#: reader would do with each. Both are authorization dimensions, so both fail the same way: an +#: older reader ignores the key and grants more than the document says. +_V7_GRANT_KEYS: Final[Mapping[str, str]] = { + "tasks": ( + "an older reader would ignore the binding and authorise the grant on every task, which " + "is the whole of what the key restricts" + ), + "budgets": ( + "an older reader would ignore the limit and let the grant spend without bound, which is " + "the whole of what the key restricts" + ), +} + + +def require_v7(document: Mapping[Any, Any], schema: str, source: str) -> None: + """Refuse a `ctrlrun.policy/v7` grant key in an older document (SPEC-v0.9 §10.1). + + `require_v3`'s shape, two versions up, and shared with `authority.py` for the same reason: + `SPEC-v0.3 §8.3`'s `--authority` file carries `schema` and `authority` and nothing else, so + a gate that lived only in the policy loader would not run on that path at all. The keys are + one level deeper than v3's and v4's, hence the walk rather than a membership test. + """ + if _at_least(schema, POLICY_SCHEMA_V7): + return + section = document.get("authority") + if not isinstance(section, Mapping): + return + entries: list[Any] = [] + grants = section.get("grants") + if isinstance(grants, list): + entries.extend(grants) + elif isinstance(grants, Mapping): + entries.extend(grants.values()) + envelopes = section.get("break_glass") + if isinstance(envelopes, Mapping): + entries.extend(envelopes.values()) + for entry in entries: + if not isinstance(entry, Mapping): + continue + for key, consequence in _V7_GRANT_KEYS.items(): + if key in entry: + raise PolicyError( + f"{source}: {key!r} on a grant needs 'schema: {POLICY_SCHEMA_V7}'; this " + f"document declares {schema!r}, and {consequence}" + ) + + +def reject_nested_mode(mapping: Mapping[Any, Any], where: str) -> None: + """Refuse a `mode:` anywhere but the top level of the policy document (SPEC-v0.3 §6.1). + + The closed key sets of `v0.1 §3.1` would already refuse it as unknown, wherever they + reach. This runs first and for its *message*: "unknown key 'mode'" reads as "CTRLRun has + no such setting", and the author who wrote it here believes they have observed one action + while enforcing the rest. A partially-enforced configuration is the failure mode the + top-level-only rule exists to prevent, so the error says which rule was broken. + + Shared with `authority.py`, which owns the two nestings inside an `authority:` section and + parses documents the policy loader never reads (§4.8). + """ + if MODE_KEY in mapping: + raise PolicyError( + f"{where}: 'mode' is top level and nothing else (SPEC-v0.3 §6.1). A configuration " + "where some actions are observed and some are enforced is one where nobody can say " + "whether an action was permitted or merely watched; move it beside 'schema:'" + ) + + +def parse_conditions( + mapping: Mapping[Any, Any], *, where: str, allow_derived: bool = False +) -> Mapping[str, Condition]: + """Parse a `when:`-shaped mapping into conditions, keyed by the raw condition key. + + Public since SPEC-v0.3 §11: a grant's `constraints:` is in exactly this syntax and MUST be + parsed by this code (§4.5). The key is injective given §3.2's longest-suffix split, which + is what lets `Grant`'s containment check look a dimension up by name. + + `allow_derived` admits §7.4's derived subjects and **defaults to off**, so `authority.py` -- + which calls this without it — sees exactly the surface it saw in v0.3. A grant naming + `data_scope` is refused as it always was, which is what keeps §11's *"matching a grant on a + data label"* out of v0.6 rather than letting it in through a shared parser. + """ + conditions: dict[str, Condition] = {} + for key, operand in mapping.items(): + condition = _parse_condition(key, operand, where, allow_derived) + conditions[condition.key] = condition + return conditions + + +def _parse_condition( + key: object, operand: object, where: str, allow_derived: bool = False +) -> Condition: + if not isinstance(key, str): + raise PolicyError(f"{where}: condition keys must be strings, got {key!r}") + argument, op = _split_condition_key(key, where, allow_derived) + return Condition( + key=key, + argument=argument, + op=op, + operand=_parse_operand(op, operand, where, key), + ) + + +def _split_condition_key(key: str, where: str, allow_derived: bool = False) -> tuple[str, str]: + for op in _OPERATORS_BY_LENGTH: + suffix = f"_{op}" + if key.endswith(suffix) and len(key) > len(suffix): + argument = key[: -len(suffix)] + if argument in DERIVED_SUBJECTS and allow_derived: + return argument, op + if argument in DERIVED_SUBJECTS: + # Reached only where derived subjects are not admitted -- an authority + # `constraints:` mapping. §11 puts *"matching a grant on a data label"* out of + # v0.6, and the message says which surface refused it rather than claiming the + # name is an `Action` field, which `data_scope` is not. + raise PolicyError( + f"{where}: condition {key!r} addresses {argument!r}, which a policy rule may " + f"see and a grant may not. Matching a grant on {argument!r} is not in v0.6; " + "write the rule in the policy instead." + ) + if argument in RESERVED_ARGUMENTS: + raise PolicyError( + f"{where}: condition {key!r} names the Action field {argument!r}, not an " + "argument; a v0.1 condition can only address the action's arguments, so " + "this rule would never match. If the protected function really does take " + f"an argument called {argument!r}, rename it." + ) + return argument, op + raise PolicyError( + f"{where}: condition {key!r} must be '_' where op is one of " + f"{', '.join(sorted(_OPERATORS))}" + ) + + +def _parse_operand(op: str, operand: object, where: str, key: str) -> object: + if op in _NUMERIC_COMPARE: + if not _is_int(operand): + # SPEC-v0.3 §4.5 — the message names the representation rule, because the operator + # who wrote `amount_lte: "2000.00"` has hit a real limit and not a typo: only + # integer arguments can be bounded, so a deployment representing money as decimal + # strings cannot express an amount ceiling in a grant at all. + raise PolicyError( + f"{where}: condition {key!r}: a numeric operator needs an int operand, " + f"got {_type_name(operand)}. Only integers can be bounded, so an amount that " + "a rule or a grant compares is written in integer minor units " + "(amount_lte: 200000), never as a decimal string" + ) + return operand + if op == "in": + if not isinstance(operand, list): + raise PolicyError( + f"{where}: condition {key!r}: '_in' needs a list operand, got {_type_name(operand)}" + ) + return tuple(_checked_operand(item, where, key) for item in operand) + checked = _checked_operand(operand, where, key) + if op in {"eq", "neq"} and isinstance(checked, list | tuple): + # A derived, set-valued subject is compared with `frozenset(...)`, so every element + # has to be hashable. `data_scope_eq: [[phi]]` used to load clean and then raise + # `TypeError: unhashable type: 'list'` on every evaluation of the action -- out of + # `Control.execute`, and not as a `CTRLRunError`, so an application catching the + # kernel's own errors did not catch it. Refuse here, where the message can name the + # condition and the operator can find the line. + for item in checked: + if isinstance(item, list | tuple | Mapping): + raise PolicyError( + f"{where}: condition {key!r}: a set-valued operand holds strings, " + f"got {_type_name(item)}. Write the labels as a flat list " + "(data_scope_eq: [phi, pci]), not nested" + ) + return checked + + +def _checked_operand(operand: object, where: str, key: str) -> object: + """Validate an operand against the argument types allowed by SPEC-v0.1 §2.3.""" + if isinstance(operand, float): + raise PolicyError( + f"{where}: condition {key!r}: float operands are not allowed; use integer minor " + "units (amount_lte: 50000) or a decimal string" + ) + if operand is None or isinstance(operand, str | int): # bool is a subclass of int + return operand + if isinstance(operand, Mapping): + for name in operand: + if not isinstance(name, str): + raise PolicyError( + f"{where}: condition {key!r}: operand keys must be strings, got {name!r}" + ) + return {name: _checked_operand(value, where, key) for name, value in operand.items()} + if isinstance(operand, list): + return [_checked_operand(item, where, key) for item in operand] + raise PolicyError( + f"{where}: condition {key!r}: {_type_name(operand)} is not an allowed operand type" + ) diff --git a/src/ctrlrun/policy.py b/src/ctrlrun/policy.py index 5d495cc9..4e7848fe 100644 --- a/src/ctrlrun/policy.py +++ b/src/ctrlrun/policy.py @@ -14,7 +14,6 @@ import hashlib import logging -import operator import os import re import unicodedata @@ -29,85 +28,53 @@ import yaml from .action import Action, PlainValue, canonical_bytes - -# Re-exported, not merely used. `SPEC-v0.1.md` §8 freezes `from .policy import Decision, Policy` -# in `__init__.py`, and `adapter.py` imports `Decision` from here too. Both names moved down to -# break the module cycle §6 records, and both still resolve from this module because that block -# is a frozen public surface and a cycle is not a reason to move a published import path. from .decision import POLICY_UNAPPROVED as POLICY_UNAPPROVED from .decision import Decision as Decision from .effect import template_placeholders from .errors import InvalidArgument, PolicyError +from .grammar import _NUMERIC_COMPARE as _NUMERIC_COMPARE +from .grammar import _OPERATORS as _OPERATORS +from .grammar import _OPERATORS_BY_LENGTH as _OPERATORS_BY_LENGTH +from .grammar import _V3_TOP_LEVEL_KEYS as _V3_TOP_LEVEL_KEYS +from .grammar import _V7_GRANT_KEYS as _V7_GRANT_KEYS -#: The schema `ctrlrun init` writes, and the one every v0.1 file declares. -POLICY_SCHEMA: Final = "ctrlrun.policy/v1" - -#: SPEC-v0.2 §3.1 — required by any document using `effect:`, `resource:` or `mcp:`. A v2 -#: file fails to load on v0.1, correctly: v0.1 would ignore the effect template and execute -#: with no duplicate protection at all. The schema string is the only thing standing between -#: those two outcomes, so it is not optional and not inferred. -POLICY_SCHEMA_V2: Final = "ctrlrun.policy/v2" - -#: SPEC-v0.3 §12.1 — required by any document using `environment:`, and later by `authority:` -#: and `mode:`. A v3 key in an older document is a load error naming the key and the schema, -#: for the reason v0.2 gives: a reader that ignored it would run with a guarantee switched off. -POLICY_SCHEMA_V3: Final = "ctrlrun.policy/v3" - -#: SPEC-v0.6 §7.1, §9.5 — required by any document using `version:`, `controls:` or `data:`. -#: `v1`, `v2` and `v3` documents load unchanged and get a `policy_hash` like any other; only -#: those three keys need `v4`. -POLICY_SCHEMA_V4: Final = "ctrlrun.policy/v4" - -#: SPEC-v0.7 §5.3 — required by any document using `max_attempts:`. An 0.6.1 reader refuses a -#: `v5` document outright, which is the fail-closed direction: a reader that ignored the key -#: would renew without a ceiling, which is the behaviour the key exists to bound. -POLICY_SCHEMA_V5: Final = "ctrlrun.policy/v5" - -#: SPEC-v0.8 §11.3: `v6` adds `approver_role` on a control entry, and items 4 and 5 add -#: their keys under it. The version moves once, here, for the reason §11.4 gives: an older -#: reader must refuse a document whose keys it would otherwise ignore, and a reader that -#: ignored `approver_role` would run a deployment believing nobody was gated. -POLICY_SCHEMA_V6: Final = "ctrlrun.policy/v6" - -#: SPEC-v0.9 §10.1: `v7` adds `tasks` on a grant (item 1) and `budgets` on one (item 3). The -#: version moves once, here, with item 1, and item 3 fills it under the version already in -#: place: two branches racing a schema bump is how a catalogue ends up with a stub row. -POLICY_SCHEMA_V7: Final = "ctrlrun.policy/v7" -#: SPEC-v0.10 §4.6 — `v8` adds one action-entry key, `upstream:`. Bumped once, by item 3. -POLICY_SCHEMA_V8: Final = "ctrlrun.policy/v8" - -#: All of them, newest last, for the message an unknown schema produces. **In version order**, -#: which `_at_least` reads: a version added out of order would make every gate below lie. -SUPPORTED_SCHEMAS: Final = ( - POLICY_SCHEMA, - POLICY_SCHEMA_V2, - POLICY_SCHEMA_V3, - POLICY_SCHEMA_V4, - POLICY_SCHEMA_V5, - POLICY_SCHEMA_V6, - POLICY_SCHEMA_V7, - POLICY_SCHEMA_V8, -) - - -def _at_least(schema: str, minimum: str) -> bool: - """Whether `schema` is `minimum` or a later version (SPEC-v0.7 §5.3). - - Each version is a **superset** of the one before: a `v5` document may use every key any - earlier version allows. Three gates in this module compared for equality instead, which was - right while `v4` was the newest and became wrong the moment it was not; `require_v3`'s own - comment predicted it. An unknown schema is refused before any of them runs, so a name that - is not in `SUPPORTED_SCHEMAS` cannot reach here from `Policy._from_document`; where one does, - from `authority.py`'s standalone path, it is treated as too old, which is fail-closed. - """ - if schema not in SUPPORTED_SCHEMAS or minimum not in SUPPORTED_SCHEMAS: - return False - return SUPPORTED_SCHEMAS.index(schema) >= SUPPORTED_SCHEMAS.index(minimum) - +# Re-exported, not merely used. `SPEC-v0.1.md` §8 freezes `from .policy import Decision, Policy` +# in `__init__.py`, and `adapter.py` imports `Decision` from here too. Both names moved down to +# break the module cycle §6 records, and both still resolve from this module because that block +# is a frozen public surface and a cycle is not a reason to move a published import path. +# Re-exported, not merely used. `SPEC-v0.3.md` §8 freezes +# `from .policy import Condition, parse_conditions`, and `SPEC-v0.1.md` §8 the `__init__` block, +# so every one of these keeps resolving from here. They moved to `grammar.py` to break the last +# cycle §6 carried an exception for; see that module. +from .grammar import DERIVED_SUBJECTS as DERIVED_SUBJECTS +from .grammar import MODE_KEY as MODE_KEY +from .grammar import POLICY_SCHEMA as POLICY_SCHEMA +from .grammar import POLICY_SCHEMA_V2 as POLICY_SCHEMA_V2 +from .grammar import POLICY_SCHEMA_V3 as POLICY_SCHEMA_V3 +from .grammar import POLICY_SCHEMA_V4 as POLICY_SCHEMA_V4 +from .grammar import POLICY_SCHEMA_V5 as POLICY_SCHEMA_V5 +from .grammar import POLICY_SCHEMA_V6 as POLICY_SCHEMA_V6 +from .grammar import POLICY_SCHEMA_V7 as POLICY_SCHEMA_V7 +from .grammar import POLICY_SCHEMA_V8 as POLICY_SCHEMA_V8 +from .grammar import RESERVED_ARGUMENTS as RESERVED_ARGUMENTS +from .grammar import SUPPORTED_SCHEMAS as SUPPORTED_SCHEMAS +from .grammar import Condition as Condition +from .grammar import _at_least as _at_least +from .grammar import _checked_operand as _checked_operand +from .grammar import _equal as _equal +from .grammar import _is_container as _is_container +from .grammar import _is_int as _is_int +from .grammar import _parse_condition as _parse_condition +from .grammar import _parse_operand as _parse_operand +from .grammar import _split_condition_key as _split_condition_key +from .grammar import _StrictLoader as _StrictLoader +from .grammar import _type_name as _type_name +from .grammar import parse_conditions as parse_conditions +from .grammar import reject_nested_mode as reject_nested_mode +from .grammar import require_v3 as require_v3 +from .grammar import require_v7 as require_v7 +from .grammar import strict_load as strict_load -#: SPEC-v0.3 §6.1 — the two values of the top-level `mode:` key, and nothing else. Absent -#: means `enforce`: the fail-closed default, so a document that predates the key enforces. -MODE_KEY: Final = "mode" OBSERVE: Final = "observe" ENFORCE: Final = "enforce" POLICY_MODES: Final = (OBSERVE, ENFORCE) @@ -122,15 +89,6 @@ def _at_least(schema: str, minimum: str) -> bool: _LOG = logging.getLogger(__name__) -_NUMERIC_COMPARE: Final[Mapping[str, Callable[[int, int], bool]]] = { - "lt": operator.lt, - "lte": operator.le, - "gt": operator.gt, - "gte": operator.ge, -} -_OPERATORS: Final = ("eq", "neq", "in", *_NUMERIC_COMPARE) -#: Longest first, so `amount_neq` reads as (amount, neq) and never as (amount_n, eq). -_OPERATORS_BY_LENGTH: Final = tuple(sorted(_OPERATORS, key=len, reverse=True)) _TOP_LEVEL_KEYS: Final = frozenset( {"schema", "actions", "environment", "authority", "mode", "version", "controls"} @@ -154,15 +112,6 @@ def _at_least(schema: str, minimum: str) -> bool: ), } -#: SPEC-v0.3 §12.1 — the top-level keys that need `ctrlrun.policy/v3`, and what an older -#: reader would do with each if it ignored one. -_V3_TOP_LEVEL_KEYS: Final[Mapping[str, str]] = { - "environment": ("an older reader would ignore it and put every action in the wrong deployment"), - "authority": ( - "an older reader would ignore it and run every action with no authority check at all" - ), - "mode": ("an older reader would enforce a configuration that was deployed to observe"), -} #: §7.4 — the action-entry keys that need `ctrlrun.policy/v4`, and what an older reader would do. _V4_ENTRY_KEYS: Final[Mapping[str, str]] = { "data": ( @@ -250,35 +199,6 @@ def _at_least(schema: str, minimum: str) -> bool: #: needs it and dependencies point downward: `policy.py` does not import `control.py`. DEFAULT_ENVIRONMENT: Final = "production" -RESERVED_ARGUMENTS: Final = frozenset( - { - "action_id", - "agent", - "claims", - "data_scope", - "environment", - "expires_at", - "issuer", - "principal", - "resource", - "user", - } -) - -#: SPEC-v0.6 §7.4 — names refused as **arguments** and permitted as **condition subjects**, -#: resolved at evaluation from something other than `action.canonical_arguments`. -#: -#: The distinction is what makes `data_scope` implementable at all. Today one check does both -#: jobs: the splitter refuses a condition whose subject is in `RESERVED_ARGUMENTS`, which is how -#: `claims_eq:` becomes a load error. Adding `data_scope` to that set unchanged would have made -#: `data_scope_in:` a load error too -- the very condition §7.4 asks operators to write. -#: -#: **A name here is still refused as an argument**, so one name never means two things in one -#: document. And this list is the *policy evaluator's*: authority `constraints:` do not consult -#: it, so a grant naming `data_scope` is refused exactly as it always was (§11 puts matching a -#: grant on a data label out of scope). -DERIVED_SUBJECTS: Final = frozenset({"data_scope"}) - def _refuse_reserved(names: Iterable[str], where: str, what: str) -> None: """Refuse a **derived** subject used as an argument name, wherever an argument is named. @@ -351,123 +271,6 @@ class Evaluation: controls: tuple[str, ...] = () -def _is_int(value: object) -> bool: - """True for a real int. `bool` subclasses int in Python; SPEC-v0.1 §3.2 excludes it.""" - return isinstance(value, int) and not isinstance(value, bool) - - -def _type_name(value: object) -> str: - return type(value).__name__ - - -def _equal(value: object, operand: object) -> bool: - """Type-strict equality: `True` never equals `1`, and a list never equals a scalar. - - SPEC: §3.2 — equality is type-strict and applies recursively inside containers. - Canonical arguments distinguish bool from int (§2.3), so conditions must too, or a - policy written for `1` would match `True`. - """ - if isinstance(value, bool) or isinstance(operand, bool): - return value is operand - if isinstance(value, Mapping) and isinstance(operand, Mapping): - return value.keys() == operand.keys() and all( - _equal(value[key], operand[key]) for key in value - ) - if isinstance(value, list | tuple) and isinstance(operand, list | tuple): - return len(value) == len(operand) and all( - _equal(item, other) for item, other in zip(value, operand, strict=True) - ) - if _is_container(value) or _is_container(operand): - return False - return bool(value == operand) - - -def _is_container(value: object) -> bool: - return isinstance(value, Mapping | list | tuple) - - -@dataclass(frozen=True) -class Condition: - """One `_: operand` test against an action's arguments (SPEC-v0.1 §3.2). - - Public since SPEC-v0.3 §11, because a `Grant`'s constraints are made of them and the two - axes share one evaluator: a second implementation would be a second place for `True` to - start comparing equal to `1`. `key` is the raw condition key the author wrote. - """ - - key: str - argument: str - op: str - operand: Any - - def matches(self, action_name: str, arguments: Mapping[str, Any]) -> bool: - if self.argument not in arguments: - # SPEC §3.2 — still false, never an error, but never silent either. Defaults are - # applied when a call is bound (§8), so an argument is either always present or - # never: an absent one is a typo, and silence let a mistyped rule disappear into - # a catch-all below it. - _LOG.warning( - "%s: condition %s ignored: the action has no argument %r (it has: %s)", - action_name, - self.key, - self.argument, - ", ".join(sorted(arguments)) or "none", - ) - return False - value = arguments[self.argument] - if self.op in {"eq", "neq"} and self.argument in DERIVED_SUBJECTS: - # SPEC-v0.6 §7.4: *"`_eq` and `_neq` compare the whole set."* **The whole set, and - # a set has no order.** The derived value is a `sorted(...)` list, and `_equal` on - # lists is order-sensitive -- so an independent review found `data_scope_eq: [phi, - # internal]` never matching, silently, while `[internal, phi]` did. An operator - # writing the labels in the order their own `data:` map declares them gets a rule - # that never fires, with no warning: the key splits, the subject is present, and - # `matches` simply returns `False` and falls through to whatever is below. Where - # the rule was the `deny` or `approve`, that is fail-open. - # - # Narrowed to `DERIVED_SUBJECTS` for exactly the reason `_in` below is: an ordinary - # list-valued argument means *this list*, and `value_eq: [1, 2]` against `[2, 1]` - # must stay false. This branch is one line away from the one that regressed when it - # was written too wide, and it is written narrow for the same reason. - if not isinstance(value, list | tuple) or not isinstance(self.operand, list | tuple): - return ( - _equal(value, self.operand) - if self.op == "eq" - else not _equal(value, self.operand) - ) - same = frozenset(value) == frozenset(self.operand) - return same if self.op == "eq" else not same - if self.op == "eq": - return _equal(value, self.operand) - if self.op == "neq": - return not _equal(value, self.operand) - if self.op == "in": - if self.argument in DERIVED_SUBJECTS and isinstance(value, list | tuple): - # SPEC-v0.6 §7.4 — a **derived, set-valued** subject intersects the list, which - # is the membership `_in` already expresses one element at a time. §7.4 adds no - # operator for it: `contains` and `not_in` would land in `_OPERATORS`, which - # authority `constraints:` share (`v0.3 §4.5` -- one implementation, not two), - # and §11 puts matching a grant on a data label out of scope. - # - # **Narrowed to derived subjects, and the first version was not.** Testing every - # list-valued subject changed `_in` for ordinary arguments: `value_in: [[1, 2]]` - # against `value = [1, 2]` means *this exact list is one of the operands* and has - # since v0.1, and intersecting broke it. A rule about a new subject may not - # quietly re-mean an operator for the old ones. - return any(_equal(item, candidate) for item in value for candidate in self.operand) - return any(_equal(value, item) for item in self.operand) - if not _is_int(value): - _LOG.warning( - "%s: condition %s ignored: argument %r is %s, not int", - action_name, - self.key, - self.argument, - _type_name(value), - ) - return False - return _NUMERIC_COMPARE[self.op](value, self.operand) - - @dataclass(frozen=True) class _Rule: decision: Decision @@ -673,80 +476,6 @@ def evaluate( return Evaluation(Decision.DENY, NO_MATCHING_RULE, _in_registry_order(self.controls, order)) -class _StrictLoader(yaml.SafeLoader): # type: ignore[misc] # PyYAML ships no stubs - """`yaml.SafeLoader` that refuses a repeated mapping key instead of resolving it. - - YAML says a duplicated key is an error and PyYAML resolves it to the last one anyway, - silently. That is a fail-**open** in the authority document: a grant written as - - actions: ["payments.refund"] - actions: ["**"] - - -- the shape of a half-finished narrowing edit -- loads as `("**",)` with no warning, and - `ctrlrun verify` reads this same loader, so nothing downstream catches it either. Every - other mistake in these documents is refused, the key sets being closed at every level, so - a clean load reads as "the document is what I meant". - - The node carries the line, which is exactly what the message needs. - """ - - def construct_mapping( - self, - node: Any, # noqa: ANN401 - PyYAML's node type, and PyYAML ships no stubs - deep: bool = False, - ) -> dict[Any, Any]: - seen: set[Any] = set() - for key_node, _ in node.value: - key = self.construct_object(key_node, deep=True) - try: - duplicate = key in seen - except TypeError: # an unhashable key; the base class refuses it below - continue - if duplicate: - mark = key_node.start_mark - raise yaml.constructor.ConstructorError( - None, - None, - f"duplicate key {key!r} on line {mark.line + 1}, column {mark.column + 1}", - mark, - ) - seen.add(key) - return super().construct_mapping(node, deep) # type: ignore[no-any-return] - - -def strict_load(text: str, source: str) -> Any: # noqa: ANN401 - any YAML scalar or node - """`yaml.safe_load`, refusing a repeated key. The one loader for every CTRLRun document. - - **`yaml.YAMLError` is not the whole contract.** PyYAML converts a scalar before it has - decided the document is well formed, and three conversions raise the interpreter's own - exception rather than a `YAMLError`: - - - `"\\U0001f600"` with too many digits overflows converting the codepoint to a C int, - which is `OverflowError`. The fuzzer found this one after 174,380 executions; - - `"\\U00110000"` is a legal-looking escape above the Unicode maximum, and `chr()` says - `ValueError`; - - `2026-99-99` is a `ValueError` from `datetime`, and is the one that matters: a mistyped - date is a thing a person writes in a real policy, not a thing a fuzzer invents. - - Each was a crash where the caller was promised a refusal, and `Policy.from_yaml` says - "anything malformed raises `PolicyError`" without qualification. The document is refused - either way, so nothing unsafe was ever admitted; what leaked was the exception type, and a - caller that catches `PolicyError` around a policy load would not have caught these. - - `ValueError` and `OverflowError` are therefore refusals too. The catch is deliberately not - `Exception`: this function calls one thing, so a `MemoryError` or a `KeyboardInterrupt` - here is not the document's fault and must not be reported as one. `RecursionError` is left - uncaught for the same reason, and `fuzz/properties.py` says so where it excludes it. - """ - try: - # `_StrictLoader` derives from `SafeLoader`, so this constructs no arbitrary object. - return yaml.load(text, Loader=_StrictLoader) - except yaml.YAMLError as exc: - raise PolicyError(f"{source}: not valid YAML: {exc}") from exc - except (ValueError, OverflowError) as exc: - raise PolicyError(f"{source}: not valid YAML: {type(exc).__name__}: {exc}") from exc - - @dataclass(frozen=True) class Policy: """Action-level autonomy policy: which actions may run, and under which conditions. @@ -1188,26 +917,6 @@ def _plain(value: object) -> PlainValue: ) -def require_v3(document: Mapping[Any, Any], schema: str, source: str) -> None: - """Refuse a `ctrlrun.policy/v3` key in an older document (SPEC-v0.3 §12.1). - - Shared with `authority.py`, which reads the same key from a document the policy loader - may never see: SPEC-v0.3 §8.3's `--authority` file carries `schema` and `authority` and - nothing else, so the check has to exist on both paths rather than on whichever runs first. - """ - # v4 is a superset: a `v4` document may use every `v3` key. Comparing for equality here was - # right while v3 was the newest and becomes a bug the moment it is not. SPEC-v0.7 §5.3: the - # membership test that replaced it had the same shape, so `v5` reads it through `_at_least`. - if _at_least(schema, POLICY_SCHEMA_V3): - return - for key, consequence in _V3_TOP_LEVEL_KEYS.items(): - if key in document: - raise PolicyError( - f"{source}: {key!r} needs 'schema: {POLICY_SCHEMA_V3}'; this document " - f"declares {schema!r}, and {consequence}" - ) - - def require_v4(document: Mapping[Any, Any], schema: str, source: str) -> None: """Refuse a `ctrlrun.policy/v4` key in an older document (SPEC-v0.6 §7.1). @@ -1227,54 +936,6 @@ def require_v4(document: Mapping[Any, Any], schema: str, source: str) -> None: ) -#: SPEC-v0.9 §10.1 — the grant-entry keys that need `ctrlrun.policy/v7`, and what an older -#: reader would do with each. Both are authorization dimensions, so both fail the same way: an -#: older reader ignores the key and grants more than the document says. -_V7_GRANT_KEYS: Final[Mapping[str, str]] = { - "tasks": ( - "an older reader would ignore the binding and authorise the grant on every task, which " - "is the whole of what the key restricts" - ), - "budgets": ( - "an older reader would ignore the limit and let the grant spend without bound, which is " - "the whole of what the key restricts" - ), -} - - -def require_v7(document: Mapping[Any, Any], schema: str, source: str) -> None: - """Refuse a `ctrlrun.policy/v7` grant key in an older document (SPEC-v0.9 §10.1). - - `require_v3`'s shape, two versions up, and shared with `authority.py` for the same reason: - `SPEC-v0.3 §8.3`'s `--authority` file carries `schema` and `authority` and nothing else, so - a gate that lived only in the policy loader would not run on that path at all. The keys are - one level deeper than v3's and v4's, hence the walk rather than a membership test. - """ - if _at_least(schema, POLICY_SCHEMA_V7): - return - section = document.get("authority") - if not isinstance(section, Mapping): - return - entries: list[Any] = [] - grants = section.get("grants") - if isinstance(grants, list): - entries.extend(grants) - elif isinstance(grants, Mapping): - entries.extend(grants.values()) - envelopes = section.get("break_glass") - if isinstance(envelopes, Mapping): - entries.extend(envelopes.values()) - for entry in entries: - if not isinstance(entry, Mapping): - continue - for key, consequence in _V7_GRANT_KEYS.items(): - if key in entry: - raise PolicyError( - f"{source}: {key!r} on a grant needs 'schema: {POLICY_SCHEMA_V7}'; this " - f"document declares {schema!r}, and {consequence}" - ) - - def _parse_mode(document: Mapping[Any, Any], source: str) -> Literal["observe", "enforce"]: """The top-level `mode:`, or the fail-closed default (SPEC-v0.3 §6.1). @@ -1294,47 +955,6 @@ def _parse_mode(document: Mapping[Any, Any], source: str) -> Literal["observe", return OBSERVE if value == OBSERVE else ENFORCE -def reject_nested_mode(mapping: Mapping[Any, Any], where: str) -> None: - """Refuse a `mode:` anywhere but the top level of the policy document (SPEC-v0.3 §6.1). - - The closed key sets of `v0.1 §3.1` would already refuse it as unknown, wherever they - reach. This runs first and for its *message*: "unknown key 'mode'" reads as "CTRLRun has - no such setting", and the author who wrote it here believes they have observed one action - while enforcing the rest. A partially-enforced configuration is the failure mode the - top-level-only rule exists to prevent, so the error says which rule was broken. - - Shared with `authority.py`, which owns the two nestings inside an `authority:` section and - parses documents the policy loader never reads (§4.8). - """ - if MODE_KEY in mapping: - raise PolicyError( - f"{where}: 'mode' is top level and nothing else (SPEC-v0.3 §6.1). A configuration " - "where some actions are observed and some are enforced is one where nobody can say " - "whether an action was permitted or merely watched; move it beside 'schema:'" - ) - - -def parse_conditions( - mapping: Mapping[Any, Any], *, where: str, allow_derived: bool = False -) -> Mapping[str, Condition]: - """Parse a `when:`-shaped mapping into conditions, keyed by the raw condition key. - - Public since SPEC-v0.3 §11: a grant's `constraints:` is in exactly this syntax and MUST be - parsed by this code (§4.5). The key is injective given §3.2's longest-suffix split, which - is what lets `Grant`'s containment check look a dimension up by name. - - `allow_derived` admits §7.4's derived subjects and **defaults to off**, so `authority.py` -- - which calls this without it — sees exactly the surface it saw in v0.3. A grant naming - `data_scope` is refused as it always was, which is what keeps §11's *"matching a grant on a - data label"* out of v0.6 rather than letting it in through a shared parser. - """ - conditions: dict[str, Condition] = {} - for key, operand in mapping.items(): - condition = _parse_condition(key, operand, where, allow_derived) - conditions[condition.key] = condition - return conditions - - def _reject_unknown_keys(mapping: Mapping[Any, Any], allowed: Iterable[str], where: str) -> None: unknown = sorted(repr(key) for key in mapping if key not in set(allowed)) if unknown: @@ -1923,109 +1543,3 @@ def _parse_rule(rule: object, where: str, known: frozenset[str] = frozenset()) - conditions=tuple(parse_conditions(when, where=where, allow_derived=True).values()), controls=cited, ) - - -def _parse_condition( - key: object, operand: object, where: str, allow_derived: bool = False -) -> Condition: - if not isinstance(key, str): - raise PolicyError(f"{where}: condition keys must be strings, got {key!r}") - argument, op = _split_condition_key(key, where, allow_derived) - return Condition( - key=key, - argument=argument, - op=op, - operand=_parse_operand(op, operand, where, key), - ) - - -def _split_condition_key(key: str, where: str, allow_derived: bool = False) -> tuple[str, str]: - for op in _OPERATORS_BY_LENGTH: - suffix = f"_{op}" - if key.endswith(suffix) and len(key) > len(suffix): - argument = key[: -len(suffix)] - if argument in DERIVED_SUBJECTS and allow_derived: - return argument, op - if argument in DERIVED_SUBJECTS: - # Reached only where derived subjects are not admitted -- an authority - # `constraints:` mapping. §11 puts *"matching a grant on a data label"* out of - # v0.6, and the message says which surface refused it rather than claiming the - # name is an `Action` field, which `data_scope` is not. - raise PolicyError( - f"{where}: condition {key!r} addresses {argument!r}, which a policy rule may " - f"see and a grant may not. Matching a grant on {argument!r} is not in v0.6; " - "write the rule in the policy instead." - ) - if argument in RESERVED_ARGUMENTS: - raise PolicyError( - f"{where}: condition {key!r} names the Action field {argument!r}, not an " - "argument; a v0.1 condition can only address the action's arguments, so " - "this rule would never match. If the protected function really does take " - f"an argument called {argument!r}, rename it." - ) - return argument, op - raise PolicyError( - f"{where}: condition {key!r} must be '_' where op is one of " - f"{', '.join(sorted(_OPERATORS))}" - ) - - -def _parse_operand(op: str, operand: object, where: str, key: str) -> object: - if op in _NUMERIC_COMPARE: - if not _is_int(operand): - # SPEC-v0.3 §4.5 — the message names the representation rule, because the operator - # who wrote `amount_lte: "2000.00"` has hit a real limit and not a typo: only - # integer arguments can be bounded, so a deployment representing money as decimal - # strings cannot express an amount ceiling in a grant at all. - raise PolicyError( - f"{where}: condition {key!r}: a numeric operator needs an int operand, " - f"got {_type_name(operand)}. Only integers can be bounded, so an amount that " - "a rule or a grant compares is written in integer minor units " - "(amount_lte: 200000), never as a decimal string" - ) - return operand - if op == "in": - if not isinstance(operand, list): - raise PolicyError( - f"{where}: condition {key!r}: '_in' needs a list operand, got {_type_name(operand)}" - ) - return tuple(_checked_operand(item, where, key) for item in operand) - checked = _checked_operand(operand, where, key) - if op in {"eq", "neq"} and isinstance(checked, list | tuple): - # A derived, set-valued subject is compared with `frozenset(...)`, so every element - # has to be hashable. `data_scope_eq: [[phi]]` used to load clean and then raise - # `TypeError: unhashable type: 'list'` on every evaluation of the action -- out of - # `Control.execute`, and not as a `CTRLRunError`, so an application catching the - # kernel's own errors did not catch it. Refuse here, where the message can name the - # condition and the operator can find the line. - for item in checked: - if isinstance(item, list | tuple | Mapping): - raise PolicyError( - f"{where}: condition {key!r}: a set-valued operand holds strings, " - f"got {_type_name(item)}. Write the labels as a flat list " - "(data_scope_eq: [phi, pci]), not nested" - ) - return checked - - -def _checked_operand(operand: object, where: str, key: str) -> object: - """Validate an operand against the argument types allowed by SPEC-v0.1 §2.3.""" - if isinstance(operand, float): - raise PolicyError( - f"{where}: condition {key!r}: float operands are not allowed; use integer minor " - "units (amount_lte: 50000) or a decimal string" - ) - if operand is None or isinstance(operand, str | int): # bool is a subclass of int - return operand - if isinstance(operand, Mapping): - for name in operand: - if not isinstance(name, str): - raise PolicyError( - f"{where}: condition {key!r}: operand keys must be strings, got {name!r}" - ) - return {name: _checked_operand(value, where, key) for name, value in operand.items()} - if isinstance(operand, list): - return [_checked_operand(item, where, key) for item in operand] - raise PolicyError( - f"{where}: condition {key!r}: {_type_name(operand)} is not an allowed operand type" - ) diff --git a/tests/test_module_graph.py b/tests/test_module_graph.py index bc6b411a..207c0ccb 100644 --- a/tests/test_module_graph.py +++ b/tests/test_module_graph.py @@ -114,26 +114,25 @@ def test_the_import_order_graph_has_no_cycle() -> None: ) -#: The one layering cycle this repository has decided to keep, and the only one. +#: **Empty, and it is meant to stay empty.** #: -#: `authority.py` imports `Condition` and `parse_conditions` from `policy.py` deliberately and at -#: module level: a grant's `constraints:` is a rule's `when:` syntax and `SPEC-v0.3.md` §4.5 says -#: the two axes MUST share one evaluator. §6 records that as an exception. The reverse edge is -#: `policy.py` reaching `authority.py` from inside `_canonical_authority` and -#: `hash_with_authority`, and it cannot be removed by relocation: `_from_section` *constructs* an -#: `Authority` and `canonical_grants` *consumes* one, so neither moves below `policy.py`, and -#: moving the two callers up to `control.py` would change what `policy_hash` is taken over -- -#: which is evidence in every receipt, not an implementation detail. +#: It held `policy <-> authority` for one commit. `authority.py` imports the condition evaluator +#: from `policy.py` deliberately, because SPEC-v0.3 §4.5 requires the two axes to share one: a +#: second evaluator would be a second place for `True` to start comparing equal to `1`. The +#: reverse edge is `policy.py` reaching `authority.py` from `_canonical_authority` and +#: `hash_with_authority`, and neither direction could be removed on its own. #: -#: So it stays, named, until someone decides that question. An allow-list of one fails the moment -#: a second appears, which a plain "no cycles" assertion softened to a skip never would. -RECORDED_LAYERING_CYCLES: frozenset[frozenset[str]] = frozenset( - {frozenset({"policy", "authority"})} -) +#: Moving the **shared** half down to `grammar.py` removed the cycle without touching either. +#: §4.5's requirement is better served than before, because the one evaluator is now owned by +#: neither axis. +#: +#: A new entry here is a decision, not a fix. Add one only with the reason it cannot be +#: relocated, the way that one carried its reason while it stood. +RECORDED_LAYERING_CYCLES: frozenset[frozenset[str]] = frozenset() def test_the_layering_graph_has_only_the_one_recorded_cycle() -> None: - """§6's actual rule, including deferred imports, with one documented exception. + """§6's actual rule, including deferred imports, and there is no exception left. A function-level import is a real edge for the module map and not one for import order, and conflating them is exactly what let `state -> receipt -> policy -> authority -> state` read as diff --git a/tests/test_properties.py b/tests/test_properties.py new file mode 100644 index 00000000..da2fe5f4 --- /dev/null +++ b/tests/test_properties.py @@ -0,0 +1,239 @@ +# SPDX-FileCopyrightText: 2026 The CTRLRun contributors +# SPDX-License-Identifier: Apache-2.0 +"""Invariants stated directly, over inputs nobody chose by hand. + +**Why this file exists, from this repository's own history.** v0.11 item 1 found that +`verify_chain`'s docstring had claimed since v0.6 that position comes from the store's `seq` +column, and it was false: both backends selected `json, hash` and ordered by a column they never +read, so every position came out of the document, which is the half a tamperer controls. One +`UPDATE` setting a document's `seq` to 99 reported `missing 2`, `content_altered 99`, +`missing 100` and `link_broken 3` -- four breaks at three positions, two of them rows that do not +exist. + +That is a **property**: one tamper is one break, at the row that was tampered with. It survived +five milestones of example tests because every example tampered in a way its author had already +thought of, and the author who writes the tamper is the author who writes the expectation. + +`SPEC-v0.11.md` §13.2 says a test passing is not the evidence and running the real thing is. +This is the other half of that: for a claim shaped like *for every X*, the evidence is a +generator rather than a list. + +**Determinism is pinned, and that is not optional here.** A suite that fails once in a while +teaches people to re-run it, and this project's gate is only worth anything while green means +green -- which the cookbook race (#207) had just finished demonstrating. `derandomize=True` makes +each run draw the same inputs, so a red run reproduces from the same command, and +`deadline=None` keeps a busy `-n auto` worker from failing a test for being slow rather than for +being wrong. A counterexample found in CI is reproducible locally by construction. + +The stores here are real SQLite files with real `Control.execute` writes, not fixtures: these are +the same objects the CLI reads. +""" + +from __future__ import annotations + +import json +import sqlite3 +from datetime import UTC, datetime, timedelta +from pathlib import Path + +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from ctrlrun import Control, Policy, SQLiteStateStore +from ctrlrun.action import Action, Principal +from ctrlrun.receipt import CHAIN_BREAKS, Receipt, UnreadableReceipt, verify_chain + +T0 = datetime(2026, 1, 1, 12, 0, tzinfo=UTC) +LEASE = timedelta(minutes=5) +ALLOW = "schema: ctrlrun.policy/v1\nactions:\n stripe.refund:\n decision: allow\n" + +#: Every property here builds a real store, so the example count is deliberately modest: the cost +#: is a SQLite file and N `Control.execute` calls, not a pure function call. `derandomize` is what +#: makes a small count trustworthy -- the same inputs every run, so "it passed" means the same +#: thing twice. +PROFILE = settings( + max_examples=40, + derandomize=True, + deadline=None, + suppress_health_check=[HealthCheck.function_scoped_fixture], +) + + +def _chain(database: Path, count: int) -> SQLiteStateStore: + store = SQLiteStateStore(database, clock=lambda: T0) + control = Control(Policy.from_yaml(ALLOW), store, clock=lambda: T0) + for index in range(count): + control.execute( + Action( + name="stripe.refund", + arguments={"payment_id": f"p{index}", "amount": 1000 + index}, + principal=Principal(agent="a"), + ), + lambda: {"ok": True}, + f"refund:p{index}", + lease=LEASE, + ) + return store + + +# --- the chain reader ------------------------------------------------------------------------- + + +@PROFILE +@given(size=st.integers(min_value=2, max_value=6), where=st.integers(min_value=0, max_value=5)) +def test_an_untouched_chain_of_any_length_verifies(tmp_path_factory, size, where) -> None: + """The positive control, and it has to come first. + + Every property below asserts something about a *tampered* chain. If an untouched chain of + the same shape did not verify, those would all be measuring the wrong thing, and each of + them would still pass. + """ + store = _chain(tmp_path_factory.mktemp("clean") / "state.db", size) + report = verify_chain(store) + store.close() + + assert report.ok, report.breaks + assert report.verified == size + + +@PROFILE +@given( + size=st.integers(min_value=2, max_value=6), + victim=st.integers(min_value=1, max_value=6), + forged=st.integers(min_value=1, max_value=400), +) +def test_every_break_a_tamper_reports_names_a_row_that_exists( + tmp_path_factory, size, victim, forged +) -> None: + """`SPEC-v0.6.md` §6.5, and **the property that actually catches v0.11 item 1's defect**. + + The first version of this test asserted *one tamper is one break*. That is false, and + hypothesis said so on its second example: altering row `n` also breaks the link at `n + 1`, + because `n + 1` carries `prev_hash` over what `n` used to hash to. Two breaks is the correct + answer and the test was wrong, which is worth leaving in the record rather than quietly + fixing, because it is the same mistake in miniature that the guarded property is about -- + an author writing down what they expected instead of what holds. + + What was actually wrong in v0.11 item 1 was not the count. Rewriting one document's `seq` to + 99 on an eight-row chain reported `missing 2`, `content_altered 99`, `missing 100` and + `link_broken 3`: **two of those name rows that do not exist**, because position came from the + document, which is the half a tamperer controls, rather than from the `seq` column. So the + invariant is that every reported position is a row the store actually holds. No arrangement + of a tamperer's chosen values can conjure a break at a row that was never written. + + The bound is asserted too, at two rather than one: a tamper is local, and a reader that + cascaded would be reporting damage the tamperer did not do. + """ + database = tmp_path_factory.mktemp("altered") / "state.db" + store = _chain(database, size) + store.close() + target = victim if victim <= size else size + + connection = sqlite3.connect(database) + stored = connection.execute("SELECT json FROM receipts WHERE seq = ?", (target,)).fetchone()[0] + document = json.loads(stored) + was = document["seq"] + document["seq"] = forged + connection.execute("UPDATE receipts SET json = ? WHERE seq = ?", (json.dumps(document), target)) + connection.commit() + connection.close() + + reopened = SQLiteStateStore(database, clock=lambda: T0) + report = verify_chain(reopened) + reopened.close() + + if forged == was: # writing back the value it already had edits nothing + assert report.ok, report.breaks + return + + assert not report.ok, "a rewritten receipt verified as intact" + positions = {item.seq for item in report.breaks} + assert positions <= set(range(1, size + 1)), ( + f"reported a break at {sorted(positions - set(range(1, size + 1)))}, " + f"and this store holds seq 1..{size}" + ) + assert target in positions, f"edited seq {target}, reported {sorted(positions)}" + assert len(report.breaks) <= 2, f"one local tamper cascaded into {report.breaks}" + assert all(item.name in CHAIN_BREAKS for item in report.breaks) + + +@PROFILE +@given( + size=st.integers(min_value=2, max_value=6), + victim=st.integers(min_value=1, max_value=6), + junk=st.text(min_size=0, max_size=12), +) +def test_one_unreadable_row_costs_exactly_one_row(tmp_path_factory, size, victim, junk) -> None: + """`SPEC-v0.11.md` §5, rule 3: a malformed row names itself and blinds nothing else. + + The generator writes arbitrary text into one row's `json`, which reaches the case the + original tests missed and a review caught: every tamper they ran changed a row's *content*, + and `{}` and a float are both valid JSON, so the parse failure path went unexercised. Here + most drawn strings are not JSON at all. + + What is asserted is the blast radius, which is what the rule is about: exactly one row comes + back unreadable, it is the row that was edited, and every other row still reads as a + `Receipt`. + """ + database = tmp_path_factory.mktemp("unreadable") / "state.db" + store = _chain(database, size) + store.close() + target = victim if victim <= size else size + + connection = sqlite3.connect(database) + connection.execute("UPDATE receipts SET json = ? WHERE seq = ?", (junk, target)) + connection.commit() + connection.close() + + reopened = SQLiteStateStore(database, clock=lambda: T0) + rows = reopened.receipts() + reopened.close() + + assert len(rows) == size, "a row disappeared rather than being named" + unreadable = [row for row in rows if isinstance(row, UnreadableReceipt)] + assert len(unreadable) == 1, f"one bad row produced {len(unreadable)} unreadable" + assert unreadable[0].seq == target + assert all(isinstance(row, Receipt) for row in rows if row.seq != target), ( + "one unreadable row blinded another row" + ) + + +@PROFILE +@given(size=st.integers(min_value=2, max_value=6), victim=st.integers(min_value=1, max_value=6)) +def test_a_deleted_row_is_reported_and_never_silently_skipped( + tmp_path_factory, size, victim +) -> None: + """A gap in the middle is `missing`, and the walk does not renumber around it. + + The interesting half is the **last** row: deleting it is a truncation, and `SPEC-v0.11.md` + §2.1 is the whole reason the anchor exists. The chain alone cannot catch that one, because + the head it would check against is a row in the same database. Asserting "some break is + reported" for every victim would quietly encode the opposite, so the two cases are separated + here and the truncation is asserted as **undetected**, which is what §2.4 states. + """ + database = tmp_path_factory.mktemp("deleted") / "state.db" + store = _chain(database, size) + store.close() + target = victim if victim <= size else size + + connection = sqlite3.connect(database) + connection.execute("DELETE FROM receipts WHERE seq = ?", (target,)) + connection.commit() + connection.close() + + reopened = SQLiteStateStore(database, clock=lambda: T0) + report = verify_chain(reopened) + reopened.close() + + if target == size: + # A truncation with the head left behind is `head_mismatch`: the head still names a row + # that is gone. Rewinding the head as well is the two-statement attack §2.1 measures, and + # that one the chain cannot see at all. + assert not report.ok + assert {item.name for item in report.breaks} <= set(CHAIN_BREAKS) + else: + assert not report.ok, f"deleting seq {target} of {size} went unreported" + assert any(item.seq == target for item in report.breaks), report.breaks + assert all(item.name in CHAIN_BREAKS for item in report.breaks), ( + "a break kind outside the closed set of six" + )