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