| 1 | //! `HostToolSpec`: an extension tool as an ordinary registry `ToolSpec`. |
| 2 | //! |
| 3 | //! Because it is a registry tool, every existing gate applies unchanged: |
| 4 | //! plan mode, the authority envelope, deferral, hooks, approval, and code |
| 5 | //! mode (which suspends gated calls for approval and refuses ungated calls |
| 6 | //! before any host call). Four rules are specific to extension tools: |
| 7 | //! |
| 8 | //! * **A plugin's tool is always `ApprovalRequirement::Required`.** A plugin's |
| 9 | //! own read-only hint (`presentCall` `kind: 'read'`, MCP-style annotations) |
| 10 | //! is display data at most. Honouring it would let a plugin switch approval |
| 11 | //! off for a tool whose body runs arbitrary Node — self-approval. A tool of |
| 12 | //! a built-in module (tier 0, owner `host:<module>`) gets the approval the |
| 13 | //! Rust table [`super::tier::BUILTIN_MODULES`] lists for it and nothing the |
| 14 | //! module says; unlisted is `Required`. It is still never read-only for |
| 15 | //! plan mode. |
| 16 | //! * **Approval grants are receipt-bound.** Keys are |
| 17 | //! `ext:<plugin_id>@<content_hash>:<name>:<hash(input)>` for both the exact |
| 18 | //! and the session-grant key ([`ToolSpec::approval_scope`]), so an updated |
| 19 | //! plugin, or a different plugin that later takes the same tool name, never |
| 20 | //! inherits a grant. |
| 21 | //! * **Input is checked against the tool's registered JSON Schema** before |
| 22 | //! approval and again before anything is sent to the host |
| 23 | //! ([`super::registry::InputValidator`]). A schema that cannot be compiled is |
| 24 | //! refused at registration. |
| 25 | //! * **Liveness is re-checked at call time**: the plugin's reviewed receipt, |
| 26 | //! the Native adapter in this build's policy, and the exact owner |
| 27 | //! generation. A revocation mid-turn fails the call closed. |
| 28 | //! |
| 29 | //! A fifth thing is not a rule but a capability: when the turn loop serves a |
| 30 | //! permission gate for exactly this call (the model called the tool directly), |
| 31 | //! the call carries an invocation ticket and the tool may ask the core to run |
| 32 | //! core tools through `core/call` (`super::core_call`). The call's deadline |
| 33 | //! then stops while one of those waits on an approval card, and what the tool |
| 34 | //! asked the core to run is attached to its result metadata (`core_calls`). |
| 35 | |
| 36 | use std::sync::Arc; |
| 37 | use std::time::Duration; |
| 38 | |
| 39 | use async_trait::async_trait; |
| 40 | use serde_json::Value; |
| 41 | |
| 42 | use super::ManagerShared; |
| 43 | use super::core_call::InvocationGuard; |
| 44 | use super::protocol::{ContentBlockWire, CoreRequest, ToolCallParams, ToolResultWire}; |
| 45 | use super::registry::ToolRegistration; |
| 46 | use super::supervisor::HostCallError; |
| 47 | use super::tier::{self, HostTier}; |
| 48 | use crate::tools::codemode::ExtensionCaller; |
| 49 | use crate::tools::spec::{ |
| 50 | ApprovalRequirement, PreparedToolCall, ToolCapability, ToolContext, ToolError, ToolResult, |
| 51 | ToolSpec, |
| 52 | }; |
| 53 | |
| 54 | /// Default per-call deadline, matching script tools |
| 55 | /// (`SupervisionOptions::tool_call_deadline`). |
| 56 | pub const TOOL_CALL_DEADLINE: Duration = Duration::from_secs(120); |
| 57 | |
| 58 | pub(crate) struct HostToolSpec { |
| 59 | registration: ToolRegistration, |
| 60 | manager: Arc<ManagerShared>, |
| 61 | selection: Option<super::composition_scope::SelectionRevision>, |
| 62 | } |
| 63 | |
| 64 | impl HostToolSpec { |
| 65 | #[cfg(test)] |
| 66 | pub(crate) fn new(registration: ToolRegistration, manager: Arc<ManagerShared>) -> Self { |
| 67 | Self { |
| 68 | registration, |
| 69 | manager, |
| 70 | selection: None, |
| 71 | } |
| 72 | } |
| 73 | |
| 74 | pub(crate) fn for_selection( |
| 75 | registration: ToolRegistration, |
| 76 | manager: Arc<ManagerShared>, |
| 77 | selection: Option<super::composition_scope::SelectionRevision>, |
| 78 | ) -> Self { |
| 79 | Self { |
| 80 | registration, |
| 81 | manager, |
| 82 | selection, |
| 83 | } |
| 84 | } |
| 85 | fn check_caller(&self, context: &ToolContext) -> Result<(), ToolError> { |
| 86 | self.manager |
| 87 | .check_selection( |
| 88 | self.selection, |
| 89 | context.plugin_registry.as_deref(), |
| 90 | &self.registration.owner.plugin_id, |
| 91 | &self.registration.content_hash, |
| 92 | self.registration.scope.as_ref(), |
| 93 | ) |
| 94 | .map_err(ToolError::not_available) |
| 95 | } |
| 96 | |
| 97 | /// `extension:<plugin>` (or the module's owner id, `host:<module>`): the |
| 98 | /// origin shown in approval cards and diagnostics. |
| 99 | #[must_use] |
| 100 | pub fn origin(&self) -> String { |
| 101 | match self.registration.tier { |
| 102 | HostTier::Plugin => format!("extension:{}", self.registration.plugin_name), |
| 103 | HostTier::Builtin => self.registration.owner.plugin_id.clone(), |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | /// The approval this tool needs: always `Required` for a plugin's tool; |
| 108 | /// for a built-in module's, what the Rust table says and `Required` when |
| 109 | /// it says nothing. |
| 110 | fn approval(&self) -> ApprovalRequirement { |
| 111 | match self.registration.tier { |
| 112 | HostTier::Plugin => ApprovalRequirement::Required, |
| 113 | HostTier::Builtin => tier::tool_approval( |
| 114 | self.manager.builtin_modules, |
| 115 | &self.registration.owner.plugin_id, |
| 116 | &self.registration.name, |
| 117 | ), |
| 118 | } |
| 119 | } |
| 120 | |
| 121 | /// Check `input` against the schema the plugin registered the tool with. |
| 122 | /// The error is the ordinary invalid-input tool error, so the model sees |
| 123 | /// what to correct; the host is never reached. |
| 124 | fn check_input(&self, input: &Value) -> Result<(), ToolError> { |
| 125 | self.registration |
| 126 | .input_validator |
| 127 | .check(input) |
| 128 | .map_err(|reason| { |
| 129 | ToolError::invalid_input(format!( |
| 130 | "extension tool `{}` rejected the input: {reason}", |
| 131 | self.registration.name |
| 132 | )) |
| 133 | }) |
| 134 | } |
| 135 | |
| 136 | /// Who this tool is to the turn loop's gate: composed here, from the |
| 137 | /// registration, never from anything the host says. |
| 138 | fn caller(&self) -> ExtensionCaller { |
| 139 | ExtensionCaller { |
| 140 | origin: self.origin(), |
| 141 | tool: self.registration.name.clone(), |
| 142 | scope: format!( |
| 143 | "ext:{}@{}:{}:{}:{}", |
| 144 | self.registration.owner.plugin_id, |
| 145 | self.registration.content_hash, |
| 146 | self.registration |
| 147 | .scope |
| 148 | .as_ref() |
| 149 | .map(|entry| format!("{}@{}", entry.path, entry.sha256)) |
| 150 | .unwrap_or_default(), |
| 151 | self.selection |
| 152 | .map(|selection| format!("{}:{}", selection.attachment_id, selection.revision)) |
| 153 | .unwrap_or_default(), |
| 154 | crate::session_manager::current_session_boot_id() |
| 155 | ), |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | /// The approval-card text. Rust composes it; the extension supplies none. |
| 160 | /// Plugins in one host share a process and can interfere with each other, |
| 161 | /// so the card says when this one is not alone (design §4.4, threat 3). |
| 162 | #[must_use] |
| 163 | pub fn approval_text(&self) -> String { |
| 164 | if self.registration.tier == HostTier::Builtin { |
| 165 | return format!( |
| 166 | "Tool `{}` of built-in host module `{}` ({}) runs on Codewhale's built-in extension host", |
| 167 | self.registration.name, |
| 168 | self.registration.plugin_name, |
| 169 | self.origin() |
| 170 | ); |
| 171 | } |
| 172 | let others = self |
| 173 | .manager |
| 174 | .registry |
| 175 | .lock() |
| 176 | .expect("registry lock") |
| 177 | .other_active_owners(&self.registration.owner.plugin_id); |
| 178 | let sharing = match others { |
| 179 | 0 => String::new(), |
| 180 | 1 => "; it shares one host process with 1 other plugin, which can alter its behaviour" |
| 181 | .to_string(), |
| 182 | n => format!( |
| 183 | "; it shares one host process with {n} other plugins, which can alter its behaviour" |
| 184 | ), |
| 185 | }; |
| 186 | format!( |
| 187 | "Extension tool `{}` from plugin `{}` ({}) runs JavaScript on this computer with the extension host's permissions{sharing}", |
| 188 | self.registration.name, |
| 189 | self.registration.plugin_name, |
| 190 | self.origin() |
| 191 | ) |
| 192 | } |
| 193 | } |
| 194 | |
| 195 | impl std::fmt::Debug for HostToolSpec { |
| 196 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| 197 | f.debug_struct("HostToolSpec") |
| 198 | .field("name", &self.registration.name) |
| 199 | .field("plugin", &self.registration.plugin_name) |
| 200 | .field("handle", &self.registration.handle) |
| 201 | .finish() |
| 202 | } |
| 203 | } |
| 204 | |
| 205 | fn map_call_error(tool: &str, error: HostCallError) -> ToolError { |
| 206 | match error { |
| 207 | HostCallError::Cancelled(reason) => ToolError::Cancelled { |
| 208 | message: format!("extension tool `{tool}`: {reason}"), |
| 209 | }, |
| 210 | HostCallError::Timeout { after, .. } => ToolError::Timeout { |
| 211 | seconds: after.as_secs(), |
| 212 | }, |
| 213 | HostCallError::Exited(reason) => { |
| 214 | ToolError::not_available(format!("extension host exited: {reason}")) |
| 215 | } |
| 216 | HostCallError::Busy => { |
| 217 | ToolError::not_available("extension host is busy; try again".to_string()) |
| 218 | } |
| 219 | HostCallError::Rpc { code, message } => { |
| 220 | if code == super::protocol::error_code::NOT_AVAILABLE { |
| 221 | ToolError::not_available(format!("extension tool `{tool}`: {message}")) |
| 222 | } else { |
| 223 | ToolError::execution_failed(format!("extension tool `{tool}` failed: {message}")) |
| 224 | } |
| 225 | } |
| 226 | } |
| 227 | } |
| 228 | |
| 229 | pub(crate) fn wire_to_result(wire: ToolResultWire, origin: &str) -> ToolResult { |
| 230 | let content = wire |
| 231 | .content |
| 232 | .iter() |
| 233 | .map(|block| match block { |
| 234 | ContentBlockWire::Text { text } => text.as_str(), |
| 235 | }) |
| 236 | .collect::<Vec<_>>() |
| 237 | .join("\n"); |
| 238 | let mut metadata = serde_json::Map::new(); |
| 239 | metadata.insert("origin".to_string(), Value::String(origin.to_string())); |
| 240 | if let Some(structured) = wire.structured { |
| 241 | metadata.insert("structured".to_string(), structured); |
| 242 | } |
| 243 | ToolResult { |
| 244 | content, |
| 245 | success: !wire.is_error, |
| 246 | metadata: Some(Value::Object(metadata)), |
| 247 | } |
| 248 | } |
| 249 | |
| 250 | #[async_trait] |
| 251 | impl ToolSpec for HostToolSpec { |
| 252 | fn name(&self) -> &str { |
| 253 | &self.registration.name |
| 254 | } |
| 255 | |
| 256 | fn registration_origin(&self) -> std::borrow::Cow<'_, str> { |
| 257 | self.origin().into() |
| 258 | } |
| 259 | |
| 260 | fn description(&self) -> &str { |
| 261 | &self.registration.description |
| 262 | } |
| 263 | |
| 264 | fn input_schema(&self) -> Value { |
| 265 | self.registration.input_schema.clone() |
| 266 | } |
| 267 | |
| 268 | fn capabilities(&self) -> Vec<ToolCapability> { |
| 269 | let mut capabilities = vec![ToolCapability::ExecutesCode]; |
| 270 | if self.approval() != ApprovalRequirement::Auto { |
| 271 | capabilities.push(ToolCapability::RequiresApproval); |
| 272 | } |
| 273 | capabilities |
| 274 | } |
| 275 | |
| 276 | fn approval_requirement(&self) -> ApprovalRequirement { |
| 277 | self.approval() |
| 278 | } |
| 279 | |
| 280 | fn approval_requirement_for(&self, _input: &Value) -> ApprovalRequirement { |
| 281 | self.approval() |
| 282 | } |
| 283 | |
| 284 | fn is_read_only(&self) -> bool { |
| 285 | false |
| 286 | } |
| 287 | |
| 288 | fn is_read_only_for(&self, _input: &Value) -> bool { |
| 289 | false |
| 290 | } |
| 291 | |
| 292 | fn defer_loading(&self) -> bool { |
| 293 | true |
| 294 | } |
| 295 | |
| 296 | /// Grants are bound to the plugin's reviewed receipt (design §4.3). |
| 297 | fn approval_scope(&self) -> Option<String> { |
| 298 | Some(self.caller().scope) |
| 299 | } |
| 300 | |
| 301 | /// The turn loop serves a permission gate for this tool's call, through |
| 302 | /// which its `core/call`s are planned and approved. |
| 303 | fn extension_caller(&self) -> Option<ExtensionCaller> { |
| 304 | Some(self.caller()) |
| 305 | } |
| 306 | |
| 307 | fn prepare(&self, input: Value, context: &ToolContext) -> Result<PreparedToolCall, ToolError> { |
| 308 | self.check_caller(context)?; |
| 309 | // Before the user is asked to approve it: a call the schema refuses is |
| 310 | // returned to the model to correct and never becomes an approval card. |
| 311 | self.check_input(&input)?; |
| 312 | Ok(PreparedToolCall { |
| 313 | name: self.registration.name.clone(), |
| 314 | // Rust composes the card; the extension cannot supply approval text. |
| 315 | description: self.approval_text(), |
| 316 | read_only: false, |
| 317 | supports_parallel: false, |
| 318 | starts_detached: false, |
| 319 | approval: self.approval(), |
| 320 | resources: vec![crate::tools::spec::ResourceClaim::GlobalExclusive], |
| 321 | input, |
| 322 | }) |
| 323 | } |
| 324 | |
| 325 | async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> { |
| 326 | self.check_caller(context)?; |
| 327 | let registration = &self.registration; |
| 328 | // Again here: `execute` is also reached without `prepare`, and nothing |
| 329 | // that fails the schema may be sent to the host. |
| 330 | self.check_input(&input)?; |
| 331 | let host = self |
| 332 | .manager |
| 333 | .live_host_for(registration) |
| 334 | .await |
| 335 | .map_err(ToolError::not_available)?; |
| 336 | self.check_caller(context)?; |
| 337 | let call_id = context |
| 338 | .execution |
| 339 | .owner_agent_id |
| 340 | .clone() |
| 341 | .map(|agent| format!("{agent}:{}", uuid::Uuid::new_v4().simple())) |
| 342 | .unwrap_or_else(|| uuid::Uuid::new_v4().simple().to_string()); |
| 343 | // The deadline travels with the call: the host is told the bound |
| 344 | // `HostProcess::call` enforces (and cancels at). |
| 345 | let deadline = self.manager.options.supervision.tool_call_deadline; |
| 346 | // An invocation ticket (so the tool may ask the core to run tools for |
| 347 | // it) exists only when the turn loop is serving a permission gate for |
| 348 | // exactly this tool's call. Otherwise (a sub-agent, a call nested in |
| 349 | // `execute_tools`, a test) the tool has no way to ask for anything. |
| 350 | let invocation: Option<InvocationGuard> = context |
| 351 | .execution |
| 352 | .nested_call_gate |
| 353 | .as_ref() |
| 354 | .and_then(|gate| { |
| 355 | self.manager.core_calls.begin_scoped( |
| 356 | registration.tier, |
| 357 | host.generation, |
| 358 | ®istration.owner, |
| 359 | &call_id, |
| 360 | &self.caller(), |
| 361 | context, |
| 362 | gate, |
| 363 | registration.scope.clone(), |
| 364 | registration.content_hash.clone(), |
| 365 | ) |
| 366 | }); |
| 367 | let request = CoreRequest::ToolCall(ToolCallParams { |
| 368 | handle: registration.handle, |
| 369 | call_id, |
| 370 | input, |
| 371 | deadline_ms: u64::try_from(deadline.as_millis()).unwrap_or(u64::MAX), |
| 372 | // The calling session's workspace and no other: the plugin never |
| 373 | // learns where else this process has workspaces. |
| 374 | workspace: context.workspace.to_str().map(str::to_owned), |
| 375 | ticket: invocation.as_ref().map(|i| i.ticket().to_string()), |
| 376 | session_id: context |
| 377 | .execution |
| 378 | .session_objects |
| 379 | .as_ref() |
| 380 | .map(|session| session.session_id.clone()) |
| 381 | .filter(|id| !id.is_empty()), |
| 382 | agent_id: context.execution.owner_agent_id.clone(), |
| 383 | origin_turn_id: context.execution.origin_turn_id.clone(), |
| 384 | }); |
| 385 | // The deadline stops while one of this call's `core/call`s waits on |
| 386 | // the gate (an approval card), so a person's time is not the tool's. |
| 387 | let value = host |
| 388 | .call_with_clock( |
| 389 | request, |
| 390 | Some(registration.owner.plugin_id.clone()), |
| 391 | invocation.as_ref().map(InvocationGuard::clock), |
| 392 | ) |
| 393 | .await |
| 394 | .map_err(|error| map_call_error(®istration.name, error))?; |
| 395 | self.check_caller(context)?; |
| 396 | self.manager |
| 397 | .live_host_for(registration) |
| 398 | .await |
| 399 | .map_err(ToolError::not_available)?; |
| 400 | self.check_caller(context)?; |
| 401 | let wire: ToolResultWire = serde_json::from_value(value).map_err(|error| { |
| 402 | ToolError::execution_failed(format!( |
| 403 | "extension tool `{}` returned a malformed result: {error}", |
| 404 | registration.name |
| 405 | )) |
| 406 | })?; |
| 407 | let mut result = wire_to_result(wire, &self.origin()); |
| 408 | // What the tool asked the core to run, so the persisted record shows it. |
| 409 | if let (Some(receipts), Some(Value::Object(metadata))) = ( |
| 410 | invocation.as_ref().and_then(InvocationGuard::receipts), |
| 411 | result.metadata.as_mut(), |
| 412 | ) { |
| 413 | metadata.insert("core_calls".to_string(), receipts); |
| 414 | } |
| 415 | Ok(result) |
| 416 | } |
| 417 | } |
| 418 | |
| 419 | #[cfg(test)] |
| 420 | mod tests { |
| 421 | use super::*; |
| 422 | use serde_json::json; |
| 423 | |
| 424 | #[test] |
| 425 | fn structured_result_and_soft_failure_survive_the_host_boundary() { |
| 426 | let value = json!({"count": 2, "items": ["a", "b"]}); |
| 427 | let result = wire_to_result( |
| 428 | ToolResultWire { |
| 429 | content: vec![ContentBlockWire::Text { |
| 430 | text: "two items".to_string(), |
| 431 | }], |
| 432 | is_error: true, |
| 433 | structured: Some(value.clone()), |
| 434 | }, |
| 435 | "extension:fixture", |
| 436 | ); |
| 437 | assert_eq!(result.content, "two items"); |
| 438 | assert!(!result.success); |
| 439 | let metadata = result.metadata.expect("result metadata"); |
| 440 | assert_eq!(metadata["origin"], "extension:fixture"); |
| 441 | assert_eq!(metadata["structured"], value); |
| 442 | } |
| 443 | } |
| 444 |