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