返回 CodeWhale
registry.rs
根目录 / crates / tui / src / extension_host / registry.rs
1 //! Owned registrations: the Rust side is the authority (design §3).
2 //!
3 //! Every registration belongs to exactly one `(plugin_id, generation)` owner.
4 //! Revocation is synchronous and never waits for the host: removing an owner
5 //! removes its tools from every later turn's registry at once, and
6 //! `HostToolSpec` re-checks liveness before each call. Handles are never
7 //! reused, so undoing one registration can never touch a newer one.
8 //!
9 //! One registry serves both trust tiers ([`super::tier`]): an owner records
10 //! which host process it lives in, `begin_owner` refuses an id the tier cannot
11 //! hold (`host:<module>` is tier 0's alone), and a host exit removes only its
12 //! own tier's owners and registrations.
13
14 use std::collections::{BTreeMap, HashMap, HashSet};
15 use std::sync::Arc;
16
17 use serde_json::Value;
18
19 use super::protocol::{EntryRef, OwnerRef, RegisterKind, RegisterParams};
20 use super::tier::HostTier;
21 use crate::plugins::types::PluginAuthority;
22
23 /// Largest accepted tool input schema, serialized.
24 pub const MAX_SCHEMA_BYTES: usize = 64 * 1024;
25 /// Largest accepted tool description.
26 pub const MAX_DESCRIPTION_BYTES: usize = 4 * 1024;
27 pub const MAX_TOOLS_PER_OWNER: usize = 128;
28 pub const MAX_TOOLS_PER_HOST: usize = 1024;
29 /// Commands are listed in the palette and `/help`, so the caps are tighter.
30 pub const MAX_COMMANDS_PER_OWNER: usize = 64;
31 pub const MAX_COMMANDS_PER_HOST: usize = 256;
32 /// A command description is one palette line; a hint is a short placeholder.
33 pub const MAX_COMMAND_DESCRIPTION_BYTES: usize = 1024;
34 pub const MAX_COMMAND_HINT_BYTES: usize = 256;
35
36 /// Name prefixes no extension may use: MCP's namespace, and one kept free
37 /// for future core-issued extension names.
38 const RESERVED_PREFIXES: &[&str] = &["mcp_", "ext_"];
39 const NAME_HINT: &str = "use a plugin-specific prefix, for example `myplugin_read_x`";
40
41 /// Core tool names that exist outside the native registry builder (catalog
42 /// meta-tools) and so never show up in a registry snapshot.
43 const RESERVED_NAMES: &[&str] = &[
44 "tool_search",
45 "tool_search_tool_regex",
46 "tool_search_tool_bm25",
47 "retrieve_tool_result",
48 "execute_tools",
49 "code_execution",
50 "js_execution",
51 "request_user_input",
52 "multi_tool_use.parallel",
53 ];
54
55 #[derive(Debug, Clone, PartialEq, Eq)]
56 pub enum OwnerState {
57 Activating,
58 Active,
59 Failed(String),
60 Faulted(String),
61 Revoked,
62 }
63
64 #[derive(Debug, Clone)]
65 pub struct OwnerEntry {
66 pub owner: OwnerRef,
67 /// The host process this owner lives in. Fixed at [`OwnerRegistry::begin_owner`],
68 /// which refuses an id the tier cannot hold.
69 pub tier: HostTier,
70 pub plugin_name: String,
71 /// The reviewed plugin authority (plugin tier). A built-in module has none:
72 /// what it is bound to is the source digest the Rust table pins, which is
73 /// its `content_hash`.
74 pub authority: Option<PluginAuthority>,
75 pub content_hash: String,
76 /// Digest of the plugin config this activation was given (empty until
77 /// [`OwnerRegistry::set_config_hash`]). A change of config is a different
78 /// activation: reconcile revokes the owner and activates a new generation.
79 pub config_hash: String,
80 pub state: OwnerState,
81 pub scopes: HashMap<EntryRef, OwnerState>,
82 }
83
84 /// Most schema violations one refused call reports back to the model.
85 const MAX_REPORTED_INPUT_ERRORS: usize = 5;
86 /// Bound on the refusal text (violations quote the offending values).
87 const MAX_INPUT_ERROR_BYTES: usize = 2048;
88
89 /// A tool's input schema, compiled once at registration. The core checks every
90 /// call's input against it before anything is sent to the host: a plugin that
91 /// registers a plain object receives only input its own schema admits, without
92 /// having to validate it itself.
93 ///
94 /// Compiled by `jsonschema` (already linked for Workflow `responseSchema`),
95 /// which resolves no external `$ref` here (no network or file resolver is
96 /// enabled); a schema it cannot compile is refused at registration.
97 #[derive(Clone)]
98 pub struct InputValidator(Arc<jsonschema::Validator>);
99
100 impl InputValidator {
101 /// Compile `schema`, or say why it cannot be.
102 pub fn compile(schema: &Value) -> Result<Self, String> {
103 jsonschema::validator_for(schema)
104 .map(|validator| Self(Arc::new(validator)))
105 .map_err(|error| error.to_string())
106 }
107
108 /// `Ok` when `input` satisfies the schema; otherwise the violations, as
109 /// text a model can correct its call from.
110 pub fn check(&self, input: &Value) -> Result<(), String> {
111 let mut errors = self.0.iter_errors(input);
112 let Some(first) = errors.next() else {
113 return Ok(());
114 };
115 let describe = |error: &jsonschema::ValidationError<'_>| {
116 let at = error.instance_path().to_string();
117 if at.is_empty() {
118 error.to_string()
119 } else {
120 format!("{error} (at {at})")
121 }
122 };
123 let mut reasons = vec![describe(&first)];
124 let mut more = 0usize;
125 for error in errors {
126 if reasons.len() < MAX_REPORTED_INPUT_ERRORS {
127 reasons.push(describe(&error));
128 } else {
129 more += 1;
130 }
131 }
132 let mut text = reasons.join("; ");
133 if more > 0 {
134 text.push_str(&format!("; and {more} more"));
135 }
136 if text.len() > MAX_INPUT_ERROR_BYTES {
137 let mut end = MAX_INPUT_ERROR_BYTES;
138 while !text.is_char_boundary(end) {
139 end -= 1;
140 }
141 text.truncate(end);
142 text.push('…');
143 }
144 Err(text)
145 }
146 }
147
148 impl PartialEq for InputValidator {
149 fn eq(&self, other: &Self) -> bool {
150 Arc::ptr_eq(&self.0, &other.0)
151 }
152 }
153
154 impl std::fmt::Debug for InputValidator {
155 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
156 f.debug_struct("InputValidator").finish_non_exhaustive()
157 }
158 }
159
160 #[derive(Debug, Clone, PartialEq)]
161 pub struct ToolRegistration {
162 pub handle: u64,
163 pub owner: OwnerRef,
164 pub scope: Option<EntryRef>,
165 /// The tier of `owner`'s host.
166 pub tier: HostTier,
167 pub plugin_name: String,
168 /// The reviewed bundle content hash of the owner that registered it: the
169 /// receipt its approval grants are bound to.
170 pub content_hash: String,
171 pub name: String,
172 pub description: String,
173 pub input_schema: Value,
174 /// `input_schema`, compiled: every call is checked against it.
175 pub input_validator: InputValidator,
176 }
177
178 /// An admitted slash command. Owned exactly like a tool: one owner
179 /// generation, a never-reused handle, removed with its owner.
180 #[derive(Debug, Clone, PartialEq)]
181 pub struct CommandRegistration {
182 pub handle: u64,
183 pub owner: OwnerRef,
184 pub scope: Option<EntryRef>,
185 /// The tier of `owner`'s host.
186 pub tier: HostTier,
187 pub plugin_name: String,
188 /// The reviewed bundle content hash of the registering owner.
189 pub content_hash: String,
190 /// The slash-command name, without the slash (lower case).
191 pub name: String,
192 pub description: String,
193 pub argument_hint: Option<String>,
194 }
195
196 /// A programmable admission listener, owned and revoked like a tool.
197 #[derive(Debug, Clone, PartialEq)]
198 pub struct HookRegistration {
199 pub handle: u64,
200 pub owner: OwnerRef,
201 pub scope: Option<EntryRef>,
202 pub tier: HostTier,
203 pub plugin_name: String,
204 pub content_hash: String,
205 pub event: String,
206 }
207
208 #[derive(Debug, Clone, PartialEq)]
209 pub struct PromptSectionRegistration {
210 pub handle: u64,
211 pub owner: OwnerRef,
212 pub scope: Option<EntryRef>,
213 pub tier: HostTier,
214 pub plugin_name: String,
215 pub content_hash: String,
216 pub id: String,
217 pub text: String,
218 pub interpolate: bool,
219 }
220
221 #[derive(Debug, Clone)]
222 pub(crate) struct ShellHookRegistration {
223 pub handle: u64,
224 pub owner: OwnerRef,
225 pub scope: Option<EntryRef>,
226 pub content_hash: String,
227 pub hook: crate::hooks::Hook,
228 }
229 #[derive(Debug, serde::Deserialize)]
230 #[serde(deny_unknown_fields)]
231 struct ShellSpec {
232 dialect: String,
233 point: String,
234 #[serde(default)]
235 matcher: Option<String>,
236 hook: crate::hooks::Hook,
237 }
238
239 #[derive(Debug, Default)]
240 pub struct OwnerRegistry {
241 next_handle: u64,
242 next_generation: u64,
243 owners: HashMap<String, OwnerEntry>,
244 tools: BTreeMap<u64, ToolRegistration>,
245 /// Lower-cased tool name → handle, so `Read` cannot impersonate `read`.
246 by_name: HashMap<String, u64>,
247 commands: BTreeMap<u64, CommandRegistration>,
248 /// Command name → handle. Commands and tools are separate namespaces: a
249 /// tool is called by the model, a command by the user.
250 commands_by_name: HashMap<String, u64>,
251 hooks: BTreeMap<u64, HookRegistration>,
252 shell_hooks: BTreeMap<u64, ShellHookRegistration>,
253 prompt_sections: BTreeMap<u64, PromptSectionRegistration>,
254 skill_roots: BTreeMap<u64, super::skills::SkillRootRegistration>,
255 mcp_servers: BTreeMap<u64, super::native_mcp::McpRegistration>,
256 /// Lower-cased names of every native tool any engine's turn build has
257 /// reported, plus the static set. Only ever grows: engines in one
258 /// process build different native surfaces, and a name that is native
259 /// anywhere is refused everywhere.
260 native_names: HashSet<String>,
261 }
262
263 fn mint_token() -> String {
264 // Two v4 UUIDs: 244 random bits from the OS generator.
265 format!(
266 "{}{}",
267 uuid::Uuid::new_v4().simple(),
268 uuid::Uuid::new_v4().simple()
269 )
270 }
271
272 fn valid_tool_name(name: &str) -> bool {
273 let mut chars = name.chars();
274 matches!(chars.next(), Some(first) if first.is_ascii_alphabetic())
275 && name.len() <= 64
276 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
277 }
278
279 /// DSH's command grammar (`^[a-z][a-z0-9_-]*$`), bounded. Lower case only:
280 /// the user's input is lower-cased before it is looked up.
281 fn valid_command_name(name: &str) -> bool {
282 let mut chars = name.chars();
283 matches!(chars.next(), Some(first) if first.is_ascii_lowercase())
284 && name.len() <= 64
285 && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
286 }
287
288 /// Why the core would treat a tool called `name` as something other than an
289 /// opaque extension tool, if it would.
290 ///
291 /// Approval keys, approval-card summaries, and the approval/auto-review
292 /// category are all derived from the tool *name*. A name any of them
293 /// special-cases would let an extension borrow a native tool's identity: a
294 /// session grant for `fetch_url` on a host (`net:<host>`) would approve a
295 /// plugin tool named `web_fetch`, and a `read_*` name would be classified as
296 /// a read. Such names need not be registered natives (`web_fetch`,
297 /// `exec_wait`, and mode-dependent tools such as `task_shell_start` are not),
298 /// so they are refused by probing the classifiers themselves rather than by a
299 /// hand-kept list that would drift from them.
300 fn core_special_case(name: &str) -> Option<&'static str> {
301 use crate::core::authority::{ToolCategory, get_tool_category_for_call};
302 use crate::tools::approval_cache::{build_approval_grouping_key, build_approval_key};
303 let empty = Value::Object(serde_json::Map::new());
304 let spellings = [name.to_string(), name.to_ascii_lowercase()];
305 for spelling in &spellings {
306 let generic = format!("tool:{spelling}:");
307 if !build_approval_key(spelling, &empty).0.starts_with(&generic)
308 || !build_approval_grouping_key(spelling, &empty)
309 .0
310 .starts_with(&generic)
311 {
312 return Some("the approval cache keys it as a built-in tool family");
313 }
314 if crate::tools::canonical_action::canonical_action_alias(spelling, &empty) != spelling {
315 return Some("it is an alias of a built-in tool");
316 }
317 if crate::tools::approval_summary::approval_summary(spelling, &empty, None)
318 != format!("Use the {spelling} tool")
319 {
320 return Some("approval cards describe it as a built-in tool");
321 }
322 if get_tool_category_for_call(spelling, &empty) != ToolCategory::Unknown {
323 return Some(
324 "the approval policy classifies it by name (read, write, shell, network, MCP or agent)",
325 );
326 }
327 }
328 None
329 }
330
331 impl OwnerRegistry {
332 #[must_use]
333 pub fn new() -> Self {
334 let mut native_names: HashSet<String> = RESERVED_NAMES
335 .iter()
336 .chain(crate::core::engine::tool_catalog::DEFAULT_ACTIVE_NATIVE_TOOLS)
337 .map(|name| name.to_ascii_lowercase())
338 .collect();
339 for (family, _, alias) in crate::tools::canonical_action::CANONICAL_ACTION_ALIASES {
340 native_names.insert(family.to_ascii_lowercase());
341 native_names.insert(alias.to_ascii_lowercase());
342 }
343 Self {
344 native_names,
345 ..Self::default()
346 }
347 }
348
349 /// Add native tool names from one engine's turn build (the registry
350 /// before scripts, plugins or extensions are added). Never removes one.
351 pub fn add_native_names<'a>(&mut self, names: impl IntoIterator<Item = &'a str>) {
352 self.native_names
353 .extend(names.into_iter().map(str::to_ascii_lowercase));
354 }
355
356 /// Start a new activation for `plugin_id` on `tier`, superseding any
357 /// previous one. Refuses an id the tier cannot hold (a `host:` id on the
358 /// plugin tier, any other on the builtin tier) and an authority that does
359 /// not fit it: a plugin owner is bound to its reviewed plugin authority, a
360 /// built-in module to none (its pinned digest is `content_hash`).
361 pub fn begin_owner(
362 &mut self,
363 tier: HostTier,
364 plugin_id: &str,
365 plugin_name: &str,
366 authority: Option<PluginAuthority>,
367 content_hash: &str,
368 ) -> Result<OwnerRef, String> {
369 tier.check_owner_id(plugin_id)?;
370 match (tier, authority.is_some()) {
371 (HostTier::Plugin, false) => {
372 return Err(format!(
373 "plugin owner `{plugin_id}` needs its reviewed plugin authority"
374 ));
375 }
376 (HostTier::Builtin, true) => {
377 return Err(format!(
378 "built-in module `{plugin_id}` has no plugin authority; the digest the Rust table pins is its authority"
379 ));
380 }
381 _ => {}
382 }
383 self.revoke_owner(plugin_id);
384 self.next_generation += 1;
385 let owner = OwnerRef {
386 plugin_id: plugin_id.to_string(),
387 generation: self.next_generation,
388 owner_token: mint_token(),
389 };
390 self.owners.insert(
391 plugin_id.to_string(),
392 OwnerEntry {
393 owner: owner.clone(),
394 tier,
395 plugin_name: plugin_name.to_string(),
396 authority,
397 content_hash: content_hash.to_string(),
398 config_hash: String::new(),
399 state: OwnerState::Activating,
400 scopes: HashMap::new(),
401 },
402 );
403 Ok(owner)
404 }
405
406 /// Record which config `owner`'s activation was given.
407 pub fn set_config_hash(&mut self, owner: &OwnerRef, hash: &str) {
408 if let Some(entry) = self.owners.get_mut(&owner.plugin_id)
409 && entry.owner == *owner
410 {
411 entry.config_hash = hash.to_string();
412 }
413 }
414
415 #[must_use]
416 pub fn owner(&self, plugin_id: &str) -> Option<&OwnerEntry> {
417 self.owners.get(plugin_id)
418 }
419
420 pub fn owners(&self) -> impl Iterator<Item = &OwnerEntry> {
421 self.owners.values()
422 }
423
424 /// The exact current, not-yet-revoked owner (token and generation match).
425 fn current(&self, owner: &OwnerRef) -> Option<&OwnerEntry> {
426 self.owners.get(&owner.plugin_id).filter(|entry| {
427 entry.owner == *owner
428 && matches!(entry.state, OwnerState::Activating | OwnerState::Active)
429 })
430 }
431
432 pub fn mark_active(&mut self, owner: &OwnerRef) -> bool {
433 match self.owners.get_mut(&owner.plugin_id) {
434 Some(entry) if entry.owner == *owner && entry.state == OwnerState::Activating => {
435 entry.state = OwnerState::Active;
436 super::native_mcp::changed();
437 super::command::bump_epoch();
438 true
439 }
440 _ => false,
441 }
442 }
443
444 /// Mark an owner failed or faulted and drop everything it registered.
445 pub fn mark_failed(&mut self, owner: &OwnerRef, state: OwnerState) -> bool {
446 let matches = self
447 .owners
448 .get(&owner.plugin_id)
449 .is_some_and(|entry| entry.owner == *owner);
450 if matches {
451 self.remove_registrations_of(&owner.plugin_id);
452 if let Some(entry) = self.owners.get_mut(&owner.plugin_id) {
453 entry.state = state;
454 }
455 }
456 matches
457 }
458
459 /// Only core invokes this after the existing Native inventory/receipt check.
460 pub(super) fn begin_scope(&mut self, owner: &OwnerRef, scope: EntryRef) -> Result<(), String> {
461 let entry = self
462 .owners
463 .get_mut(&owner.plugin_id)
464 .filter(|entry| entry.owner == *owner)
465 .ok_or("scope owner was withdrawn")?;
466 if entry.tier != HostTier::Plugin || entry.authority.is_none() {
467 return Err("scope requires reviewed Native authority".into());
468 }
469 if entry.scopes.contains_key(&scope) {
470 return Err("scope is already admitted".into());
471 }
472 entry.scopes.insert(scope, OwnerState::Activating);
473 Ok(())
474 }
475 pub(super) fn mark_scope_active(&mut self, owner: &OwnerRef, scope: &EntryRef) -> bool {
476 let Some(entry) = self
477 .owners
478 .get_mut(&owner.plugin_id)
479 .filter(|entry| entry.owner == *owner)
480 else {
481 return false;
482 };
483 let Some(state) = entry.scopes.get_mut(scope) else {
484 return false;
485 };
486 if *state != OwnerState::Activating {
487 return false;
488 }
489 *state = OwnerState::Active;
490 super::native_mcp::changed();
491 super::command::bump_epoch();
492 true
493 }
494 pub(super) fn check_scope(
495 &self,
496 owner: &OwnerRef,
497 scope: Option<&EntryRef>,
498 active: bool,
499 ) -> Result<(), String> {
500 let entry = self.current(owner).ok_or("scope owner was withdrawn")?;
501 if scope.is_none() && entry.tier == HostTier::Plugin && !entry.scopes.is_empty() {
502 return Err("Native contribution must name its core-admitted entry scope".into());
503 }
504 if let Some(scope) = scope {
505 let state = entry
506 .scopes
507 .get(scope)
508 .ok_or("scope was not admitted by core")?;
509 if !matches!(state, OwnerState::Active)
510 && (active || !matches!(state, OwnerState::Activating))
511 {
512 return Err("scope was withdrawn or is not ready".into());
513 }
514 }
515 Ok(())
516 }
517 pub(super) fn fail_scope(&mut self, owner: &OwnerRef, scope: &EntryRef) {
518 let _ = self.revoke_scope(owner, scope);
519 if let Some(entry) = self
520 .owners
521 .get_mut(&owner.plugin_id)
522 .filter(|entry| entry.owner == *owner)
523 {
524 entry.scopes.insert(
525 scope.clone(),
526 OwnerState::Failed("Native entry activation failed".into()),
527 );
528 }
529 }
530 pub(super) fn revoke_scope(&mut self, owner: &OwnerRef, scope: &EntryRef) -> Vec<u64> {
531 if let Some(entry) = self
532 .owners
533 .get_mut(&owner.plugin_id)
534 .filter(|entry| entry.owner == *owner)
535 {
536 entry.scopes.remove(scope);
537 }
538 let handles: Vec<_> = self
539 .tools
540 .values()
541 .filter(|r| r.owner == *owner && r.scope.as_ref() == Some(scope))
542 .map(|r| r.handle)
543 .chain(
544 self.commands
545 .values()
546 .filter(|r| r.owner == *owner && r.scope.as_ref() == Some(scope))
547 .map(|r| r.handle),
548 )
549 .chain(
550 self.hooks
551 .values()
552 .filter(|r| r.owner == *owner && r.scope.as_ref() == Some(scope))
553 .map(|r| r.handle),
554 )
555 .chain(
556 self.shell_hooks
557 .values()
558 .filter(|r| r.owner == *owner && r.scope.as_ref() == Some(scope))
559 .map(|r| r.handle),
560 )
561 .chain(
562 self.prompt_sections
563 .values()
564 .filter(|r| r.owner == *owner && r.scope.as_ref() == Some(scope))
565 .map(|r| r.handle),
566 )
567 .chain(
568 self.skill_roots
569 .values()
570 .filter(|r| r.owner == *owner && r.scope.as_ref() == Some(scope))
571 .map(|r| r.handle),
572 )
573 .chain(
574 self.mcp_servers
575 .values()
576 .filter(|r| r.owner == *owner && r.scope == *scope)
577 .map(|r| r.handle),
578 )
579 .collect();
580 for handle in &handles {
581 self.unregister(owner, *handle);
582 }
583 super::command::bump_epoch();
584 handles
585 }
586
587 /// Admit or refuse one `registry/register`, whatever its kind.
588 pub fn register(&mut self, params: &RegisterParams) -> Result<u64, String> {
589 match params.kind {
590 RegisterKind::Tool => self.register_tool(params),
591 RegisterKind::Command => self.register_command(params),
592 RegisterKind::Hook => self.register_hook(params),
593 RegisterKind::ShellHook => self.register_shell_hook(params),
594 RegisterKind::PromptSection | RegisterKind::PromptTemplate => {
595 self.register_prompt_section(params)
596 }
597 RegisterKind::McpServer => {
598 Err("MCP definitions require reviewed snapshot admission".into())
599 }
600 RegisterKind::SkillRoot => {
601 Err("skill roots require reviewed snapshot admission".to_string())
602 }
603 }
604 }
605
606 pub(super) fn register_mcp(
607 &mut self,
608 params: &RegisterParams,
609 config: crate::mcp::McpServerConfig,
610 host_generation: u64,
611 ) -> Result<u64, String> {
612 self.check_scope(&params.owner, params.scope.as_ref(), false)?;
613 params.check_spec()?;
614 if params.kind != RegisterKind::McpServer
615 || params.spec.description.len() > super::native_mcp::MAX_DEFINITION_BYTES
616 {
617 return Err("Invalid MCP definition".into());
618 }
619 let scope = params
620 .scope
621 .clone()
622 .ok_or("MCP definition requires an entry scope")?;
623 let owner = self.current(&params.owner).ok_or("MCP owner is stale")?;
624 if owner.tier != HostTier::Plugin {
625 return Err("MCP definition owner must be Native".into());
626 }
627 let content_hash = owner.content_hash.clone();
628 let owned = self
629 .mcp_servers
630 .values()
631 .filter(|r| r.owner == params.owner)
632 .collect::<Vec<_>>();
633 if owned.len() >= super::native_mcp::MAX_PER_OWNER
634 || self.mcp_servers.len() >= super::native_mcp::MAX_PER_HOST
635 {
636 return Err("MCP owner or host definition limit reached".into());
637 }
638 if owned
639 .iter()
640 .any(|r| r.name == params.spec.name && r.scope == scope)
641 {
642 return Err("MCP server is already registered in this entry".into());
643 }
644 self.next_handle += 1;
645 let handle = self.next_handle;
646 self.mcp_servers.insert(
647 handle,
648 super::native_mcp::McpRegistration {
649 handle,
650 owner: params.owner.clone(),
651 scope,
652 content_hash,
653 host_generation,
654 name: params.spec.name.clone(),
655 config,
656 cancel: tokio_util::sync::CancellationToken::new(),
657 },
658 );
659 super::native_mcp::changed();
660 Ok(handle)
661 }
662 pub(super) fn live_mcp(&self) -> Vec<super::native_mcp::McpRegistration> {
663 self.mcp_servers
664 .values()
665 .filter(|r| self.is_live_mcp(r))
666 .cloned()
667 .collect()
668 }
669 pub(super) fn is_live_mcp(&self, r: &super::native_mcp::McpRegistration) -> bool {
670 self.mcp_servers.get(&r.handle).is_some_and(|current| {
671 current.owner == r.owner
672 && current.scope == r.scope
673 && current.host_generation == r.host_generation
674 && current.content_hash == r.content_hash
675 }) && self.owners.get(&r.owner.plugin_id).is_some_and(|owner| {
676 owner.owner == r.owner
677 && owner.state == OwnerState::Active
678 && self.check_scope(&r.owner, Some(&r.scope), true).is_ok()
679 })
680 }
681
682 pub(crate) fn register_skill_root(
683 &mut self,
684 params: &RegisterParams,
685 snapshots: Vec<crate::plugins::types::PluginSkillSnapshot>,
686 host_generation: u64,
687 ) -> Result<u64, String> {
688 self.check_scope(&params.owner, params.scope.as_ref(), false)?;
689 use super::skills::*;
690 params.check_spec()?;
691 root_path(&params.spec.name)?;
692 if params.kind != RegisterKind::SkillRoot || !params.spec.description.is_empty() {
693 return Err("invalid skill root spec".to_string());
694 }
695 let entry = self
696 .current(&params.owner)
697 .ok_or_else(|| "stale or unknown skill owner".to_string())?;
698 if entry.tier != HostTier::Plugin {
699 return Err("skill root owner has no reviewed bundle".to_string());
700 }
701 let content_hash = entry.content_hash.clone();
702 let owned: Vec<_> = self
703 .skill_roots
704 .values()
705 .filter(|root| root.owner == params.owner)
706 .collect();
707 let bytes = snapshots.iter().map(snapshot_bytes).sum::<usize>();
708 if snapshots.is_empty()
709 || owned
710 .iter()
711 .any(|root| root.path == params.spec.name && root.scope == params.scope)
712 {
713 return Err(
714 "skill root is empty or already registered; dispose it before registering it again"
715 .to_string(),
716 );
717 }
718 let names: HashSet<_> = owned
719 .iter()
720 .filter(|root| root.scope == params.scope)
721 .flat_map(|root| root.snapshots.iter().map(|skill| &skill.name))
722 .collect();
723 if snapshots.iter().any(|skill| names.contains(&skill.name)) {
724 return Err("skill name is duplicated across this owner's roots".to_string());
725 }
726 if owned.len() >= MAX_ROOTS_PER_OWNER
727 || self.skill_roots.len() >= MAX_ROOTS_PER_HOST
728 || owned.iter().map(|root| root.snapshots.len()).sum::<usize>() + snapshots.len()
729 > MAX_SKILLS_PER_OWNER
730 || self
731 .skill_roots
732 .values()
733 .map(|root| root.snapshots.len())
734 .sum::<usize>()
735 + snapshots.len()
736 > MAX_SKILLS_PER_HOST
737 || owned.iter().map(|root| root.bytes).sum::<usize>() + bytes > MAX_BYTES_PER_OWNER
738 || self
739 .skill_roots
740 .values()
741 .map(|root| root.bytes)
742 .sum::<usize>()
743 + bytes
744 > MAX_BYTES_PER_HOST
745 {
746 return Err(
747 "skill root owner or host count/instruction-byte limit reached".to_string(),
748 );
749 }
750 self.next_handle += 1;
751 let handle = self.next_handle;
752 self.skill_roots.insert(
753 handle,
754 SkillRootRegistration {
755 handle,
756 owner: params.owner.clone(),
757 scope: params.scope.clone(),
758 host_generation,
759 content_hash,
760 path: params.spec.name.clone(),
761 snapshots,
762 bytes,
763 },
764 );
765 super::command::bump_epoch();
766 Ok(handle)
767 }
768
769 pub(crate) fn live_skill_roots(&self) -> Vec<super::skills::SkillRootRegistration> {
770 self.skill_roots
771 .values()
772 .filter(|root| {
773 self.owners.get(&root.owner.plugin_id).is_some_and(|entry| {
774 entry.owner == root.owner
775 && entry.state == OwnerState::Active
776 && self
777 .check_scope(&root.owner, root.scope.as_ref(), true)
778 .is_ok()
779 })
780 })
781 .cloned()
782 .collect()
783 }
784
785 pub(crate) fn is_live_skill_root(
786 &self,
787 handle: u64,
788 plugin_id: &str,
789 generation: u64,
790 host_generation: u64,
791 content_hash: &str,
792 state_generation: u64,
793 ) -> bool {
794 self.skill_roots.get(&handle).is_some_and(|root| {
795 root.owner.plugin_id == plugin_id
796 && root.owner.generation == generation
797 && root.host_generation == host_generation
798 && root.content_hash == content_hash
799 && self
800 .check_scope(&root.owner, root.scope.as_ref(), true)
801 .is_ok()
802 && self.owners.get(plugin_id).is_some_and(|entry| {
803 entry.owner == root.owner
804 && entry.state == OwnerState::Active
805 && entry
806 .authority
807 .as_ref()
808 .is_some_and(|authority| authority.state_generation == state_generation)
809 })
810 })
811 }
812
813 pub(crate) fn register_prompt_section(
814 &mut self,
815 params: &RegisterParams,
816 ) -> Result<u64, String> {
817 self.check_scope(&params.owner, params.scope.as_ref(), false)?;
818 use super::prompt::{
819 MAX_PROMPT_HOST_BYTES, MAX_PROMPT_OWNER_BYTES, MAX_PROMPT_SECTION_BYTES,
820 MAX_PROMPT_SECTIONS_PER_HOST, MAX_PROMPT_SECTIONS_PER_OWNER,
821 };
822 params.check_spec()?;
823 if !matches!(
824 params.kind,
825 RegisterKind::PromptSection | RegisterKind::PromptTemplate
826 ) {
827 return Err("prompt registration has an invalid kind".to_string());
828 }
829 let id = &params.spec.name;
830 let text = &params.spec.description;
831 if params.kind == RegisterKind::PromptTemplate {
832 super::prompt::validate_prompt_template(text)?;
833 }
834 if !valid_command_name(id)
835 || id.len() > 64
836 || text.trim().is_empty()
837 || text.len() > MAX_PROMPT_SECTION_BYTES
838 || text
839 .chars()
840 .any(|c| c.is_control() && !matches!(c, '\n' | '\r' | '\t'))
841 {
842 return Err("prompt section needs a valid short id and bounded non-empty text without control characters".to_string());
843 }
844 let entry = self
845 .current(&params.owner)
846 .ok_or_else(|| "stale or unknown owner".to_string())?;
847 let owned: Vec<_> = self
848 .prompt_sections
849 .values()
850 .filter(|section| section.owner == params.owner)
851 .collect();
852 if owned
853 .iter()
854 .any(|section| &section.id == id && section.scope == params.scope)
855 {
856 return Err(
857 "prompt section id is already registered; dispose it before registering it again"
858 .to_string(),
859 );
860 }
861 if owned.len() >= MAX_PROMPT_SECTIONS_PER_OWNER
862 || self.prompt_sections.len() >= MAX_PROMPT_SECTIONS_PER_HOST
863 || owned
864 .iter()
865 .map(|section| section.text.len())
866 .sum::<usize>()
867 + text.len()
868 > MAX_PROMPT_OWNER_BYTES
869 || self
870 .prompt_sections
871 .values()
872 .map(|section| section.text.len())
873 .sum::<usize>()
874 + text.len()
875 > MAX_PROMPT_HOST_BYTES
876 {
877 return Err("prompt section owner or host byte/registration limit reached".to_string());
878 }
879 let section = PromptSectionRegistration {
880 handle: self.next_handle + 1,
881 owner: params.owner.clone(),
882 scope: params.scope.clone(),
883 tier: entry.tier,
884 plugin_name: entry.plugin_name.clone(),
885 content_hash: entry.content_hash.clone(),
886 id: id.clone(),
887 text: text.clone(),
888 interpolate: params.kind == RegisterKind::PromptTemplate,
889 };
890 self.next_handle += 1;
891 self.prompt_sections.insert(section.handle, section);
892 Ok(self.next_handle)
893 }
894
895 fn register_shell_hook(&mut self, params: &RegisterParams) -> Result<u64, String> {
896 self.check_scope(&params.owner, params.scope.as_ref(), false)?;
897 params.check_spec()?;
898 let entry = self
899 .current(&params.owner)
900 .ok_or("shell hook owner is stale")?;
901 if entry.tier != HostTier::Plugin {
902 return Err("shell hooks require reviewed Native code".into());
903 }
904 if params.spec.description.len() > 64 * 1024 || params.spec.name.len() > 256 {
905 return Err("shell hook definition is oversized".into());
906 }
907 let mut spec: ShellSpec = serde_json::from_str(&params.spec.description)
908 .map_err(|_| "invalid shell hook definition")?;
909 let expected = match spec.point.as_str() {
910 "SessionStart" => crate::hooks::HookEvent::SessionStart,
911 "UserPromptSubmit" => crate::hooks::HookEvent::MessageSubmit,
912 "PreToolUse" => crate::hooks::HookEvent::ToolCallBefore,
913 "PostToolUse" => crate::hooks::HookEvent::ToolCallAfter,
914 "Stop" => crate::hooks::HookEvent::TurnEnd,
915 "SubagentStart" => crate::hooks::HookEvent::SubagentSpawn,
916 "SubagentStop" => crate::hooks::HookEvent::SubagentComplete,
917 _ => return Err("unsupported dialect hook point".into()),
918 };
919 if !matches!(spec.dialect.as_str(), "claude-code" | "codex")
920 || spec.hook.event != expected
921 || (spec.dialect == "codex"
922 && matches!(spec.point.as_str(), "SubagentStart" | "SubagentStop"))
923 {
924 return Err("dialect hook point does not match its core event".into());
925 }
926 if spec.matcher.as_ref().is_some_and(|m| m.len() > 1024)
927 || spec.hook.command.len() > 32 * 1024
928 || spec.hook.timeout_secs == 0
929 || spec.hook.timeout_secs > 86_400
930 || spec.hook.background
931 {
932 return Err("shell hook command/matcher/timeout is not bounded".into());
933 }
934 if self.shell_hooks.len() >= 1024
935 || self
936 .shell_hooks
937 .values()
938 .filter(|h| h.owner == params.owner)
939 .count()
940 >= 128
941 {
942 return Err("shell hook registration cap reached".into());
943 }
944 let authority = entry
945 .authority
946 .clone()
947 .ok_or("Native shell hook authority is absent")?;
948 let content_hash = entry.content_hash.clone();
949 let handle = self.next_handle + 1;
950 spec.hook.native_shell = Some(crate::hooks::config::NativeShellHook {
951 owner: params.owner.clone(),
952 scope: params.scope.clone(),
953 handle,
954 dialect: spec.dialect,
955 point: spec.point,
956 matcher: spec.matcher,
957 });
958 spec.hook.plugin_authority = Some(authority);
959 spec.hook.project_authority = None;
960 spec.hook.continue_on_error = false;
961 self.next_handle = handle;
962 self.shell_hooks.insert(
963 handle,
964 ShellHookRegistration {
965 handle,
966 owner: params.owner.clone(),
967 scope: params.scope.clone(),
968 content_hash,
969 hook: spec.hook,
970 },
971 );
972 Ok(handle)
973 }
974 pub(crate) fn live_shell_hooks(&self) -> Vec<ShellHookRegistration> {
975 self.shell_hooks
976 .values()
977 .filter(|h| {
978 self.check_shell_hook(h.hook.native_shell.as_ref().expect("Native reference"))
979 .is_ok()
980 })
981 .cloned()
982 .collect()
983 }
984 pub(crate) fn check_shell_hook(
985 &self,
986 native: &crate::hooks::config::NativeShellHook,
987 ) -> Result<(), String> {
988 self.check_scope(&native.owner, native.scope.as_ref(), true)?;
989 let owner = self
990 .current(&native.owner)
991 .filter(|o| o.state == OwnerState::Active)
992 .ok_or("Native shell hook owner is not active")?;
993 self.shell_hooks
994 .get(&native.handle)
995 .filter(|h| {
996 h.owner == native.owner
997 && h.scope == native.scope
998 && h.content_hash == owner.content_hash
999 })
1000 .ok_or("Native shell hook was withdrawn")?;
1001 Ok(())
1002 }
1003
1004 fn register_hook(&mut self, params: &RegisterParams) -> Result<u64, String> {
1005 self.check_scope(&params.owner, params.scope.as_ref(), false)?;
1006 params.check_spec()?;
1007 if params.spec.name != "tools/pre-execute" {
1008 return Err("only `tools/pre-execute` admission listeners are supported".to_string());
1009 }
1010 let entry = self
1011 .current(&params.owner)
1012 .ok_or_else(|| "stale or unknown owner".to_string())?;
1013 if self.hooks.len() >= 1024
1014 || self
1015 .hooks
1016 .values()
1017 .filter(|hook| hook.owner == params.owner)
1018 .count()
1019 >= 128
1020 {
1021 return Err("extension hook registration limit reached".to_string());
1022 }
1023 let registration = HookRegistration {
1024 handle: self.next_handle + 1,
1025 owner: params.owner.clone(),
1026 scope: params.scope.clone(),
1027 tier: entry.tier,
1028 plugin_name: entry.plugin_name.clone(),
1029 content_hash: entry.content_hash.clone(),
1030 event: params.spec.name.clone(),
1031 };
1032 self.next_handle += 1;
1033 self.hooks.insert(registration.handle, registration);
1034 Ok(self.next_handle)
1035 }
1036
1037 /// Admit or refuse one command registration. An extension command never
1038 /// shadows a built-in command or another plugin's command; a clash with
1039 /// a user, workspace or manifest (markdown) command is resolved when the
1040 /// user registry loads (the markdown command wins and the extension
1041 /// command is not loaded), because only that registry knows the workspace.
1042 pub fn register_command(&mut self, params: &RegisterParams) -> Result<u64, String> {
1043 self.check_scope(&params.owner, params.scope.as_ref(), false)?;
1044 let entry = self
1045 .current(&params.owner)
1046 .ok_or_else(|| "stale or unknown owner".to_string())?;
1047 let plugin_name = entry.plugin_name.clone();
1048 let content_hash = entry.content_hash.clone();
1049 let tier = entry.tier;
1050 let spec = &params.spec;
1051 let name = spec.name.as_str();
1052 const COMMAND_HINT: &str = "a command name is lower case, starts with a letter, and uses only a-z, 0-9, `_` and `-` (at most 64 characters)";
1053 if !valid_command_name(name) {
1054 return Err(format!(
1055 "command name `{}` is invalid: {COMMAND_HINT}",
1056 crate::safe_label::SafeLabel::identifier(name)
1057 ));
1058 }
1059 // Fail closed: with no catalog installed there is nothing to check the
1060 // name against, so the registration is refused, not accepted unchecked.
1061 let Some(catalog) = super::command::builtin_commands() else {
1062 return Err(format!(
1063 "command `/{name}` cannot be checked against the built-in commands: no built-in command catalog is installed, so extension commands are refused"
1064 ));
1065 };
1066 if catalog.answers_to(name) {
1067 return Err(format!(
1068 "command `/{name}` collides with a built-in command; extensions never shadow core commands; use a plugin-specific name, for example `/myplugin-{name}`"
1069 ));
1070 }
1071 if spec.input_schema.is_some() {
1072 return Err(format!("command `/{name}` has no input schema"));
1073 }
1074 let description = spec.description.trim();
1075 if description.is_empty() {
1076 return Err(format!("command `/{name}` needs a description"));
1077 }
1078 if description.len() > MAX_COMMAND_DESCRIPTION_BYTES {
1079 return Err(format!(
1080 "command `/{name}` description exceeds {MAX_COMMAND_DESCRIPTION_BYTES} bytes"
1081 ));
1082 }
1083 let hint = spec.argument_hint.as_deref().map(str::trim);
1084 if hint.is_some_and(str::is_empty) {
1085 return Err(format!("command `/{name}` argument hint must not be empty"));
1086 }
1087 if hint.is_some_and(|hint| hint.len() > MAX_COMMAND_HINT_BYTES) {
1088 return Err(format!(
1089 "command `/{name}` argument hint exceeds {MAX_COMMAND_HINT_BYTES} bytes"
1090 ));
1091 }
1092 // The palette, `/help` and the composer print these verbatim.
1093 if description.chars().any(char::is_control)
1094 || hint.is_some_and(|h| h.chars().any(char::is_control))
1095 {
1096 return Err(format!(
1097 "command `/{name}` description and argument hint must be single-line text without control characters"
1098 ));
1099 }
1100 let mut replaced = None;
1101 if let Some(existing) = self
1102 .commands_by_name
1103 .get(&super::composition_scope::name_key(
1104 &params.owner.plugin_id,
1105 name,
1106 params.scope.as_ref(),
1107 ))
1108 .and_then(|handle| self.commands.get(handle))
1109 {
1110 if existing.owner.plugin_id != params.owner.plugin_id {
1111 return Err(format!(
1112 "command `/{name}` is already registered by extension `{}`; use a plugin-specific name",
1113 existing.plugin_name
1114 ));
1115 }
1116 // Same owner re-registering a name: the new handle retires the old.
1117 replaced = Some(existing.handle);
1118 }
1119 let owned = self
1120 .commands
1121 .values()
1122 .filter(|command| command.owner.plugin_id == params.owner.plugin_id)
1123 .count()
1124 - usize::from(replaced.is_some());
1125 if owned >= MAX_COMMANDS_PER_OWNER {
1126 return Err(format!(
1127 "an extension may register at most {MAX_COMMANDS_PER_OWNER} commands"
1128 ));
1129 }
1130 if self.commands.len() - usize::from(replaced.is_some()) >= MAX_COMMANDS_PER_HOST {
1131 return Err(format!(
1132 "the extension host holds at most {MAX_COMMANDS_PER_HOST} commands"
1133 ));
1134 }
1135 if let Some(old) = replaced {
1136 self.commands.remove(&old);
1137 }
1138 self.next_handle += 1;
1139 let handle = self.next_handle;
1140 self.commands.insert(
1141 handle,
1142 CommandRegistration {
1143 handle,
1144 owner: params.owner.clone(),
1145 scope: params.scope.clone(),
1146 tier,
1147 plugin_name,
1148 content_hash,
1149 name: name.to_string(),
1150 description: description.to_string(),
1151 argument_hint: hint.map(str::to_string),
1152 },
1153 );
1154 self.commands_by_name.insert(
1155 super::composition_scope::name_key(
1156 &params.owner.plugin_id,
1157 name,
1158 params.scope.as_ref(),
1159 ),
1160 handle,
1161 );
1162 super::command::bump_epoch();
1163 Ok(handle)
1164 }
1165
1166 /// Admit or refuse one tool registration.
1167 pub fn register_tool(&mut self, params: &RegisterParams) -> Result<u64, String> {
1168 self.check_scope(&params.owner, params.scope.as_ref(), false)?;
1169 let entry = self
1170 .current(&params.owner)
1171 .ok_or_else(|| "stale or unknown owner".to_string())?;
1172 let plugin_name = entry.plugin_name.clone();
1173 let content_hash = entry.content_hash.clone();
1174 let tier = entry.tier;
1175 let spec = &params.spec;
1176 let name = spec.name.as_str();
1177 if !valid_tool_name(name) {
1178 return Err(format!(
1179 "tool name `{}` must match ^[A-Za-z][A-Za-z0-9_-]{{0,63}}$; {NAME_HINT}",
1180 crate::safe_label::SafeLabel::identifier(name)
1181 ));
1182 }
1183 let plain_key = name.to_ascii_lowercase();
1184 let key = super::composition_scope::name_key(
1185 &params.owner.plugin_id,
1186 &plain_key,
1187 params.scope.as_ref(),
1188 );
1189 if RESERVED_PREFIXES
1190 .iter()
1191 .any(|prefix| plain_key.starts_with(prefix))
1192 {
1193 return Err(format!(
1194 "tool name `{name}` uses a reserved prefix; {NAME_HINT}"
1195 ));
1196 }
1197 if self.native_names.contains(&plain_key) {
1198 return Err(format!(
1199 "tool name `{name}` collides with a built-in tool; extensions never shadow core tools; {NAME_HINT}"
1200 ));
1201 }
1202 if let Some(reason) = core_special_case(name) {
1203 return Err(format!(
1204 "tool name `{name}` is reserved: {reason}; extension tools never borrow a built-in's approval identity; {NAME_HINT}"
1205 ));
1206 }
1207 if spec.description.len() > MAX_DESCRIPTION_BYTES {
1208 return Err(format!(
1209 "tool `{name}` description exceeds {MAX_DESCRIPTION_BYTES} bytes"
1210 ));
1211 }
1212 let schema = Value::Object(
1213 spec.input_schema
1214 .clone()
1215 .ok_or_else(|| format!("tool `{name}` needs an input schema"))?,
1216 );
1217 let schema_bytes = serde_json::to_vec(&schema)
1218 .map(|bytes| bytes.len())
1219 .unwrap_or(usize::MAX);
1220 if schema_bytes > MAX_SCHEMA_BYTES {
1221 return Err(format!(
1222 "tool `{name}` input schema exceeds {MAX_SCHEMA_BYTES} bytes"
1223 ));
1224 }
1225 if schema.get("type").and_then(Value::as_str) != Some("object") {
1226 return Err(format!(
1227 "tool `{name}` input schema must be a JSON object schema (`\"type\": \"object\"`)"
1228 ));
1229 }
1230 let input_validator = InputValidator::compile(&schema).map_err(|reason| {
1231 format!("tool `{name}` input schema is not a valid JSON Schema: {reason}")
1232 })?;
1233 let mut replaced = None;
1234 if let Some(existing) = self
1235 .by_name
1236 .get(&key)
1237 .and_then(|handle| self.tools.get(handle))
1238 {
1239 if existing.owner.plugin_id != params.owner.plugin_id {
1240 return Err(format!(
1241 "tool name `{name}` is already registered by extension `{}`; {NAME_HINT}",
1242 existing.plugin_name
1243 ));
1244 }
1245 // Same owner re-registering a name: the new handle retires the old.
1246 replaced = Some(existing.handle);
1247 }
1248 let owned = self
1249 .tools
1250 .values()
1251 .filter(|tool| tool.owner.plugin_id == params.owner.plugin_id)
1252 .count()
1253 - usize::from(replaced.is_some());
1254 if owned >= MAX_TOOLS_PER_OWNER {
1255 return Err(format!(
1256 "an extension may register at most {MAX_TOOLS_PER_OWNER} tools"
1257 ));
1258 }
1259 if self.tools.len() - usize::from(replaced.is_some()) >= MAX_TOOLS_PER_HOST {
1260 return Err(format!(
1261 "the extension host holds at most {MAX_TOOLS_PER_HOST} tools"
1262 ));
1263 }
1264 if let Some(old) = replaced {
1265 self.tools.remove(&old);
1266 }
1267 self.next_handle += 1;
1268 let handle = self.next_handle;
1269 self.tools.insert(
1270 handle,
1271 ToolRegistration {
1272 handle,
1273 owner: params.owner.clone(),
1274 scope: params.scope.clone(),
1275 tier,
1276 plugin_name,
1277 content_hash,
1278 name: name.to_string(),
1279 description: spec.description.clone(),
1280 input_schema: schema,
1281 input_validator,
1282 },
1283 );
1284 self.by_name.insert(key, handle);
1285 Ok(handle)
1286 }
1287
1288 /// Undo exactly one registration. Idempotent; a stale or foreign handle is a no-op.
1289 pub fn unregister(&mut self, owner: &OwnerRef, handle: u64) {
1290 if self
1291 .mcp_servers
1292 .get(&handle)
1293 .is_some_and(|r| r.owner == *owner)
1294 {
1295 if let Some(r) = self.mcp_servers.remove(&handle) {
1296 r.cancel.cancel();
1297 }
1298 super::native_mcp::changed();
1299 return;
1300 }
1301
1302 if self
1303 .shell_hooks
1304 .get(&handle)
1305 .is_some_and(|h| h.owner == *owner)
1306 {
1307 self.shell_hooks.remove(&handle);
1308 return;
1309 }
1310 if self
1311 .skill_roots
1312 .get(&handle)
1313 .is_some_and(|root| root.owner == *owner)
1314 {
1315 self.skill_roots.remove(&handle);
1316 super::command::bump_epoch();
1317 return;
1318 }
1319 if self
1320 .prompt_sections
1321 .get(&handle)
1322 .is_some_and(|section| section.owner == *owner)
1323 {
1324 self.prompt_sections.remove(&handle);
1325 return;
1326 }
1327 if self
1328 .hooks
1329 .get(&handle)
1330 .is_some_and(|hook| hook.owner == *owner)
1331 {
1332 self.hooks.remove(&handle);
1333 return;
1334 }
1335 if self
1336 .commands
1337 .get(&handle)
1338 .is_some_and(|command| command.owner == *owner)
1339 && let Some(command) = self.commands.remove(&handle)
1340 {
1341 if self
1342 .commands_by_name
1343 .get(&super::composition_scope::name_key(
1344 &command.owner.plugin_id,
1345 &command.name,
1346 command.scope.as_ref(),
1347 ))
1348 == Some(&handle)
1349 {
1350 self.commands_by_name
1351 .remove(&super::composition_scope::name_key(
1352 &command.owner.plugin_id,
1353 &command.name,
1354 command.scope.as_ref(),
1355 ));
1356 }
1357 super::command::bump_epoch();
1358 return;
1359 }
1360 let owned = self
1361 .tools
1362 .get(&handle)
1363 .is_some_and(|tool| tool.owner == *owner);
1364 if !owned {
1365 return;
1366 }
1367 if let Some(tool) = self.tools.remove(&handle) {
1368 let key = super::composition_scope::name_key(
1369 &tool.owner.plugin_id,
1370 &tool.name.to_ascii_lowercase(),
1371 tool.scope.as_ref(),
1372 );
1373 if self.by_name.get(&key) == Some(&handle) {
1374 self.by_name.remove(&key);
1375 }
1376 }
1377 }
1378
1379 fn remove_commands_of(&mut self, plugin_id: &str) {
1380 // Called by `remove_registrations_of`, so every revocation path that
1381 // drops an owner's tools drops its commands too.
1382 let handles: Vec<u64> = self
1383 .commands
1384 .values()
1385 .filter(|command| command.owner.plugin_id == plugin_id)
1386 .map(|command| command.handle)
1387 .collect();
1388 for handle in handles {
1389 if let Some(command) = self.commands.remove(&handle)
1390 && self
1391 .commands_by_name
1392 .get(&super::composition_scope::name_key(
1393 &command.owner.plugin_id,
1394 &command.name,
1395 command.scope.as_ref(),
1396 ))
1397 == Some(&handle)
1398 {
1399 self.commands_by_name
1400 .remove(&super::composition_scope::name_key(
1401 &command.owner.plugin_id,
1402 &command.name,
1403 command.scope.as_ref(),
1404 ));
1405 }
1406 }
1407 super::command::bump_epoch();
1408 }
1409
1410 fn remove_registrations_of(&mut self, plugin_id: &str) -> Vec<u64> {
1411 let count = self.mcp_servers.len();
1412 self.mcp_servers.retain(|_, r| {
1413 if r.owner.plugin_id == plugin_id {
1414 r.cancel.cancel();
1415 false
1416 } else {
1417 true
1418 }
1419 });
1420 if count != self.mcp_servers.len() {
1421 super::native_mcp::changed()
1422 }
1423
1424 self.shell_hooks
1425 .retain(|_, h| h.owner.plugin_id != plugin_id);
1426 self.skill_roots
1427 .retain(|_, root| root.owner.plugin_id != plugin_id);
1428 self.prompt_sections
1429 .retain(|_, section| section.owner.plugin_id != plugin_id);
1430 self.hooks
1431 .retain(|_, hook| hook.owner.plugin_id != plugin_id);
1432 self.remove_commands_of(plugin_id);
1433 let handles: Vec<u64> = self
1434 .tools
1435 .values()
1436 .filter(|tool| tool.owner.plugin_id == plugin_id)
1437 .map(|tool| tool.handle)
1438 .collect();
1439 for handle in &handles {
1440 if let Some(tool) = self.tools.remove(handle) {
1441 let key = super::composition_scope::name_key(
1442 &tool.owner.plugin_id,
1443 &tool.name.to_ascii_lowercase(),
1444 tool.scope.as_ref(),
1445 );
1446 if self.by_name.get(&key) == Some(handle) {
1447 self.by_name.remove(&key);
1448 }
1449 }
1450 }
1451 handles
1452 }
1453
1454 /// Revoke an owner synchronously. Returns the owner that was live, if any.
1455 pub fn revoke_owner(&mut self, plugin_id: &str) -> Option<OwnerRef> {
1456 self.remove_registrations_of(plugin_id);
1457 let entry = self.owners.get_mut(plugin_id)?;
1458 let was_live = matches!(entry.state, OwnerState::Activating | OwnerState::Active);
1459 entry.state = OwnerState::Revoked;
1460 was_live.then(|| entry.owner.clone())
1461 }
1462
1463 /// Forget an owner entirely (after revocation, when its plugin is gone).
1464 pub fn forget_owner(&mut self, plugin_id: &str) {
1465 self.remove_registrations_of(plugin_id);
1466 self.owners.remove(plugin_id);
1467 }
1468
1469 /// Forget owners that are not live (failed, faulted, revoked) so a new
1470 /// explicit plugin mutation retries them.
1471 pub fn forget_inactive(&mut self) {
1472 for owner in self.owners.values_mut() {
1473 owner
1474 .scopes
1475 .retain(|_, state| matches!(state, OwnerState::Activating | OwnerState::Active));
1476 }
1477 self.owners
1478 .retain(|_, entry| matches!(entry.state, OwnerState::Activating | OwnerState::Active));
1479 }
1480
1481 /// Drop every registration owned by `tier`'s host: the host that held
1482 /// them is gone, and the other tier's host is not.
1483 fn clear_tier_registrations(&mut self, tier: HostTier) {
1484 if tier == HostTier::Plugin && !self.mcp_servers.is_empty() {
1485 for r in self.mcp_servers.values() {
1486 r.cancel.cancel();
1487 }
1488 self.mcp_servers.clear();
1489 super::native_mcp::changed()
1490 }
1491
1492 if tier == HostTier::Plugin {
1493 self.skill_roots.clear();
1494 }
1495 self.prompt_sections
1496 .retain(|_, section| section.tier != tier);
1497 if tier == HostTier::Plugin {
1498 self.shell_hooks.clear();
1499 }
1500 self.hooks.retain(|_, hook| hook.tier != tier);
1501 self.tools.retain(|_, tool| tool.tier != tier);
1502 let tools = &self.tools;
1503 self.by_name.retain(|_, handle| tools.contains_key(handle));
1504 self.commands.retain(|_, command| command.tier != tier);
1505 let commands = &self.commands;
1506 self.commands_by_name
1507 .retain(|_, handle| commands.contains_key(handle));
1508 super::command::bump_epoch();
1509 }
1510
1511 /// `tier`'s host exited: its crash drops its live registrations, preserves
1512 /// its failed/faulted receipts, and blames its sole activating owner.
1513 /// Other owners of that tier are replayable only after reconciliation
1514 /// verifies their current persisted authority again. The other tier's
1515 /// owners are not touched: they live in another process.
1516 pub fn host_exited(&mut self, tier: HostTier, reason: &str) {
1517 self.clear_tier_registrations(tier);
1518 let activating: Vec<_> = self
1519 .owners
1520 .values()
1521 .filter(|entry| entry.tier == tier && entry.state == OwnerState::Activating)
1522 .map(|entry| entry.owner.plugin_id.clone())
1523 .collect();
1524 if let [plugin] = activating.as_slice() {
1525 self.owners.get_mut(plugin).expect("activating owner").state =
1526 OwnerState::Failed(format!("host crashed during activation: {reason}"));
1527 }
1528 self.owners.retain(|_, entry| {
1529 entry.tier != tier
1530 || matches!(entry.state, OwnerState::Failed(_) | OwnerState::Faulted(_))
1531 });
1532 }
1533
1534 /// Planned test shutdown drops `tier`'s tools and fails its remaining live owners.
1535 #[cfg(test)]
1536 pub fn revoke_all(&mut self, tier: HostTier, reason: &str) {
1537 self.clear_tier_registrations(tier);
1538 for entry in self.owners.values_mut() {
1539 if entry.tier == tier
1540 && matches!(entry.state, OwnerState::Activating | OwnerState::Active)
1541 {
1542 entry.state = OwnerState::Failed(reason.to_string());
1543 }
1544 }
1545 }
1546
1547 /// Tools of active owners, in handle order.
1548 #[must_use]
1549 pub fn live_tools(&self) -> Vec<ToolRegistration> {
1550 self.tools
1551 .values()
1552 .filter(|tool| {
1553 self.owners.get(&tool.owner.plugin_id).is_some_and(|entry| {
1554 entry.owner == tool.owner
1555 && entry.state == OwnerState::Active
1556 && self
1557 .check_scope(&tool.owner, tool.scope.as_ref(), true)
1558 .is_ok()
1559 })
1560 })
1561 .cloned()
1562 .collect()
1563 }
1564
1565 /// Hooks of active owners, in registration order. Multiple listeners for
1566 /// the same event coexist; withdrawing one never removes another.
1567 pub fn live_hooks(&self) -> Vec<HookRegistration> {
1568 self.hooks
1569 .values()
1570 .filter(|hook| self.is_live_hook(hook.handle, &hook.owner))
1571 .cloned()
1572 .collect()
1573 }
1574
1575 pub fn live_prompt_sections(&self) -> Vec<PromptSectionRegistration> {
1576 let mut sections: Vec<_> = self
1577 .prompt_sections
1578 .values()
1579 .filter(|section| self.is_live_prompt_section(section.handle, &section.owner))
1580 .cloned()
1581 .collect();
1582 sections.sort_by(|a, b| (&a.owner.plugin_id, &a.id).cmp(&(&b.owner.plugin_id, &b.id)));
1583 sections
1584 }
1585
1586 pub fn is_live_prompt_section(&self, handle: u64, owner: &OwnerRef) -> bool {
1587 self.prompt_sections.get(&handle).is_some_and(|section| {
1588 section.owner == *owner
1589 && self
1590 .check_scope(owner, section.scope.as_ref(), true)
1591 .is_ok()
1592 }) && self
1593 .owners
1594 .get(&owner.plugin_id)
1595 .is_some_and(|entry| entry.owner == *owner && entry.state == OwnerState::Active)
1596 }
1597
1598 pub fn is_live_hook(&self, handle: u64, owner: &OwnerRef) -> bool {
1599 self.hooks.get(&handle).is_some_and(|hook| {
1600 hook.owner == *owner && self.check_scope(owner, hook.scope.as_ref(), true).is_ok()
1601 }) && self
1602 .owners
1603 .get(&owner.plugin_id)
1604 .is_some_and(|entry| entry.owner == *owner && entry.state == OwnerState::Active)
1605 }
1606
1607 /// Commands of active owners, in handle order.
1608 #[must_use]
1609 pub fn live_commands(&self) -> Vec<CommandRegistration> {
1610 self.commands
1611 .values()
1612 .filter(|command| {
1613 self.owners
1614 .get(&command.owner.plugin_id)
1615 .is_some_and(|entry| {
1616 entry.owner == command.owner
1617 && entry.state == OwnerState::Active
1618 && self
1619 .check_scope(&command.owner, command.scope.as_ref(), true)
1620 .is_ok()
1621 })
1622 })
1623 .cloned()
1624 .collect()
1625 }
1626
1627 /// The command behind `handle`, if it is still admitted for exactly this
1628 /// owner generation of `plugin_id`. A stale reference finds nothing.
1629 #[must_use]
1630 pub fn live_command(
1631 &self,
1632 handle: u64,
1633 plugin_id: &str,
1634 generation: u64,
1635 ) -> Option<CommandRegistration> {
1636 let command = self.commands.get(&handle)?;
1637 (command.owner.plugin_id == plugin_id
1638 && command.owner.generation == generation
1639 && self.owners.get(plugin_id).is_some_and(|entry| {
1640 entry.owner == command.owner
1641 && entry.state == OwnerState::Active
1642 && self
1643 .check_scope(&command.owner, command.scope.as_ref(), true)
1644 .is_ok()
1645 }))
1646 .then(|| command.clone())
1647 }
1648
1649 /// Whether `handle` is still admitted for exactly this owner generation.
1650 #[must_use]
1651 pub fn is_live(&self, handle: u64, owner: &OwnerRef) -> bool {
1652 self.tools.get(&handle).is_some_and(|tool| {
1653 tool.owner == *owner && self.check_scope(owner, tool.scope.as_ref(), true).is_ok()
1654 }) && self
1655 .owners
1656 .get(&owner.plugin_id)
1657 .is_some_and(|entry| entry.owner == *owner && entry.state == OwnerState::Active)
1658 }
1659
1660 /// Active owners other than `plugin_id` sharing its host process (the
1661 /// other owners of its tier).
1662 #[must_use]
1663 pub fn other_active_owners(&self, plugin_id: &str) -> usize {
1664 let tier = HostTier::of_owner_id(plugin_id);
1665 self.owners
1666 .values()
1667 .filter(|entry| {
1668 entry.tier == tier
1669 && entry.owner.plugin_id != plugin_id
1670 && entry.state == OwnerState::Active
1671 })
1672 .count()
1673 }
1674
1675 /// The plugin authority of the exact current owner: `None` for a stale
1676 /// owner and for a built-in module, which has none.
1677 #[must_use]
1678 pub fn authority_for(&self, owner: &OwnerRef) -> Option<PluginAuthority> {
1679 self.current(owner)
1680 .and_then(|entry| entry.authority.clone())
1681 }
1682
1683 /// The tier of the exact current owner.
1684 #[must_use]
1685 pub fn tier_of(&self, owner: &OwnerRef) -> Option<HostTier> {
1686 self.current(owner).map(|entry| entry.tier)
1687 }
1688 }
1689
1690 #[cfg(test)]
1691 mod shell_hook_tests {
1692 use super::super::protocol::RegisterSpecWire;
1693 use super::*;
1694 fn spec(owner: &OwnerRef, scope: Option<EntryRef>) -> RegisterParams {
1695 RegisterParams {owner:owner.clone(),scope,kind:RegisterKind::ShellHook,spec:RegisterSpecWire {name:"claude:PreToolUse:1".into(),description:serde_json::json!({"dialect":"claude-code","point":"PreToolUse","matcher":"write","hook":{"event":"tool_call_before","command":"true","timeout_secs":3,"background":false,"continue_on_error":false}}).to_string(),input_schema:None,argument_hint:None}}
1696 }
1697 fn new_owner(registry: &mut OwnerRegistry, id: &str) -> OwnerRef {
1698 registry
1699 .begin_owner(
1700 HostTier::Plugin,
1701 id,
1702 id,
1703 Some(super::super::tests::fake_authority(id)),
1704 "build",
1705 )
1706 .unwrap()
1707 }
1708 #[test]
1709 fn native_shell_hooks_withdraw_exact_scope_and_host_exit_releases_budget() {
1710 let mut registry = OwnerRegistry::new();
1711 let owner = new_owner(&mut registry, "hook-a");
1712 let scope = EntryRef {
1713 path: "/reviewed/a.mjs".into(),
1714 sha256: "a".repeat(64),
1715 };
1716 registry.begin_scope(&owner, scope.clone()).unwrap();
1717 let handle = registry
1718 .register(&spec(&owner, Some(scope.clone())))
1719 .unwrap();
1720 assert!(registry.live_shell_hooks().is_empty());
1721 registry.mark_scope_active(&owner, &scope);
1722 registry.mark_active(&owner);
1723 let native = registry.live_shell_hooks()[0]
1724 .hook
1725 .native_shell
1726 .clone()
1727 .unwrap();
1728 assert_eq!(native.handle, handle);
1729 registry.host_exited(HostTier::Builtin, "builtin test exit");
1730 assert!(registry.check_shell_hook(&native).is_ok());
1731 registry.revoke_scope(&owner, &scope);
1732 assert!(registry.check_shell_hook(&native).is_err());
1733 assert!(registry.shell_hooks.is_empty());
1734 let other = new_owner(&mut registry, "hook-b");
1735 let admitted = registry.register(&spec(&other, None)).unwrap();
1736 registry.mark_active(&other);
1737 assert_eq!(registry.live_shell_hooks()[0].handle, admitted);
1738 registry.host_exited(HostTier::Plugin, "test crash");
1739 assert!(registry.shell_hooks.is_empty());
1740 }
1741 #[test]
1742 fn native_shell_registration_refuses_wrong_event_oversized_and_foreign_withdrawal() {
1743 let mut registry = OwnerRegistry::new();
1744 let a = new_owner(&mut registry, "a");
1745 let b = new_owner(&mut registry, "b");
1746 let mut wrong = spec(&a, None);
1747 wrong.spec.description = wrong
1748 .spec
1749 .description
1750 .replace("tool_call_before", "shell_env");
1751 assert!(registry.register(&wrong).is_err());
1752 let mut huge = spec(&a, None);
1753 huge.spec.description = "x".repeat(65537);
1754 assert!(registry.register(&huge).is_err());
1755 let handle = registry.register(&spec(&a, None)).unwrap();
1756 registry.mark_active(&a);
1757 registry.unregister(&b, handle);
1758 assert_eq!(registry.live_shell_hooks().len(), 1);
1759 registry.unregister(&a, handle);
1760 assert!(registry.live_shell_hooks().is_empty());
1761 }
1762 }
1763
1763 lines RUST