Skip to main content

kaish_kernel/tools/
context.rs

1//! Execution context for tools.
2
3use std::collections::HashMap;
4use std::path::{Component, Path, 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
169/// What the write-model gate chose for a single truncating overwrite.
170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
171pub(crate) enum MutationAction {
172    /// Write now — new file, append, excluded path, or both gates off.
173    Proceed,
174    /// Snapshot the prior content to trash, then write.
175    TrashFirst,
176    /// Gate behind a confirmation nonce (exit 2 until `--confirm`).
177    Latch,
178}
179
180/// Prior content the gate snapshotted, keyed by resolved path, for callers that
181/// compare-and-swap their overwrite against it (see `overwrite_checked`). Only
182/// gated existing targets that were trash-snapshotted appear; a new file, an
183/// append, an excluded/ungated path, or a latch-only target is absent — the
184/// caller writes those without a CAS expectation.
185pub(crate) type GateSnapshots = std::collections::HashMap<PathBuf, Vec<u8>>;
186
187/// Real paths the trash gate skips: scratch (`/tmp`) and the in-memory VFS
188/// (`/v`) mounts, where snapshotting prior content to trash is pointless.
189/// Shared by `rm`'s delete gate (`decide_rm_action`) and the overwrite gate
190/// (`decide_mutation_action`) so the exclusion list can't drift between them.
191/// `Path::starts_with` is component-aware, so `/tmp_file` does not match `/tmp`.
192pub(crate) fn is_trash_excluded(real_path: Option<&Path>) -> bool {
193    matches!(real_path, Some(rp) if rp.starts_with("/tmp") || rp.starts_with("/v"))
194}
195
196/// Decide how to gate a truncating overwrite, mirroring `rm`'s trash/latch
197/// priority. Pure so the decision table is unit-testable in isolation.
198///
199/// - A non-existent target or an append has nothing to lose → `Proceed`.
200/// - A real path under `/tmp` or `/v` is excluded (matches `rm`) → `Proceed`.
201/// - Trash wins over latch (trash IS the safety net): `TrashFirst` when trash
202///   is on **and** the prior content fits under `trash_max_size` (a file too
203///   big to snapshot can't be backed up, so it falls through, exactly like
204///   `rm`); else `Latch` when latch is on; else `Proceed`.
205///
206/// An overlay/in-memory target has `real_path == None`, so it is *not* excluded
207/// and stays gated — the protection is about agent-operation safety, not just
208/// real-FS data (Amy, 2026-06-17).
209pub(crate) fn decide_mutation_action(
210    trash_enabled: bool,
211    latch_enabled: bool,
212    real_path: Option<&Path>,
213    target_exists: bool,
214    is_append: bool,
215    file_size: u64,
216    trash_max_size: u64,
217) -> MutationAction {
218    if !target_exists || is_append {
219        return MutationAction::Proceed;
220    }
221    if is_trash_excluded(real_path) {
222        return MutationAction::Proceed;
223    }
224    if trash_enabled && file_size <= trash_max_size {
225        return MutationAction::TrashFirst;
226    }
227    if latch_enabled {
228        return MutationAction::Latch;
229    }
230    MutationAction::Proceed
231}
232
233/// Overwrite `resolved` with `content`, optionally compare-and-swapping against
234/// `expected` first. When `expected` is `Some`, the current bytes are re-read
235/// and must equal it (the write-model gate's snapshot), else a concurrent
236/// change is a loud conflict — never a silent clobber. Binary-safe (raw bytes,
237/// unlike the `String`-based `PatchOp` CAS). Shared by the byte-oriented gated
238/// builtins via `ExecContext::overwrite_checked` (`tee`/`write`/`dd`) and
239/// directly by `cp`'s free copy path. Not OS-atomic — a crash mid-write can
240/// still truncate (the atomic write-temp-then-rename primitive is a tracked
241/// write-model residual).
242pub(crate) async fn cas_overwrite(
243    backend: &dyn KernelBackend,
244    resolved: &Path,
245    content: &[u8],
246    expected: Option<&[u8]>,
247) -> Result<(), crate::backend::BackendError> {
248    if let Some(exp) = expected {
249        // Propagate a re-read failure loudly — never `unwrap_or_default()` it to
250        // empty bytes, which would false-match an empty snapshot (silent
251        // overwrite) or report a bogus "file changed" for a real I/O error. A
252        // target that vanished since the gate (NotFound) is a change → abort.
253        let current = backend.read(resolved, None).await?;
254        if current != exp {
255            return Err(crate::backend::BackendError::InvalidOperation(
256                "file changed since the write-model gate checked it (concurrent write); \
257                 aborting overwrite"
258                    .to_string(),
259            ));
260        }
261    }
262    backend
263        .write(resolved, content, crate::backend::WriteMode::Overwrite)
264        .await
265}
266
267impl ExecContext {
268    /// Create a new execution context with a VFS (uses LocalBackend without tools).
269    ///
270    /// This constructor is for backward compatibility and tests that don't need tool dispatch.
271    /// For full tool support, use `with_vfs_and_tools`.
272    pub fn new(vfs: Arc<VfsRouter>) -> Self {
273        Self {
274            backend: Arc::new(LocalBackend::new(vfs)),
275            scope: Scope::new(),
276            cwd: PathBuf::from("/"),
277            prev_cwd: None,
278            stdin: None,
279            stdin_data: None,
280            stdin_data_rx: None,
281            pipe_stdin: None,
282            pipe_stdout: None,
283            stderr: None,
284            tool_schemas: Vec::new(),
285            tools: None,
286            job_manager: None,
287            pipeline_position: PipelinePosition::Only,
288            interactive: false,
289            aliases: HashMap::new(),
290            ignore_config: IgnoreConfig::none(),
291            output_limit: OutputLimitConfig::none(),
292            allow_external_commands: true,
293            nonce_store: NonceStore::new(),
294            trash_backend: None,
295            #[cfg(all(unix, feature = "subprocess"))]
296            terminal_state: None,
297            dispatcher: None,
298            cancel: CancellationToken::new(),
299            output_format: None,
300            vfs_budget: None,
301            watchdog: None,
302            #[cfg(all(feature = "localfs", feature = "overlay"))]
303            overlay_handle: None,
304        }
305    }
306
307    /// Create a new execution context with VFS and tool registry.
308    ///
309    /// This is the preferred constructor for full kaish operation where
310    /// tools need to be dispatched through the backend.
311    pub fn with_vfs_and_tools(vfs: Arc<VfsRouter>, tools: Arc<ToolRegistry>) -> Self {
312        Self {
313            backend: Arc::new(LocalBackend::with_tools(vfs, tools.clone())),
314            scope: Scope::new(),
315            cwd: PathBuf::from("/"),
316            prev_cwd: None,
317            stdin: None,
318            stdin_data: None,
319            stdin_data_rx: None,
320            pipe_stdin: None,
321            pipe_stdout: None,
322            stderr: None,
323            tool_schemas: Vec::new(),
324            tools: Some(tools),
325            job_manager: None,
326            pipeline_position: PipelinePosition::Only,
327            interactive: false,
328            aliases: HashMap::new(),
329            ignore_config: IgnoreConfig::none(),
330            output_limit: OutputLimitConfig::none(),
331            allow_external_commands: true,
332            nonce_store: NonceStore::new(),
333            trash_backend: None,
334            #[cfg(all(unix, feature = "subprocess"))]
335            terminal_state: None,
336            dispatcher: None,
337            cancel: CancellationToken::new(),
338            output_format: None,
339            vfs_budget: None,
340            watchdog: None,
341            #[cfg(all(feature = "localfs", feature = "overlay"))]
342            overlay_handle: None,
343        }
344    }
345
346    /// Create a new execution context with a custom backend.
347    pub fn with_backend(backend: Arc<dyn KernelBackend>) -> Self {
348        Self {
349            backend,
350            scope: Scope::new(),
351            cwd: PathBuf::from("/"),
352            prev_cwd: None,
353            stdin: None,
354            stdin_data: None,
355            stdin_data_rx: 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    /// Create a context with VFS, tools, and a specific scope.
383    pub fn with_vfs_tools_and_scope(vfs: Arc<VfsRouter>, tools: Arc<ToolRegistry>, scope: Scope) -> Self {
384        Self {
385            backend: Arc::new(LocalBackend::with_tools(vfs, tools.clone())),
386            scope,
387            cwd: PathBuf::from("/"),
388            prev_cwd: None,
389            stdin: None,
390            stdin_data: None,
391            stdin_data_rx: None,
392            pipe_stdin: None,
393            pipe_stdout: None,
394            stderr: None,
395            tool_schemas: Vec::new(),
396            tools: Some(tools),
397            job_manager: None,
398            pipeline_position: PipelinePosition::Only,
399            interactive: false,
400            aliases: HashMap::new(),
401            ignore_config: IgnoreConfig::none(),
402            output_limit: OutputLimitConfig::none(),
403            allow_external_commands: true,
404            nonce_store: NonceStore::new(),
405            trash_backend: None,
406            #[cfg(all(unix, feature = "subprocess"))]
407            terminal_state: None,
408            dispatcher: None,
409            cancel: CancellationToken::new(),
410            output_format: None,
411            vfs_budget: None,
412            watchdog: None,
413            #[cfg(all(feature = "localfs", feature = "overlay"))]
414            overlay_handle: None,
415        }
416    }
417
418    /// Create a context with a specific scope (uses LocalBackend without tools).
419    ///
420    /// For tests that don't need tool dispatch. For full tool support,
421    /// use `with_vfs_tools_and_scope`.
422    pub fn with_scope(vfs: Arc<VfsRouter>, scope: Scope) -> Self {
423        Self {
424            backend: Arc::new(LocalBackend::new(vfs)),
425            scope,
426            cwd: PathBuf::from("/"),
427            prev_cwd: None,
428            stdin: None,
429            stdin_data: None,
430            stdin_data_rx: None,
431            pipe_stdin: None,
432            pipe_stdout: None,
433            stderr: None,
434            tool_schemas: Vec::new(),
435            tools: None,
436            job_manager: None,
437            pipeline_position: PipelinePosition::Only,
438            interactive: false,
439            aliases: HashMap::new(),
440            ignore_config: IgnoreConfig::none(),
441            output_limit: OutputLimitConfig::none(),
442            allow_external_commands: true,
443            nonce_store: NonceStore::new(),
444            trash_backend: None,
445            #[cfg(all(unix, feature = "subprocess"))]
446            terminal_state: None,
447            dispatcher: None,
448            cancel: CancellationToken::new(),
449            output_format: None,
450            vfs_budget: None,
451            watchdog: None,
452            #[cfg(all(feature = "localfs", feature = "overlay"))]
453            overlay_handle: None,
454        }
455    }
456
457    /// Create a context with a custom backend and scope.
458    pub fn with_backend_and_scope(backend: Arc<dyn KernelBackend>, scope: Scope) -> Self {
459        Self {
460            backend,
461            scope,
462            cwd: PathBuf::from("/"),
463            prev_cwd: None,
464            stdin: None,
465            stdin_data: None,
466            stdin_data_rx: None,
467            pipe_stdin: None,
468            pipe_stdout: None,
469            stderr: None,
470            tool_schemas: Vec::new(),
471            tools: None,
472            job_manager: None,
473            pipeline_position: PipelinePosition::Only,
474            interactive: false,
475            aliases: HashMap::new(),
476            ignore_config: IgnoreConfig::none(),
477            output_limit: OutputLimitConfig::none(),
478            allow_external_commands: true,
479            nonce_store: NonceStore::new(),
480            trash_backend: None,
481            #[cfg(all(unix, feature = "subprocess"))]
482            terminal_state: None,
483            dispatcher: None,
484            cancel: CancellationToken::new(),
485            output_format: None,
486            vfs_budget: None,
487            watchdog: None,
488            #[cfg(all(feature = "localfs", feature = "overlay"))]
489            overlay_handle: None,
490        }
491    }
492
493    /// Set the available tool schemas (for help command).
494    pub fn set_tool_schemas(&mut self, schemas: Vec<ToolSchema>) {
495        self.tool_schemas = schemas;
496    }
497
498    /// Set the tool registry reference.
499    pub fn set_tools(&mut self, tools: Arc<ToolRegistry>) {
500        self.tools = Some(tools);
501    }
502
503    /// Set the job manager for background job tracking.
504    pub fn set_job_manager(&mut self, manager: Arc<JobManager>) {
505        self.job_manager = Some(manager);
506    }
507
508    /// Set the trash backend.
509    pub fn set_trash_backend(&mut self, backend: Arc<dyn TrashBackend>) {
510        self.trash_backend = Some(backend);
511    }
512
513    /// Set stdin for this execution.
514    ///
515    /// An explicit stdin string (`< file`, heredoc, here-string, or a pipeline
516    /// hand-off) supersedes any inherited lazy `pipe_stdin`. Since `read_stdin_*`
517    /// prefers `pipe_stdin`, clear it here so redirect precedence holds — a
518    /// `< file` must beat a frontend-seeded piped stdin.
519    pub fn set_stdin(&mut self, stdin: String) {
520        self.stdin = Some(stdin);
521        self.pipe_stdin = None;
522    }
523
524    /// Get stdin, consuming it.
525    pub fn take_stdin(&mut self) -> Option<String> {
526        self.stdin.take()
527    }
528
529    /// Set both text stdin and structured data.
530    ///
531    /// Use this when passing output through a pipeline where the previous
532    /// command produced structured data (e.g., JSON from MCP tools).
533    pub fn set_stdin_with_data(&mut self, text: String, data: Option<Value>) {
534        self.stdin = Some(text);
535        self.stdin_data = data;
536    }
537
538    /// Take structured data if available, consuming it.
539    ///
540    /// Tools can use this to avoid re-parsing JSON that was already parsed
541    /// by a previous command in the pipeline.
542    pub fn take_stdin_data(&mut self) -> Option<Value> {
543        self.stdin_data.take()
544    }
545
546    /// Resolve stdin for a builtin that can consume *either* structured `.data`
547    /// or raw text from the previous pipeline stage (jq, scatter, …). Returns
548    /// `(Some(data), _)` when the upstream produced structured data, else
549    /// `(None, text)`.
550    ///
551    /// Ordering matters and is the whole point: the pipe is drained to text
552    /// FIRST, which runs the upstream producer to completion (it can't be parked
553    /// on pipe backpressure), and only THEN is the structured-data sideband
554    /// awaited — by which point the producer has definitely sent it (it sends
555    /// before writing/closing its pipe). A streaming upstream that emits a lot
556    /// of text before sending its (absent) data therefore can't deadlock us, and
557    /// a fast structured producer (`seq`) is no longer lost to a startup race
558    /// that a one-shot `try_recv` used to drop on the floor.
559    pub async fn resolve_stdin(&mut self) -> Result<(Option<Value>, String), String> {
560        // Data set directly on the context (not via the pipeline sideband) wins
561        // and needs no pipe — e.g. a non-pipeline caller seeded `stdin_data`.
562        if let Some(data) = self.stdin_data.take() {
563            return Ok((Some(data), String::new()));
564        }
565        // Drain the pipe (and/or buffered stdin) to text — unblocks the upstream.
566        let text = self.read_stdin_to_text().await?.unwrap_or_default();
567        // Upstream has now finished; its structured data (if any) is waiting.
568        if let Some(rx) = self.stdin_data_rx.take()
569            && let Ok(Some(data)) = rx.await
570        {
571            return Ok((Some(data), text));
572        }
573        Ok((None, text))
574    }
575
576    /// Resolve a path relative to cwd, normalizing `.` and `..` components.
577    pub fn resolve_path(&self, path: &str) -> PathBuf {
578        let raw = if path.starts_with('/') {
579            PathBuf::from(path)
580        } else {
581            self.cwd.join(path)
582        };
583        normalize_path(&raw)
584    }
585
586    /// Change the current working directory.
587    ///
588    /// Saves the old directory for `cd -` support.
589    pub fn set_cwd(&mut self, path: PathBuf) {
590        self.prev_cwd = Some(self.cwd.clone());
591        self.cwd = path;
592    }
593
594    /// Get the previous working directory (for `cd -`).
595    pub fn get_prev_cwd(&self) -> Option<&PathBuf> {
596        self.prev_cwd.as_ref()
597    }
598
599    /// Read all stdin (pipe or buffered string) into a String.
600    ///
601    /// Prefers pipe_stdin if set (streaming pipeline), otherwise falls back
602    /// to the buffered stdin string. Consumes the source.
603    pub async fn read_stdin_to_string(&mut self) -> Option<String> {
604        if let Some(mut reader) = self.pipe_stdin.take() {
605            use tokio::io::AsyncReadExt;
606            let mut buf = Vec::new();
607            reader.read_to_end(&mut buf).await.ok()?;
608            Some(String::from_utf8_lossy(&buf).into_owned())
609        } else {
610            self.stdin.take()
611        }
612    }
613
614    /// Read stdin as text, erroring on non-UTF-8 instead of silently
615    /// lossy-decoding it (which corrupts binary with `U+FFFD`).
616    ///
617    /// The strict counterpart to [`Self::read_stdin_to_string`], for text-only
618    /// builtins (`grep`, `sed`, `awk`, `cut`, `sort`, `jq`, …): a binary stream
619    /// is a loud error, not a mangle. Returns `Ok(None)` when there is no stdin
620    /// at all. The `Err` is a ready-to-use message; callers prefix their name.
621    /// See `docs/binary-data.md` and `docs/issues.md`.
622    pub async fn read_stdin_to_text(&mut self) -> Result<Option<String>, String> {
623        match self.read_stdin_to_bytes().await {
624            None => Ok(None),
625            Some(bytes) => String::from_utf8(bytes).map(Some).map_err(|_| {
626                "input is not valid UTF-8 (binary data?) — pipe through base64/xxd \
627                 or use a binary-aware tool (cat, dd, cmp, wc -c)"
628                    .to_string()
629            }),
630        }
631    }
632
633    /// Read all of stdin as raw bytes, preserving binary intact.
634    ///
635    /// The byte-clean counterpart to [`Self::read_stdin_to_string`], for
636    /// binary-aware builtins (`base64`, `xxd`, `checksum`, `wc -c`, `cmp`, …).
637    /// Returns `None` when there is no stdin at all (no pipe and no buffer);
638    /// an empty pipe yields `Some(vec![])`. A buffered text stdin is returned
639    /// as its UTF-8 bytes. See `docs/binary-data.md`.
640    pub async fn read_stdin_to_bytes(&mut self) -> Option<Vec<u8>> {
641        if let Some(mut reader) = self.pipe_stdin.take() {
642            use tokio::io::AsyncReadExt;
643            let mut buf = Vec::new();
644            reader.read_to_end(&mut buf).await.ok()?;
645            Some(buf)
646        } else {
647            self.stdin.take().map(String::into_bytes)
648        }
649    }
650
651    /// Create a child context for a pipeline stage.
652    ///
653    /// Shares backend, tools, job_manager, aliases, cwd, and scope
654    /// but has independent stdin/stdout pipes.
655    pub fn child_for_pipeline(&self) -> Self {
656        Self {
657            backend: self.backend.clone(),
658            scope: self.scope.clone(),
659            cwd: self.cwd.clone(),
660            prev_cwd: self.prev_cwd.clone(),
661            stdin: None,
662            stdin_data: None,
663            stdin_data_rx: None,
664            pipe_stdin: None,
665            pipe_stdout: None,
666            stderr: self.stderr.clone(),
667            tool_schemas: self.tool_schemas.clone(),
668            tools: self.tools.clone(),
669            job_manager: self.job_manager.clone(),
670            pipeline_position: PipelinePosition::Only,
671            interactive: self.interactive,
672            aliases: self.aliases.clone(),
673            ignore_config: self.ignore_config.clone(),
674            output_limit: self.output_limit.clone(),
675            allow_external_commands: self.allow_external_commands,
676            nonce_store: self.nonce_store.clone(),
677            trash_backend: self.trash_backend.clone(),
678            #[cfg(all(unix, feature = "subprocess"))]
679            terminal_state: self.terminal_state.clone(),
680            dispatcher: self.dispatcher.clone(),
681            cancel: self.cancel.clone(),
682            // Output format is per-execution; child pipeline stages start fresh.
683            output_format: None,
684            // Budget is shared: the child draws from the same pool as the parent.
685            vfs_budget: self.vfs_budget.clone(),
686            // Watchdog is shared: a patient hold in a pipeline stage or fork
687            // suspends the same script clock as foreground execution.
688            watchdog: self.watchdog.clone(),
689            // Overlay handle is shared: pipeline stages share the same transaction.
690            #[cfg(all(feature = "localfs", feature = "overlay"))]
691            overlay_handle: self.overlay_handle.clone(),
692        }
693    }
694
695    /// Build an `IgnoreFilter` from the current ignore configuration.
696    ///
697    /// Returns `None` if no filtering is configured.
698    pub async fn build_ignore_filter(&self, root: &std::path::Path) -> Option<crate::walker::IgnoreFilter> {
699        use crate::backend_walker_fs::BackendWalkerFs;
700        let fs = BackendWalkerFs(self.backend.as_ref());
701        self.ignore_config.build_filter(root, &fs).await
702    }
703
704    /// Validate a confirmation nonce against a command and paths.
705    ///
706    /// Thin wrapper on `NonceStore::validate` for ergonomic use from builtins.
707    pub fn verify_nonce(&self, nonce: &str, command: &str, paths: &[&str]) -> Result<(), String> {
708        self.nonce_store.validate(nonce, command, paths)
709    }
710
711    /// Issue a nonce and build the standard exit-2 latch result.
712    ///
713    /// `reason` explains why confirmation is needed (e.g., `"latch enabled"`,
714    /// `"emptying trash is destructive"`). The `confirm_hint` closure receives
715    /// the nonce string so each tool can format its own re-run command.
716    ///
717    /// The result includes structured data in `.data` for programmatic access:
718    /// ```json
719    /// {"nonce": "a3f7b2c1", "command": "rm", "paths": [...], "hint": "rm --confirm=a3f7b2c1 file", "ttl": 60}
720    /// ```
721    pub fn latch_result(
722        &self,
723        command: &str,
724        paths: &[&str],
725        reason: &str,
726        confirm_hint: impl FnOnce(&str) -> String,
727    ) -> ExecResult {
728        let nonce = self.nonce_store.issue(command, paths);
729        let ttl = self.nonce_store.ttl().as_secs();
730        let authorized = if paths.is_empty() {
731            String::new()
732        } else {
733            format!("\nAuthorized: {}", paths.join(", "))
734        };
735        let hint = confirm_hint(&nonce);
736
737        let mut result = ExecResult::failure(2, format!(
738            "{command}: confirmation required ({reason}){authorized}\nTo confirm, run: {hint}\nNonce expires in {ttl} seconds."
739        ));
740        result.data = Some(Value::Json(serde_json::json!({
741            "nonce": nonce,
742            "command": command,
743            "paths": paths,
744            "hint": hint,
745            "ttl": ttl,
746        })));
747        result
748    }
749
750    /// Gate a batch of truncating overwrites through latch + trash, the way
751    /// `rm` gates deletes — so `tee`/`patch`/`sed -i` can't silently clobber a
752    /// file with no recoverable prior copy and no confirmation.
753    ///
754    /// Each target is `(display_path, is_append)`. A path that doesn't exist yet
755    /// or is an append has nothing to lose and passes. For an existing file
756    /// under `set -o trash`, the prior content is copied to trash first (via
757    /// `trash_bytes`) so it's recoverable; the file is left in place for the
758    /// caller to overwrite. Under `set -o latch` (and trash off) the batch needs
759    /// `--confirm=<nonce>`: the first call returns an exit-2 latch result with
760    /// one nonce scoping every latched path.
761    ///
762    /// `Ok(snapshots)` means every snapshot is done and the caller may write
763    /// all targets; `snapshots` maps each trash-snapshotted target's resolved
764    /// path to its prior bytes, so a byte-oriented caller can pass them as the
765    /// `expected` to `overwrite_checked` for a binary-safe compare-and-swap.
766    /// `Err(result)` is what the caller must return verbatim (the latch prompt,
767    /// an invalid nonce, or a trash failure — never fall through to a
768    /// destructive overwrite on error).
769    ///
770    /// `confirm_hint` builds the re-run command shown in the latch prompt, given
771    /// the nonce and the space-joined latched paths. Most callers want
772    /// `|nonce, joined| format!("{command} --confirm=\"{nonce}\" {joined}")`, but
773    /// a tool whose argv carries operands the operation can't run without — e.g.
774    /// `sed -i`'s expression — must reinject them here, or the advertised
775    /// re-run will misbehave (or hang on stdin).
776    pub async fn gate_overwrites(
777        &mut self,
778        command: &str,
779        targets: &[(String, bool)],
780        confirm: Option<&str>,
781        confirm_hint: impl FnOnce(&str, &str) -> String,
782    ) -> Result<GateSnapshots, ExecResult> {
783        let trash_enabled = self.scope.trash_enabled();
784        let latch_enabled = self.scope.latch_enabled();
785        // Fast path: both gates off, nothing to do.
786        if !trash_enabled && !latch_enabled {
787            return Ok(GateSnapshots::new());
788        }
789        let trash_max_size = self.scope.trash_max_size();
790
791        struct Decided {
792            display: String,
793            resolved: PathBuf,
794            action: MutationAction,
795        }
796        // Dedup by resolved path (keep first): a multi-file patch with an
797        // explicit target lists the same file once per hunk-group, and we must
798        // not snapshot it N times or list it N times in the latch prompt.
799        let mut seen = std::collections::HashSet::new();
800        let mut decided = Vec::with_capacity(targets.len());
801        for (display, is_append) in targets {
802            let resolved = self.resolve_path(display);
803            if !seen.insert(resolved.clone()) {
804                continue;
805            }
806            // `real` is used only for the exclusion decision (/tmp, /v); the
807            // snapshot reads bytes through the backend, not the real path.
808            let real = self.backend.resolve_real_path(Path::new(&resolved));
809            let exists = self.backend.exists(Path::new(&resolved)).await;
810            // Prior size decides trash eligibility (a file too big to snapshot
811            // can't be backed up). Only stat an existing target.
812            let size = if exists {
813                self.backend
814                    .stat(Path::new(&resolved))
815                    .await
816                    .map(|e| e.size)
817                    .unwrap_or(0)
818            } else {
819                0
820            };
821            let action = decide_mutation_action(
822                trash_enabled,
823                latch_enabled,
824                real.as_deref(),
825                exists,
826                *is_append,
827                size,
828                trash_max_size,
829            );
830            decided.push(Decided { display: display.clone(), resolved, action });
831        }
832
833        // Latch: one nonce scopes every latched path (subset confirmation).
834        let latched: Vec<&str> = decided
835            .iter()
836            .filter(|d| matches!(d.action, MutationAction::Latch))
837            .map(|d| d.display.as_str())
838            .collect();
839        if !latched.is_empty() {
840            match confirm {
841                Some(nonce) => {
842                    if let Err(e) = self.verify_nonce(nonce, command, &latched) {
843                        return Err(ExecResult::failure(1, format!("{command}: {e}")));
844                    }
845                }
846                None => {
847                    let joined = latched.join(" ");
848                    return Err(self.latch_result(command, &latched, "latch enabled", |nonce| {
849                        confirm_hint(nonce, &joined)
850                    }));
851                }
852            }
853        }
854
855        // Snapshot prior content for every trash-first target before any write,
856        // keeping the bytes so a byte-oriented caller can CAS against them.
857        let mut snapshots = GateSnapshots::new();
858        for d in &decided {
859            if matches!(d.action, MutationAction::TrashFirst) {
860                match self.snapshot_for_overwrite(&d.display, &d.resolved).await {
861                    Ok(bytes) => {
862                        snapshots.insert(d.resolved.clone(), bytes);
863                    }
864                    Err(e) => return Err(ExecResult::failure(1, format!("{command}: {e}"))),
865                }
866            }
867        }
868        Ok(snapshots)
869    }
870
871    /// Copy the prior content of `resolved` into the trash before it's
872    /// overwritten, returning those bytes for the caller's compare-and-swap.
873    ///
874    /// We **copy** (not move): the builtin overwrites the file in place next,
875    /// and read-modify-write callers (`patch`, `sed -i`) still need to read it —
876    /// the file keeps its identity, only its content changes. (`rm` *moves*
877    /// because removal is the op; an overwrite backs up the prior bytes.) Reads
878    /// through the backend so a real, overlay, or in-memory file is handled the
879    /// same way. A missing trash backend or a trash failure is an error — never
880    /// a silent fall-through to a destructive overwrite.
881    async fn snapshot_for_overwrite(
882        &self,
883        display: &str,
884        resolved: &Path,
885    ) -> Result<Vec<u8>, String> {
886        let trash = self
887            .trash_backend
888            .as_ref()
889            .ok_or_else(|| "trash backend not available".to_string())?;
890        let bytes = self
891            .backend
892            .read(resolved, None)
893            .await
894            .map_err(|e| format!("{display}: {e}"))?;
895        trash
896            .trash_bytes(Path::new(display), &bytes)
897            .await
898            .map_err(|e| format!("{display}: trash failed: {e}"))?;
899        Ok(bytes)
900    }
901
902    /// Overwrite `resolved` with `content`. When `expected` is `Some`, this is a
903    /// binary-safe compare-and-swap: the current bytes are re-read and must
904    /// equal `expected` (the gate's snapshot), else it errors — a concurrent
905    /// change since the gate is a loud conflict, never a silent clobber. Unlike
906    /// the `String`-based `PatchOp::Replace` CAS used by `patch`/`sed -i`, this
907    /// operates on raw bytes, so binary overwrites (`tee`, `write`, `dd`, `cp`,
908    /// `mv`) keep the same protection. It is *not* OS-atomic — a crash mid-write
909    /// can still truncate; the atomic write-temp-then-rename primitive remains a
910    /// tracked write-model residual.
911    pub(crate) async fn overwrite_checked(
912        &self,
913        resolved: &Path,
914        content: &[u8],
915        expected: Option<&[u8]>,
916    ) -> Result<(), String> {
917        cas_overwrite(&*self.backend, resolved, content, expected)
918            .await
919            .map_err(|e| e.to_string())
920    }
921
922    /// Expand a glob pattern to matching file paths.
923    ///
924    /// Returns the matched paths (absolute). Used by builtins that accept glob
925    /// patterns in their path arguments (ls, cat, head, tail, wc, etc.).
926    pub async fn expand_glob(&self, pattern: &str) -> Result<Vec<PathBuf>, String> {
927        use crate::backend_walker_fs::BackendWalkerFs;
928        use crate::walker::{EntryTypes, FileWalker, GlobPath, WalkOptions};
929
930        let glob = GlobPath::new(pattern).map_err(|e| format!("invalid pattern: {}", e))?;
931
932        let root = if glob.is_anchored() {
933            self.resolve_path("/")
934        } else {
935            self.resolve_path(".")
936        };
937
938        let options = WalkOptions {
939            entry_types: EntryTypes::all(),
940            respect_gitignore: self.ignore_config.auto_gitignore(),
941            ..WalkOptions::default()
942        };
943
944        let fs = BackendWalkerFs(self.backend.as_ref());
945        let mut walker = FileWalker::new(&fs, &root)
946            .with_pattern(glob)
947            .with_options(options);
948
949        // Note: if ignore_files contains ".gitignore" AND auto_gitignore is true,
950        // the root .gitignore is loaded twice (once here, once by the walker).
951        // This is harmless — merge is additive and rules are idempotent.
952        if let Some(filter) = self.ignore_config.build_filter(&root, &fs).await {
953            walker = walker.with_ignore(filter);
954        }
955
956        walker.collect().await.map_err(|e| e.to_string())
957    }
958
959    /// Expand positional arguments, resolving glob patterns to relative paths.
960    ///
961    /// Used by file-processing builtins (cat, head, tail, wc) that accept
962    /// glob patterns in their path arguments. Non-string values are converted
963    /// to strings (matching shell conventions).
964    pub async fn expand_paths(&self, positional: &[Value]) -> Result<Vec<String>, String> {
965        let mut paths = Vec::new();
966        for arg in positional {
967            let s = match arg {
968                Value::String(s) => s.clone(),
969                Value::Int(n) => n.to_string(),
970                Value::Float(f) => f.to_string(),
971                _ => continue,
972            };
973            if crate::glob::contains_glob(&s) {
974                let expanded = self.expand_glob(&s).await?;
975                let root = self.resolve_path(".");
976                for p in expanded {
977                    let rel = p.strip_prefix(&root).unwrap_or(&p);
978                    paths.push(rel.to_string_lossy().to_string());
979                }
980            } else {
981                paths.push(s);
982            }
983        }
984        Ok(paths)
985    }
986
987    /// Default chunk size for forward file scans. Bounds the memory a
988    /// scan-oriented builtin holds at once, independent of file size.
989    pub const STREAM_CHUNK_SIZE: u64 = 256 * 1024;
990
991    /// Stream a file's bytes forward in `chunk_size` slices, handing each
992    /// non-empty chunk to `f`.
993    ///
994    /// Reads are issued as positional `read_range` requests, so backends slice
995    /// without materialising the whole file (LocalFs seeks; MemoryFs/OverlayFs
996    /// slice their stored bytes). The loop terminates on the first empty chunk,
997    /// which every backend returns once the offset reaches EOF. `f` returns a
998    /// [`ControlFlow`](std::ops::ControlFlow): `Break` stops the loop early
999    /// (e.g. a consumer that has detected binary content and will discard the
1000    /// rest), so we don't keep reading a file the caller is done with. This is
1001    /// the shared engine for scan-oriented builtins (`wc`, `checksum`, `grep`)
1002    /// that walk a file front-to-back and must not hold it all in memory.
1003    pub async fn read_file_chunked<F>(
1004        &self,
1005        path: &std::path::Path,
1006        chunk_size: u64,
1007        mut f: F,
1008    ) -> kaish_types::backend::BackendResult<()>
1009    where
1010        F: FnMut(&[u8]) -> std::ops::ControlFlow<()>,
1011    {
1012        use kaish_types::ReadRange;
1013        let mut offset = 0u64;
1014        loop {
1015            let chunk = self
1016                .backend
1017                .read(path, Some(ReadRange::bytes(offset, chunk_size)))
1018                .await?;
1019            if chunk.is_empty() {
1020                break;
1021            }
1022            offset += chunk.len() as u64;
1023            if f(&chunk).is_break() {
1024                break;
1025            }
1026        }
1027        Ok(())
1028    }
1029}
1030
1031/// The kernel's full execution context satisfies the trimmed portable
1032/// [`ToolCtx`](kaish_tool_api::ToolCtx) contract that out-of-tree tools see.
1033///
1034/// Trusted in-tree builtins recover the concrete `ExecContext` (job control,
1035/// pipes, dispatcher) through [`ToolCtx::as_any_mut`].
1036impl kaish_tool_api::ToolCtx for ExecContext {
1037    fn backend(&self) -> &Arc<dyn KernelBackend> {
1038        &self.backend
1039    }
1040
1041    fn cwd(&self) -> &std::path::Path {
1042        self.cwd.as_path()
1043    }
1044
1045    fn resolve_path(&self, path: &str) -> PathBuf {
1046        // Inherent methods shadow trait methods in call syntax, so the
1047        // fully-qualified inherent call here is not recursive.
1048        ExecContext::resolve_path(self, path)
1049    }
1050
1051    fn var(&self, name: &str) -> Option<Value> {
1052        self.scope.get(name).cloned()
1053    }
1054
1055    fn set_var(&mut self, name: &str, value: Value) {
1056        self.scope.set(name, value);
1057    }
1058
1059    fn set_output_format(&mut self, format: OutputFormat) {
1060        self.output_format = Some(format);
1061    }
1062
1063    fn patient(&self, budget: std::time::Duration) -> kaish_tool_api::PatientGuard {
1064        match &self.watchdog {
1065            Some(watchdog) => kaish_tool_api::PatientGuard::held(Box::new(watchdog.hold(budget))),
1066            None => kaish_tool_api::PatientGuard::inert(),
1067        }
1068    }
1069
1070    fn as_any(&self) -> &dyn std::any::Any {
1071        self
1072    }
1073
1074    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
1075        self
1076    }
1077}
1078
1079/// Normalize a path by resolving `.` and `..` components lexically (no filesystem access).
1080fn normalize_path(path: &std::path::Path) -> PathBuf {
1081    let mut parts: Vec<Component> = Vec::new();
1082    for component in path.components() {
1083        match component {
1084            Component::CurDir => {} // skip `.`
1085            Component::ParentDir => {
1086                // Pop the last normal component, but don't pop past root
1087                if let Some(Component::Normal(_)) = parts.last() {
1088                    parts.pop();
1089                } else {
1090                    parts.push(component);
1091                }
1092            }
1093            _ => parts.push(component),
1094        }
1095    }
1096    if parts.is_empty() {
1097        PathBuf::from("/")
1098    } else {
1099        parts.iter().collect()
1100    }
1101}
1102
1103#[cfg(test)]
1104mod tests {
1105    use super::{decide_mutation_action, MutationAction};
1106    use std::path::Path;
1107
1108    fn decide(
1109        trash: bool,
1110        latch: bool,
1111        real: Option<&str>,
1112        exists: bool,
1113        append: bool,
1114    ) -> MutationAction {
1115        // Default to a small file well under the cap; the size-cap behavior
1116        // has its own dedicated test below.
1117        decide_mutation_action(trash, latch, real.map(Path::new), exists, append, 1, 10_000_000)
1118    }
1119
1120    #[test]
1121    fn new_file_and_append_always_proceed() {
1122        // Non-existent target: nothing to lose, regardless of gates.
1123        assert_eq!(decide(true, true, Some("/work/new"), false, false), MutationAction::Proceed);
1124        // Append to an existing file doesn't destroy prior content.
1125        assert_eq!(decide(true, true, Some("/work/log"), true, true), MutationAction::Proceed);
1126    }
1127
1128    #[test]
1129    fn trash_wins_over_latch_on_existing_file() {
1130        assert_eq!(decide(true, true, Some("/work/f"), true, false), MutationAction::TrashFirst);
1131        assert_eq!(decide(true, false, Some("/work/f"), true, false), MutationAction::TrashFirst);
1132    }
1133
1134    #[test]
1135    fn latch_gates_when_trash_off() {
1136        assert_eq!(decide(false, true, Some("/work/f"), true, false), MutationAction::Latch);
1137    }
1138
1139    #[test]
1140    fn both_gates_off_proceeds() {
1141        assert_eq!(decide(false, false, Some("/work/f"), true, false), MutationAction::Proceed);
1142    }
1143
1144    #[test]
1145    fn excluded_real_paths_bypass_the_gate() {
1146        // /tmp and /v real paths proceed even with both gates on (matches rm).
1147        assert_eq!(decide(true, true, Some("/tmp/scratch"), true, false), MutationAction::Proceed);
1148        assert_eq!(decide(true, true, Some("/v/mem/file"), true, false), MutationAction::Proceed);
1149    }
1150
1151    #[test]
1152    fn overlay_no_real_path_stays_gated() {
1153        // No real path (overlay/in-memory) is NOT excluded — still trash-first.
1154        assert_eq!(decide(true, true, None, true, false), MutationAction::TrashFirst);
1155        assert_eq!(decide(false, true, None, true, false), MutationAction::Latch);
1156    }
1157
1158    #[test]
1159    fn file_too_big_to_trash_falls_through_like_rm() {
1160        // Prior content larger than the cap can't be snapshotted, so trash is
1161        // skipped: latch gates if on, else the overwrite proceeds unbacked.
1162        let big = 100u64;
1163        let cap = 10u64;
1164        assert_eq!(
1165            decide_mutation_action(true, true, Some(Path::new("/work/f")), true, false, big, cap),
1166            MutationAction::Latch
1167        );
1168        assert_eq!(
1169            decide_mutation_action(true, false, Some(Path::new("/work/f")), true, false, big, cap),
1170            MutationAction::Proceed
1171        );
1172        // Exactly at the cap still trashes (inclusive bound, matches rm).
1173        assert_eq!(
1174            decide_mutation_action(true, false, Some(Path::new("/work/f")), true, false, cap, cap),
1175            MutationAction::TrashFirst
1176        );
1177    }
1178}