返回 CodeWhale
spec.rs
根目录 / crates / tui / src / tools / spec.rs
1 //! Tool specification traits for the CodeWhale agent system.
2 //!
3 //! This module defines the core abstractions for tools:
4 //! - `ToolSpec`: The main trait that all tools must implement
5 //! - `ToolContext`: Execution context passed to tools
6 //! - `ToolResult`: Unified result type for tool execution
7 //! - `ToolCapability`: Capabilities and requirements of tools
8
9 use std::collections::HashMap;
10 use std::fs;
11 use std::path::{Component, Path, PathBuf};
12 use std::sync::{Arc, Mutex, OnceLock};
13 use std::time::SystemTime;
14
15 use async_trait::async_trait;
16 use serde::{Deserialize, Serialize};
17 use serde_json::Value;
18 use tokio_util::sync::CancellationToken;
19 use unicode_normalization::UnicodeNormalization;
20
21 use crate::features::Features;
22 use crate::lsp::LspManager;
23 use crate::network_policy::NetworkPolicyDecider;
24 use crate::rlm::session::SessionObjectSnapshot;
25 use crate::rlm::session::{SharedRlmSessionStore, new_shared_rlm_session_store};
26 use crate::sandbox::backend::SandboxBackend;
27 use crate::tools::handle::{SharedHandleStore, new_shared_handle_store};
28 use crate::tools::shell::{SharedShellManager, new_shared_shell_manager};
29 use crate::worker_profile::ShellPolicy;
30 #[allow(unused_imports)]
31 pub use codewhale_tools::{
32 ApprovalRequirement, PreparedToolCall, ResourceClaim, ToolCapability, ToolError,
33 ToolExecutionOutcome, ToolResult, ToolResultContentBlock, ToolTerminalStatus, optional_bool,
34 optional_bool_opt, optional_str, optional_u64, required_str, required_u64,
35 schedule_non_conflicting, type_mismatch,
36 };
37
38 /// Text plus provider-neutral rich blocks at the conversation boundary.
39 #[derive(Debug, Clone)]
40 pub(crate) struct RichToolResult {
41 pub result: ToolResult,
42 pub content_blocks: Vec<ToolResultContentBlock>,
43 }
44
45 impl RichToolResult {
46 #[must_use]
47 pub fn plain(result: ToolResult) -> Self {
48 Self {
49 result,
50 content_blocks: Vec::new(),
51 }
52 }
53
54 #[must_use]
55 pub fn with_content_blocks(
56 result: ToolResult,
57 content_blocks: Vec<ToolResultContentBlock>,
58 ) -> Self {
59 Self {
60 result,
61 content_blocks,
62 }
63 }
64
65 #[must_use]
66 pub fn into_result(self) -> ToolResult {
67 self.result
68 }
69 }
70
71 impl std::ops::Deref for RichToolResult {
72 type Target = ToolResult;
73
74 fn deref(&self) -> &Self::Target {
75 &self.result
76 }
77 }
78
79 #[async_trait]
80 pub trait DynamicToolExecutor: Send + Sync {
81 async fn execute_dynamic_tool(
82 &self,
83 thread_id: Option<String>,
84 namespace: Option<String>,
85 name: String,
86 input: Value,
87 ) -> Result<ToolResult, ToolError>;
88 }
89
90 /// Optional durable runtime services made available to model-visible tools.
91 ///
92 /// These are intentionally optional so existing unit tests and one-off tool
93 /// contexts keep working. Tools that need durable task/automation state fail
94 /// closed with a clear "not available" error when the relevant service is not
95 /// attached.
96 #[derive(Clone)]
97 pub struct RuntimeToolServices {
98 pub shell_manager: Option<SharedShellManager>,
99 /// True only for the real headless exec host after it has established the
100 /// explicit authority required to transfer `persist:true` services.
101 pub persist_services_enabled: bool,
102 pub task_manager: Option<crate::task_manager::SharedTaskManager>,
103 pub automations: Option<crate::automation_manager::SharedAutomationManager>,
104 pub task_data_dir: Option<PathBuf>,
105 pub active_task_id: Option<String>,
106 pub active_thread_id: Option<String>,
107 pub dynamic_tool_executor: Option<Arc<dyn DynamicToolExecutor>>,
108 /// Active-session Work Graph authority plus its legacy Plan/To-do views.
109 pub work: Option<crate::work_graph::SharedWorkRuntime>,
110 /// Hook executor for `shell_env` injection (#456) and any future
111 /// tool-side hook events. `None` outside the live engine — test
112 /// contexts that don't care about hooks get a no-op.
113 pub hook_executor: Option<std::sync::Arc<crate::hooks::HookExecutor>>,
114 /// Per-session backing store for `var_handle` payloads. Cloned tool
115 /// contexts share this Arc so handles survive across turns.
116 pub handle_store: SharedHandleStore,
117 /// Per-session persistent RLM kernels, keyed by caller-chosen context name.
118 pub rlm_sessions: SharedRlmSessionStore,
119 /// Directory for `read_media`'s content-addressed store of
120 /// pre-compression image originals. `None` (tests and one-off contexts)
121 /// disables persistence so unit tests never touch the real state dir;
122 /// production wiring points it at `<codewhale home>/media-originals`.
123 pub media_originals_dir: Option<PathBuf>,
124 }
125
126 impl Default for RuntimeToolServices {
127 fn default() -> Self {
128 Self {
129 shell_manager: None,
130 persist_services_enabled: false,
131 task_manager: None,
132 automations: None,
133 task_data_dir: None,
134 active_task_id: None,
135 active_thread_id: None,
136 dynamic_tool_executor: None,
137 work: None,
138 hook_executor: None,
139 handle_store: new_shared_handle_store(),
140 rlm_sessions: new_shared_rlm_session_store(),
141 media_originals_dir: None,
142 }
143 }
144 }
145
146 impl std::fmt::Debug for RuntimeToolServices {
147 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
148 f.debug_struct("RuntimeToolServices")
149 .field("shell_manager", &self.shell_manager.is_some())
150 .field("persist_services_enabled", &self.persist_services_enabled)
151 .field("task_manager", &self.task_manager.is_some())
152 .field("automations", &self.automations.is_some())
153 .field("task_data_dir", &self.task_data_dir)
154 .field("active_task_id", &self.active_task_id)
155 .field("active_thread_id", &self.active_thread_id)
156 .field(
157 "dynamic_tool_executor",
158 &self.dynamic_tool_executor.is_some(),
159 )
160 .field("work", &self.work.is_some())
161 .field("hook_executor", &self.hook_executor.is_some())
162 .field("handle_store", &true)
163 .field("rlm_sessions", &true)
164 .field("media_originals_dir", &self.media_originals_dir)
165 .finish()
166 }
167 }
168
169 #[derive(Debug, Clone, PartialEq, Eq)]
170 struct FileReadSnapshot {
171 len: u64,
172 modified: Option<SystemTime>,
173 }
174
175 #[derive(Debug, Default)]
176 pub struct FileReadTracker {
177 reads: HashMap<PathBuf, FileReadSnapshot>,
178 }
179
180 pub type SharedFileReadTracker = Arc<Mutex<FileReadTracker>>;
181
182 pub(crate) fn new_shared_file_read_tracker() -> SharedFileReadTracker {
183 Arc::new(Mutex::new(FileReadTracker::default()))
184 }
185
186 fn file_read_snapshot(path: &Path) -> Result<FileReadSnapshot, ToolError> {
187 let metadata = fs::metadata(path).map_err(|e| {
188 ToolError::execution_failed(format!("Failed to inspect {}: {e}", path.display()))
189 })?;
190 Ok(FileReadSnapshot {
191 len: metadata.len(),
192 modified: metadata.modified().ok(),
193 })
194 }
195
196 /// Sandbox policy for command execution.
197 #[derive(Debug, Clone, Default)]
198 pub enum SandboxPolicy {
199 /// No sandboxing (dangerous but sometimes needed)
200 #[default]
201 None,
202 }
203
204 /// Machine-readable mutation boundary for a headless worker process.
205 ///
206 /// Fleet serializes this envelope onto the exact `codewhale exec` argv. The
207 /// child installs it before constructing its engine, and every ToolContext in
208 /// that process inherits the same outer cap. Nested agents may narrow this
209 /// boundary, but cannot remove or expand it.
210 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
211 #[serde(deny_unknown_fields)]
212 pub struct ToolAuthorityEnvelope {
213 pub schema_version: u32,
214 pub owner: String,
215 pub authority: ToolMutationAuthority,
216 /// Optional outer network cap for headless workers. `None` preserves the
217 /// behavior of v1 envelopes written before this field existed; new Fleet
218 /// launches always carry the resolved worker permission explicitly.
219 #[serde(default, skip_serializing_if = "Option::is_none")]
220 pub network_access: Option<bool>,
221 /// Explicit shell cap for a headless worker. Older v1 envelopes omit this
222 /// field and therefore remain shell-less; mutation authority is never
223 /// treated as an implicit shell grant.
224 #[serde(default, skip_serializing_if = "ToolShellAuthority::is_none")]
225 pub shell: ToolShellAuthority,
226 /// Narrow process-start authority for the built-in verification surface.
227 /// This is separate from both mutation and shell authority: a verifier may
228 /// run classifier-bounded workspace checks, but that never grants Bash or
229 /// an operator-supplied command line.
230 #[serde(default, skip_serializing_if = "ToolVerificationAuthority::is_none")]
231 pub verification: ToolVerificationAuthority,
232 #[serde(default)]
233 pub writable_roots: Vec<String>,
234 #[serde(default)]
235 pub writable_files: Vec<String>,
236 #[serde(default)]
237 pub coordination_contracts: Vec<String>,
238 }
239
240 /// Shell authority carried across the Fleet subprocess boundary.
241 ///
242 /// Full/arbitrary shell is intentionally not representable here. Fleet can
243 /// opt a Scout/Reviewer worker into the classifier-proven read subset, while every
244 /// other headless role keeps the historical shell-less posture.
245 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
246 #[serde(rename_all = "snake_case")]
247 pub enum ToolShellAuthority {
248 #[default]
249 None,
250 ReadOnly,
251 }
252
253 impl ToolShellAuthority {
254 #[must_use]
255 pub const fn is_none(&self) -> bool {
256 matches!(self, Self::None)
257 }
258
259 #[must_use]
260 const fn shell_policy(self) -> ShellPolicy {
261 match self {
262 Self::None => ShellPolicy::None,
263 Self::ReadOnly => ShellPolicy::ReadOnly,
264 }
265 }
266 }
267
268 /// Process authority for Fleet's dedicated verifier role.
269 ///
270 /// Arbitrary execution is intentionally not representable. The registry and
271 /// dispatch boundary admit only calls that the shared verification classifier
272 /// proves are default workspace checks or pure test selection.
273 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
274 #[serde(rename_all = "snake_case")]
275 pub enum ToolVerificationAuthority {
276 #[default]
277 None,
278 Bounded,
279 }
280
281 impl ToolVerificationAuthority {
282 #[must_use]
283 pub const fn is_none(&self) -> bool {
284 matches!(self, Self::None)
285 }
286 }
287
288 /// Whether a headless Fleet process should register the one read-only Bash
289 /// surface after intersecting the transported cap with explicit tool denies.
290 #[must_use]
291 pub(crate) fn fleet_exec_shell_enabled(
292 fleet_authority_active: bool,
293 shell_authority: ToolShellAuthority,
294 disallowed_tools: Option<&[String]>,
295 ) -> bool {
296 fleet_authority_active
297 && shell_authority == ToolShellAuthority::ReadOnly
298 && !disallowed_tools.is_some_and(|rules| {
299 rules.iter().any(|rule| {
300 let rule = rule.trim().to_ascii_lowercase();
301 rule.strip_suffix('*')
302 .map_or_else(|| rule == "bash", |prefix| "bash".starts_with(prefix))
303 })
304 })
305 }
306
307 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
308 #[serde(rename_all = "snake_case")]
309 pub enum ToolMutationAuthority {
310 ReadOnly,
311 ScopedWrite,
312 }
313
314 static PROCESS_TOOL_AUTHORITY: OnceLock<Arc<ToolAuthorityEnvelope>> = OnceLock::new();
315
316 impl ToolAuthorityEnvelope {
317 pub fn normalized(mut self) -> Result<Self, String> {
318 if self.schema_version != 1 {
319 return Err(format!(
320 "unsupported tool authority schema version {}",
321 self.schema_version
322 ));
323 }
324 self.owner = bounded_authority_value("owner", &self.owner, 128)?;
325 self.writable_roots = normalize_authority_paths(&self.writable_roots, "writable_roots")?;
326 self.writable_files = normalize_authority_paths(&self.writable_files, "writable_files")?;
327 self.coordination_contracts = normalize_authority_values(
328 &self.coordination_contracts,
329 "coordination_contracts",
330 16,
331 128,
332 )?;
333 if self.authority == ToolMutationAuthority::ScopedWrite
334 && self.writable_roots.is_empty()
335 && self.writable_files.is_empty()
336 && self.coordination_contracts.is_empty()
337 {
338 return Err(
339 "scoped_write authority requires a writable root, exact file, or coordination contract"
340 .to_string(),
341 );
342 }
343 if self.authority == ToolMutationAuthority::ReadOnly
344 && (!self.writable_roots.is_empty()
345 || !self.writable_files.is_empty()
346 || !self.coordination_contracts.is_empty())
347 {
348 return Err("read_only authority cannot carry mutation scope".to_string());
349 }
350 if self.verification == ToolVerificationAuthority::Bounded
351 && (self.authority != ToolMutationAuthority::ReadOnly
352 || self.shell != ToolShellAuthority::None)
353 {
354 return Err(
355 "bounded verification requires read_only mutation authority and no Bash authority"
356 .to_string(),
357 );
358 }
359 Ok(self)
360 }
361
362 pub fn from_json(raw: &str) -> Result<Self, String> {
363 serde_json::from_str::<Self>(raw)
364 .map_err(|error| format!("invalid tool authority envelope: {error}"))?
365 .normalized()
366 }
367
368 #[cfg(test)]
369 fn is_within(&self, outer: &Self) -> bool {
370 if self.shell > outer.shell
371 || self.verification > outer.verification
372 || (outer.network_access == Some(false) && self.network_access != Some(false))
373 {
374 return false;
375 }
376 if self.authority == ToolMutationAuthority::ReadOnly {
377 return true;
378 }
379 if outer.authority != ToolMutationAuthority::ScopedWrite {
380 return false;
381 }
382 self.writable_roots.iter().all(|path| {
383 outer
384 .writable_roots
385 .iter()
386 .any(|root| authority_path_is_within_root(path, root))
387 }) && self.writable_files.iter().all(|path| {
388 outer.writable_files.contains(path)
389 || outer
390 .writable_roots
391 .iter()
392 .any(|root| authority_path_is_within_root(path, root))
393 }) && self
394 .coordination_contracts
395 .iter()
396 .all(|contract| outer.coordination_contracts.contains(contract))
397 }
398
399 pub fn permits_mutation_path(
400 &self,
401 context: &ToolContext,
402 raw_path: &str,
403 ) -> Result<bool, ToolError> {
404 if self.authority == ToolMutationAuthority::ReadOnly {
405 return Ok(false);
406 }
407 let target = resolve_strict_authority_path(context, raw_path)?;
408 for file in &self.writable_files {
409 if resolve_strict_authority_path(context, file)? == target {
410 return Ok(true);
411 }
412 }
413 for root in &self.writable_roots {
414 if target.starts_with(resolve_strict_authority_path(context, root)?) {
415 return Ok(true);
416 }
417 }
418 Ok(false)
419 }
420 }
421
422 #[cfg(test)]
423 fn authority_path_is_within_root(path: &str, root: &str) -> bool {
424 root == "."
425 || path == root
426 || path
427 .strip_prefix(root)
428 .is_some_and(|suffix| suffix.starts_with('/'))
429 }
430
431 pub fn install_process_tool_authority(envelope: ToolAuthorityEnvelope) -> Result<(), String> {
432 let envelope = Arc::new(envelope.normalized()?);
433 if let Some(existing) = PROCESS_TOOL_AUTHORITY.get() {
434 return if existing.as_ref() == envelope.as_ref() {
435 Ok(())
436 } else {
437 Err("tool authority envelope was already installed for this process".to_string())
438 };
439 }
440 PROCESS_TOOL_AUTHORITY
441 .set(envelope)
442 .map_err(|_| "tool authority envelope was already installed for this process".to_string())
443 }
444
445 fn process_tool_authority() -> Option<Arc<ToolAuthorityEnvelope>> {
446 PROCESS_TOOL_AUTHORITY.get().cloned()
447 }
448
449 fn bounded_authority_value(field: &str, value: &str, max_chars: usize) -> Result<String, String> {
450 let value = value.trim().nfc().collect::<String>();
451 if value.is_empty()
452 || value.chars().count() > max_chars
453 || value.chars().any(|ch| matches!(ch, '\0' | '\r' | '\n'))
454 {
455 return Err(format!(
456 "tool authority {field} must be one non-empty line of at most {max_chars} characters"
457 ));
458 }
459 Ok(value)
460 }
461
462 fn normalize_authority_paths(values: &[String], field: &str) -> Result<Vec<String>, String> {
463 if values.len() > 32 {
464 return Err(format!("tool authority {field} accepts at most 32 entries"));
465 }
466 let mut normalized = Vec::new();
467 for raw in values {
468 let raw = bounded_authority_value(field, raw, 512)?.replace('\\', "/");
469 let windows_drive = raw.as_bytes().get(1) == Some(&b':')
470 && raw.as_bytes().first().is_some_and(u8::is_ascii_alphabetic);
471 if raw.starts_with('/') || raw.starts_with("//") || windows_drive {
472 return Err(format!(
473 "tool authority {field} entries must be repo-relative"
474 ));
475 }
476 let mut segments = Vec::new();
477 for segment in raw.split('/') {
478 match segment {
479 "" | "." => {}
480 ".." => {
481 return Err(format!(
482 "tool authority {field} cannot contain parent traversal"
483 ));
484 }
485 value => segments.push(value),
486 }
487 }
488 let path = if segments.is_empty() {
489 ".".to_string()
490 } else {
491 segments.join("/")
492 };
493 if !normalized.contains(&path) {
494 normalized.push(path);
495 }
496 }
497 Ok(normalized)
498 }
499
500 fn normalize_authority_values(
501 values: &[String],
502 field: &str,
503 max_entries: usize,
504 max_chars: usize,
505 ) -> Result<Vec<String>, String> {
506 if values.len() > max_entries {
507 return Err(format!(
508 "tool authority {field} accepts at most {max_entries} entries"
509 ));
510 }
511 let mut normalized = Vec::new();
512 for value in values {
513 let value = bounded_authority_value(field, value, max_chars)?;
514 if !normalized.contains(&value) {
515 normalized.push(value);
516 }
517 }
518 Ok(normalized)
519 }
520
521 pub(crate) fn resolve_strict_authority_path(
522 context: &ToolContext,
523 raw_path: &str,
524 ) -> Result<PathBuf, ToolError> {
525 let normalized = normalize_authority_paths(&[raw_path.to_string()], "mutation_path")
526 .map_err(ToolError::permission_denied)?
527 .into_iter()
528 .next()
529 .ok_or_else(|| ToolError::permission_denied("mutation path cannot be empty"))?;
530 let workspace = context.workspace.canonicalize().map_err(|error| {
531 ToolError::execution_failed(format!(
532 "Failed to canonicalize authority workspace {}: {error}",
533 context.workspace.display()
534 ))
535 })?;
536 let mut current = workspace.clone();
537 if normalized != "." {
538 for segment in normalized.split('/') {
539 current.push(segment);
540 match fs::symlink_metadata(&current) {
541 Ok(metadata) if metadata.file_type().is_symlink() => {
542 return Err(ToolError::permission_denied(format!(
543 "machine-readable authority paths must not traverse symlinks: {}",
544 current.display()
545 )));
546 }
547 Ok(_) => {}
548 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
549 Err(error) => {
550 return Err(ToolError::execution_failed(format!(
551 "Failed to inspect authority path {}: {error}",
552 current.display()
553 )));
554 }
555 }
556 }
557 }
558 if !current.starts_with(&workspace) {
559 return Err(ToolError::permission_denied(format!(
560 "machine-readable authority path escapes workspace: {}",
561 current.display()
562 )));
563 }
564 Ok(current)
565 }
566
567 /// Context passed to tools during execution.
568 #[derive(Clone)]
569 pub struct ToolContext {
570 /// The workspace root directory
571 pub workspace: PathBuf,
572 /// Per-turn policy and attached services. Kept behind one owned group so
573 /// cloning a context preserves the historical value semantics while the
574 /// top-level context remains small and stable as services evolve.
575 pub execution: Box<ToolExecutionState>,
576 }
577
578 /// Policy and service state attached to one tool-execution context.
579 ///
580 /// `ToolContext` dereferences to this group for source compatibility with
581 /// existing tools. New code can use `context.execution` when the grouping is
582 /// useful, without growing the top-level context by another field per feature.
583 #[derive(Clone)]
584 pub struct ToolExecutionState {
585 /// Effective session/ancestor tool ceiling, carried to MCP dispatch and runtime registration.
586 pub(crate) disallowed_tools: Vec<String>,
587 /// Shared shell manager for background tasks and streaming IO.
588 pub shell_manager: SharedShellManager,
589 /// Per-session snapshots for files successfully observed by `read_file`.
590 /// Mutation tools use this to reject narrow edits against unread or stale
591 /// content.
592 pub file_read_tracker: SharedFileReadTracker,
593 /// Sub-agent that owns tool work started through this context. Root user
594 /// turns leave this unset; child contexts stamp it so long-running shell
595 /// jobs can be attributed in UI surfaces.
596 pub owner_agent_id: Option<String>,
597 pub owner_agent_name: Option<String>,
598 /// Tool call and engine turn that created long-running work through this
599 /// context. Hosts use these stable identities to reconcile later updates
600 /// with the originating transcript position.
601 pub(crate) origin_tool_call_id: Option<String>,
602 pub(crate) origin_turn_id: Option<String>,
603 /// Outer process authority cap installed by Fleet/headless dispatch.
604 /// `None` for ordinary interactive/root sessions.
605 pub(crate) tool_authority: Option<Arc<ToolAuthorityEnvelope>>,
606 /// Whether to allow paths outside workspace
607 pub trust_mode: bool,
608 /// Current sandbox policy
609 #[expect(dead_code)]
610 pub sandbox_policy: SandboxPolicy,
611 /// Path for notes file
612 pub notes_path: PathBuf,
613 /// MCP configuration path
614 #[expect(dead_code)]
615 pub mcp_config_path: PathBuf,
616 /// Explicit skills directory used for model-visible skill discovery.
617 pub skills_dir: Option<PathBuf>,
618 /// Restrict skill discovery to CodeWhale-owned roots plus `skills_dir`.
619 pub skills_scan_codewhale_only: bool,
620 /// Immutable registry snapshot for this workspace/engine context.
621 pub plugin_registry: Option<Arc<crate::plugins::PluginRegistry>>,
622 /// Elevated sandbox policy override (used when retrying after sandbox denial).
623 /// This overrides the default sandbox behavior for shell commands.
624 pub elevated_sandbox_policy: Option<crate::sandbox::SandboxPolicy>,
625 /// Whether the enclosing host is the real headless `codewhale exec`
626 /// process. `persist:true` background services are only permitted there;
627 /// interactive TUI, desktop/app-server, and hosted runtime-thread engines
628 /// leave this false so the feature fails closed.
629 pub persist_services_enabled: bool,
630 /// Optional user-facing hint for shell commands that fail because the
631 /// active sandbox policy intentionally denies outbound network access.
632 pub shell_network_denied_hint: Option<String>,
633 /// Whether tools should auto-approve without safety checks (YOLO mode).
634 /// When true, command safety analysis is skipped for shell execution.
635 pub auto_approve: bool,
636 /// Effective shell policy for this execution context.
637 pub shell_policy: ShellPolicy,
638 /// Effective feature flag set for the running session.
639 pub features: Features,
640 /// Namespace for tool state that should be scoped to the current session/thread.
641 pub state_namespace: String,
642 /// Effective context window for the active provider/model route. Web tools
643 /// use this to keep inline page content below three percent of the route.
644 pub route_context_window: Option<u32>,
645 /// User-trusted external paths the agent may read/write even when they
646 /// fall outside `workspace`. Loaded from `~/.deepseek/workspace-trust.json`
647 /// and refreshed when the user runs `/trust add <path>`. Distinct from
648 /// `trust_mode`, which is the all-or-nothing legacy switch (#29).
649 pub trusted_external_paths: Vec<PathBuf>,
650 /// Whether to follow symbolic links during file discovery and tool
651 /// operations. When `true`, symlinked directories are traversed and
652 /// symlinked paths that resolve outside the workspace are still allowed
653 /// (the symlink itself must be inside the workspace). Mirrors the
654 /// `workspace_follow_symlinks` setting.
655 pub follow_symlinks: bool,
656 /// Per-domain network policy (#135). When `None`, network tools fall back
657 /// to a permissive default that mirrors pre-v0.7.0 behavior so tests and
658 /// other contexts that don't construct a real policy keep working.
659 pub network_policy: Option<NetworkPolicyDecider>,
660 /// Durable runtime services for task, gate, PR-attempt, GitHub evidence,
661 /// and automation tools.
662 pub runtime: RuntimeToolServices,
663 /// Snapshot of the active prompt/session/history exposed as symbolic RLM
664 /// objects. Tools only receive compact cards unless explicitly opening a
665 /// bounded object through `rlm_open`.
666 pub session_objects: Option<SessionObjectSnapshot>,
667 /// Cancellation token for the active engine turn. Tools that may wait on
668 /// external work should observe this so UI cancel can interrupt them.
669 pub cancel_token: Option<CancellationToken>,
670 /// Optional external sandbox backend for shell execution.
671 /// When set, exec_shell routes commands through this instead of spawning
672 /// a local process.
673 pub sandbox_backend: Option<std::sync::Arc<dyn SandboxBackend>>,
674 /// Path to the user memory file. `None` when the user-memory feature
675 /// (#489) is disabled — tools that read or write the file should
676 /// short-circuit on `None` rather than fall back to a workspace-local
677 /// default.
678 pub memory_path: Option<PathBuf>,
679 /// LSP manager for post-edit diagnostics injection (#428). `None` when
680 /// LSP is disabled or the context is constructed in a test that does not
681 /// need diagnostics. Edit tools append a `<diagnostics>` block to their
682 /// result when this is present and the manager is enabled.
683 pub lsp_manager: Option<Arc<LspManager>>,
684
685 /// Adaptive evidence router (#4619). Consulted only when
686 /// `CODEWHALE_ADAPTIVE_OUTPUT_ROUTING` opts the process in; under the
687 /// default classic lane this field is inert and bounding happens at the
688 /// engine/subagent completion boundary. `None` in sub-agents and test
689 /// contexts.
690 pub large_output_router: Option<crate::tools::large_output_router::LargeOutputRouter>,
691
692 /// Which search backend `web_search` should use. Default: Firecrawl. Set via
693 /// `[search] provider` in config.toml.
694 pub search_provider: crate::config::SearchProvider,
695 /// Optional Firecrawl key, or required key for other API search providers.
696 /// Metaso also falls back to the `METASO_API_KEY` env var.
697 /// Baidu also falls back to `BAIDU_SEARCH_API_KEY`.
698 pub search_api_key: Option<String>,
699 /// Optional DuckDuckGo-compatible HTML endpoint override for `web_search`.
700 pub search_base_url: Option<String>,
701 /// Opaque client for the active route's documented first-party search
702 /// tool. It owns provider authentication internally and is attached only
703 /// when the exact route capability says server-side search is supported.
704 pub(crate) provider_native_search: Option<crate::client::ProviderNativeSearchClient>,
705 /// Exact active route capability facts. Unknown stays fail-closed.
706 pub(crate) route_capabilities: codewhale_config::route::RouteCapabilities,
707 }
708
709 impl std::ops::Deref for ToolContext {
710 type Target = ToolExecutionState;
711
712 fn deref(&self) -> &Self::Target {
713 &self.execution
714 }
715 }
716
717 impl std::ops::DerefMut for ToolContext {
718 fn deref_mut(&mut self) -> &mut Self::Target {
719 &mut self.execution
720 }
721 }
722
723 impl ToolContext {
724 /// Create an inert context for a registry that intentionally has no tools.
725 ///
726 /// Empty paths are deliberate: isolated Runtime Chat must not retain the
727 /// host workspace or derive project notes and MCP paths. Isolation comes
728 /// from callers pairing this context with an empty registry and allow-list
729 /// plus a zero tool-call budget; this constructor is not a security
730 /// boundary by itself.
731 #[must_use]
732 pub(crate) fn for_empty_registry() -> Self {
733 Self::with_options(PathBuf::new(), false, PathBuf::new(), PathBuf::new())
734 }
735
736 /// Create a new `ToolContext` with default settings.
737 #[must_use]
738 pub fn new(workspace: impl Into<PathBuf>) -> Self {
739 let workspace = workspace.into();
740 // Prefer .codewhale, fall back to .deepseek for project-local state
741 let notes_path = codewhale_config::resolve_project_state_dir(&workspace, "notes.md")
742 .expect("hardcoded project notes state path is valid")
743 .1;
744 let mcp_config_path = codewhale_config::resolve_project_state_dir(&workspace, "mcp.json")
745 .expect("hardcoded project MCP state path is valid")
746 .1;
747 Self::with_options(workspace, false, notes_path, mcp_config_path)
748 }
749
750 /// Create a `ToolContext` with all settings specified.
751 pub fn with_options(
752 workspace: impl Into<PathBuf>,
753 trust_mode: bool,
754 notes_path: impl Into<PathBuf>,
755 mcp_config_path: impl Into<PathBuf>,
756 ) -> Self {
757 let workspace = workspace.into();
758 let shell_manager = new_shared_shell_manager(workspace.clone());
759 let tool_authority = process_tool_authority();
760 let shell_policy = match tool_authority.as_deref() {
761 Some(cap) => cap.shell.shell_policy(),
762 None => ShellPolicy::Full,
763 };
764 Self {
765 workspace,
766 execution: Box::new(ToolExecutionState {
767 disallowed_tools: Vec::new(),
768 shell_manager,
769 file_read_tracker: new_shared_file_read_tracker(),
770 owner_agent_id: None,
771 owner_agent_name: None,
772 origin_tool_call_id: None,
773 origin_turn_id: None,
774 tool_authority,
775 trust_mode,
776 sandbox_policy: SandboxPolicy::None,
777 notes_path: notes_path.into(),
778 mcp_config_path: mcp_config_path.into(),
779 skills_dir: None,
780 skills_scan_codewhale_only: false,
781 plugin_registry: None,
782 elevated_sandbox_policy: None,
783 persist_services_enabled: false,
784 shell_network_denied_hint: None,
785 auto_approve: false,
786 shell_policy,
787 features: Features::with_defaults(),
788 state_namespace: "workspace".to_string(),
789 route_context_window: None,
790 trusted_external_paths: Vec::new(),
791 follow_symlinks: false,
792 network_policy: None,
793 runtime: RuntimeToolServices::default(),
794 session_objects: None,
795 cancel_token: None,
796 sandbox_backend: None,
797 memory_path: None,
798 lsp_manager: None,
799 large_output_router: None,
800 search_provider: crate::config::SearchProvider::default(),
801 search_api_key: None,
802 search_base_url: None,
803 provider_native_search: None,
804 route_capabilities: codewhale_config::route::RouteCapabilities::default(),
805 }),
806 }
807 }
808
809 /// Create a `ToolContext` with auto-approve mode (YOLO).
810 pub fn with_auto_approve(
811 workspace: impl Into<PathBuf>,
812 trust_mode: bool,
813 notes_path: impl Into<PathBuf>,
814 mcp_config_path: impl Into<PathBuf>,
815 auto_approve: bool,
816 ) -> Self {
817 let mut context = Self::with_options(workspace, trust_mode, notes_path, mcp_config_path);
818 context.auto_approve = auto_approve;
819 context
820 }
821
822 /// Attach a per-domain network policy to this context (#135).
823 #[must_use]
824 pub fn with_network_policy(mut self, policy: NetworkPolicyDecider) -> Self {
825 self.network_policy = Some(policy);
826 self
827 }
828
829 /// Attach durable runtime services to tools.
830 #[must_use]
831 pub fn with_runtime_services(mut self, runtime: RuntimeToolServices) -> Self {
832 self.runtime = runtime;
833 self
834 }
835
836 /// Stamp tool work with the sub-agent that owns it.
837 #[must_use]
838 pub fn with_owner_agent(
839 mut self,
840 agent_id: impl Into<String>,
841 agent_name: impl Into<String>,
842 ) -> Self {
843 let agent_id = agent_id.into();
844 let agent_name = agent_name.into();
845 self.owner_agent_id = (!agent_id.trim().is_empty()).then_some(agent_id);
846 self.owner_agent_name = (!agent_name.trim().is_empty()).then_some(agent_name);
847 self
848 }
849
850 /// Bind long-running work to the engine turn that created it.
851 #[must_use]
852 pub(crate) fn with_origin_turn_id(mut self, turn_id: impl Into<String>) -> Self {
853 self.origin_turn_id = Some(turn_id.into());
854 self
855 }
856
857 /// Bind long-running work to the tool call that created it.
858 #[must_use]
859 pub(crate) fn with_origin_tool_call_id(mut self, tool_call_id: impl Into<String>) -> Self {
860 self.origin_tool_call_id = Some(tool_call_id.into());
861 self
862 }
863
864 #[cfg(test)]
865 pub(crate) fn with_tool_authority(
866 mut self,
867 envelope: ToolAuthorityEnvelope,
868 ) -> Result<Self, String> {
869 let envelope = envelope.normalized()?;
870 if let Some(outer) = self.tool_authority.as_ref()
871 && !envelope.is_within(outer)
872 {
873 return Err(
874 "nested tool authority cannot expand its process authority cap".to_string(),
875 );
876 }
877 self.tool_authority = Some(Arc::new(envelope));
878 self.shell_policy = self.authority_clamped_shell_policy(self.shell_policy);
879 Ok(self)
880 }
881
882 /// Attach skill discovery settings for tools that need to resolve
883 /// model-visible skills by name.
884 #[must_use]
885 pub fn with_skills_config(
886 mut self,
887 skills_dir: impl Into<PathBuf>,
888 scan_codewhale_only: bool,
889 ) -> Self {
890 self.skills_dir = Some(skills_dir.into());
891 self.skills_scan_codewhale_only = scan_codewhale_only;
892 self
893 }
894
895 #[must_use]
896 pub fn with_plugin_registry(mut self, registry: Arc<crate::plugins::PluginRegistry>) -> Self {
897 self.plugin_registry = Some(registry);
898 self
899 }
900
901 /// Attach active prompt/history/session symbolic objects for RLM tools.
902 #[must_use]
903 pub fn with_session_objects(mut self, snapshot: SessionObjectSnapshot) -> Self {
904 self.session_objects = Some(snapshot);
905 self
906 }
907
908 /// Attach the active engine cancellation token.
909 #[must_use]
910 pub fn with_cancel_token(mut self, cancel_token: CancellationToken) -> Self {
911 self.cancel_token = Some(cancel_token);
912 self
913 }
914
915 /// Attach the effective shell policy for this turn.
916 #[must_use]
917 pub fn with_shell_policy(mut self, policy: ShellPolicy) -> Self {
918 self.shell_policy = self.authority_clamped_shell_policy(policy);
919 self
920 }
921
922 /// Replace the turn shell policy while retaining the process authority as
923 /// an outer ceiling. Live mode changes rebuild this value on every tool
924 /// call, so the clamp belongs here rather than only at engine startup.
925 pub(crate) fn set_shell_policy(&mut self, policy: ShellPolicy) {
926 self.shell_policy = self.authority_clamped_shell_policy(policy);
927 }
928
929 fn authority_clamped_shell_policy(&self, policy: ShellPolicy) -> ShellPolicy {
930 match self.tool_authority.as_deref() {
931 Some(cap) => policy.min_with(cap.shell.shell_policy()),
932 None => policy,
933 }
934 }
935
936 /// Attach an external sandbox backend for remote shell execution.
937 #[must_use]
938 pub fn with_sandbox_backend(mut self, backend: std::sync::Arc<dyn SandboxBackend>) -> Self {
939 self.sandbox_backend = Some(backend);
940 self
941 }
942
943 /// Set the user's trusted external paths (loaded from
944 /// `~/.deepseek/workspace-trust.json`). See [`Self::resolve_path`] for
945 /// how the list is consulted.
946 #[must_use]
947 pub fn with_trusted_external_paths(mut self, paths: Vec<PathBuf>) -> Self {
948 self.trusted_external_paths = paths;
949 self
950 }
951
952 /// Set whether tools should follow symbolic links. When `true`,
953 /// `resolve_path` allows symlinked paths that resolve outside the
954 /// workspace, and walk-based tools traverse symlinked directories.
955 /// Mirrors the `workspace_follow_symlinks` setting.
956 #[must_use]
957 pub fn with_follow_symlinks(mut self, follow: bool) -> Self {
958 self.follow_symlinks = follow;
959 self
960 }
961
962 /// Attach an LSP manager so that edit tools can auto-inject diagnostics
963 /// into their results after a successful file modification (#428).
964 #[must_use]
965 #[cfg(test)]
966 pub fn with_lsp_manager(mut self, manager: Arc<LspManager>) -> Self {
967 self.lsp_manager = Some(manager);
968 self
969 }
970
971 /// Remember that the caller has observed the current on-disk state of a
972 /// file. This is intentionally best-effort so successful reads/writes do
973 /// not fail after completing only because a post-operation metadata lookup
974 /// raced with filesystem changes.
975 pub fn note_file_read(&self, path: &Path) {
976 let Ok(snapshot) = file_read_snapshot(path) else {
977 return;
978 };
979 let Ok(mut tracker) = self.file_read_tracker.lock() else {
980 return;
981 };
982 tracker.reads.insert(path.to_path_buf(), snapshot);
983 }
984
985 /// Require a successful, still-fresh `read_file` snapshot before a narrow
986 /// in-place edit. This catches model edits made against guessed or stale
987 /// content while leaving transactional patch preflight separate.
988 pub fn require_fresh_file_read(
989 &self,
990 path: &Path,
991 requested_path: &str,
992 ) -> Result<(), ToolError> {
993 let prior = {
994 let tracker = self.file_read_tracker.lock().map_err(|_| {
995 ToolError::execution_failed(
996 "Failed to check read-before-edit state: tracker lock poisoned".to_string(),
997 )
998 })?;
999 tracker.reads.get(path).cloned()
1000 };
1001
1002 let Some(prior) = prior else {
1003 return Err(ToolError::execution_failed(format!(
1004 "Refusing File action=\"edit\" for {} because it has not been read in this session. \
1005 Recovery: call File with action=\"read\" path=\"{requested_path}\" to inspect the current contents, \
1006 then retry File action=\"edit\" with a unique search string.",
1007 path.display()
1008 )));
1009 };
1010
1011 let current = file_read_snapshot(path).map_err(|e| {
1012 ToolError::execution_failed(format!(
1013 "Refusing File action=\"edit\" for {} because the file could not be checked for staleness ({e}). \
1014 Recovery: call File with action=\"read\" path=\"{requested_path}\" again, then retry File action=\"edit\".",
1015 path.display()
1016 ))
1017 })?;
1018
1019 if current != prior {
1020 return Err(ToolError::execution_failed(format!(
1021 "Refusing File action=\"edit\" for {} because it changed since the last File action=\"read\" call. \
1022 Recovery: call File with action=\"read\" path=\"{requested_path}\" again and retry with the current contents.",
1023 path.display()
1024 )));
1025 }
1026
1027 Ok(())
1028 }
1029
1030 /// Resolve a path relative to workspace, validating it doesn't escape.
1031 ///
1032 /// This handles both existing files (using canonicalize) and non-existent files
1033 /// (for write operations) by canonicalizing the parent directory and appending
1034 /// the filename.
1035 /// Resolve a path relative to workspace, validating it doesn't escape.
1036 ///
1037 /// # Examples
1038 ///
1039 /// ```ignore
1040 /// # use crate::tools::spec::ToolContext;
1041 /// let ctx = ToolContext::new(".");
1042 /// let path = ctx.resolve_path("README.md")?;
1043 /// # Ok::<(), crate::tools::spec::ToolError>(())
1044 /// ```
1045 pub fn resolve_path(&self, raw: &str) -> Result<PathBuf, ToolError> {
1046 let candidate = if let Some(home_path) = resolve_home_path(raw)? {
1047 home_path
1048 } else if std::path::Path::new(raw).is_absolute() {
1049 PathBuf::from(raw)
1050 } else {
1051 self.workspace.join(raw)
1052 };
1053
1054 // In trust mode, allow any path without validation
1055 if self.trust_mode {
1056 // Still try to canonicalize for consistency, but don't require it
1057 return Ok(candidate.canonicalize().unwrap_or(candidate));
1058 }
1059
1060 // Try to canonicalize the workspace
1061 let workspace_canonical = self
1062 .workspace
1063 .canonicalize()
1064 .unwrap_or_else(|_| self.workspace.clone());
1065
1066 // When follow_symlinks is enabled, check the non-canonical (symlink)
1067 // path against the workspace first. A symlink inside the workspace
1068 // that resolves outside is allowed — the symlink itself is the gate.
1069 if self.follow_symlinks {
1070 let candidate_normalized = normalize_path(&candidate);
1071 let workspace_normalized = normalize_path(&self.workspace);
1072 let workspace_canonical_normalized = normalize_path(&workspace_canonical);
1073
1074 if candidate_normalized.starts_with(&workspace_normalized)
1075 || candidate_normalized.starts_with(&workspace_canonical_normalized)
1076 {
1077 // The symlink (or plain path) is inside the workspace.
1078 // Return the canonicalized target so file I/O works correctly.
1079 if candidate.exists() {
1080 return Ok(candidate.canonicalize().unwrap_or(candidate));
1081 }
1082 // Non-existent path: canonicalize the deepest existing ancestor
1083 return self.resolve_nonexistent_path(candidate, &workspace_canonical);
1084 }
1085
1086 // Path is outside workspace even before resolving symlinks.
1087 // Fall through to the standard escape check.
1088 }
1089
1090 // For the initial check, also try to canonicalize the candidate if possible
1091 // This handles symlinks like /var -> /private/var on macOS
1092 let candidate_canonical = candidate
1093 .canonicalize()
1094 .unwrap_or_else(|_| normalize_path(&candidate));
1095 let workspace_normalized = normalize_path(&workspace_canonical);
1096
1097 // Check if the candidate is under the workspace (comparing canonical paths)
1098 if !candidate_canonical.starts_with(&workspace_normalized) {
1099 // Also try with non-canonical workspace for cases where workspace itself
1100 // hasn't been canonicalized yet
1101 let workspace_plain = normalize_path(&self.workspace);
1102 let candidate_normalized = normalize_path(&candidate);
1103 if !candidate_normalized.starts_with(&workspace_plain)
1104 && !self.is_trusted_external_path(&candidate_canonical)
1105 && !self.is_trusted_external_path(&candidate_normalized)
1106 {
1107 return Err(ToolError::PathEscape {
1108 path: candidate_canonical,
1109 });
1110 }
1111 }
1112
1113 // For existing paths, use canonicalize directly
1114 if candidate.exists() {
1115 let canonical = candidate.canonicalize().map_err(|e| {
1116 ToolError::execution_failed(format!(
1117 "Failed to canonicalize {}: {}",
1118 candidate.display(),
1119 e
1120 ))
1121 })?;
1122
1123 if !canonical.starts_with(&workspace_canonical)
1124 && !self.is_trusted_external_path(&canonical)
1125 {
1126 return Err(ToolError::PathEscape { path: canonical });
1127 }
1128
1129 return Ok(canonical);
1130 }
1131
1132 self.resolve_nonexistent_path(candidate, &workspace_canonical)
1133 }
1134
1135 /// Resolve `raw` against the workspace and require an existing directory.
1136 ///
1137 /// Tools that scope execution to a subdirectory (Run `cwd`) resolve
1138 /// through here so containment, existence, and the refusal wording have
1139 /// one owner. A workspace escape keeps the typed `PathEscape`; a missing
1140 /// or non-directory path names the fallback (drop the field to run in
1141 /// the workspace root).
1142 pub fn resolve_existing_dir(&self, raw: &str, field: &str) -> Result<PathBuf, ToolError> {
1143 let resolved = self.resolve_path(raw)?;
1144 if resolved.is_dir() {
1145 Ok(resolved)
1146 } else {
1147 Err(ToolError::invalid_input(format!(
1148 "{field} '{raw}' is not an existing directory inside the workspace; drop `{field}` to run in the workspace root"
1149 )))
1150 }
1151 }
1152
1153 /// Resolve a non-existent path by canonicalizing its deepest existing
1154 /// ancestor and validating the result is under the workspace or a
1155 /// trusted external path.
1156 fn resolve_nonexistent_path(
1157 &self,
1158 candidate: PathBuf,
1159 workspace_canonical: &Path,
1160 ) -> Result<PathBuf, ToolError> {
1161 let workspace_normalized = normalize_path(workspace_canonical);
1162 let workspace_plain = normalize_path(&self.workspace);
1163 let mut existing_ancestor = candidate.clone();
1164 let mut suffix_parts: Vec<std::ffi::OsString> = Vec::new();
1165
1166 while !existing_ancestor.exists() {
1167 if let Some(file_name) = existing_ancestor.file_name() {
1168 suffix_parts.push(file_name.to_owned());
1169 }
1170 match existing_ancestor.parent() {
1171 Some(parent) if !parent.as_os_str().is_empty() => {
1172 existing_ancestor = parent.to_path_buf();
1173 }
1174 _ => {
1175 // No existing parent found; fall back to simple check
1176 break;
1177 }
1178 }
1179 }
1180 let ancestor_normalized = normalize_path(&existing_ancestor);
1181
1182 let canonical_ancestor = if existing_ancestor.exists() {
1183 existing_ancestor
1184 .canonicalize()
1185 .unwrap_or(existing_ancestor)
1186 } else {
1187 existing_ancestor
1188 };
1189
1190 // Rebuild the full path from canonicalized ancestor
1191 let mut canonical = canonical_ancestor;
1192 for part in suffix_parts.into_iter().rev() {
1193 canonical.push(part);
1194 }
1195 let canonical = normalize_path(&canonical);
1196
1197 if self.follow_symlinks
1198 && (ancestor_normalized.starts_with(&workspace_plain)
1199 || ancestor_normalized.starts_with(&workspace_normalized))
1200 {
1201 return Ok(canonical);
1202 }
1203
1204 // Validate it's under workspace, OR is under a user-trusted external
1205 // path (`/trust add <path>` from the slash command, persisted in
1206 // `~/.deepseek/workspace-trust.json`).
1207 if !canonical.starts_with(workspace_canonical)
1208 && !canonical.starts_with(&workspace_normalized)
1209 && !self.is_trusted_external_path(&canonical)
1210 {
1211 return Err(ToolError::PathEscape { path: canonical });
1212 }
1213
1214 Ok(canonical)
1215 }
1216
1217 /// Whether `path` is under any of the user-trusted external roots. The
1218 /// caller should pass an already-canonicalized (or normalized) path.
1219 fn is_trusted_external_path(&self, path: &Path) -> bool {
1220 self.trusted_external_paths
1221 .iter()
1222 .any(|trusted| path.starts_with(trusted))
1223 }
1224
1225 /// Set the trust mode.
1226 #[cfg(test)]
1227 pub fn with_trust_mode(mut self, trust: bool) -> Self {
1228 self.trust_mode = trust;
1229 self
1230 }
1231
1232 /// Set feature flags for tool execution.
1233 pub fn with_features(mut self, features: Features) -> Self {
1234 self.features = features;
1235 self
1236 }
1237
1238 /// Override the shared shell manager.
1239 pub fn with_shell_manager(mut self, shell_manager: SharedShellManager) -> Self {
1240 self.shell_manager = shell_manager;
1241 self
1242 }
1243
1244 /// Reuse the engine's session-scoped read snapshots across tool-context
1245 /// rebuilds. A fresh context is assembled for each turn, but successful
1246 /// reads must remain authoritative until the observed file changes.
1247 pub fn with_file_read_tracker(mut self, tracker: SharedFileReadTracker) -> Self {
1248 self.file_read_tracker = tracker;
1249 self
1250 }
1251
1252 /// Set the elevated sandbox policy override.
1253 ///
1254 /// This is used when retrying a tool after a sandbox denial, to run
1255 /// with elevated permissions.
1256 pub fn with_elevated_sandbox_policy(mut self, policy: crate::sandbox::SandboxPolicy) -> Self {
1257 self.elevated_sandbox_policy = Some(policy);
1258 self
1259 }
1260
1261 /// Set the shell network-denial hint used by network-restricted modes.
1262 pub fn with_shell_network_denied_hint(mut self, hint: impl Into<String>) -> Self {
1263 self.shell_network_denied_hint = Some(hint.into());
1264 self
1265 }
1266
1267 /// Set the namespace used for session-scoped tool state.
1268 pub fn with_state_namespace(mut self, namespace: impl Into<String>) -> Self {
1269 self.state_namespace = namespace.into();
1270 self
1271 }
1272
1273 /// Attach the active route's effective context window.
1274 #[must_use]
1275 pub fn with_route_context_window(mut self, context_window: u32) -> Self {
1276 self.route_context_window = (context_window > 0).then_some(context_window);
1277 self
1278 }
1279
1280 /// Attach the adaptive evidence router (#4619). Consulted only under the
1281 /// `CODEWHALE_ADAPTIVE_OUTPUT_ROUTING` opt-in.
1282 #[must_use]
1283 pub fn with_large_output_router(
1284 mut self,
1285 router: crate::tools::large_output_router::LargeOutputRouter,
1286 ) -> Self {
1287 self.large_output_router = Some(router);
1288 self
1289 }
1290 }
1291
1292 /// Gather LSP diagnostics for `paths` using the manager stored in `context`,
1293 /// and return the rendered `<diagnostics …>` blocks joined by newlines.
1294 ///
1295 /// Returns an empty string when:
1296 /// - `context.lsp_manager` is `None`
1297 /// - the manager's `enabled` flag is `false`
1298 /// - none of the files produce diagnostics (e.g. all clean, or language unknown)
1299 ///
1300 /// This function is non-blocking by design: every failure mode (missing LSP
1301 /// binary, timeout, unknown language) degrades to an empty string rather than
1302 /// propagating an error to the caller.
1303 pub async fn lsp_diagnostics_for_paths(context: &ToolContext, paths: &[PathBuf]) -> String {
1304 use crate::lsp::render_blocks;
1305
1306 let manager = match context.lsp_manager.as_ref() {
1307 Some(m) if m.config().enabled => m,
1308 _ => return String::new(),
1309 };
1310
1311 let mut blocks = Vec::new();
1312 for (idx, path) in paths.iter().enumerate() {
1313 if let Some(block) = manager.diagnostics_for(path, idx as u64).await {
1314 blocks.push(block);
1315 }
1316 }
1317
1318 render_blocks(&blocks)
1319 }
1320
1321 pub(crate) fn normalize_path(path: &Path) -> PathBuf {
1322 let mut prefix: Option<std::ffi::OsString> = None;
1323 let mut is_root = false;
1324 let mut stack: Vec<std::ffi::OsString> = Vec::new();
1325
1326 for component in path.components() {
1327 match component {
1328 Component::Prefix(prefix_component) => {
1329 prefix = Some(prefix_component.as_os_str().to_owned());
1330 }
1331 Component::RootDir => {
1332 is_root = true;
1333 }
1334 Component::CurDir => {}
1335 Component::ParentDir => {
1336 let parent = Component::ParentDir.as_os_str();
1337 if let Some(last) = stack.pop() {
1338 if last == parent {
1339 stack.push(last);
1340 stack.push(parent.to_owned());
1341 }
1342 } else if !is_root {
1343 stack.push(parent.to_owned());
1344 }
1345 }
1346 Component::Normal(part) => {
1347 stack.push(part.to_owned());
1348 }
1349 }
1350 }
1351
1352 let mut normalized = PathBuf::new();
1353 if let Some(prefix) = prefix {
1354 normalized.push(prefix);
1355 }
1356 if is_root {
1357 normalized.push(Path::new(std::path::MAIN_SEPARATOR_STR));
1358 }
1359 for part in stack {
1360 normalized.push(part);
1361 }
1362 normalized
1363 }
1364
1365 /// Resolve an exact `~` or `~/` path prefix to the current user's home directory.
1366 ///
1367 /// Only exact `~` and `~/` (or `~\` on Windows) prefixes are resolved. Prefixes like
1368 /// `~otheruser`, shell variables (`$VAR`), command substitution, and globs are not
1369 /// expanded. Literal paths like `./~/file` stay literal.
1370 ///
1371 /// Returns:
1372 /// - `Ok(Some(path))` if `raw` has an exact home prefix and home was determined.
1373 /// - `Ok(None)` if `raw` does not have an exact home prefix.
1374 /// - `Err(ToolError)` if `raw` has an exact home prefix but user home could not be determined.
1375 pub(crate) fn resolve_home_path(raw: &str) -> Result<Option<PathBuf>, ToolError> {
1376 resolve_home_path_with(raw, crate::config::effective_home_dir)
1377 }
1378
1379 pub(crate) fn resolve_home_path_with(
1380 raw: &str,
1381 home_lookup: impl FnOnce() -> Option<PathBuf>,
1382 ) -> Result<Option<PathBuf>, ToolError> {
1383 let suffix = if raw == "~" {
1384 ""
1385 } else if let Some(rest) = raw.strip_prefix("~/") {
1386 rest.trim_start_matches(|c| c == '/' || (cfg!(windows) && c == '\\'))
1387 } else {
1388 #[cfg(windows)]
1389 if let Some(rest) = raw.strip_prefix(r"~\") {
1390 rest.trim_start_matches(['/', '\\'])
1391 } else {
1392 return Ok(None);
1393 }
1394 #[cfg(not(windows))]
1395 return Ok(None);
1396 };
1397
1398 // A drive prefix is not a home-relative suffix. `PathBuf::join` would
1399 // otherwise replace the home on Windows (for example `~/C:\file`).
1400 #[cfg(windows)]
1401 if Path::new(suffix)
1402 .components()
1403 .any(|part| matches!(part, std::path::Component::Prefix(_)))
1404 {
1405 return Err(ToolError::invalid_input(
1406 "a home-relative path cannot contain a drive prefix",
1407 ));
1408 }
1409 let home = home_lookup().ok_or_else(|| {
1410 ToolError::execution_failed(format!(
1411 "Failed to resolve path '{raw}': user home directory could not be determined"
1412 ))
1413 })?;
1414
1415 if suffix.is_empty() {
1416 Ok(Some(home))
1417 } else {
1418 Ok(Some(home.join(suffix)))
1419 }
1420 }
1421
1422 /// The core trait that all tools must implement.
1423 #[async_trait]
1424 pub trait ToolSpec: Send + Sync {
1425 /// Returns the unique name of this tool (used in API calls).
1426 fn name(&self) -> &str;
1427
1428 /// Identifies the implementation in local registration diagnostics only.
1429 /// Adapters should use their existing source identity, never credentials,
1430 /// command arguments, descriptions, or other execution payloads.
1431 fn registration_origin(&self) -> std::borrow::Cow<'_, str> {
1432 std::any::type_name::<Self>().into()
1433 }
1434
1435 /// Returns a human-readable description of what this tool does.
1436 fn description(&self) -> &str;
1437
1438 /// Returns the JSON Schema for the tool's input parameters.
1439 fn input_schema(&self) -> Value;
1440
1441 /// Returns the capabilities this tool has.
1442 fn capabilities(&self) -> Vec<ToolCapability>;
1443
1444 /// Returns the approval requirement for this tool.
1445 fn approval_requirement(&self) -> ApprovalRequirement {
1446 let caps = self.capabilities();
1447 if caps.contains(&ToolCapability::ExecutesCode) {
1448 ApprovalRequirement::Required
1449 } else if caps.contains(&ToolCapability::WritesFiles) {
1450 ApprovalRequirement::Suggest
1451 } else {
1452 ApprovalRequirement::Auto
1453 }
1454 }
1455
1456 /// Returns the approval requirement for this concrete tool input.
1457 fn approval_requirement_for(&self, _input: &Value) -> ApprovalRequirement {
1458 self.approval_requirement()
1459 }
1460
1461 /// Returns whether this tool is sandboxable.
1462 #[cfg(test)]
1463 fn is_sandboxable(&self) -> bool {
1464 self.capabilities().contains(&ToolCapability::Sandboxable)
1465 }
1466
1467 /// Returns whether this tool is read-only.
1468 fn is_read_only(&self) -> bool {
1469 let caps = self.capabilities();
1470 caps.contains(&ToolCapability::ReadOnly)
1471 && !caps.contains(&ToolCapability::WritesFiles)
1472 && !caps.contains(&ToolCapability::ExecutesCode)
1473 }
1474
1475 /// Returns whether this concrete tool input is read-only.
1476 fn is_read_only_for(&self, _input: &Value) -> bool {
1477 self.is_read_only()
1478 }
1479
1480 /// Returns whether this tool can be executed in parallel with others.
1481 fn supports_parallel(&self) -> bool {
1482 false
1483 }
1484
1485 /// Returns whether this concrete tool input can run in parallel.
1486 fn supports_parallel_for(&self, _input: &Value) -> bool {
1487 self.supports_parallel()
1488 }
1489
1490 /// Returns whether this input starts durable/detached work and returns
1491 /// immediately. Detached starts are not read-only, but in auto-approved
1492 /// turns they do not need to block neighboring read-only inspections.
1493 fn starts_detached_for(&self, _input: &Value) -> bool {
1494 false
1495 }
1496
1497 /// Resolve input-specific policy without performing external side effects.
1498 ///
1499 /// Resource claims deliberately default to global exclusivity until a
1500 /// first-party tool opts into narrower, canonicalized claims. The initial
1501 /// seam records this decision but leaves the existing scheduler unchanged.
1502 fn prepare(&self, input: Value, _context: &ToolContext) -> Result<PreparedToolCall, ToolError> {
1503 Ok(PreparedToolCall {
1504 name: self.name().to_string(),
1505 description: self.description().to_string(),
1506 read_only: self.is_read_only_for(&input),
1507 supports_parallel: self.supports_parallel_for(&input),
1508 starts_detached: self.starts_detached_for(&input),
1509 approval: self.approval_requirement_for(&input),
1510 resources: vec![ResourceClaim::GlobalExclusive],
1511 input,
1512 })
1513 }
1514
1515 /// Returns whether this tool should be excluded from the model-visible
1516 /// tool catalog (deferred loading). Tools marked `true` are registered
1517 /// but not sent to the model until explicitly activated via tool search.
1518 fn defer_loading(&self) -> bool {
1519 false
1520 }
1521
1522 /// Returns whether this tool should be advertised in the model-facing
1523 /// catalog. Hidden compatibility tools remain registered and executable
1524 /// by name so saved transcripts can replay without teaching new sessions
1525 /// the deprecated spelling.
1526 fn model_visible(&self) -> bool {
1527 true
1528 }
1529
1530 /// Execute the tool with the given input and context.
1531 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError>;
1532
1533 /// Execute with rich result blocks. Existing tools inherit text-only
1534 /// behavior; tools such as lowercase `read` can opt in without changing
1535 /// the published `ToolResult` struct.
1536 async fn execute_rich(
1537 &self,
1538 input: Value,
1539 context: &ToolContext,
1540 ) -> Result<RichToolResult, ToolError> {
1541 self.execute(input, context)
1542 .await
1543 .map(RichToolResult::plain)
1544 }
1545 }
1546
1547 #[cfg(test)]
1548 mod tests;
1549
1549 lines RUST