返回 CodeWhale
tool.rs
根目录 / crates / tui / src / extension_host / tool.rs
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 &registration.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(&registration.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
444 lines RUST