diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index e9780e6fbd7a..7b32cb991717 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,116 +1,116 @@ -d6a31e20a863bdd5706e7136ca0cb9818389f388e3c43fefd8ae984260acd5d2 module/account-core -df71299237a4752d6b19ab0cf1d3479ca3e1f3ccb4481d96ec09d4c4be4a4c3a module/account-helpers +7d306f95a7f8c3ea36ba68dda7d121c4f93fc9f6326c6c53683ab01eccf0a2fc module/account-core +c38b59ef4745b7447295baf17900078bf460fe36d98b3516e0f37aa2c25fda5c module/account-helpers 71522995185b956a0cc4927a472cc8d1153e5e998874bfd9a750513175174713 module/account-id -9017717bd6213d60ad3906e92b25f2c7eb02fe47ba1c674b45135913a9d134d1 module/account-resolution +62e64563d598ebb16411a0248da2f2d7fc61ca42602335c54b27586d1a4dc3bd module/account-resolution 3fe118210b885af40088457ed81ffa5ede18c8e695295731a2ee059af46843cc module/agent-config-primitives -0606acccb050167cad09aa7568d446de5c4006549ccd35fad18c7eac429f7ed1 module/agent-harness -4326e1c6050d7a964e09a374136908c4e455f1119753b273b9c823e82697f873 module/agent-harness-runtime +bf64b124edf54fac9d0296197299fa8218a369959ccf9ce813e8feca615fad0e module/agent-harness +f4212394d8c3a1955a2c332c6b8f90e8619bbd46ee2f792dc185b957aaae85c9 module/agent-harness-runtime 773943f0f5cc26d4bfd1dc6cfdac5effc2bec14f1bde927e9e407ab4c9701ba0 module/agent-media-payload -7cddbe1ffc43664c079bba0f0b25cc3cef8f73907d4e9487e63dd98e821e9c00 module/agent-runtime +769b2dfc03475942c5e4fcf018de830b17aa3c08eb054700c774a15bfb2d2cc5 module/agent-runtime 241d467d0af5f81d8a535fe0c65d226356daf8325a037cb23092b8dd82aaa456 module/agent-scope-runtime 8fecb210e22bce4532b6ab649b09465f0bd2c857a44abf40db7d683d6491e6da module/allow-from -a089721688670b451885e3100043b2b978709f20fbb06440cdb3f1231b114b65 module/allowlist-config-edit -0d28d49daa0e5a6329f78c5fb85a7690b5105eff95a9b08b27baee6d34d536b6 module/approval-auth-runtime -f2df07078abca83be93b13ad71aaa326ae79d59e9aafdb668980919e78d2d14b module/approval-client-runtime -40564751f3c7e73b7de54dffce0c2a7c3be0f2ab2c1c20b90cf87d7a342f24e8 module/approval-delivery-runtime +ad09805cfb46d6155fb54807ea9794ec3980a5eac62a9d1e4d718dae8d0b61df module/allowlist-config-edit +05e9015f0b462f67be33831ca58e31af3cf99a13ec103a00c3111bc1eff989b0 module/approval-auth-runtime +f06655c8a4524497abe4fac09808a3c18c10260fdcf90f97698efce6cf5d62cf module/approval-client-runtime +5cdacacc7cb9950bd8fa3b4332691adf36e69cd3f9e2928dc59e70897560f86e module/approval-delivery-runtime 2d670f3e370ce6e03937b7ac8502d546bf0a4903b2f93c3957bd5ef39e136b24 module/approval-gateway-runtime 5da9a30393531c72d5df3ea256eaec71ca7ad323ffe9539d27a7bfc4c75d279f module/approval-handler-adapter-runtime -d7bc23a008be159554b6c59f0b558be25e4a0be5b837963a90abd80795190956 module/approval-handler-runtime -9ebebacc8d37b1fa640074ecb5018fa2f731bc1ea373f3e218b856b01504deeb module/approval-native-runtime -d577aded81ae440803d3f75a679073655d86099fbfdb2b85430b76bdb8fdaa7b module/approval-reply-runtime -c43aec5d7c9e6ab47bb041d3d044e206f53b3fc6eb777ffb55efd443cf10ebd9 module/approval-runtime +d38d69f95305ddaad8f059d16f6593938ef385991b00f0909434d0855c1e769c module/approval-handler-runtime +040dd9bbd3d1235c4679fcf219bdca65551f014b6ef08eb94935399f3d83972f module/approval-native-runtime +573a09bfa745b9128aeb73cf736ff0e5718c51ea5fad050bae795ed31c8120f3 module/approval-reply-runtime +de70e007063b51cef54bf7c48150c2eb295d4a14c9c2a60409d3dc036bdc0f9f module/approval-runtime 01ca912836b8dec672f705e294f72d346e778557e4c591317d67558ea7669c0b module/archive d7e53de63b0ac11a266e4abdc18ba6e9401b80309f5c8f5f6a72a00f65dfe3bd module/boolean-param 39769190fc9d790ae5e2c4ebd5de0a78b45b3d529aaf264c84eba4e7f86a3066 module/channel-actions -0f4c394da75dae393c6c5dae7b93bbebe31036a991a11e27770914f58d795a34 module/channel-config-helpers +54160b43e8382b33667384c56492c7d1a9641795b990d026821a4c1a588d0126 module/channel-config-helpers c2cc71d5070b6071c51248b0648d1ad1a9468d3737df890adc77ec02025e8853 module/channel-config-primitives 76ad615d374431580ea1755594e2ce3ce047ac1de9fd4621053dca0fbc3afc4d module/channel-config-schema -429a622f16518dc102357238db361966076439c09458d61e0281e2d298f03938 module/channel-contract -dfbe406d071195bff2747b5c9fc37b1600387d3299b42c1e375895f7d376622c module/channel-core -1791224e2302e91d14efd7d3724a4aa0826e6d4df6b766bea27555e031505d75 module/channel-dm-policy -a62e0ceb0f526b05c110f71c52ff124dfda405bf0b8a82050399f0766393e57c module/channel-entry-contract +cd873744a01a47c5284227d8af5c50d2f6606a815e8586e11c2012f9c301429a module/channel-contract +f57191ecbc801dbce50e4b3f0fa449de07663280b405dd29d742cba093c1bbde module/channel-core +caa54fde5a2a515491019ec163ed278e09bdc95b9de9926aff04c1743ca0c966 module/channel-dm-policy +bccffec9da8f7c58dfcf765516b272ecacb343f00828ee7ea1eaf4bda7452f4e module/channel-entry-contract 2c55b3f3d1d275f760a7e1c78764e4030e7a06c4273a4d51d8f3c82b55ea818d module/channel-feedback -228274a0bebdeb5260b385b3ec10d48f0e0d818be26bd4359215ecbef0778961 module/channel-inbound +1d3056a8709387121c4e99bc8744847f5742cd95e6265a8f3434ce82331fd7e0 module/channel-inbound d7e21bb831ad5125e6498e88fcc27e820ed296784eb2296c22795c08d55ade06 module/channel-inbound-debounce 79a8f244b0627ce4b601231bf71f0ee539f6ac7692e490847ec8e7ace7d29f1c module/channel-ingress-runtime -db1c401ab2c5ad89fa801580ac495ba91291de6dba2449aa6b25e0f078469040 module/channel-lifecycle +e8e08fd9dfcc15758c552fd594055cde15457fac07688553e613de27a6909d98 module/channel-lifecycle 0e47457e38d1df0bd572e1408cde2ca6a788b65205f43c585316b5ad3a8f2f16 module/channel-logging -8c56d53b2559d806a2d83c5a6769bf36b41eb4c2d05a04f5f740a0d4d2b041fb module/channel-message -2be061d17794acc6375f1a4d9a2c0c67be88ad6b301af974a1ecb8af6fa36b05 module/channel-outbound -fd12f764333c4a54b597eea46011c4333ff09237d2305dd30947d86a9204f5a2 module/channel-pairing -ce1e83aaf577610ab5437e4fc9372c1689ba265c6afe09eba2b5fbf8f2df9c91 module/channel-plugin-common -06d2928491181215a1814a89f62cae1611ef9b422593bddf12d888a1e6ae91a4 module/channel-policy -a547b035d75d8072b684f343e3d18fb4f87d05fec00b9cc817c6d92d676f2dc9 module/channel-reply-pipeline +7db3229db75404d4051268d0586d6d21942768d840907038722aa9098a057c72 module/channel-message +00b3eb03a22523e799d8112ee6e0d60f490082e3347a3c19a9ef6edf4f837290 module/channel-outbound +eef03825bb6d321ed4390a9379eaec19b7d3a5440b644589e380363bc3763953 module/channel-pairing +d7207ec3dfe8622ae1ce4298b42a370362dc35387a7073d91689becc9ea50b81 module/channel-plugin-common +39e460870b572913c321680b6a4ca2fed963b8c8c71bc2de9193ec68b74ef12b module/channel-policy +dd291daf278dbe9110ad9007048830b6f0bd6701dca09e25094ce98e4642d972 module/channel-reply-pipeline 482370e60135db9bfaf07f24bab549e5fde09ab265a6061a1f587c5d93929e91 module/channel-runtime-context 04948928b3cf310c4c401bdb89e94ac2ff2066b5b464a9bb501b36dbe7711c07 module/channel-secret-basic-runtime e99d6f57a89503e67035da553b0ae62b893722e564031c050f3097cf5fa3bf5f module/channel-secret-runtime -05666d6267ac7e578d7603fd440b9baceb28434b164d5048a9ef273bebba2a8e module/channel-send-result -9da2b56ecda12f5cdf40b5cece3f27f7ef385e9f95038bf7edf559295ad2da16 module/channel-setup -bb5b3388b642d4dac5d42746d9c2b7d6eb69b4057346b6bdcdf4cec7dcb8091c module/channel-status +57d12e9b62cfb09142bf95820b670b7ee9f1172fb069a9afdb7a62f7c30c598d module/channel-send-result +8337cefa180c854ca943642f9e1c50886dca0ed879d67be268ba84d0bc4e0cc2 module/channel-setup +24cc60a72cf009f02383b5ee1a787dacbf6a8d87b192375f1a51b5f04036825c module/channel-status 95dc206f832a0f563238dcea72d41554f409a3a9df081f41c52a8241c7dc5161 module/channel-streaming 67df67da5ae72e9eaeb19d41b6bd2432ec4fd8b7b63b2b616fb98f3b4e0ec41d module/channel-streaming-config 1303df5cc58539c6941e2cd159c93259804c925795219f1630f4d740896a77c1 module/cli-argv ad12670dbfe538f8d0ebf4fb2b68080e93a760278278e6b1ce9bb129d4b2d533 module/collection-runtime -303c425aae45f908006801d90472ecc99da2b7068ad617fb0f9cede3f52c03a3 module/command-auth -ba5ece50c493b5b680fcedf61751a7dc05eb183ae499b8235052e7c54f7a5506 module/command-auth-native +0883877ab26a6cdc30d31281d2b7aafd075657d7885f16de45fc35872d0ebb97 module/command-auth +eb0803d88af9a0fd8b41d8633ef1f784b9189b8ecaf71fdfb5c86571d54ba3ab module/command-auth-native a41d9effc1656cbc131611099bdeb81423ab48adce8c1b1ba07100dff8d32818 module/command-detection 31044216c6495728a36dbe60da31f7c1ede2b069cd0d0685d887ebca31814d5f module/command-primitives-runtime e42cc234ace96a64cec0bdb8051ddded77618e384ae2bbce8b633bf35dbd0288 module/command-status 91f95003a7ce8d78af219f6997e3001a7690674c2682ffe987942d3b1f45d8ea module/config-contracts 5e866c4f8dea30045a8a94e037f0e202d597afb1ca221d9854b15bc416503643 module/config-mutation -c66b3cf0f9591dcfcba4b5108d09cd81670aafdb210220d25f86f335b4860da7 module/config-runtime -8b531387918aff0c41ded4233c62c42958a86d110d2e854a393d8bbf35f08599 module/conversation-runtime -268f0c329704797599b477c1f896a27c83fd9a6d4d2f1d9275f73c2c790b0c7e module/core -d65b2bbfa0b4b7efc3b7fb739afc53292a298039477ab5b2c2078bb057255487 module/dedupe-runtime +a5328945c964794236201aa2c947d783f210f49af34d213d5a188b1e6c40b1a4 module/config-runtime +c6d742c9e6027647502399560b4264bed80bb8c069c2d08ba8527336f58dc13b module/conversation-runtime +258c06ace77a84ae28d2976bab77dd7b28b48e9e670f5cab536e3ba87bf87767 module/core +f0802bd2172418d41e4aebafe6a35fc37daab8cd3620f6d73d0cb510e8ca23e8 module/dedupe-runtime ebef0e650ab45e44c9335e2b3e15588c968cea6dadd125364a076f9c50ad1e8c module/device-bootstrap fd7c489415aa272af724ac15e95d297eac0edeb229e30d02c4061d5276f874b2 module/diagnostic-runtime -b6d5d4acbeb7530ffd06ab34b0f8253b6b3eba9eff2d65e132c4ad506ad5f7c8 module/directory-runtime -885a377ca4a6a034d4fab1557844be84032f7a3dce4887678e57f2558d01f017 module/discord +6dba2e37cfd962cc8c0aaae2d74d82407fa660fa56a4cf146b8674dc64239608 module/directory-runtime +3dcd8f6f7f65ddc2532ab2deea93272db066ca707ae55a91381d470e0cefdd20 module/discord c468c0ca5e5fc093ef5bd0cd15c57e19059bbe5c453d68f2664e9aaa35661cab module/error-runtime 6d9b6396888d7cddded108e211053559b10d79397d20098e0616767be4a4bfb3 module/extension-shared dd9f6e0fd33cc88b22543c1ee30cc09cf4de4d8f30dff7b7f9cebef885c21543 module/gateway-method-runtime -82e3fa56b3582020a2fd30905c7dfb91236b03debff8761f82193daf9f61da83 module/gateway-runtime +7bd8de3a6bfa3c0a3d7a6c4e38bb7fc31ebe25e275d126be02cc4c9ccd0a4de8 module/gateway-runtime 575656e5e0195c8d1813a4e2e3a271e800bb97d44f2dd78c242e3b6714ffa097 module/group-access -69ca093cce7bf8283eae64d7f1d5e29c0a736ffcbcc3231e438ed05e8a9885d9 module/health -648fdd8d7ea3505342ccb59ac396602b63d589a8b9651b2f39a42f30a8615e6e module/hook-runtime +92566a68cbf1c635fe3dc29afcab042e2b06aaaa0438c8cbd2c001b07730aa9c module/health +70abcc263a1f320faf7e589ba238bb1b4c7a602d52af42461420523460804aec module/hook-runtime 185a5acedbd7f1a73e5cc773e22bf494b124a09bcd68b5a756ff0f881abf431b module/inbound-envelope 4928af5d2509f696b896f53ac790303a0742202dbcdae3e44fe6d1b434a9c1ba module/inbound-event-delivery -e24d13349487fb2d9a293f6eead854e7bebcb38ff48bb7ef7a52995c355a8a25 module/inbound-reply-dispatch -54b2b398555ac7fe5c7643c809abc4c2d0074305ac9cabd67e7b98ec4f673dc6 module/infra-runtime +0a8fe52421723605b7d882b3d13f4be7ec10cd9e7e4d0ac01ff453f18e7867ba module/inbound-reply-dispatch +05df434589f317935ef1c251cf95c18406455f04c6c573daed0289ca629c4f22 module/infra-runtime ce73721421f1b903dd04ead4df173582e59ea3e9990248102c448b419cc6d272 module/ingress-effect-once -f6c42d02df87ec83486ecfdc73027a628f65f2bbd1a5a0f502adf00d68655740 module/interactive-runtime +dd7a5a732737c74cfe287ab40d62dd380ab4a3b78eeea6cd52574c0613a874cf module/interactive-runtime 408d257ab5cc4b88a22b7e7595039cb8fc524b261c44141b294fbd0100ba62ee module/json-store e907fd3a98185f2c261f2aafcaa5a19ee1d7b459d519a498397d629f84c68312 module/lazy-runtime 3a6d4cd20932d21e5ae665320ff594046c656eb84d95008a318ee1e9793edf2b module/logging-core f1ca4ced4305d0769c2d8cc1291137ac7002fe0e6eaec2c1a71edad2204c8311 module/matrix 7869c8dcea3b96ab00a33fcbd21a6ca171131b0c7dc6a527bf1da178c092621e module/media-local-roots f74d7295fe716aa140aa0bc9300d6259d71dab826de0808fca6bb02592bf5d6e module/media-mime -4d35dced50510b1cc74a5e4850889b4a01367058381333ec47acf3a8bc9ce86f module/media-runtime +dfddf0032904cf003c325578bc4b2789b2b695c4f7aaa9d08d2f932df30477cb module/media-runtime 6a52f93107335f88751704352cc01e62add06f854a5b7d765e2a5ee87c0313b6 module/media-store 7dd4a69b33196e946b03a3579e3b569176ff32db22fa668d5eacc06227574cd1 module/media-understanding -d2301239c1beb0b24d33ac49e7bfc6b22c787911a8161def521e024a390a7fa0 module/media-understanding-runtime -e72836c2db7f2dfeedb10a5c82aa3fa612f3026e51c697f7f66fd514246dc1e9 module/meeting-runtime +151c5fb10718764a03a913b3d6a8a6716cdc12fb95010f306b228a512d3a3d91 module/media-understanding-runtime +591bd88843dcdc21967d6098dad5489e41e15b70a8fca08e5fc5634b56fba71e module/meeting-runtime d16cbced4f2e6672ac9a032ac41691fe7ff4994e328d44ed6ac8a46dbbec834f module/memory-core-host-engine-foundation -7f3273aeadbd3b8cb822658af96435f1386dd0aa8164d72cbcc06692029bb6ef module/memory-host-core +5869a92060114a270f02b76972999d9ba5b31d4d755b0aedf2d4808e50202490 module/memory-host-core 1efa0aadc4261d1c6073058cbf3dcc9fa681424819bdd14333e19b249bbc4b18 module/messaging-targets -b679bab01e041322bc44fd65137931dab638ce50144f7bbd81184fe50ab8c403 module/model-session-runtime -51b60d4335655a7dc39beed6a006a9d2f6ce7455f15caf5cb8d3a43bec0ef61f module/models-provider-runtime -d7d1843f5958c67cbdcd816aa9a280b534cb7b9d30c6c7441fec34789b973e15 module/native-command-config-runtime +a3c8b86036354d27ae5c669502de05b823f3fb5fe4ef06a4defd4ed1fbcbf9a0 module/model-session-runtime +0372d5d52682bcec06b1c5736276f9b8f54147107f0957fdb80858ca27bdc57f module/models-provider-runtime +06b3bd19f3dde06b77cb1b0e8e389a0bc69b90f2aca4da86ddbc0e29ef8a33d8 module/native-command-config-runtime faaa22538f3459cef412c52088cb40dc732aa560fba2e70655938460022287d0 module/native-command-registry 5b968ecbef95fda927d0944409994e355ec878608dd084358970078d2ac545a9 module/param-readers ca7a56bb1a6169b4cf9befbf5aa21da280a8086fdc49fca4eec520a7a7c98549 module/persistent-dedupe 1bf4d4dfe5a4b264cf6fb8fbd0c7bc76f520ff9845cffad6da4b3a3c2bc3f6f6 module/plugin-config-runtime -7cbab51c5ca7c79a9aca6685e5659918eaaaa9c2c93e528c763d3a180c093216 module/plugin-entry -0b2d93c6a23217f2b6170cd3f3603cb8e81b9af629145aa55c437a55ef440256 module/plugin-runtime -8cf06b6f2bfbb49d1cd10e719188c12bf8f51acc9e1ec80117e4b1cf461e8482 module/provider-auth -570480a7f5be7a7a98c68cc06c8541d876951853978eb7680c651312b711595b module/provider-catalog-runtime +a8b04f9efdf7a5923e20dd87579b608f5ffcc925d8587dbbc224a1ac1da44f63 module/plugin-entry +0cb30aac2840b9cb54032e745f6a354c0366852436232aa43dc5626b571c5ba1 module/plugin-runtime +b7b684a81f769f63ff2ec6ab515ea0337339d9f166fc7b3945e65e0bdac4e100 module/provider-auth +395a600ca6fb645f1be130a95cdb834c83073dd36fd98639739107fc48396b29 module/provider-catalog-runtime 8131147d699394bd06503e2ea2f5f1a50b1594a87dded6d118b74a8d0328c8f6 module/proxy-capture 69a2931ee70410d6e782c0e6f4ea1964a9f72651999070c7453eb6c992ba6cc8 module/question-gateway-runtime -ca1cc8a9cd34068aceb2a96cae9c02f2cb4b9a09d7cf6124a588c21be2d7719d module/reply-chunking -8a78bf916cec8655e7e4b7a403645a14a9d6fae8bc53ba21489b47b83fb06c12 module/reply-dispatch-runtime +158d7fa58b45efc8569684cbb8cd2d0a8e9d331911c9eacd6e1323c4b764cbb5 module/reply-chunking +20f592eb816da5b2e75e38d8f3dfe0c0c01059e1b37d45b05fe438f2fc07b980 module/reply-dispatch-runtime 73f861fa3179d5af1159853c5acab0eec7a6c8f9398dcb75ea770e784fca6727 module/reply-history -e023767a2b193c8404b3a2992cad4c5b71d944c2ffa29ddf180749d488d84721 module/reply-payload -5ade3c2186a864e4732b437f4217769fdb3bdd0311d2474112b207eb2bcd3fcb module/reply-runtime +c4633871d5982f7b3d447ea77afa90031fd2faf90750f6f5dea8367e3f57d52a module/reply-payload +ad838aea4708728b912e64024b75ba6e975cebe6f84897af22daa903599691cb module/reply-runtime aa07d85d99fdd2b1e0cbe9975fb6dcae66b8bdce2607c6bd5402ae68bb15118c module/root-walk e26cc92679c768fa1474f15828f545aec87f32718a6b35f7907c4f56f65542fc module/routing 7877a7e58fa32a64107154e5b714c6d165e96989d4aa5f43e0afac085a187af0 module/run-command @@ -118,34 +118,34 @@ c83779c80c9e7b196b31a39ec4b986098bd701b089bf6eb2f53c368fb982ea77 module/runtime 159b563aad773cef67f18bf9bd2653420bec94b599e052f2a1d5a238bf341e16 module/runtime-config-snapshot 9fe5bcb52b462010214eda1c01f60b3a018837d9f95dc864f6d457cb3da001cf module/runtime-env 7e871b7319745678bb83fcfc1b54c8751b0ab1af92ff06c01d0659ac92863c11 module/runtime-group-policy -47bd9be46e0e3e4b41e70fc8d969f4b5d217bae35892a646e3538d8ad92cc3bb module/runtime-store +21ab9f99fc7c530caaec1ca15334b598ed7b2f1d01fed5e05b1ec4a69563a756 module/runtime-store d17862c40825af1ddf0257b44f1e1cbb9c375e8e5ed668fae75d530d1a465cf9 module/secret-file 8e2ac4d3973d8d8ce4478e3440d66ee5c0d9213b0fe9e927c421d14fd31e5e86 module/secret-input e7672788f052a1249839c8fa24d6a9a45967c4e3fd94e890b0f90296026cd1ca module/secret-input-runtime 026631cbf010d0325be2c4fa4ccd8bbdf8e1008f0904c742c270811f578b07b8 module/secret-ref-runtime -6a642666b8615801d4ddde14f6d96f8e9d86c152990ec6ca95e135cd567bb1d8 module/security-runtime -ba6ed0e61ab76e349b7a6740a16b307f35a13cdea6c416c6bd9964f4f4320b76 module/session-catalog -879860214e9e30ed4b4c4e47fcb1226920a2f1d2833f6bc2b021417e7c6cc406 module/session-discussion -96b770e79104743ea4476b4cea24aace91d6434491d2c46efd63eb64aa113b3c module/session-store-runtime -4a73a7c32b0dd2ac0a7bd5baf320aef0c7a3cc917eee009e18a4d62edc0fd855 module/setup -32591a4713689552df4bd4533b439a49d9540835cc041e76844157707ea4867b module/setup-runtime +536d3196e17422652d755286b9921133562907537c906b067a568b874221e7a0 module/security-runtime +2ddfb6d7aa51bc45edb68d005400ff6934162fd4fe32e3190d7b8231b8583960 module/session-catalog +56813dc7cf43f2b662caf2daaa0ba1918c3a40ea45f6aa4d7c73c27344b9866d module/session-discussion +7262e6e6dee725b4d9b8226bbb31f24460d7a4d5956aa6d60df81181c4221c07 module/session-store-runtime +e68e6edb57b7dc978431495ab53712d38a02d61620ab272b46a38c3b94199cfa module/setup +f188bdb868523aa17457c9338b02fcdf0df548d2fe776750b39d36c702edc061 module/setup-runtime 44d37e0d9131ad2859f41068f2604090c784e65f1bd6ebda8e051b6f2e5e1660 module/setup-tools -569fd20d35765b74889bfc853eec39b8240e595717fbc21bc0fec0e4b7c3ca2e module/skill-commands-runtime -01b71c8b7ac8f682e17c371d0a447f9c409a1e645f289807d0336733cfc1c9f4 module/speech-settings -a4c6c4173cfdb87988720f7f150e1698bb4b21080864e89fd64587d290544bb0 module/ssrf-policy -503fd73b679f48ab34e354ad61b3e868de6c5313e9ee789c296202a180ed97ac module/ssrf-runtime +3601dd9f28de70915005457146881685b73690ef82c9bfed80f35d0cf9b645b1 module/skill-commands-runtime +cdf1ec944678336dcc289f795f2004867148ebd4bbe73a938b6443deb7abe294 module/speech-settings +4a1bcc606805b5a20d04e048fc268764df9b140be9d161ad981d213343b87fd6 module/ssrf-policy +3ad3f12186d9cf44600904f22d0659b3a72737d0ce7fb33d1177372f44db827c module/ssrf-runtime 5501c65f90feec38049ce100cb320b2a629ebf1ad04b21f015a0d44aa1e4c448 module/state-paths -c4b8bd54bfa1c007384e0cb276e6c4fc3bae70beaea1ea408ab0025e35efedba module/status-helpers +6422d1324ea329a670e357e522dfa28f0b3c6c2fb006c71d7fd75f8dd6bb13d7 module/status-helpers f097d0096b21c8a052f0f649b7512ecf2aba4744ae6956f001950e053828b309 module/string-coerce-runtime c2aca425088c2bc9a74035f34d8b541354792eb0c44b988e314d59c25ac3aa7f module/telegram-account aef35bee2502cd6ed8765409b758e452aff8ac9469fd773e6a2a44c9a1bc3f66 module/temp-path 87fa81b9e58d8fc04a4b4202d2d37fca339615f5225687d9db905151439e0f4d module/text-chunking 434804a2166f6bf2e872bff0f03f3af850e44dea04b49b1d13b41df872bb71d1 module/text-runtime -5f4e7498a8cbcbd4aa34220895a761fbcb223a78cfdf06a4eeb00fb981650b89 module/tool-plugin +c407126a0ab30c77cb37bba55f747385bde6db7f59f04004c4a7f97a8d7de538 module/tool-plugin dc1a073c59ab61e2789533b777b3f0cb9af689d64a97796b10e8aa82552510db module/tool-results -8c47283ce8caf5008363cd8bc93f5db913f90ba7e2f13b38aa1dbc35cf0083c1 module/tool-send +788e35ecca74d535b95b109f6837025b97cd2b4854008166b9f2e4832c31210a module/tool-send cda105b721d498df23a554c6b68be150b8fe66b8b9172185c31a0b3b0646b1dc module/web-media -587559b5ee30350a801d028df25c2d679f330e0f3a294af28cd3935db8b17976 module/webhook-ingress +72862995b712c423731878c013bfc610415c030a10510799fd4f8567b944b7aa module/webhook-ingress a107b97d3c1bb7494e516760d613950d30dbf57ccaea4b037e2e832ca6115839 module/webhook-request-guards de59e86e126b75d13251cba7ebbe27b44d9b5588785d98df5ff4d6722374c81f module/widget-html 9161b36ec0ab062ea41b363c894fcd672a7727f21cb726739f99f9c184fce69d module/zod diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index 0541966d79f3..bb854e3f5c10 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -173,6 +173,23 @@ In groups and forum topics, an explicit mention of the configured bot handle (fo Common confusion: DM pairing approval does not mean "this sender is authorized everywhere." Pairing grants DM access only. If no command owner exists yet, the first approved pairing also sets `commands.ownerAllowFrom`, giving owner-only commands and exec approvals an explicit operator account. Group sender authorization still comes from explicit config allowlists. To be authorized for both DMs and group commands with one identity: put your numeric Telegram user ID in `channels.telegram.allowFrom`, and for owner-only commands make sure `commands.ownerAllowFrom` contains `telegram:`. + Use `channels.telegram.direct..tools` to set the built-in tool policy for one DM. `toolsBySender` selects a sender-specific policy by typed sender key such as `channel:telegram:` or `id:`: + +```json5 +{ + channels: { + telegram: { + direct: { + "*": { tools: { deny: ["write", "edit"] } }, + "603767951": { tools: {} }, + }, + }, + }, +} +``` + + A matching `toolsBySender` entry replaces `tools` for that DM. An exact chat entry replaces the whole `"*"` entry; it does not inherit wildcard fields. Account-level `direct` replaces the root `direct` map when present and inherits it only when omitted. The selected direct policy, global policy, per-agent policy, `tools.toolsBySender`, and `agents..tools.toolsBySender` apply as intersecting layers; a deny in any layer still blocks the tool. Codex uses policy-filtered OpenClaw tools for explicitly restricted turns and keeps its native tool surface for default profile narrowing. ACP-bound sessions reject a restrictive direct policy when their runtime cannot enforce it. + ### Finding your Telegram user ID Safer (no third-party bot): DM your bot, run `openclaw logs --follow`, read `from.id`. @@ -967,7 +984,7 @@ Primary reference: [Configuration reference - Telegram](/gateway/config-channels - startup/auth: `enabled`, `botToken`, `tokenFile` (must be a regular file; symlinks are rejected), `accounts.*` -- access control: `dmPolicy`, `allowFrom`, `groupPolicy`, `groupAllowFrom`, `groups`, `groups.*.topics.*`, top-level `bindings[]` (`type: "acp"`) +- access control: `dmPolicy`, `allowFrom`, `direct.*.tools`, `direct.*.toolsBySender`, `groupPolicy`, `groupAllowFrom`, `groups`, `groups.*.topics.*`, top-level `bindings[]` (`type: "acp"`) - topic defaults: `groups..topics."*"` applies to unmatched forum topics; exact topic IDs override it - exec approvals: `execApprovals`, `accounts.*.execApprovals` - command/menu: `commands.native`, `commands.nativeSkills`, `customCommands` diff --git a/docs/plugins/sdk-agent-harness.md b/docs/plugins/sdk-agent-harness.md index 5a876ebdfa30..fc10551a8abe 100644 --- a/docs/plugins/sdk-agent-harness.md +++ b/docs/plugins/sdk-agent-harness.md @@ -44,6 +44,21 @@ Before a harness is selected, OpenClaw has already resolved: A harness runs a prepared attempt; it does not pick providers, replace channel delivery, or silently switch models. +### Native tool-policy enforcement + +Set `conversationToolPolicySupport: "exact"` only when `runAttempt` enforces every +explicit OpenClaw tool-policy layer across native and built-in tools, OpenClaw +tools, requester and configured MCP servers, apps, delegation, and resumed +threads. Core passes `params.pluginHarnessToolPolicyRestricted` as the prepared +decision that the native surface must be isolated. Default tool-profile narrowing +does not set this flag. + +Omit the declaration when any native capability can bypass those layers. +OpenClaw then visibly rejects explicitly restricted turns before invoking the +harness. The operator can switch the session to the embedded runtime or upgrade +the harness. Channel `/btw` side questions with a restrictive direct policy are +rejected by core and are not covered by this declaration. + ### Harness-owned auth bootstrap By default, core resolves provider credentials before calling a harness. A diff --git a/extensions/codex/harness.ts b/extensions/codex/harness.ts index 6bd87c71cb6f..0acf963d0e61 100644 --- a/extensions/codex/harness.ts +++ b/extensions/codex/harness.ts @@ -72,6 +72,7 @@ export function createCodexAppServerAgentHarness(options: { autoSelection: { providerIds: [...providerIds] }, delegatedExecutionPluginIds: ["voice-call"], contextEngineHostCapabilities: CODEX_APP_SERVER_CONTEXT_ENGINE_HOST_CAPABILITIES, + conversationToolPolicySupport: "exact", deliveryDefaults: { visibleReplies: "message_tool", }, diff --git a/extensions/codex/src/app-server/bounded-turn.test.ts b/extensions/codex/src/app-server/bounded-turn.test.ts index 494e97eca784..795f84294ebe 100644 --- a/extensions/codex/src/app-server/bounded-turn.test.ts +++ b/extensions/codex/src/app-server/bounded-turn.test.ts @@ -126,7 +126,19 @@ function createClientFactory( return threadStartResult(); } if (method === "mcpServerStatus/list") { - return { data: options.mcpServers ?? [], nextCursor: null }; + return { + data: options.mcpServers ?? [ + { + name: "inherited", + serverInfo: null, + tools: {}, + resources: [], + resourceTemplates: [], + authStatus: "unsupported", + }, + ], + nextCursor: null, + }; } if (method === "thread/inject_items") { return {}; @@ -429,7 +441,9 @@ describe("runBoundedCodexAppServerTurn settled finalization isolation", () => { }); it("fails before history injection when the started thread exposes an MCP server", async () => { - const fake = createClientFactory({ mcpServers: [{ name: "unexpected" }] }); + const fake = createClientFactory({ + mcpServers: [{ name: "unexpected", serverInfo: null, tools: {} }], + }); await expect( runBoundedCodexAppServerTurn({ @@ -444,7 +458,9 @@ describe("runBoundedCodexAppServerTurn settled finalization isolation", () => { historyItems: [{ type: "function_call_output", call_id: "call-1", output: "sent" }], requireNoExternalCapabilities: true, }), - ).rejects.toThrow("Codex ring-zero MCP attestation found server unexpected"); + ).rejects.toThrow( + "Codex restricted-tool-surface MCP attestation found unexpected server unexpected", + ); expect(fake.methods).not.toContain("thread/inject_items"); expect(fake.methods).not.toContain("turn/start"); }); diff --git a/extensions/codex/src/app-server/bounded-turn.ts b/extensions/codex/src/app-server/bounded-turn.ts index f9d546ab3ec3..338bcf630c2c 100644 --- a/extensions/codex/src/app-server/bounded-turn.ts +++ b/extensions/codex/src/app-server/bounded-turn.ts @@ -42,8 +42,8 @@ import { } from "./shared-client.js"; import { buildCodexRuntimeThreadConfig } from "./thread-lifecycle.js"; import { - assertCodexRingZeroHasNoManagedHooks, - attestCodexRingZeroThreadHasNoMcpServers, + assertCodexRestrictedToolSurfaceHasNoManagedHooks, + attestCodexRestrictedToolSurfaceMcpServersDisabled, buildCodexRingZeroThreadConfigPatch, readCodexInheritedMcpServerNames, } from "./thread-requests.js"; @@ -230,8 +230,12 @@ async function runBoundedCodexAppServerTurnInWorkspace( ? await readCodexInheritedMcpServerNames(client, workspace.cwd, abortController.signal) : []; if (params.requireNoExternalCapabilities) { - await assertCodexRingZeroHasNoManagedHooks(client, abortController.signal); + await assertCodexRestrictedToolSurfaceHasNoManagedHooks(client, abortController.signal); } + const threadConfig = buildCodexRuntimeThreadConfig( + resolveBoundedThreadConfig(params, workspace, inheritedMcpServerNames), + { nativeCodeModeEnabled: false }, + ); const thread = assertCodexThreadStartResponse( await client.request( "thread/start", @@ -244,10 +248,7 @@ async function runBoundedCodexAppServerTurnInWorkspace( serviceName: "OpenClaw", ...(params.requireNoExternalCapabilities ? { baseInstructions: "" } : {}), developerInstructions: params.developerInstructions, - config: buildCodexRuntimeThreadConfig( - resolveBoundedThreadConfig(params, workspace, inheritedMcpServerNames), - { nativeCodeModeEnabled: false }, - ), + config: threadConfig, environments: [], dynamicTools: [], experimentalRawEvents: true, @@ -263,9 +264,10 @@ async function runBoundedCodexAppServerTurnInWorkspace( if (params.requireNoExternalCapabilities) { // Attest the started thread before injecting historical tool evidence. // Otherwise inherited MCP state could act on a finalization-only turn. - await attestCodexRingZeroThreadHasNoMcpServers( + await attestCodexRestrictedToolSurfaceMcpServersDisabled( client, thread.thread.id, + threadConfig, abortController.signal, ); } diff --git a/extensions/codex/src/app-server/dynamic-tool-build.test.ts b/extensions/codex/src/app-server/dynamic-tool-build.test.ts index 780f1413239c..46a720205f0a 100644 --- a/extensions/codex/src/app-server/dynamic-tool-build.test.ts +++ b/extensions/codex/src/app-server/dynamic-tool-build.test.ts @@ -28,6 +28,7 @@ import { } from "./dynamic-tool-build.js"; import { filterCodexDynamicTools, + filterCodexDynamicToolsForDisabledNativeSurface, resolveCodexDynamicToolsLoading, resolveCodexDynamicToolsLoadingForRuntime, } from "./dynamic-tool-profile.js"; @@ -168,6 +169,102 @@ async function buildDynamicToolsForTest( } describe("Codex app-server dynamic tool build", () => { + it("uses the prepared explicit-policy fact to disable the native surface", () => { + const params = createParams("/tmp/session.jsonl", "/tmp/workspace"); + params.disableTools = false; + + expect(shouldEnableCodexAppServerNativeToolSurface(params)).toBe(true); + params.config = { tools: { profile: "coding" } }; + expect(shouldEnableCodexAppServerNativeToolSurface(params)).toBe(true); + params.conversationToolPolicy = { deny: ["exec"] }; + expect(shouldEnableCodexAppServerNativeToolSurface(params)).toBe(true); + params.pluginHarnessToolPolicyRestricted = true; + expect(shouldEnableCodexAppServerNativeToolSurface(params)).toBe(false); + }); + + it("keeps policy-filterable OpenClaw coding replacements when native tools are disabled", () => { + const tools = [ + "read", + "write", + "edit", + "apply_patch", + "exec", + "process", + "update_plan", + "get_goal", + "create_goal", + "update_goal", + "message", + ].map((name) => ({ name })); + + expect( + filterCodexDynamicToolsForDisabledNativeSurface(tools, {}, { preserveShell: true }).map( + (tool) => tool.name, + ), + ).toEqual([ + "read", + "write", + "edit", + "apply_patch", + "exec", + "process", + "update_plan", + "get_goal", + "create_goal", + "update_goal", + "message", + ]); + expect( + filterCodexDynamicToolsForDisabledNativeSurface( + tools, + { codexDynamicToolsExclude: ["write", "apply_patch"] }, + { preserveShell: false }, + ).map((tool) => tool.name), + ).toEqual(["read", "edit", "update_plan", "get_goal", "create_goal", "update_goal", "message"]); + }); + + it("filters disabled-native replacements with the canonical conversation profile", async () => { + setOpenClawCodingToolsFactoryForTests((options) => + createOpenClawCodingTools(options).filter((tool) => + ["read", "write", "edit", "apply_patch", "exec", "process"].includes(tool.name), + ), + ); + const workspaceDir = path.join(tempDir, "workspace"); + const params = createParams(path.join(tempDir, "policy-session.jsonl"), workspaceDir); + params.disableTools = false; + params.runtimePlan = createCodexRuntimePlanFixture(); + params.conversationToolPolicy = { deny: ["exec", "process", "write", "edit"] }; + const tools = await buildDynamicToolsForTest(params, workspaceDir, { + nativeToolSurfaceEnabled: false, + }); + const names = tools.map((tool) => tool.name); + + expect(names.toSorted()).toEqual(["apply_patch", "read"]); + + params.config = { tools: { deny: ["apply_patch"] } }; + const intersected = await buildDynamicToolsForTest(params, workspaceDir, { + nativeToolSurfaceEnabled: false, + }); + expect(intersected.map((tool) => tool.name)).not.toContain("apply_patch"); + + params.conversationToolPolicy = {}; + params.config = { tools: { deny: ["exec", "process", "write", "edit"] } }; + const globalPolicyTools = await buildDynamicToolsForTest(params, workspaceDir, { + nativeToolSurfaceEnabled: false, + }); + expect(globalPolicyTools.map((tool) => tool.name).toSorted()).toEqual(["apply_patch", "read"]); + + params.config = { + agents: { + list: [{ id: "main", tools: { deny: ["exec", "process", "write", "edit"] } }], + }, + }; + const agentPolicyTools = await buildDynamicToolsForTest(params, workspaceDir, { + nativeToolSurfaceEnabled: false, + }); + expect(agentPolicyTools.map((tool) => tool.name).toSorted()).toEqual(["apply_patch", "read"]); + }); + it("removes account-wide app access when native tools are restricted", () => { expect( disableCodexPluginThreadConfig({ @@ -862,7 +959,15 @@ describe("Codex app-server dynamic tool build", () => { nativeToolSurfaceEnabled: false, }); - expect(tools.map((tool) => tool.name)).toEqual(["message", "sandbox_exec", "sandbox_process"]); + expect(tools.map((tool) => tool.name)).toEqual([ + "read", + "write", + "edit", + "apply_patch", + "message", + "sandbox_exec", + "sandbox_process", + ]); expect(tools.find((tool) => tool.name === "sandbox_exec")?.description).toContain( "configured sandbox backend", ); diff --git a/extensions/codex/src/app-server/dynamic-tool-build.ts b/extensions/codex/src/app-server/dynamic-tool-build.ts index 59ba7135b0e9..26fdd77af5bc 100644 --- a/extensions/codex/src/app-server/dynamic-tool-build.ts +++ b/extensions/codex/src/app-server/dynamic-tool-build.ts @@ -25,9 +25,9 @@ import { readCodexPluginConfig, type CodexPluginConfig } from "./config.js"; import { dynamicToolBuildState } from "./dynamic-tool-build-state.js"; import { filterCodexDynamicTools, - filterCodexDynamicToolsWithOpenClawShell, - isSystemAgentOnlyCodexDynamicToolAllowlist, + filterCodexDynamicToolsForDisabledNativeSurface, isForcedPrivateQaCodexRuntime, + isSystemAgentOnlyCodexDynamicToolAllowlist, normalizeCodexDynamicToolName, } from "./dynamic-tool-profile.js"; import { addCodexMessageToolOnlyFinalControl } from "./message-tool-final-control.js"; @@ -371,9 +371,12 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) { nativeProviderWebSearchSupport: input.nativeProviderWebSearchSupport, }); const readableAllTools = [...readableAllToolProjection.tools]; - const normallyProfiledTools = shouldKeepOpenClawShellDynamicTools(input, nativeExecutionPolicy) - ? filterCodexDynamicToolsWithOpenClawShell(readableAllTools, input.pluginConfig) - : filterCodexDynamicTools(readableAllTools, input.pluginConfig); + const normallyProfiledTools = + input.nativeToolSurfaceEnabled === false + ? filterCodexDynamicToolsForDisabledNativeSurface(readableAllTools, input.pluginConfig, { + preserveShell: shouldKeepOpenClawShellDynamicTools(input, nativeExecutionPolicy), + }) + : filterCodexDynamicTools(readableAllTools, input.pluginConfig); const hostSystemAgentActive = input.isHostScopedToolActive?.("openclaw") ?? isHostScopedAgentToolActive("openclaw"); const profileFilteredTools = @@ -536,6 +539,9 @@ export function shouldEnableCodexAppServerNativeToolSurface( sandboxExecServerEnabled?: boolean; } = {}, ): boolean { + if (params.pluginHarnessToolPolicyRestricted === true) { + return false; + } if (isCodexMemoryFlushRun(params)) { return false; } diff --git a/extensions/codex/src/app-server/dynamic-tool-profile.ts b/extensions/codex/src/app-server/dynamic-tool-profile.ts index b924dd524464..6c8c97436bc2 100644 --- a/extensions/codex/src/app-server/dynamic-tool-profile.ts +++ b/extensions/codex/src/app-server/dynamic-tool-profile.ts @@ -22,6 +22,14 @@ const CODEX_APP_SERVER_OWNED_DYNAMIC_TOOL_EXCLUDES = [ "tool_search_code", ] as const; const CODEX_NATIVE_GOAL_TOOL_EXCLUDES = ["get_goal", "create_goal", "update_goal"] as const; +const CODEX_APP_SERVER_OWNED_REPLACEABLE_TOOL_EXCLUDES = new Set([ + "read", + "write", + "edit", + "apply_patch", + "update_plan", + ...CODEX_NATIVE_GOAL_TOOL_EXCLUDES, +]); const CODEX_APP_SERVER_OWNED_SHELL_TOOL_EXCLUDES = new Set(["exec", "process"]); const DYNAMIC_TOOL_NAME_ALIASES: Record = { @@ -129,18 +137,21 @@ export function filterCodexDynamicTools( env: CodexDynamicToolProfileEnv = process.env, ): T[] { return filterCodexDynamicToolsWithOptions(tools, config, env, { + preserveOpenClawReplacements: false, preserveOpenClawShell: false, }); } -/** Keeps exec/process only when Codex cannot advertise an environment-backed native shell. */ -export function filterCodexDynamicToolsWithOpenClawShell( +/** Keeps OpenClaw coding tools that replace a disabled Codex native surface. */ +export function filterCodexDynamicToolsForDisabledNativeSurface( tools: T[], config: Pick, + options: { preserveShell: boolean }, env: CodexDynamicToolProfileEnv = process.env, ): T[] { return filterCodexDynamicToolsWithOptions(tools, config, env, { - preserveOpenClawShell: true, + preserveOpenClawReplacements: true, + preserveOpenClawShell: options.preserveShell, }); } @@ -148,11 +159,13 @@ function filterCodexDynamicToolsWithOptions( tools: T[], config: Pick, env: CodexDynamicToolProfileEnv, - options: { preserveOpenClawShell: boolean }, + options: { preserveOpenClawReplacements: boolean; preserveOpenClawShell: boolean }, ): T[] { const excludes = new Set(); - for (const name of CODEX_NATIVE_GOAL_TOOL_EXCLUDES) { - excludes.add(name); + if (!options.preserveOpenClawReplacements) { + for (const name of CODEX_NATIVE_GOAL_TOOL_EXCLUDES) { + excludes.add(name); + } } if (isForcedPrivateQaCodexRuntime(env)) { // Native apply_patch is registered first; advertising a second handler @@ -160,6 +173,12 @@ function filterCodexDynamicToolsWithOptions( excludes.add("apply_patch"); } else { for (const name of CODEX_APP_SERVER_OWNED_DYNAMIC_TOOL_EXCLUDES) { + if ( + options.preserveOpenClawReplacements && + CODEX_APP_SERVER_OWNED_REPLACEABLE_TOOL_EXCLUDES.has(name) + ) { + continue; + } if (options.preserveOpenClawShell && CODEX_APP_SERVER_OWNED_SHELL_TOOL_EXCLUDES.has(name)) { continue; } diff --git a/extensions/codex/src/app-server/run-attempt-resources.ts b/extensions/codex/src/app-server/run-attempt-resources.ts index dbd9148f2dfe..5d22b9704991 100644 --- a/extensions/codex/src/app-server/run-attempt-resources.ts +++ b/extensions/codex/src/app-server/run-attempt-resources.ts @@ -204,6 +204,13 @@ export function prepareCodexAttemptResources(prompt: CodexAttemptPrompt) { decision: { action: "resume"; binding: CodexAppServerThreadBinding } | { action: "start" }, ) => { state.nativeHookRelay?.unregister(); + if (params.pluginHarnessToolPolicyRestricted === true) { + state.nativeHookRelay = undefined; + return { + configPatch: buildCodexNativeHookRelayDisabledConfig(), + nativeHookRelayGeneration: undefined, + }; + } state.nativeHookRelay = createCodexNativeHookRelay({ options: options.nativeHookRelay, generation: diff --git a/extensions/codex/src/app-server/run-attempt-tool-setup.ts b/extensions/codex/src/app-server/run-attempt-tool-setup.ts index 043364199a07..74372266f130 100644 --- a/extensions/codex/src/app-server/run-attempt-tool-setup.ts +++ b/extensions/codex/src/app-server/run-attempt-tool-setup.ts @@ -249,6 +249,7 @@ export async function prepareCodexAttemptTools(runtime: CodexAttemptRuntime) { workspaceDir: effectiveWorkspace, cwd: effectiveCwd ?? effectiveWorkspace, sandboxToolPolicy: sandbox?.tools, + conversationToolPolicy: params.conversationToolPolicy, inputProvenance: params.inputProvenance, trustedInternalHandoff: params.trustedInternalHandoff, scheduledToolPolicy: params.scheduledToolPolicy, diff --git a/extensions/codex/src/app-server/run-attempt.test.ts b/extensions/codex/src/app-server/run-attempt.test.ts index 2631639fc10d..f2634e8d0250 100644 --- a/extensions/codex/src/app-server/run-attempt.test.ts +++ b/extensions/codex/src/app-server/run-attempt.test.ts @@ -1,6 +1,7 @@ // Codex tests cover run attempt plugin behavior. import fs from "node:fs/promises"; import path from "node:path"; +import { createOpenClawCodingTools } from "openclaw/plugin-sdk/agent-harness"; import { embeddedAgentLog, type EmbeddedRunAttemptParams, @@ -2249,6 +2250,7 @@ describe("runCodexAppServerAttempt", () => { ]); const params = createRunParams(); params.disableTools = false; + setCodexTestModelSupportsTools(params, true); params.runtimePlan = createCodexRuntimePlanFixture(); params.toolsAllow = []; params.extraSystemPrompt = "Tool and file actions are disabled for this sender by chat policy."; @@ -2304,6 +2306,62 @@ describe("runCodexAppServerAttempt", () => { expect(request.mock.calls.map(([method]) => method)).not.toContain("app/read"); }); + it("replaces the native surface with an exact conversation-policy-filtered catalog", async () => { + testing.setOpenClawCodingToolsFactoryForTests((options) => + createOpenClawCodingTools(options).filter((tool) => + ["read", "write", "edit", "apply_patch", "exec", "process"].includes(tool.name), + ), + ); + const params = createRunParams(); + params.disableTools = false; + setCodexTestModelSupportsTools(params, true); + params.runtimePlan = createCodexRuntimePlanFixture(); + params.conversationToolPolicy = { + deny: ["exec", "process", "write", "edit"], + }; + params.pluginHarnessToolPolicyRestricted = true; + const harness = createStartedThreadHarness(async (method) => { + if (method === "config/read") { + return { config: {}, layers: [] }; + } + if (method === "configRequirements/read") { + return { requirements: null }; + } + if (method === "mcpServerStatus/list") { + return { data: [], nextCursor: null }; + } + return undefined; + }); + + const run = runCodexAppServerAttempt(params); + await harness.waitForMethod("turn/start"); + const startRequest = harness.requests.find((request) => request.method === "thread/start"); + const startParams = startRequest?.params as + | { + dynamicTools?: CodexDynamicToolSpec[]; + environments?: unknown[]; + config?: Record; + } + | undefined; + const dynamicToolNames = flattenSpecsWithNamespace(startParams?.dynamicTools ?? []).map( + (tool) => tool.name, + ); + + expect(startParams?.environments).toEqual([]); + expect(dynamicToolNames.toSorted()).toEqual(["apply_patch", "read"]); + expect(startParams?.config).toMatchObject({ + "features.hooks": false, + "hooks.PreToolUse": [], + "hooks.PostToolUse": [], + "hooks.PermissionRequest": [], + "hooks.Stop": [], + }); + expect(harness.requests.map((request) => request.method)).toContain("mcpServerStatus/list"); + + await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" }); + await run; + }); + it("fails closed for Codex app defaults when restricted native tools have no plugin config", async () => { testing.setOpenClawCodingToolsFactoryForTests(() => [createRuntimeDynamicTool("message")]); const params = createRunParams(); diff --git a/extensions/codex/src/app-server/thread-lifecycle-errors.ts b/extensions/codex/src/app-server/thread-lifecycle-errors.ts index 3e11a1d9d24d..473658583276 100644 --- a/extensions/codex/src/app-server/thread-lifecycle-errors.ts +++ b/extensions/codex/src/app-server/thread-lifecycle-errors.ts @@ -14,10 +14,10 @@ export class CodexThreadBindingConflictError extends Error { } } -export class CodexRingZeroAttestationError extends Error { +export class CodexRestrictedToolSurfaceAttestationError extends Error { constructor(cause: unknown) { - super("Codex ring-zero MCP attestation failed", { cause }); - this.name = "CodexRingZeroAttestationError"; + super("Codex restricted-tool-surface MCP attestation failed", { cause }); + this.name = "CodexRestrictedToolSurfaceAttestationError"; } } diff --git a/extensions/codex/src/app-server/thread-lifecycle-io.ts b/extensions/codex/src/app-server/thread-lifecycle-io.ts index 43af34e0bbd0..75e5861f3e32 100644 --- a/extensions/codex/src/app-server/thread-lifecycle-io.ts +++ b/extensions/codex/src/app-server/thread-lifecycle-io.ts @@ -41,7 +41,7 @@ import { } from "./thread-fingerprints.js"; import { CodexAdoptedThreadActiveError, - CodexRingZeroAttestationError, + CodexRestrictedToolSurfaceAttestationError, CodexThreadBindingConflictError, CodexThreadStartRequestError, } from "./thread-lifecycle-errors.js"; @@ -55,7 +55,7 @@ import { resolveCodexAppServerThreadModelSelection, } from "./thread-model-selection.js"; import { - attestCodexRingZeroThreadHasNoMcpServers, + attestCodexRestrictedToolSurfaceMcpServersDisabled, buildThreadResumeParams, buildThreadStartParams, } from "./thread-requests.js"; @@ -78,7 +78,7 @@ type ThreadRequestContext = { environmentSelectionFingerprint?: string; hostSystemAgentActive: boolean; ringZeroActive: boolean; - ringZeroInheritedMcpServerNames: string[]; + restrictedToolSurfaceInheritedMcpServerNames: string[]; nativeSkillIsolation?: CodexNativeSkillIsolation; lifecycleTiming: CodexThreadLifecycleTimingTracker; normalizeBindingModelProvider: ( @@ -139,7 +139,7 @@ export async function resumeExistingCodexThread( environmentSelectionFingerprint, hostSystemAgentActive, ringZeroActive, - ringZeroInheritedMcpServerNames, + restrictedToolSurfaceInheritedMcpServerNames, nativeSkillIsolation, lifecycleTiming, normalizeBindingModelProvider, @@ -191,7 +191,7 @@ export async function resumeExistingCodexThread( nativeCodeModeOnlyEnabled: params.nativeCodeModeOnlyEnabled, webSearchAllowed: params.webSearchAllowed, hostSystemAgentActive, - ringZeroInheritedMcpServerNames, + restrictedToolSurfaceInheritedMcpServerNames, }), ); const requestModelProvider = @@ -226,18 +226,23 @@ export async function resumeExistingCodexThread( signal: params.signal, }), ); - if (ringZeroActive || isMessageOnlyCodexSourceReply(params.params)) { + if ( + ringZeroActive || + isMessageOnlyCodexSourceReply(params.params) || + params.params.pluginHarnessToolPolicyRestricted === true + ) { try { - await lifecycleTiming.measure("ring-zero-mcp-attestation", () => - attestCodexRingZeroThreadHasNoMcpServers( + await lifecycleTiming.measure("restricted-tool-surface-mcp-attestation", () => + attestCodexRestrictedToolSurfaceMcpServersDisabled( params.client, response.thread.id, + resumeParams.config, params.signal, ), ); } catch (error) { await (params.abandonClient ?? (() => closeCodexStartupClientBestEffort(params.client)))(); - throw new CodexRingZeroAttestationError(error); + throw new CodexRestrictedToolSurfaceAttestationError(error); } } throwIfAborted(); @@ -348,8 +353,8 @@ export async function resumeExistingCodexThread( if (isCodexAppServerStartSelectionChangedError(error)) { throw error; } - if (error instanceof CodexRingZeroAttestationError) { - await clearCurrentBinding("retiring a failed ring-zero thread attestation"); + if (error instanceof CodexRestrictedToolSurfaceAttestationError) { + await clearCurrentBinding("retiring a failed restricted-tool-surface attestation"); throw error; } if (error instanceof CodexAdoptedThreadActiveError) { @@ -415,7 +420,7 @@ export async function startFreshCodexThread( environmentSelectionFingerprint, hostSystemAgentActive, ringZeroActive, - ringZeroInheritedMcpServerNames, + restrictedToolSurfaceInheritedMcpServerNames, nativeSkillIsolation, lifecycleTiming, normalizeBindingModelProvider, @@ -461,7 +466,7 @@ export async function startFreshCodexThread( model: startModelSelection.model, modelProvider: startModelProvider, hostSystemAgentActive, - ringZeroInheritedMcpServerNames, + restrictedToolSurfaceInheritedMcpServerNames, }), ); const requestModelProvider = @@ -509,10 +514,19 @@ export async function startFreshCodexThread( } } const rolloutPath = resolveCodexThreadRolloutPath(response.thread); - if (ringZeroActive || isMessageOnlyCodexSourceReply(params.params)) { + if ( + ringZeroActive || + isMessageOnlyCodexSourceReply(params.params) || + params.params.pluginHarnessToolPolicyRestricted === true + ) { try { - await lifecycleTiming.measure("ring-zero-mcp-attestation", () => - attestCodexRingZeroThreadHasNoMcpServers(params.client, response.thread.id, params.signal), + await lifecycleTiming.measure("restricted-tool-surface-mcp-attestation", () => + attestCodexRestrictedToolSurfaceMcpServersDisabled( + params.client, + response.thread.id, + startParams.config, + params.signal, + ), ); } catch (error) { await (params.abandonClient ?? (() => closeCodexStartupClientBestEffort(params.client)))(); diff --git a/extensions/codex/src/app-server/thread-lifecycle-preflight.ts b/extensions/codex/src/app-server/thread-lifecycle-preflight.ts index 53d23ff4e988..d13cf0d2ae5d 100644 --- a/extensions/codex/src/app-server/thread-lifecycle-preflight.ts +++ b/extensions/codex/src/app-server/thread-lifecycle-preflight.ts @@ -24,7 +24,7 @@ import { import { createCodexThreadLifecycleTimingTracker } from "./thread-lifecycle-timing.js"; import type { CodexStartOrResumeThreadParams } from "./thread-lifecycle-types.js"; import { - assertCodexRingZeroHasNoManagedHooks, + assertCodexRestrictedToolSurfaceHasNoManagedHooks, buildCodexRingZeroThreadConfigPatch, CODEX_RING_ZERO_BASE_INSTRUCTIONS, readCodexInheritedMcpServerNames, @@ -103,18 +103,21 @@ export async function prepareCodexThreadLifecyclePreflight(params: CodexStartOrR const ringZeroActive = hostSystemAgentActive && isSystemAgentOnlyCodexDynamicToolAllowlist(params.params.toolsAllow); const messageOnlySourceReply = isMessageOnlyCodexSourceReply(params.params); - const restrictedToolSurface = ringZeroActive || messageOnlySourceReply; + const restrictedToolSurface = + ringZeroActive || + messageOnlySourceReply || + params.params.pluginHarnessToolPolicyRestricted === true; if (restrictedToolSurface && params.nativeCodeModeEnabled !== false) { throw new Error("Codex restricted tool surfaces require native code mode to be disabled"); } - const ringZeroInheritedMcpServerNames = restrictedToolSurface - ? await lifecycleTiming.measure("ring-zero-mcp-config-read", () => + const restrictedToolSurfaceInheritedMcpServerNames = restrictedToolSurface + ? await lifecycleTiming.measure("restricted-tool-surface-mcp-config-read", () => readCodexInheritedMcpServerNames(params.client, params.cwd, params.signal), ) : []; if (restrictedToolSurface) { - await lifecycleTiming.measure("ring-zero-config-requirements-read", () => - assertCodexRingZeroHasNoManagedHooks(params.client, params.signal), + await lifecycleTiming.measure("restricted-tool-surface-config-requirements-read", () => + assertCodexRestrictedToolSurfaceHasNoManagedHooks(params.client, params.signal), ); } const ringZeroConfigFingerprint = ringZeroActive @@ -124,7 +127,7 @@ export async function prepareCodexThreadLifecyclePreflight(params: CodexStartOrR config: buildCodexRingZeroThreadConfigPatch( params.params, true, - ringZeroInheritedMcpServerNames, + restrictedToolSurfaceInheritedMcpServerNames, )!, }) : undefined; @@ -146,7 +149,8 @@ export async function prepareCodexThreadLifecyclePreflight(params: CodexStartOrR ringZeroActive, ringZeroClientInstanceId, ringZeroConfigFingerprint, - ringZeroInheritedMcpServerNames, + restrictedToolSurface, + restrictedToolSurfaceInheritedMcpServerNames, userMcpServersConfigPatch, userMcpServersFingerprint, webSearchThreadConfigFingerprint, diff --git a/extensions/codex/src/app-server/thread-lifecycle-run.ts b/extensions/codex/src/app-server/thread-lifecycle-run.ts index ba3756bc1b14..b8ca026e579b 100644 --- a/extensions/codex/src/app-server/thread-lifecycle-run.ts +++ b/extensions/codex/src/app-server/thread-lifecycle-run.ts @@ -75,7 +75,8 @@ export async function startOrResumeThread( ringZeroActive, ringZeroClientInstanceId, ringZeroConfigFingerprint, - ringZeroInheritedMcpServerNames, + restrictedToolSurface, + restrictedToolSurfaceInheritedMcpServerNames, userMcpServersConfigPatch, userMcpServersFingerprint, webSearchThreadConfigFingerprint, @@ -183,6 +184,9 @@ export async function startOrResumeThread( nativeProviderWebSearchSupport: params.nativeProviderWebSearchSupport, nativeCodeModeOnlyEnabled: params.nativeCodeModeOnlyEnabled, webSearchAllowed: params.webSearchAllowed, + hostSystemAgentActive, + restrictedToolSurface, + restrictedToolSurfaceInheritedMcpServerNames, environmentSelection: params.environmentSelection, provisionalAppIds: pluginThreadConfig?.provisionalAppIds, signal: params.signal, @@ -396,7 +400,7 @@ export async function startOrResumeThread( assertCodexBindingMayBeReplaced(binding, "changing web-search configuration"); if (!ringZeroActive && transientWebSearchRestriction) { embeddedAgentLog.debug( - "codex app-server web search restricted for turn; starting transient thread", + "codex app-server tool surface restricted for turn; starting transient thread", { threadId: binding.threadId, }, @@ -613,7 +617,7 @@ export async function startOrResumeThread( cause, }), ringZeroActive, - ringZeroInheritedMcpServerNames, + restrictedToolSurfaceInheritedMcpServerNames, startModelProvider, startModelSelection, throwIfAborted, @@ -643,7 +647,7 @@ export async function startOrResumeThread( environmentSelectionFingerprint, hostSystemAgentActive, ringZeroActive, - ringZeroInheritedMcpServerNames, + restrictedToolSurfaceInheritedMcpServerNames, nativeSkillIsolation, lifecycleTiming, normalizeBindingModelProvider, @@ -677,7 +681,7 @@ export async function startOrResumeThread( environmentSelectionFingerprint, hostSystemAgentActive, ringZeroActive, - ringZeroInheritedMcpServerNames, + restrictedToolSurfaceInheritedMcpServerNames, nativeSkillIsolation, lifecycleTiming, normalizeBindingModelProvider, diff --git a/extensions/codex/src/app-server/thread-lifecycle-warm.ts b/extensions/codex/src/app-server/thread-lifecycle-warm.ts index e4c1450e8632..a9fcc3de52e0 100644 --- a/extensions/codex/src/app-server/thread-lifecycle-warm.ts +++ b/extensions/codex/src/app-server/thread-lifecycle-warm.ts @@ -43,7 +43,7 @@ type CodexWarmThreadReuseParams = { nativeSkillIsolation?: Parameters[1]; releaseConsumedThread: (threadId: string, cause?: unknown) => Promise; ringZeroActive: boolean; - ringZeroInheritedMcpServerNames: string[]; + restrictedToolSurfaceInheritedMcpServerNames: string[]; startModelProvider?: string; startModelSelection: ReturnType; throwIfAborted: () => void; @@ -127,7 +127,7 @@ export async function tryReuseCodexLiveThread( nativeSkillIsolation, releaseConsumedThread, ringZeroActive, - ringZeroInheritedMcpServerNames, + restrictedToolSurfaceInheritedMcpServerNames, startModelProvider, startModelSelection, throwIfAborted, @@ -181,7 +181,7 @@ export async function tryReuseCodexLiveThread( nativeCodeModeOnlyEnabled: params.nativeCodeModeOnlyEnabled, webSearchAllowed: params.webSearchAllowed, hostSystemAgentActive, - ringZeroInheritedMcpServerNames, + restrictedToolSurfaceInheritedMcpServerNames, }), ); const liveThreadConfigFingerprint = fingerprintCodexThreadConfig( diff --git a/extensions/codex/src/app-server/thread-lifecycle.binding.test.ts b/extensions/codex/src/app-server/thread-lifecycle.binding.test.ts index 90eee67c59a3..e0c57655e923 100644 --- a/extensions/codex/src/app-server/thread-lifecycle.binding.test.ts +++ b/extensions/codex/src/app-server/thread-lifecycle.binding.test.ts @@ -40,6 +40,17 @@ function startOrResumeThread( }); } +function disabledMcpServerStatus(name: string) { + return { + name, + serverInfo: null, + tools: {}, + resources: [], + resourceTemplates: [], + authStatus: "unsupported", + }; +} + function createThreadLifecycleAppServerOptions(): Parameters< typeof startOrResumeThread >[0]["appServer"] { @@ -1153,7 +1164,13 @@ describe("Codex app-server thread lifecycle bindings", () => { return threadStartResult("thread-ring-zero-1"); } if (method === "mcpServerStatus/list") { - return { data: [], nextCursor: null }; + return { + data: [ + disabledMcpServerStatus("arbitrary.server"), + disabledMcpServerStatus("local helper"), + ], + nextCursor: null, + }; } throw new Error(`unexpected method: ${method}`); }); @@ -1240,7 +1257,14 @@ describe("Codex app-server thread lifecycle bindings", () => { return threadStartResult(`thread-message-only-${nextThread++}`); } if (method === "mcpServerStatus/list") { - return { data: [], nextCursor: null }; + return { + data: [ + disabledMcpServerStatus("arbitrary.server"), + disabledMcpServerStatus("local helper"), + disabledMcpServerStatus("request-only"), + ], + nextCursor: null, + }; } throw new Error(`unexpected method: ${method}`); }); @@ -1294,7 +1318,7 @@ describe("Codex app-server thread lifecycle bindings", () => { config: common.config, nativeCodeModeEnabled: false, hostSystemAgentActive: false, - ringZeroInheritedMcpServerNames: ["arbitrary.server", "local helper"], + restrictedToolSurfaceInheritedMcpServerNames: ["arbitrary.server", "local helper"], }); const threadPayloads = [ ...threadRequests.map(([, threadRequest]) => threadRequest), @@ -1357,12 +1381,68 @@ describe("Codex app-server thread lifecycle bindings", () => { for (const threadId of ["thread-message-only-1", "thread-message-only-2"]) { expect(request).toHaveBeenCalledWith( "mcpServerStatus/list", - { threadId, limit: 1, detail: "toolsAndAuthOnly" }, + { threadId, detail: "toolsAndAuthOnly" }, expect.anything(), ); } }); + it("removes every native capability from an explicitly restricted thread", () => { + const params = createParams( + path.join(tempDir, "conversation-policy-session.jsonl"), + path.join(tempDir, "conversation-policy-workspace"), + ); + params.conversationToolPolicy = { deny: ["exec"] }; + params.pluginHarnessToolPolicyRestricted = true; + const request = buildThreadResumeParams(params, { + threadId: "thread-policy-restricted", + appServer: createThreadLifecycleAppServerOptions(), + dynamicTools: [], + config: { + "features.apps": true, + "features.current_time_reminder": true, + "features.deferred_executor": true, + "features.hooks": true, + "features.image_generation": true, + "features.memories": true, + "features.multi_agent": true, + "features.multi_agent_v2": true, + "features.plugins": true, + "features.standalone_web_search": true, + "features.token_budget": true, + "orchestrator.mcp.enabled": true, + "orchestrator.skills.enabled": true, + "tools.experimental_request_user_input.enabled": true, + "tools.update_plan.enabled": true, + mcp_servers: { inherited: { command: "unsafe" } }, + web_search: "live", + }, + nativeCodeModeEnabled: false, + hostSystemAgentActive: false, + restrictedToolSurfaceInheritedMcpServerNames: ["inherited"], + }); + + expect(request.config).toMatchObject({ + "features.apps": false, + "features.current_time_reminder": false, + "features.deferred_executor": false, + "features.hooks": false, + "features.image_generation": false, + "features.memories": false, + "features.multi_agent": false, + "features.multi_agent_v2": false, + "features.plugins": false, + "features.standalone_web_search": false, + "features.token_budget": false, + "orchestrator.mcp.enabled": false, + "orchestrator.skills.enabled": false, + "tools.experimental_request_user_input.enabled": false, + "tools.update_plan.enabled": false, + mcp_servers: { inherited: { enabled: false } }, + web_search: "disabled", + }); + }); + it("starts a fresh restricted OpenClaw thread for a new app-server client", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); const workspaceDir = path.join(tempDir, "workspace"); @@ -1449,7 +1529,7 @@ describe("Codex app-server thread lifecycle bindings", () => { await startOrResumeThread(common); await expect(startOrResumeThread(common)).rejects.toThrow( - "Codex ring-zero MCP attestation failed", + "Codex restricted-tool-surface MCP attestation failed", ); expect(abandonClient).toHaveBeenCalledTimes(1); @@ -3035,7 +3115,7 @@ describe("Codex app-server thread lifecycle bindings", () => { expect(binding?.dynamicToolsFingerprint).not.toContain("tool_199"); }); - it("keeps plugin app bindings across transient native-tool-disabled turns", async () => { + it("keeps the native binding isolated from a restricted replacement-tool turn", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); const workspaceDir = path.join(tempDir, "workspace"); const pluginAppPolicyContext = createPluginAppPolicyContext(); @@ -3050,15 +3130,24 @@ describe("Codex app-server thread lifecycle bindings", () => { }); const params = createParams(sessionFile, workspaceDir); const appServer = createThreadLifecycleAppServerOptions(); - const request = vi.fn(async (method: string) => { - if (method === "thread/start") { - return threadStartResult("thread-transient"); - } - if (method === "thread/resume") { - return threadStartResult("thread-existing"); - } - throw new Error(`unexpected method: ${method}`); - }); + const request = vi.fn( + async ( + method: string, + _requestParams?: { + config?: unknown; + dynamicTools?: unknown[]; + environments?: unknown[]; + }, + ) => { + if (method === "thread/start") { + return threadStartResult("thread-transient"); + } + if (method === "thread/resume") { + return threadStartResult("thread-existing"); + } + throw new Error(`unexpected method: ${method}`); + }, + ); const buildDenyAllPluginThreadConfig = vi.fn(async () => ({ enabled: true, configPatch: { @@ -3088,7 +3177,7 @@ describe("Codex app-server thread lifecycle bindings", () => { client: { request } as never, params, cwd: workspaceDir, - dynamicTools: [], + dynamicTools: [createNamedDynamicTool("read"), createNamedDynamicTool("apply_patch")], appServer, nativeCodeModeEnabled: false, pluginThreadConfig: { @@ -3120,9 +3209,16 @@ describe("Codex app-server thread lifecycle bindings", () => { expect(buildDenyAllPluginThreadConfig).toHaveBeenCalledTimes(1); expect(buildEnabledPluginThreadConfig).toHaveBeenCalledTimes(1); - const requestCalls = request.mock.calls as unknown as Array<[string, { config?: unknown }]>; + const requestCalls = request.mock.calls; expect(requestCalls.map(([method]) => method)).toEqual(["thread/start", "thread/resume"]); - expect(requestCalls[0]?.[1].config).toMatchObject({ + expect(requestCalls[0]?.[1]).toMatchObject({ + dynamicTools: [ + expect.objectContaining({ name: "read" }), + expect.objectContaining({ name: "apply_patch" }), + ], + environments: [], + }); + expect(requestCalls[0]?.[1]?.config).toMatchObject({ apps: { _default: { enabled: false, diff --git a/extensions/codex/src/app-server/thread-lifecycle.test.ts b/extensions/codex/src/app-server/thread-lifecycle.test.ts index dc5e321f4651..349f37075f4a 100644 --- a/extensions/codex/src/app-server/thread-lifecycle.test.ts +++ b/extensions/codex/src/app-server/thread-lifecycle.test.ts @@ -35,6 +35,7 @@ import { resolveReasoningEffort, startOrResumeThread as startOrResumeThreadImpl, } from "./thread-lifecycle.js"; +import { attestCodexRestrictedToolSurfaceMcpServersDisabled } from "./thread-requests.js"; type CodexThreadLifecycleTimingLogger = NonNullable< NonNullable[0]["timing"]>["log"] @@ -61,6 +62,97 @@ describe("Codex incognito thread persistence", () => { }); describe("Codex ring-zero thread config", () => { + it("accepts upstream-shaped inactive rows for the disabled MCP names", async () => { + const request = vi.fn(async () => ({ + data: [disabledMcpServerStatus("inherited")], + nextCursor: null, + })); + + await expect( + attestCodexRestrictedToolSurfaceMcpServersDisabled( + { request } as never, + "thread-restricted", + { mcp_servers: { inherited: { enabled: false } } }, + ), + ).resolves.toBeUndefined(); + + expect(request).toHaveBeenCalledWith( + "mcpServerStatus/list", + { threadId: "thread-restricted", detail: "toolsAndAuthOnly" }, + { signal: undefined }, + ); + }); + + it.each([ + { + name: "an unexpected server", + status: { name: "unexpected", serverInfo: null, tools: {} }, + failure: "found unexpected server unexpected", + }, + { + name: "an active disabled server", + status: { + name: "inherited", + serverInfo: { name: "inherited", version: "1.0.0" }, + tools: {}, + }, + failure: "found active server inherited", + }, + { + name: "a disabled server without explicit inactive status", + status: { name: "inherited", tools: {} }, + failure: "returned malformed server inherited", + }, + { + name: "tools from a disabled server", + status: { name: "inherited", serverInfo: null, tools: { lookup: {} } }, + failure: "found tools for server inherited", + }, + ])("rejects $name", async ({ status, failure }) => { + const request = vi.fn(async () => ({ data: [status], nextCursor: null })); + + await expect( + attestCodexRestrictedToolSurfaceMcpServersDisabled( + { request } as never, + "thread-restricted", + { mcp_servers: { inherited: { enabled: false } } }, + ), + ).rejects.toThrow(failure); + }); + + it.each([ + { + name: "an empty status inventory", + statuses: [], + failure: "is missing server inherited", + }, + { + name: "one missing server", + statuses: [disabledMcpServerStatus("inherited")], + failure: "is missing server request", + }, + { + name: "a duplicate server", + statuses: [disabledMcpServerStatus("inherited"), disabledMcpServerStatus("inherited")], + failure: "returned duplicate server inherited", + }, + ])("rejects $name", async ({ statuses, failure }) => { + const request = vi.fn(async () => ({ data: statuses, nextCursor: null })); + + await expect( + attestCodexRestrictedToolSurfaceMcpServersDisabled( + { request } as never, + "thread-restricted", + { + mcp_servers: { + inherited: { enabled: false }, + request: { enabled: false }, + }, + }, + ), + ).rejects.toThrow(failure); + }); + it("applies the restriction to both thread start and resume", () => { const params = createAttemptParams({ provider: "openai" }); params.toolsAllow = ["openclaw"]; @@ -580,6 +672,17 @@ function nativeThreadResult(threadId: string, model: string, modelProvider: stri }; } +function disabledMcpServerStatus(name: string) { + return { + name, + serverInfo: null, + tools: {}, + resources: [], + resourceTemplates: [], + authStatus: "unsupported", + }; +} + function sourceThread(params: { threadId: string; status?: "idle" | "active"; @@ -2837,6 +2940,219 @@ describe("Codex app-server supervised branch lifecycle", () => { }); }); + it("isolates both supervised threads and restores native MCP config on the next unrestricted turn", async () => { + const sourceThreadId = "thread-source"; + const probeThreadId = "thread-probe"; + const finalThreadId = "thread-final"; + const workspaceDir = path.join(tempDir, "workspace"); + const attempt = createThreadLifecycleParams(path.join(tempDir, "session.jsonl"), workspaceDir); + attempt.pluginHarnessToolPolicyRestricted = true; + attempt.toolsAllow = ["openclaw"]; + const identity = await seedPendingSupervisionBinding({ + attempt, + cwd: workspaceDir, + pending: { sourceThreadId }, + }); + const request = vi.fn(async (method: string, requestParams?: unknown) => { + if (method === "config/read") { + return { + config: { mcp_servers: { inherited: { command: "inherited-mcp" } } }, + layers: [{ name: { type: "user" } }], + }; + } + if (method === "configRequirements/read") { + return { requirements: null }; + } + if (method === "thread/read") { + const threadId = (requestParams as { threadId?: string }).threadId; + return { + thread: + threadId === sourceThreadId + ? sourceThread({ threadId: sourceThreadId }) + : sourceThread({ threadId: finalThreadId }), + }; + } + if (method === "thread/fork") { + return nativeThreadResult(probeThreadId, "native-effective", "native-provider"); + } + if (method === "thread/start" || method === "thread/resume") { + return nativeThreadResult(finalThreadId, "native-effective", "native-provider"); + } + if (method === "mcpServerStatus/list") { + return { + data: [disabledMcpServerStatus("inherited"), disabledMcpServerStatus("request-only")], + nextCursor: null, + }; + } + if (method === "thread/archive") { + return {}; + } + throw new Error(`unexpected method: ${method}`); + }); + const common = { + client: { request } as never, + params: attempt, + cwd: workspaceDir, + dynamicTools: [], + config: { mcp_servers: { "request-only": { command: "request-mcp" } } }, + appServer: createThreadLifecycleAppServerOptions(), + nativeCodeModeEnabled: false, + userMcpServersEnabled: false, + hostSystemAgentActive: true, + }; + + await expect(startOrResumeThread(common)).resolves.toMatchObject({ + threadId: finalThreadId, + lifecycle: { action: "forked" }, + }); + + expect(request.mock.calls.map(([method]) => method)).toEqual([ + "config/read", + "configRequirements/read", + "thread/read", + "thread/fork", + "mcpServerStatus/list", + "thread/start", + "mcpServerStatus/list", + "thread/archive", + ]); + for (const method of ["thread/fork", "thread/start"]) { + const threadRequest = request.mock.calls.find(([candidate]) => candidate === method)?.[1] as + | { config?: Record } + | undefined; + expect(threadRequest?.config).toMatchObject({ + mcp_servers: { + inherited: { enabled: false }, + "request-only": { enabled: false }, + }, + }); + } + expect(request.mock.calls.find(([method]) => method === "thread/start")?.[1]).toMatchObject({ + baseInstructions: "", + }); + expect( + request.mock.calls + .filter(([method]) => method === "mcpServerStatus/list") + .map(([, requestParams]) => requestParams), + ).toEqual([ + { threadId: probeThreadId, detail: "toolsAndAuthOnly" }, + { threadId: finalThreadId, detail: "toolsAndAuthOnly" }, + ]); + + attempt.pluginHarnessToolPolicyRestricted = false; + attempt.toolsAllow = undefined; + request.mockClear(); + await expect( + startOrResumeThread({ + ...common, + hostSystemAgentActive: false, + nativeCodeModeEnabled: true, + }), + ).resolves.toMatchObject({ + threadId: finalThreadId, + lifecycle: { action: "resumed" }, + }); + + expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/read", "thread/resume"]); + const resumeParams = request.mock.calls[1]?.[1] as { config?: Record }; + expect(resumeParams.config).toMatchObject({ + mcp_servers: { "request-only": { command: "request-mcp" } }, + }); + expect(resumeParams.config).not.toHaveProperty("mcp_servers.inherited"); + const restoredBinding = await testCodexAppServerBindingStore.read(identity); + expect(restoredBinding?.pendingSupervisionBranch).toBeUndefined(); + expect(restoredBinding).not.toHaveProperty("restrictedToolSurface"); + }); + + it.each(["probe", "final"] as const)( + "cleans tracked threads and preserves the pending binding when the %s MCP attestation fails", + async (failedThread) => { + const sourceThreadId = "thread-source"; + const probeThreadId = "thread-probe"; + const finalThreadId = "thread-final"; + const workspaceDir = path.join(tempDir, "workspace"); + const attempt = createThreadLifecycleParams( + path.join(tempDir, "session.jsonl"), + workspaceDir, + ); + attempt.pluginHarnessToolPolicyRestricted = true; + const identity = await seedPendingSupervisionBinding({ + attempt, + cwd: workspaceDir, + pending: { sourceThreadId }, + }); + let attestationCount = 0; + const request = vi.fn(async (method: string, _requestParams?: unknown) => { + if (method === "config/read") { + return { + config: { mcp_servers: { inherited: { command: "inherited-mcp" } } }, + layers: [{ name: { type: "user" } }], + }; + } + if (method === "configRequirements/read") { + return { requirements: null }; + } + if (method === "thread/read") { + return { thread: sourceThread({ threadId: sourceThreadId }) }; + } + if (method === "thread/fork") { + return nativeThreadResult(probeThreadId, "native-effective", "native-provider"); + } + if (method === "thread/start") { + return nativeThreadResult(finalThreadId, "native-effective", "native-provider"); + } + if (method === "mcpServerStatus/list") { + attestationCount += 1; + const shouldFail = failedThread === "probe" || attestationCount === 2; + return { + data: shouldFail + ? [{ name: "unexpected", serverInfo: null, tools: {} }] + : [disabledMcpServerStatus("inherited")], + nextCursor: null, + }; + } + if (method === "thread/archive") { + return {}; + } + throw new Error(`unexpected method: ${method}`); + }); + const abandonClient = vi.fn(async () => undefined); + + await expect( + startOrResumeThread({ + client: { request } as never, + abandonClient, + params: attempt, + cwd: workspaceDir, + dynamicTools: [], + appServer: createThreadLifecycleAppServerOptions(), + nativeCodeModeEnabled: false, + userMcpServersEnabled: false, + }), + ).rejects.toThrow("found unexpected server unexpected"); + + const methods = request.mock.calls.map(([method]) => method); + expect(methods).not.toContain("thread/inject_items"); + expect(methods.filter((method) => method === "thread/start")).toHaveLength( + failedThread === "probe" ? 0 : 1, + ); + expect( + request.mock.calls + .filter(([method]) => method === "thread/archive") + .map(([, requestParams]) => requestParams), + ).toEqual( + (failedThread === "probe" ? [probeThreadId] : [probeThreadId, finalThreadId]).map( + (threadId) => ({ threadId }), + ), + ); + expect(abandonClient).not.toHaveBeenCalled(); + await expect(testCodexAppServerBindingStore.read(identity)).resolves.toMatchObject({ + threadId: sourceThreadId, + pendingSupervisionBranch: { sourceThreadId }, + }); + }, + ); + it.each([ { source: "configured plugin", diff --git a/extensions/codex/src/app-server/thread-requests.ts b/extensions/codex/src/app-server/thread-requests.ts index 962fe5437a58..2f88233865eb 100644 --- a/extensions/codex/src/app-server/thread-requests.ts +++ b/extensions/codex/src/app-server/thread-requests.ts @@ -140,7 +140,7 @@ export function buildThreadStartParams( model?: string | null; modelProvider?: string | null; hostSystemAgentActive?: boolean; - ringZeroInheritedMcpServerNames?: readonly string[]; + restrictedToolSurfaceInheritedMcpServerNames?: readonly string[]; }, ): CodexThreadStartParams { const ringZeroActive = @@ -182,7 +182,8 @@ export function buildThreadStartParams( webSearchAllowed: options.webSearchAllowed, appServer: options.appServer, hostSystemAgentActive: options.hostSystemAgentActive, - ringZeroInheritedMcpServerNames: options.ringZeroInheritedMcpServerNames, + restrictedToolSurfaceInheritedMcpServerNames: + options.restrictedToolSurfaceInheritedMcpServerNames, }), ...resolveCodexThreadEnvironmentSelection(options), developerInstructions: @@ -214,7 +215,7 @@ export function buildThreadResumeParams( webSearchAllowed?: boolean; model?: string | null; hostSystemAgentActive?: boolean; - ringZeroInheritedMcpServerNames?: readonly string[]; + restrictedToolSurfaceInheritedMcpServerNames?: readonly string[]; preserveNativeModel?: boolean; }, ): CodexThreadResumeParams { @@ -267,7 +268,8 @@ export function buildThreadResumeParams( webSearchAllowed: options.webSearchAllowed, appServer: options.appServer, hostSystemAgentActive: options.hostSystemAgentActive, - ringZeroInheritedMcpServerNames: options.ringZeroInheritedMcpServerNames, + restrictedToolSurfaceInheritedMcpServerNames: + options.restrictedToolSurfaceInheritedMcpServerNames, }), developerInstructions: options.developerInstructions ?? @@ -367,20 +369,21 @@ export function buildCodexRuntimeThreadConfigForRun( webSearchAllowed?: boolean; appServer?: Pick; hostSystemAgentActive?: boolean; - ringZeroInheritedMcpServerNames?: readonly string[]; + restrictedToolSurfaceInheritedMcpServerNames?: readonly string[]; } = {}, ): JsonObject { const ringZeroActive = (options.hostSystemAgentActive ?? isHostScopedAgentToolActive("openclaw")) && isSystemAgentOnlyCodexDynamicToolAllowlist(params.toolsAllow); const messageOnlySourceReply = isMessageOnlyCodexSourceReply(params); - const restrictedToolSurface = ringZeroActive || messageOnlySourceReply; + const restrictedToolSurface = + ringZeroActive || messageOnlySourceReply || params.pluginHarnessToolPolicyRestricted === true; const configMcpServers = config?.mcp_servers; if (restrictedToolSurface && configMcpServers !== undefined && !isJsonObject(configMcpServers)) { - throw new Error("Codex ring-zero received invalid thread mcp_servers config"); + throw new Error("Codex restricted tool surface received invalid thread mcp_servers config"); } - const ringZeroMcpServerNames = [ - ...(options.ringZeroInheritedMcpServerNames ?? []), + const restrictedToolSurfaceMcpServerNames = [ + ...(options.restrictedToolSurfaceInheritedMcpServerNames ?? []), ...(isJsonObject(configMcpServers) ? Object.keys(configMcpServers) : []), ]; // Per-thread configs deep-merge; drop server launch details before the @@ -410,12 +413,12 @@ export function buildCodexRuntimeThreadConfigForRun( params.delegationCapability === "report_only" ? CODEX_DELEGATION_DISABLED_THREAD_CONFIG : undefined, - messageOnlySourceReply - ? buildCodexRestrictedToolThreadConfigPatch(ringZeroMcpServerNames) + messageOnlySourceReply || params.pluginHarnessToolPolicyRestricted === true + ? buildCodexRestrictedToolThreadConfigPatch(restrictedToolSurfaceMcpServerNames) : buildCodexRingZeroThreadConfigPatch( params, options.hostSystemAgentActive, - ringZeroMcpServerNames, + restrictedToolSurfaceMcpServerNames, ), ) ?? baseConfig; if (params.bootstrapContextMode !== "lightweight") { @@ -443,8 +446,8 @@ export function buildCodexRingZeroThreadConfigPatch( function buildCodexRestrictedToolThreadConfigPatch( inheritedMcpServerNames: readonly string[], ): JsonObject { - // Narrow OpenClaw allowlists already send environments: [] and disable - // native code mode. Remove every other configurable Codex-owned source so + // Restricted turns already send environments: [] and disable native code + // mode. Remove every other configurable Codex-owned source so // native delegation, installed MCP tools, and utilities cannot escape the cap. const mcpServers = Object.fromEntries( [...new Set(inheritedMcpServerNames)].toSorted().map((name) => [name, { enabled: false }]), @@ -482,10 +485,14 @@ export async function readCodexInheritedMcpServerNames( layer.name.type === "legacyManagedConfigTomlFromFile" || layer.name.type === "legacyManagedConfigTomlFromMdm" ) { - throw new Error(`Codex ring-zero cannot override config layer ${layer.name.type}`); + throw new Error( + `Codex restricted tool surface cannot override config layer ${layer.name.type}`, + ); } if (!CODEX_RING_ZERO_OVERRIDABLE_LAYER_TYPES.has(layer.name.type)) { - throw new Error(`Codex ring-zero does not recognize config layer ${layer.name.type}`); + throw new Error( + `Codex restricted tool surface does not recognize config layer ${layer.name.type}`, + ); } } const configuredServers = response.config.mcp_servers; @@ -498,7 +505,7 @@ export async function readCodexInheritedMcpServerNames( return Object.keys(configuredServers).toSorted(); } -export async function assertCodexRingZeroHasNoManagedHooks( +export async function assertCodexRestrictedToolSurfaceHasNoManagedHooks( client: Pick, signal?: AbortSignal, ): Promise { @@ -525,7 +532,7 @@ export async function assertCodexRingZeroHasNoManagedHooks( throw new Error("Codex configRequirements/read returned invalid managed hooks"); } if (hasNonEmptyJsonValue(hooks)) { - throw new Error("Codex ring-zero cannot override managed hooks"); + throw new Error("Codex restricted tool surface cannot override managed hooks"); } } for (const key of ["featureRequirements", "feature_requirements"] as const) { @@ -541,30 +548,83 @@ export async function assertCodexRingZeroHasNoManagedHooks( throw new Error("Codex configRequirements/read returned invalid feature requirements"); } if (enabled && CODEX_RING_ZERO_RESTRICTED_FEATURES.has(feature)) { - throw new Error(`Codex ring-zero cannot override required feature ${feature}`); + throw new Error( + `Codex restricted tool surface cannot override required feature ${feature}`, + ); } } } } -export async function attestCodexRingZeroThreadHasNoMcpServers( +export async function attestCodexRestrictedToolSurfaceMcpServersDisabled( client: Pick, threadId: string, + threadConfig: JsonObject | undefined, signal?: AbortSignal, ): Promise { + const configuredServers = threadConfig?.mcp_servers; + if (configuredServers !== undefined && !isJsonObject(configuredServers)) { + throw new Error("Codex restricted-tool-surface thread config has invalid mcp_servers"); + } + // Codex reports configured-but-disabled servers as inactive status rows. + // Match those rows to the exact per-thread deny patch instead of requiring an empty inventory. + const expectedDisabledServerNames = new Set(); + for (const [name, serverConfig] of Object.entries(configuredServers ?? {})) { + if (!isJsonObject(serverConfig) || serverConfig.enabled !== false) { + throw new Error(`Codex restricted-tool-surface MCP server ${name} is not disabled`); + } + expectedDisabledServerNames.add(name); + } const response = await client.request( "mcpServerStatus/list", - { threadId, limit: 1, detail: "toolsAndAuthOnly" }, + { threadId, detail: "toolsAndAuthOnly" }, { signal }, ); if (!isJsonObject(response) || !Array.isArray(response.data)) { - throw new Error("Codex mcpServerStatus/list returned an invalid ring-zero attestation"); + throw new Error( + "Codex mcpServerStatus/list returned an invalid restricted-tool-surface attestation", + ); } - if (response.data.length > 0) { - const first = response.data[0]; - const serverName = - isJsonObject(first) && typeof first.name === "string" ? first.name : "unknown"; - throw new Error(`Codex ring-zero MCP attestation found server ${serverName}`); + const observedDisabledServerNames = new Set(); + for (const status of response.data) { + if (!isJsonObject(status) || typeof status.name !== "string" || !isJsonObject(status.tools)) { + throw new Error( + "Codex mcpServerStatus/list returned an invalid restricted-tool-surface server", + ); + } + if (!expectedDisabledServerNames.has(status.name)) { + throw new Error( + `Codex restricted-tool-surface MCP attestation found unexpected server ${status.name}`, + ); + } + if (observedDisabledServerNames.has(status.name)) { + throw new Error( + `Codex restricted-tool-surface MCP attestation returned duplicate server ${status.name}`, + ); + } + observedDisabledServerNames.add(status.name); + if (!Object.hasOwn(status, "serverInfo")) { + throw new Error( + `Codex restricted-tool-surface MCP attestation returned malformed server ${status.name}`, + ); + } + if (status.serverInfo !== null) { + throw new Error( + `Codex restricted-tool-surface MCP attestation found active server ${status.name}`, + ); + } + if (Object.keys(status.tools).length > 0) { + throw new Error( + `Codex restricted-tool-surface MCP attestation found tools for server ${status.name}`, + ); + } + } + for (const expectedName of expectedDisabledServerNames) { + if (!observedDisabledServerNames.has(expectedName)) { + throw new Error( + `Codex restricted-tool-surface MCP attestation is missing server ${expectedName}`, + ); + } } if (response.nextCursor !== undefined && response.nextCursor !== null) { throw new Error("Codex mcpServerStatus/list returned an invalid empty-page cursor"); diff --git a/extensions/codex/src/app-server/thread-supervision.ts b/extensions/codex/src/app-server/thread-supervision.ts index a68c21b905ef..33df018ec03c 100644 --- a/extensions/codex/src/app-server/thread-supervision.ts +++ b/extensions/codex/src/app-server/thread-supervision.ts @@ -43,6 +43,7 @@ import type { CodexThreadLifecycleTimingTracker } from "./thread-lifecycle-timin import type { CodexAppServerThreadLifecycleBinding } from "./thread-lifecycle-types.js"; import { buildDeveloperInstructions } from "./thread-prompt.js"; import { + attestCodexRestrictedToolSurfaceMcpServersDisabled, buildCodexRuntimeThreadConfigForRun, buildThreadStartParams, codexThreadSandboxOrPermissions, @@ -69,6 +70,9 @@ type PendingSupervisionMaterializationParams = { nativeProviderWebSearchSupport?: CodexNativeWebSearchSupport; nativeCodeModeOnlyEnabled?: boolean; webSearchAllowed?: boolean; + hostSystemAgentActive: boolean; + restrictedToolSurface: boolean; + restrictedToolSurfaceInheritedMcpServerNames: string[]; environmentSelection?: CodexTurnEnvironmentParams[]; signal?: AbortSignal; provisionalAppIds?: readonly string[]; @@ -147,6 +151,16 @@ export async function materializePendingSupervisionBranch( pending = await trackPendingSupervisionArtifacts(params, pending, [probeThreadId]); params.throwIfAborted(); const probeResponse = assertCodexThreadForkResponse(rawProbeResponse); + if (params.restrictedToolSurface) { + await params.lifecycleTiming.measure("restricted-tool-surface-mcp-attestation", () => + attestCodexRestrictedToolSurfaceMcpServersDisabled( + params.client, + probeThreadId, + probeParams.config ?? undefined, + params.signal, + ), + ); + } const nativeModel = requireNonBlankSupervisionValue(probeResponse.model, "native model"); const nativeModelProvider = requireNativeSupervisionModelProvider({ responseModelProvider: probeResponse.modelProvider, @@ -167,6 +181,9 @@ export async function materializePendingSupervisionBranch( environmentSelection: params.environmentSelection, model: nativeModel, modelProvider: nativeModelProvider, + hostSystemAgentActive: params.hostSystemAgentActive, + restrictedToolSurfaceInheritedMcpServerNames: + params.restrictedToolSurfaceInheritedMcpServerNames, }); assertExactSupervisionModelSelection(startParams, { model: nativeModel, @@ -208,6 +225,16 @@ export async function materializePendingSupervisionBranch( modelProvider: nativeModelProvider, operation: "thread/start response", }); + if (params.restrictedToolSurface) { + await params.lifecycleTiming.measure("restricted-tool-surface-mcp-attestation", () => + attestCodexRestrictedToolSurfaceMcpServersDisabled( + params.client, + finalThreadId, + startParams.config, + params.signal, + ), + ); + } if (params.provisionalAppIds?.length) { try { await params.lifecycleTiming.measure("plugin-app-attestation", () => @@ -403,6 +430,9 @@ function buildPendingSupervisionProbeForkParams( nativeCodeModeOnlyEnabled: params.nativeCodeModeOnlyEnabled, webSearchAllowed: params.webSearchAllowed, appServer: params.appServer, + hostSystemAgentActive: params.hostSystemAgentActive, + restrictedToolSurfaceInheritedMcpServerNames: + params.restrictedToolSurfaceInheritedMcpServerNames, }); return { threadId: pending.sourceThreadId, diff --git a/extensions/copilot/harness.ts b/extensions/copilot/harness.ts index be00320f0dd2..b7b6ba864b99 100644 --- a/extensions/copilot/harness.ts +++ b/extensions/copilot/harness.ts @@ -917,6 +917,7 @@ export function createCopilotAgentHarness( id: options?.id ?? "copilot", label: options?.label ?? "GitHub Copilot agent runtime", autoSelection: { providerIds: [] }, + conversationToolPolicySupport: "exact", supports(ctx) { const requestedRuntime = String(ctx.requestedRuntime ?? "") diff --git a/extensions/copilot/src/attempt-session-setup.ts b/extensions/copilot/src/attempt-session-setup.ts index 1ed1a6c82c66..bf1b079e9e0b 100644 --- a/extensions/copilot/src/attempt-session-setup.ts +++ b/extensions/copilot/src/attempt-session-setup.ts @@ -82,6 +82,12 @@ export async function createCopilotSessionSetup(params: { promptBuild.toolsAllow, shouldForceCopilotMessageTool(input) ? { forceToolNames: ["message"] } : undefined, ); + // Restricted turns may expose native ask_user only when its policy-filtered + // OpenClaw equivalent survived the canonical tool catalog. + const includeAskUser = + !ringZeroSystemAgentRun && + (attemptInput.pluginHarnessToolPolicyRestricted !== true || + promptTools.some((tool) => tool.name === "ask_user")); let promptImagesCount = 0; const emitLlmInput = (prompt: string, additionalContext?: string) => { if (settledToolFinalization) { @@ -127,7 +133,7 @@ export async function createCopilotSessionSetup(params: { emitLlmInput(prompt, additionalContext), } : undefined, - includeAskUser: !ringZeroSystemAgentRun, + includeAskUser, operation: operation ?? "attempt", }, ); @@ -149,7 +155,7 @@ export async function createCopilotSessionSetup(params: { emitLlmInput(prompt, additionalContext), } : undefined, - includeAskUser: !ringZeroSystemAgentRun, + includeAskUser, operation: operation ?? "attempt", }, ) diff --git a/extensions/copilot/src/attempt.test.ts b/extensions/copilot/src/attempt.test.ts index 463b807552c0..bf0ae4eeb19f 100644 --- a/extensions/copilot/src/attempt.test.ts +++ b/extensions/copilot/src/attempt.test.ts @@ -4618,6 +4618,38 @@ describe("runCopilotAttempt", () => { ]); }); + it("omits native ask_user from a restricted create-session catalog", async () => { + const sdk = makeFakeSdk(); + const pool = makeFakePool(sdk); + const sdkTools = [makeFakeSdkTool("read")]; + const createToolBridge = vi.fn(async () => ({ sdkTools, sourceTools: [] })); + + await runCopilotAttempt(makeParams({ pluginHarnessToolPolicyRestricted: true }), { + createToolBridge, + pool, + }); + + expect(readAvailableTools(sdk.createSession.mock.calls[0])).toEqual(["read"]); + }); + + it("keeps native ask_user when its restricted OpenClaw equivalent remains allowed", async () => { + const sdk = makeFakeSdk(); + const pool = makeFakePool(sdk); + const sdkTools = [makeFakeSdkTool("read"), makeFakeSdkTool("ask_user")]; + const createToolBridge = vi.fn(async () => ({ sdkTools, sourceTools: [] })); + + await runCopilotAttempt(makeParams({ pluginHarnessToolPolicyRestricted: true }), { + createToolBridge, + pool, + }); + + expect(readAvailableTools(sdk.createSession.mock.calls[0])).toEqual([ + "read", + "ask_user", + "builtin:ask_user", + ]); + }); + it("keeps a host-scoped OpenClaw create-session surface ring-zero", async () => { const sdk = makeFakeSdk(); const pool = makeFakePool(sdk); @@ -4701,6 +4733,27 @@ describe("runCopilotAttempt", () => { expect(resumeCfg?.availableTools).toEqual(["read", "builtin:ask_user"]); }); + it("omits native ask_user from a restricted resume-session catalog", async () => { + const sdk = makeFakeSdk({ + onResumeSession: (session) => { + session.sendAndWait.mockResolvedValueOnce(makeAssistantMessageEvent("resumed")); + }, + }); + const pool = makeFakePool(sdk); + const sdkTools = [makeFakeSdkTool("read")]; + const createToolBridge = vi.fn(async () => ({ sdkTools, sourceTools: [] })); + + await runCopilotAttempt( + makeParams({ + initialReplayState: { sdkSessionId: "sess-restricted" }, + pluginHarnessToolPolicyRestricted: true, + } as never), + { createToolBridge, pool }, + ); + + expect(requireResumeSessionConfig(sdk).availableTools).toEqual(["read"]); + }); + it("keeps a host-scoped OpenClaw resume-session surface ring-zero", async () => { const sdk = makeFakeSdk({ onResumeSession: (session) => { diff --git a/extensions/copilot/src/tool-bridge.test.ts b/extensions/copilot/src/tool-bridge.test.ts index 71d47b1b0061..2d6f3cb0e646 100644 --- a/extensions/copilot/src/tool-bridge.test.ts +++ b/extensions/copilot/src/tool-bridge.test.ts @@ -16,6 +16,7 @@ import { type MemoryFlushPlan, registerMemoryCapability, } from "openclaw/plugin-sdk/memory-core-host-runtime-core"; +import { withTempDir } from "openclaw/plugin-sdk/test-env"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createCopilotToolBridge } from "./tool-bridge.js"; @@ -1126,6 +1127,37 @@ describe("createCopilotToolBridge", () => { // so a Copilot run cannot expose the SDK any tool that the same // OpenClaw attempt would suppress. These tests pin the contract. describe("tool-surface gating (PR #86155 [P1] round-6)", () => { + it("submits the exact conversation-policy-filtered catalog to the SDK", async () => { + await withTempDir("openclaw-copilot-policy-", async (workspaceDir) => { + const result = await createCopilotToolBridge({ + agentId: "agent-1", + attemptParams: { + conversationToolPolicy: { + deny: ["exec", "process", "write", "edit", "ask_user"], + }, + runId: "policy-run", + sessionKey: "agent:main:policy-session", + workspaceDir, + } as never, + createOpenClawCodingTools: createRealOpenClawCodingTools, + modelId: "gpt-4o", + modelProvider: "github-copilot", + sessionId: "policy-session", + sessionKey: "agent:main:policy-session", + workspaceDir, + }); + const names = result.sdkTools.map((tool) => tool.name); + + expect(names).toContain("read"); + expect(names).toContain("apply_patch"); + expect(names).not.toContain("exec"); + expect(names).not.toContain("process"); + expect(names).not.toContain("write"); + expect(names).not.toContain("edit"); + expect(names).not.toContain("ask_user"); + }); + }); + it("short-circuits when attemptParams.disableTools is true and never calls createOpenClawCodingTools", async () => { const createOpenClawCodingTools = vi.fn(async () => [makeTool()]); const result = await createCopilotToolBridge({ diff --git a/extensions/copilot/src/tool-bridge.ts b/extensions/copilot/src/tool-bridge.ts index e4b8bf7652ef..2ea8e740b0c1 100644 --- a/extensions/copilot/src/tool-bridge.ts +++ b/extensions/copilot/src/tool-bridge.ts @@ -27,6 +27,9 @@ import { createAgentHarnessToolSurfaceRuntime } from "openclaw/plugin-sdk/agent- type CreateOpenClawCodingTools = (typeof import("openclaw/plugin-sdk/agent-harness"))["createOpenClawCodingTools"]; type OpenClawCodingToolsOptions = NonNullable[0]>; +type CreateOpenClawCodingToolsForBridge = ( + options?: OpenClawCodingToolsOptions, +) => ReturnType | Promise>; type AgentHarnessToolSurfaceRuntime = ReturnType; type CatalogExecuteParams = Parameters< NonNullable @@ -133,7 +136,7 @@ interface CopilotToolBridgeInput { */ onYieldDetected?: (message?: string) => void; onToolCompleted?: (completion: CopilotToolCompletion) => void | Promise; - createOpenClawCodingTools?: (opts: unknown) => AnyAgentTool[] | Promise; + createOpenClawCodingTools?: CreateOpenClawCodingToolsForBridge; beforeExecute?: (ctx: { toolName: string; toolCallId: string; @@ -372,6 +375,7 @@ function buildOpenClawCodingToolsOptions( jobId: a.jobId, memoryFlushWritePath: a.memoryFlushWritePath, toolsAllow: a.toolsAllow, + conversationToolPolicy: a.conversationToolPolicy, }), exec: { ...a.execOverrides, diff --git a/extensions/telegram/src/bot-message-context.media-carriers.test.ts b/extensions/telegram/src/bot-message-context.media-carriers.test.ts index 50fbbb7b6b24..a0c81214bb4c 100644 --- a/extensions/telegram/src/bot-message-context.media-carriers.test.ts +++ b/extensions/telegram/src/bot-message-context.media-carriers.test.ts @@ -8,6 +8,47 @@ vi.mock("./sticker-vision.runtime.js", () => ({ })); describe("buildTelegramMessageContext media carriers", () => { + it("carries direct tool policy into a topic-bound admitted turn", async () => { + const context = await buildTelegramMessageContextForTest({ + message: { + chat: { id: 42, type: "private", first_name: "Ada" }, + from: { id: 42, is_bot: false, first_name: "Ada", username: "ada" }, + message_thread_id: 7, + is_topic_message: true, + text: "hello", + }, + resolveTelegramGroupConfig: () => ({ + groupConfig: { + tools: { deny: ["write"] }, + toolsBySender: { + "channel:telegram:42": { deny: ["exec"] }, + }, + }, + topicConfig: { agentId: "support" }, + }), + }); + + expect(context?.ctxPayload).toMatchObject({ + ConversationToolPolicy: { deny: ["exec"] }, + }); + }); + + it("does not attach direct policy to group turns", async () => { + const context = await buildTelegramMessageContextForTest({ + message: { + chat: { id: -42, type: "supergroup", title: "Ops" }, + from: { id: 42, is_bot: false, first_name: "Ada" }, + text: "hello", + }, + resolveTelegramGroupConfig: () => ({ + groupConfig: { tools: { deny: ["exec"] }, requireMention: false }, + topicConfig: undefined, + }), + }); + + expect(context?.ctxPayload.ConversationToolPolicy).toBeUndefined(); + }); + it("keeps reply media structured before reply-chain rendering", () => { const target = describeReplyTarget({ message_id: 11, diff --git a/extensions/telegram/src/bot-message-context.session.ts b/extensions/telegram/src/bot-message-context.session.ts index bba7ebd71ff9..cecd58ddeec8 100644 --- a/extensions/telegram/src/bot-message-context.session.ts +++ b/extensions/telegram/src/bot-message-context.session.ts @@ -54,7 +54,10 @@ import { type TelegramThreadSpec, } from "./bot/helpers.js"; import type { TelegramContext } from "./bot/types.js"; -import { resolveTelegramGroupPromptSettings } from "./group-config-helpers.js"; +import { + resolveTelegramDirectToolPolicy, + resolveTelegramGroupPromptSettings, +} from "./group-config-helpers.js"; import { isTelegramHistoryEntryAfterAmbientWatermark, isTelegramChatWindowPromptContext, @@ -639,6 +642,14 @@ export async function buildTelegramInboundContextPayload(params: { commands: { authorized: commandAuthorized, }, + toolPolicy: isGroup + ? undefined + : resolveTelegramDirectToolPolicy({ + directConfig: groupConfig, + senderId, + senderName, + senderUsername, + }), mentions: mentionFacts, }, command: diff --git a/extensions/telegram/src/bot-native-commands.ts b/extensions/telegram/src/bot-native-commands.ts index 028c427acad0..1ed22ca0cd79 100644 --- a/extensions/telegram/src/bot-native-commands.ts +++ b/extensions/telegram/src/bot-native-commands.ts @@ -115,7 +115,10 @@ import { evaluateTelegramGroupBaseAccess, evaluateTelegramGroupPolicyAccess, } from "./group-access.js"; -import { resolveTelegramGroupPromptSettings } from "./group-config-helpers.js"; +import { + resolveTelegramDirectToolPolicy, + resolveTelegramGroupPromptSettings, +} from "./group-config-helpers.js"; import { resolveTelegramCommandIngressAuthorization } from "./ingress.js"; import { buildInlineKeyboard } from "./inline-keyboard.js"; import { buildTelegramNativeCommandCallbackData } from "./native-command-callback-data.js"; @@ -1709,6 +1712,14 @@ export const registerTelegramNativeCommands = ({ From: isGroup ? buildTelegramGroupFrom(chatId, resolvedThreadId) : `telegram:${chatId}`, To: `slash:${senderId || chatId}`, ChatType: isGroup ? "group" : "direct", + ConversationToolPolicy: isGroup + ? undefined + : resolveTelegramDirectToolPolicy({ + directConfig: groupConfig, + senderId, + senderName: buildSenderName(msg), + senderUsername, + }), ConversationLabel: conversationLabel, GroupSubject: isGroup ? (msg.chat.title ?? undefined) : undefined, GroupSystemPrompt: isGroup || (!isGroup && groupConfig) ? groupSystemPrompt : undefined, diff --git a/extensions/telegram/src/group-config-helpers.test.ts b/extensions/telegram/src/group-config-helpers.test.ts new file mode 100644 index 000000000000..d62531d6dfa2 --- /dev/null +++ b/extensions/telegram/src/group-config-helpers.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { mergeTelegramAccountConfig } from "./account-config.js"; +import { + resolveTelegramDirectToolPolicy, + resolveTelegramScopedGroupConfig, +} from "./group-config-helpers.js"; + +describe("resolveTelegramDirectToolPolicy", () => { + it("leaves tool access unchanged without a direct policy", () => { + expect(resolveTelegramDirectToolPolicy({})).toBeUndefined(); + }); + + it("prefers sender policy and matches the Telegram provider", () => { + const directConfig = { + tools: { deny: ["write"] }, + toolsBySender: { + "channel:telegram:42": { deny: ["exec"] }, + "channel:discord:42": { deny: ["read"] }, + "*": { deny: ["process"] }, + }, + }; + + expect(resolveTelegramDirectToolPolicy({ directConfig, senderId: "42" })).toEqual({ + deny: ["exec"], + }); + expect(resolveTelegramDirectToolPolicy({ directConfig, senderId: "7" })).toEqual({ + deny: ["process"], + }); + }); + + it("lets an explicit empty sender policy mask direct tools", () => { + expect( + resolveTelegramDirectToolPolicy({ + directConfig: { + tools: { deny: ["write"] }, + toolsBySender: { "id:42": {} }, + }, + senderId: "42", + }), + ).toEqual({}); + }); +}); + +describe("Telegram direct config precedence", () => { + it("uses whole-entry exact-over-wildcard replacement", () => { + const account = { + direct: { + "*": { tools: { deny: ["write"] } }, + "42": {}, + }, + }; + + const wildcard = resolveTelegramScopedGroupConfig(account, 7).groupConfig; + const exact = resolveTelegramScopedGroupConfig(account, 42).groupConfig; + + expect(resolveTelegramDirectToolPolicy({ directConfig: wildcard })).toEqual({ + deny: ["write"], + }); + expect(resolveTelegramDirectToolPolicy({ directConfig: exact })).toBeUndefined(); + }); + + it("inherits root direct config only when the account omits direct", () => { + const rootDirect = { "*": { tools: { deny: ["write"] } } }; + const base = { + channels: { + telegram: { + direct: rootDirect, + accounts: { inherited: {}, replaced: { direct: {} } }, + }, + }, + }; + + expect(mergeTelegramAccountConfig(base, "inherited").direct).toBe(rootDirect); + expect(mergeTelegramAccountConfig(base, "replaced").direct).toEqual({}); + }); +}); diff --git a/extensions/telegram/src/group-config-helpers.ts b/extensions/telegram/src/group-config-helpers.ts index f733e5902e08..ecc6bfdf98ca 100644 --- a/extensions/telegram/src/group-config-helpers.ts +++ b/extensions/telegram/src/group-config-helpers.ts @@ -1,4 +1,9 @@ -import { resolveChannelGroupPolicy, type ScopeTree } from "openclaw/plugin-sdk/channel-policy"; +import { + resolveChannelGroupPolicy, + resolveToolsBySender, + type GroupToolPolicyConfig, + type ScopeTree, +} from "openclaw/plugin-sdk/channel-policy"; // Telegram helper module supports group config helpers behavior. import type { OpenClawConfig, @@ -74,3 +79,20 @@ export function resolveTelegramGroupPromptSettings(params: { systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined; return { skillFilter, groupSystemPrompt }; } + +export function resolveTelegramDirectToolPolicy(params: { + directConfig?: Pick; + senderId?: string | null; + senderName?: string | null; + senderUsername?: string | null; +}): GroupToolPolicyConfig | undefined { + return ( + resolveToolsBySender({ + toolsBySender: params.directConfig?.toolsBySender, + messageProvider: "telegram", + senderId: params.senderId, + senderName: params.senderName, + senderUsername: params.senderUsername, + }) ?? params.directConfig?.tools + ); +} diff --git a/src/agents/agent-bundle-mcp-harness.test.ts b/src/agents/agent-bundle-mcp-harness.test.ts index 6f16b0269c26..a264688049bc 100644 --- a/src/agents/agent-bundle-mcp-harness.test.ts +++ b/src/agents/agent-bundle-mcp-harness.test.ts @@ -594,6 +594,29 @@ describe("materializeRequesterScopedMcpToolsForHarnessRun", () => { await guest!.dispose(); }); + it("removes direct-policy-denied tools from executable and advertised requester catalogs", async () => { + mocks.setResolveImpl(async (params) => + makeRuntime({ + sessionId: params.sessionId, + requesterSenderId: params.requesterSenderId ?? "authed", + }), + ); + + const result = await materializeRequesterScopedMcpToolsForHarnessRun({ + sessionId: "session-policy", + workspaceDir: "/workspace", + requesterSenderId: "authed", + policyContext: { + conversationToolPolicy: { deny: ["user-mail__inbox"] }, + }, + }); + + expect(result).toBeDefined(); + expect(result!.tools).toEqual([]); + expect(result!.advertisedTools).toEqual([]); + await result!.dispose(); + }); + it("routes authed calls to that sender's runtime only", async () => { mocks.setResolveImpl(async (params) => { const senderId = diff --git a/src/agents/agent-tools.ts b/src/agents/agent-tools.ts index 4fc89942eaf9..836f0c75fe3e 100644 --- a/src/agents/agent-tools.ts +++ b/src/agents/agent-tools.ts @@ -13,6 +13,7 @@ import type { ChatType } from "../channels/chat-type.js"; import type { InboundEventKind } from "../channels/inbound-event/kind.js"; import type { ModelCompatConfig } from "../config/types.models.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { GroupToolPolicyConfig } from "../config/types.tools.js"; import type { DiagnosticTraceContext } from "../infra/diagnostic-trace-context.js"; import { resolveEventSessionRoutingPolicy } from "../infra/event-session-routing.js"; import { applyExecPolicyLayer } from "../infra/exec-policy.js"; @@ -374,6 +375,8 @@ type OpenClawCodingToolsOptions = { skillUsagePaths?: SkillUsagePath[]; /** Prepared conversation-scoped facts for callers that already resolved this run context. */ conversationCapabilityProfile?: ResolvedConversationCapabilityProfile; + /** Trusted conversation policy prepared at channel ingress. */ + conversationToolPolicy?: GroupToolPolicyConfig; inputProvenance?: InputProvenance; /** Consumed in-process completion capability; never derived from model-facing input. */ trustedInternalHandoff?: TrustedSubagentCompletionHandoff; @@ -410,6 +413,7 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions) chatType: options?.chatType, messageTo: options?.messageTo, messageThreadId: options?.messageThreadId, + conversationToolPolicy: options?.conversationToolPolicy, currentChannelId: options?.currentChannelId, currentMessagingTarget: options?.currentMessagingTarget, currentThreadTs: options?.currentThreadTs, diff --git a/src/agents/conversation-capability-profile.test.ts b/src/agents/conversation-capability-profile.test.ts index 3bb06b6251ab..00a8ee6a73e9 100644 --- a/src/agents/conversation-capability-profile.test.ts +++ b/src/agents/conversation-capability-profile.test.ts @@ -11,9 +11,44 @@ import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createTestRegistry } from "../test-utils/channel-plugins.js"; import { INTERNAL_MESSAGE_CHANNEL } from "../utils/message-channel.js"; import { resolveConversationCapabilityProfile } from "./conversation-capability-profile.js"; +import { projectConversationToolNames } from "./conversation-tool-policy-pipeline.js"; import { isToolAllowedByPolicyName } from "./tool-policy-match.js"; describe("resolveConversationCapabilityProfile", () => { + it("intersects a prepared direct policy with existing tool policy", () => { + const profile = resolveConversationCapabilityProfile({ + config: { tools: { deny: ["write"] } }, + chatType: "direct", + conversationToolPolicy: { allow: ["read", "write", "exec"], deny: ["exec"] }, + }); + + expect(profile.policy.groupPolicy).toEqual({ + allow: ["read", "write", "exec"], + deny: ["exec"], + }); + expect(profile.policy.inheritancePolicies).toContain(profile.policy.groupPolicy); + expect( + projectConversationToolNames({ + capabilityProfile: profile, + toolNames: ["read", "write", "exec", "process"], + warn: () => undefined, + }), + ).toEqual(["read"]); + }); + + it("does not add a requester restriction without a conversation policy", () => { + const profile = resolveConversationCapabilityProfile({ chatType: "direct" }); + + expect(profile.policy.groupPolicy).toBeUndefined(); + expect( + projectConversationToolNames({ + capabilityProfile: profile, + toolNames: ["read", "write", "exec"], + warn: () => undefined, + }), + ).toEqual(["read", "write", "exec"]); + }); + it("prepares a direct conversation profile with sender tool restrictions", () => { const cfg: OpenClawConfig = { tools: { diff --git a/src/agents/conversation-capability-profile.ts b/src/agents/conversation-capability-profile.ts index e82ac92ff416..f36e32f4efe8 100644 --- a/src/agents/conversation-capability-profile.ts +++ b/src/agents/conversation-capability-profile.ts @@ -7,6 +7,7 @@ import { uniqueStrings } from "@openclaw/normalization-core/string-normalization import type { ChatType } from "../channels/chat-type.js"; import { normalizeChatType } from "../channels/chat-type.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { GroupToolPolicyConfig } from "../config/types.tools.js"; import type { RuntimePluginToolGrant } from "../plugins/runtime/tool-grant.js"; import type { InputProvenance } from "../sessions/input-provenance.js"; import type { SkillSnapshot } from "../skills/types.js"; @@ -21,6 +22,7 @@ import { resolveRequesterToolPolicies, type RequesterToolPolicySource, } from "./requester-tool-policy.js"; +import { pickSandboxToolPolicy } from "./sandbox-tool-policy.js"; import type { SandboxToolPolicy } from "./sandbox/types.js"; import type { ScheduledToolPolicyContext } from "./scheduled-tool-policy.js"; import type { TrustedSubagentCompletionHandoff } from "./subagent-announce-handoff.js"; @@ -52,6 +54,7 @@ export type ConversationCapabilityProfileParams = { chatType?: string; messageTo?: string | null; messageThreadId?: string | number | null; + conversationToolPolicy?: GroupToolPolicyConfig; currentChannelId?: string | null; currentMessagingTarget?: string | null; currentThreadTs?: string | null; @@ -236,6 +239,7 @@ export function resolveConversationCapabilityProfile( senderPolicyMode: params.scheduledToolPolicy || isOwnerInternalSession ? "never" : "always", groupPolicySessionKey: params.scheduledToolPolicy?.ownerSessionKey, requireConfiguredGroupAccount: params.scheduledToolPolicy?.mode === "account", + conversationPolicy: pickSandboxToolPolicy(params.conversationToolPolicy), }); const { groupPolicy, senderPolicy, subagentPolicy, inheritedToolPolicy } = requesterPolicies; const profilePolicy = resolveToolProfilePolicy(effective.profile); diff --git a/src/agents/embedded-agent-runner/compact.types.ts b/src/agents/embedded-agent-runner/compact.types.ts index 81215798c157..b969b59428d8 100644 --- a/src/agents/embedded-agent-runner/compact.types.ts +++ b/src/agents/embedded-agent-runner/compact.types.ts @@ -7,6 +7,7 @@ import type { ReasoningLevel, ThinkLevel } from "../../auto-reply/thinking.js"; import type { ChatType } from "../../channels/chat-type.js"; import type { SessionToolOverrides } from "../../config/sessions/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import type { GroupToolPolicyConfig } from "../../config/types.tools.js"; import type { ContextEngine, ContextEngineRuntimeContext } from "../../context-engine/types.js"; import type { CommandQueueEnqueueFn } from "../../process/command-queue.types.js"; import type { InputProvenance } from "../../sessions/input-provenance.js"; @@ -32,6 +33,7 @@ export type CompactEmbeddedAgentSessionParams = { clientCaps?: string[]; chatType?: ChatType; agentAccountId?: string; + conversationToolPolicy?: GroupToolPolicyConfig; currentChannelId?: string; currentThreadTs?: string; currentMessageId?: string | number; diff --git a/src/agents/embedded-agent-runner/prepared-compaction-runtime.ts b/src/agents/embedded-agent-runner/prepared-compaction-runtime.ts index d11e4d845e4a..53315a603caf 100644 --- a/src/agents/embedded-agent-runner/prepared-compaction-runtime.ts +++ b/src/agents/embedded-agent-runner/prepared-compaction-runtime.ts @@ -268,6 +268,7 @@ export async function buildPreparedCompactionRuntime(prepared: DirectCompactionP agentAccountId: params.agentAccountId, messageProvider: resolvedMessageProvider, chatType: params.chatType, + conversationToolPolicy: params.conversationToolPolicy, groupId: params.groupId, groupChannel: params.groupChannel, groupSpace: params.groupSpace, diff --git a/src/agents/embedded-agent-runner/run/attempt-tool-base-prepare.ts b/src/agents/embedded-agent-runner/run/attempt-tool-base-prepare.ts index d625164299f3..85732dec047e 100644 --- a/src/agents/embedded-agent-runner/run/attempt-tool-base-prepare.ts +++ b/src/agents/embedded-agent-runner/run/attempt-tool-base-prepare.ts @@ -139,6 +139,7 @@ export function prepareEmbeddedAttemptToolBase(params: { chatType: attempt.chatType, messageTo: attempt.messageTo, messageThreadId: attempt.messageThreadId, + conversationToolPolicy: attempt.conversationToolPolicy, currentChannelId: attempt.currentChannelId, currentMessagingTarget: attempt.currentMessagingTarget, currentThreadTs: attempt.currentThreadTs, diff --git a/src/agents/embedded-agent-runner/run/attempt.tool-run-context.ts b/src/agents/embedded-agent-runner/run/attempt.tool-run-context.ts index abad0c213459..af78390664b3 100644 --- a/src/agents/embedded-agent-runner/run/attempt.tool-run-context.ts +++ b/src/agents/embedded-agent-runner/run/attempt.tool-run-context.ts @@ -1,3 +1,4 @@ +import type { GroupToolPolicyConfig } from "../../../config/types.tools.js"; /** * Builds tool run context passed to embedded-agent tool handlers. */ @@ -15,12 +16,14 @@ export function buildEmbeddedAttemptToolRunContext(params: { jobId?: string; memoryFlushWritePath?: string; toolsAllow?: string[]; + conversationToolPolicy?: GroupToolPolicyConfig; trace?: DiagnosticTraceContext; }): { trigger?: EmbeddedRunTrigger; jobId?: string; memoryFlushWritePath?: string; runtimeToolAllowlist?: string[]; + conversationToolPolicy?: GroupToolPolicyConfig; trace?: DiagnosticTraceContext; } { return { @@ -28,6 +31,9 @@ export function buildEmbeddedAttemptToolRunContext(params: { jobId: params.jobId, memoryFlushWritePath: params.memoryFlushWritePath, ...(params.toolsAllow ? { runtimeToolAllowlist: params.toolsAllow } : {}), + ...(params.conversationToolPolicy + ? { conversationToolPolicy: params.conversationToolPolicy } + : {}), // Freeze trace metadata at the attempt boundary so later mutable diagnostic updates do not // rewrite the facts attached to tool calls already in flight. ...(params.trace ? { trace: freezeDiagnosticTraceContext(params.trace) } : {}), diff --git a/src/agents/embedded-agent-runner/run/params.ts b/src/agents/embedded-agent-runner/run/params.ts index 9eebd6a55c11..43653b3a4f1d 100644 --- a/src/agents/embedded-agent-runner/run/params.ts +++ b/src/agents/embedded-agent-runner/run/params.ts @@ -15,6 +15,7 @@ import type { ChatType } from "../../../channels/chat-type.js"; import type { InboundEventKind } from "../../../channels/inbound-event/kind.js"; import type { SessionToolOverrides } from "../../../config/sessions/types.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; +import type { GroupToolPolicyConfig } from "../../../config/types.tools.js"; import type { ImageContent } from "../../../llm/types.js"; import type { MediaFact } from "../../../media/media-facts.js"; import type { PromptImageOrderEntry } from "../../../media/prompt-image-order.js"; @@ -111,6 +112,8 @@ export type RunEmbeddedAgentParams = { messageTo?: string; /** Thread/topic identifier for routing replies to the originating thread. */ messageThreadId?: string | number; + /** Trusted channel-configured policy for the admitted conversation turn. */ + conversationToolPolicy?: GroupToolPolicyConfig; /** Group id for channel-level tool policy resolution. */ groupId?: string | null; /** Group channel label (e.g. #general) for channel-level tool policy resolution. */ diff --git a/src/agents/embedded-agent-runner/run/run-attempt-dispatch.ts b/src/agents/embedded-agent-runner/run/run-attempt-dispatch.ts index b6c2de09ab46..f0c199df3bc5 100644 --- a/src/agents/embedded-agent-runner/run/run-attempt-dispatch.ts +++ b/src/agents/embedded-agent-runner/run/run-attempt-dispatch.ts @@ -217,6 +217,7 @@ export async function dispatchEmbeddedRunAttempt(input: { agentAccountId: params.agentAccountId, messageTo: params.messageTo, messageThreadId: params.messageThreadId, + conversationToolPolicy: params.conversationToolPolicy, messageActionTurnCapability: params.messageActionTurnCapability, groupId: params.groupId, groupChannel: params.groupChannel, diff --git a/src/agents/embedded-agent-runner/run/types.ts b/src/agents/embedded-agent-runner/run/types.ts index eb5daa964929..c73516edb562 100644 --- a/src/agents/embedded-agent-runner/run/types.ts +++ b/src/agents/embedded-agent-runner/run/types.ts @@ -97,6 +97,8 @@ export type EmbeddedRunAttemptTrajectoryRecorder = { export type EmbeddedRunAttemptParams = EmbeddedRunAttemptBase & { /** Sticky operation identity used to suppress ordinary retry and hook policy. */ operation?: EmbeddedRunAttemptOperation; + /** Core-prepared fact that explicit requester/config policy restricts plugin-native tools. */ + pluginHarnessToolPolicyRestricted?: boolean; preparedModelRuntime?: PreparedModelRuntimeSnapshot; /** Active file-backed artifact target resolved by the run/session target seam. */ sessionFile: string; diff --git a/src/agents/harness/selection.test.ts b/src/agents/harness/selection.test.ts index 79a4f9b30392..f961c7cc4e12 100644 --- a/src/agents/harness/selection.test.ts +++ b/src/agents/harness/selection.test.ts @@ -1097,7 +1097,10 @@ describe("runAgentHarnessAttempt", () => { const classifyCall = classify.mock.calls.at(0); expect(classifyCall?.[0].sessionIdUsed).toBe("codex"); - expect(classifyCall?.[1]).toStrictEqual(params); + expect(classifyCall?.[1]).toStrictEqual({ + ...params, + pluginHarnessToolPolicyRestricted: false, + }); expect(result.agentHarnessId).toBe("codex"); expect(result.agentHarnessResultClassification).toBe("empty"); }); @@ -1108,6 +1111,7 @@ describe("runAgentHarnessAttempt", () => { { id: "codex", label: "Codex", + conversationToolPolicySupport: "exact", supports: (ctx) => ctx.provider === "codex" ? { supported: true, priority: 100 } : { supported: false }, runAttempt, @@ -1131,12 +1135,140 @@ describe("runAgentHarnessAttempt", () => { expect(attempt?.extraSystemPrompt).toContain("this sender is not allowed by policy"); }); + it("passes partial conversation policy to harnesses that enforce it exactly", async () => { + const received: Array<{ + conversationToolPolicy: EmbeddedRunAttemptParams["conversationToolPolicy"]; + pluginHarnessToolPolicyRestricted: boolean | undefined; + toolsAllow: string[] | undefined; + }> = []; + const runAttempt = vi.fn(async (attempt) => { + received.push({ + conversationToolPolicy: attempt.conversationToolPolicy, + pluginHarnessToolPolicyRestricted: attempt.pluginHarnessToolPolicyRestricted, + toolsAllow: attempt.toolsAllow, + }); + return createAttemptResult("codex"); + }); + registerAgentHarness( + { + id: "codex", + label: "Codex", + conversationToolPolicySupport: "exact", + supports: (ctx) => + ctx.provider === "codex" ? { supported: true, priority: 100 } : { supported: false }, + runAttempt, + }, + { ownerPluginId: "codex" }, + ); + + for (const toolsAllow of [undefined, ["Read", "Bash"]]) { + await runAgentHarnessAttempt({ + ...createAttemptParams(), + conversationToolPolicy: { deny: ["exec"] }, + toolsAllow, + }); + } + + expect(received).toEqual([ + { + conversationToolPolicy: { deny: ["exec"] }, + pluginHarnessToolPolicyRestricted: true, + toolsAllow: undefined, + }, + { + conversationToolPolicy: { deny: ["exec"] }, + pluginHarnessToolPolicyRestricted: true, + toolsAllow: ["Read", "Bash"], + }, + ]); + }); + + it("marks only explicit restrictive policy layers for plugin harness isolation", async () => { + const received: boolean[] = []; + const runAttempt = vi.fn(async (attempt) => { + received.push(attempt.pluginHarnessToolPolicyRestricted === true); + return createAttemptResult("codex"); + }); + registerAgentHarness( + { + id: "codex", + label: "Codex", + conversationToolPolicySupport: "exact", + supports: (ctx) => + ctx.provider === "codex" ? { supported: true, priority: 100 } : { supported: false }, + runAttempt, + }, + { ownerPluginId: "codex" }, + ); + + const cases: Array<{ + config?: OpenClawConfig; + conversationToolPolicy?: EmbeddedRunAttemptParams["conversationToolPolicy"]; + agentId?: string; + sessionKey?: string; + }> = [ + {}, + { config: { tools: { profile: "coding" } } as OpenClawConfig }, + { conversationToolPolicy: {} }, + { conversationToolPolicy: { allow: ["*"] } }, + { conversationToolPolicy: { deny: ["exec"] } }, + { config: { tools: { deny: ["exec"] } } as OpenClawConfig }, + { + config: { + agents: { list: [{ id: "worker", tools: { deny: ["exec"] } }] }, + } as OpenClawConfig, + agentId: "worker", + sessionKey: "agent:worker:session-1", + }, + { + config: { tools: { deny: ["exec"] } } as OpenClawConfig, + conversationToolPolicy: {}, + }, + ]; + + for (const testCase of cases) { + await runAgentHarnessAttempt({ + ...createAttemptParams(testCase.config), + conversationToolPolicy: testCase.conversationToolPolicy, + agentId: testCase.agentId, + sessionKey: testCase.sessionKey, + }); + } + + expect(received).toEqual([false, false, false, false, true, true, true, true]); + }); + + it("rejects restrictive policy before an unsupported plugin harness runs", async () => { + const runAttempt = vi.fn(async () => createAttemptResult("other")); + registerAgentHarness( + { + id: "other", + label: "Other runtime", + supports: (ctx) => + ctx.provider === "codex" ? { supported: true, priority: 100 } : { supported: false }, + runAttempt, + }, + { ownerPluginId: "other" }, + ); + + await expect( + runAgentHarnessAttempt({ + ...createAttemptParams(), + conversationToolPolicy: { deny: ["exec"] }, + }), + ).rejects.toThrow( + "Other runtime cannot enforce this conversation's tool policy. Use the embedded runtime", + ); + expect(runAttempt).not.toHaveBeenCalled(); + }); + it("adds chat policy wording for plugin harness group deny-all", async () => { const runAttempt = vi.fn(async () => createAttemptResult("codex")); registerAgentHarness( { id: "codex", label: "Codex", + conversationToolPolicySupport: "exact", supports: (ctx) => ctx.provider === "codex" ? { supported: true, priority: 100 } : { supported: false }, runAttempt, diff --git a/src/agents/harness/selection.ts b/src/agents/harness/selection.ts index d26156488394..d0fd512a7708 100644 --- a/src/agents/harness/selection.ts +++ b/src/agents/harness/selection.ts @@ -30,13 +30,18 @@ import { unwrapSecretSentinelsForProviderEgress, } from "../provider-secret-egress.js"; import { resolveSandboxRuntimeStatus } from "../sandbox/runtime-status.js"; -import { expandToolGroups, mergeAlsoAllowPolicy, normalizeToolName } from "../tool-policy.js"; +import { + expandToolGroups, + mergeAlsoAllowPolicy, + normalizeToolName, + toolPolicyRestrictsTools, +} from "../tool-policy.js"; import type { SystemAgentToolOptions } from "../tools/system-agent-tool.js"; import { resolveAgentHarnessAutoSelectionHint } from "./auto-selection.js"; import { createOpenClawAgentHarness } from "./builtin-openclaw.js"; import { selectContextEngineForTranscriptHost } from "./context-engine-logical-turn.js"; import { drainPendingContextEngineTurnsBeforeRun } from "./context-engine-turn-attempt.js"; -import { MissingAgentHarnessError } from "./errors.js"; +import { AgentHarnessPreflightError, MissingAgentHarnessError } from "./errors.js"; import { runAgentHarnessLifecycleAttempt, runAgentHarnessLifecycleFinalization, @@ -145,6 +150,7 @@ type PluginHarnessToolPolicyContext = Pick< | "modelId" | "messageProvider" | "messageChannel" + | "conversationToolPolicy" | "spawnedBy" | "groupId" | "groupChannel" @@ -167,6 +173,7 @@ type ResolvedPluginHarnessToolPolicies = { senderScopedGroupPolicy?: PluginHarnessToolPolicy; groupPolicy?: PluginHarnessToolPolicy; runtimePolicies: Array; + toolPolicyRestricted: boolean; }; function listPluginAgentHarnesses(): AgentHarness[] { @@ -549,8 +556,19 @@ async function runSelectedAgentHarnessAttempt( runWithAgentRingZeroTools(ringZeroTools, () => { // Resolve plugin policy after entering the host scope. Ring-zero tools are // trusted setup authority and must survive ordinary deny-all policy. - const attemptParams = + const hostOpenClawAuthority = + isHostScopedAgentToolActive("openclaw") && + isSystemAgentOnlyAllowlist(pluginParams.toolsAllow); + const preparedParams = harness.id === "openclaw" ? pluginParams : preparePluginHarnessParams(pluginParams); + const attemptParams = + hostOpenClawAuthority && preparedParams.pluginHarnessToolPolicyRestricted + ? { ...preparedParams, pluginHarnessToolPolicyRestricted: false } + : preparedParams; + assertPluginHarnessConversationToolPolicySupport( + harness, + attemptParams.pluginHarnessToolPolicyRestricted === true, + ); return runAgentHarnessLifecycleAttempt(harness, attemptParams); }), ); @@ -666,18 +684,39 @@ function preparePluginHarnessParams(params: EmbeddedRunAttemptParams): EmbeddedR ? unwrapSecretSentinelsForProviderEgress(params.resolvedApiKey, boundary) : params.resolvedApiKey; const model = unwrapModelHeaderSentinelsForProviderEgress(params.model, boundary); - if (model === params.model && resolvedApiKey === params.resolvedApiKey) { - return applyPluginHarnessDenyAllToolPolicy(params); + const preparedParams = + model === params.model && resolvedApiKey === params.resolvedApiKey + ? params + : { ...params, model, resolvedApiKey }; + const policies = resolvePluginHarnessToolPolicies(preparedParams); + return applyPluginHarnessDenyAllToolPolicy( + { + ...preparedParams, + pluginHarnessToolPolicyRestricted: policies.toolPolicyRestricted, + }, + policies, + ); +} + +function assertPluginHarnessConversationToolPolicySupport( + harness: AgentHarness, + restricted: boolean, +): void { + if ( + harness.id !== "openclaw" && + restricted && + harness.conversationToolPolicySupport !== "exact" + ) { + throw new AgentHarnessPreflightError( + `${harness.label} cannot enforce this conversation's tool policy. Use the embedded runtime or ask in the main conversation.`, + { scope: "harness" }, + ); } - return applyPluginHarnessDenyAllToolPolicy({ - ...params, - model, - resolvedApiKey, - }); } function applyPluginHarnessDenyAllToolPolicy( params: EmbeddedRunAttemptParams, + policies: ResolvedPluginHarnessToolPolicies, ): EmbeddedRunAttemptParams { if ( isHostScopedAgentToolActive("openclaw") && @@ -686,7 +725,7 @@ function applyPluginHarnessDenyAllToolPolicy( ) { return params; } - const prompt = resolvePluginHarnessDenyAllToolPolicyPrompt(params); + const prompt = resolvePluginHarnessDenyAllToolPolicyPrompt(policies); if (!prompt) { return params; } @@ -702,16 +741,15 @@ export function resolvePluginHarnessPolicyToolsAllow( ): [] | undefined { const policies = resolvePluginHarnessToolPolicies(params); return [policies.senderPolicy, policies.groupPolicy, ...policies.runtimePolicies].some( - policyRestrictsNativeTools, + toolPolicyRestrictsTools, ) ? [] : undefined; } function resolvePluginHarnessDenyAllToolPolicyPrompt( - params: PluginHarnessToolPolicyContext, + policies: ResolvedPluginHarnessToolPolicies, ): string | undefined { - const policies = resolvePluginHarnessToolPolicies(params); if ( policyDeniesAllTools(policies.senderPolicy) || policyDeniesAllTools(policies.senderScopedGroupPolicy) @@ -731,6 +769,11 @@ function resolvePluginHarnessToolPolicies( ): ResolvedPluginHarnessToolPolicies { const messageProvider = params.messageProvider ?? params.messageChannel; const sandboxSessionKey = params.sandboxSessionKey ?? params.sessionKey; + const sandboxRuntime = resolveSandboxRuntimeStatus({ + cfg: params.config, + sessionKey: sandboxSessionKey, + }); + const sandboxPolicy = sandboxRuntime.sandboxed ? sandboxRuntime.toolPolicy : undefined; const capabilityProfile = resolveConversationCapabilityProfile({ config: params.config, sessionId: params.sessionId, @@ -741,6 +784,7 @@ function resolvePluginHarnessToolPolicies( modelId: params.modelId, messageProvider, messageChannel: params.messageChannel, + conversationToolPolicy: params.conversationToolPolicy, agentAccountId: params.agentAccountId, groupId: params.groupId, groupChannel: params.groupChannel, @@ -751,6 +795,7 @@ function resolvePluginHarnessToolPolicies( senderUsername: params.senderUsername, senderE164: params.senderE164, senderIsOwner: params.senderIsOwner, + sandboxToolPolicy: sandboxPolicy, inputProvenance: params.inputProvenance, trustedInternalHandoff: params.trustedInternalHandoff, scheduledToolPolicy: params.scheduledToolPolicy, @@ -772,11 +817,18 @@ function resolvePluginHarnessToolPolicies( senderPolicyMode: params.scheduledToolPolicy ? ("never" as const) : ("always" as const), }; const { policy } = capabilityProfile; - const sandboxRuntime = resolveSandboxRuntimeStatus({ - cfg: params.config, - sessionKey: sandboxSessionKey, - }); - const sandboxPolicy = sandboxRuntime.sandboxed ? sandboxRuntime.toolPolicy : undefined; + const explicitPolicies = [ + policy.globalPolicy, + policy.globalProviderPolicy, + policy.agentPolicy, + policy.agentProviderPolicy, + policy.groupPolicy, + policy.senderPolicy, + policy.sandboxPolicy, + policy.subagentPolicy, + policy.inheritedToolPolicy, + policy.runtimeToolPolicyForInheritance, + ]; return { senderPolicy: policy.senderPolicy, senderScopedGroupPolicy: resolveSenderScopedGroupToolPolicy( @@ -796,6 +848,7 @@ function resolvePluginHarnessToolPolicies( policy.subagentPolicy, policy.inheritedToolPolicy, ], + toolPolicyRestricted: explicitPolicies.some(toolPolicyRestrictsTools), }; } @@ -838,23 +891,6 @@ function policyDeniesAllTools(policy?: { deny?: string[] }): boolean { return expandToolGroups(policy?.deny ?? []).some((entry) => normalizeToolName(entry) === "*"); } -function policyRestrictsNativeTools(policy?: PluginHarnessToolPolicy): boolean { - if (!policy) { - return false; - } - const deniesAnyTool = expandToolGroups(policy.deny ?? []).some((entry) => - Boolean(normalizeToolName(entry)), - ); - if (deniesAnyTool) { - return true; - } - return ( - Array.isArray(policy.allow) && - policy.allow.length > 0 && - !expandToolGroups(policy.allow).some((entry) => normalizeToolName(entry) === "*") - ); -} - function listHarnessCandidates(harnesses: AgentHarness[]): AgentHarnessSelectionCandidate[] { return harnesses.map((harness) => ({ id: harness.id, diff --git a/src/agents/harness/types.ts b/src/agents/harness/types.ts index d5f0af239957..beec84a5dbd5 100644 --- a/src/agents/harness/types.ts +++ b/src/agents/harness/types.ts @@ -288,6 +288,8 @@ type AgentHarnessRunCapability = { */ contextEngineHostCapabilities?: readonly import("../../context-engine/types.js").ContextEngineHostCapability[]; deliveryDefaults?: AgentHarnessDeliveryDefaults; + /** Certifies exact runAttempt enforcement; direct-policy-restricted channel side questions fail in core. */ + conversationToolPolicySupport?: "exact"; supports(ctx: AgentHarnessSupportContext): AgentHarnessSupport; /** Lets this harness resolve forwarded profiles or its own native credentials. */ authBootstrap?: "harness"; diff --git a/src/agents/requester-tool-policy.test.ts b/src/agents/requester-tool-policy.test.ts index bc767dad9da0..5aafc25acbf1 100644 --- a/src/agents/requester-tool-policy.test.ts +++ b/src/agents/requester-tool-policy.test.ts @@ -91,6 +91,7 @@ describe("resolveRequesterToolPolicies", () => { config: config(), agentId: "main", sessionKey: childSessionKey, + conversationPolicy: { deny: ["exec"] }, }); expect(result.delegated).toBe(true); diff --git a/src/agents/requester-tool-policy.ts b/src/agents/requester-tool-policy.ts index 31139d2a79e0..b35a9bee81a7 100644 --- a/src/agents/requester-tool-policy.ts +++ b/src/agents/requester-tool-policy.ts @@ -70,6 +70,8 @@ type RequesterToolPolicyParams = { groupPolicySessionKey?: string; /** Fail closed when scheduled authority names a removed non-default account. */ requireConfiguredGroupAccount?: boolean; + /** Policy prepared by the trusted channel ingress owner for this conversation. */ + conversationPolicy?: SandboxToolPolicy; }; function policyFromEnvelope( @@ -222,22 +224,24 @@ export function resolveRequesterToolPolicies( return { delegated: false, requesterPolicySource: "current-request", - groupPolicy: resolveGroupToolPolicy({ - config: params.config, - sessionKey: params.groupPolicySessionKey ?? params.sessionKey, - spawnedBy: params.spawnedBy, - messageProvider: params.messageProvider ?? undefined, - groupId: params.groupId, - groupChannel: params.groupChannel, - groupSpace: params.groupSpace, - accountId: params.accountId, - requireConfiguredAccount: params.requireConfiguredGroupAccount, - senderId: params.senderId, - senderName: params.senderName, - senderUsername: params.senderUsername, - senderE164: params.senderE164, - senderPolicyMode: senderPolicyMode === "never" ? "never" : "always", - }), + groupPolicy: + params.conversationPolicy ?? + resolveGroupToolPolicy({ + config: params.config, + sessionKey: params.groupPolicySessionKey ?? params.sessionKey, + spawnedBy: params.spawnedBy, + messageProvider: params.messageProvider ?? undefined, + groupId: params.groupId, + groupChannel: params.groupChannel, + groupSpace: params.groupSpace, + accountId: params.accountId, + requireConfiguredAccount: params.requireConfiguredGroupAccount, + senderId: params.senderId, + senderName: params.senderName, + senderUsername: params.senderUsername, + senderE164: params.senderE164, + senderPolicyMode: senderPolicyMode === "never" ? "never" : "always", + }), senderPolicy: shouldResolveSenderPolicy ? resolveSenderToolPolicy({ config: params.config, diff --git a/src/agents/tool-policy.ts b/src/agents/tool-policy.ts index 29fd0e00c83e..0b812c730f08 100644 --- a/src/agents/tool-policy.ts +++ b/src/agents/tool-policy.ts @@ -71,6 +71,21 @@ export function hasRestrictiveAllowPolicy(policy?: { allow?: string[] }): boolea ); } +/** Returns whether a policy removes at least one tool from the default surface. */ +export function toolPolicyRestrictsTools(policy?: ToolPolicyLike): boolean { + if (!policy) { + return false; + } + if (expandToolGroups(policy.deny ?? []).some((entry) => Boolean(normalizeToolName(entry)))) { + return true; + } + return ( + Array.isArray(policy.allow) && + policy.allow.length > 0 && + !expandToolGroups(policy.allow).some((entry) => normalizeToolName(entry) === "*") + ); +} + /** Replaces an allowlist with the normalized names of an effective tool array. */ export function replaceWithEffectiveToolAllowlist( target: string[], diff --git a/src/auto-reply/reply/agent-runner-memory.ts b/src/auto-reply/reply/agent-runner-memory.ts index 958f1f2d9cf1..404e8f3cdb3f 100644 --- a/src/auto-reply/reply/agent-runner-memory.ts +++ b/src/auto-reply/reply/agent-runner-memory.ts @@ -901,6 +901,7 @@ export async function runPreflightCompactionIfNeeded(params: { allowGatewaySubagentBinding: true, messageChannel: params.followupRun.run.messageProvider, clientCaps: params.followupRun.run.clientCaps, + conversationToolPolicy: params.followupRun.run.conversationToolPolicy, groupId: entry.groupId ?? params.followupRun.run.groupId, groupChannel: entry.groupChannel ?? params.followupRun.run.groupChannel, groupSpace: entry.space ?? params.followupRun.run.groupSpace, diff --git a/src/auto-reply/reply/agent-runner-run-params.ts b/src/auto-reply/reply/agent-runner-run-params.ts index ad341dd66836..5064f48a388e 100644 --- a/src/auto-reply/reply/agent-runner-run-params.ts +++ b/src/auto-reply/reply/agent-runner-run-params.ts @@ -100,6 +100,7 @@ export function buildEmbeddedRunBaseParams(params: { ownerNumbers: params.run.ownerNumbers, inputProvenance: params.run.inputProvenance, senderIsOwner: params.run.senderIsOwner, + conversationToolPolicy: params.run.conversationToolPolicy, channelContext: params.run.channelContext, approvalReviewerDeviceId: params.run.approvalReviewerDeviceId, enforceFinalTag, diff --git a/src/auto-reply/reply/agent-runner-utils.test.ts b/src/auto-reply/reply/agent-runner-utils.test.ts index 77b86dd5920f..6bee2daa011c 100644 --- a/src/auto-reply/reply/agent-runner-utils.test.ts +++ b/src/auto-reply/reply/agent-runner-utils.test.ts @@ -205,6 +205,24 @@ describe("agent-runner-utils", () => { expect(resolved.runBaseParams.promptCacheKey).toBe("stable-session-cache-key"); }); + it("uses the queued conversation policy snapshot", () => { + const run = makeRun({ conversationToolPolicy: { deny: ["exec"] } }); + + const resolved = buildEmbeddedRunExecutionParams({ + run, + sessionCtx: { + Provider: "telegram", + ConversationToolPolicy: { deny: ["write"] }, + }, + hasRepliedRef: undefined, + provider: "openai", + model: "gpt-4.1-mini", + runId: "run-1", + }); + + expect(resolved.runBaseParams.conversationToolPolicy).toEqual({ deny: ["exec"] }); + }); + it("uses session chat type over stale queued metadata for embedded execution params", () => { const run = makeRun({ chatType: "direct" }); diff --git a/src/auto-reply/reply/commands-btw.test.ts b/src/auto-reply/reply/commands-btw.test.ts index 24f82e0a1730..47d03fbb8e15 100644 --- a/src/auto-reply/reply/commands-btw.test.ts +++ b/src/auto-reply/reply/commands-btw.test.ts @@ -118,6 +118,25 @@ describe("handleBtwCommand", () => { expect(typing.startTypingLoop).toHaveBeenCalledTimes(1); }); + it("returns an actionable visible error before running a restricted side question", async () => { + const params = buildParams("/btw what changed?"); + params.agentDir = "/tmp/agent"; + params.sessionEntry = { sessionId: "session-1", updatedAt: Date.now() }; + params.ctx.ConversationToolPolicy = { deny: ["exec"] }; + + const result = await handleBtwCommand(params, true); + + expect(result).toEqual({ + shouldContinue: false, + reply: { + text: "⚠️ /btw cannot enforce this conversation's tool policy. Ask in the main conversation or switch this session to the embedded runtime.", + btw: { question: "what changed?" }, + isError: true, + }, + }); + expect(runBtwSideQuestionMock).not.toHaveBeenCalled(); + }); + it("delegates to the side-question runner", async () => { const params = buildParams("/btw what changed?"); params.command.senderId = "sender-1"; diff --git a/src/auto-reply/reply/commands-btw.ts b/src/auto-reply/reply/commands-btw.ts index 80882675f608..180e073d0d88 100644 --- a/src/auto-reply/reply/commands-btw.ts +++ b/src/auto-reply/reply/commands-btw.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import { resolveAgentDir, resolveSessionAgentId } from "../../agents/agent-scope.js"; import { runBtwSideQuestion } from "../../agents/btw.js"; +import { toolPolicyRestrictsTools } from "../../agents/tool-policy.js"; import { normalizeChatType } from "../../channels/chat-type.js"; import { normalizeAnyChannelId } from "../../channels/registry.js"; import { resolveGroupSessionKey } from "../../config/sessions/group.js"; @@ -42,6 +43,17 @@ export const handleBtwCommand: CommandHandler = defineAuthorizedTextCommand( ); } + if (toolPolicyRestrictsTools(params.ctx.ConversationToolPolicy)) { + return { + shouldContinue: false, + reply: { + text: "⚠️ /btw cannot enforce this conversation's tool policy. Ask in the main conversation or switch this session to the embedded runtime.", + btw: { question }, + isError: true, + }, + }; + } + try { await params.typing?.startTypingLoop(); const messageTo = diff --git a/src/auto-reply/reply/commands-compact.ts b/src/auto-reply/reply/commands-compact.ts index a2171d5606d2..773ced135a02 100644 --- a/src/auto-reply/reply/commands-compact.ts +++ b/src/auto-reply/reply/commands-compact.ts @@ -271,6 +271,7 @@ export const handleCompactCommand: CommandHandler = async (params) => { allowGatewaySubagentBinding: true, messageChannel: params.command.channel, clientCaps: params.ctx.GatewayClientCaps, + conversationToolPolicy: params.ctx.ConversationToolPolicy, groupId: targetSessionEntry.groupId, groupChannel: targetSessionEntry.groupChannel, groupSpace: targetSessionEntry.space, diff --git a/src/auto-reply/reply/dispatch-acp.test.ts b/src/auto-reply/reply/dispatch-acp.test.ts index 4a4c4a9d19c9..1addce11e023 100644 --- a/src/auto-reply/reply/dispatch-acp.test.ts +++ b/src/auto-reply/reply/dispatch-acp.test.ts @@ -2362,7 +2362,29 @@ describe("tryDispatchAcpReply", () => { expect(managerMocks.runTurn).not.toHaveBeenCalled(); expect(dispatcherCall(dispatcher.sendFinalReply).isError).toBe(true); - expect(dispatcherCall(dispatcher.sendFinalReply).text).toContain("runtime toolsAllow"); + expect(dispatcherCall(dispatcher.sendFinalReply).text).toContain( + "cannot enforce its tool policy", + ); + expect(auditMocks.emitAcpLifecycleError).toHaveBeenCalledWith( + expect.objectContaining({ terminalOutcome: "blocked" }), + ); + }); + + it("fails visibly when a bound ACP runtime receives restrictive conversation policy", async () => { + setReadyAcpResolution(); + const { dispatcher } = createDispatcher(); + + await runDispatch({ + bodyForAgent: "test", + dispatcher, + ctxOverrides: { ConversationToolPolicy: { deny: ["exec"] } }, + }); + + expect(managerMocks.runTurn).not.toHaveBeenCalled(); + expect(dispatcherCall(dispatcher.sendFinalReply)).toMatchObject({ + isError: true, + text: expect.stringContaining("use an embedded runtime"), + }); expect(auditMocks.emitAcpLifecycleError).toHaveBeenCalledWith( expect.objectContaining({ terminalOutcome: "blocked" }), ); diff --git a/src/auto-reply/reply/dispatch-acp.ts b/src/auto-reply/reply/dispatch-acp.ts index a745b7611e99..a50347befe46 100644 --- a/src/auto-reply/reply/dispatch-acp.ts +++ b/src/auto-reply/reply/dispatch-acp.ts @@ -14,6 +14,7 @@ import type { AcpTurnAttachment } from "../../acp/control-plane/manager.types.js import { resolveAcpAgentPolicyError, resolveAcpDispatchPolicyError } from "../../acp/policy.js"; import { AcpRuntimeError, toAcpRuntimeError } from "../../acp/runtime/errors.js"; import { resolveAgentDir, resolveAgentWorkspaceDir } from "../../agents/agent-scope.js"; +import { toolPolicyRestrictsTools } from "../../agents/tool-policy.js"; import type { ChatType } from "../../channels/chat-type.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { TtsAutoMode } from "../../config/types.tts.js"; @@ -660,11 +661,14 @@ export async function tryDispatchAcpReply(params: { auditTerminalOutcome = "blocked"; throw dispatchPolicyError; } - if (isRestrictiveRuntimeToolsAllow(params.toolsAllow)) { + if ( + isRestrictiveRuntimeToolsAllow(params.toolsAllow) || + toolPolicyRestrictsTools(params.ctx.ConversationToolPolicy) + ) { auditTerminalOutcome = "blocked"; throw new AcpRuntimeError( "ACP_DISPATCH_DISABLED", - "ACP dispatch cannot enforce runtime toolsAllow for this session; use an embedded runtime for restricted tool policy.", + "This session's bound runtime cannot enforce its tool policy; use an embedded runtime for this restricted conversation.", ); } if (acpResolution.kind === "stale") { diff --git a/src/auto-reply/reply/get-reply-run-execute.ts b/src/auto-reply/reply/get-reply-run-execute.ts index 4151ca4a95ff..4e91497a1f05 100644 --- a/src/auto-reply/reply/get-reply-run-execute.ts +++ b/src/auto-reply/reply/get-reply-run-execute.ts @@ -342,6 +342,7 @@ export async function executePreparedReplyRun(state: PreparedReplyRunAdmission) toolBindings: ctx.GatewayRunToolBindings, chatType: replyRoute.chatType, agentAccountId: replyRoute.accountId, + conversationToolPolicy: sessionCtx.ConversationToolPolicy, groupId: resolveGroupSessionKey(sessionCtx)?.id ?? undefined, groupChannel: normalizeOptionalString(sessionCtx.GroupChannel) ?? diff --git a/src/auto-reply/reply/queue/drain.client-caps.test.ts b/src/auto-reply/reply/queue/drain.client-caps.test.ts index 6c64f3746a53..1699b4170d92 100644 --- a/src/auto-reply/reply/queue/drain.client-caps.test.ts +++ b/src/auto-reply/reply/queue/drain.client-caps.test.ts @@ -46,6 +46,27 @@ describe("followup delivery context", () => { ); }); + it("separates runs admitted under different conversation policies", () => { + const restricted = createQueueTestRun({ prompt: "restricted" }); + restricted.run.conversationToolPolicy = { deny: ["exec"] }; + const unrestricted = createQueueTestRun({ prompt: "unrestricted" }); + + expect(resolveFollowupDeliveryContextKey(restricted)).not.toBe( + resolveFollowupDeliveryContextKey(unrestricted), + ); + }); + + it("canonicalizes equivalent conversation policies", () => { + const first = createQueueTestRun({ prompt: "first" }); + first.run.conversationToolPolicy = { allow: ["read"], deny: ["exec"] }; + const second = createQueueTestRun({ prompt: "second" }); + second.run.conversationToolPolicy = { deny: ["exec"], allow: ["read"] }; + + expect(resolveFollowupDeliveryContextKey(first)).toBe( + resolveFollowupDeliveryContextKey(second), + ); + }); + it("separates runs with different parent policy provenance", () => { const first = createQueueTestRun({ prompt: "first" }); first.run.spawnedBy = "agent:main:telegram:group:first"; diff --git a/src/auto-reply/reply/queue/drain.ts b/src/auto-reply/reply/queue/drain.ts index 8881816747d9..c67225e67c3b 100644 --- a/src/auto-reply/reply/queue/drain.ts +++ b/src/auto-reply/reply/queue/drain.ts @@ -166,6 +166,7 @@ function resolveFollowupAuthorizationKey(run: FollowupRun["run"]): string { return JSON.stringify([ run.senderId ?? "", JSON.stringify(run.channelContext ?? null), + stableStringify(run.conversationToolPolicy ?? null), run.senderE164 ?? "", run.senderIsOwner === true, run.execOverrides?.host ?? "", diff --git a/src/auto-reply/reply/queue/types.ts b/src/auto-reply/reply/queue/types.ts index e053da89c220..80b7dfd08d1f 100644 --- a/src/auto-reply/reply/queue/types.ts +++ b/src/auto-reply/reply/queue/types.ts @@ -12,6 +12,7 @@ import type { InboundEventKind } from "../../../channels/inbound-event/kind.js"; import type { SessionEntry, SessionToolOverrides } from "../../../config/sessions.js"; import type { ReplyToMode } from "../../../config/types.base.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; +import type { GroupToolPolicyConfig } from "../../../config/types.tools.js"; import type { MediaFact } from "../../../media/media-facts.js"; import type { PromptImageOrderEntry } from "../../../media/prompt-image-order.js"; import type { PluginHookChannelContext } from "../../../plugins/hook-types.js"; @@ -149,6 +150,7 @@ export type FollowupRun = { toolBindings?: Readonly>; chatType?: ChatType; agentAccountId?: string; + conversationToolPolicy?: GroupToolPolicyConfig; groupId?: string; groupChannel?: string; groupSpace?: string; diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index 2d1051f3142a..e5d0895a79b3 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -1,6 +1,7 @@ /** Shared inbound message context types used by prompt templating and reply dispatch. */ import type { InboundEventKind } from "../channels/inbound-event/kind.js"; import type { DmScope, ReplyToMode } from "../config/types.base.js"; +import type { GroupToolPolicyConfig } from "../config/types.tools.js"; import type { MediaUnderstandingDecision, MediaUnderstandingOutput, @@ -276,6 +277,8 @@ export type MsgContext = Partial & { Prompt?: string; MaxChars?: number; ChatType?: string; + /** Trusted channel-configured policy for this admitted conversation turn. */ + ConversationToolPolicy?: GroupToolPolicyConfig; /** Human label for envelope headers (conversation label, not sender). */ ConversationLabel?: string; GroupSubject?: string; diff --git a/src/channels/inbound-event/context.ts b/src/channels/inbound-event/context.ts index 788bc5f39c53..b8618f5cb02c 100644 --- a/src/channels/inbound-event/context.ts +++ b/src/channels/inbound-event/context.ts @@ -19,6 +19,7 @@ import type { SessionTranscriptContext, } from "../../auto-reply/templating.js"; import type { ContextVisibilityMode } from "../../config/types.base.js"; +import type { GroupToolPolicyConfig } from "../../config/types.tools.js"; import type { PluginHookChannelContext } from "../../plugins/hook-channel-context.types.js"; import { shouldIncludeSupplementalContext } from "../../security/context-visibility.js"; import type { InboundImplicitMentionKind } from "../mention-gating.js"; @@ -58,6 +59,8 @@ export type ChannelInboundSupplementalResolutionOptions = { }; type BuildChannelInboundEventAccess = { commands?: Pick; + /** Channel-configured policy resolved at the trusted ingress boundary. */ + toolPolicy?: GroupToolPolicyConfig; mentions?: { canDetectMention: boolean; wasMentioned: boolean; @@ -540,6 +543,7 @@ export function buildChannelInboundEventContext( ImplicitMentionKinds: params.access?.mentions?.implicitMentionKinds, MentionSource: params.access?.mentions?.mentionSource, CommandAuthorized: resolveIngressCommandAuthorized(params.access) === true, + ConversationToolPolicy: params.access?.toolPolicy, CommandTurn: commandTurn, MessageThreadId: params.reply.messageThreadId ?? params.conversation.threadId, NativeChannelId: params.reply.nativeChannelId ?? params.conversation.nativeChannelId,