返回 CodeWhale
tier.rs
根目录 / crates / tui / src / extension_host / tier.rs
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
332 lines RUST