| 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(¶ms.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(¶ms.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(¶ms.owner, params.scope.as_ref(), false)?; |
| 689 | use super::skills::*; |
| 690 | params.check_spec()?; |
| 691 | root_path(¶ms.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(¶ms.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(¶ms.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 = ¶ms.spec.name; |
| 830 | let text = ¶ms.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(¶ms.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| §ion.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(¶ms.owner, params.scope.as_ref(), false)?; |
| 897 | params.check_spec()?; |
| 898 | let entry = self |
| 899 | .current(¶ms.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(¶ms.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(¶ms.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(¶ms.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(¶ms.owner, params.scope.as_ref(), false)?; |
| 1044 | let entry = self |
| 1045 | .current(¶ms.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 = ¶ms.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 | ¶ms.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 | ¶ms.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(¶ms.owner, params.scope.as_ref(), false)?; |
| 1169 | let entry = self |
| 1170 | .current(¶ms.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 = ¶ms.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 | ¶ms.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, §ion.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 |