mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-23 10:55:31 -06:00
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:
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -91,6 +91,7 @@ describe("resolveRequesterToolPolicies", () => {
|
||||
config: config(),
|
||||
agentId: "main",
|
||||
sessionKey: childSessionKey,
|
||||
conversationPolicy: { deny: ["exec"] },
|
||||
});
|
||||
|
||||
expect(result.delegated).toBe(true);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" });
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" }),
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 ?? "",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user