返回 CodeWhale
activation.rs
根目录 / crates / tui / src / plugins / activation.rs
1 //! Versioned plugin activation policy.
2 //!
3 //! This is the single source of truth for which reviewed component adapters
4 //! this Codewhale build will execute. Compatibility, `active()` decisions,
5 //! consumption-boundary checks, and the capability hash all read this policy.
6 //! Enabling a new adapter later must change the policy (version and/or mask)
7 //! so existing trust receipts fail closed as `CapabilitiesChanged`.
8
9 use sha2::Digest;
10
11 /// Capability-hash domain for the current activation-policy binding.
12 pub const CAPABILITY_HASH_DOMAIN_V3: &[u8] = b"codewhale-plugin-capabilities-v3\0";
13
14 /// Historical policy domain kept so persisted v2 receipts are intentionally
15 /// invalidated when the declarative Commands, Agents, and Hooks adapters ship.
16 pub const CAPABILITY_HASH_DOMAIN_V2: &[u8] = b"codewhale-plugin-capabilities-v2\0";
17
18 /// Historical domain used before the activation policy was bound into the
19 /// receipt. Kept so discovery can prove a v1 receipt no longer matches.
20 pub const CAPABILITY_HASH_DOMAIN_V1: &[u8] = b"codewhale-plugin-capabilities-v1\0";
21
22 pub const ACTIVATION_POLICY_VERSION: u32 = 3;
23
24 /// Policy version selected when `[features] extension_host` is on: `Native`
25 /// (host code run by the TypeScript extension host) moves from inactive to
26 /// supported. Every receipt reviewed under v3 fails closed as
27 /// `CapabilitiesChanged` under v4 and the reverse, so toggling the flag in
28 /// either direction re-reviews every plugin. That is intended.
29 pub const EXTENSION_HOST_POLICY_VERSION: u32 = 4;
30
31 /// Process-wide policy selection, set once at boot from config
32 /// (`install_extension_host_policy`). A config reload never flips it
33 /// mid-process; unset means v3, which also covers tests.
34 static EXTENSION_HOST_POLICY: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
35
36 #[cfg(test)]
37 thread_local! {
38 /// Test-only per-thread override so one test can exercise v4 without
39 /// changing the policy every other (parallel) test observes.
40 static TEST_POLICY_OVERRIDE: std::cell::Cell<Option<bool>> = const { std::cell::Cell::new(None) };
41 }
42
43 /// Select the activation policy for this process. The first call wins.
44 pub fn install_extension_host_policy(extension_host_enabled: bool) {
45 let _ = EXTENSION_HOST_POLICY.set(extension_host_enabled);
46 }
47
48 /// Whether this process activates `Native` (extension host) components.
49 #[must_use]
50 pub fn extension_host_policy_enabled() -> bool {
51 #[cfg(test)]
52 if let Some(value) = TEST_POLICY_OVERRIDE.with(std::cell::Cell::get) {
53 return value;
54 }
55 EXTENSION_HOST_POLICY.get().copied().unwrap_or(false)
56 }
57
58 /// Carries the calling thread's policy into a `spawn_blocking` closure. In
59 /// production the policy is process-wide, so this is a no-op; under test it
60 /// re-applies the per-thread override on the blocking thread.
61 pub(crate) struct PolicyScope {
62 #[cfg(test)]
63 _guard: TestPolicyGuard,
64 }
65
66 impl PolicyScope {
67 #[must_use]
68 pub(crate) fn propagate(extension_host_enabled: bool) -> Self {
69 #[cfg(test)]
70 {
71 Self {
72 _guard: TestPolicyGuard::extension_host(extension_host_enabled),
73 }
74 }
75 #[cfg(not(test))]
76 {
77 let _ = extension_host_enabled;
78 Self {}
79 }
80 }
81 }
82
83 /// Test guard selecting the v4 (or v3) policy on the current thread only.
84 #[cfg(test)]
85 pub(crate) struct TestPolicyGuard {
86 previous: Option<bool>,
87 }
88
89 #[cfg(test)]
90 impl TestPolicyGuard {
91 pub(crate) fn extension_host(enabled: bool) -> Self {
92 let previous = TEST_POLICY_OVERRIDE.with(|cell| cell.replace(Some(enabled)));
93 Self { previous }
94 }
95 }
96
97 #[cfg(test)]
98 impl Drop for TestPolicyGuard {
99 fn drop(&mut self) {
100 TEST_POLICY_OVERRIDE.with(|cell| cell.set(self.previous));
101 }
102 }
103
104 /// A runtime adapter or inventoried capability that a bundle may declare.
105 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
106 pub enum PluginActivationCapability {
107 Skills,
108 McpStdio,
109 McpRemote,
110 Commands,
111 Agents,
112 Hooks,
113 Lsp,
114 Native,
115 FilesystemRoots,
116 LifecycleMutation,
117 }
118
119 impl PluginActivationCapability {
120 pub const ALL: &'static [Self] = &[
121 Self::Skills,
122 Self::McpStdio,
123 Self::McpRemote,
124 Self::Commands,
125 Self::Agents,
126 Self::Hooks,
127 Self::Lsp,
128 Self::Native,
129 Self::FilesystemRoots,
130 Self::LifecycleMutation,
131 ];
132
133 #[must_use]
134 pub fn as_str(self) -> &'static str {
135 match self {
136 Self::Skills => "skills",
137 Self::McpStdio => "mcp-stdio",
138 Self::McpRemote => "mcp-remote",
139 Self::Commands => "commands",
140 Self::Agents => "agents",
141 Self::Hooks => "hooks",
142 Self::Lsp => "lsp",
143 Self::Native => "native",
144 Self::FilesystemRoots => "filesystem-roots",
145 Self::LifecycleMutation => "lifecycle-mutation",
146 }
147 }
148 }
149
150 /// The adapters this exact Codewhale build will activate, plus the inventoried
151 /// surfaces that stay inactive. Fields are public so tests can construct a
152 /// mutated policy and prove the capability hash moves.
153 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
154 pub struct PluginActivationPolicy {
155 pub version: u32,
156 pub supported: &'static [PluginActivationCapability],
157 pub inactive: &'static [PluginActivationCapability],
158 }
159
160 impl PluginActivationPolicy {
161 /// The policy this process runs under: v3, or v4 when the experimental
162 /// extension host is enabled (see [`install_extension_host_policy`]).
163 #[must_use]
164 pub fn current() -> Self {
165 if extension_host_policy_enabled() {
166 Self::extension_host()
167 } else {
168 Self::v3()
169 }
170 }
171
172 /// v4: identical to v3 except `Native` (host code) is supported.
173 #[must_use]
174 pub const fn extension_host() -> Self {
175 Self {
176 version: EXTENSION_HOST_POLICY_VERSION,
177 supported: &[
178 PluginActivationCapability::Skills,
179 PluginActivationCapability::McpStdio,
180 PluginActivationCapability::McpRemote,
181 PluginActivationCapability::Commands,
182 PluginActivationCapability::Agents,
183 PluginActivationCapability::Hooks,
184 PluginActivationCapability::Native,
185 ],
186 inactive: &[
187 PluginActivationCapability::Lsp,
188 PluginActivationCapability::FilesystemRoots,
189 PluginActivationCapability::LifecycleMutation,
190 ],
191 }
192 }
193
194 /// v3: the shipping policy. Must stay byte-for-byte stable while the
195 /// extension host is experimental so existing receipts stay valid.
196 #[must_use]
197 pub const fn v3() -> Self {
198 Self {
199 version: ACTIVATION_POLICY_VERSION,
200 supported: &[
201 PluginActivationCapability::Skills,
202 PluginActivationCapability::McpStdio,
203 PluginActivationCapability::McpRemote,
204 PluginActivationCapability::Commands,
205 PluginActivationCapability::Agents,
206 PluginActivationCapability::Hooks,
207 ],
208 inactive: &[
209 PluginActivationCapability::Lsp,
210 PluginActivationCapability::Native,
211 PluginActivationCapability::FilesystemRoots,
212 PluginActivationCapability::LifecycleMutation,
213 ],
214 }
215 }
216
217 #[must_use]
218 pub fn is_supported(self, capability: PluginActivationCapability) -> bool {
219 self.supported.contains(&capability)
220 }
221
222 pub fn write_hash_material(self, hasher: &mut impl Digest) {
223 hasher.update(CAPABILITY_HASH_DOMAIN_V3);
224 hasher.update(b"policy-version\0");
225 hasher.update(self.version.to_string().as_bytes());
226 hasher.update(b"\0");
227 for capability in self.supported {
228 hasher.update(b"supported\0");
229 hasher.update(capability.as_str().as_bytes());
230 hasher.update(b"\0");
231 }
232 for capability in self.inactive {
233 hasher.update(b"inactive\0");
234 hasher.update(capability.as_str().as_bytes());
235 hasher.update(b"\0");
236 }
237 }
238 }
239
240 #[cfg(test)]
241 mod tests {
242 use super::*;
243
244 #[test]
245 fn current_activation_policy_partitions_known_capabilities() {
246 let policy = PluginActivationPolicy::current();
247 for capability in PluginActivationCapability::ALL {
248 let supported = policy.supported.contains(capability);
249 let inactive = policy.inactive.contains(capability);
250 assert_ne!(
251 supported, inactive,
252 "{capability:?} must be supported or inactive, not both or neither"
253 );
254 }
255 }
256
257 fn policy_digest(policy: PluginActivationPolicy) -> String {
258 let mut hasher = sha2::Sha256::new();
259 policy.write_hash_material(&mut hasher);
260 hasher
261 .finalize()
262 .iter()
263 .map(|byte| format!("{byte:02x}"))
264 .collect()
265 }
266
267 /// Flag off must hash exactly as the v3 policy always has, or every
268 /// user's plugin receipts (Computer Use included) would re-review.
269 #[test]
270 fn flag_off_policy_hashes_exactly_as_v3() {
271 let _guard = TestPolicyGuard::extension_host(false);
272 let current = PluginActivationPolicy::current();
273 assert_eq!(current, PluginActivationPolicy::v3());
274 assert_eq!(
275 policy_digest(current),
276 "1d8de17f08b1ef6246454881bfc38b7cd2855d56c9c6e3fdb11c54e4f756df85"
277 );
278 assert!(!current.is_supported(PluginActivationCapability::Native));
279 }
280
281 #[test]
282 fn flag_on_policy_supports_native_and_moves_the_hash() {
283 let _guard = TestPolicyGuard::extension_host(true);
284 let current = PluginActivationPolicy::current();
285 assert_eq!(current.version, EXTENSION_HOST_POLICY_VERSION);
286 assert!(current.is_supported(PluginActivationCapability::Native));
287 assert_ne!(
288 policy_digest(current),
289 policy_digest(PluginActivationPolicy::v3())
290 );
291 for capability in PluginActivationCapability::ALL {
292 assert_ne!(
293 current.supported.contains(capability),
294 current.inactive.contains(capability),
295 "{capability:?}"
296 );
297 }
298 }
299 }
300
300 lines RUST