fix(telegram): enforce direct-message tool policies

Enforce configured Telegram direct-message tool policies across queued runs and native harnesses. Unsupported restricted harnesses now refuse visibly; turns without explicit policy keep existing tool access.

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
This commit is contained in:
Ayaan Zaidi
2026-08-09 19:32:30 +05:30
committed by GitHub
parent 52a76313e8
commit 409fb7abca
62 changed files with 1650 additions and 240 deletions
+74 -74
View File
@@ -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
+18 -1
View File
@@ -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:<your user id>`.
Use `channels.telegram.direct.<chatId>.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:<userId>` or `id:<userId>`:
```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.<id>.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
<Accordion title="High-signal Telegram fields">
- 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.<chatId>.topics."*"` applies to unmatched forum topics; exact topic IDs override it
- exec approvals: `execApprovals`, `accounts.*.execApprovals`
- command/menu: `commands.native`, `commands.nativeSkills`, `customCommands`
+15
View File
@@ -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
+1
View File
@@ -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",
},
@@ -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");
});
@@ -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<unknown>(
"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,
);
}
@@ -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",
);
@@ -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;
}
@@ -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<string, string> = {
@@ -129,18 +137,21 @@ export function filterCodexDynamicTools<T extends { name: string }>(
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<T extends { name: string }>(
/** Keeps OpenClaw coding tools that replace a disabled Codex native surface. */
export function filterCodexDynamicToolsForDisabledNativeSurface<T extends { name: string }>(
tools: T[],
config: Pick<CodexPluginConfig, "codexDynamicToolsExclude">,
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<T extends { name: string }>(
tools: T[],
config: Pick<CodexPluginConfig, "codexDynamicToolsExclude">,
env: CodexDynamicToolProfileEnv,
options: { preserveOpenClawShell: boolean },
options: { preserveOpenClawReplacements: boolean; preserveOpenClawShell: boolean },
): T[] {
const excludes = new Set<string>();
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<T extends { name: string }>(
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;
}
@@ -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:
@@ -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,
@@ -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<string, unknown>;
}
| 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();
@@ -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";
}
}
@@ -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)))();
@@ -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,
@@ -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,
@@ -43,7 +43,7 @@ type CodexWarmThreadReuseParams = {
nativeSkillIsolation?: Parameters<typeof applyCodexNativeSkillIsolation>[1];
releaseConsumedThread: (threadId: string, cause?: unknown) => Promise<void>;
ringZeroActive: boolean;
ringZeroInheritedMcpServerNames: string[];
restrictedToolSurfaceInheritedMcpServerNames: string[];
startModelProvider?: string;
startModelSelection: ReturnType<typeof resolveCodexAppServerThreadModelSelection>;
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(
@@ -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,
@@ -35,6 +35,7 @@ import {
resolveReasoningEffort,
startOrResumeThread as startOrResumeThreadImpl,
} from "./thread-lifecycle.js";
import { attestCodexRestrictedToolSurfaceMcpServersDisabled } from "./thread-requests.js";
type CodexThreadLifecycleTimingLogger = NonNullable<
NonNullable<Parameters<typeof startOrResumeThreadImpl>[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<string, unknown> }
| 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<string, unknown> };
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",
@@ -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<CodexAppServerRuntimeOptions, "networkProxy">;
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<CodexAppServerClient, "request">,
signal?: AbortSignal,
): Promise<void> {
@@ -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<CodexAppServerClient, "request">,
threadId: string,
threadConfig: JsonObject | undefined,
signal?: AbortSignal,
): Promise<void> {
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<string>();
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<string>();
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");
@@ -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,
+1
View File
@@ -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 ?? "")
@@ -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",
},
)
+53
View File
@@ -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) => {
@@ -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({
+5 -1
View File
@@ -27,6 +27,9 @@ import { createAgentHarnessToolSurfaceRuntime } from "openclaw/plugin-sdk/agent-
type CreateOpenClawCodingTools =
(typeof import("openclaw/plugin-sdk/agent-harness"))["createOpenClawCodingTools"];
type OpenClawCodingToolsOptions = NonNullable<Parameters<CreateOpenClawCodingTools>[0]>;
type CreateOpenClawCodingToolsForBridge = (
options?: OpenClawCodingToolsOptions,
) => ReturnType<CreateOpenClawCodingTools> | Promise<ReturnType<CreateOpenClawCodingTools>>;
type AgentHarnessToolSurfaceRuntime = ReturnType<typeof createAgentHarnessToolSurfaceRuntime>;
type CatalogExecuteParams = Parameters<
NonNullable<AgentHarnessToolSurfaceRuntime["toolSearchCatalogExecutor"]>
@@ -133,7 +136,7 @@ interface CopilotToolBridgeInput {
*/
onYieldDetected?: (message?: string) => void;
onToolCompleted?: (completion: CopilotToolCompletion) => void | Promise<void>;
createOpenClawCodingTools?: (opts: unknown) => AnyAgentTool[] | Promise<AnyAgentTool[]>;
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,
@@ -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,
@@ -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:
+12 -1
View File
@@ -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,
@@ -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({});
});
});
@@ -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<TelegramDirectConfig, "tools" | "toolsBySender">;
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
);
}
@@ -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 =
+4
View File
@@ -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,
@@ -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: {
@@ -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);
@@ -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;
@@ -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,
@@ -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,
@@ -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) } : {}),
@@ -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. */
@@ -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,
@@ -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;
+133 -1
View File
@@ -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<AgentHarness["runAttempt"]>(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<AgentHarness["runAttempt"]>(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<AgentHarness["runAttempt"]>(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<AgentHarness["runAttempt"]>(async () => createAttemptResult("codex"));
registerAgentHarness(
{
id: "codex",
label: "Codex",
conversationToolPolicySupport: "exact",
supports: (ctx) =>
ctx.provider === "codex" ? { supported: true, priority: 100 } : { supported: false },
runAttempt,
+72 -36
View File
@@ -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<PluginHarnessToolPolicy | undefined>;
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,
+2
View File
@@ -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";
+1
View File
@@ -91,6 +91,7 @@ describe("resolveRequesterToolPolicies", () => {
config: config(),
agentId: "main",
sessionKey: childSessionKey,
conversationPolicy: { deny: ["exec"] },
});
expect(result.delegated).toBe(true);
+20 -16
View File
@@ -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,
+15
View File
@@ -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[],
@@ -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,
@@ -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,
@@ -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" });
+19
View File
@@ -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";
+12
View File
@@ -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 =
+1
View File
@@ -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,
+23 -1
View File
@@ -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" }),
);
+6 -2
View File
@@ -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") {
@@ -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) ??
@@ -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";
+1
View File
@@ -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 ?? "",
+2
View File
@@ -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<Record<string, unknown>>;
chatType?: ChatType;
agentAccountId?: string;
conversationToolPolicy?: GroupToolPolicyConfig;
groupId?: string;
groupChannel?: string;
groupSpace?: string;
+3
View File
@@ -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<CanonicalInboundText> & {
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;
+4
View File
@@ -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<ChannelIngressCommandAccess, "authorized">;
/** 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,