返回 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, ToolTerminalStatus, optional_bool, optional_bool_opt,
34 optional_str, optional_u64, required_str, required_u64, schedule_non_conflicting,
35 type_mismatch,
36 };
37
38 #[async_trait]
39 pub trait DynamicToolExecutor: Send + Sync {
40 async fn execute_dynamic_tool(
41 &self,
42 thread_id: Option<String>,
43 namespace: Option<String>,
44 name: String,
45 input: Value,
46 ) -> Result<ToolResult, ToolError>;
47 }
48
49 /// Optional durable runtime services made available to model-visible tools.
50 ///
51 /// These are intentionally optional so existing unit tests and one-off tool
52 /// contexts keep working. Tools that need durable task/automation state fail
53 /// closed with a clear "not available" error when the relevant service is not
54 /// attached.
55 #[derive(Clone)]
56 pub struct RuntimeToolServices {
57 pub shell_manager: Option<SharedShellManager>,
58 pub task_manager: Option<crate::task_manager::SharedTaskManager>,
59 pub automations: Option<crate::automation_manager::SharedAutomationManager>,
60 pub task_data_dir: Option<PathBuf>,
61 pub active_task_id: Option<String>,
62 pub active_thread_id: Option<String>,
63 pub dynamic_tool_executor: Option<Arc<dyn DynamicToolExecutor>>,
64 /// Active-session Work Graph authority plus its legacy Plan/To-do views.
65 pub work: Option<crate::work_graph::SharedWorkRuntime>,
66 /// Hook executor for `shell_env` injection (#456) and any future
67 /// tool-side hook events. `None` outside the live engine — test
68 /// contexts that don't care about hooks get a no-op.
69 pub hook_executor: Option<std::sync::Arc<crate::hooks::HookExecutor>>,
70 /// Per-session backing store for `var_handle` payloads. Cloned tool
71 /// contexts share this Arc so handles survive across turns.
72 pub handle_store: SharedHandleStore,
73 /// Per-session persistent RLM kernels, keyed by caller-chosen context name.
74 pub rlm_sessions: SharedRlmSessionStore,
75 }
76
77 impl Default for RuntimeToolServices {
78 fn default() -> Self {
79 Self {
80 shell_manager: None,
81 task_manager: None,
82 automations: None,
83 task_data_dir: None,
84 active_task_id: None,
85 active_thread_id: None,
86 dynamic_tool_executor: None,
87 work: None,
88 hook_executor: None,
89 handle_store: new_shared_handle_store(),
90 rlm_sessions: new_shared_rlm_session_store(),
91 }
92 }
93 }
94
95 impl std::fmt::Debug for RuntimeToolServices {
96 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97 f.debug_struct("RuntimeToolServices")
98 .field("shell_manager", &self.shell_manager.is_some())
99 .field("task_manager", &self.task_manager.is_some())
100 .field("automations", &self.automations.is_some())
101 .field("task_data_dir", &self.task_data_dir)
102 .field("active_task_id", &self.active_task_id)
103 .field("active_thread_id", &self.active_thread_id)
104 .field(
105 "dynamic_tool_executor",
106 &self.dynamic_tool_executor.is_some(),
107 )
108 .field("work", &self.work.is_some())
109 .field("hook_executor", &self.hook_executor.is_some())
110 .field("handle_store", &true)
111 .field("rlm_sessions", &true)
112 .finish()
113 }
114 }
115
116 #[derive(Debug, Clone, PartialEq, Eq)]
117 struct FileReadSnapshot {
118 len: u64,
119 modified: Option<SystemTime>,
120 }
121
122 #[derive(Debug, Default)]
123 pub struct FileReadTracker {
124 reads: HashMap<PathBuf, FileReadSnapshot>,
125 }
126
127 pub type SharedFileReadTracker = Arc<Mutex<FileReadTracker>>;
128
129 pub(crate) fn new_shared_file_read_tracker() -> SharedFileReadTracker {
130 Arc::new(Mutex::new(FileReadTracker::default()))
131 }
132
133 fn file_read_snapshot(path: &Path) -> Result<FileReadSnapshot, ToolError> {
134 let metadata = fs::metadata(path).map_err(|e| {
135 ToolError::execution_failed(format!("Failed to inspect {}: {e}", path.display()))
136 })?;
137 Ok(FileReadSnapshot {
138 len: metadata.len(),
139 modified: metadata.modified().ok(),
140 })
141 }
142
143 /// Sandbox policy for command execution.
144 #[derive(Debug, Clone, Default)]
145 pub enum SandboxPolicy {
146 /// No sandboxing (dangerous but sometimes needed)
147 #[default]
148 None,
149 }
150
151 /// Machine-readable mutation boundary for a headless worker process.
152 ///
153 /// Fleet serializes this envelope onto the exact `codewhale exec` argv. The
154 /// child installs it before constructing its engine, and every ToolContext in
155 /// that process inherits the same outer cap. Nested agents may narrow this
156 /// boundary, but cannot remove or expand it.
157 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
158 #[serde(deny_unknown_fields)]
159 pub struct ToolAuthorityEnvelope {
160 pub schema_version: u32,
161 pub owner: String,
162 pub authority: ToolMutationAuthority,
163 /// Optional outer network cap for headless workers. `None` preserves the
164 /// behavior of v1 envelopes written before this field existed; new Fleet
165 /// launches always carry the resolved worker permission explicitly.
166 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub network_access: Option<bool>,
168 #[serde(default)]
169 pub writable_roots: Vec<String>,
170 #[serde(default)]
171 pub writable_files: Vec<String>,
172 #[serde(default)]
173 pub coordination_contracts: Vec<String>,
174 }
175
176 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
177 #[serde(rename_all = "snake_case")]
178 pub enum ToolMutationAuthority {
179 ReadOnly,
180 ScopedWrite,
181 }
182
183 static PROCESS_TOOL_AUTHORITY: OnceLock<Arc<ToolAuthorityEnvelope>> = OnceLock::new();
184
185 impl ToolAuthorityEnvelope {
186 pub fn normalized(mut self) -> Result<Self, String> {
187 if self.schema_version != 1 {
188 return Err(format!(
189 "unsupported tool authority schema version {}",
190 self.schema_version
191 ));
192 }
193 self.owner = bounded_authority_value("owner", &self.owner, 128)?;
194 self.writable_roots = normalize_authority_paths(&self.writable_roots, "writable_roots")?;
195 self.writable_files = normalize_authority_paths(&self.writable_files, "writable_files")?;
196 self.coordination_contracts = normalize_authority_values(
197 &self.coordination_contracts,
198 "coordination_contracts",
199 16,
200 128,
201 )?;
202 if self.authority == ToolMutationAuthority::ScopedWrite
203 && self.writable_roots.is_empty()
204 && self.writable_files.is_empty()
205 && self.coordination_contracts.is_empty()
206 {
207 return Err(
208 "scoped_write authority requires a writable root, exact file, or coordination contract"
209 .to_string(),
210 );
211 }
212 if self.authority == ToolMutationAuthority::ReadOnly
213 && (!self.writable_roots.is_empty()
214 || !self.writable_files.is_empty()
215 || !self.coordination_contracts.is_empty())
216 {
217 return Err("read_only authority cannot carry mutation scope".to_string());
218 }
219 Ok(self)
220 }
221
222 pub fn from_json(raw: &str) -> Result<Self, String> {
223 serde_json::from_str::<Self>(raw)
224 .map_err(|error| format!("invalid tool authority envelope: {error}"))?
225 .normalized()
226 }
227
228 #[cfg(test)]
229 fn is_within(&self, outer: &Self) -> bool {
230 if self.authority == ToolMutationAuthority::ReadOnly {
231 return true;
232 }
233 if outer.authority != ToolMutationAuthority::ScopedWrite {
234 return false;
235 }
236 self.writable_roots.iter().all(|path| {
237 outer
238 .writable_roots
239 .iter()
240 .any(|root| authority_path_is_within_root(path, root))
241 }) && self.writable_files.iter().all(|path| {
242 outer.writable_files.contains(path)
243 || outer
244 .writable_roots
245 .iter()
246 .any(|root| authority_path_is_within_root(path, root))
247 }) && self
248 .coordination_contracts
249 .iter()
250 .all(|contract| outer.coordination_contracts.contains(contract))
251 }
252
253 pub fn permits_mutation_path(
254 &self,
255 context: &ToolContext,
256 raw_path: &str,
257 ) -> Result<bool, ToolError> {
258 if self.authority == ToolMutationAuthority::ReadOnly {
259 return Ok(false);
260 }
261 let target = resolve_strict_authority_path(context, raw_path)?;
262 for file in &self.writable_files {
263 if resolve_strict_authority_path(context, file)? == target {
264 return Ok(true);
265 }
266 }
267 for root in &self.writable_roots {
268 if target.starts_with(resolve_strict_authority_path(context, root)?) {
269 return Ok(true);
270 }
271 }
272 Ok(false)
273 }
274 }
275
276 #[cfg(test)]
277 fn authority_path_is_within_root(path: &str, root: &str) -> bool {
278 root == "."
279 || path == root
280 || path
281 .strip_prefix(root)
282 .is_some_and(|suffix| suffix.starts_with('/'))
283 }
284
285 pub fn install_process_tool_authority(envelope: ToolAuthorityEnvelope) -> Result<(), String> {
286 let envelope = Arc::new(envelope.normalized()?);
287 if let Some(existing) = PROCESS_TOOL_AUTHORITY.get() {
288 return if existing.as_ref() == envelope.as_ref() {
289 Ok(())
290 } else {
291 Err("tool authority envelope was already installed for this process".to_string())
292 };
293 }
294 PROCESS_TOOL_AUTHORITY
295 .set(envelope)
296 .map_err(|_| "tool authority envelope was already installed for this process".to_string())
297 }
298
299 fn process_tool_authority() -> Option<Arc<ToolAuthorityEnvelope>> {
300 PROCESS_TOOL_AUTHORITY.get().cloned()
301 }
302
303 fn bounded_authority_value(field: &str, value: &str, max_chars: usize) -> Result<String, String> {
304 let value = value.trim().nfc().collect::<String>();
305 if value.is_empty()
306 || value.chars().count() > max_chars
307 || value.chars().any(|ch| matches!(ch, '\0' | '\r' | '\n'))
308 {
309 return Err(format!(
310 "tool authority {field} must be one non-empty line of at most {max_chars} characters"
311 ));
312 }
313 Ok(value)
314 }
315
316 fn normalize_authority_paths(values: &[String], field: &str) -> Result<Vec<String>, String> {
317 if values.len() > 32 {
318 return Err(format!("tool authority {field} accepts at most 32 entries"));
319 }
320 let mut normalized = Vec::new();
321 for raw in values {
322 let raw = bounded_authority_value(field, raw, 512)?.replace('\\', "/");
323 let windows_drive = raw.as_bytes().get(1) == Some(&b':')
324 && raw.as_bytes().first().is_some_and(u8::is_ascii_alphabetic);
325 if raw.starts_with('/') || raw.starts_with("//") || windows_drive {
326 return Err(format!(
327 "tool authority {field} entries must be repo-relative"
328 ));
329 }
330 let mut segments = Vec::new();
331 for segment in raw.split('/') {
332 match segment {
333 "" | "." => {}
334 ".." => {
335 return Err(format!(
336 "tool authority {field} cannot contain parent traversal"
337 ));
338 }
339 value => segments.push(value),
340 }
341 }
342 let path = if segments.is_empty() {
343 ".".to_string()
344 } else {
345 segments.join("/")
346 };
347 if !normalized.contains(&path) {
348 normalized.push(path);
349 }
350 }
351 Ok(normalized)
352 }
353
354 fn normalize_authority_values(
355 values: &[String],
356 field: &str,
357 max_entries: usize,
358 max_chars: usize,
359 ) -> Result<Vec<String>, String> {
360 if values.len() > max_entries {
361 return Err(format!(
362 "tool authority {field} accepts at most {max_entries} entries"
363 ));
364 }
365 let mut normalized = Vec::new();
366 for value in values {
367 let value = bounded_authority_value(field, value, max_chars)?;
368 if !normalized.contains(&value) {
369 normalized.push(value);
370 }
371 }
372 Ok(normalized)
373 }
374
375 pub(crate) fn resolve_strict_authority_path(
376 context: &ToolContext,
377 raw_path: &str,
378 ) -> Result<PathBuf, ToolError> {
379 let normalized = normalize_authority_paths(&[raw_path.to_string()], "mutation_path")
380 .map_err(ToolError::permission_denied)?
381 .into_iter()
382 .next()
383 .ok_or_else(|| ToolError::permission_denied("mutation path cannot be empty"))?;
384 let workspace = context.workspace.canonicalize().map_err(|error| {
385 ToolError::execution_failed(format!(
386 "Failed to canonicalize authority workspace {}: {error}",
387 context.workspace.display()
388 ))
389 })?;
390 let mut current = workspace.clone();
391 if normalized != "." {
392 for segment in normalized.split('/') {
393 current.push(segment);
394 match fs::symlink_metadata(&current) {
395 Ok(metadata) if metadata.file_type().is_symlink() => {
396 return Err(ToolError::permission_denied(format!(
397 "machine-readable authority paths must not traverse symlinks: {}",
398 current.display()
399 )));
400 }
401 Ok(_) => {}
402 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
403 Err(error) => {
404 return Err(ToolError::execution_failed(format!(
405 "Failed to inspect authority path {}: {error}",
406 current.display()
407 )));
408 }
409 }
410 }
411 }
412 if !current.starts_with(&workspace) {
413 return Err(ToolError::permission_denied(format!(
414 "machine-readable authority path escapes workspace: {}",
415 current.display()
416 )));
417 }
418 Ok(current)
419 }
420
421 /// Context passed to tools during execution.
422 #[derive(Clone)]
423 pub struct ToolContext {
424 /// The workspace root directory
425 pub workspace: PathBuf,
426 /// Per-turn policy and attached services. Kept behind one owned group so
427 /// cloning a context preserves the historical value semantics while the
428 /// top-level context remains small and stable as services evolve.
429 pub execution: Box<ToolExecutionState>,
430 }
431
432 /// Policy and service state attached to one tool-execution context.
433 ///
434 /// `ToolContext` dereferences to this group for source compatibility with
435 /// existing tools. New code can use `context.execution` when the grouping is
436 /// useful, without growing the top-level context by another field per feature.
437 #[derive(Clone)]
438 pub struct ToolExecutionState {
439 /// Shared shell manager for background tasks and streaming IO.
440 pub shell_manager: SharedShellManager,
441 /// Per-session snapshots for files successfully observed by `read_file`.
442 /// Mutation tools use this to reject narrow edits against unread or stale
443 /// content.
444 pub file_read_tracker: SharedFileReadTracker,
445 /// Sub-agent that owns tool work started through this context. Root user
446 /// turns leave this unset; child contexts stamp it so long-running shell
447 /// jobs can be attributed in UI surfaces.
448 pub owner_agent_id: Option<String>,
449 pub owner_agent_name: Option<String>,
450 /// Outer process authority cap installed by Fleet/headless dispatch.
451 /// `None` for ordinary interactive/root sessions.
452 pub(crate) tool_authority: Option<Arc<ToolAuthorityEnvelope>>,
453 /// Whether to allow paths outside workspace
454 pub trust_mode: bool,
455 /// Current sandbox policy
456 #[allow(dead_code)]
457 pub sandbox_policy: SandboxPolicy,
458 /// Path for notes file
459 pub notes_path: PathBuf,
460 /// MCP configuration path
461 #[allow(dead_code)]
462 pub mcp_config_path: PathBuf,
463 /// Explicit skills directory used for model-visible skill discovery.
464 pub skills_dir: Option<PathBuf>,
465 /// Restrict skill discovery to CodeWhale-owned roots plus `skills_dir`.
466 pub skills_scan_codewhale_only: bool,
467 /// Immutable registry snapshot for this workspace/engine context.
468 pub plugin_registry: Option<Arc<crate::plugins::PluginRegistry>>,
469 /// Elevated sandbox policy override (used when retrying after sandbox denial).
470 /// This overrides the default sandbox behavior for shell commands.
471 pub elevated_sandbox_policy: Option<crate::sandbox::SandboxPolicy>,
472 /// Optional user-facing hint for shell commands that fail because the
473 /// active sandbox policy intentionally denies outbound network access.
474 pub shell_network_denied_hint: Option<String>,
475 /// Whether tools should auto-approve without safety checks (YOLO mode).
476 /// When true, command safety analysis is skipped for shell execution.
477 pub auto_approve: bool,
478 /// Effective shell policy for this execution context.
479 pub shell_policy: ShellPolicy,
480 /// Effective feature flag set for the running session.
481 pub features: Features,
482 /// Namespace for tool state that should be scoped to the current session/thread.
483 pub state_namespace: String,
484 /// Effective context window for the active provider/model route. Web tools
485 /// use this to keep inline page content below three percent of the route.
486 pub route_context_window: Option<u32>,
487 /// User-trusted external paths the agent may read/write even when they
488 /// fall outside `workspace`. Loaded from `~/.deepseek/workspace-trust.json`
489 /// and refreshed when the user runs `/trust add <path>`. Distinct from
490 /// `trust_mode`, which is the all-or-nothing legacy switch (#29).
491 pub trusted_external_paths: Vec<PathBuf>,
492 /// Whether to follow symbolic links during file discovery and tool
493 /// operations. When `true`, symlinked directories are traversed and
494 /// symlinked paths that resolve outside the workspace are still allowed
495 /// (the symlink itself must be inside the workspace). Mirrors the
496 /// `workspace_follow_symlinks` setting.
497 pub follow_symlinks: bool,
498 /// Per-domain network policy (#135). When `None`, network tools fall back
499 /// to a permissive default that mirrors pre-v0.7.0 behavior so tests and
500 /// other contexts that don't construct a real policy keep working.
501 pub network_policy: Option<NetworkPolicyDecider>,
502 /// Durable runtime services for task, gate, PR-attempt, GitHub evidence,
503 /// and automation tools.
504 pub runtime: RuntimeToolServices,
505 /// Snapshot of the active prompt/session/history exposed as symbolic RLM
506 /// objects. Tools only receive compact cards unless explicitly opening a
507 /// bounded object through `rlm_open`.
508 pub session_objects: Option<SessionObjectSnapshot>,
509 /// Cancellation token for the active engine turn. Tools that may wait on
510 /// external work should observe this so UI cancel can interrupt them.
511 pub cancel_token: Option<CancellationToken>,
512 /// Optional external sandbox backend for shell execution.
513 /// When set, exec_shell routes commands through this instead of spawning
514 /// a local process.
515 pub sandbox_backend: Option<std::sync::Arc<dyn SandboxBackend>>,
516 /// Path to the user memory file. `None` when the user-memory feature
517 /// (#489) is disabled — tools that read or write the file should
518 /// short-circuit on `None` rather than fall back to a workspace-local
519 /// default.
520 pub memory_path: Option<PathBuf>,
521 /// LSP manager for post-edit diagnostics injection (#428). `None` when
522 /// LSP is disabled or the context is constructed in a test that does not
523 /// need diagnostics. Edit tools append a `<diagnostics>` block to their
524 /// result when this is present and the manager is enabled.
525 pub lsp_manager: Option<Arc<LspManager>>,
526
527 /// Large-output router (#548). When `Some`, tool results that exceed the
528 /// configured token threshold are routed through a V4-Flash synthesis
529 /// sub-agent before being returned to the parent context. `None` disables
530 /// routing (e.g. in sub-agents and test contexts to avoid recursion).
531 pub large_output_router: Option<crate::tools::large_output_router::LargeOutputRouter>,
532
533 /// Which search backend `web_search` should use. Default: DuckDuckGo. Set via
534 /// `[search] provider` in config.toml.
535 pub search_provider: crate::config::SearchProvider,
536 /// API key for Tavily, Bocha, Metaso, Baidu, Volcengine, or Sofya.
537 /// `None` for Bing, DuckDuckGo, or SearXNG.
538 /// Metaso also falls back to the `METASO_API_KEY` env var.
539 /// Baidu also falls back to `BAIDU_SEARCH_API_KEY`.
540 pub search_api_key: Option<String>,
541 /// Optional DuckDuckGo-compatible HTML endpoint override for `web_search`.
542 pub search_base_url: Option<String>,
543 /// Opaque client for the active route's documented first-party search
544 /// tool. It owns provider authentication internally and is attached only
545 /// when the exact route capability says server-side search is supported.
546 pub(crate) provider_native_search: Option<crate::client::ProviderNativeSearchClient>,
547 /// Exact active route capability facts. Unknown stays fail-closed.
548 pub(crate) route_capabilities: codewhale_config::route::RouteCapabilities,
549
550 /// Per-session workshop variable store (#548). Holds the raw content of
551 /// the most recent large-tool routing event so the parent can call
552 /// `promote_to_context` later. `None` when the router is disabled.
553 pub workshop_vars: Option<
554 std::sync::Arc<tokio::sync::Mutex<crate::tools::large_output_router::WorkshopVariables>>,
555 >,
556 }
557
558 impl std::ops::Deref for ToolContext {
559 type Target = ToolExecutionState;
560
561 fn deref(&self) -> &Self::Target {
562 &self.execution
563 }
564 }
565
566 impl std::ops::DerefMut for ToolContext {
567 fn deref_mut(&mut self) -> &mut Self::Target {
568 &mut self.execution
569 }
570 }
571
572 impl ToolContext {
573 /// Create a new `ToolContext` with default settings.
574 #[must_use]
575 pub fn new(workspace: impl Into<PathBuf>) -> Self {
576 let workspace = workspace.into();
577 // Prefer .codewhale, fall back to .deepseek for project-local state
578 let notes_path = codewhale_config::resolve_project_state_dir(&workspace, "notes.md")
579 .expect("hardcoded project notes state path is valid")
580 .1;
581 let mcp_config_path = codewhale_config::resolve_project_state_dir(&workspace, "mcp.json")
582 .expect("hardcoded project MCP state path is valid")
583 .1;
584 Self::with_options(workspace, false, notes_path, mcp_config_path)
585 }
586
587 /// Create a `ToolContext` with all settings specified.
588 #[allow(dead_code)]
589 pub fn with_options(
590 workspace: impl Into<PathBuf>,
591 trust_mode: bool,
592 notes_path: impl Into<PathBuf>,
593 mcp_config_path: impl Into<PathBuf>,
594 ) -> Self {
595 let workspace = workspace.into();
596 let shell_manager = new_shared_shell_manager(workspace.clone());
597 Self {
598 workspace,
599 execution: Box::new(ToolExecutionState {
600 shell_manager,
601 file_read_tracker: new_shared_file_read_tracker(),
602 owner_agent_id: None,
603 owner_agent_name: None,
604 tool_authority: process_tool_authority(),
605 trust_mode,
606 sandbox_policy: SandboxPolicy::None,
607 notes_path: notes_path.into(),
608 mcp_config_path: mcp_config_path.into(),
609 skills_dir: None,
610 skills_scan_codewhale_only: false,
611 plugin_registry: None,
612 elevated_sandbox_policy: None,
613 shell_network_denied_hint: None,
614 auto_approve: false,
615 shell_policy: ShellPolicy::Full,
616 features: Features::with_defaults(),
617 state_namespace: "workspace".to_string(),
618 route_context_window: None,
619 trusted_external_paths: Vec::new(),
620 follow_symlinks: false,
621 network_policy: None,
622 runtime: RuntimeToolServices::default(),
623 session_objects: None,
624 cancel_token: None,
625 sandbox_backend: None,
626 memory_path: None,
627 lsp_manager: None,
628 large_output_router: None,
629 search_provider: crate::config::SearchProvider::default(),
630 search_api_key: None,
631 search_base_url: None,
632 provider_native_search: None,
633 route_capabilities: codewhale_config::route::RouteCapabilities::default(),
634 workshop_vars: None,
635 }),
636 }
637 }
638
639 /// Create a `ToolContext` with auto-approve mode (YOLO).
640 pub fn with_auto_approve(
641 workspace: impl Into<PathBuf>,
642 trust_mode: bool,
643 notes_path: impl Into<PathBuf>,
644 mcp_config_path: impl Into<PathBuf>,
645 auto_approve: bool,
646 ) -> Self {
647 let mut context = Self::with_options(workspace, trust_mode, notes_path, mcp_config_path);
648 context.auto_approve = auto_approve;
649 context
650 }
651
652 /// Attach a per-domain network policy to this context (#135).
653 #[must_use]
654 pub fn with_network_policy(mut self, policy: NetworkPolicyDecider) -> Self {
655 self.network_policy = Some(policy);
656 self
657 }
658
659 /// Attach durable runtime services to tools.
660 #[must_use]
661 pub fn with_runtime_services(mut self, runtime: RuntimeToolServices) -> Self {
662 self.runtime = runtime;
663 self
664 }
665
666 /// Stamp tool work with the sub-agent that owns it.
667 #[must_use]
668 pub fn with_owner_agent(
669 mut self,
670 agent_id: impl Into<String>,
671 agent_name: impl Into<String>,
672 ) -> Self {
673 let agent_id = agent_id.into();
674 let agent_name = agent_name.into();
675 self.owner_agent_id = (!agent_id.trim().is_empty()).then_some(agent_id);
676 self.owner_agent_name = (!agent_name.trim().is_empty()).then_some(agent_name);
677 self
678 }
679
680 #[cfg(test)]
681 pub(crate) fn with_tool_authority(
682 mut self,
683 envelope: ToolAuthorityEnvelope,
684 ) -> Result<Self, String> {
685 let envelope = envelope.normalized()?;
686 if let Some(outer) = self.tool_authority.as_ref()
687 && !envelope.is_within(outer)
688 {
689 return Err(
690 "nested tool authority cannot expand its process authority cap".to_string(),
691 );
692 }
693 self.tool_authority = Some(Arc::new(envelope));
694 Ok(self)
695 }
696
697 /// Attach skill discovery settings for tools that need to resolve
698 /// model-visible skills by name.
699 #[must_use]
700 pub fn with_skills_config(
701 mut self,
702 skills_dir: impl Into<PathBuf>,
703 scan_codewhale_only: bool,
704 ) -> Self {
705 self.skills_dir = Some(skills_dir.into());
706 self.skills_scan_codewhale_only = scan_codewhale_only;
707 self
708 }
709
710 #[must_use]
711 pub fn with_plugin_registry(mut self, registry: Arc<crate::plugins::PluginRegistry>) -> Self {
712 self.plugin_registry = Some(registry);
713 self
714 }
715
716 /// Attach active prompt/history/session symbolic objects for RLM tools.
717 #[must_use]
718 pub fn with_session_objects(mut self, snapshot: SessionObjectSnapshot) -> Self {
719 self.session_objects = Some(snapshot);
720 self
721 }
722
723 /// Attach the active engine cancellation token.
724 #[must_use]
725 pub fn with_cancel_token(mut self, cancel_token: CancellationToken) -> Self {
726 self.cancel_token = Some(cancel_token);
727 self
728 }
729
730 /// Attach the effective shell policy for this turn.
731 #[must_use]
732 pub fn with_shell_policy(mut self, policy: ShellPolicy) -> Self {
733 self.shell_policy = policy;
734 self
735 }
736
737 /// Attach an external sandbox backend for remote shell execution.
738 #[must_use]
739 #[allow(dead_code)]
740 pub fn with_sandbox_backend(mut self, backend: std::sync::Arc<dyn SandboxBackend>) -> Self {
741 self.sandbox_backend = Some(backend);
742 self
743 }
744
745 /// Set the user's trusted external paths (loaded from
746 /// `~/.deepseek/workspace-trust.json`). See [`Self::resolve_path`] for
747 /// how the list is consulted.
748 #[must_use]
749 pub fn with_trusted_external_paths(mut self, paths: Vec<PathBuf>) -> Self {
750 self.trusted_external_paths = paths;
751 self
752 }
753
754 /// Set whether tools should follow symbolic links. When `true`,
755 /// `resolve_path` allows symlinked paths that resolve outside the
756 /// workspace, and walk-based tools traverse symlinked directories.
757 /// Mirrors the `workspace_follow_symlinks` setting.
758 #[must_use]
759 pub fn with_follow_symlinks(mut self, follow: bool) -> Self {
760 self.follow_symlinks = follow;
761 self
762 }
763
764 /// Attach an LSP manager so that edit tools can auto-inject diagnostics
765 /// into their results after a successful file modification (#428).
766 #[must_use]
767 #[allow(dead_code)]
768 pub fn with_lsp_manager(mut self, manager: Arc<LspManager>) -> Self {
769 self.lsp_manager = Some(manager);
770 self
771 }
772
773 /// Remember that the caller has observed the current on-disk state of a
774 /// file. This is intentionally best-effort so successful reads/writes do
775 /// not fail after completing only because a post-operation metadata lookup
776 /// raced with filesystem changes.
777 pub fn note_file_read(&self, path: &Path) {
778 let Ok(snapshot) = file_read_snapshot(path) else {
779 return;
780 };
781 let Ok(mut tracker) = self.file_read_tracker.lock() else {
782 return;
783 };
784 tracker.reads.insert(path.to_path_buf(), snapshot);
785 }
786
787 /// Require a successful, still-fresh `read_file` snapshot before a narrow
788 /// in-place edit. This catches model edits made against guessed or stale
789 /// content while leaving transactional patch preflight separate.
790 pub fn require_fresh_file_read(
791 &self,
792 path: &Path,
793 requested_path: &str,
794 ) -> Result<(), ToolError> {
795 let prior = {
796 let tracker = self.file_read_tracker.lock().map_err(|_| {
797 ToolError::execution_failed(
798 "Failed to check read-before-edit state: tracker lock poisoned".to_string(),
799 )
800 })?;
801 tracker.reads.get(path).cloned()
802 };
803
804 let Some(prior) = prior else {
805 return Err(ToolError::execution_failed(format!(
806 "Refusing File action=\"edit\" for {} because it has not been read in this session. \
807 Recovery: call File with action=\"read\" path=\"{requested_path}\" to inspect the current contents, \
808 then retry File action=\"edit\" with a unique search string.",
809 path.display()
810 )));
811 };
812
813 let current = file_read_snapshot(path).map_err(|e| {
814 ToolError::execution_failed(format!(
815 "Refusing File action=\"edit\" for {} because the file could not be checked for staleness ({e}). \
816 Recovery: call File with action=\"read\" path=\"{requested_path}\" again, then retry File action=\"edit\".",
817 path.display()
818 ))
819 })?;
820
821 if current != prior {
822 return Err(ToolError::execution_failed(format!(
823 "Refusing File action=\"edit\" for {} because it changed since the last File action=\"read\" call. \
824 Recovery: call File with action=\"read\" path=\"{requested_path}\" again and retry with the current contents.",
825 path.display()
826 )));
827 }
828
829 Ok(())
830 }
831
832 /// Resolve a path relative to workspace, validating it doesn't escape.
833 ///
834 /// This handles both existing files (using canonicalize) and non-existent files
835 /// (for write operations) by canonicalizing the parent directory and appending
836 /// the filename.
837 /// Resolve a path relative to workspace, validating it doesn't escape.
838 ///
839 /// # Examples
840 ///
841 /// ```ignore
842 /// # use crate::tools::spec::ToolContext;
843 /// let ctx = ToolContext::new(".");
844 /// let path = ctx.resolve_path("README.md")?;
845 /// # Ok::<(), crate::tools::spec::ToolError>(())
846 /// ```
847 pub fn resolve_path(&self, raw: &str) -> Result<PathBuf, ToolError> {
848 let candidate = if std::path::Path::new(raw).is_absolute() {
849 PathBuf::from(raw)
850 } else {
851 self.workspace.join(raw)
852 };
853
854 // In trust mode, allow any path without validation
855 if self.trust_mode {
856 // Still try to canonicalize for consistency, but don't require it
857 return Ok(candidate.canonicalize().unwrap_or(candidate));
858 }
859
860 // Try to canonicalize the workspace
861 let workspace_canonical = self
862 .workspace
863 .canonicalize()
864 .unwrap_or_else(|_| self.workspace.clone());
865
866 // When follow_symlinks is enabled, check the non-canonical (symlink)
867 // path against the workspace first. A symlink inside the workspace
868 // that resolves outside is allowed — the symlink itself is the gate.
869 if self.follow_symlinks {
870 let candidate_normalized = normalize_path(&candidate);
871 let workspace_normalized = normalize_path(&self.workspace);
872 let workspace_canonical_normalized = normalize_path(&workspace_canonical);
873
874 if candidate_normalized.starts_with(&workspace_normalized)
875 || candidate_normalized.starts_with(&workspace_canonical_normalized)
876 {
877 // The symlink (or plain path) is inside the workspace.
878 // Return the canonicalized target so file I/O works correctly.
879 if candidate.exists() {
880 return Ok(candidate.canonicalize().unwrap_or(candidate));
881 }
882 // Non-existent path: canonicalize the deepest existing ancestor
883 return self.resolve_nonexistent_path(candidate, &workspace_canonical);
884 }
885
886 // Path is outside workspace even before resolving symlinks.
887 // Fall through to the standard escape check.
888 }
889
890 // For the initial check, also try to canonicalize the candidate if possible
891 // This handles symlinks like /var -> /private/var on macOS
892 let candidate_canonical = candidate
893 .canonicalize()
894 .unwrap_or_else(|_| normalize_path(&candidate));
895 let workspace_normalized = normalize_path(&workspace_canonical);
896
897 // Check if the candidate is under the workspace (comparing canonical paths)
898 if !candidate_canonical.starts_with(&workspace_normalized) {
899 // Also try with non-canonical workspace for cases where workspace itself
900 // hasn't been canonicalized yet
901 let workspace_plain = normalize_path(&self.workspace);
902 let candidate_normalized = normalize_path(&candidate);
903 if !candidate_normalized.starts_with(&workspace_plain)
904 && !self.is_trusted_external_path(&candidate_canonical)
905 && !self.is_trusted_external_path(&candidate_normalized)
906 {
907 return Err(ToolError::PathEscape {
908 path: candidate_canonical,
909 });
910 }
911 }
912
913 // For existing paths, use canonicalize directly
914 if candidate.exists() {
915 let canonical = candidate.canonicalize().map_err(|e| {
916 ToolError::execution_failed(format!(
917 "Failed to canonicalize {}: {}",
918 candidate.display(),
919 e
920 ))
921 })?;
922
923 if !canonical.starts_with(&workspace_canonical)
924 && !self.is_trusted_external_path(&canonical)
925 {
926 return Err(ToolError::PathEscape { path: canonical });
927 }
928
929 return Ok(canonical);
930 }
931
932 self.resolve_nonexistent_path(candidate, &workspace_canonical)
933 }
934
935 /// Resolve a non-existent path by canonicalizing its deepest existing
936 /// ancestor and validating the result is under the workspace or a
937 /// trusted external path.
938 fn resolve_nonexistent_path(
939 &self,
940 candidate: PathBuf,
941 workspace_canonical: &Path,
942 ) -> Result<PathBuf, ToolError> {
943 let workspace_normalized = normalize_path(workspace_canonical);
944 let workspace_plain = normalize_path(&self.workspace);
945 let mut existing_ancestor = candidate.clone();
946 let mut suffix_parts: Vec<std::ffi::OsString> = Vec::new();
947
948 while !existing_ancestor.exists() {
949 if let Some(file_name) = existing_ancestor.file_name() {
950 suffix_parts.push(file_name.to_owned());
951 }
952 match existing_ancestor.parent() {
953 Some(parent) if !parent.as_os_str().is_empty() => {
954 existing_ancestor = parent.to_path_buf();
955 }
956 _ => {
957 // No existing parent found; fall back to simple check
958 break;
959 }
960 }
961 }
962 let ancestor_normalized = normalize_path(&existing_ancestor);
963
964 let canonical_ancestor = if existing_ancestor.exists() {
965 existing_ancestor
966 .canonicalize()
967 .unwrap_or(existing_ancestor)
968 } else {
969 existing_ancestor
970 };
971
972 // Rebuild the full path from canonicalized ancestor
973 let mut canonical = canonical_ancestor;
974 for part in suffix_parts.into_iter().rev() {
975 canonical.push(part);
976 }
977 let canonical = normalize_path(&canonical);
978
979 if self.follow_symlinks
980 && (ancestor_normalized.starts_with(&workspace_plain)
981 || ancestor_normalized.starts_with(&workspace_normalized))
982 {
983 return Ok(canonical);
984 }
985
986 // Validate it's under workspace, OR is under a user-trusted external
987 // path (`/trust add <path>` from the slash command, persisted in
988 // `~/.deepseek/workspace-trust.json`).
989 if !canonical.starts_with(workspace_canonical)
990 && !canonical.starts_with(&workspace_normalized)
991 && !self.is_trusted_external_path(&canonical)
992 {
993 return Err(ToolError::PathEscape { path: canonical });
994 }
995
996 Ok(canonical)
997 }
998
999 /// Whether `path` is under any of the user-trusted external roots. The
1000 /// caller should pass an already-canonicalized (or normalized) path.
1001 fn is_trusted_external_path(&self, path: &Path) -> bool {
1002 self.trusted_external_paths
1003 .iter()
1004 .any(|trusted| path.starts_with(trusted))
1005 }
1006
1007 /// Set the trust mode.
1008 #[allow(dead_code)]
1009 pub fn with_trust_mode(mut self, trust: bool) -> Self {
1010 self.trust_mode = trust;
1011 self
1012 }
1013
1014 /// Set the sandbox policy.
1015 #[allow(dead_code)]
1016 pub fn with_sandbox_policy(mut self, policy: SandboxPolicy) -> Self {
1017 self.sandbox_policy = policy;
1018 self
1019 }
1020
1021 /// Set feature flags for tool execution.
1022 pub fn with_features(mut self, features: Features) -> Self {
1023 self.features = features;
1024 self
1025 }
1026
1027 /// Override the shared shell manager.
1028 pub fn with_shell_manager(mut self, shell_manager: SharedShellManager) -> Self {
1029 self.shell_manager = shell_manager;
1030 self
1031 }
1032
1033 /// Reuse the engine's session-scoped read snapshots across tool-context
1034 /// rebuilds. A fresh context is assembled for each turn, but successful
1035 /// reads must remain authoritative until the observed file changes.
1036 pub fn with_file_read_tracker(mut self, tracker: SharedFileReadTracker) -> Self {
1037 self.file_read_tracker = tracker;
1038 self
1039 }
1040
1041 /// Set the elevated sandbox policy override.
1042 ///
1043 /// This is used when retrying a tool after a sandbox denial, to run
1044 /// with elevated permissions.
1045 pub fn with_elevated_sandbox_policy(mut self, policy: crate::sandbox::SandboxPolicy) -> Self {
1046 self.elevated_sandbox_policy = Some(policy);
1047 self
1048 }
1049
1050 /// Set the shell network-denial hint used by network-restricted modes.
1051 pub fn with_shell_network_denied_hint(mut self, hint: impl Into<String>) -> Self {
1052 self.shell_network_denied_hint = Some(hint.into());
1053 self
1054 }
1055
1056 /// Set the namespace used for session-scoped tool state.
1057 pub fn with_state_namespace(mut self, namespace: impl Into<String>) -> Self {
1058 self.state_namespace = namespace.into();
1059 self
1060 }
1061
1062 /// Attach the active route's effective context window.
1063 #[must_use]
1064 pub fn with_route_context_window(mut self, context_window: u32) -> Self {
1065 self.route_context_window = (context_window > 0).then_some(context_window);
1066 self
1067 }
1068
1069 /// Attach the large-output router (#548). When set, tool results that
1070 /// exceed the configured token threshold are synthesised by a V4-Flash
1071 /// sub-agent before being returned to the parent context.
1072 #[must_use]
1073 pub fn with_large_output_router(
1074 mut self,
1075 router: crate::tools::large_output_router::LargeOutputRouter,
1076 vars: std::sync::Arc<
1077 tokio::sync::Mutex<crate::tools::large_output_router::WorkshopVariables>,
1078 >,
1079 ) -> Self {
1080 self.large_output_router = Some(router);
1081 self.workshop_vars = Some(vars);
1082 self
1083 }
1084 }
1085
1086 /// Gather LSP diagnostics for `paths` using the manager stored in `context`,
1087 /// and return the rendered `<diagnostics …>` blocks joined by newlines.
1088 ///
1089 /// Returns an empty string when:
1090 /// - `context.lsp_manager` is `None`
1091 /// - the manager's `enabled` flag is `false`
1092 /// - none of the files produce diagnostics (e.g. all clean, or language unknown)
1093 ///
1094 /// This function is non-blocking by design: every failure mode (missing LSP
1095 /// binary, timeout, unknown language) degrades to an empty string rather than
1096 /// propagating an error to the caller.
1097 pub async fn lsp_diagnostics_for_paths(context: &ToolContext, paths: &[PathBuf]) -> String {
1098 use crate::lsp::render_blocks;
1099
1100 let manager = match context.lsp_manager.as_ref() {
1101 Some(m) if m.config().enabled => m,
1102 _ => return String::new(),
1103 };
1104
1105 let mut blocks = Vec::new();
1106 for (idx, path) in paths.iter().enumerate() {
1107 if let Some(block) = manager.diagnostics_for(path, idx as u64).await {
1108 blocks.push(block);
1109 }
1110 }
1111
1112 render_blocks(&blocks)
1113 }
1114
1115 pub(crate) fn normalize_path(path: &Path) -> PathBuf {
1116 let mut prefix: Option<std::ffi::OsString> = None;
1117 let mut is_root = false;
1118 let mut stack: Vec<std::ffi::OsString> = Vec::new();
1119
1120 for component in path.components() {
1121 match component {
1122 Component::Prefix(prefix_component) => {
1123 prefix = Some(prefix_component.as_os_str().to_owned());
1124 }
1125 Component::RootDir => {
1126 is_root = true;
1127 }
1128 Component::CurDir => {}
1129 Component::ParentDir => {
1130 let parent = Component::ParentDir.as_os_str();
1131 if let Some(last) = stack.pop() {
1132 if last == parent {
1133 stack.push(last);
1134 stack.push(parent.to_owned());
1135 }
1136 } else if !is_root {
1137 stack.push(parent.to_owned());
1138 }
1139 }
1140 Component::Normal(part) => {
1141 stack.push(part.to_owned());
1142 }
1143 }
1144 }
1145
1146 let mut normalized = PathBuf::new();
1147 if let Some(prefix) = prefix {
1148 normalized.push(prefix);
1149 }
1150 if is_root {
1151 normalized.push(Path::new(std::path::MAIN_SEPARATOR_STR));
1152 }
1153 for part in stack {
1154 normalized.push(part);
1155 }
1156 normalized
1157 }
1158
1159 /// The core trait that all tools must implement.
1160 #[async_trait]
1161 pub trait ToolSpec: Send + Sync {
1162 /// Returns the unique name of this tool (used in API calls).
1163 fn name(&self) -> &str;
1164
1165 /// Returns a human-readable description of what this tool does.
1166 fn description(&self) -> &str;
1167
1168 /// Returns the JSON Schema for the tool's input parameters.
1169 fn input_schema(&self) -> Value;
1170
1171 /// Returns the capabilities this tool has.
1172 fn capabilities(&self) -> Vec<ToolCapability>;
1173
1174 /// Returns the approval requirement for this tool.
1175 fn approval_requirement(&self) -> ApprovalRequirement {
1176 let caps = self.capabilities();
1177 if caps.contains(&ToolCapability::ExecutesCode) {
1178 ApprovalRequirement::Required
1179 } else if caps.contains(&ToolCapability::WritesFiles) {
1180 ApprovalRequirement::Suggest
1181 } else {
1182 ApprovalRequirement::Auto
1183 }
1184 }
1185
1186 /// Returns the approval requirement for this concrete tool input.
1187 fn approval_requirement_for(&self, _input: &Value) -> ApprovalRequirement {
1188 self.approval_requirement()
1189 }
1190
1191 /// Returns whether this tool is sandboxable.
1192 #[allow(dead_code)]
1193 fn is_sandboxable(&self) -> bool {
1194 self.capabilities().contains(&ToolCapability::Sandboxable)
1195 }
1196
1197 /// Returns whether this tool is read-only.
1198 fn is_read_only(&self) -> bool {
1199 let caps = self.capabilities();
1200 caps.contains(&ToolCapability::ReadOnly)
1201 && !caps.contains(&ToolCapability::WritesFiles)
1202 && !caps.contains(&ToolCapability::ExecutesCode)
1203 }
1204
1205 /// Returns whether this concrete tool input is read-only.
1206 fn is_read_only_for(&self, _input: &Value) -> bool {
1207 self.is_read_only()
1208 }
1209
1210 /// Returns whether this tool can be executed in parallel with others.
1211 fn supports_parallel(&self) -> bool {
1212 false
1213 }
1214
1215 /// Returns whether this concrete tool input can run in parallel.
1216 fn supports_parallel_for(&self, _input: &Value) -> bool {
1217 self.supports_parallel()
1218 }
1219
1220 /// Returns whether this input starts durable/detached work and returns
1221 /// immediately. Detached starts are not read-only, but in auto-approved
1222 /// turns they do not need to block neighboring read-only inspections.
1223 fn starts_detached_for(&self, _input: &Value) -> bool {
1224 false
1225 }
1226
1227 /// Resolve input-specific policy without performing external side effects.
1228 ///
1229 /// Resource claims deliberately default to global exclusivity until a
1230 /// first-party tool opts into narrower, canonicalized claims. The initial
1231 /// seam records this decision but leaves the existing scheduler unchanged.
1232 fn prepare(&self, input: Value, _context: &ToolContext) -> Result<PreparedToolCall, ToolError> {
1233 Ok(PreparedToolCall {
1234 name: self.name().to_string(),
1235 description: self.description().to_string(),
1236 read_only: self.is_read_only_for(&input),
1237 supports_parallel: self.supports_parallel_for(&input),
1238 starts_detached: self.starts_detached_for(&input),
1239 approval: self.approval_requirement_for(&input),
1240 resources: vec![ResourceClaim::GlobalExclusive],
1241 input,
1242 })
1243 }
1244
1245 /// Returns whether this tool should be excluded from the model-visible
1246 /// tool catalog (deferred loading). Tools marked `true` are registered
1247 /// but not sent to the model until explicitly activated via tool search.
1248 fn defer_loading(&self) -> bool {
1249 false
1250 }
1251
1252 /// Returns whether this tool should be advertised in the model-facing
1253 /// catalog. Hidden compatibility tools remain registered and executable
1254 /// by name so saved transcripts can replay without teaching new sessions
1255 /// the deprecated spelling.
1256 fn model_visible(&self) -> bool {
1257 true
1258 }
1259
1260 /// Execute the tool with the given input and context.
1261 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError>;
1262 }
1263
1264 #[cfg(test)]
1265 mod tests;
1266
1266 lines RUST