| 1 | //! Trust tiers of the extension host. |
| 2 | //! |
| 3 | //! One process is one trust domain (design §1.5), so the host runs as two |
| 4 | //! processes that never share code, state or a data directory: |
| 5 | //! |
| 6 | //! * **Plugin** (tier 1): reviewed third-party plugins. Owner ids are the |
| 7 | //! plugin ids discovery builds (`<scope>/<12 hex>/<name>`). This is the only |
| 8 | //! tier serves reviewed third-party code independently of the selected |
| 9 | //! builtin MCP protocol backend. |
| 10 | //! * **Builtin** (tier 0): Codewhale's own host code, so that it never shares |
| 11 | //! a process with third-party code. Owner ids are `host:<module>`. A module |
| 12 | //! is admitted by a row of [`BUILTIN_MODULES`], which pins the SHA-256 of its |
| 13 | //! source and names each of its tools with the approval the core gives it |
| 14 | //! (`Auto` or `Required`). The table is Rust data: nothing a module says |
| 15 | //! about itself can lower its approval, and a tool the table does not list is |
| 16 | //! `Required`. |
| 17 | //! |
| 18 | //! The two id spaces cannot meet. A plugin id starts with a scope name and a |
| 19 | //! manifest name cannot hold `:`, so discovery never builds a `host:` id; |
| 20 | //! [`HostTier::check_owner_id`] is the one place that says so, and the owner |
| 21 | //! registry refuses a `host:` id on the plugin tier and any other id on the |
| 22 | //! builtin tier (`OwnerRegistry::begin_owner`). |
| 23 | //! |
| 24 | //! Production pins the MCP SDK module, activated only when a selected Host |
| 25 | //! stdio connection asks for it. Plugin-tier frames cannot use proc/* or mcp/*. |
| 26 | //! Rust owns launch, exact operation tickets, credentials and decision keys; |
| 27 | //! the builtin is a protocol owner, not an execution or approval authority. |
| 28 | //! The pinned digest detects changed bytes; it does not sandbox code already |
| 29 | //! executing as the current OS user. Sources are embedded and materialized at |
| 30 | //! `<bundle dir>/builtin/<module>.mjs` with the host's notices. |
| 31 | |
| 32 | use crate::tools::spec::ApprovalRequirement; |
| 33 | |
| 34 | /// Every tier-0 owner id starts with this: `host:<module>`. |
| 35 | pub(crate) const HOST_OWNER_PREFIX: &str = "host:"; |
| 36 | |
| 37 | /// Which of the two host processes something belongs to. Also the wire value |
| 38 | /// of `host/hello.tier` and of a method's tier allow-list |
| 39 | /// (`protocol::MethodSpec::tiers`). |
| 40 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] |
| 41 | #[cfg_attr(test, derive(schemars::JsonSchema))] |
| 42 | #[serde(rename_all = "lowercase")] |
| 43 | pub(crate) enum HostTier { |
| 44 | /// Codewhale's own host code (tier 0). |
| 45 | Builtin, |
| 46 | /// Reviewed third-party plugins (tier 1). |
| 47 | Plugin, |
| 48 | } |
| 49 | |
| 50 | impl HostTier { |
| 51 | /// Plugin first for ordinary extension discovery; builtin stays separate. |
| 52 | pub(crate) const ALL: [Self; 2] = [Self::Plugin, Self::Builtin]; |
| 53 | |
| 54 | /// The value of `--tier=` and of the data directory's name. |
| 55 | #[must_use] |
| 56 | pub(crate) fn name(self) -> &'static str { |
| 57 | match self { |
| 58 | Self::Builtin => "builtin", |
| 59 | Self::Plugin => "plugin", |
| 60 | } |
| 61 | } |
| 62 | |
| 63 | /// The argument the core launches this tier's host with. |
| 64 | #[must_use] |
| 65 | pub(crate) fn argv_flag(self) -> String { |
| 66 | format!("--tier={}", self.name()) |
| 67 | } |
| 68 | |
| 69 | /// How diagnostics and errors name this tier's host process. The plugin |
| 70 | /// tier keeps the name it has always had. |
| 71 | #[must_use] |
| 72 | pub(crate) fn host_label(self) -> &'static str { |
| 73 | match self { |
| 74 | Self::Builtin => "built-in extension host", |
| 75 | Self::Plugin => "extension host", |
| 76 | } |
| 77 | } |
| 78 | |
| 79 | /// The tier an owner id belongs to. Total: the id decides. |
| 80 | #[must_use] |
| 81 | pub(crate) fn of_owner_id(owner_id: &str) -> Self { |
| 82 | if owner_id.starts_with(HOST_OWNER_PREFIX) { |
| 83 | Self::Builtin |
| 84 | } else { |
| 85 | Self::Plugin |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | /// Whether `owner_id` may be an owner on this tier, or why not. |
| 90 | pub(crate) fn check_owner_id(self, owner_id: &str) -> Result<(), String> { |
| 91 | match self { |
| 92 | Self::Plugin if owner_id.starts_with(HOST_OWNER_PREFIX) => Err(format!( |
| 93 | "`{owner_id}` is in the built-in host namespace (`{HOST_OWNER_PREFIX}<module>`); a plugin id can never use it" |
| 94 | )), |
| 95 | Self::Plugin => Ok(()), |
| 96 | Self::Builtin => match owner_id.strip_prefix(HOST_OWNER_PREFIX) { |
| 97 | Some(module) if valid_module_id(module) => Ok(()), |
| 98 | _ => Err(format!( |
| 99 | "`{owner_id}` is not a built-in host module id: the built-in tier takes only `{HOST_OWNER_PREFIX}<module>` (a lower-case module name of letters, digits and `-`)" |
| 100 | )), |
| 101 | }, |
| 102 | } |
| 103 | } |
| 104 | } |
| 105 | |
| 106 | /// `^[a-z][a-z0-9-]{0,63}$`: a module name is also a file name and a |
| 107 | /// directory name, so it is as plain as one. |
| 108 | fn valid_module_id(module: &str) -> bool { |
| 109 | let mut chars = module.chars(); |
| 110 | matches!(chars.next(), Some(first) if first.is_ascii_lowercase()) |
| 111 | && module.len() <= 64 |
| 112 | && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') |
| 113 | } |
| 114 | |
| 115 | /// One tool of a built-in module and the approval the core gives it: `Auto` or |
| 116 | /// `Required` (the core's own [`ApprovalRequirement`]; [`tool_approval`] reads |
| 117 | /// anything but `Auto` as `Required`). Only this table can say `Auto`. |
| 118 | #[derive(Debug)] |
| 119 | pub(crate) struct Tier0Tool { |
| 120 | pub name: &'static str, |
| 121 | pub approval: ApprovalRequirement, |
| 122 | } |
| 123 | |
| 124 | /// One built-in host module: Codewhale's own code, pinned. |
| 125 | #[derive(Debug)] |
| 126 | pub(crate) struct BuiltinModule { |
| 127 | /// The module name; its owner id is `host:<id>`. |
| 128 | pub id: &'static str, |
| 129 | /// SHA-256 (lower-case hex) of the module's source file, as the host |
| 130 | /// build records it in `dist/builtin-modules.json`. The core refuses to |
| 131 | /// activate a file with any other digest. |
| 132 | pub source_sha256: &'static str, |
| 133 | pub tools: &'static [Tier0Tool], |
| 134 | } |
| 135 | |
| 136 | impl BuiltinModule { |
| 137 | /// The owner id this module activates under. |
| 138 | #[must_use] |
| 139 | pub(crate) fn owner_id(&self) -> String { |
| 140 | format!("{HOST_OWNER_PREFIX}{}", self.id) |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | /// The built-in modules the core pins. MCP is started only by the explicit |
| 145 | /// Host SDK backend. The host build records the same digest; the drift |
| 146 | /// test refuses any row/file mismatch. |
| 147 | pub(crate) const BUILTIN_MODULES: &[BuiltinModule] = &[ |
| 148 | BuiltinModule { |
| 149 | id: "harness", |
| 150 | source_sha256: "bf685db5e808ab708ec698e1bc038d173db59f2facb6907fdbd689f336123f8f", |
| 151 | tools: &[], |
| 152 | }, |
| 153 | BuiltinModule { |
| 154 | id: "mcp", |
| 155 | source_sha256: "d5eb38941113934f9768e90ab3f1db021b93980e41be5cdf8836489b7f233b55", |
| 156 | tools: &[], |
| 157 | }, |
| 158 | ]; |
| 159 | |
| 160 | /// The approval for `tool` of the module that owns `owner_id`, from `modules` |
| 161 | /// and nowhere else: `Auto` only where a row says so, `Required` for a module |
| 162 | /// or tool the table does not list and for any row that says anything else. |
| 163 | #[must_use] |
| 164 | pub(crate) fn tool_approval( |
| 165 | modules: &[BuiltinModule], |
| 166 | owner_id: &str, |
| 167 | tool: &str, |
| 168 | ) -> ApprovalRequirement { |
| 169 | let listed = owner_id |
| 170 | .strip_prefix(HOST_OWNER_PREFIX) |
| 171 | .and_then(|module_id| modules.iter().find(|module| module.id == module_id)) |
| 172 | .and_then(|module| module.tools.iter().find(|listed| listed.name == tool)); |
| 173 | match listed { |
| 174 | Some(Tier0Tool { |
| 175 | approval: ApprovalRequirement::Auto, |
| 176 | .. |
| 177 | }) => ApprovalRequirement::Auto, |
| 178 | _ => ApprovalRequirement::Required, |
| 179 | } |
| 180 | } |
| 181 | |
| 182 | #[cfg(test)] |
| 183 | mod tests { |
| 184 | use std::collections::BTreeMap; |
| 185 | |
| 186 | use super::*; |
| 187 | |
| 188 | /// The host build (`extension-host/build.mjs`) writes the digest of every |
| 189 | /// built-in module's source to `dist/builtin-modules.json`; the Rust table |
| 190 | /// must say exactly the same, both ways: a module with no row, a row with |
| 191 | /// no module and a changed source all fail here. Same pattern as the |
| 192 | /// generated-protocol drift test in `protocol/tests.rs`, except the build |
| 193 | /// is the source of truth, so the fix is to update the table. |
| 194 | #[test] |
| 195 | fn table_matches_the_host_build() { |
| 196 | let built: serde_json::Value = serde_json::from_str(include_str!( |
| 197 | "../../extension-host/dist/builtin-modules.json" |
| 198 | )) |
| 199 | .expect("dist/builtin-modules.json is JSON"); |
| 200 | let built: BTreeMap<String, String> = built["modules"] |
| 201 | .as_object() |
| 202 | .expect("`modules` is an object") |
| 203 | .iter() |
| 204 | .map(|(id, digest)| (id.clone(), digest.as_str().expect("a digest").to_string())) |
| 205 | .collect(); |
| 206 | let table: BTreeMap<String, String> = BUILTIN_MODULES |
| 207 | .iter() |
| 208 | .map(|module| (module.id.to_string(), module.source_sha256.to_string())) |
| 209 | .collect(); |
| 210 | assert_eq!( |
| 211 | table.len(), |
| 212 | BUILTIN_MODULES.len(), |
| 213 | "BUILTIN_MODULES lists a module twice" |
| 214 | ); |
| 215 | assert_eq!( |
| 216 | table, built, |
| 217 | "BUILTIN_MODULES and crates/tui/extension-host/dist/builtin-modules.json disagree. \ |
| 218 | Rebuild the host (`npm run build` in crates/tui/extension-host) and make the table \ |
| 219 | list exactly the modules and digests the build recorded." |
| 220 | ); |
| 221 | } |
| 222 | |
| 223 | #[test] |
| 224 | fn every_row_is_well_formed() { |
| 225 | for module in BUILTIN_MODULES { |
| 226 | assert!(valid_module_id(module.id), "{}", module.id); |
| 227 | assert!( |
| 228 | module.source_sha256.len() == 64 |
| 229 | && module |
| 230 | .source_sha256 |
| 231 | .bytes() |
| 232 | .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)), |
| 233 | "{}: not a lower-case SHA-256", |
| 234 | module.id |
| 235 | ); |
| 236 | assert_eq!(HostTier::of_owner_id(&module.owner_id()), HostTier::Builtin); |
| 237 | } |
| 238 | } |
| 239 | |
| 240 | #[test] |
| 241 | fn the_two_id_spaces_cannot_meet() { |
| 242 | assert!( |
| 243 | HostTier::Plugin |
| 244 | .check_owner_id("user/0123456789ab/demo") |
| 245 | .is_ok() |
| 246 | ); |
| 247 | assert!(HostTier::Plugin.check_owner_id("a").is_ok()); |
| 248 | assert!(HostTier::Builtin.check_owner_id("host:mcp").is_ok()); |
| 249 | for host_id in ["host:mcp", "host:", "host:evil name"] { |
| 250 | let refused = HostTier::Plugin.check_owner_id(host_id).unwrap_err(); |
| 251 | assert!( |
| 252 | refused.contains("a plugin id can never use it"), |
| 253 | "{refused}" |
| 254 | ); |
| 255 | } |
| 256 | for id in [ |
| 257 | "user/0123456789ab/demo", |
| 258 | "mcp", |
| 259 | "host", |
| 260 | "host:", |
| 261 | "host:UPPER", |
| 262 | "host:../x", |
| 263 | "host:a/b", |
| 264 | "HOST:mcp", |
| 265 | ] { |
| 266 | let refused = HostTier::Builtin.check_owner_id(id).unwrap_err(); |
| 267 | assert!( |
| 268 | refused.contains("not a built-in host module id"), |
| 269 | "{id}: {refused}" |
| 270 | ); |
| 271 | } |
| 272 | assert_eq!(HostTier::of_owner_id("host:mcp"), HostTier::Builtin); |
| 273 | assert_eq!( |
| 274 | HostTier::of_owner_id("user/0123456789ab/demo"), |
| 275 | HostTier::Plugin |
| 276 | ); |
| 277 | } |
| 278 | |
| 279 | #[test] |
| 280 | fn a_tier_zero_tool_is_required_unless_the_table_says_otherwise() { |
| 281 | const TOOLS: &[Tier0Tool] = &[ |
| 282 | Tier0Tool { |
| 283 | name: "open", |
| 284 | approval: ApprovalRequirement::Auto, |
| 285 | }, |
| 286 | Tier0Tool { |
| 287 | name: "write", |
| 288 | approval: ApprovalRequirement::Required, |
| 289 | }, |
| 290 | Tier0Tool { |
| 291 | name: "suggest", |
| 292 | approval: ApprovalRequirement::Suggest, |
| 293 | }, |
| 294 | ]; |
| 295 | let table = [BuiltinModule { |
| 296 | id: "demo", |
| 297 | source_sha256: "0000000000000000000000000000000000000000000000000000000000000000", |
| 298 | tools: TOOLS, |
| 299 | }]; |
| 300 | assert_eq!( |
| 301 | tool_approval(&table, "host:demo", "open"), |
| 302 | ApprovalRequirement::Auto |
| 303 | ); |
| 304 | assert_eq!( |
| 305 | tool_approval(&table, "host:demo", "write"), |
| 306 | ApprovalRequirement::Required |
| 307 | ); |
| 308 | // Only `Auto` lowers anything: any other row reads as `Required`. |
| 309 | assert_eq!( |
| 310 | tool_approval(&table, "host:demo", "suggest"), |
| 311 | ApprovalRequirement::Required |
| 312 | ); |
| 313 | // Unlisted tool, unlisted module, and an id that is not tier 0 at all. |
| 314 | assert_eq!( |
| 315 | tool_approval(&table, "host:demo", "other"), |
| 316 | ApprovalRequirement::Required |
| 317 | ); |
| 318 | assert_eq!( |
| 319 | tool_approval(&table, "host:other", "open"), |
| 320 | ApprovalRequirement::Required |
| 321 | ); |
| 322 | assert_eq!( |
| 323 | tool_approval(&table, "demo", "open"), |
| 324 | ApprovalRequirement::Required |
| 325 | ); |
| 326 | assert_eq!( |
| 327 | tool_approval(&[], "host:demo", "open"), |
| 328 | ApprovalRequirement::Required |
| 329 | ); |
| 330 | } |
| 331 | } |
| 332 |