Skip to main content

kaish_kernel/tools/
context.rs

1//! Execution context for tools.
2
3use std::collections::HashMap;
4use std::path::{Component, PathBuf};
5use std::sync::Arc;
6
7use crate::ast::Value;
8use crate::backend::{KernelBackend, LocalBackend};
9use crate::dispatch::PipelinePosition;
10use crate::ignore_config::IgnoreConfig;
11use crate::interpreter::{ExecResult, Scope};
12use crate::nonce::NonceStore;
13use crate::output_limit::OutputLimitConfig;
14use crate::scheduler::{JobManager, PipeReader, PipeWriter, StderrStream};
15use crate::tools::ToolRegistry;
16use crate::trash::TrashBackend;
17use crate::vfs::VfsRouter;
18use kaish_vfs::ByteBudget;
19use tokio_util::sync::CancellationToken;
20
21use crate::interpreter::OutputFormat;
22
23use super::traits::ToolSchema;
24
25/// Output context determines how command output should be formatted.
26///
27/// Different contexts prefer different output formats:
28/// - **Interactive** — Pretty columns, colors, traditional tree (TTY/REPL)
29/// - **Piped** — Raw output for pipeline processing
30/// - **Model** — Token-efficient compact formats (MCP server / agent context)
31/// - **Script** — Non-interactive script execution
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
33pub enum OutputContext {
34    /// Interactive TTY/REPL - use human-friendly format with colors.
35    #[default]
36    Interactive,
37    /// Output to another command - use raw output for pipes.
38    Piped,
39    /// MCP server / agent context - use token-efficient model format.
40    Model,
41    /// Non-interactive script - use raw output.
42    Script,
43}
44
45/// Execution context passed to tools.
46///
47/// Provides access to the backend (for file operations and tool dispatch),
48/// scope, and other kernel state.
49pub struct ExecContext {
50    /// Kernel backend for I/O operations.
51    ///
52    /// This is the preferred way to access filesystem operations.
53    /// Use `backend.read()`, `backend.write()`, etc.
54    pub backend: Arc<dyn KernelBackend>,
55    /// Variable scope.
56    pub scope: Scope,
57    /// Current working directory (VFS path).
58    pub cwd: PathBuf,
59    /// Previous working directory (for `cd -`).
60    pub prev_cwd: Option<PathBuf>,
61    /// Standard input for the tool (from pipeline).
62    pub stdin: Option<String>,
63    /// Structured data from pipeline (pre-parsed JSON from previous command).
64    /// Tools can check this before parsing stdin to avoid redundant JSON parsing.
65    pub stdin_data: Option<Value>,
66    /// Streaming pipe input (set when this command is in a concurrent pipeline).
67    pub pipe_stdin: Option<PipeReader>,
68    /// Streaming pipe output (set when this command is in a concurrent pipeline).
69    pub pipe_stdout: Option<PipeWriter>,
70    /// Tool schemas for help command.
71    pub tool_schemas: Vec<ToolSchema>,
72    /// Tool registry reference (for tools that need to inspect available tools).
73    pub tools: Option<Arc<ToolRegistry>>,
74    /// Job manager for background jobs (optional).
75    pub job_manager: Option<Arc<JobManager>>,
76    /// Kernel stderr stream for real-time error output from pipeline stages.
77    ///
78    /// When set, pipeline stages write stderr here instead of buffering in
79    /// `ExecResult.err`. This allows stderr from all stages to stream to
80    /// the terminal (or other sink) concurrently, matching bash behavior.
81    pub stderr: Option<StderrStream>,
82    /// Position of this command within a pipeline (for stdio decisions).
83    pub pipeline_position: PipelinePosition,
84    /// Whether we're running in interactive (REPL) mode.
85    pub interactive: bool,
86    /// Command aliases (name → expansion string).
87    pub aliases: HashMap<String, String>,
88    /// Ignore file configuration for file-walking tools.
89    pub ignore_config: IgnoreConfig,
90    /// Output size limit configuration for agent safety.
91    pub output_limit: OutputLimitConfig,
92    /// Whether external command execution is allowed.
93    ///
94    /// When `false`, external commands (PATH lookup, `exec`, `spawn`) are blocked.
95    /// Only kaish builtins and backend-registered tools (MCP) are available.
96    pub allow_external_commands: bool,
97    /// Confirmation nonce store for latch-gated operations.
98    ///
99    /// Arc-shared across pipeline stages so nonces issued in one stage
100    /// can be validated in another.
101    pub nonce_store: NonceStore,
102    /// Trash backend for safe file deletion.
103    ///
104    /// Always present when the kernel creates the context (even if `set -o trash`
105    /// is off — the backend exists so `kaish-trash list/restore/empty` work
106    /// regardless of the trash flag).
107    pub trash_backend: Option<Arc<dyn TrashBackend>>,
108    /// Terminal state for job control (interactive mode, Unix only).
109    #[cfg(all(unix, feature = "subprocess"))]
110    pub terminal_state: Option<std::sync::Arc<crate::terminal::TerminalState>>,
111    /// Command dispatcher for re-dispatching through the full resolution chain.
112    ///
113    /// When set (via `Kernel::into_arc()`), builtins like `timeout` can dispatch
114    /// inner commands through the full chain (user tools → builtins → .kai scripts
115    /// → external commands) instead of being limited to `backend.call_tool()`.
116    ///
117    /// `None` when the Kernel was not wrapped via `into_arc()`.
118    pub dispatcher: Option<Arc<dyn crate::dispatch::CommandDispatcher>>,
119    /// Cancellation token for this execution path.
120    ///
121    /// Populated by the kernel at execute entry, then propagated through pipeline
122    /// stages, foreground forks (scatter workers, concurrent pipeline stages,
123    /// `$(...)` cmdsubs), and into spawned external children. When the token
124    /// fires, externals receive SIGTERM/SIGKILL via the `wait_or_kill` helper.
125    ///
126    /// Default for stand-alone `ExecContext` constructors is a fresh, never-fired
127    /// token so non-kernel test contexts behave as before.
128    pub cancel: CancellationToken,
129    /// Per-execution output format override set by a builtin's GlobalFlags
130    /// flatten (e.g. `--json`). The dispatcher reads this after `tool.execute()`
131    /// returns and applies the format via `apply_output_format`.
132    ///
133    /// Builtins set this via `GlobalFlags::apply(ctx)`; external commands
134    /// don't touch it.
135    pub output_format: Option<OutputFormat>,
136
137    /// Shared VFS memory budget for this kernel's `MemoryFs` mounts.
138    ///
139    /// `Arc`-cloned from the owning `Kernel` (or its fork parent) so all
140    /// concurrent execution paths draw from the same pool. `None` means
141    /// unbounded. Populated by `Kernel::assemble` and forwarded through
142    /// `child_for_pipeline` / `fork_inner` so background jobs and scatter
143    /// workers see the same cap as foreground execution.
144    pub vfs_budget: Option<Arc<ByteBudget>>,
145
146    /// The per-execute timeout watchdog, when a script timeout is in effect.
147    ///
148    /// Populated by the kernel at execute entry (alongside `cancel`) and
149    /// shared through `child_for_pipeline` so forks and pipeline stages can
150    /// acquire patient holds against the same script clock. `None` when no
151    /// timeout is configured — `ToolCtx::patient` then returns an inert guard.
152    pub watchdog: Option<Arc<crate::watchdog::Watchdog>>,
153
154    /// Active overlay handle when the kernel was constructed with `overlay: true`.
155    ///
156    /// `Arc`-cloned so forks and pipeline stages share the same transaction.
157    /// `None` when no overlay is active (most kernels).
158    #[cfg(all(feature = "localfs", feature = "overlay"))]
159    pub overlay_handle: Option<Arc<crate::kernel::OverlayHandle>>,
160}
161
162impl ExecContext {
163    /// Create a new execution context with a VFS (uses LocalBackend without tools).
164    ///
165    /// This constructor is for backward compatibility and tests that don't need tool dispatch.
166    /// For full tool support, use `with_vfs_and_tools`.
167    pub fn new(vfs: Arc<VfsRouter>) -> Self {
168        Self {
169            backend: Arc::new(LocalBackend::new(vfs)),
170            scope: Scope::new(),
171            cwd: PathBuf::from("/"),
172            prev_cwd: None,
173            stdin: None,
174            stdin_data: None,
175            pipe_stdin: None,
176            pipe_stdout: None,
177            stderr: None,
178            tool_schemas: Vec::new(),
179            tools: None,
180            job_manager: None,
181            pipeline_position: PipelinePosition::Only,
182            interactive: false,
183            aliases: HashMap::new(),
184            ignore_config: IgnoreConfig::none(),
185            output_limit: OutputLimitConfig::none(),
186            allow_external_commands: true,
187            nonce_store: NonceStore::new(),
188            trash_backend: None,
189            #[cfg(all(unix, feature = "subprocess"))]
190            terminal_state: None,
191            dispatcher: None,
192            cancel: CancellationToken::new(),
193            output_format: None,
194            vfs_budget: None,
195            watchdog: None,
196            #[cfg(all(feature = "localfs", feature = "overlay"))]
197            overlay_handle: None,
198        }
199    }
200
201    /// Create a new execution context with VFS and tool registry.
202    ///
203    /// This is the preferred constructor for full kaish operation where
204    /// tools need to be dispatched through the backend.
205    pub fn with_vfs_and_tools(vfs: Arc<VfsRouter>, tools: Arc<ToolRegistry>) -> Self {
206        Self {
207            backend: Arc::new(LocalBackend::with_tools(vfs, tools.clone())),
208            scope: Scope::new(),
209            cwd: PathBuf::from("/"),
210            prev_cwd: None,
211            stdin: None,
212            stdin_data: None,
213            pipe_stdin: None,
214            pipe_stdout: None,
215            stderr: None,
216            tool_schemas: Vec::new(),
217            tools: Some(tools),
218            job_manager: None,
219            pipeline_position: PipelinePosition::Only,
220            interactive: false,
221            aliases: HashMap::new(),
222            ignore_config: IgnoreConfig::none(),
223            output_limit: OutputLimitConfig::none(),
224            allow_external_commands: true,
225            nonce_store: NonceStore::new(),
226            trash_backend: None,
227            #[cfg(all(unix, feature = "subprocess"))]
228            terminal_state: None,
229            dispatcher: None,
230            cancel: CancellationToken::new(),
231            output_format: None,
232            vfs_budget: None,
233            watchdog: None,
234            #[cfg(all(feature = "localfs", feature = "overlay"))]
235            overlay_handle: None,
236        }
237    }
238
239    /// Create a new execution context with a custom backend.
240    pub fn with_backend(backend: Arc<dyn KernelBackend>) -> Self {
241        Self {
242            backend,
243            scope: Scope::new(),
244            cwd: PathBuf::from("/"),
245            prev_cwd: None,
246            stdin: None,
247            stdin_data: None,
248            pipe_stdin: None,
249            pipe_stdout: None,
250            stderr: None,
251            tool_schemas: Vec::new(),
252            tools: None,
253            job_manager: None,
254            pipeline_position: PipelinePosition::Only,
255            interactive: false,
256            aliases: HashMap::new(),
257            ignore_config: IgnoreConfig::none(),
258            output_limit: OutputLimitConfig::none(),
259            allow_external_commands: true,
260            nonce_store: NonceStore::new(),
261            trash_backend: None,
262            #[cfg(all(unix, feature = "subprocess"))]
263            terminal_state: None,
264            dispatcher: None,
265            cancel: CancellationToken::new(),
266            output_format: None,
267            vfs_budget: None,
268            watchdog: None,
269            #[cfg(all(feature = "localfs", feature = "overlay"))]
270            overlay_handle: None,
271        }
272    }
273
274    /// Create a context with VFS, tools, and a specific scope.
275    pub fn with_vfs_tools_and_scope(vfs: Arc<VfsRouter>, tools: Arc<ToolRegistry>, scope: Scope) -> Self {
276        Self {
277            backend: Arc::new(LocalBackend::with_tools(vfs, tools.clone())),
278            scope,
279            cwd: PathBuf::from("/"),
280            prev_cwd: None,
281            stdin: None,
282            stdin_data: None,
283            pipe_stdin: None,
284            pipe_stdout: None,
285            stderr: None,
286            tool_schemas: Vec::new(),
287            tools: Some(tools),
288            job_manager: None,
289            pipeline_position: PipelinePosition::Only,
290            interactive: false,
291            aliases: HashMap::new(),
292            ignore_config: IgnoreConfig::none(),
293            output_limit: OutputLimitConfig::none(),
294            allow_external_commands: true,
295            nonce_store: NonceStore::new(),
296            trash_backend: None,
297            #[cfg(all(unix, feature = "subprocess"))]
298            terminal_state: None,
299            dispatcher: None,
300            cancel: CancellationToken::new(),
301            output_format: None,
302            vfs_budget: None,
303            watchdog: None,
304            #[cfg(all(feature = "localfs", feature = "overlay"))]
305            overlay_handle: None,
306        }
307    }
308
309    /// Create a context with a specific scope (uses LocalBackend without tools).
310    ///
311    /// For tests that don't need tool dispatch. For full tool support,
312    /// use `with_vfs_tools_and_scope`.
313    pub fn with_scope(vfs: Arc<VfsRouter>, scope: Scope) -> Self {
314        Self {
315            backend: Arc::new(LocalBackend::new(vfs)),
316            scope,
317            cwd: PathBuf::from("/"),
318            prev_cwd: None,
319            stdin: None,
320            stdin_data: None,
321            pipe_stdin: None,
322            pipe_stdout: None,
323            stderr: None,
324            tool_schemas: Vec::new(),
325            tools: None,
326            job_manager: None,
327            pipeline_position: PipelinePosition::Only,
328            interactive: false,
329            aliases: HashMap::new(),
330            ignore_config: IgnoreConfig::none(),
331            output_limit: OutputLimitConfig::none(),
332            allow_external_commands: true,
333            nonce_store: NonceStore::new(),
334            trash_backend: None,
335            #[cfg(all(unix, feature = "subprocess"))]
336            terminal_state: None,
337            dispatcher: None,
338            cancel: CancellationToken::new(),
339            output_format: None,
340            vfs_budget: None,
341            watchdog: None,
342            #[cfg(all(feature = "localfs", feature = "overlay"))]
343            overlay_handle: None,
344        }
345    }
346
347    /// Create a context with a custom backend and scope.
348    pub fn with_backend_and_scope(backend: Arc<dyn KernelBackend>, scope: Scope) -> Self {
349        Self {
350            backend,
351            scope,
352            cwd: PathBuf::from("/"),
353            prev_cwd: None,
354            stdin: None,
355            stdin_data: None,
356            pipe_stdin: None,
357            pipe_stdout: None,
358            stderr: None,
359            tool_schemas: Vec::new(),
360            tools: None,
361            job_manager: None,
362            pipeline_position: PipelinePosition::Only,
363            interactive: false,
364            aliases: HashMap::new(),
365            ignore_config: IgnoreConfig::none(),
366            output_limit: OutputLimitConfig::none(),
367            allow_external_commands: true,
368            nonce_store: NonceStore::new(),
369            trash_backend: None,
370            #[cfg(all(unix, feature = "subprocess"))]
371            terminal_state: None,
372            dispatcher: None,
373            cancel: CancellationToken::new(),
374            output_format: None,
375            vfs_budget: None,
376            watchdog: None,
377            #[cfg(all(feature = "localfs", feature = "overlay"))]
378            overlay_handle: None,
379        }
380    }
381
382    /// Set the available tool schemas (for help command).
383    pub fn set_tool_schemas(&mut self, schemas: Vec<ToolSchema>) {
384        self.tool_schemas = schemas;
385    }
386
387    /// Set the tool registry reference.
388    pub fn set_tools(&mut self, tools: Arc<ToolRegistry>) {
389        self.tools = Some(tools);
390    }
391
392    /// Set the job manager for background job tracking.
393    pub fn set_job_manager(&mut self, manager: Arc<JobManager>) {
394        self.job_manager = Some(manager);
395    }
396
397    /// Set the trash backend.
398    pub fn set_trash_backend(&mut self, backend: Arc<dyn TrashBackend>) {
399        self.trash_backend = Some(backend);
400    }
401
402    /// Set stdin for this execution.
403    pub fn set_stdin(&mut self, stdin: String) {
404        self.stdin = Some(stdin);
405    }
406
407    /// Get stdin, consuming it.
408    pub fn take_stdin(&mut self) -> Option<String> {
409        self.stdin.take()
410    }
411
412    /// Set both text stdin and structured data.
413    ///
414    /// Use this when passing output through a pipeline where the previous
415    /// command produced structured data (e.g., JSON from MCP tools).
416    pub fn set_stdin_with_data(&mut self, text: String, data: Option<Value>) {
417        self.stdin = Some(text);
418        self.stdin_data = data;
419    }
420
421    /// Take structured data if available, consuming it.
422    ///
423    /// Tools can use this to avoid re-parsing JSON that was already parsed
424    /// by a previous command in the pipeline.
425    pub fn take_stdin_data(&mut self) -> Option<Value> {
426        self.stdin_data.take()
427    }
428
429    /// Resolve a path relative to cwd, normalizing `.` and `..` components.
430    pub fn resolve_path(&self, path: &str) -> PathBuf {
431        let raw = if path.starts_with('/') {
432            PathBuf::from(path)
433        } else {
434            self.cwd.join(path)
435        };
436        normalize_path(&raw)
437    }
438
439    /// Change the current working directory.
440    ///
441    /// Saves the old directory for `cd -` support.
442    pub fn set_cwd(&mut self, path: PathBuf) {
443        self.prev_cwd = Some(self.cwd.clone());
444        self.cwd = path;
445    }
446
447    /// Get the previous working directory (for `cd -`).
448    pub fn get_prev_cwd(&self) -> Option<&PathBuf> {
449        self.prev_cwd.as_ref()
450    }
451
452    /// Read all stdin (pipe or buffered string) into a String.
453    ///
454    /// Prefers pipe_stdin if set (streaming pipeline), otherwise falls back
455    /// to the buffered stdin string. Consumes the source.
456    pub async fn read_stdin_to_string(&mut self) -> Option<String> {
457        if let Some(mut reader) = self.pipe_stdin.take() {
458            use tokio::io::AsyncReadExt;
459            let mut buf = Vec::new();
460            reader.read_to_end(&mut buf).await.ok()?;
461            Some(String::from_utf8_lossy(&buf).into_owned())
462        } else {
463            self.stdin.take()
464        }
465    }
466
467    /// Create a child context for a pipeline stage.
468    ///
469    /// Shares backend, tools, job_manager, aliases, cwd, and scope
470    /// but has independent stdin/stdout pipes.
471    pub fn child_for_pipeline(&self) -> Self {
472        Self {
473            backend: self.backend.clone(),
474            scope: self.scope.clone(),
475            cwd: self.cwd.clone(),
476            prev_cwd: self.prev_cwd.clone(),
477            stdin: None,
478            stdin_data: None,
479            pipe_stdin: None,
480            pipe_stdout: None,
481            stderr: self.stderr.clone(),
482            tool_schemas: self.tool_schemas.clone(),
483            tools: self.tools.clone(),
484            job_manager: self.job_manager.clone(),
485            pipeline_position: PipelinePosition::Only,
486            interactive: self.interactive,
487            aliases: self.aliases.clone(),
488            ignore_config: self.ignore_config.clone(),
489            output_limit: self.output_limit.clone(),
490            allow_external_commands: self.allow_external_commands,
491            nonce_store: self.nonce_store.clone(),
492            trash_backend: self.trash_backend.clone(),
493            #[cfg(all(unix, feature = "subprocess"))]
494            terminal_state: self.terminal_state.clone(),
495            dispatcher: self.dispatcher.clone(),
496            cancel: self.cancel.clone(),
497            // Output format is per-execution; child pipeline stages start fresh.
498            output_format: None,
499            // Budget is shared: the child draws from the same pool as the parent.
500            vfs_budget: self.vfs_budget.clone(),
501            // Watchdog is shared: a patient hold in a pipeline stage or fork
502            // suspends the same script clock as foreground execution.
503            watchdog: self.watchdog.clone(),
504            // Overlay handle is shared: pipeline stages share the same transaction.
505            #[cfg(all(feature = "localfs", feature = "overlay"))]
506            overlay_handle: self.overlay_handle.clone(),
507        }
508    }
509
510    /// Build an `IgnoreFilter` from the current ignore configuration.
511    ///
512    /// Returns `None` if no filtering is configured.
513    pub async fn build_ignore_filter(&self, root: &std::path::Path) -> Option<crate::walker::IgnoreFilter> {
514        use crate::backend_walker_fs::BackendWalkerFs;
515        let fs = BackendWalkerFs(self.backend.as_ref());
516        self.ignore_config.build_filter(root, &fs).await
517    }
518
519    /// Validate a confirmation nonce against a command and paths.
520    ///
521    /// Thin wrapper on `NonceStore::validate` for ergonomic use from builtins.
522    pub fn verify_nonce(&self, nonce: &str, command: &str, paths: &[&str]) -> Result<(), String> {
523        self.nonce_store.validate(nonce, command, paths)
524    }
525
526    /// Issue a nonce and build the standard exit-2 latch result.
527    ///
528    /// `reason` explains why confirmation is needed (e.g., `"latch enabled"`,
529    /// `"emptying trash is destructive"`). The `confirm_hint` closure receives
530    /// the nonce string so each tool can format its own re-run command.
531    ///
532    /// The result includes structured data in `.data` for programmatic access:
533    /// ```json
534    /// {"nonce": "a3f7b2c1", "command": "rm", "paths": [...], "hint": "rm --confirm=a3f7b2c1 file", "ttl": 60}
535    /// ```
536    pub fn latch_result(
537        &self,
538        command: &str,
539        paths: &[&str],
540        reason: &str,
541        confirm_hint: impl FnOnce(&str) -> String,
542    ) -> ExecResult {
543        let nonce = self.nonce_store.issue(command, paths);
544        let ttl = self.nonce_store.ttl().as_secs();
545        let authorized = if paths.is_empty() {
546            String::new()
547        } else {
548            format!("\nAuthorized: {}", paths.join(", "))
549        };
550        let hint = confirm_hint(&nonce);
551
552        let mut result = ExecResult::failure(2, format!(
553            "{command}: confirmation required ({reason}){authorized}\nTo confirm, run: {hint}\nNonce expires in {ttl} seconds."
554        ));
555        result.data = Some(Value::Json(serde_json::json!({
556            "nonce": nonce,
557            "command": command,
558            "paths": paths,
559            "hint": hint,
560            "ttl": ttl,
561        })));
562        result
563    }
564
565    /// Expand a glob pattern to matching file paths.
566    ///
567    /// Returns the matched paths (absolute). Used by builtins that accept glob
568    /// patterns in their path arguments (ls, cat, head, tail, wc, etc.).
569    pub async fn expand_glob(&self, pattern: &str) -> Result<Vec<PathBuf>, String> {
570        use crate::backend_walker_fs::BackendWalkerFs;
571        use crate::walker::{EntryTypes, FileWalker, GlobPath, WalkOptions};
572
573        let glob = GlobPath::new(pattern).map_err(|e| format!("invalid pattern: {}", e))?;
574
575        let root = if glob.is_anchored() {
576            self.resolve_path("/")
577        } else {
578            self.resolve_path(".")
579        };
580
581        let options = WalkOptions {
582            entry_types: EntryTypes::all(),
583            respect_gitignore: self.ignore_config.auto_gitignore(),
584            ..WalkOptions::default()
585        };
586
587        let fs = BackendWalkerFs(self.backend.as_ref());
588        let mut walker = FileWalker::new(&fs, &root)
589            .with_pattern(glob)
590            .with_options(options);
591
592        // Note: if ignore_files contains ".gitignore" AND auto_gitignore is true,
593        // the root .gitignore is loaded twice (once here, once by the walker).
594        // This is harmless — merge is additive and rules are idempotent.
595        if let Some(filter) = self.ignore_config.build_filter(&root, &fs).await {
596            walker = walker.with_ignore(filter);
597        }
598
599        walker.collect().await.map_err(|e| e.to_string())
600    }
601
602    /// Expand positional arguments, resolving glob patterns to relative paths.
603    ///
604    /// Used by file-processing builtins (cat, head, tail, wc) that accept
605    /// glob patterns in their path arguments. Non-string values are converted
606    /// to strings (matching shell conventions).
607    pub async fn expand_paths(&self, positional: &[Value]) -> Result<Vec<String>, String> {
608        let mut paths = Vec::new();
609        for arg in positional {
610            let s = match arg {
611                Value::String(s) => s.clone(),
612                Value::Int(n) => n.to_string(),
613                Value::Float(f) => f.to_string(),
614                _ => continue,
615            };
616            if crate::glob::contains_glob(&s) {
617                let expanded = self.expand_glob(&s).await?;
618                let root = self.resolve_path(".");
619                for p in expanded {
620                    let rel = p.strip_prefix(&root).unwrap_or(&p);
621                    paths.push(rel.to_string_lossy().to_string());
622                }
623            } else {
624                paths.push(s);
625            }
626        }
627        Ok(paths)
628    }
629}
630
631/// The kernel's full execution context satisfies the trimmed portable
632/// [`ToolCtx`](kaish_tool_api::ToolCtx) contract that out-of-tree tools see.
633///
634/// Trusted in-tree builtins recover the concrete `ExecContext` (job control,
635/// pipes, dispatcher) through [`ToolCtx::as_any_mut`].
636impl kaish_tool_api::ToolCtx for ExecContext {
637    fn backend(&self) -> &Arc<dyn KernelBackend> {
638        &self.backend
639    }
640
641    fn cwd(&self) -> &std::path::Path {
642        self.cwd.as_path()
643    }
644
645    fn resolve_path(&self, path: &str) -> PathBuf {
646        // Inherent methods shadow trait methods in call syntax, so the
647        // fully-qualified inherent call here is not recursive.
648        ExecContext::resolve_path(self, path)
649    }
650
651    fn var(&self, name: &str) -> Option<Value> {
652        self.scope.get(name).cloned()
653    }
654
655    fn set_var(&mut self, name: &str, value: Value) {
656        self.scope.set(name, value);
657    }
658
659    fn set_output_format(&mut self, format: OutputFormat) {
660        self.output_format = Some(format);
661    }
662
663    fn patient(&self, budget: std::time::Duration) -> kaish_tool_api::PatientGuard {
664        match &self.watchdog {
665            Some(watchdog) => kaish_tool_api::PatientGuard::held(Box::new(watchdog.hold(budget))),
666            None => kaish_tool_api::PatientGuard::inert(),
667        }
668    }
669
670    fn as_any(&self) -> &dyn std::any::Any {
671        self
672    }
673
674    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
675        self
676    }
677}
678
679/// Normalize a path by resolving `.` and `..` components lexically (no filesystem access).
680fn normalize_path(path: &std::path::Path) -> PathBuf {
681    let mut parts: Vec<Component> = Vec::new();
682    for component in path.components() {
683        match component {
684            Component::CurDir => {} // skip `.`
685            Component::ParentDir => {
686                // Pop the last normal component, but don't pop past root
687                if let Some(Component::Normal(_)) = parts.last() {
688                    parts.pop();
689                } else {
690                    parts.push(component);
691                }
692            }
693            _ => parts.push(component),
694        }
695    }
696    if parts.is_empty() {
697        PathBuf::from("/")
698    } else {
699        parts.iter().collect()
700    }
701}