| 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 |