From 8127b477ad5a7d37e48d657ff6abd60c9e8d05ad Mon Sep 17 00:00:00 2001 From: enqiang Date: Wed, 14 Oct 2026 22:25:45 +0800 Subject: [PATCH 1/2] fix(security): harden credential, UI and atomic write boundaries --- .aoci/baseline.json | 184 +++++++------ CHANGELOG.md | 26 +- SECURITY.md | 13 + aoci.code.txt | 30 +-- docs/troubleshooting.md | 32 +++ internal/cli/ui.go | 5 + internal/cli/ui_security_test.go | 45 ++++ internal/fs/atomic.go | 7 + internal/fs/atomic_exchange_linux.go | 70 +---- .../fs/atomic_exchange_linux_fallback_test.go | 255 ++---------------- internal/hooks/installer.go | 4 +- internal/hooks/installer_backup_test.go | 37 +++ internal/llm/client.go | 37 +-- internal/llm/errors.go | 5 +- internal/llm/options.go | 15 +- internal/llm/security_test.go | 111 ++++++++ internal/ui/page.html | 18 +- internal/ui/registry.go | 56 +++- internal/ui/security_test.go | 126 +++++++++ internal/ui/server.go | 28 +- internal/ui/server_test.go | 12 +- .../workflow/endpoint_error_security_test.go | 57 ++++ scripts/blackbox/mcp_scenarios.py | 19 +- spec/public/aoci-code-cli-runtime-v1.txt | 26 ++ textassets/en-US/contracts/ui/messages.json | 3 +- textassets/zh-CN/contracts/ui/messages.json | 3 +- 26 files changed, 767 insertions(+), 457 deletions(-) create mode 100644 internal/cli/ui_security_test.go create mode 100644 internal/llm/security_test.go create mode 100644 internal/ui/security_test.go create mode 100644 internal/workflow/endpoint_error_security_test.go diff --git a/.aoci/baseline.json b/.aoci/baseline.json index 883c059f..f6f871a2 100644 --- a/.aoci/baseline.json +++ b/.aoci/baseline.json @@ -1,7 +1,7 @@ { "version": 1, "created_at": "2026-08-07T17:44:36Z", - "updated_at": "2026-10-13T02:55:06Z", + "updated_at": "2026-10-14T14:19:18Z", "files": { ".gitattributes": { "role": "index", @@ -65,9 +65,9 @@ }, "CHANGELOG.md": { "role": "index", - "sha256": "43e189d7aef16e951d4e7c87b16db02dfcfb339260c4683259680d61bb1f44ce", - "size": 122134, - "normalized_sha256": "43e189d7aef16e951d4e7c87b16db02dfcfb339260c4683259680d61bb1f44ce" + "sha256": "a29fddcc3d6bfa4c2779e6b64f31e51379074530c0ec49cc57eb9c7e37513438", + "size": 122392, + "normalized_sha256": "a29fddcc3d6bfa4c2779e6b64f31e51379074530c0ec49cc57eb9c7e37513438" }, "CODE_OF_CONDUCT.md": { "role": "index", @@ -119,9 +119,9 @@ }, "SECURITY.md": { "role": "index", - "sha256": "cf854ee7a8028929fc71e9bb39ea7d4674b01407efbe13093c955a55f29c5a9f", - "size": 1995, - "normalized_sha256": "cf854ee7a8028929fc71e9bb39ea7d4674b01407efbe13093c955a55f29c5a9f" + "sha256": "375190a71b2adbeea3d719c5e30144f5f663965f5ef8e5c3b766bdcb0a10552c", + "size": 2931, + "normalized_sha256": "375190a71b2adbeea3d719c5e30144f5f663965f5ef8e5c3b766bdcb0a10552c" }, "THIRD-PARTY-NOTICES": { "role": "index", @@ -137,9 +137,9 @@ }, "aoci.code.txt": { "role": "index", - "sha256": "1ad12c1ef0cd3a2c85cc5c79475319870cc49ce9be88abfadff4d533c1dd3d24", - "size": 207150, - "normalized_sha256": "1ad12c1ef0cd3a2c85cc5c79475319870cc49ce9be88abfadff4d533c1dd3d24" + "sha256": "ddbf8e11a511b78081059bf019ab4a347ae214303537b65f77b3e9c59ca8b9e3", + "size": 207786, + "normalized_sha256": "ddbf8e11a511b78081059bf019ab4a347ae214303537b65f77b3e9c59ca8b9e3" }, "aoci.meta.txt": { "role": "index", @@ -257,9 +257,9 @@ }, "docs/troubleshooting.md": { "role": "index", - "sha256": "363e3c9ac614d0d903b156269611977de91d7aba15d3622b68cd1184d67e5e42", - "size": 28555, - "normalized_sha256": "363e3c9ac614d0d903b156269611977de91d7aba15d3622b68cd1184d67e5e42" + "sha256": "cd3e3be58369d08b5980884c5d8fb0503fbb1ada55f85dd31386645784091579", + "size": 30442, + "normalized_sha256": "cd3e3be58369d08b5980884c5d8fb0503fbb1ada55f85dd31386645784091579" }, "docs/uninstall.md": { "role": "index", @@ -2370,10 +2370,18 @@ }, "internal/cli/ui.go": { "role": "index", - "sha256": "d9db957d9b1f965dd505516f7682b32f521abaaa7023fb66755d1dd504999021", - "size": 7113, - "normalized_sha256": "d9db957d9b1f965dd505516f7682b32f521abaaa7023fb66755d1dd504999021", - "format_sha256": "d9db957d9b1f965dd505516f7682b32f521abaaa7023fb66755d1dd504999021", + "sha256": "f4b818a0f598f1fcd0ecf21f088a41faf9d769d85a508bc4c5d6de1ec3e6bfef", + "size": 7375, + "normalized_sha256": "f4b818a0f598f1fcd0ecf21f088a41faf9d769d85a508bc4c5d6de1ec3e6bfef", + "format_sha256": "f4b818a0f598f1fcd0ecf21f088a41faf9d769d85a508bc4c5d6de1ec3e6bfef", + "format_kind": "gofmt" + }, + "internal/cli/ui_security_test.go": { + "role": "observe", + "sha256": "77dbf20a138ca6a49f68944dc442b5e40c4f095c6ae1b97c11994712d7f2344a", + "size": 1242, + "normalized_sha256": "77dbf20a138ca6a49f68944dc442b5e40c4f095c6ae1b97c11994712d7f2344a", + "format_sha256": "77dbf20a138ca6a49f68944dc442b5e40c4f095c6ae1b97c11994712d7f2344a", "format_kind": "gofmt" }, "internal/cli/update_entry.go": { @@ -3457,10 +3465,10 @@ }, "internal/fs/atomic.go": { "role": "index", - "sha256": "45e6e66bc6fef7b31c19221b49108ff2f2a0c12d6503d4584c496e8f7ab1910e", - "size": 19662, - "normalized_sha256": "45e6e66bc6fef7b31c19221b49108ff2f2a0c12d6503d4584c496e8f7ab1910e", - "format_sha256": "45e6e66bc6fef7b31c19221b49108ff2f2a0c12d6503d4584c496e8f7ab1910e", + "sha256": "1d510ad2bd7279be3ef9a919c976531d9bdd16227ff23f6149726b298b81d2fd", + "size": 20028, + "normalized_sha256": "1d510ad2bd7279be3ef9a919c976531d9bdd16227ff23f6149726b298b81d2fd", + "format_sha256": "1d510ad2bd7279be3ef9a919c976531d9bdd16227ff23f6149726b298b81d2fd", "format_kind": "gofmt" }, "internal/fs/atomic_cas_recovery_test.go": { @@ -3569,18 +3577,18 @@ }, "internal/fs/atomic_exchange_linux.go": { "role": "index", - "sha256": "7fd63ca6f22e0f26bc8f026b60b9f5be16303f58a45daad5c5fb9eef2b2968ee", - "size": 5984, - "normalized_sha256": "7fd63ca6f22e0f26bc8f026b60b9f5be16303f58a45daad5c5fb9eef2b2968ee", - "format_sha256": "7fd63ca6f22e0f26bc8f026b60b9f5be16303f58a45daad5c5fb9eef2b2968ee", + "sha256": "ea1648622e31ee419da16b67d7355f0240b7f9708acdd30c06912a54391e9ed3", + "size": 2633, + "normalized_sha256": "ea1648622e31ee419da16b67d7355f0240b7f9708acdd30c06912a54391e9ed3", + "format_sha256": "ea1648622e31ee419da16b67d7355f0240b7f9708acdd30c06912a54391e9ed3", "format_kind": "gofmt" }, "internal/fs/atomic_exchange_linux_fallback_test.go": { "role": "observe", - "sha256": "5f6369a5d7f3f161b4705540676289ec5266dde66defca0c4b1cd3b49693a0d9", - "size": 22319, - "normalized_sha256": "5f6369a5d7f3f161b4705540676289ec5266dde66defca0c4b1cd3b49693a0d9", - "format_sha256": "5f6369a5d7f3f161b4705540676289ec5266dde66defca0c4b1cd3b49693a0d9", + "sha256": "6de2c6eeb3209a619c20873e0fb652e650879ae102235a39c06442569aad9737", + "size": 12644, + "normalized_sha256": "6de2c6eeb3209a619c20873e0fb652e650879ae102235a39c06442569aad9737", + "format_sha256": "6de2c6eeb3209a619c20873e0fb652e650879ae102235a39c06442569aad9737", "format_kind": "gofmt" }, "internal/fs/atomic_exchange_other.go": { @@ -3921,18 +3929,18 @@ }, "internal/hooks/installer.go": { "role": "index", - "sha256": "a6893ee3bde0c9d9f988a29f2b7aeb457b94401acfe602f7836b9338914ff82c", - "size": 11726, - "normalized_sha256": "a6893ee3bde0c9d9f988a29f2b7aeb457b94401acfe602f7836b9338914ff82c", - "format_sha256": "a6893ee3bde0c9d9f988a29f2b7aeb457b94401acfe602f7836b9338914ff82c", + "sha256": "43ae28277f837273b659cab066792189e1593214e6a07e9856d88e42cae2a4db", + "size": 11882, + "normalized_sha256": "43ae28277f837273b659cab066792189e1593214e6a07e9856d88e42cae2a4db", + "format_sha256": "43ae28277f837273b659cab066792189e1593214e6a07e9856d88e42cae2a4db", "format_kind": "gofmt" }, "internal/hooks/installer_backup_test.go": { "role": "observe", - "sha256": "196d73fff34bacb9e062df2ab0eee1ed1d6c55119c9dd5e7e6e5b8078edb1099", - "size": 1117, - "normalized_sha256": "196d73fff34bacb9e062df2ab0eee1ed1d6c55119c9dd5e7e6e5b8078edb1099", - "format_sha256": "196d73fff34bacb9e062df2ab0eee1ed1d6c55119c9dd5e7e6e5b8078edb1099", + "sha256": "5e198cfa0c14359ae400c9f442efee571a9aabbf38eea3fcb1506b1dc6f6cc15", + "size": 2284, + "normalized_sha256": "5e198cfa0c14359ae400c9f442efee571a9aabbf38eea3fcb1506b1dc6f6cc15", + "format_sha256": "5e198cfa0c14359ae400c9f442efee571a9aabbf38eea3fcb1506b1dc6f6cc15", "format_kind": "gofmt" }, "internal/hooks/locale_testmain_test.go": { @@ -4473,10 +4481,10 @@ }, "internal/llm/client.go": { "role": "index", - "sha256": "1b0c05ad31a9ad652ca70845b1b4d66e555d71668e0a1e9f460302796d4b9408", - "size": 7678, - "normalized_sha256": "1b0c05ad31a9ad652ca70845b1b4d66e555d71668e0a1e9f460302796d4b9408", - "format_sha256": "1b0c05ad31a9ad652ca70845b1b4d66e555d71668e0a1e9f460302796d4b9408", + "sha256": "05ec181d06878fc36acc94ac98c4e4bd3c551de708719ea3ddbc938e2c2a775c", + "size": 6922, + "normalized_sha256": "05ec181d06878fc36acc94ac98c4e4bd3c551de708719ea3ddbc938e2c2a775c", + "format_sha256": "05ec181d06878fc36acc94ac98c4e4bd3c551de708719ea3ddbc938e2c2a775c", "format_kind": "gofmt" }, "internal/llm/client_test.go": { @@ -4489,18 +4497,26 @@ }, "internal/llm/errors.go": { "role": "index", - "sha256": "53b7160965676a55de0047baf8f04fe84520149e176339410111f5c9b57753f7", - "size": 2844, - "normalized_sha256": "53b7160965676a55de0047baf8f04fe84520149e176339410111f5c9b57753f7", - "format_sha256": "53b7160965676a55de0047baf8f04fe84520149e176339410111f5c9b57753f7", + "sha256": "1c2dc517bba45d50ebb004a0780c65ddb9cfe4c7746f14d582789ab920246351", + "size": 2926, + "normalized_sha256": "1c2dc517bba45d50ebb004a0780c65ddb9cfe4c7746f14d582789ab920246351", + "format_sha256": "1c2dc517bba45d50ebb004a0780c65ddb9cfe4c7746f14d582789ab920246351", "format_kind": "gofmt" }, "internal/llm/options.go": { "role": "index", - "sha256": "e019112e27a8c231e1dc1674e31d506b82715f604a9cea5f1cc7ff593888c12d", - "size": 5557, - "normalized_sha256": "e019112e27a8c231e1dc1674e31d506b82715f604a9cea5f1cc7ff593888c12d", - "format_sha256": "e019112e27a8c231e1dc1674e31d506b82715f604a9cea5f1cc7ff593888c12d", + "sha256": "76c3d73996714f956380ef18a21375fc4caf84c30028e7aae84150a59c4859f9", + "size": 6187, + "normalized_sha256": "76c3d73996714f956380ef18a21375fc4caf84c30028e7aae84150a59c4859f9", + "format_sha256": "76c3d73996714f956380ef18a21375fc4caf84c30028e7aae84150a59c4859f9", + "format_kind": "gofmt" + }, + "internal/llm/security_test.go": { + "role": "observe", + "sha256": "e8a4e604bd6c084d2c9678579e9a70d3ffe1aa087652f2b8f88c08bbf3b3f117", + "size": 4243, + "normalized_sha256": "e8a4e604bd6c084d2c9678579e9a70d3ffe1aa087652f2b8f88c08bbf3b3f117", + "format_sha256": "e8a4e604bd6c084d2c9678579e9a70d3ffe1aa087652f2b8f88c08bbf3b3f117", "format_kind": "gofmt" }, "internal/machinecontract/artifact_governance_test.go": { @@ -6269,9 +6285,9 @@ }, "internal/ui/page.html": { "role": "index", - "sha256": "1bd6b0a956ac2cbf2aeb748ccbfd43820241c790f7016afbd05ec269d381ef58", - "size": 29000, - "normalized_sha256": "1bd6b0a956ac2cbf2aeb748ccbfd43820241c790f7016afbd05ec269d381ef58" + "sha256": "04550faae711198b6d132110d6f7ae1a24be831569aca11f4506e6096434c696", + "size": 29785, + "normalized_sha256": "04550faae711198b6d132110d6f7ae1a24be831569aca11f4506e6096434c696" }, "internal/ui/page_filter_test.go": { "role": "observe", @@ -6315,10 +6331,10 @@ }, "internal/ui/registry.go": { "role": "index", - "sha256": "e0a46fe46fbb87a99a3c0b16ed293cd26d2ee6510c98b7ddb60e78f00db50424", - "size": 4924, - "normalized_sha256": "e0a46fe46fbb87a99a3c0b16ed293cd26d2ee6510c98b7ddb60e78f00db50424", - "format_sha256": "e0a46fe46fbb87a99a3c0b16ed293cd26d2ee6510c98b7ddb60e78f00db50424", + "sha256": "d1629eac09d080ebce259beacd6bfb06c8a59459ffde27aa17062b898ac8f105", + "size": 6512, + "normalized_sha256": "d1629eac09d080ebce259beacd6bfb06c8a59459ffde27aa17062b898ac8f105", + "format_sha256": "d1629eac09d080ebce259beacd6bfb06c8a59459ffde27aa17062b898ac8f105", "format_kind": "gofmt" }, "internal/ui/registry_test.go": { @@ -6329,20 +6345,28 @@ "format_sha256": "db6deb4a8c0a0dc8f06323dd38a0708d4a8f47c208e00c5234cb974dbc755362", "format_kind": "gofmt" }, + "internal/ui/security_test.go": { + "role": "observe", + "sha256": "6ba525c648d1c67fae124faa8ff847c356ef21d87fc37bbd67d6c27214c7ac29", + "size": 5393, + "normalized_sha256": "6ba525c648d1c67fae124faa8ff847c356ef21d87fc37bbd67d6c27214c7ac29", + "format_sha256": "6ba525c648d1c67fae124faa8ff847c356ef21d87fc37bbd67d6c27214c7ac29", + "format_kind": "gofmt" + }, "internal/ui/server.go": { "role": "index", - "sha256": "b3530634b2d7dbb84ce37d62236f08f29d9abeb14c4a7cb626732da55f80e160", - "size": 11741, - "normalized_sha256": "b3530634b2d7dbb84ce37d62236f08f29d9abeb14c4a7cb626732da55f80e160", - "format_sha256": "b3530634b2d7dbb84ce37d62236f08f29d9abeb14c4a7cb626732da55f80e160", + "sha256": "ed444fc5e5a8ca2086ea53857f1e930ea85c6c8b0d93c3f3a274555b5e35bd49", + "size": 12494, + "normalized_sha256": "ed444fc5e5a8ca2086ea53857f1e930ea85c6c8b0d93c3f3a274555b5e35bd49", + "format_sha256": "ed444fc5e5a8ca2086ea53857f1e930ea85c6c8b0d93c3f3a274555b5e35bd49", "format_kind": "gofmt" }, "internal/ui/server_test.go": { "role": "observe", - "sha256": "e47a0af0358ec6934388c4fe80662d282d4118fd226c248428024aacdd3a5060", - "size": 21507, - "normalized_sha256": "e47a0af0358ec6934388c4fe80662d282d4118fd226c248428024aacdd3a5060", - "format_sha256": "e47a0af0358ec6934388c4fe80662d282d4118fd226c248428024aacdd3a5060", + "sha256": "ae840c34f4fbb2a275939d0be29f651e9c5a1548920f2fd8ed6add0d1fe5bd90", + "size": 21752, + "normalized_sha256": "ae840c34f4fbb2a275939d0be29f651e9c5a1548920f2fd8ed6add0d1fe5bd90", + "format_sha256": "ae840c34f4fbb2a275939d0be29f651e9c5a1548920f2fd8ed6add0d1fe5bd90", "format_kind": "gofmt" }, "internal/ui/snapshot.go": { @@ -6417,6 +6441,14 @@ "format_sha256": "0fa72c575dcd2f913f82f2386f1479549b807eca42ffa7e2b8818c8d6fe2dfd1", "format_kind": "gofmt" }, + "internal/workflow/endpoint_error_security_test.go": { + "role": "observe", + "sha256": "a9ebeca4e8cd2dc15de8a46e80a35a86c2049782fc30f92863e5f8953f104266", + "size": 1705, + "normalized_sha256": "a9ebeca4e8cd2dc15de8a46e80a35a86c2049782fc30f92863e5f8953f104266", + "format_sha256": "a9ebeca4e8cd2dc15de8a46e80a35a86c2049782fc30f92863e5f8953f104266", + "format_kind": "gofmt" + }, "internal/workflow/entries.go": { "role": "index", "sha256": "be281164966d54f8583c3722edf2e325d9f89b99258fcca7be930629b0f7b413", @@ -6553,9 +6585,9 @@ }, "scripts/blackbox/mcp_scenarios.py": { "role": "index", - "sha256": "202df929e147dd47fc4627a915d93938bbfc626a1d3b71ad97cb0ae29eb7d6ae", - "size": 127957, - "normalized_sha256": "202df929e147dd47fc4627a915d93938bbfc626a1d3b71ad97cb0ae29eb7d6ae" + "sha256": "7b53ab68c6727da52987e4dbbbce9104dc25a37a08c3fd62da147bbd1187e23c", + "size": 128549, + "normalized_sha256": "7b53ab68c6727da52987e4dbbbce9104dc25a37a08c3fd62da147bbd1187e23c" }, "scripts/blackbox/mcp_upgrade.py": { "role": "index", @@ -6704,9 +6736,9 @@ }, "spec/public/aoci-code-cli-runtime-v1.txt": { "role": "index", - "sha256": "a5066207964c437943d5bd0e1441d10f159cdd6804e3c58626c93a871f22be69", - "size": 7964, - "normalized_sha256": "a5066207964c437943d5bd0e1441d10f159cdd6804e3c58626c93a871f22be69" + "sha256": "a1edf297802f5dd76c368e12a0587692077873b2690b6ae602035cab841fa350", + "size": 9639, + "normalized_sha256": "a1edf297802f5dd76c368e12a0587692077873b2690b6ae602035cab841fa350" }, "spec/public/aoci-cognition-refresh-v1.txt": { "role": "index", @@ -7578,9 +7610,9 @@ }, "textassets/en-US/contracts/ui/messages.json": { "role": "observe", - "sha256": "cd82e4352b185a4134e8d5c6295c3efbb4d9ce19e060911fb023e626e45d7aab", - "size": 193901, - "normalized_sha256": "cd82e4352b185a4134e8d5c6295c3efbb4d9ce19e060911fb023e626e45d7aab" + "sha256": "eb6b9fc322f543ce2e36e56681a8d16ebacdaff94bac8306000709b0c01da682", + "size": 194047, + "normalized_sha256": "eb6b9fc322f543ce2e36e56681a8d16ebacdaff94bac8306000709b0c01da682" }, "textassets/en-US/prompts/ai-probe-system.txt": { "role": "observe", @@ -8686,9 +8718,9 @@ }, "textassets/zh-CN/contracts/ui/messages.json": { "role": "observe", - "sha256": "1b922fe7e8c4ae91dff8af0a288dc263995b4d3ab6fa88cb5478dd08f04d5d9f", - "size": 176740, - "normalized_sha256": "1b922fe7e8c4ae91dff8af0a288dc263995b4d3ab6fa88cb5478dd08f04d5d9f" + "sha256": "9dc25080b5c5248af5dd1781257b7ab2637e533b15f34ceb715a8be53eb96655", + "size": 176866, + "normalized_sha256": "9dc25080b5c5248af5dd1781257b7ab2637e533b15f34ceb715a8be53eb96655" }, "textassets/zh-CN/prompts/ai-probe-system.txt": { "role": "observe", diff --git a/CHANGELOG.md b/CHANGELOG.md index 4aa6b43f..93d8ebfa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable public changes to AOCI-CODE will be documented in this file. ## Unreleased +- Host-configuration backups are owner-only. Optional model clients reject + redirects and credential-bearing base URLs, preserve caller-owned HTTP + clients, and expose status/category diagnostics without endpoint error + bodies or transport details. No MCP or formal-asset schema changes. +- The loopback status page protects repository data with a per-process + capability carried in the readiness URL fragment and sent by the browser + in Authorization headers. The static shell contains no capability or + repository data. Registrations keep their existing shape; legacy pages can + be stopped but must be stopped before a new detach command replaces them. + - Every `aoci` invocation built its command tree by decoding the embedded localization manifest once per localized flag, several hundred times, about half a second before the first byte of output. The decoded manifest is now @@ -90,17 +100,11 @@ All notable public changes to AOCI-CODE will be documented in this file. exists, rendered "Repair only the reported of this candidate"; each now has its own action naming the identity and the create/update alternative, and no field-less finding renders a blank. -- Governed CAS writes work on filesystems that refuse `RENAME_EXCHANGE`, - such as WSL DrvFs (`/mnt/*`, 9p), where every Entry batch and Managed Scope - write failed with "原子交换目标失败: invalid argument" while `init` and - `scan` worked (#116). The primitive is probed and, when refused, emulated - with a hard link and two renames that end in the primitive's exact - post-state; crash states converge through the existing recovery. On such - a filesystem the replacement window is link-to-rename rather than zero: a - writer outside aoci that replaces the target by rename in that instant is - overwritten rather than detected, while in-place modification is still - caught; every aoci writer is serialized by the CAS lock. Verified on a real - DrvFs mount. +- Governed CAS writes fail closed on filesystems that refuse native + `RENAME_EXCHANGE`, including WSL DrvFs (#116). The unreleased link/rename + fallback was removed because it could overwrite a non-cooperating writer's + replacement. Existing capture/intent artifacts keep their recovery reader; + recover on a filesystem with native exchange if rollback needs it. - Strict decoders of governance and protocol artifacts (receipts, run manifests, previews, checkpoints, onboarding sessions, Curation documents, Host-Agent stage requests) reject two keys that differ only by letter case: diff --git a/SECURITY.md b/SECURITY.md index 12cad1b2..dd3323f9 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -34,7 +34,20 @@ remediation and disclosure, and publish guidance when disclosure is safe. - MCP stdio stdout is reserved for JSON-RPC; logs and diagnostics use stderr. - Configuration stores credential environment-variable names, not values. - Optional model access uses only an explicitly configured endpoint. +- Model HTTP clients refuse redirects, including caller-supplied clients. + Endpoint error bodies and transport details are not formatted into errors + that callers may log or persist. Endpoint URLs cannot contain credentials, + query strings, or fragments. +- Host-configuration backups use owner-only file permissions. This protects + configuration content independently of Git ignore rules; on Windows, + restrict access to the containing directory with the operating system ACL. +- The optional loopback status page requires a fresh per-process capability + for repository data. Its printed URL and private user-cache registration + carry that capability; do not share them or put them into public logs. - Formal asset updates use validation, locking, CAS, atomic writes, and recovery. + A filesystem without native atomic exchange cannot start a governed CAS + replacement. Use a filesystem supporting that primitive; cooperating-writer + locks alone cannot protect against an external editor's atomic replacement. - Database Evidence collectors are limited to schema/catalog metadata and do not issue DDL or DML. - Checksums establish artifact integrity but do not identify the publisher; diff --git a/aoci.code.txt b/aoci.code.txt index 88f74ae7..fcdebf34 100644 --- a/aoci.code.txt +++ b/aoci.code.txt @@ -5,7 +5,7 @@ .gitignore[CG5T]: F:Separates tracked cognition assets from ignored runtime, recovery, build, agent host integration, cached release binaries, and local configuration state | R:code:internal/hooks/installer.go,code:internal/hooks/claude.go | A:- | S:Never track the agent host configs listed here: their paths are machine-bound and installers early-return on a key-presence check, so a committed copy silently no-ops another machine's init .goreleaser.yml[CG5T]: F:Defines six CGO-free trimmed aoci archives with legal documents, bilingual READMEs and logos, checksums, and per-archive SBOM generation | R:code:.github/workflows/release.yml,code:scripts/release/archive-smoke/main.go | A:GoReleaser v2 configuration | S:Remote publishing remains disabled; the signed workflow rebuilds SHA256SUMS after adding all six SBOMs AGENTS.md[CG8S]: F:Defines repository integration plus AOCI cognition establishment, reuse, maintenance, task-closing, verification, and index-admission rules for agents | R:code:docs/contract-authority.md,code:docs/zh-cn-contract-authority.md,code:scripts/blackbox/README.md,code:Makefile | A:- | S:Live Guide, tool schema, Spec, and Validator remain authoritative over this integration block; historical memory never substitutes for a current repository-bound cognition receipt; a green make fast proves nothing about the make full-only gates; make verify is the one-shot closure command and never replaces the per-change table -CHANGELOG.md[CG3L]: F:Records public release history, an Unreleased section for changes not yet tagged, and per-version contract, governance, and Schema Evidence changes | R:- | A:- | S:Pointer tests read the top ## v heading as current +CHANGELOG.md[CG3L]: F:Records tagged releases and unreleased changes to governance, endpoint privacy, host backups, authenticated status pages and filesystem safety | R:- | A:- | S:Pointer tests read the top ## v heading as current CODE_OF_CONDUCT.md[CG3T]: F:Sets public community behavior and reporting expectations | R:- | A:- | S:- CONTRIBUTING.md[CG5T]: F:Explains contribution boundaries, verification gates, public contracts, and review requirements | R:code:Makefile | A:- | S:The go.mod version names the base Go install, not what go version prints, because GOTOOLCHAIN=auto re-executes a downloaded toolchain and hides an older base LICENSE[CG5S]: F:Carries FSL-1.1-MIT terms and the 2026 Liu JinShi copyright notice, including Permitted Purpose limits and the future MIT grant | R:code:NOTICE,code:scripts/check-licenses.sh | A:FSL-1.1-MIT | S:The MIT grant begins on the second anniversary of the date each version is made available @@ -14,7 +14,7 @@ NOTICE[CG3T]: F:Records AOCI-CODE v0.1.0-rc1 through v0.1.0-rc20 under FSL-1.1-M PATENTS[CG3T]: F:Provides Liu JinShi as Patent/IP contact while making no patent ownership, number, application, registration, or additional-rights claim | R:code:LICENSE | A:- | S:Authorized counsel must confirm extra disclosure README.md[CG5L]: F:Introduces AOCI-CODE in English: what it does, sample entry, CodeGraph contrast, quick start, how it works, format, hosts, CLI, databases, releases, community | R:code:README.zh-CN.md,code:docs/database-evidence.md,code:docs/supply-chain.md,code:spec/public/aoci-database-evidence-v1.txt,code:textassets/en-US/templates/volume-root.txt.tmpl,code:textassets/en-US/templates/volume-meta.txt.tmpl | A:- | S:Tag links serve archives; the QR URL is absolute as archives omit it; MySQL 5.7.6+, no MariaDB; openGauss 6.0.5 A/PG base tables; header blocks, audit paragraph, badge, version, counts test-bound README.zh-CN.md[CG5L]: F:Presents, in Chinese, AOCI-CODE capabilities, usage, CodeGraph contrast, cognition, layout, onboarding, governance, releases, openGauss, and a user group | R:code:README.md,code:docs/database-evidence.md,code:docs/supply-chain.md,code:spec/public/aoci-database-evidence-v1.txt,code:textassets/zh-CN/templates/volume-root.txt.tmpl,code:textassets/zh-CN/templates/volume-meta.txt.tmpl | A:- | S:Tag links serve archives; the QR URL is absolute because archives omit it; MySQL 5.7.6+, no MariaDB; openGauss 6.0.5 A/PG base tables only; header blocks test-bound; badge, version, suite counts gated -SECURITY.md[CG5T]: F:Defines supported security reporting, sensitive-data boundaries, and coordinated disclosure expectations | R:- | A:- | S:- +SECURITY.md[CG5T]: F:Defines confidential reporting, credential handling, model egress, local page access and formal-write security boundaries | R:- | A:- | S:Checksums do not identify publishers; Windows backup access also depends on directory ACLs; printed status-page links are credentials THIRD-PARTY-NOTICES[CG3L]: F:Retains hashed license texts and the exact 22-module release-matrix inventory, including the patched openGauss Connector and transitives | R:code:go.mod,code:docs/supply-chain.md,code:scripts/check-licenses.sh,code:third_party/openGauss-connector-go-pq.PROVENANCE.md | A:- | S:Connector attribution must remain auditable TRADEMARKS[CG3T]: F:Provides Liu JinShi as Trademark/IP contact while making no ownership or registration claim and limiting use to LICENSE identification | R:code:LICENSE | A:- | S:Final marks and brand policy require legal review go.mod[CG8T]: F:Declares the canonical Go module, exact Go version, and pinned runtime graph, including the official openGauss Connector identity and reviewed local replacement | R:code:third_party/openGauss-connector-go-pq/go.mod,code:third_party/openGauss-connector-go-pq.PROVENANCE.md,code:THIRD-PARTY-NOTICES | A:Go module dependency graph | S:The exact local replace keeps builds off unpatched upstream; the go directive carries the full patch version because setup-go installs it via go-version-file and the GOTOOLCHAIN=local license gate rejects a toolchain switch. A bump changing cmd/aoci's module set breaks the license inventory (make licenses only); a first-party import must be a direct requirement, since CI tidies and diffs the marker @@ -44,7 +44,7 @@ overview-delivery.md[CG5S]: F:Documents Overview chunk transport, cursors, the p rollback.md[CG5T]: F:Provides safe binary rollback, conditional MCP process refresh, and layout-appropriate post-rollback verification | R:code:docs/upgrading.md | A:- | S:A host-wide restart is unnecessary when its MCP integration can reload and prove the replacement process; Volumes close through Verify, Check, and Guide, while status --deep remains Legacy-only safe-inventory-and-scope-refresh.md[CG5T]: F:Explains Safe Inventory exclusions, scope proposals, review, atomic refresh boundaries, and the approval artifact the Apply step reads | R:- | A:- | S:Only an ignored directory whose every file is a hard exclusion collapses to one line; other ignored files stay candidates; an exclude_root_dirs name is hard at the root and wherever git ignores it supply-chain.md[CG8M]: F:Defines release assets, signing, provenance, dependency auditing with a go.mod-checked driver table, and reproducible verification of the patched Connector | R:code:.github/workflows/release.yml,code:THIRD-PARTY-NOTICES,code:scripts/check-licenses.sh,code:scripts/check-opengauss-connector.sh,code:go.mod,code:internal/dbevidence/driver_pin_contract_test.go,code:third_party/openGauss-connector-go-pq.PROVENANCE.md | A:make release-check | S:go mod verify does not cover a local replace; the connector gate must verify origin and sums, replay the canonical patch, and byte-compare the complete tree; the driver audit table is a released declaration, so a row may change only when go.mod changes with it, never by hand -troubleshooting.md[CG5L]: F:Diagnoses MCP identity, host scope, slow authoring, stdio, repair, receipts, held files, drift, scope changes, excluded names, submodules, renames, E scale | R:code:docs/upgrading.md,code:docs/cognition-volumes.md,code:internal/ui/server.go,code:internal/index/parser.go,code:internal/cognitiontxn/transaction.go,code:internal/mcptools/tools_remove_volumes.go | A:- | S:Disk bytes do not prove active MCP identity: the server self-reports service_binary_replaced_on_disk; a renamed tree stops at orphan_remove_candidates; a pending exclude is no orphan until activated +troubleshooting.md[CG5L]: F:Explains host identity, authoring and recovery failures, scope changes, authenticated page access, endpoint refusals and filesystem limitations | R:code:docs/upgrading.md,code:internal/ui/server.go,code:internal/cognitiontxn/transaction.go | A:- | S:Disk version is not MCP identity; activate exclusions before orphan removal; retain recovery artifacts on unsupported filesystems; stop legacy pages before replacement and new pages before downgrade uninstall.md[CG5T]: F:Explains removal of the binary, host integration, and optional repository-local AOCI assets | R:- | A:- | S:- upgrading.md[CG5T]: F:Defines safe binary upgrade, artifact verification, MCP refresh, runtime identity, Managed Scope path migration, in-flight approval limits, and rollback | R:code:docs/install.md,code:docs/rollback.md,code:spec/public/aoci-managed-scope-and-budget-v1.txt | A:- | S:An approval binds the preview envelope, not the plan: finish or discard a pending pair before replacing the binary; rc13- read an rc14 index but not its special names; rc19 drops exclude_root_dirs windows-host-agent.md[CG5L]: F:Documents Windows host integration, runtime identity, Guide execution, PowerShell-safe workflows, and the no-staging-files rule for Volumes authoring batches | R:code:docs/cognition-volumes.md | A:- | S:The Stage pipeline sections are Legacy-only; Volume-first repositories use Guide plus no-argument Maintain, and the F-length-never-blocks rule does not apply to FRAS v2 objects @@ -166,7 +166,7 @@ remove_entry.go[CG7T]: F:Registers the human CLI for previewing or removing one status.go[CG7L]: F:Reports compact cognition metadata or deep Managed Scope drift and budget diagnostics | R:code:internal/managedstate/state.go,code:internal/cognitionbudget/report.go,code:internal/cli/cognition_layout.go | A:aoci status | S:Default mode avoids repository rehashing; --deep is Legacy-only, and its layout guard must allow a missing index or the curated absent-index answer is shadowed by a raw error carrying a host path verify_render.go[CG7M]: F:Renders stable localized human Verify output and workflow-priority hints from an already computed report | R:code:internal/cli/verify.go,code:textassets/catalog.go | A:- | S:Pending Curation hints remain subordinate to actionable Missing, Unbaselined, and Stale work and defer final phase selection to Guide; the S coverage line and hint print only on the Legacy branch stdin_limit.go[CG7T]: F:Reads standard input under an explicit byte bound and reports oversize separately from a read failure | R:code:internal/cli/hook.go,code:internal/cli/update_entry.go,code:internal/machinecontract/numeric.go | A:readLimitedInput,hookInputMaxBytes | S:The bound reads maxBytes+1 because only that extra byte separates exactly-at-limit from oversize; hook input carries its own small bound and fails open while update-entry refuses -ui.go[CG7M]: F:Runs the local status page in the foreground, or with --detach as its own session that reports its URL and returns, and stops a registered page with --stop | R:code:internal/ui/server.go,code:internal/ui/detach.go,code:internal/ui/registry.go | A:aoci ui,aoci ui --detach,aoci ui --stop | S:Readiness goes to the process stdout, not the command writer, which stays buffered until exit, and this command exits only when the page stops +ui.go[CG7M]: F:Starts foreground or detached local status pages, reuses authenticated registrations and stops registered processes after liveness verification | R:code:internal/ui/server.go,code:internal/ui/detach.go,code:internal/ui/registry.go | A:aoci ui,aoci ui --detach,aoci ui --stop | S:Readiness uses process stdout because command output is buffered until exit; refuse detach over a live tokenless registration so --stop can still reach the old unprotected page scope_activate.go[CG7S]: F:Activates the desired Managed Scope policy through the empty-candidate preview and Apply, pausing for approval or answering already_aligned when nothing changes | R:code:internal/cli/scope.go,code:internal/scopechange/plan.go,code:internal/scopechange/transaction.go,code:internal/cli/host_interaction.go,code:internal/baseline/baseline.go | A:aoci scope activate | S:Never prompts, approves, edits rules, or authors Entries; already_aligned needs equal policy, budget, posture and empty change lists, files nothing; a pause saves its preview in a fresh activate-* dir scan_skipped.go[CG5S]: F:Counts, at scan time, the index-role files no model can author from their bytes or that curation keeps out, and estimates the first build's rounds | R:code:internal/curation/classify.go,code:internal/cli/scan.go,code:internal/volumegovernance/facts.go,code:internal/mcptools/tools_maintain_budget.go | A:- | S:Same classification Verify reports as code_skipped and code_curation_excluded; described files (Root for Legacy) and formal assets are left out; the estimate runs the real batch cut over the repo Meta git_query_detail.go[CG5T]: F:Prints a failed Safe Inventory git query's stderr tail and keeps causes reachable behind CLI messages | R:code:internal/fs/safe_inventory.go,code:internal/cli/root.go | A:- | S:The tail is git's verbatim text on stderr only, the one documented locale exception; causedError keeps the message so exit codes stay the same @@ -222,14 +222,14 @@ collector_opengauss.go[CG9L]: F:Collects deterministic openGauss 6.0.5 A/PG tabl collector_mysql_version.go[CG7S]: F:Parses the MySQL server version and selects the version-gated catalog profile: whether CHECK is read and which STATISTICS projection a release can answer | R:code:internal/dbevidence/collector_mysql.go,code:internal/dbevidence/queries.go | A:parseMySQLServerVersion,mysqlCatalogProfileFor | S:Floor 5.7.6; MariaDB (any 'mariadb' in VERSION(), even behind the 5.5.5- prefix) or an unreadable version fails closed as unsupported_catalog_feature; three STATISTICS projections keep one shape ===/home/alkor2000/aoci-code-public-staging-phase1/public-candidate/internal/fs/=== -atomic.go[CG9L]: F:Implements durable replacement, no-replace create or move, and exchange-based CAS with crash recovery and conflict preservation | R:code:internal/fs/atomic_exchange_linux.go,code:internal/fs/atomic_create_linux.go,code:internal/fs/lock.go | A:AtomicWrite,AtomicCreateCAS,AtomicCreateCASMode,ValidateCreateTarget,AtomicMoveCAS,AtomicWriteCAS | S:CAS never silently overwrites unproven third-party bytes; only plain AtomicWrite has a non-atomic Windows fallback, and that fallback renames the target to a same-directory backup instead of deleting it, restores it when the retry fails, and keeps both files when even restoration fails +atomic.go[CG9L]: F:Publishes atomic replacements with preserved or explicit permissions and provides no-replace creation, moves and exchange-based CAS recovery | R:code:internal/fs/atomic_exchange_linux.go,code:internal/fs/atomic_create_linux.go,code:internal/fs/lock.go | A:AtomicWrite,AtomicWriteMode,AtomicCreateCASMode,AtomicMoveCAS,AtomicWriteCAS,ValidateCreateTarget | S:CAS must not overwrite unproven third-party bytes; only plain replacement has a Windows backup-and-restore fallback; explicit modes override existing permissions for private backups and credentials; failures retain recoverable original bytes safe_inventory.go[CG9L]: F:Builds the deterministic Safe Inventory while excluding secrets, runtime data, generated assets, and unsafe paths | R:code:internal/fs/git_command.go | A:- | S:Excluded content is classified by path, never opened; Git and non-Git traversal share one hard boundary. AutoBlockerCount, not RequiredHumanReview, keys authorization; a non-Git root reads no .gitignore. git status --ignored=matching collapses only ignored directories whose every file is a hard exclusion, so candidates equal the full listing; git before 2.16 falls back. An exclude_root_dirs name counts at the root and for any git-ignored directory of that name, which collapses and beats an opt-in; a gitlink is git_submodule; the rules-identity tokens enter every scope identity and never move atomic_create_darwin.go[CG5S]: F:Publishes a staged file into a name that must not already exist, using the Darwin no-replace primitive with an O_EXCL copy fallback | R:code:internal/fs/atomic.go | A:- | S:exFAT answers ENOTSUP, which on darwin is a different errno from EOPNOTSUPP; the copy fallback keeps the refusal but not atomic content, so callers must verify the published digest atomic_create_linux.go[CG5T]: F:Publishes a staged file into a name that must not already exist, using the Linux no-replace primitive with a link-based fallback | R:code:internal/fs/atomic.go | A:- | S:RENAME_NOREPLACE is a filesystem capability, not a kernel one; DrvFs refuses it and the link fallback carries the identical guarantee; the syscall is a package variable so tests replay DrvFs on ext4 atomic_create_other.go[CG3T]: F:Refuses to publish where the platform has no atomic no-replace primitive, instead of degrading to a replacing rename | R:code:internal/fs/atomic.go | A:- | S:This refusal is the contract, not a stub atomic_create_windows.go[CG3T]: F:Publishes a staged file with MoveFileEx and no replace flag, so an existing name, directory, or reparse point is a hard conflict | R:code:internal/fs/atomic.go | A:- | S:MOVEFILE_REPLACE_EXISTING would void the guarantee atomic_exchange_darwin.go[CG3T]: F:Uses Darwin rename exchange support for exact atomic replacement where available | R:- | A:- | S:- -atomic_exchange_linux.go[CG7S]: F:Exchanges two paths with renameat2 RENAME_EXCHANGE, emulating it with a hard link and two renames where the filesystem refuses the flag | R:code:internal/fs/atomic.go,code:internal/fs/atomic_create_linux.go | A:- | S:The emulation ends in the primitive's post-state; crash states converge via the capture name; its link-to-rename window lets a foreign rename-replace win unseen while in-place edits still fail CAS +atomic_exchange_linux.go[CG7T]: F:Provides native Linux atomic path exchange and normalizes capture artifacts left by older interrupted compatibility writes | R:code:internal/fs/atomic.go | A:- | S:Unsupported exchange must fail closed without link/rename emulation; retain legacy capture recovery so an upgrade does not strand prior transaction formats atomic_exchange_other.go[CG3T]: F:Reports unavailable atomic exchange on platforms without a safe primitive | R:- | A:- | S:- atomic_exchange_windows.go[CG3T]: F:Implements Windows replacement behavior for guarded atomic writes | R:- | A:- | S:- countlines.go[CG7T]: F:Counts file lines consistently for inventory and cognition scale evidence | R:- | A:- | S:Empty files count as zero, a terminal newline adds no empty line, and an unterminated final line adds one; this streaming helper deliberately performs no binary sniffing @@ -363,7 +363,7 @@ signed_release.go[CG9L]: F:Defines and verifies manifest v4 with six archive-SBO ===/home/alkor2000/aoci-code-public-staging-phase1/public-candidate/spec/public/=== README.md[CG8T]: F:Maps the public runtime contract set and explicitly excludes private design and commercial material | R:- | A:- | S:- -aoci-code-cli-runtime-v1.txt[CG8S]: F:Specifies stable CLI identity, global flags, command and config behavior, bounded standard input, layout-scoped reads, exit status, and side-effect boundaries | R:- | A:- | S:aoci ui is loopback-only and read-only, not an MCP transport; aoci mcp opens no socket; scope activate composes preview and Apply: exit 2 with a saved preview means approval is required, already_aligned means nothing to do and no transaction; config set --json reports the merged value and takes directory names, never paths; scope status carries repository_root +aoci-code-cli-runtime-v1.txt[CG8S]: F:Specifies public CLI identity, config, exit and side-effect contracts, authenticated status pages, model endpoint privacy and atomic-write requirements | R:- | A:- | S:MCP stays stdio-only; page links are capabilities and live old pages require stop before detach; model errors omit untrusted detail; CAS needs native exchange. Scope activate exit 2 with preview requires approval; already_aligned writes no transaction. Config JSON reports merged values and directory names; status includes repository_root aoci-cognition-refresh-v1.txt[CG8M]: F:Specifies cognition checkpoint reasons, refresh decisions, receipts, generation semantics, the session line, the probe, and compaction handoff limits | R:code:docs/cognition-refresh.md | A:- | S:- aoci-cognition-state-v2.txt[CG8S]: F:Specifies independent delivery, usability, attestation, governance, and reliability cognition states | R:- | A:- | S:- aoci-cognition-volumes-v1.txt[CG8L]: F:Specifies Volumes layout, starter dictionaries, batch plans, atomicity, domain identities, repair boundaries, next_commands, and aligned-entry optimization | R:code:spec/public/aoci-object-fras-v2.txt,code:spec/public/aoci-index-format-v1.txt | A:cognition-volumes/v1 | S:Starter expansion applies only to new Volume-first init and existing Meta is never retagged; copied-binding repair needs an unambiguous current receipt without source, Scope, CAS, or Recovery drift; a batch is cut by the transport budget; next_commands is additive and Host-filled, omitted when unresolvable; scope_change_required names scope activate; s_absent_high_importance is a review fact, never a verdict @@ -465,7 +465,7 @@ manifest_decode.go[CG7T]: F:Delegates draft Manifest loading to the shared stric ===/home/alkor2000/aoci-code-public-staging-phase1/public-candidate/internal/hooks/=== claude.go[CG7S]: F:Installs and idempotently merges project-scoped AOCI MCP and Claude PreToolUse hook configuration | R:code:internal/hooks/status.go,code:textassets/catalog.go | A:InstallClaudeMCP,InstallClaudeHook | S:Writes merge into existing .mcp.json and settings with backups instead of overwriting; MCP uses explicit binary and repository paths, and hook detection shares the exact status predicate codex.go[CG7M]: F:Installs the idempotent repository-local Codex MCP configuration table and the AOCI-managed Codex compaction prompt and reload hook | R:code:internal/hooks/installer.go,code:textassets/agents_assets.go,code:internal/cli/hook.go | A:InstallCodexMCP,InstallCodexCompaction,ValidateCodexCompactionInstall | S:Existing bytes are preserved and only missing AOCI-managed tables are appended; project trust remains a user action -installer.go[CG7M]: F:Installs or updates supported host integration files, including the Codex compaction assets, without overwriting unrelated configuration | R:code:internal/hooks/opencode.go,code:internal/hooks/codex.go | A:Detect,Install | S:Only the marked AGENTS block may be replaced; Windows paths are slash-normalized before TOML or JSON rendering to prevent escape corruption; qoder reuses the Claude Code .mcp.json and has no hooks +installer.go[CG8M]: F:Installs supported host integrations and managed agent instructions with private preimage backups while preserving unrelated configuration | R:code:internal/hooks/opencode.go,code:internal/hooks/codex.go,code:internal/fs/atomic.go | A:Detect,Install,BackupThenWrite,BackupThenWriteCAS | S:Only marked AGENTS content may be replaced; host backups can contain other servers' credentials and always use 0600; Windows paths are slash-normalized before rendering; Qoder shares Claude MCP configuration without hooks pretool.go[CG7M]: F:Injects the target Entry and drift warning before Agent writes, with optional strict stale blocking | R:code:internal/baseline/baseline.go,code:internal/index/parser.go,code:internal/config/config.go,code:internal/cognition/loader.go,code:textassets/ui.go | A:HandlePreTool | S:Fails open on bad paths, missing config, Index, Baseline, or catalog text; reads the Code Volume under Volumes v1; only real stale plus hook_strict blocks; path refusal reason is catalog text status.go[CG7T]: F:Inspects installed host integration state and reports drift or missing assets | R:code:internal/hooks/opencode.go | A:- | S:All read or parse failures return false rather than installed, and each predicate reuses the same marker or parser as its installer to prevent status/write drift opencode.go[CG7M]: F:Validates and atomically installs the project-root OpenCode V1 mcp.aoci local server bound to absolute AOCI binary and repository paths | R:code:internal/hooks/installer.go,code:internal/hooks/status.go,code:internal/fs/atomic.go,code:internal/jsonstrict/duplicate.go | A:ValidateOpenCodeMCPInstall,InstallOpenCodeMCP | S:Duplicate keys (exact check; case-distinct user keys are legal), JSONC, V2 mcp.servers, alternate paths, conflicts, unsafe targets fail closed; exact config is a no-op; create or merge is CAS-bound @@ -484,9 +484,9 @@ duplicate.go[CG7M]: F:Rejects duplicate JSON object keys, exactly or under case ledger.go[CG7L]: F:Best-effort appends versioned local JSONL audit and measurement events and reads recent valid history | R:- | A:Append,Recent,EstimateTokens,EndpointHash | S:Non-authoritative: disabled or failed writes never block callers; a corrupt or over-1 MiB line is skipped and counted, never ending the read; secrets, source, prompts or chat bodies not recorded ===/home/alkor2000/aoci-code-public-staging-phase1/public-candidate/internal/llm/=== -client.go[CG7M]: F:Calls an explicitly configured OpenAI-compatible endpoint for bounded optional authoring tasks | R:- | A:- | S:- -errors.go[CG7S]: F:Classifies endpoint, HTTP, schema, timeout, and response failures for safe caller handling | R:- | A:- | S:- -options.go[CG7S]: F:Defines validated options and defaults for constructing the optional OpenAI-compatible endpoint client | R:code:internal/llm/client.go,code:internal/llm/errors.go | A:Options,Client,NewClient,Client.Model,DefaultTimeout,DefaultChatCompletionsPath | S:Timeout and MaxInputTokens are metadata; caller context and workflow own deadlines and truncation, the default HTTP client prevents endless waits, and the package never reads or persists secrets +client.go[CG7M]: F:Performs bounded OpenAI-compatible authoring requests and returns response text, usage and classified safe failures | R:code:internal/llm/options.go,code:internal/llm/errors.go | A:Client.Complete | S:Endpoint error bodies must never enter error strings or persisted draft notes, including 200 error envelopes; absent usage stays missing +errors.go[CG7S]: F:Classifies endpoint failures and retry eligibility while exposing safe error messages and typed cause traversal | R:- | A:Error.Error,Error.Unwrap,Error.IsRetryable | S:Error formatting must omit wrapped transport details, which can contain credentials or URLs; Unwrap preserves errors.Is and errors.As classification +options.go[CG8S]: F:Validates optional model endpoints and constructs clients that refuse redirects while preserving supplied transports and timeouts | R:code:internal/llm/client.go,code:internal/llm/errors.go | A:Options,Client,NewClient,Client.Model,DefaultTimeout,DefaultChatCompletionsPath | S:Reject URL user information, query strings and fragments; clone supplied HTTP clients before replacing redirect policy; caller context and workflow own deadlines and input limits; credentials are never read from environment or persisted by this package ===/home/alkor2000/aoci-code-public-staging-phase1/public-candidate/internal/migrationapply/=== approval.go[CG7T]: F:Creates and validates human-confirmed digest-bound Legacy migration approval audit records | R:code:internal/migrationapply/transaction.go,code:internal/cognitiontxn/transaction.go | A:RecordApproval,ValidateApproval | S:Approval is an audit binding, not cryptographic authentication; exact envelope, mapping, write set, recovery policy, actor, and UTC timestamp are revalidated under Apply @@ -623,7 +623,7 @@ README.md[CG5M]: F:Documents the four Python suites and the Go longrun runner: l generate_repo_c.py[CG5L]: F:Generates the frozen 453-file layered fixture that lets the scale suite reach the real batch limit with realistic cross-batch relations | R:- | A:python3 scripts/blackbox/generate_repo_c.py | S:Regenerating changes the fixture identity the scale suite asserts; generated names must avoid the built-in sensitive and runtime patterns, and a NUL byte makes Curation silently skip the file mcp_conformance.py[CG8L]: F:Drives the built binary as a real MCP stdio client and checks the wire surface: handshake, nine tools, schemas, response shapes, malformed input, locale twins | R:code:scripts/blackbox/README.md,code:README.md,code:README.zh-CN.md,code:internal/mcptools/server.go,code:scripts/blackbox/stdio_deadline.py,code:scripts/blackbox/stdio_capture.py | A:python3 scripts/blackbox/mcp_conformance.py,AOCI_REPO,AOCI_BIN,AOCI_EXPECT_VERSION | S:Read-only on the host repository, which must be aligned with a multi-chunk Overview; the 48 KiB host window is measured on a real host; the published count (53) is a suite property and the run fails when a document disagrees; the Overview reader follows the exact cursor chain; the zh-CN lane builds en-US and zh-CN twin fixtures in a temp dir and demands identical machine fields with localized sentences mcp_lifecycle.py[CG8L]: F:Runs init-to-realignment lifecycles over three frozen fixtures, bring-up lanes for submodules, root lists, removal, machine documents, zh-CN, and a model track | R:code:scripts/blackbox/README.md,code:scripts/blackbox/generate_repo_c.py,code:scripts/blackbox/mcp_scenarios.py,code:internal/cli/init.go | A:python3 scripts/blackbox/mcp_lifecycle.py,--suites,--model,--compare,--mysql-image | S:repo-a/b cover drift and re-alignment, repo-c multi-batch authoring at the raised limit; the database suite needs Docker and takes --mysql-image, mapping the fixture's two 8.0-only statements to 5.7 in memory; committed fixtures must be named; governance walks the pending-policy path and held-source probes to aligned; the submodule lane needs protocol.file.allow; the import scratch dir is removed at exit -mcp_scenarios.py[CG8L]: F:Drives hostile handling of fixtures: cursors, write rejection, crashes, races, Scope Change, activation, held sources, batches, next commands, page, hook | R:code:scripts/blackbox/README.md,code:README.md,code:README.zh-CN.md,code:internal/mcptools/tools_next_commands.go,code:internal/mcptools/tools_maintain_budget.go,code:internal/ui/server.go,code:internal/cli/hook.go | A:python3 scripts/blackbox/mcp_scenarios.py,--group,--list,AOCI_SCENARIO_WORK,AOCI_SCENARIO_KEEP | S:A writing scenario builds its own fixture; the host repository stays read-only; budget, Guide batch, and next-command group are measured; past 50 per batch the budget is raised too; tty needs a real pty; paths are slash-normalized on Windows; Scope Change over a changed source: Volumes retains, Legacy blocks; O2 pins S-absent, E mismatch, C; a --group run skips the published-count check +mcp_scenarios.py[CG8L]: F:Exercises hostile MCP and CLI lifecycle scenarios, including authenticated page traffic, recovery, races, scope governance and host hooks | R:code:scripts/blackbox/README.md,code:internal/mcptools/tools_next_commands.go,code:internal/mcptools/tools_maintain_budget.go,code:internal/ui/server.go,code:internal/cli/hook.go | A:python3 scripts/blackbox/mcp_scenarios.py,--group,--list,AOCI_SCENARIO_WORK,AOCI_SCENARIO_KEEP | S:Writes use disposable fixtures and leave the host repo read-only; page helpers extract readiness-fragment tokens into headers and verify anonymous refusal; actual PTY and measured batch budgets remain mandatory; Windows paths are normalized; selective groups skip the published count check mcp_upgrade.py[CG8L]: F:Proves the upgrade axis both ways: repositories authored by released binaries stay governable by the binary under test, and each release reads one it wrote | R:code:scripts/blackbox/README.md,code:internal/cognitionbudget/policy.go,code:internal/config/scope_budget.go,code:.github/workflows/full-confidence.yml,code:scripts/blackbox/stdio_deadline.py,code:scripts/blackbox/stdio_capture.py,code:internal/index/parser.go | A:python3 scripts/blackbox/mcp_upgrade.py,--versions,--recent,--self,--allow-offline,AOCI_UPGRADE_CACHE | S:Only nobudget reaches LegacyPolicy; spacedroot and cutsegment carry roots truncated up to rc13; inflight applies an unsubmitted batch via MCP; legacydirs pins the released policy's roles: any-depth to rc19, root list from rc20; downgrade expects aligned from rc20, before it a named scope_change_required refusal with zero formal writes; --self labels the candidate from the CHANGELOG top; fetch failures fail the run stdio_deadline.py[CG5T]: F:Wraps MCP RPC I/O in a wall-clock watchdog that kills and reaps the server on expiry and includes captured stderr in its method-specific TimeoutError | R:code:scripts/blackbox/mcp_conformance.py,code:scripts/blackbox/mcp_scenarios.py,code:scripts/blackbox/mcp_upgrade.py | A:rpc_deadline | S:A clock check around readline cannot interrupt a silent or partial line and select does not cover Windows pipes, so expiry kills the server; an expired session is unusable by design stdio_capture.py[CG5T]: F:Drains an MCP test server's stderr on a daemon thread, retains a bounded 16 KiB tail, and appends it to early-EOF diagnostics | R:code:scripts/blackbox/mcp_conformance.py,code:scripts/blackbox/mcp_scenarios.py,code:scripts/blackbox/mcp_upgrade.py | A:BoundedStderr,stderr_failure | S:An unread stderr pipe fills the OS buffer and blocks both sides; failure joins briefly and tolerates a session without a collector so bare test construction stays valid @@ -641,13 +641,13 @@ discover.go[CG5T]: F:Recognises an aoci mcp command line and extracts the reposi discover_linux.go[CG5T]: F:Lists the current user's running aoci mcp processes from /proc with each one's repository, executable, and start time | R:code:internal/ui/discover.go | A:discoverRunningServers,discoverRunningServersFrom | S:Ownership must match os.Geteuid before cmdline is read; missing metadata skips it. exe (deleted) reports replacement; relative roots use /proc cwd; deleted or unreadable cwd skips discover_other.go[CG3T]: F:Reports no running servers where no portable, permission-safe process scan exists | R:code:internal/ui/discover.go | A:- | S:Only named repositories are shown embed.go[CG3T]: F:Embeds the status page into the binary so it needs no external file or network resource | R:code:internal/ui/page.html | A:- | S:The page must stay offline and self-contained -page.html[CG5L]: F:Renders the status page: a verbatim index pane with an All tab on the left; recommended input, status, and environment in a self-scrolling right column | R:code:internal/ui/server.go | A:- | S:All is the Overview body; bad answers keep prior data and polling; raw failures never cache or copy; views bind to root, order, and cache generation; filtering tracks section and banner in one pass -server.go[CG5M]: F:Serves the loopback-only read-only routes: page, repositories, state with ETag, entries, every formal asset verbatim, and the Overview body | R:code:internal/ui/cache.go,code:internal/ui/snapshot.go,code:internal/ui/discover.go,code:internal/ui/registry.go | A:Serve,Options,ErrNotLoopback,ErrNoRepository | S:Host must equal the listener address; non-loopback binds fail before listening; GET/HEAD only; refresh=1 rechecks; raw Volumes serve the snapshot's validated bytes; registrations stay outside the repo +page.html[CG8L]: F:Renders the local cognition and status page with authenticated API reads, locale controls, filtering, refresh and verbatim asset copying | R:code:internal/ui/server.go | A:- | S:Capability comes from the URL fragment or tab session storage, never persistent local storage or request URLs; remove fragment after successful storage and refuse fetch redirects; failed refreshes retain prior data; failed raw reads are never cached or copied; views remain bound to root, sequence and cache generation +server.go[CG8M]: F:Serves a loopback status-page shell and capability-protected repository lists, governance snapshots, entries and raw cognition assets | R:code:internal/ui/cache.go,code:internal/ui/snapshot.go,code:internal/ui/discover.go,code:internal/ui/registry.go | A:Serve,Options,ErrNotLoopback,ErrNoRepository | S:Fresh random capability per process; authenticate data routes before lookup or ETag handling, including HEAD; empty credentials fail closed; static shell contains neither token nor repository data; exact Host and GET/HEAD restrictions remain; responses forbid caching, framing and referrers snapshot.go[CG5M]: F:Builds one repository's read-only picture: governance facts, chunk plan, source coverage, per-locale suggestions, the Guide, and host integrations | R:code:internal/volumegovernance/facts.go,code:internal/cli/ui.go,code:internal/mcptools/overview_plan_export.go,code:internal/fs/countlines.go | A:Snapshot,Coverage,Suggestion,GuideFunc | S:Assess is called directly because check and verify append Ledger events; coverage counts unreadable source files instead of guessing; Overview text and validated Volume bytes stay outside JSON detach.go[CG5T]: F:Starts the page as its own session detached from the calling shell and waits for its readiness line before returning its URL and PID | R:code:internal/ui/registry.go,code:internal/cli/ui.go | A:Detach,DetachResult | S:The child is the same binary running the same command, so a detached page behaves exactly like a foreground one; a child that exits or stays silent past the deadline is killed and reported detach_unix.go[CG3T]: F:Puts the page in its own session on Unix and asks it to stop with SIGTERM so it unregisters itself | R:code:internal/ui/detach.go | A:- | S:- detach_windows.go[CG3T]: F:Starts the page without a console in its own process group on Windows and ends it with Kill, which has no graceful form | R:code:internal/ui/detach.go | A:- | S:- -registry.go[CG5S]: F:Registers a running page in the user cache directory so a later detach reuses it and stop can end it, proving liveness through the page | R:code:internal/ui/server.go,code:internal/ui/detach.go,code:internal/cli/ui.go,code:internal/fs/lock.go | A:RegistryDir,Register,Registered,Live,Stop,LogPath | S:Liveness is the page answering for the root, never the PID, which a process that must not be signalled may reuse; Register and Unregister share a cache-dir lock; Stop removes only its captured PID +registry.go[CG8M]: F:Stores private local-page registrations, verifies authenticated loopback liveness and stops only the registered live process | R:code:internal/ui/server.go,code:internal/ui/detach.go,code:internal/cli/ui.go,code:internal/fs/lock.go,code:internal/fs/atomic.go | A:RegistryDir,Register,Registered,Live,Stop,HasAccessToken | S:Capability-bearing registrations use 0600; probes allow numeric HTTP loopback only and never follow redirects; old tokenless registrations remain stoppable; liveness must prove the repository, not just PID; mutations share the cache lock and removal checks the captured PID ===/home/alkor2000/aoci-code-public-staging-phase1/public-candidate/internal/testkit/=== testkit.go[CG5M]: F:Provides shared test helpers for repository fixtures: git init and commits, file writing, tree copying, locale pinning, stdout capture, and a fixture builder | R:code:textassets/catalog.go,code:internal/cli/root.go | A:Git,Commit,WriteFile,CopyTree,PinLocale,NewFixture | S:Imports only the standard library and textassets so cli and mcptools tests share it without a cycle; PinLocale restores the process locale via t.Cleanup, so pinned tests never run in parallel diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index ec69a6a2..d9718406 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -477,3 +477,35 @@ it: aoci ui --detach --json aoci ui --stop ``` + +Open the complete link printed by the current process: the fragment carries +the page's access capability. Repository API requests without it return 401. +The browser stores it for the current tab, never in persistent local storage, +and removes it from the address bar when session storage is available. Do not +share the readiness link. A legacy page can still be stopped, but a new detach +command refuses to replace its registration while it is live: run +`aoci ui --stop` for that repository first. Only authenticated pages are reused. +Before downgrading, stop the page +using the same binary that started it. + +## Governed writes on filesystems without atomic exchange + +On Linux, a governed replacement requires native `RENAME_EXCHANGE`. If the +filesystem refuses it, the operation returns `ErrAtomicCASUnavailable` without +replacing the target; move the checkout to a filesystem supporting atomic +exchange, such as the Linux filesystem inside WSL, rather than a DrvFs mount. +The former link/rename compatibility path is no longer used for new writes +because it cannot preserve a concurrent replacement by an external editor. +Older capture/intent artifacts retain their format and recovery reader. If a +recovery needs an exchange the current filesystem cannot provide, preserve the +artifacts and recover on a supported filesystem. Do not delete recovery files +or accept a weakened replacement to clear the error. + +## Model endpoint refusals + +Configure the final HTTP(S) completion base URL directly. The optional model +client does not follow redirects, and base URLs with user information, query +strings or fragments are refused. Authentication remains in the configured +credential environment variable. Error output keeps the HTTP status and error +category, but omits the endpoint's arbitrary error text and transport detail; +diagnose those details in the provider's own protected logs. diff --git a/internal/cli/ui.go b/internal/cli/ui.go index 3e480a71..cf6022a5 100644 --- a/internal/cli/ui.go +++ b/internal/cli/ui.go @@ -125,6 +125,11 @@ func stopUI(cmd *cobra.Command, registry, root string) error { // otherwise starts the same command as a detached child. func detachUI(cmd *cobra.Command, registry, root string, port int, host string, discover bool, also []string, open bool) error { if reg, ok := ui.Registered(registry, root); ok && ui.Live(reg, time.Second) { + if !ui.HasAccessToken(reg.URL) { + // Keep the old registration reachable by --stop. Starting another + // page would overwrite it and leave the unprotected process running. + return &ExitError{Code: ExitConfig, Msg: cliMessage("ui.error.legacy_page")} + } reportDetached(cmd, reg.URL, reg.PID, -1, true) if open { openBrowser(reg.URL) diff --git a/internal/cli/ui_security_test.go b/internal/cli/ui_security_test.go new file mode 100644 index 00000000..3f4837c6 --- /dev/null +++ b/internal/cli/ui_security_test.go @@ -0,0 +1,45 @@ +package cli + +import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/spf13/cobra" + + "github.com/aoci-spec/aoci-code/internal/ui" +) + +func TestDetachPreservesLiveLegacyPageUntilExplicitStop(t *testing.T) { + root, registry := t.TempDir(), t.TempDir() + legacy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/repos" { + http.NotFound(w, r) + return + } + _ = json.NewEncoder(w).Encode([]map[string]string{{"root": root}}) + })) + defer legacy.Close() + reg := ui.Registration{URL: legacy.URL, Root: root, PID: 42} + if err := ui.Register(registry, reg); err != nil { + t.Fatal(err) + } + var output bytes.Buffer + command := &cobra.Command{} + command.SetOut(&output) + err := detachUI(command, registry, root, 0, "127.0.0.1", false, nil, false) + var exit *ExitError + if !errors.As(err, &exit) || exit.Code != ExitConfig { + t.Fatalf("live legacy page was not refused: %v", err) + } + if output.Len() != 0 { + t.Fatal("legacy page was reported as a usable panel") + } + got, ok := ui.Registered(registry, root) + if !ok || got.URL != reg.URL || got.PID != reg.PID { + t.Fatal("legacy registration was replaced instead of preserved for --stop") + } +} diff --git a/internal/fs/atomic.go b/internal/fs/atomic.go index 223a9bf0..2e2cac74 100644 --- a/internal/fs/atomic.go +++ b/internal/fs/atomic.go @@ -63,6 +63,13 @@ func AtomicWrite(targetPath string, data []byte) error { return atomicWrite(targetPath, data) } +// AtomicWriteMode publishes with an explicit permission ceiling, including +// when replacing an existing file. Callers storing private backups must not +// inherit the generic new-file mode or a prior backup's broader permissions. +func AtomicWriteMode(targetPath string, data []byte, mode os.FileMode) error { + return atomicWriteWithPerm(targetPath, data, &mode) +} + // AtomicCreateCAS publishes complete bytes only when targetPath is absent at // the atomic publication instant. It never falls back to an overwrite rename. func AtomicCreateCAS(targetPath string, data []byte) error { diff --git a/internal/fs/atomic_exchange_linux.go b/internal/fs/atomic_exchange_linux.go index cb24df7d..4a8fd762 100644 --- a/internal/fs/atomic_exchange_linux.go +++ b/internal/fs/atomic_exchange_linux.go @@ -1,53 +1,17 @@ -// Linux原子路径交换 —— CAS必须捕获交换瞬间的真实preimage。 -// -// renameat2 with RENAME_EXCHANGE is the primitive, but like RENAME_NOREPLACE -// next door it is a filesystem capability, not a kernel one: WSL's DrvFs (9p) -// answers EINVAL, so every governed CAS write under /mnt/c failed with -// "原子交换目标失败: invalid argument" while `aoci init` on the same path had -// worked since the no-replace path learned to probe (#116). The flag is now -// probed, not assumed. When it is refused the exchange is emulated with a hard -// link and two renames, which DrvFs honours, and the emulation ends in the -// exact post-state the primitive produces: first holds second's old inode and -// second holds first's old inode, so AtomicWriteCAS's preimage check and -// rollbackAtomicCAS's swap-back run unchanged on top of either. -// -// What the emulation cannot give is a zero-width replacement window. The -// primitive swaps both names in one step; the emulation holds the target's -// inode under a capture link, renames second over first, then renames the -// capture into second. A non-cooperating writer that replaces the target by -// rename between the link and the first rename is overwritten rather than -// detected, because the exchange never sees its inode. A writer that modifies -// the target in place during that window is still detected, because the -// capture link shares the inode and the modified bytes arrive in the swap as -// the captured preimage. Every cooperating aoci writer is serialized by the -// cross-process CAS lock AtomicWriteCAS takes on the target before any of -// this runs, so the window is only ever visible to writers outside aoci. -// -// A crash inside the emulation leaves the capture link beside the swap; -// normalizeAtomicExchangeArtifacts converges both states before recovery -// judges digests, so the next call finds the same two assets it would have -// found after a crashed primitive exchange. +// Linux CAS requires native atomic exchange. Link/rename emulation can +// overwrite a non-cooperating writer's replacement and therefore fails closed. +// Old capture artifacts remain readable for recovery after an upgrade. package fs import ( "errors" - "fmt" "os" "golang.org/x/sys/unix" ) func exchangeAtomicPaths(first, second string) error { - err := renameat2Exchange(first, second) - if err == nil || !renameat2FlagUnsupported(err) { - return err - } - if fallbackErr := exchangeAtomicPathsByLink(first, second); fallbackErr != nil { - // Name both, or a future reader sees only the second failure and cannot - // tell that the primitive was refused before the fallback was tried. - return fmt.Errorf("renameat2 refused the exchange flag (%w) and the link fallback failed: %w", err, fallbackErr) - } - return nil + return renameat2Exchange(first, second) } // renameat2Exchange is the primitive behind exchangeAtomicPaths. It is a @@ -71,32 +35,6 @@ func atomicExchangeCapturePath(second string) string { return second + ".capture" } -// exchangeAtomicPathsByLink is the exchange built from link and rename. -// -// link(first, capture) capture and first now name the same inode -// rename(second, first) first names second's inode; the old inode lives on in capture -// rename(capture, second) second names the old inode; capture is gone -// -// A failure before the second rename takes effect is undone by unlinking the -// capture, which is redundant with first. A failure after it leaves first -// already replaced and the old inode only under the capture; that is state (b) -// of normalizeAtomicExchangeArtifacts and is left for it rather than reversed -// here, since the reverse would be two more renames that can fail in turn. -func exchangeAtomicPathsByLink(first, second string) error { - capture := atomicExchangeCapturePath(second) - if err := unix.Link(first, capture); err != nil { - return &linkError{op: "link", err: err} - } - if err := unix.Rename(second, first); err != nil { - _ = unix.Unlink(capture) - return &linkError{op: "rename second over first", err: err} - } - if err := unix.Rename(capture, second); err != nil { - return &linkError{op: "rename capture to second", err: err} - } - return nil -} - // normalizeAtomicExchangeArtifacts converges the two states a crash inside the // link emulation can leave, so recoverAtomicCAS only ever judges the target and // swap digests it already understands. It runs on every filesystem, because an diff --git a/internal/fs/atomic_exchange_linux_fallback_test.go b/internal/fs/atomic_exchange_linux_fallback_test.go index f7cecf6c..88b9c81b 100644 --- a/internal/fs/atomic_exchange_linux_fallback_test.go +++ b/internal/fs/atomic_exchange_linux_fallback_test.go @@ -4,23 +4,13 @@ package fs import ( "errors" + "golang.org/x/sys/unix" "os" "path/filepath" "strings" "testing" - - "golang.org/x/sys/unix" ) -// A filesystem that refuses RENAME_EXCHANGE outright (DrvFs answers EINVAL) -// must still complete every governed CAS write through the link emulation, -// with the same preimage capture, rollback, and recovery contract the -// primitive gives. ext4 honours the flag, so the refusal is injected at the -// syscall seam; everything after it — the link, the two renames, the digests, -// the rollback, and the recovery bookkeeping — is real. Each public-entry -// test here mirrors the ext4 test that pins the same outcome, so the two -// paths can never drift apart unnoticed. - func withRenameat2ExchangeAnswer(t *testing.T, answer error) { t.Helper() previous := renameat2Exchange @@ -56,182 +46,59 @@ func casTestWriteTarget(t *testing.T, data []byte, perm os.FileMode) string { return path } -// Locks: each "cannot honour the flag" answer routes to the link emulation and -// the write completes exactly as on ext4 — new bytes canonical, the target's -// own permission kept, and no intent, swap, capture, or staging name left -// beside it. Drop the fallback and every governed write under /mnt/c fails -// again with "原子交换目标失败: invalid argument". -func TestAtomicWriteCASEmulatesTheExchangeWhenTheFlagIsRefused(t *testing.T) { +func TestAtomicWriteCASRefusesUnsupportedExchange(t *testing.T) { for _, refusal := range []error{unix.EINVAL, unix.ENOSYS, unix.EOPNOTSUPP} { t.Run(refusal.Error(), func(t *testing.T) { withRenameat2ExchangeAnswer(t, refusal) original := []byte("original\n") path := casTestWriteTarget(t, original, 0o640) - if err := AtomicWriteCAS(path, []byte("ours\n"), casTestDigest(original)); err != nil { - t.Fatalf("the emulation must complete the write: %v", err) + err := AtomicWriteCAS(path, []byte("ours\n"), casTestDigest(original)) + if !errors.Is(err, ErrAtomicCASUnavailable) { + t.Fatalf("unsupported exchange must fail closed: %v", err) } - if got := casTestRead(t, path); got != "ours\n" { - t.Fatalf("canonical bytes = %q", got) + if casTestRead(t, path) != string(original) { + t.Fatal("unsupported exchange changed target") } info, err := os.Stat(path) if err != nil || info.Mode().Perm() != 0o640 { - t.Fatalf("target permission must survive the emulation: err=%v mode=%v", err, info.Mode().Perm()) + t.Fatal("target permission changed") } if names := casTestNeighbours(t, path); len(names) != 0 { - t.Fatalf("a completed write must leave nothing beside the target: %v", names) + t.Fatalf("unexpected artifacts: %v", names) } }) } } -// Locks: through the emulation a stale expectation is still refused before -// anything is exchanged, and the target keeps its bytes. -func TestAtomicWriteCASEmulationRefusesAStaleExpectation(t *testing.T) { - withRenameat2ExchangeAnswer(t, unix.EINVAL) - original := []byte("original\n") - path := casTestWriteTarget(t, original, 0o644) - err := AtomicWriteCAS(path, []byte("ours\n"), casTestDigest([]byte("something else\n"))) - if !errors.Is(err, ErrAtomicCASConflict) { - t.Fatalf("a stale expectation must be a CAS conflict: %v", err) - } - if got := casTestRead(t, path); got != string(original) { - t.Fatalf("a refused write changed the target: %q", got) - } - if names := casTestNeighbours(t, path); len(names) != 0 { - t.Fatalf("a refused write must leave nothing beside the target: %v", names) - } -} - -// Locks: the same outcome TestAtomicWriteCASPreservesChangeAtReplacementBoundary -// pins for the primitive. A writer that modifies the target in place after the -// preimage check and before the exchange is still caught by the emulation, -// because the capture link shares the inode and its bytes arrive in the swap -// as the captured preimage: the call is an ordinary conflict, not a recovery -// condition, the external bytes end up canonical, and nothing is left beside -// the target. -func TestAtomicWriteCASEmulationRestoresTheWriteAtTheReplacementBoundary(t *testing.T) { - withRenameat2ExchangeAnswer(t, unix.EINVAL) - original := []byte("original\n") - external := []byte("external-at-boundary\n") - path := casTestWriteTarget(t, original, 0o644) - previousHook := beforeAtomicCASExchange - beforeAtomicCASExchange = func(target string) { - if err := os.WriteFile(target, external, 0o644); err != nil { - t.Fatalf("inject boundary write: %v", err) - } - } - t.Cleanup(func() { beforeAtomicCASExchange = previousHook }) - - err := AtomicWriteCAS(path, []byte("ours\n"), casTestDigest(original)) - if !errors.Is(err, ErrAtomicCASConflict) || errors.Is(err, ErrAtomicCASRecovery) { - t.Fatalf("a boundary write is an ordinary conflict through the emulation too: %v", err) - } - if got := casTestRead(t, path); got != string(external) { - t.Fatalf("the external bytes captured at the exchange must be restored: %q", got) - } - if names := casTestNeighbours(t, path); len(names) != 0 { - t.Fatalf("a rolled-back conflict must leave nothing beside the target: %v", names) - } -} - -// Locks: the same outcome TestAtomicWriteCASQuarantinesCapturedVersionWhenRollbackRaces -// pins for the primitive, driven through three consecutive emulated exchanges -// (capture, rollback, re-exchange) that reuse one capture name in both -// directions. The newest external version stays canonical, the version caught -// at the first exchange is quarantined rather than deleted, and the intent and -// swap are cleared. -func TestAtomicWriteCASEmulationQuarantinesTheCapturedVersionWhenRollbackRaces(t *testing.T) { - withRenameat2ExchangeAnswer(t, unix.EINVAL) +func TestAtomicWriteCASUnsupportedExchangePreservesExternalReplacement(t *testing.T) { original := []byte("original\n") - boundary := []byte("external-at-boundary\n") - later := []byte("external-during-rollback\n") - path := casTestWriteTarget(t, original, 0o644) - previousBoundary := beforeAtomicCASExchange - beforeAtomicCASExchange = func(target string) { - if err := os.WriteFile(target, boundary, 0o644); err != nil { - t.Fatalf("inject boundary write: %v", err) + path := casTestWriteTarget(t, original, 0o600) + previous := renameat2Exchange + renameat2Exchange = func(first, second string) error { + replacement := first + ".external" + if err := os.WriteFile(replacement, []byte("external\n"), 0o600); err != nil { + t.Fatal(err) } - } - previousExchange := exchangeAtomicPathsPlatform - calls := 0 - exchangeAtomicPathsPlatform = func(first, second string) error { - calls++ - if calls == 2 { - if err := os.WriteFile(first, later, 0o644); err != nil { - t.Fatalf("inject rollback-race write: %v", err) - } + if err := os.Rename(replacement, first); err != nil { + t.Fatal(err) } - return previousExchange(first, second) - } - t.Cleanup(func() { - beforeAtomicCASExchange = previousBoundary - exchangeAtomicPathsPlatform = previousExchange - }) - - err := AtomicWriteCAS(path, []byte("ours\n"), casTestDigest(original)) - if !errors.Is(err, ErrAtomicCASConflict) || !errors.Is(err, ErrAtomicCASRecovery) { - t.Fatalf("rollback race must report conflict and recovery together: %v", err) + return unix.EINVAL } - if calls != 3 { - t.Fatalf("expected exchange, rollback, and re-exchange, got %d exchanges", calls) - } - if got := casTestRead(t, path); got != string(later) { - t.Fatalf("newest external version must stay canonical: %q", got) - } - names := casTestNeighbours(t, path) - if len(names) != 1 || !strings.HasPrefix(names[0], "asset.txt.aoci-cas-conflict.") { - t.Fatalf("exactly the quarantine file may remain beside the target: %v", names) - } - if got := casTestRead(t, filepath.Join(filepath.Dir(path), names[0])); got != string(boundary) { - t.Fatalf("quarantine must hold the version captured at the first exchange: %q", got) - } -} - -// Locks: when the flag is refused and the link step of the emulation is -// refused too, the error names both failures and the target is untouched. The -// capture name is aoci's own staging name, so a stray file there is what a -// crashed emulation leaves, and recovery converges it (state (a): the swap -// still exists, so the capture is redundant) before the next call, which then -// succeeds. -func TestAtomicWriteCASNamesBothFailuresWhenTheLinkFallbackIsRefused(t *testing.T) { - withRenameat2ExchangeAnswer(t, unix.EINVAL) - original := []byte("original\n") - path := casTestWriteTarget(t, original, 0o644) - capture := atomicExchangeCapturePath(path + ".aoci-cas.swap") - if err := os.WriteFile(capture, []byte("stray\n"), 0o644); err != nil { - t.Fatal(err) - } - + t.Cleanup(func() { renameat2Exchange = previous }) err := AtomicWriteCAS(path, []byte("ours\n"), casTestDigest(original)) if !errors.Is(err, ErrAtomicCASUnavailable) { - t.Fatalf("a refused fallback is reported as unavailable, never as success: %v", err) + t.Fatalf("expected refusal: %v", err) } - if !strings.Contains(err.Error(), "renameat2 refused the exchange flag (invalid argument)") || - !strings.Contains(err.Error(), "link fallback failed: link: file exists") { - t.Fatalf("error must name both steps: %v", err) - } - if got := casTestRead(t, path); got != string(original) { - t.Fatalf("a refused exchange changed the target: %q", got) + if casTestRead(t, path) != "external\n" { + t.Fatal("external replacement lost") } if names := casTestNeighbours(t, path); len(names) != 0 { - t.Fatalf("recovery after the refusal must leave nothing beside the target: %v", names) - } - if err := AtomicWriteCAS(path, []byte("ours\n"), casTestDigest(original)); err != nil { - t.Fatalf("the next call must succeed once the stray capture is gone: %v", err) - } - if got := casTestRead(t, path); got != "ours\n" { - t.Fatalf("canonical bytes = %q", got) + t.Fatalf("unexpected artifacts: %v", names) } } -// Locks: each state a crash inside the emulation can leave on disk converges -// exactly one way, and it converges whether or not the filesystem the next -// call runs on honours the flag — an index crashed on DrvFs may be recovered -// after a move to ext4. The states are hand-built: a capture link beside a -// still-present swap means rename(second, first) never happened, a capture -// with the swap absent means it did and rename(capture, second) did not. Both -// directions of the exchange (the write, its rollback, and the rollback's -// re-exchange) share the one capture name, so all three are covered. +// Existing capture/intent formats still converge on a filesystem providing +// native exchange. No new unsupported-filesystem write is authorised by recovery. func TestAtomicWriteCASConvergesEachEmulationCrashState(t *testing.T) { original := []byte("original\n") ours := []byte("ours\n") @@ -296,7 +163,7 @@ func TestAtomicWriteCASConvergesEachEmulationCrashState(t *testing.T) { for _, flag := range []struct { name string answer error // nil keeps the real primitive - }{{name: "flag refused", answer: unix.EINVAL}, {name: "flag honoured"}} { + }{{name: "flag honoured"}} { t.Run(flag.name, func(t *testing.T) { if flag.answer != nil { withRenameat2ExchangeAnswer(t, flag.answer) @@ -360,10 +227,6 @@ func TestAtomicWriteCASConvergesEachEmulationCrashState(t *testing.T) { } } -// Locks: where the flag is honoured the primitive is taken and the capture -// name is never consulted. A sentinel planted at that name survives the write -// untouched; had the fallback run, its link step would have been refused and -// recovery would have removed the sentinel as a stray capture. func TestAtomicWriteCASUsesThePrimitiveWhereTheFlagIsHonoured(t *testing.T) { skipUnlessTheExchangeFlagIsHonoured(t) original := []byte("original\n") @@ -423,70 +286,6 @@ func TestExchangeAtomicPathsPropagatesRealRefusalsWithoutFallback(t *testing.T) } } -// Locks: the emulation ends in the primitive's post-state — first names -// second's old inode and second names first's old inode, not copies of their -// bytes — and leaves no capture. A failure of the rename over first unlinks -// the capture again, names its step, and leaves first untouched. -func TestExchangeAtomicPathsByLinkSwapsInodes(t *testing.T) { - dir := t.TempDir() - first := filepath.Join(dir, "first") - second := filepath.Join(dir, "second") - if err := os.WriteFile(first, []byte("first\n"), 0o600); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(second, []byte("second\n"), 0o644); err != nil { - t.Fatal(err) - } - firstBefore, err := os.Stat(first) - if err != nil { - t.Fatal(err) - } - secondBefore, err := os.Stat(second) - if err != nil { - t.Fatal(err) - } - if err := exchangeAtomicPathsByLink(first, second); err != nil { - t.Fatalf("emulated exchange failed: %v", err) - } - firstAfter, err := os.Stat(first) - if err != nil { - t.Fatal(err) - } - secondAfter, err := os.Stat(second) - if err != nil { - t.Fatal(err) - } - if !os.SameFile(firstAfter, secondBefore) || !os.SameFile(secondAfter, firstBefore) { - t.Fatal("the emulation must swap the inodes, not rewrite bytes") - } - if casTestRead(t, first) != "second\n" || casTestRead(t, second) != "first\n" { - t.Fatal("bytes did not follow the inodes") - } - if entries, _ := os.ReadDir(dir); len(entries) != 2 { - t.Fatalf("the capture must not survive a completed exchange: %v", entries) - } - - // The rename over first fails because second is gone: the capture is - // unlinked again and first keeps its inode. - if err := os.Remove(second); err != nil { - t.Fatal(err) - } - err = exchangeAtomicPathsByLink(first, second) - if !errors.Is(err, unix.ENOENT) || !strings.HasPrefix(err.Error(), "rename second over first: ") { - t.Fatalf("the failing step must be named and its errno unwrap: %v", err) - } - firstKept, err := os.Stat(first) - if err != nil || !os.SameFile(firstKept, firstAfter) { - t.Fatalf("first must be untouched after a failed emulation: %v", err) - } - if entries, _ := os.ReadDir(dir); len(entries) != 1 { - t.Fatalf("the capture must be unlinked after a failed rename: %v", entries) - } -} - -// Locks: normalization is a no-op without a capture, removes a capture that -// still has its swap beside it, and promotes a capture whose swap is absent -// into the swap — the two crash states, converged before any digest is read. func TestNormalizeAtomicExchangeArtifactsConvergesBothCaptureStates(t *testing.T) { dir := t.TempDir() swap := filepath.Join(dir, "asset.txt.aoci-cas.swap") diff --git a/internal/hooks/installer.go b/internal/hooks/installer.go index 57856729..9e2dcc4a 100644 --- a/internal/hooks/installer.go +++ b/internal/hooks/installer.go @@ -139,7 +139,9 @@ func backupCurrent(path string) error { digest := sha256.Sum256(old) bak := path + ".backup." + time.Now().Format("20060102_150405") + "." + hex.EncodeToString(digest[:]) - if err := afs.AtomicWrite(bak, old); err != nil { + // Host configurations may include credentials for unrelated servers. + // Backups are private even when the original configuration is public. + if err := afs.AtomicWriteMode(bak, old, 0o600); err != nil { return errors.New(hookMessage("hook.backup_error", path, err)) } } diff --git a/internal/hooks/installer_backup_test.go b/internal/hooks/installer_backup_test.go index 91d746ef..51217a85 100644 --- a/internal/hooks/installer_backup_test.go +++ b/internal/hooks/installer_backup_test.go @@ -3,6 +3,7 @@ package hooks import ( "os" "path/filepath" + "runtime" "testing" ) @@ -37,3 +38,39 @@ func TestBackupThenWritePreservesDistinctSameSecondPreimages(t *testing.T) { t.Fatalf("备份内容不完整: original=%v first=%v", seenOriginal, seenFirst) } } + +func TestBackupThenWriteKeepsHostConfigurationBackupsPrivate(t *testing.T) { + for _, mode := range []os.FileMode{0o600, 0o640, 0o644} { + t.Run(mode.String(), func(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.toml") + original := []byte("token = \"synthetic-config-value\"\n") + if err := os.WriteFile(path, original, mode); err != nil { + t.Fatal(err) + } + if err := os.Chmod(path, mode); err != nil { + t.Fatal(err) + } + if err := BackupThenWrite(path, []byte("updated\n")); err != nil { + t.Fatal(err) + } + backups, err := filepath.Glob(path + ".backup.*") + if err != nil || len(backups) != 1 { + t.Fatalf("backups=%v err=%v", backups, err) + } + data, err := os.ReadFile(backups[0]) + if err != nil || string(data) != string(original) { + t.Fatalf("backup lost: %v", err) + } + if runtime.GOOS != "windows" { + info, err := os.Stat(backups[0]) + if err != nil || info.Mode().Perm() != 0o600 { + t.Fatalf("backup must be owner-only: %v", info) + } + info, err = os.Stat(path) + if err != nil || info.Mode().Perm() != mode { + t.Fatalf("original mode changed: %v", info) + } + } + }) + } +} diff --git a/internal/llm/client.go b/internal/llm/client.go index 97b0e786..28d361c6 100644 --- a/internal/llm/client.go +++ b/internal/llm/client.go @@ -12,7 +12,6 @@ import ( "fmt" "io" "net/http" - "net/url" "strings" ) @@ -148,14 +147,11 @@ func (c *Client) Complete(ctx context.Context, req CompletionRequest) (*Completi } } - // 非 2xx: 归类并尽量带上端点返回的错误信息 + // Error bodies are untrusted and can echo credentials or source content. + // Keep only the status classification; callers persist these errors. if resp.StatusCode < 200 || resp.StatusCode >= 300 { kind := classifyHTTPStatus(resp.StatusCode) - msg := endpointErrorMessage(respBody) - if msg == "" { - msg = "端点返回非成功状态" - } - return nil, &Error{Kind: kind, Message: msg, StatusCode: resp.StatusCode} + return nil, &Error{Kind: kind, Message: "端点返回非成功状态", StatusCode: resp.StatusCode} } var wire wireChatResponse @@ -164,8 +160,8 @@ func (c *Client) Complete(ctx context.Context, req CompletionRequest) (*Completi } // 某些端点以 200 + error 体表达错误 - if wire.Error != nil && wire.Error.Message != "" { - return nil, &Error{Kind: KindHTTP, Message: "端点返回错误: " + wire.Error.Message, StatusCode: resp.StatusCode} + if wire.Error != nil { + return nil, &Error{Kind: KindHTTP, Message: "端点返回错误", StatusCode: resp.StatusCode} } if len(wire.Choices) == 0 { @@ -196,32 +192,9 @@ func (c *Client) Complete(ctx context.Context, req CompletionRequest) (*Completi func (c *Client) completionsURL() (string, error) { trimmed := strings.TrimRight(c.baseURL, "/") full := trimmed + DefaultChatCompletionsPath - if _, err := url.ParseRequestURI(full); err != nil { - return "", &Error{Kind: KindConfig, Message: fmt.Sprintf("端点地址非法: %s", full), Err: err} - } return full, nil } -// endpointErrorMessage 尝试从端点错误响应体中提取可读信息(尽力而为,失败返回空)。 -func endpointErrorMessage(body []byte) string { - var probe struct { - Error *struct { - Message string `json:"message"` - } `json:"error"` - Message string `json:"message"` - } - if err := json.Unmarshal(body, &probe); err != nil { - return "" - } - if probe.Error != nil && probe.Error.Message != "" { - return "端点错误: " + probe.Error.Message - } - if probe.Message != "" { - return "端点错误: " + probe.Message - } - return "" -} - // isTimeoutErr 判断是否为网络超时类错误(net.Error 的 Timeout)。 func isTimeoutErr(err error) bool { type timeout interface{ Timeout() bool } diff --git a/internal/llm/errors.go b/internal/llm/errors.go index 2fdac925..8234c666 100644 --- a/internal/llm/errors.go +++ b/internal/llm/errors.go @@ -64,9 +64,8 @@ func (e *Error) Error() string { if e.StatusCode != 0 { base = fmt.Sprintf("%s (HTTP %d)", base, e.StatusCode) } - if e.Err != nil { - base = fmt.Sprintf("%s: %v", base, e.Err) - } + // Transport errors may contain URLs, credentials, headers or response + // fragments. Preserve Unwrap for classification, never format that cause. return base } diff --git a/internal/llm/options.go b/internal/llm/options.go index 32a683d4..5a76ed0a 100644 --- a/internal/llm/options.go +++ b/internal/llm/options.go @@ -32,6 +32,7 @@ package llm import ( "net/http" + "net/url" "time" ) @@ -100,19 +101,31 @@ func NewClient(opts Options) (*Client, error) { if opts.Model == "" { return nil, &Error{Kind: KindConfig, Message: "AI 模型 model 未配置(不能为空)"} } + endpoint, err := url.Parse(opts.BaseURL) + if err != nil || endpoint.Hostname() == "" || + (endpoint.Scheme != "https" && endpoint.Scheme != "http") || + endpoint.User != nil || endpoint.RawQuery != "" || endpoint.ForceQuery || endpoint.Fragment != "" { + return nil, &Error{Kind: KindConfig, Message: "AI 端点地址非法"} + } hc := opts.HTTPClient if hc == nil { // 兜底超时(第二层防御):正常路径由调用方 context 先触发,此处仅防无限挂起。 hc = &http.Client{Timeout: clientHardTimeout} } + // Copy caller-owned clients: preserve transports and deadlines without + // changing their redirect policy outside this authoring client. + boundedClient := *hc + boundedClient.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + } return &Client{ baseURL: opts.BaseURL, model: opts.Model, apiKey: opts.APIKey, maxInputTokens: opts.MaxInputTokens, - httpClient: hc, + httpClient: &boundedClient, }, nil } diff --git a/internal/llm/security_test.go b/internal/llm/security_test.go new file mode 100644 index 00000000..83951efc --- /dev/null +++ b/internal/llm/security_test.go @@ -0,0 +1,111 @@ +package llm + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" +) + +func TestCompleteNeverFollowsRedirects(t *testing.T) { + for _, status := range []int{301, 302, 303, 307, 308} { + for _, custom := range []bool{false, true} { + t.Run(fmt.Sprintf("%d/custom=%t", status, custom), func(t *testing.T) { + var destinationCalls, callbackCalls atomic.Int32 + destination := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + destinationCalls.Add(1) + _, _ = io.WriteString(w, `{"choices":[{"message":{"content":"unexpected"}}]}`) + })) + defer destination.Close() + origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, destination.URL, status) + })) + defer origin.Close() + opts := Options{BaseURL: origin.URL, Model: "test", APIKey: "synthetic-key"} + if custom { + opts.HTTPClient = origin.Client() + opts.HTTPClient.CheckRedirect = func(*http.Request, []*http.Request) error { callbackCalls.Add(1); return nil } + } + client, err := NewClient(opts) + if err != nil { + t.Fatal(err) + } + _, err = client.Complete(context.Background(), CompletionRequest{Messages: []Message{{Role: "user", Content: "synthetic source"}}}) + var failure *Error + if !errors.As(err, &failure) || failure.StatusCode != status || failure.IsRetryable() { + t.Fatalf("redirect was not refused: %v", err) + } + if destinationCalls.Load() != 0 || callbackCalls.Load() != 0 { + t.Fatal("redirect escaped the configured endpoint") + } + if custom { + _ = opts.HTTPClient.CheckRedirect(nil, nil) + if callbackCalls.Load() != 1 { + t.Fatal("caller-owned client was mutated") + } + } + }) + } + } +} + +func TestCompleteErrorsDoNotRetainEndpointPayloads(t *testing.T) { + for _, status := range []int{200, 401, 429, 500} { + t.Run(fmt.Sprint(status), func(t *testing.T) { + endpoint := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(status) + _, _ = io.WriteString(w, `{"error":{"message":"synthetic-key private-source\nforged-log-line"}}`) + })) + defer endpoint.Close() + client := newTestClient(t, endpoint, "synthetic-key") + _, err := client.Complete(context.Background(), CompletionRequest{Messages: []Message{{Role: "user", Content: "private-source"}}}) + if err == nil { + t.Fatal("error response accepted") + } + for _, forbidden := range []string{"synthetic-key", "private-source", "forged-log-line", endpoint.URL} { + if strings.Contains(err.Error(), forbidden) { + t.Fatal("untrusted endpoint detail escaped") + } + } + }) + } +} + +type failingTransport struct{ err error } + +func (transport failingTransport) RoundTrip(*http.Request) (*http.Response, error) { + return nil, transport.err +} + +func TestTransportFailureKeepsClassificationWithoutFormattingCause(t *testing.T) { + cause := fmt.Errorf("private-url?token=synthetic-key: %w", context.DeadlineExceeded) + client, err := NewClient(Options{BaseURL: "https://example.test", Model: "test", HTTPClient: &http.Client{Transport: failingTransport{cause}}}) + if err != nil { + t.Fatal(err) + } + _, err = client.Complete(context.Background(), CompletionRequest{Messages: []Message{{Role: "user", Content: "test"}}}) + var failure *Error + if !errors.As(err, &failure) || failure.Kind != KindTimeout || !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("lost timeout classification: %v", err) + } + if strings.Contains(err.Error(), "synthetic-key") || strings.Contains(err.Error(), "example.test") { + t.Fatal("transport detail escaped") + } +} + +func TestClientRefusesCredentialBearingOrAmbiguousEndpoints(t *testing.T) { + for _, endpoint := range []string{"https://user:synthetic-key@example.test", "https://example.test?token=synthetic-key", "https://example.test#synthetic-key", "file:///private", "https:///missing-host", "://synthetic-key"} { + _, err := NewClient(Options{BaseURL: endpoint, Model: "test"}) + if err == nil { + t.Fatal("unsafe endpoint accepted") + } + if strings.Contains(err.Error(), endpoint) || strings.Contains(err.Error(), "synthetic-key") { + t.Fatal("invalid endpoint echoed") + } + } +} diff --git a/internal/ui/page.html b/internal/ui/page.html index e0a04db0..0644db20 100644 --- a/internal/ui/page.html +++ b/internal/ui/page.html @@ -146,6 +146,18 @@

')[0]; +source=source.replace('(async () => { renderChrome(); await loadRepos(); renderChrome(); await refresh(true); schedule(); })();','globalThis.apiFetch=apiFetch;'); +const calls=[],saved=new Map();let cleared=false; +const element={addEventListener(){},querySelector(){return null;}}; +const context=vm.createContext({URLSearchParams, + window:{addEventListener(){},location:{hash:'#access_token=synthetic-token',pathname:'/',search:''},history:{replaceState(a,b,c){assert.equal(c,'/');cleared=true;}}}, + sessionStorage:{getItem:k=>saved.get(k),setItem:(k,v)=>saved.set(k,v)}, + document:{documentElement:{},getElementById:()=>element}, + fetch:(url,options)=>{calls.push({url,options});return Promise.resolve({});},setTimeout(){},clearTimeout(){} +}); +vm.runInContext(source,context); +context.apiFetch('/api/repos',{headers:{'If-None-Match':'version'}}); +assert.equal(calls[0].url,'/api/repos'); +assert.equal(calls[0].options.headers.Authorization,'Bearer synthetic-token'); +assert.equal(calls[0].options.headers['If-None-Match'],'version'); +assert.equal(calls[0].options.redirect,'error'); +assert.equal(saved.get('aoci.accessToken'),'synthetic-token');assert.ok(cleared); +`) + cmd.Stdin = strings.NewReader(pageHTML) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("page authentication regression: %v\n%s", err, output) + } +} diff --git a/internal/ui/server.go b/internal/ui/server.go index cacb71d2..a6c7890c 100644 --- a/internal/ui/server.go +++ b/internal/ui/server.go @@ -2,6 +2,9 @@ package ui import ( "context" + "crypto/rand" + "crypto/subtle" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -75,6 +78,7 @@ type server struct { cache *repoCache page []byte allowedHost string + accessToken string } // Serve binds a loopback listener, calls ready with the URL, and serves until @@ -98,18 +102,23 @@ func Serve(ctx context.Context, options Options, ready func(url string, reposito if len(roots) == 0 { return &detailError{kind: ErrNoRepository, detail: "none"} } + secret := make([]byte, 32) + if _, err := rand.Read(secret); err != nil { + return err + } + accessToken := hex.EncodeToString(secret) listener, err := net.Listen("tcp", net.JoinHostPort(host, strconv.Itoa(options.Port))) if err != nil { return err } - url := "http://" + listener.Addr().String() + "/" + url := "http://" + listener.Addr().String() + "/#access_token=" + accessToken if dir, dirErr := RegistryDir(options.RegistryDir); dirErr == nil { // Best effort and outside the repository: it lets a later --detach // reuse this page and --stop end it. Serving never depends on it. _ = Register(dir, Registration{PID: os.Getpid(), URL: url, Root: roots[0], StartedAt: time.Now().UTC()}) defer func() { _ = Unregister(dir, roots[0], os.Getpid()) }() } - s := &server{options: options, roots: roots, cache: newRepoCache(), allowedHost: listener.Addr().String()} + s := &server{options: options, roots: roots, cache: newRepoCache(), allowedHost: listener.Addr().String(), accessToken: accessToken} s.page = []byte(renderPage(options.Locale)) httpServer := &http.Server{Handler: s.handler(), ReadHeaderTimeout: 5 * time.Second} go func() { @@ -198,6 +207,10 @@ func (s *server) handler() http.Handler { // external resources, no caching outside the ETag handshake. func (s *server) guard(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; frame-ancestors 'none'") if !hostAllowed(r.Host, s.allowedHost) { http.Error(w, "unexpected Host", http.StatusForbidden) return @@ -207,9 +220,14 @@ func (s *server) guard(next http.HandlerFunc) http.HandlerFunc { http.Error(w, "read-only", http.StatusMethodNotAllowed) return } - w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:") - w.Header().Set("X-Content-Type-Options", "nosniff") - w.Header().Set("Cache-Control", "no-store") + // The static shell contains no repository data or token. Every data + // route authenticates before repository lookup, including HEAD/ETag. + if r.URL.Path != "/" && (s.accessToken == "" || + subtle.ConstantTimeCompare([]byte(r.Header.Get("Authorization")), []byte("Bearer "+s.accessToken)) != 1) { + w.Header().Set("WWW-Authenticate", "Bearer") + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } next(w, r) } } diff --git a/internal/ui/server_test.go b/internal/ui/server_test.go index 812c4430..8dc586c0 100644 --- a/internal/ui/server_test.go +++ b/internal/ui/server_test.go @@ -57,12 +57,13 @@ func buildVolumesRepo(t *testing.T) string { func testServer(t *testing.T, roots ...string) *server { t.Helper() options := Options{Roots: roots, Locale: "en-US", BinaryVersion: "test"} - return &server{options: options, roots: normalizeRoots(roots), cache: newRepoCache(), page: []byte(renderPage("en-US")), allowedHost: "example.com"} + return &server{options: options, roots: normalizeRoots(roots), cache: newRepoCache(), page: []byte(renderPage("en-US")), allowedHost: "example.com", accessToken: strings.Repeat("a", 64)} } func get(t *testing.T, handler http.Handler, target string, headers map[string]string) *httptest.ResponseRecorder { t.Helper() request := httptest.NewRequest(http.MethodGet, target, nil) + request.Header.Set("Authorization", "Bearer "+strings.Repeat("a", 64)) for key, value := range headers { request.Header.Set(key, value) } @@ -404,7 +405,11 @@ func TestServeListensOnLoopbackAndStopsWithContext(t *testing.T) { if !strings.HasPrefix(url, "http://127.0.0.1:") { t.Fatalf("url %q", url) } - response, err := http.Get(url + "api/repos") + request, err := pageRequest(url, "/api/repos") + if err != nil { + t.Fatal(err) + } + response, err := http.DefaultClient.Do(request) if err != nil || response.StatusCode != http.StatusOK { t.Fatalf("repos: %v %v", err, response) } @@ -431,6 +436,7 @@ func TestGuardAcceptsTheDefaultPortHostForm(t *testing.T) { for host, want := range map[string]int{"127.0.0.1": http.StatusOK, "127.0.0.1:80": http.StatusOK, "127.0.0.1:8080": http.StatusForbidden, "localhost": http.StatusForbidden} { request := httptest.NewRequest(http.MethodGet, "/api/repos", nil) + request.Header.Set("Authorization", "Bearer "+server.accessToken) request.Host = host recorder := httptest.NewRecorder() handler.ServeHTTP(recorder, request) @@ -466,7 +472,7 @@ func TestServeRejectsUnexpectedHost(t *testing.T) { case <-time.After(5 * time.Second): t.Fatal("server did not become ready") } - request, err := http.NewRequest(http.MethodGet, url+"api/repos", nil) + request, err := pageRequest(url, "/api/repos") if err != nil { t.Fatal(err) } diff --git a/internal/workflow/endpoint_error_security_test.go b/internal/workflow/endpoint_error_security_test.go new file mode 100644 index 00000000..ec4c17dc --- /dev/null +++ b/internal/workflow/endpoint_error_security_test.go @@ -0,0 +1,57 @@ +package workflow + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/aoci-spec/aoci-code/internal/draft" + "github.com/aoci-spec/aoci-code/internal/llm" +) + +func TestEndpointErrorsNeverEnterPersistedDraftArtifacts(t *testing.T) { + root, cfg, doc := buildRepo(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":{"message":"synthetic-auth-value synthetic-private-source"}}`)) + })) + defer server.Close() + cfg.AI.BaseURL = server.URL + client, err := llm.NewClient(llm.Options{BaseURL: server.URL, Model: "test", APIKey: "synthetic-auth-value"}) + if err != nil { + t.Fatal(err) + } + result, err := RunEntriesDraft(context.Background(), root, cfg, doc, client, []string{"newfile.go"}, nil) + if err != nil || result.Failed != 1 { + t.Fatalf("expected one recorded failure: result=%+v err=%v", result, err) + } + manifest, err := draft.LoadManifest(root, result.RunID) + if err != nil || len(manifest.Entries) != 1 || manifest.Entries[0].Note == "" { + t.Fatalf("missing safe diagnosis: %v", err) + } + err = filepath.WalkDir(filepath.Join(root, ".aoci"), func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + for _, forbidden := range []string{"synthetic-auth-value", "synthetic-private-source"} { + if strings.Contains(string(data), forbidden) { + t.Errorf("untrusted endpoint detail persisted in %s", filepath.Base(path)) + } + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} diff --git a/scripts/blackbox/mcp_scenarios.py b/scripts/blackbox/mcp_scenarios.py index b273214b..7e21a68f 100644 --- a/scripts/blackbox/mcp_scenarios.py +++ b/scripts/blackbox/mcp_scenarios.py @@ -1993,7 +1993,11 @@ def stop_ui(process): process.kill() def ui_get(base, path): - with urllib.request.urlopen(base.rstrip("/") + path, timeout=10) as response: + address, fragment = urllib.parse.urldefrag(base) + token = urllib.parse.parse_qs(fragment).get("access_token", [""])[0] + request = urllib.request.Request(address.rstrip("/") + path, + headers={"Authorization": "Bearer " + token}) + with urllib.request.urlopen(request, timeout=10) as response: return response.status, response.read().decode("utf-8") def group_u(): @@ -2022,6 +2026,13 @@ def group_u(): "code_entry_count", "code_source_count", "next_required_action") differing = [k for k in compared if facts.get(k) != governance.get(k)] same = bool(governance) and not differing and snapshot.get("composite_identity") == report.get("composite_identity") + # The readiness fragment authorises this client, never an anonymous + # request from another local process. Keep the normal facts check too. + try: + with urllib.request.urlopen(urllib.parse.urldefrag(base)[0] + "api/repos", timeout=3): + same = False + except urllib.error.HTTPError as error: + same = same and error.code == 401 record(g, "U1.page-serves-the-same-facts-as-verify", "PASS" if same else "FAIL", f"agree on {len(compared)} fields, result={facts.get('result')}" if same else f"differ: {differing} page={facts.get('result')} verify={governance.get('result')}"[:200]) @@ -2100,9 +2111,9 @@ def run(*extra): def answers(url): try: - with urllib.request.urlopen(url + "api/repos", timeout=3) as r: - return any(os.path.normcase(os.path.normpath(item.get("root", ""))) == want - for item in json.loads(r.read().decode("utf-8"))) + _, body = ui_get(url, "/api/repos") + return any(os.path.normcase(os.path.normpath(item.get("root", ""))) == want + for item in json.loads(body)) except Exception: return False diff --git a/spec/public/aoci-code-cli-runtime-v1.txt b/spec/public/aoci-code-cli-runtime-v1.txt index bfb136e5..3fc65e53 100644 --- a/spec/public/aoci-code-cli-runtime-v1.txt +++ b/spec/public/aoci-code-cli-runtime-v1.txt @@ -46,9 +46,35 @@ duplicated. `aoci ui --stop` ends the registered page, proving liveness through the page itself before any signal is sent. Registrations live in the user's cache directory (or AOCI_UI_REGISTRY_DIR), never in the repository. +The readiness URL contains a per-process access capability in its fragment. +The static page shell is public on loopback; repository lists, state, entries, +and raw assets require that capability in the Authorization header, including +HEAD and conditional requests. The browser retains it only in tab session +storage, removes the fragment when storage succeeds, and sends no credential +in request URLs. Treat the printed link and private registration as credentials. +Registrations retain their existing JSON shape and use owner-only file modes. +Legacy tokenless registrations remain readable for --stop, but a new --detach +refuses to reuse or replace a live legacy registration until --stop completes. +Older binaries cannot authenticate new pages; stop pages +with the binary that started them before a downgrade. MCP schemas and formal +cognition formats are unchanged. + State and writes ---------------- +Optional model requests use the final configured HTTP(S) base URL and never +follow redirects. URL user information, query strings and fragments are +refused by the client. Error diagnostics retain category and HTTP status, +not arbitrary endpoint response bodies or formatted transport causes; this +also applies to failure notes persisted by endpoint-native drafting. + +Host-configuration backups are published with owner-only file permissions. +On Windows, directory ACLs remain the operating-system access boundary. +Governed CAS replacement requires native atomic exchange. Unsupported +filesystems fail closed rather than emulating a weaker replacement. Existing +capture/intent artifacts remain readable, but recovery that needs exchange +must run on a supporting filesystem; no persistent schema is changed. + Command help and the live Guide identify whether an operation is observational, prepares a candidate, requires human interaction, or can update formal assets. An observational command does not modify the formal index or Baseline unless diff --git a/textassets/en-US/contracts/ui/messages.json b/textassets/en-US/contracts/ui/messages.json index c4a2260f..c47cf72d 100644 --- a/textassets/en-US/contracts/ui/messages.json +++ b/textassets/en-US/contracts/ui/messages.json @@ -1760,5 +1760,6 @@ "ui.stopped": "Stopped the AOCI panel (PID %d).", "ui.none_running": "No AOCI panel is running for this repository.", "ui.error.detach_failed": "could not start the panel in the background: %s", - "ui.error.registry": "cannot locate the panel registry (user cache directory): %s" + "ui.error.registry": "cannot locate the panel registry (user cache directory): %s", + "ui.error.legacy_page": "an unauthenticated legacy panel is still running; run aoci ui --stop for this repository before starting a new panel" } diff --git a/textassets/zh-CN/contracts/ui/messages.json b/textassets/zh-CN/contracts/ui/messages.json index b6456870..d3b28279 100644 --- a/textassets/zh-CN/contracts/ui/messages.json +++ b/textassets/zh-CN/contracts/ui/messages.json @@ -1760,5 +1760,6 @@ "ui.stopped": "已停止 AOCI 面板(PID %d)。", "ui.none_running": "本仓库没有正在运行的 AOCI 面板。", "ui.error.detach_failed": "无法在后台启动面板:%s", - "ui.error.registry": "无法定位面板登记目录(用户缓存目录):%s" + "ui.error.registry": "无法定位面板登记目录(用户缓存目录):%s", + "ui.error.legacy_page": "旧版未认证面板仍在运行;请先对本仓库运行 aoci ui --stop,再启动新面板" } From 6c1b5d309888751c5a334bf97be94f386559e864 Mon Sep 17 00:00:00 2001 From: enqiang Date: Wed, 14 Oct 2026 23:24:36 +0800 Subject: [PATCH 2/2] test(security): cover private registration replacement --- .aoci/baseline.json | 10 +++++----- internal/ui/security_test.go | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/.aoci/baseline.json b/.aoci/baseline.json index f6f871a2..c6bab00b 100644 --- a/.aoci/baseline.json +++ b/.aoci/baseline.json @@ -1,7 +1,7 @@ { "version": 1, "created_at": "2026-08-07T17:44:36Z", - "updated_at": "2026-10-14T14:19:18Z", + "updated_at": "2026-10-14T15:21:48Z", "files": { ".gitattributes": { "role": "index", @@ -6347,10 +6347,10 @@ }, "internal/ui/security_test.go": { "role": "observe", - "sha256": "6ba525c648d1c67fae124faa8ff847c356ef21d87fc37bbd67d6c27214c7ac29", - "size": 5393, - "normalized_sha256": "6ba525c648d1c67fae124faa8ff847c356ef21d87fc37bbd67d6c27214c7ac29", - "format_sha256": "6ba525c648d1c67fae124faa8ff847c356ef21d87fc37bbd67d6c27214c7ac29", + "sha256": "2174731e3ab773056e98610b89125dfc090f69c709f9f86f2ee6e55636c5f485", + "size": 6563, + "normalized_sha256": "2174731e3ab773056e98610b89125dfc090f69c709f9f86f2ee6e55636c5f485", + "format_sha256": "2174731e3ab773056e98610b89125dfc090f69c709f9f86f2ee6e55636c5f485", "format_kind": "gofmt" }, "internal/ui/server.go": { diff --git a/internal/ui/security_test.go b/internal/ui/security_test.go index 0f4c1fd5..e569c77d 100644 --- a/internal/ui/security_test.go +++ b/internal/ui/security_test.go @@ -93,6 +93,38 @@ func TestPageRegistryAuthenticationAndLegacyStopCompatibility(t *testing.T) { } } +func TestRegistrationReplacementTightensExistingPermissions(t *testing.T) { + dir := t.TempDir() + previous := Registration{PID: 10, Root: "/synthetic/repository", URL: "http://127.0.0.1:12345/"} + if err := Register(dir, previous); err != nil { + t.Fatal(err) + } + path := registrationPath(dir, previous.Root) + // Simulate a registration written with broader permissions before the + // capability was introduced. Reusing its pathname must not inherit them. + if err := os.Chmod(path, 0o644); err != nil { + t.Fatal(err) + } + replacement := Registration{PID: 20, Root: previous.Root, + URL: "http://127.0.0.1:12346/#access_token=" + strings.Repeat("c", 64)} + if err := Register(dir, replacement); err != nil { + t.Fatal(err) + } + got, ok := Registered(dir, replacement.Root) + if !ok || got.PID != replacement.PID || got.URL != replacement.URL || !HasAccessToken(got.URL) { + t.Fatal("replacement registration lost its process or capability binding") + } + if runtime.GOOS != "windows" { + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("replacement inherited public permissions: %v", info.Mode().Perm()) + } + } +} + func TestPageSendsCapabilityOnlyInHeaders(t *testing.T) { node, err := exec.LookPath("node") if err != nil {