Skip to main content

everruns_core/capabilities/bashkit_shell/
mod.rs

1//! Bashkit Shell Capability
2//!
3//! This capability provides a sandboxed bash interpreter using bashkit.
4//! The bash environment uses a custom FileSystem adapter that bridges
5//! directly to the session file store.
6//!
7//! Design decisions:
8//! - SessionFileSystemAdapter implements bashkit's FileSystem trait
9//! - Direct delegation to SessionFileSystem - no sync overhead
10//! - Live visibility: files written by other tools are immediately visible
11//! - Resource limits prevent runaway scripts (max commands, loop iterations)
12//! - Context-aware tool that requires session filesystem access
13//! - SearchCapable impl delegates grep to SessionFileSystem::grep_files for
14//!   single-query indexed search instead of per-file linear scan
15//! - Outbound HTTP (curl/wget) is opt-in via per-capability config
16//!   `{"enable_http": true}` and only functions when the runtime provides an
17//!   `EgressService`: every request crosses the egress boundary through
18//!   `egress_transport::BashkitEgressTransport` (see `specs/egress.md`).
19//!   Without the config flag - or without an egress service in context - the
20//!   shell has no network path at all, preserving the historical default.
21//!
22//! Trust boundary (TM-AGENT-005, TM-BASH-001..016):
23//! - `risk_level()` returns `High`. Per the capability admin-only tier contract
24//!   (`specs/capabilities.md`, `specs/permissions.md`), assigning `bashkit_shell`
25//!   to an agent requires `OrgRole::Admin`; the canonical create/update gate is
26//!   `check_high_risk_caps` in `crates/server/src/domains/agents/commands.rs`
27//!   (invoked from `CreateAgent::execute`, `UpdateAgent::execute`, and
28//!   `UpsertAgent::execute`). The sibling `require_admin_for_high_risk` helper
29//!   in `crates/server/src/api/agents.rs` enforces the same contract on
30//!   agent-import / copy paths. Existing member-owned agents that already had
31//!   `bashkit_shell` before the elevation continue to run (gate is
32//!   creation/update only, not runtime). New assignments by non-admin members
33//!   are rejected with HTTP 403. The legacy `virtual_bash` alias resolves to
34//!   this capability through the registry, so the gate covers it too.
35//! - Rationale: `bashkit_shell` exposes scripted code execution. The bashkit
36//!   sandbox provides workspace-only filesystem access and no network by
37//!   default (outbound HTTP is opt-in config routed through the egress
38//!   boundary); still, the combination of arbitrary command composition +
39//!   LLM-driven invocation makes this a meaningful trust elevation versus
40//!   single-purpose tools. Org admins are the only principals expected to
41//!   accept that surface for shared agents.
42
43mod egress_transport;
44
45use super::{Capability, CapabilityLocalization, CapabilityStatus, RiskLevel};
46use crate::background::{
47    BackgroundEventSink, BackgroundExecutableTool, BackgroundOutcome, BackgroundProgress,
48};
49use crate::exec_tool_result::ExecToolResultPayload;
50use crate::session_file::SessionFile;
51use crate::tool_types::ToolHints;
52use crate::tools::{Tool, ToolExecutionResult};
53use crate::traits::{SessionFileSystem, ToolContext};
54use crate::typed_id::SessionId;
55use async_trait::async_trait;
56use bashkit::{
57    Bash, BashBuilder, BashTool as BashkitTool, DirEntry, ExecutionLimits, FileSystem,
58    FileSystemExt, FileType, Metadata, NetworkAllowlist, OutputCallback, SearchCapabilities,
59    SearchCapable, SearchMatch as BashkitSearchMatch, SearchProvider, SearchQuery, SearchResults,
60    Tool as BashkitToolTrait, TraceEventKind, TraceMode,
61};
62use serde_json::{Value, json};
63use std::path::{Path, PathBuf};
64use std::sync::atomic::{AtomicUsize, Ordering};
65use std::sync::{Arc, LazyLock};
66use std::time::SystemTime;
67
68// ============================================================================
69// Static configuration
70// ============================================================================
71
72/// Shared execution limits for bashkit.
73fn execution_limits() -> ExecutionLimits {
74    ExecutionLimits::new()
75        .max_commands(1000)
76        .max_loop_iterations(10000)
77        .max_function_depth(100)
78        .max_input_bytes(1_000_000) // 1MB max script size
79        .max_ast_depth(100)
80        .parser_timeout(std::time::Duration::from_secs(5))
81}
82
83/// Resolve the shell working directory and `WORKSPACE` env value from the file
84/// store's namespace (EVE-660).
85///
86/// The file store ([`MountFs`](crate::mount_fs::MountFs)) is the single path
87/// authority: `working_dir` is resolved through it to an absolute path in the
88/// same namespace the file tools use, so a model that learns a path from
89/// `read_file` can pass it straight to `cd`. With no `working_dir`, the shell
90/// starts at the store's display root (`/workspace` for the mounted stores used
91/// by agent execution). The tuple is `(cwd, workspace_env)`.
92fn resolve_shell_workspace(
93    store: &dyn SessionFileSystem,
94    working_dir_arg: Option<&str>,
95) -> (String, String) {
96    let workspace_env = store.display_root();
97    let cwd = match working_dir_arg {
98        Some(arg) => store.resolve_path(arg),
99        None => workspace_env.clone(),
100    };
101    (cwd, workspace_env)
102}
103
104/// Configured bashkit tool instance with everruns settings.
105static BASHKIT_TOOL: LazyLock<BashkitTool> = LazyLock::new(|| {
106    BashkitTool::builder()
107        .username("everruns")
108        .hostname("everruns")
109        .limits(execution_limits())
110        .env("HOME", "/home/agent")
111        .env("SHELL", "/bin/bash")
112        .env("PATH", "/usr/local/bin:/usr/bin:/bin")
113        .env("WORKSPACE", "/workspace")
114        .build()
115});
116
117/// Tool description from bashkit library.
118static TOOL_DESCRIPTION: LazyLock<String> =
119    LazyLock::new(|| BASHKIT_TOOL.description().to_string());
120
121/// System prompt addition from bashkit library + output economy hint.
122static TOOL_SYSTEM_PROMPT: LazyLock<String> = LazyLock::new(|| {
123    let mut prompt = BASHKIT_TOOL.system_prompt().to_string();
124    prompt.push_str(crate::tool_output_sanitizer::EXEC_OUTPUT_HINT);
125    prompt
126});
127
128/// Input schema from bashkit library, extended with everruns-specific `working_dir`.
129/// Delegating to bashkit avoids schema drift when bashkit adds/changes parameters.
130static TOOL_INPUT_SCHEMA: LazyLock<Value> = LazyLock::new(|| {
131    let mut schema = BASHKIT_TOOL.input_schema();
132    // Add everruns-specific working_dir param only if bashkit does not already define it.
133    if let Some(props) = schema.get_mut("properties").and_then(|p| p.as_object_mut()) {
134        if !props.contains_key("working_dir") {
135            props.insert(
136                "working_dir".to_string(),
137                json!({
138                    "type": "string",
139                    "default": "/workspace",
140                    "description": "Working directory for command execution"
141                }),
142            );
143        }
144        if !props.contains_key("output") {
145            props.insert(
146                "output".to_string(),
147                crate::tool_output_sanitizer::output_verbosity_schema(),
148            );
149        }
150    }
151    schema
152});
153
154pub const BASHKIT_SHELL_CAPABILITY_ID: &str = "bashkit_shell";
155
156/// Bashkit Shell capability - execute bash commands in a sandboxed environment
157pub struct BashkitShellCapability;
158
159impl Capability for BashkitShellCapability {
160    fn id(&self) -> &str {
161        BASHKIT_SHELL_CAPABILITY_ID
162    }
163
164    fn aliases(&self) -> Vec<&'static str> {
165        // Pre-rename ID; still present in persisted agent configs.
166        vec!["virtual_bash"]
167    }
168
169    fn name(&self) -> &str {
170        "Bashkit Shell"
171    }
172
173    fn description(&self) -> &str {
174        r#"Execute bash commands in an isolated, sandboxed environment.
175
176> [!NOTE]
177> Commands run in a virtual environment with no access to the host system.
178> The session filesystem is mounted at root, so you can read and write session files.
179
180> [!TIP]
181> Use standard Unix commands like `ls`, `cat`, `grep`, `echo`, and shell features
182> like pipes, redirections, and command substitution. Built-in commands support
183> `<command> --help`, and many also support `<command> --version`."#
184    }
185
186    fn localizations(&self) -> Vec<CapabilityLocalization> {
187        vec![CapabilityLocalization::text(
188            "uk",
189            "Оболонка Bashkit",
190            r#"Виконуйте bash-команди в ізольованому середовищі-пісочниці.
191
192> [!NOTE]
193> Команди виконуються у віртуальному середовищі без доступу до хост-системи.
194> Файлова система сесії змонтована в корені, тож можна читати й записувати файли сесії.
195
196> [!TIP]
197> Використовуйте стандартні Unix-команди, як-от `ls`, `cat`, `grep`, `echo`, і можливості оболонки
198> на кшталт конвеєрів, перенаправлень і підстановки команд. Вбудовані команди підтримують
199> `<command> --help`, а багато з них також `<command> --version`."#,
200        )]
201    }
202
203    fn status(&self) -> CapabilityStatus {
204        CapabilityStatus::Available
205    }
206
207    fn risk_level(&self) -> RiskLevel {
208        RiskLevel::High
209    }
210
211    fn icon(&self) -> Option<&str> {
212        Some("terminal")
213    }
214
215    fn category(&self) -> Option<&str> {
216        Some("Execution")
217    }
218
219    fn system_prompt_addition(&self) -> Option<&str> {
220        Some(&TOOL_SYSTEM_PROMPT)
221    }
222
223    fn tools(&self) -> Vec<Box<dyn Tool>> {
224        vec![Box::new(BashTool::default())]
225    }
226
227    fn tools_with_config(&self, config: &serde_json::Value) -> Vec<Box<dyn Tool>> {
228        let enable_http = config
229            .get("enable_http")
230            .and_then(|v| v.as_bool())
231            .unwrap_or(false);
232        vec![Box::new(BashTool { enable_http })]
233    }
234
235    fn config_schema(&self) -> Option<serde_json::Value> {
236        Some(serde_json::json!({
237            "type": "object",
238            "properties": {
239                "enable_http": {
240                    "type": "boolean",
241                    "title": "Allow outbound HTTP (curl/wget)",
242                    "description": "Let shell scripts make outbound HTTP requests. Every \
243                                    request is routed through the platform egress boundary, \
244                                    where the network access list and system allowlist are \
245                                    enforced.",
246                    "default": false
247                }
248            }
249        }))
250    }
251
252    fn validate_config(&self, config: &serde_json::Value) -> Result<(), String> {
253        if config.is_null() {
254            return Ok(());
255        }
256        if !config.is_object() {
257            return Err("bashkit_shell config must be an object".to_string());
258        }
259        match config.get("enable_http") {
260            None | Some(serde_json::Value::Bool(_)) => Ok(()),
261            Some(other) => Err(format!("enable_http must be a boolean, got {other}")),
262        }
263    }
264
265    fn dependencies(&self) -> Vec<&'static str> {
266        // Depends on session filesystem for file access
267        vec!["session_file_system"]
268    }
269
270    fn features(&self) -> Vec<&'static str> {
271        vec!["file_system"]
272    }
273}
274
275// ============================================================================
276// BashTool
277// ============================================================================
278
279/// Tool to execute bash commands in a sandboxed environment
280#[derive(Default)]
281pub struct BashTool {
282    /// Opt-in outbound HTTP for curl/wget, set from per-capability config
283    /// `{"enable_http": true}`. Only effective when the execution context
284    /// provides an `EgressService` (see `configure_http`).
285    enable_http: bool,
286}
287
288#[async_trait]
289impl Tool for BashTool {
290    fn narrate(
291        &self,
292        tool_call: &crate::tool_types::ToolCall,
293        phase: crate::tool_narration::ToolNarrationPhase,
294        locale: Option<&str>,
295        _ctx: crate::tool_narration::ToolNarrationContext<'_>,
296    ) -> Option<String> {
297        let fallback = self.display_name().unwrap_or("Bash");
298        Some(crate::tool_narration::narrate_shell_exec(
299            &tool_call.arguments,
300            fallback,
301            phase,
302            locale,
303        ))
304    }
305
306    fn name(&self) -> &str {
307        "bash"
308    }
309
310    fn display_name(&self) -> Option<&str> {
311        Some("Bash")
312    }
313
314    fn description(&self) -> &str {
315        &TOOL_DESCRIPTION
316    }
317
318    fn parameters_schema(&self) -> Value {
319        TOOL_INPUT_SCHEMA.clone()
320    }
321
322    fn hints(&self) -> ToolHints {
323        ToolHints::default()
324            .with_long_running(true)
325            .with_open_world(true)
326            .with_persist_output(true)
327            .with_supports_background(true)
328            // Mutates the shared session workspace: serialize concurrent bash
329            // calls in a batch so they don't race on the filesystem. Runs an
330            // in-process interpreter, so offload to its own task to avoid
331            // starving I/O-bound tools sharing the act batch.
332            .with_concurrency_class("session_workspace")
333            .with_cpu_bound(true)
334    }
335
336    async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
337        ToolExecutionResult::tool_error(
338            "bash requires context. This tool must be executed with session context.",
339        )
340    }
341
342    async fn execute_with_context(
343        &self,
344        arguments: Value,
345        context: &ToolContext,
346    ) -> ToolExecutionResult {
347        let command = match arguments.get("commands").and_then(|v| v.as_str()) {
348            Some(c) => c,
349            None => {
350                return ToolExecutionResult::tool_error("Missing required parameter: commands");
351            }
352        };
353
354        let file_store = match &context.file_store {
355            Some(store) => store.clone(),
356            None => {
357                return ToolExecutionResult::tool_error(
358                    "File system not available in this context",
359                );
360            }
361        };
362
363        let (working_dir, workspace_env) = resolve_shell_workspace(
364            file_store.as_ref(),
365            arguments.get("working_dir").and_then(|v| v.as_str()),
366        );
367
368        let timeout_ms = arguments
369            .get("timeout_ms")
370            .and_then(|v| v.as_u64())
371            .unwrap_or(30000)
372            .min(60000);
373
374        // EVE-489: persistence-first default. `auto` returns a compact
375        // summary on success and a `normal`-sized diagnostic window on failure.
376        let output_mode = arguments
377            .get("output")
378            .and_then(|v| v.as_str())
379            .unwrap_or("auto");
380
381        // Create filesystem adapter that bridges to session file store
382        let session_fs = Arc::new(SessionFileSystemAdapter::new(
383            context.session_id,
384            file_store,
385        ));
386
387        // Resolve locale from context (defaults to en-US).
388        let locale = context.locale.as_deref().unwrap_or("en-US");
389
390        // Configure bash with resource limits (uses shared execution_limits).
391        // Observability hooks are installed last so per-builtin / error telemetry
392        // is available without changing any existing limits or boundaries.
393        let builder = Bash::builder()
394            .fs(session_fs)
395            .cwd(working_dir.as_str())
396            .username("everruns")
397            .hostname("everruns")
398            .env("HOME", "/home/agent")
399            .env("SHELL", "/bin/bash")
400            .env("PATH", "/usr/local/bin:/usr/bin:/bin")
401            .env("WORKSPACE", workspace_env.as_str())
402            .env("LANG", locale)
403            .limits(execution_limits())
404            .max_memory(10 * 1024 * 1024) // 10 MB — prevent OOM from untrusted input
405            .trace_mode(TraceMode::Redacted);
406        let builder = install_observability_hooks(builder, context.session_id);
407        let builder = configure_http(builder, self.enable_http, context);
408        let mut bash = builder.build();
409
410        // Stream output via tool.output.delta events for live UI/CLI rendering.
411        // bashkit's exec_streaming calls OutputCallback with (stdout_chunk, stderr_chunk)
412        // after each command completes. We bridge to async emit via a channel.
413        // A bounded channel collects partial output for cancellation recovery
414        // without allowing unbounded memory growth.
415        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<(String, String)>();
416        let (partial_tx, partial_rx) = tokio::sync::mpsc::channel::<(String, String)>(128);
417
418        let output_callback: OutputCallback =
419            Box::new(move |stdout_chunk: &str, stderr_chunk: &str| {
420                // Best-effort: if receiver dropped, we just ignore
421                let _ = tx.send((stdout_chunk.to_string(), stderr_chunk.to_string()));
422                // Bounded: drop if full rather than growing without bound.
423                let _ = partial_tx.try_send((stdout_chunk.to_string(), stderr_chunk.to_string()));
424            });
425
426        // Spawn a task that reads chunks from the channel and emits events
427        let emit_context = context.clone();
428        let emit_task = tokio::spawn(async move {
429            while let Some((stdout_chunk, stderr_chunk)) = rx.recv().await {
430                if !stdout_chunk.is_empty() {
431                    emit_context
432                        .emit_tool_output("bash", &stdout_chunk, "stdout")
433                        .await;
434                }
435                if !stderr_chunk.is_empty() {
436                    emit_context
437                        .emit_tool_output("bash", &stderr_chunk, "stderr")
438                        .await;
439                }
440            }
441        });
442
443        // Grab the cancellation token so we can signal graceful abort on timeout.
444        let cancel_token = bash.cancellation_token();
445
446        // Execute with timeout. On timeout, signal cancellation via the token
447        // so bashkit aborts at the next command boundary and we can collect
448        // partial output instead of discarding everything.
449        let exec_start = std::time::Instant::now();
450        let result = tokio::time::timeout(
451            std::time::Duration::from_millis(timeout_ms),
452            bash.exec_streaming(command, output_callback),
453        )
454        .await;
455        let exec_duration = exec_start.elapsed();
456
457        // Wait for all buffered chunks to be emitted (sender dropped when exec completes)
458        let _ = emit_task.await;
459
460        match result {
461            Ok(Ok(output)) => {
462                // Extract metadata from trace events (EVE-240)
463                let commands_executed = output
464                    .events
465                    .iter()
466                    .filter(|e| e.kind == TraceEventKind::CommandExit)
467                    .count();
468                let fs_reads = output
469                    .events
470                    .iter()
471                    .filter(|e| e.kind == TraceEventKind::FileAccess)
472                    .count();
473                let fs_writes = output
474                    .events
475                    .iter()
476                    .filter(|e| e.kind == TraceEventKind::FileMutation)
477                    .count();
478
479                tracing::info!(
480                    tool = "bash",
481                    duration_ms = exec_duration.as_millis() as u64,
482                    exit_code = output.exit_code,
483                    commands_executed,
484                    fs_reads,
485                    fs_writes,
486                    stdout_bytes = output.stdout.len(),
487                    stderr_bytes = output.stderr.len(),
488                    "bashkit execution completed"
489                );
490
491                let payload = ExecToolResultPayload::new(
492                    &output.stdout,
493                    &output.stderr,
494                    output.exit_code,
495                    output_mode,
496                );
497                let ExecToolResultPayload {
498                    stdout,
499                    stderr,
500                    exit_code,
501                    success,
502                    truncated,
503                    total_lines,
504                    raw_output,
505                } = payload;
506                ToolExecutionResult::success_with_raw_output(
507                    json!({
508                        "stdout": stdout,
509                        "stderr": stderr,
510                        "exit_code": exit_code,
511                        "success": success,
512                        "truncated": truncated,
513                        "total_lines": total_lines,
514                    }),
515                    raw_output,
516                )
517            }
518            Ok(Err(e)) => {
519                // Execution error (syntax error, resource limit, etc.)
520                ToolExecutionResult::tool_error(format!("Bash execution error: {}", e))
521            }
522            Err(_) => {
523                // Timeout — signal cancellation for the in-flight execution so any
524                // underlying bashkit work stops promptly, then collect whatever
525                // partial output the streaming callback captured.
526                cancel_token.store(true, std::sync::atomic::Ordering::Relaxed);
527
528                let partial = collect_partial_output(partial_rx);
529                if partial.is_empty() {
530                    ToolExecutionResult::tool_error(format!(
531                        "Command timed out after {}ms",
532                        timeout_ms
533                    ))
534                } else {
535                    use crate::tool_output_sanitizer::{
536                        clean_exec_output, output_verbosity_budget, priority_aware_truncate,
537                        resolve_auto_mode,
538                    };
539                    // EVE-489: a timeout is a failure — `auto` resolves to
540                    // `normal` so the model gets useful diagnostics.
541                    let effective = resolve_auto_mode(output_mode, 1);
542                    let clean = clean_exec_output(&partial);
543                    let truncated = if let Some(budget) = output_verbosity_budget(effective) {
544                        priority_aware_truncate(&clean, budget)
545                    } else {
546                        clean.clone()
547                    };
548                    ToolExecutionResult::tool_error(format!(
549                        "Command timed out after {}ms. Partial output:\n{}",
550                        timeout_ms, truncated
551                    ))
552                }
553            }
554        }
555    }
556
557    fn requires_context(&self) -> bool {
558        true
559    }
560
561    fn as_background_executable(&self) -> Option<&dyn BackgroundExecutableTool> {
562        Some(self)
563    }
564}
565
566#[async_trait]
567impl BackgroundExecutableTool for BashTool {
568    async fn execute_background(
569        &self,
570        arguments: Value,
571        context: ToolContext,
572        sink: Arc<dyn BackgroundEventSink>,
573    ) -> Result<BackgroundOutcome, ToolExecutionResult> {
574        let command = match arguments.get("commands").and_then(|v| v.as_str()) {
575            Some(c) => c,
576            None => {
577                return Err(ToolExecutionResult::tool_error(
578                    "Missing required parameter: commands",
579                ));
580            }
581        };
582
583        let file_store = match &context.file_store {
584            Some(store) => store.clone(),
585            None => {
586                return Err(ToolExecutionResult::tool_error(
587                    "File system not available in this context",
588                ));
589            }
590        };
591
592        let (working_dir, workspace_env) = resolve_shell_workspace(
593            file_store.as_ref(),
594            arguments.get("working_dir").and_then(|v| v.as_str()),
595        );
596
597        let timeout_ms = arguments
598            .get("timeout_ms")
599            .and_then(|v| v.as_u64())
600            .unwrap_or(30000)
601            .min(60000);
602
603        // EVE-489: persistence-first default for background execution as well.
604        let output_mode = arguments
605            .get("output")
606            .and_then(|v| v.as_str())
607            .unwrap_or("auto");
608
609        let session_fs = Arc::new(SessionFileSystemAdapter::new(
610            context.session_id,
611            file_store,
612        ));
613        let locale = context.locale.as_deref().unwrap_or("en-US");
614
615        let builder = Bash::builder()
616            .fs(session_fs)
617            .cwd(working_dir.as_str())
618            .username("everruns")
619            .hostname("everruns")
620            .env("HOME", "/home/agent")
621            .env("SHELL", "/bin/bash")
622            .env("PATH", "/usr/local/bin:/usr/bin:/bin")
623            .env("WORKSPACE", workspace_env.as_str())
624            .env("LANG", locale)
625            .limits(execution_limits())
626            .max_memory(10 * 1024 * 1024)
627            .trace_mode(TraceMode::Redacted);
628        let builder = install_observability_hooks(builder, context.session_id);
629        let builder = configure_http(builder, self.enable_http, &context);
630        let mut bash = builder.build();
631
632        let (tx, mut rx) = tokio::sync::mpsc::channel::<(String, String)>(128);
633        let (partial_tx, partial_rx) = tokio::sync::mpsc::channel::<(String, String)>(128);
634        let sink_for_output = sink.clone();
635        let dropped_chunks = Arc::new(AtomicUsize::new(0));
636        let dropped_chunks_for_callback = dropped_chunks.clone();
637        let output_callback: OutputCallback =
638            Box::new(move |stdout_chunk: &str, stderr_chunk: &str| {
639                if tx
640                    .try_send((stdout_chunk.to_string(), stderr_chunk.to_string()))
641                    .is_err()
642                {
643                    dropped_chunks_for_callback.fetch_add(1, Ordering::Relaxed);
644                }
645                let _ = partial_tx.try_send((stdout_chunk.to_string(), stderr_chunk.to_string()));
646            });
647
648        let emit_task = tokio::spawn(async move {
649            while let Some((stdout_chunk, stderr_chunk)) = rx.recv().await {
650                if !stdout_chunk.is_empty() {
651                    let _ = sink_for_output.output("stdout", &stdout_chunk).await;
652                }
653                if !stderr_chunk.is_empty() {
654                    let _ = sink_for_output.output("stderr", &stderr_chunk).await;
655                }
656            }
657        });
658
659        let _ = sink.status("Running bash command").await;
660        let cancel_token = bash.cancellation_token();
661        let exec_start = std::time::Instant::now();
662        let result = tokio::time::timeout(
663            std::time::Duration::from_millis(timeout_ms),
664            bash.exec_streaming(command, output_callback),
665        )
666        .await;
667        let exec_duration = exec_start.elapsed();
668        let _ = emit_task.await;
669        let dropped_chunks = dropped_chunks.load(Ordering::Relaxed);
670        if dropped_chunks > 0 {
671            let _ = sink
672                .output(
673                    "stderr",
674                    &format!(
675                        "[system] dropped {dropped_chunks} background output chunk(s) due to backpressure\n"
676                    ),
677                )
678                .await;
679        }
680
681        match result {
682            Ok(Ok(output)) => {
683                let payload = ExecToolResultPayload::new(
684                    &output.stdout,
685                    &output.stderr,
686                    output.exit_code,
687                    output_mode,
688                );
689                let ExecToolResultPayload {
690                    stdout,
691                    stderr,
692                    exit_code,
693                    success,
694                    truncated,
695                    total_lines,
696                    raw_output,
697                } = payload;
698                let _ = sink
699                    .progress(BackgroundProgress {
700                        current: Some(exec_duration.as_millis() as u64),
701                        total: None,
702                        unit: Some("ms".to_string()),
703                        label: Some("runtime".to_string()),
704                    })
705                    .await;
706                Ok(BackgroundOutcome {
707                    summary: format!(
708                        "Bash command exited with code {} after {} ms",
709                        exit_code,
710                        exec_duration.as_millis()
711                    ),
712                    result: json!({
713                        "stdout": stdout,
714                        "stderr": stderr,
715                        "exit_code": exit_code,
716                        "success": success,
717                        "truncated": truncated,
718                        "total_lines": total_lines,
719                    }),
720                    raw_output: Some(raw_output),
721                })
722            }
723            Ok(Err(e)) => Err(ToolExecutionResult::tool_error(format!(
724                "Bash execution error: {}",
725                e
726            ))),
727            Err(_) => {
728                cancel_token.store(true, std::sync::atomic::Ordering::Relaxed);
729
730                let partial = collect_partial_output(partial_rx);
731                if partial.is_empty() {
732                    Err(ToolExecutionResult::tool_error(format!(
733                        "Command timed out after {}ms",
734                        timeout_ms
735                    )))
736                } else {
737                    use crate::tool_output_sanitizer::{
738                        clean_exec_output, output_verbosity_budget, priority_aware_truncate,
739                        resolve_auto_mode,
740                    };
741                    // EVE-489: a timeout is a failure — resolve `auto` to
742                    // `normal` so the model gets useful diagnostics.
743                    let effective = resolve_auto_mode(output_mode, 1);
744                    let clean = clean_exec_output(&partial);
745                    let truncated = if let Some(budget) = output_verbosity_budget(effective) {
746                        priority_aware_truncate(&clean, budget)
747                    } else {
748                        clean.clone()
749                    };
750                    Err(ToolExecutionResult::tool_error(format!(
751                        "Command timed out after {}ms. Partial output:\n{}",
752                        timeout_ms, truncated
753                    )))
754                }
755            }
756        }
757    }
758}
759
760// Observational-only. Emits `tracing` events for each bashkit builtin
761// invocation and interpreter error, tagged with the active `session_id` for
762// audit correlation. Every hook returns `HookAction::Continue`; none widen
763// bashkit's existing limits or sandbox (TM-BASH). Hook callbacks log
764// structural metadata (tool name, arg count, exit code, byte lengths) but
765// never the argument values or builtin stdout — those surfaces can carry
766// tenant paths, URLs, or embedded secrets. HTTP hooks (`before_http` /
767// `after_http`) are registered in `configure_http` when outbound HTTP is
768// enabled (TM-BASH-003: off by default, egress-routed when on).
769fn install_observability_hooks(builder: BashBuilder, session_id: SessionId) -> BashBuilder {
770    use bashkit::hooks::{ErrorEvent, HookAction, ToolEvent, ToolResult};
771    builder
772        .before_tool(Box::new(move |ev: ToolEvent| {
773            tracing::debug!(
774                target: "bashkit.hook",
775                capability = "bashkit_shell",
776                session_id = %session_id,
777                event = "before_tool",
778                tool = %ev.name,
779                arg_count = ev.args.len(),
780                "builtin invoked"
781            );
782            HookAction::Continue(ev)
783        }))
784        .after_tool(Box::new(move |res: ToolResult| {
785            tracing::debug!(
786                target: "bashkit.hook",
787                capability = "bashkit_shell",
788                session_id = %session_id,
789                event = "after_tool",
790                tool = %res.name,
791                exit_code = res.exit_code,
792                stdout_bytes = res.stdout.len(),
793                "builtin completed"
794            );
795            HookAction::Continue(res)
796        }))
797        .on_error(Box::new(move |ev: ErrorEvent| {
798            let preview = truncate_for_log(&ev.message, 256);
799            tracing::warn!(
800                target: "bashkit.hook",
801                capability = "bashkit_shell",
802                session_id = %session_id,
803                event = "on_error",
804                message = %preview,
805                "interpreter error"
806            );
807            HookAction::Continue(ev)
808        }))
809}
810
811/// Enable outbound HTTP for curl/wget when the per-capability config opted in
812/// AND the execution context provides an egress service.
813///
814/// Design (specs/egress.md migration step 3, bashkit `specs/http-transport.md`):
815/// bashkit keeps its full HTTP policy pipeline — `allow_all()` retains the
816/// private-IP-blocking SSRF precheck whose resolve-then-check result is
817/// forwarded as pinned addresses — while connectivity is owned by
818/// [`egress_transport::BashkitEgressTransport`], so the merged
819/// `NetworkAccessList` and the deployment-wide system allowlist are enforced
820/// at the egress boundary for every hop (curl/wget re-dispatch redirects).
821///
822/// THREAT[TM-BASH-003]: with `enable_http` off (the default) this function is
823/// a no-op and the interpreter has no network path. When on, there is no
824/// direct-dial fallback: absent an egress service the shell stays offline
825/// rather than opening host-local connectivity.
826///
827/// Bot-auth request signing mirrors web_fetch: server-wide
828/// `BOT_AUTH_SIGNING_KEY_SEED` (+ optional `BOT_AUTH_AGENT_FQDN`,
829/// `BOT_AUTH_VALIDITY_SECS`) transparently signs every outbound request
830/// before it reaches the transport (bashkit `specs/request-signing.md`).
831fn configure_http(builder: BashBuilder, enable_http: bool, context: &ToolContext) -> BashBuilder {
832    if !enable_http {
833        return builder;
834    }
835    let Some(egress) = context.egress_service.clone() else {
836        tracing::warn!(
837            capability = "bashkit_shell",
838            session_id = %context.session_id,
839            "enable_http set but no egress service in context; shell HTTP stays disabled"
840        );
841        return builder;
842    };
843    let session_id = context.session_id;
844    let mut builder = builder
845        .network(NetworkAllowlist::allow_all())
846        .http_transport(Arc::new(egress_transport::BashkitEgressTransport::new(
847            egress,
848            context.network_access.clone(),
849        )))
850        // Observational HTTP hooks (bashkit-requirements.md): log method and
851        // status only — URLs and headers can carry tenant data or secrets.
852        .before_http(Box::new(move |ev: bashkit::hooks::HttpRequestEvent| {
853            tracing::debug!(
854                target: "bashkit.hook",
855                capability = "bashkit_shell",
856                session_id = %session_id,
857                event = "before_http",
858                method = %ev.method,
859                header_count = ev.headers.len(),
860                "outbound http request"
861            );
862            bashkit::hooks::HookAction::Continue(ev)
863        }))
864        .after_http(Box::new(move |ev: bashkit::hooks::HttpResponseEvent| {
865            tracing::debug!(
866                target: "bashkit.hook",
867                capability = "bashkit_shell",
868                session_id = %session_id,
869                event = "after_http",
870                status = ev.status,
871                "outbound http response"
872            );
873            bashkit::hooks::HookAction::Continue(ev)
874        }));
875    if let Some(bot_auth) = bot_auth_config_from_env() {
876        builder = builder.bot_auth(bot_auth);
877    }
878    builder
879}
880
881/// Read the server-wide bot-auth signing config once (same env contract as
882/// `web_fetch`; see `specs/fetchkit.md` "Bot-auth"). Returns a fresh clone per
883/// call site because `BashBuilder::bot_auth` takes ownership.
884fn bot_auth_config_from_env() -> Option<bashkit::BotAuthConfig> {
885    static CONFIG: LazyLock<Option<bashkit::BotAuthConfig>> = LazyLock::new(|| {
886        let seed = std::env::var("BOT_AUTH_SIGNING_KEY_SEED").ok()?;
887        let mut config = match bashkit::BotAuthConfig::from_base64_seed(&seed) {
888            Ok(config) => config,
889            Err(e) => {
890                tracing::warn!(error = %e, "invalid BOT_AUTH_SIGNING_KEY_SEED, bashkit bot-auth disabled");
891                return None;
892            }
893        };
894        if let Ok(fqdn) = std::env::var("BOT_AUTH_AGENT_FQDN") {
895            config = config.with_agent_fqdn(&fqdn);
896        }
897        if let Ok(secs) = std::env::var("BOT_AUTH_VALIDITY_SECS")
898            && let Ok(secs) = secs.parse::<u64>()
899        {
900            config = config.with_validity_secs(secs);
901        }
902        Some(config)
903    });
904    CONFIG.clone()
905}
906
907/// Bounded diagnostic preview for hook log fields. The return value is
908/// guaranteed to be no longer than `max_bytes` and to end on a valid UTF-8
909/// char boundary. When there is room, a trailing marker is appended so
910/// truncated entries remain visible in logs without exceeding the budget.
911fn truncate_for_log(msg: &str, max_bytes: usize) -> String {
912    const MARKER: &str = "…[truncated]";
913    if msg.len() <= max_bytes {
914        return msg.to_string();
915    }
916    let budget = max_bytes.saturating_sub(MARKER.len());
917    let mut cut = budget.min(msg.len());
918    while cut > 0 && !msg.is_char_boundary(cut) {
919        cut -= 1;
920    }
921    if max_bytes > MARKER.len() {
922        format!("{}{}", &msg[..cut], MARKER)
923    } else {
924        // Budget too small to fit the marker; return just the bounded slice.
925        let mut cut = max_bytes.min(msg.len());
926        while cut > 0 && !msg.is_char_boundary(cut) {
927            cut -= 1;
928        }
929        msg[..cut].to_string()
930    }
931}
932
933/// Drain all buffered chunks from the partial output channel into a single string.
934/// Keeps stdout and stderr separated with the same delimiter convention used elsewhere.
935fn collect_partial_output(mut rx: tokio::sync::mpsc::Receiver<(String, String)>) -> String {
936    let mut stdout_buf = String::new();
937    let mut stderr_buf = String::new();
938    while let Ok((stdout, stderr)) = rx.try_recv() {
939        stdout_buf.push_str(&stdout);
940        stderr_buf.push_str(&stderr);
941    }
942    let mut partial = stdout_buf;
943    if !stderr_buf.is_empty() {
944        if !partial.is_empty() && !partial.ends_with('\n') {
945            partial.push('\n');
946        }
947        partial.push_str("--- stderr ---\n");
948        partial.push_str(&stderr_buf);
949    }
950    partial
951}
952
953// ============================================================================
954// SessionFileSystemAdapter
955// ============================================================================
956
957/// Adapter that implements bashkit's FileSystem trait by delegating to SessionFileSystem.
958///
959/// This provides live visibility of session files during bash execution - any files
960/// written by other tools are immediately visible, and vice versa.
961pub struct SessionFileSystemAdapter {
962    session_id: SessionId,
963    store: Arc<dyn SessionFileSystem>,
964}
965
966impl SessionFileSystemAdapter {
967    pub fn new(session_id: SessionId, store: Arc<dyn SessionFileSystem>) -> Self {
968        Self { session_id, store }
969    }
970
971    /// The bash VFS path as a string for the store to resolve.
972    ///
973    /// The store ([`MountFs`](crate::mount_fs::MountFs)) is the single path
974    /// authority (EVE-660): it routes the `/workspace` alias, the root mount,
975    /// relative-to-cwd, and host-absolute paths to the right backend, and the
976    /// backend enforces containment (host stores reject symlinks and clamp to
977    /// their root). The adapter no longer parses paths itself, so the shell, the
978    /// file tools, and the resolver share one namespace — and the shell can
979    /// address files anywhere from `/`, with `/workspace` as just its cwd.
980    fn store_path(path: &Path) -> String {
981        path.to_string_lossy().into_owned()
982    }
983
984    /// Whether `session_path` is an implicit directory — one with children but no row
985    /// of its own (virtual mounts, unmaterialized parents).
986    ///
987    /// Stores answer `list_directory` for an unknown path with `Ok(vec![])`, so only a
988    /// non-empty listing distinguishes a real directory from an absent path.
989    async fn directory_has_entries(&self, session_path: &str) -> bool {
990        self.store
991            .list_directory(self.session_id, session_path)
992            .await
993            .is_ok_and(|entries| !entries.is_empty())
994    }
995}
996
997#[async_trait]
998impl FileSystemExt for SessionFileSystemAdapter {}
999
1000#[async_trait]
1001impl FileSystem for SessionFileSystemAdapter {
1002    async fn read_file(&self, path: &Path) -> bashkit::Result<Vec<u8>> {
1003        let session_path = Self::store_path(path);
1004
1005        match self.store.read_file(self.session_id, &session_path).await {
1006            Ok(Some(file)) => {
1007                let content = file.content.unwrap_or_default();
1008                SessionFile::decode_content(&content, &file.encoding)
1009                    .map_err(|e| bashkit::Error::Io(std::io::Error::other(e.to_string())))
1010            }
1011            Ok(None) => Err(bashkit::Error::Io(std::io::Error::new(
1012                std::io::ErrorKind::NotFound,
1013                format!("File not found: {}", path.display()),
1014            ))),
1015            Err(e) => Err(bashkit::Error::Io(std::io::Error::other(e.to_string()))),
1016        }
1017    }
1018
1019    async fn write_file(&self, path: &Path, content: &[u8]) -> bashkit::Result<()> {
1020        let session_path = Self::store_path(path);
1021
1022        let (encoded, encoding) = SessionFile::encode_content(content);
1023
1024        self.store
1025            .write_file(self.session_id, &session_path, &encoded, &encoding)
1026            .await
1027            .map(|_| ())
1028            .map_err(|e| bashkit::Error::Io(std::io::Error::other(e.to_string())))
1029    }
1030
1031    async fn append_file(&self, path: &Path, content: &[u8]) -> bashkit::Result<()> {
1032        let session_path = Self::store_path(path);
1033
1034        // Read existing content
1035        let mut existing = match self.store.read_file(self.session_id, &session_path).await {
1036            Ok(Some(file)) => {
1037                let content = file.content.unwrap_or_default();
1038                SessionFile::decode_content(&content, &file.encoding)
1039                    .map_err(|e| bashkit::Error::Io(std::io::Error::other(e.to_string())))?
1040            }
1041            Ok(None) => Vec::new(),
1042            Err(e) => return Err(bashkit::Error::Io(std::io::Error::other(e.to_string()))),
1043        };
1044
1045        // Append new content
1046        existing.extend_from_slice(content);
1047
1048        // Write back
1049        let (encoded, encoding) = SessionFile::encode_content(&existing);
1050        self.store
1051            .write_file(self.session_id, &session_path, &encoded, &encoding)
1052            .await
1053            .map(|_| ())
1054            .map_err(|e| bashkit::Error::Io(std::io::Error::other(e.to_string())))
1055    }
1056
1057    async fn mkdir(&self, path: &Path, _recursive: bool) -> bashkit::Result<()> {
1058        let session_path = Self::store_path(path);
1059
1060        self.store
1061            .create_directory(self.session_id, &session_path)
1062            .await
1063            .map(|_| ())
1064            .map_err(|e| bashkit::Error::Io(std::io::Error::other(e.to_string())))
1065    }
1066
1067    async fn remove(&self, path: &Path, recursive: bool) -> bashkit::Result<()> {
1068        let session_path = Self::store_path(path);
1069
1070        self.store
1071            .delete_file(self.session_id, &session_path, recursive)
1072            .await
1073            .map(|_| ())
1074            .map_err(|e| bashkit::Error::Io(std::io::Error::other(e.to_string())))
1075    }
1076
1077    async fn stat(&self, path: &Path) -> bashkit::Result<Metadata> {
1078        // Handle /workspace itself
1079        if path.to_string_lossy() == "/workspace" {
1080            let now = SystemTime::now();
1081            return Ok(Metadata {
1082                file_type: FileType::Directory,
1083                size: 0,
1084                mode: 0o755,
1085                modified: now,
1086                created: now,
1087            });
1088        }
1089
1090        let session_path = Self::store_path(path);
1091
1092        // Check if it's a file
1093        match self.store.read_file(self.session_id, &session_path).await {
1094            Ok(Some(file)) => {
1095                let now = SystemTime::now();
1096
1097                let file_type = if file.is_directory {
1098                    FileType::Directory
1099                } else {
1100                    FileType::File
1101                };
1102
1103                // Use 0o755 so files are executable by default in the virtual filesystem.
1104                // The session filesystem doesn't track Unix permissions, and scripts
1105                // stored in /workspace need to be directly executable.
1106                Ok(Metadata {
1107                    file_type,
1108                    size: file.size_bytes as u64,
1109                    mode: 0o755,
1110                    modified: now,
1111                    created: now,
1112                })
1113            }
1114            Ok(None) => {
1115                // No row: the path can still be an implicit directory (a virtual mount,
1116                // or a parent never materialized as its own row). Only a *non-empty*
1117                // listing proves that. Treating any `Ok` as a directory made every
1118                // absent path look like an empty directory, because stores return
1119                // `Ok(vec![])` rather than an error for paths they do not know.
1120                if self.directory_has_entries(&session_path).await {
1121                    let now = SystemTime::now();
1122                    Ok(Metadata {
1123                        file_type: FileType::Directory,
1124                        size: 0,
1125                        mode: 0o755,
1126                        modified: now,
1127                        created: now,
1128                    })
1129                } else {
1130                    Err(bashkit::Error::Io(std::io::Error::new(
1131                        std::io::ErrorKind::NotFound,
1132                        format!("Path not found: {}", path.display()),
1133                    )))
1134                }
1135            }
1136            Err(e) => Err(bashkit::Error::Io(std::io::Error::other(e.to_string()))),
1137        }
1138    }
1139
1140    async fn read_dir(&self, path: &Path) -> bashkit::Result<Vec<DirEntry>> {
1141        let session_path = Self::store_path(path);
1142
1143        let entries = self
1144            .store
1145            .list_directory(self.session_id, &session_path)
1146            .await
1147            .map_err(|e| bashkit::Error::Io(std::io::Error::other(e.to_string())))?;
1148
1149        let now = SystemTime::now();
1150
1151        Ok(entries
1152            .into_iter()
1153            .map(|e| {
1154                let file_type = if e.is_directory {
1155                    FileType::Directory
1156                } else {
1157                    FileType::File
1158                };
1159
1160                DirEntry {
1161                    name: e.name,
1162                    metadata: Metadata {
1163                        file_type,
1164                        size: e.size_bytes as u64,
1165                        mode: 0o755,
1166                        modified: now,
1167                        created: now,
1168                    },
1169                }
1170            })
1171            .collect())
1172    }
1173
1174    async fn exists(&self, path: &Path) -> bashkit::Result<bool> {
1175        // /workspace always exists
1176        if path.to_string_lossy() == "/workspace" {
1177            return Ok(true);
1178        }
1179
1180        let session_path = Self::store_path(path);
1181
1182        // A row exists for both files and materialized directories.
1183        if let Ok(Some(_)) = self.store.read_file(self.session_id, &session_path).await {
1184            return Ok(true);
1185        }
1186
1187        // Otherwise only an implicit directory counts — see `stat` for why an empty
1188        // listing must not be read as existence.
1189        Ok(self.directory_has_entries(&session_path).await)
1190    }
1191
1192    async fn rename(&self, from: &Path, to: &Path) -> bashkit::Result<()> {
1193        let from_session = Self::store_path(from);
1194
1195        // Read source file
1196        let content = self.read_file(from).await?;
1197
1198        // Write to destination
1199        self.write_file(to, &content).await?;
1200
1201        // Delete source
1202        self.store
1203            .delete_file(self.session_id, &from_session, false)
1204            .await
1205            .map_err(|e| bashkit::Error::Io(std::io::Error::other(e.to_string())))?;
1206
1207        Ok(())
1208    }
1209
1210    async fn copy(&self, from: &Path, to: &Path) -> bashkit::Result<()> {
1211        let content = self.read_file(from).await?;
1212        self.write_file(to, &content).await
1213    }
1214
1215    async fn symlink(&self, _target: &Path, _link: &Path) -> bashkit::Result<()> {
1216        // Session filesystem doesn't support symlinks
1217        Err(bashkit::Error::Io(std::io::Error::new(
1218            std::io::ErrorKind::Unsupported,
1219            "Symlinks not supported in session filesystem",
1220        )))
1221    }
1222
1223    async fn read_link(&self, path: &Path) -> bashkit::Result<PathBuf> {
1224        // Session filesystem doesn't support symlinks
1225        Err(bashkit::Error::Io(std::io::Error::new(
1226            std::io::ErrorKind::Unsupported,
1227            format!("Symlinks not supported: {}", path.display()),
1228        )))
1229    }
1230
1231    async fn chmod(&self, _path: &Path, _mode: u32) -> bashkit::Result<()> {
1232        // chmod is a no-op - session filesystem doesn't track permissions
1233        Ok(())
1234    }
1235
1236    // THREAT[TM-BASH-017]: no-op like `chmod` (TM-BASH-014). The session store does not
1237    // persist mtimes and `stat` synthesizes them, so there is nothing to spoof. The
1238    // bashkit default impl errors, which made `touch` fail after it had already
1239    // created the file.
1240    async fn set_modified_time(&self, _path: &Path, _time: SystemTime) -> bashkit::Result<()> {
1241        Ok(())
1242    }
1243
1244    fn as_search_capable(&self) -> Option<&dyn SearchCapable> {
1245        Some(self)
1246    }
1247}
1248
1249// ============================================================================
1250// SearchCapable / SearchProvider — indexed search via SessionFileSystem
1251// ============================================================================
1252
1253impl SearchCapable for SessionFileSystemAdapter {
1254    fn search_provider(&self, _path: &Path) -> Option<Box<dyn SearchProvider>> {
1255        // The store resolves any path (root mount included), so indexed search is
1256        // available everywhere the shell can address.
1257        Some(Box::new(SessionSearchProvider {
1258            session_id: self.session_id,
1259            store: self.store.clone(),
1260        }))
1261    }
1262}
1263
1264/// Bridges bashkit's synchronous `SearchProvider` to `SessionFileSystem::grep_files`.
1265///
1266/// Uses a scoped thread with a dedicated tokio runtime to call the async
1267/// store method from the sync trait, avoiding nested `block_on` calls.
1268struct SessionSearchProvider {
1269    session_id: SessionId,
1270    store: Arc<dyn SessionFileSystem>,
1271}
1272
1273impl SearchProvider for SessionSearchProvider {
1274    fn search(&self, query: &SearchQuery) -> bashkit::Result<SearchResults> {
1275        let session_id = self.session_id;
1276        let store = self.store.clone();
1277        let root = query.root.to_string_lossy().into_owned();
1278        let max_results = query.max_results;
1279
1280        // Honor case_insensitive flag via inline regex flag
1281        let pattern = if query.case_insensitive {
1282            format!("(?i){}", query.pattern)
1283        } else {
1284            query.pattern.clone()
1285        };
1286
1287        // The store ([`MountFs`]) resolves the search root, so search shares the
1288        // shell's namespace. A root at the workspace top searches the whole tree
1289        // (no path filter); anything deeper is passed through for the store to
1290        // resolve and scope.
1291        let path_pattern = if root == crate::mount_fs::WORKSPACE_MOUNT || root == "/" {
1292            None
1293        } else {
1294            Some(root)
1295        };
1296
1297        // Bridge async grep_files to sync SearchProvider::search.
1298        // Run on a dedicated thread with its own runtime to avoid nesting
1299        // block_on calls within the caller's tokio runtime.
1300        let matches = std::thread::scope(|s| {
1301            s.spawn(|| {
1302                let rt = tokio::runtime::Builder::new_current_thread()
1303                    .enable_all()
1304                    .build()
1305                    .map_err(|e| bashkit::Error::Io(std::io::Error::other(e.to_string())))?;
1306                rt.block_on(async {
1307                    store
1308                        .grep_files(session_id, &pattern, path_pattern.as_deref())
1309                        .await
1310                })
1311                .map_err(|e| bashkit::Error::Io(std::io::Error::other(e.to_string())))
1312            })
1313            .join()
1314            .unwrap_or_else(|_| {
1315                Err(bashkit::Error::Io(std::io::Error::other(
1316                    "search thread panicked",
1317                )))
1318            })
1319        })?;
1320
1321        let truncated = max_results.is_some_and(|max| matches.len() > max);
1322        let matches: Vec<BashkitSearchMatch> = matches
1323            .into_iter()
1324            .take(max_results.unwrap_or(usize::MAX))
1325            .map(|m| {
1326                // Render the backend match path back into the shell's namespace
1327                // (the `/workspace` view) so matches read back in the same
1328                // namespace the shell resolves against.
1329                let vfs_path = self.store.display_path(&m.path);
1330                BashkitSearchMatch {
1331                    path: PathBuf::from(vfs_path),
1332                    line_number: m.line_number,
1333                    line_content: m.line,
1334                }
1335            })
1336            .collect();
1337
1338        Ok(SearchResults { matches, truncated })
1339    }
1340
1341    fn capabilities(&self) -> SearchCapabilities {
1342        SearchCapabilities {
1343            regex: true,
1344            glob_filter: false,
1345            content_search: true,
1346            filename_search: false,
1347        }
1348    }
1349}
1350
1351#[cfg(test)]
1352mod tests {
1353    use super::*;
1354    use crate::session_file::FileInfo;
1355    use crate::traits::SessionFileSystem;
1356    use crate::typed_id::SessionId;
1357    use crate::{FileStat, GrepMatch, Result};
1358    use std::collections::HashMap;
1359    use std::sync::Mutex;
1360
1361    // ========================================================================
1362    // MockFileStore for testing
1363    // ========================================================================
1364
1365    /// In-memory file store for testing
1366    struct MockFileStore {
1367        files: Mutex<HashMap<(SessionId, String), (String, String)>>, // (content, encoding)
1368        directories: Mutex<HashMap<(SessionId, String), bool>>,
1369    }
1370
1371    impl MockFileStore {
1372        fn new() -> Self {
1373            Self {
1374                files: Mutex::new(HashMap::new()),
1375                directories: Mutex::new(HashMap::new()),
1376            }
1377        }
1378
1379        fn normalize_path(path: &str) -> String {
1380            let mut normalized = path.trim().to_string();
1381            if !normalized.starts_with('/') {
1382                normalized = format!("/{}", normalized);
1383            }
1384            if normalized.len() > 1 && normalized.ends_with('/') {
1385                normalized.pop();
1386            }
1387            normalized
1388        }
1389    }
1390
1391    #[async_trait]
1392    impl SessionFileSystem for MockFileStore {
1393        fn is_mount_resolver(&self) -> bool {
1394            false
1395        }
1396
1397        async fn read_file(
1398            &self,
1399            session_id: SessionId,
1400            path: &str,
1401        ) -> Result<Option<SessionFile>> {
1402            let path = Self::normalize_path(path);
1403            let files = self.files.lock().unwrap();
1404            if let Some((content, encoding)) = files.get(&(session_id, path.clone())) {
1405                Ok(Some(SessionFile {
1406                    id: uuid::Uuid::new_v4(),
1407                    session_id: session_id.into(),
1408                    path: path.clone(),
1409                    name: path.split('/').next_back().unwrap_or("").to_string(),
1410                    is_directory: false,
1411                    is_readonly: false,
1412                    content: Some(content.clone()),
1413                    encoding: encoding.clone(),
1414                    size_bytes: content.len() as i64,
1415                    created_at: chrono::Utc::now(),
1416                    updated_at: chrono::Utc::now(),
1417                }))
1418            } else if self
1419                .directories
1420                .lock()
1421                .unwrap()
1422                .contains_key(&(session_id, path.clone()))
1423            {
1424                // Production stores materialize directories as rows that `read_file`
1425                // returns (see `InMemorySessionFileStore::create_directory`), so the
1426                // double must too — otherwise `exists`/`stat` cannot see an empty dir.
1427                Ok(Some(SessionFile {
1428                    id: uuid::Uuid::new_v4(),
1429                    session_id: session_id.into(),
1430                    path: path.clone(),
1431                    name: path.split('/').next_back().unwrap_or("").to_string(),
1432                    is_directory: true,
1433                    is_readonly: false,
1434                    content: None,
1435                    encoding: "text".to_string(),
1436                    size_bytes: 0,
1437                    created_at: chrono::Utc::now(),
1438                    updated_at: chrono::Utc::now(),
1439                }))
1440            } else {
1441                Ok(None)
1442            }
1443        }
1444
1445        async fn write_file(
1446            &self,
1447            session_id: SessionId,
1448            path: &str,
1449            content: &str,
1450            encoding: &str,
1451        ) -> Result<SessionFile> {
1452            let path = Self::normalize_path(path);
1453            let mut files = self.files.lock().unwrap();
1454            files.insert(
1455                (session_id, path.clone()),
1456                (content.to_string(), encoding.to_string()),
1457            );
1458            Ok(SessionFile {
1459                id: uuid::Uuid::new_v4(),
1460                session_id: session_id.into(),
1461                path: path.clone(),
1462                name: path.split('/').next_back().unwrap_or("").to_string(),
1463                is_directory: false,
1464                is_readonly: false,
1465                content: Some(content.to_string()),
1466                encoding: encoding.to_string(),
1467                size_bytes: content.len() as i64,
1468                created_at: chrono::Utc::now(),
1469                updated_at: chrono::Utc::now(),
1470            })
1471        }
1472
1473        async fn delete_file(
1474            &self,
1475            session_id: SessionId,
1476            path: &str,
1477            _recursive: bool,
1478        ) -> Result<bool> {
1479            let path = Self::normalize_path(path);
1480            let mut files = self.files.lock().unwrap();
1481            Ok(files.remove(&(session_id, path)).is_some())
1482        }
1483
1484        async fn list_directory(&self, session_id: SessionId, path: &str) -> Result<Vec<FileInfo>> {
1485            let path = Self::normalize_path(path);
1486            let files = self.files.lock().unwrap();
1487            let dirs = self.directories.lock().unwrap();
1488            let mut entries = Vec::new();
1489
1490            // Root directory always exists
1491            let is_root = path == "/";
1492
1493            for ((sid, file_path), (content, _)) in files.iter() {
1494                if *sid != session_id {
1495                    continue;
1496                }
1497
1498                // Check if file is directly under this path
1499                let parent = if let Some(idx) = file_path.rfind('/') {
1500                    if idx == 0 {
1501                        "/".to_string()
1502                    } else {
1503                        file_path[..idx].to_string()
1504                    }
1505                } else {
1506                    "/".to_string()
1507                };
1508
1509                if parent == path {
1510                    entries.push(FileInfo {
1511                        id: uuid::Uuid::new_v4(),
1512                        session_id: session_id.into(),
1513                        path: file_path.clone(),
1514                        name: file_path.split('/').next_back().unwrap_or("").to_string(),
1515                        is_directory: false,
1516                        is_readonly: false,
1517                        size_bytes: content.len() as i64,
1518                        created_at: chrono::Utc::now(),
1519                        updated_at: chrono::Utc::now(),
1520                    });
1521                }
1522            }
1523
1524            // Subdirectory entries. Production lists directory rows alongside file rows,
1525            // so recursive walks (`find`, `grep -r`) can descend. Cover both explicitly
1526            // created directories and ones implied by a nested file path.
1527            let prefix = if is_root {
1528                "/".to_string()
1529            } else {
1530                format!("{path}/")
1531            };
1532            let mut child_dirs: std::collections::BTreeSet<String> =
1533                std::collections::BTreeSet::new();
1534            let descendant_paths = files
1535                .keys()
1536                .chain(dirs.keys())
1537                .filter(|(sid, _)| *sid == session_id)
1538                .map(|(_, p)| p);
1539            for candidate in descendant_paths {
1540                if let Some(rest) = candidate.strip_prefix(&prefix)
1541                    && let Some(name) = rest.split('/').next()
1542                    && rest.contains('/')
1543                    && !name.is_empty()
1544                {
1545                    child_dirs.insert(name.to_string());
1546                }
1547            }
1548            for name in child_dirs {
1549                entries.push(FileInfo {
1550                    id: uuid::Uuid::new_v4(),
1551                    session_id: session_id.into(),
1552                    path: format!("{prefix}{name}"),
1553                    name,
1554                    is_directory: true,
1555                    is_readonly: false,
1556                    size_bytes: 0,
1557                    created_at: chrono::Utc::now(),
1558                    updated_at: chrono::Utc::now(),
1559                });
1560            }
1561
1562            // Return error if directory doesn't exist (not root, not explicitly created,
1563            // and no files have it as parent)
1564            if !is_root && entries.is_empty() && !dirs.contains_key(&(session_id, path.clone())) {
1565                // Also check if any file has this as an ancestor (implicit directory)
1566                let has_children = files
1567                    .keys()
1568                    .any(|(sid, fp)| *sid == session_id && fp.starts_with(&format!("{}/", path)));
1569                if !has_children {
1570                    return Err(anyhow::anyhow!("Directory not found: {}", path).into());
1571                }
1572            }
1573
1574            Ok(entries)
1575        }
1576
1577        async fn stat_file(&self, session_id: SessionId, path: &str) -> Result<Option<FileStat>> {
1578            let path = Self::normalize_path(path);
1579            let files = self.files.lock().unwrap();
1580            if let Some((content, _)) = files.get(&(session_id, path.clone())) {
1581                Ok(Some(FileStat {
1582                    path: path.clone(),
1583                    name: path.split('/').next_back().unwrap_or("").to_string(),
1584                    is_directory: false,
1585                    is_readonly: false,
1586                    size_bytes: content.len() as i64,
1587                    created_at: chrono::Utc::now(),
1588                    updated_at: chrono::Utc::now(),
1589                }))
1590            } else {
1591                Ok(None)
1592            }
1593        }
1594
1595        async fn grep_files(
1596            &self,
1597            session_id: SessionId,
1598            pattern: &str,
1599            path_pattern: Option<&str>,
1600        ) -> Result<Vec<GrepMatch>> {
1601            let regex = regex::Regex::new(pattern)
1602                .map_err(|e| anyhow::anyhow!("invalid pattern: {}", e))?;
1603            let files = self.files.lock().unwrap();
1604            let mut matches = Vec::new();
1605            for ((sid, file_path), (content, _)) in files.iter() {
1606                if *sid != session_id {
1607                    continue;
1608                }
1609                if let Some(pp) = path_pattern
1610                    && !file_path.starts_with(pp)
1611                {
1612                    continue;
1613                }
1614                let decoded = SessionFile::decode_content(content, "utf-8")
1615                    .unwrap_or_else(|_| content.as_bytes().to_vec());
1616                let text = String::from_utf8_lossy(&decoded);
1617                for (i, line) in text.lines().enumerate() {
1618                    if regex.is_match(line) {
1619                        matches.push(GrepMatch {
1620                            path: file_path.clone(),
1621                            line_number: i + 1,
1622                            line: line.to_string(),
1623                        });
1624                    }
1625                }
1626            }
1627            matches.sort_by(|a, b| a.path.cmp(&b.path).then(a.line_number.cmp(&b.line_number)));
1628            Ok(matches)
1629        }
1630
1631        async fn create_directory(&self, session_id: SessionId, path: &str) -> Result<FileInfo> {
1632            let path = Self::normalize_path(path);
1633            let mut dirs = self.directories.lock().unwrap();
1634            dirs.insert((session_id, path.clone()), true);
1635            Ok(FileInfo {
1636                id: uuid::Uuid::new_v4(),
1637                session_id: session_id.into(),
1638                path: path.clone(),
1639                name: path.split('/').next_back().unwrap_or("").to_string(),
1640                is_directory: true,
1641                is_readonly: false,
1642                size_bytes: 0,
1643                created_at: chrono::Utc::now(),
1644                updated_at: chrono::Utc::now(),
1645            })
1646        }
1647    }
1648
1649    // ========================================================================
1650    // Capability metadata tests
1651    // ========================================================================
1652
1653    // Metadata (id/name/status/risk/icon/category), tool-list, and dependency
1654    // constants are covered registry-wide by
1655    // `builtin_capabilities_satisfy_registry_invariants` in `capabilities::tests`.
1656    // Only the behavioral assertion — the description advertises built-in help —
1657    // is kept here.
1658    #[test]
1659    fn description_advertises_builtin_help_and_version() {
1660        let description = BashkitShellCapability.description();
1661        assert!(
1662            description.contains("`<command> --help`"),
1663            "description should advertise built-in help, got: {description}"
1664        );
1665        assert!(
1666            description.contains("`<command> --version`"),
1667            "description should advertise built-in version support, got: {description}"
1668        );
1669    }
1670
1671    #[test]
1672    fn test_capability_has_system_prompt() {
1673        let cap = BashkitShellCapability;
1674        let prompt = cap.system_prompt_addition().unwrap();
1675        // System prompt is now provided by bashkit library
1676        assert!(!prompt.is_empty(), "System prompt should not be empty");
1677        // Should contain the configured username/hostname
1678        assert!(
1679            prompt.contains("everruns"),
1680            "System prompt should contain configured identity"
1681        );
1682    }
1683
1684    // ========================================================================
1685    // Path resolution (delegated to the store / MountFs)
1686    // ========================================================================
1687
1688    // The adapter no longer parses paths itself (EVE-660): it hands them to the
1689    // store, which is a `MountFs` in production. These confirm the delegation, so
1690    // the shell shares the file tools' namespace — `/workspace` is the cwd view,
1691    // and the root mount makes any path addressable. Resolution edge cases live
1692    // in `mount_fs::tests`.
1693    fn mount_adapter() -> SessionFileSystemAdapter {
1694        let store = crate::mount_fs::MountFs::wrap(Arc::new(MockFileStore::new()));
1695        SessionFileSystemAdapter::new(SessionId::new(), store)
1696    }
1697
1698    #[tokio::test]
1699    async fn adapter_maps_workspace_alias_to_backend_root() {
1700        let adapter = mount_adapter();
1701        adapter
1702            .write_file(Path::new("/workspace/file.txt"), b"hi")
1703            .await
1704            .unwrap();
1705        // The same file is visible via the `/workspace` alias and the
1706        // backend-native path — one namespace.
1707        assert_eq!(
1708            adapter
1709                .read_file(Path::new("/workspace/file.txt"))
1710                .await
1711                .unwrap(),
1712            b"hi"
1713        );
1714        assert_eq!(
1715            adapter.read_file(Path::new("/file.txt")).await.unwrap(),
1716            b"hi"
1717        );
1718    }
1719
1720    #[tokio::test]
1721    async fn adapter_addresses_any_path_from_root() {
1722        // The old adapter rejected paths outside `/workspace`; with the root
1723        // mount they resolve into the backend instead (write anywhere from root,
1724        // still contained by the backend).
1725        let adapter = mount_adapter();
1726        adapter
1727            .write_file(Path::new("/tmp/file.txt"), b"x")
1728            .await
1729            .unwrap();
1730        assert_eq!(
1731            adapter.read_file(Path::new("/tmp/file.txt")).await.unwrap(),
1732            b"x"
1733        );
1734    }
1735
1736    // ========================================================================
1737    // Tool error handling tests
1738    // ========================================================================
1739
1740    #[tokio::test]
1741    async fn test_bash_without_context() {
1742        let tool = BashTool::default();
1743        let result = tool.execute(json!({"commands": "echo hello"})).await;
1744
1745        if let ToolExecutionResult::ToolError(msg) = result {
1746            assert!(msg.contains("requires context"));
1747        } else {
1748            panic!("Expected tool error");
1749        }
1750    }
1751
1752    #[tokio::test]
1753    async fn test_bash_missing_command() {
1754        let tool = BashTool::default();
1755        let context = ToolContext::new(SessionId::new());
1756
1757        let result = tool.execute_with_context(json!({}), &context).await;
1758
1759        if let ToolExecutionResult::ToolError(msg) = result {
1760            assert!(msg.contains("Missing required parameter"));
1761        } else {
1762            panic!("Expected tool error for missing command");
1763        }
1764    }
1765
1766    #[tokio::test]
1767    async fn test_bash_no_file_store() {
1768        let tool = BashTool::default();
1769        let context = ToolContext::new(SessionId::new());
1770
1771        let result = tool
1772            .execute_with_context(json!({"commands": "echo hello"}), &context)
1773            .await;
1774
1775        if let ToolExecutionResult::ToolError(msg) = result {
1776            assert!(msg.contains("not available"));
1777        } else {
1778            panic!("Expected tool error for missing file store");
1779        }
1780    }
1781
1782    // ========================================================================
1783    // Bash execution tests with MockFileStore
1784    // ========================================================================
1785
1786    fn create_context_with_mock_store() -> (ToolContext, SessionId) {
1787        let session_id = SessionId::new();
1788        // Wrap in MountFs exactly as production does, so the shell resolves
1789        // `/workspace` and the root mount through the same path it uses live.
1790        let store = crate::mount_fs::MountFs::wrap(Arc::new(MockFileStore::new()));
1791        let mut context = ToolContext::new(session_id);
1792        context.file_store = Some(store);
1793        (context, session_id)
1794    }
1795
1796    #[tokio::test]
1797    async fn test_bash_echo_command() {
1798        let (context, _) = create_context_with_mock_store();
1799        let tool = BashTool::default();
1800
1801        let result = tool
1802            .execute_with_context(json!({"commands": "echo hello world"}), &context)
1803            .await;
1804
1805        if let ToolExecutionResult::Success(output) = result {
1806            assert_eq!(output["stdout"], "hello world\n");
1807            assert_eq!(output["exit_code"], 0);
1808            assert_eq!(output["success"], true);
1809        } else {
1810            panic!("Expected success result, got: {:?}", result);
1811        }
1812    }
1813
1814    #[tokio::test]
1815    async fn test_bash_pwd_default_workspace() {
1816        let (context, _) = create_context_with_mock_store();
1817        let tool = BashTool::default();
1818
1819        let result = tool
1820            .execute_with_context(json!({"commands": "pwd"}), &context)
1821            .await;
1822
1823        if let ToolExecutionResult::Success(output) = result {
1824            assert_eq!(output["stdout"], "/workspace\n");
1825            assert_eq!(output["exit_code"], 0);
1826        } else {
1827            panic!("Expected success result, got: {:?}", result);
1828        }
1829    }
1830
1831    #[tokio::test]
1832    async fn test_bash_env_variables() {
1833        let (context, _) = create_context_with_mock_store();
1834        let tool = BashTool::default();
1835
1836        // Test HOME
1837        let result = tool
1838            .execute_with_context(json!({"commands": "echo $HOME"}), &context)
1839            .await;
1840        if let ToolExecutionResult::Success(output) = result {
1841            assert_eq!(output["stdout"], "/home/agent\n");
1842        } else {
1843            panic!("Expected success");
1844        }
1845
1846        // Test WORKSPACE
1847        let result = tool
1848            .execute_with_context(json!({"commands": "echo $WORKSPACE"}), &context)
1849            .await;
1850        if let ToolExecutionResult::Success(output) = result {
1851            assert_eq!(output["stdout"], "/workspace\n");
1852        } else {
1853            panic!("Expected success");
1854        }
1855
1856        // Test USER (set by bashkit from username)
1857        let result = tool
1858            .execute_with_context(json!({"commands": "echo $USER"}), &context)
1859            .await;
1860        if let ToolExecutionResult::Success(output) = result {
1861            assert_eq!(output["stdout"], "everruns\n");
1862        } else {
1863            panic!("Expected success");
1864        }
1865    }
1866
1867    #[tokio::test]
1868    async fn test_bash_lang_env_default() {
1869        let (context, _) = create_context_with_mock_store();
1870        let tool = BashTool::default();
1871
1872        // Default locale (None) should set LANG to en-US
1873        let result = tool
1874            .execute_with_context(json!({"commands": "echo $LANG"}), &context)
1875            .await;
1876        if let ToolExecutionResult::Success(output) = result {
1877            assert_eq!(output["stdout"], "en-US\n");
1878        } else {
1879            panic!("Expected success");
1880        }
1881    }
1882
1883    #[tokio::test]
1884    async fn test_bash_lang_env_from_context_locale() {
1885        let (mut context, _) = create_context_with_mock_store();
1886        context.locale = Some("uk-UA".to_string());
1887        let tool = BashTool::default();
1888
1889        let result = tool
1890            .execute_with_context(json!({"commands": "echo $LANG"}), &context)
1891            .await;
1892        if let ToolExecutionResult::Success(output) = result {
1893            assert_eq!(output["stdout"], "uk-UA\n");
1894        } else {
1895            panic!("Expected success");
1896        }
1897    }
1898
1899    #[tokio::test]
1900    async fn test_bash_write_and_read_file() {
1901        let (context, _) = create_context_with_mock_store();
1902        let tool = BashTool::default();
1903
1904        // Write a file
1905        let result = tool
1906            .execute_with_context(
1907                json!({"commands": "echo 'test content' > /workspace/test.txt"}),
1908                &context,
1909            )
1910            .await;
1911        assert!(matches!(result, ToolExecutionResult::Success(_)));
1912
1913        // Read it back
1914        let result = tool
1915            .execute_with_context(json!({"commands": "cat /workspace/test.txt"}), &context)
1916            .await;
1917        if let ToolExecutionResult::Success(output) = result {
1918            assert_eq!(output["stdout"], "test content\n");
1919        } else {
1920            panic!("Expected success result");
1921        }
1922    }
1923
1924    #[tokio::test]
1925    async fn test_bash_recursive_walk_descends_into_subdirectories() {
1926        let (context, _guard) = create_context_with_mock_store();
1927        let tool = BashTool::default();
1928
1929        let result = tool
1930            .execute_with_context(
1931                json!({"commands": "mkdir -p /workspace/a/b && echo needle > /workspace/a/b/c.txt \
1932                     && ls /workspace/a && find /workspace/a -name '*.txt'"}),
1933                &context,
1934            )
1935            .await;
1936
1937        match result {
1938            ToolExecutionResult::Success(output) => {
1939                let stdout = output["stdout"].as_str().unwrap_or("");
1940                assert_eq!(output["exit_code"], 0, "stderr: {}", output["stderr"]);
1941                assert!(
1942                    stdout.contains("b\n"),
1943                    "expected subdir in ls, got {stdout:?}"
1944                );
1945                assert!(
1946                    stdout.contains("/workspace/a/b/c.txt"),
1947                    "expected find to descend, got {stdout:?}"
1948                );
1949            }
1950            other => panic!("Expected success result, got {other:?}"),
1951        }
1952    }
1953
1954    #[tokio::test]
1955    async fn test_adapter_reports_absent_paths_as_missing() {
1956        let session_id = SessionId::new();
1957        let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
1958        let adapter = SessionFileSystemAdapter::new(session_id, store);
1959
1960        // Stores answer `list_directory` for unknown paths with an empty listing, so
1961        // `exists`/`stat` must not read that as "an empty directory is here". When they
1962        // did, `touch` skipped creation (the file already "existed") and reported success.
1963        assert!(
1964            !adapter
1965                .exists(Path::new("/workspace/nope.txt"))
1966                .await
1967                .unwrap()
1968        );
1969        assert!(
1970            adapter
1971                .stat(Path::new("/workspace/nope.txt"))
1972                .await
1973                .is_err()
1974        );
1975
1976        adapter
1977            .mkdir(Path::new("/workspace/realdir"), false)
1978            .await
1979            .unwrap();
1980        assert!(
1981            adapter
1982                .exists(Path::new("/workspace/realdir"))
1983                .await
1984                .unwrap()
1985        );
1986    }
1987
1988    #[tokio::test]
1989    async fn test_bash_touch_creates_and_updates_files() {
1990        let (context, _guard) = create_context_with_mock_store();
1991        let tool = BashTool::default();
1992
1993        // `touch` writes the file and then sets its mtime; the session filesystem
1994        // does not track mtimes, so the second step must not fail the command.
1995        let result = tool
1996            .execute_with_context(
1997                json!({"commands": "touch /workspace/a.txt && touch /workspace/a.txt && ls /workspace"}),
1998                &context,
1999            )
2000            .await;
2001
2002        match result {
2003            ToolExecutionResult::Success(output) => {
2004                assert_eq!(output["exit_code"], 0, "stderr: {}", output["stderr"]);
2005                assert!(
2006                    output["stdout"].as_str().unwrap_or("").contains("a.txt"),
2007                    "expected a.txt in listing, got {:?}",
2008                    output["stdout"]
2009                );
2010            }
2011            other => panic!("Expected success result, got {other:?}"),
2012        }
2013    }
2014
2015    #[tokio::test]
2016    async fn test_bash_pipe_command() {
2017        let (context, _) = create_context_with_mock_store();
2018        let tool = BashTool::default();
2019
2020        let result = tool
2021            .execute_with_context(json!({"commands": "echo hello | cat"}), &context)
2022            .await;
2023
2024        if let ToolExecutionResult::Success(output) = result {
2025            assert_eq!(output["stdout"], "hello\n");
2026            assert_eq!(output["exit_code"], 0);
2027        } else {
2028            panic!("Expected success result");
2029        }
2030    }
2031
2032    #[tokio::test]
2033    async fn test_bash_arithmetic() {
2034        let (context, _) = create_context_with_mock_store();
2035        let tool = BashTool::default();
2036
2037        let result = tool
2038            .execute_with_context(json!({"commands": "echo $((2 + 3 * 4))"}), &context)
2039            .await;
2040
2041        if let ToolExecutionResult::Success(output) = result {
2042            assert_eq!(output["stdout"], "14\n");
2043        } else {
2044            panic!("Expected success result");
2045        }
2046    }
2047
2048    #[tokio::test]
2049    async fn test_bash_command_substitution() {
2050        let (context, _) = create_context_with_mock_store();
2051        let tool = BashTool::default();
2052
2053        let result = tool
2054            .execute_with_context(json!({"commands": "echo $(echo nested)"}), &context)
2055            .await;
2056
2057        if let ToolExecutionResult::Success(output) = result {
2058            assert_eq!(output["stdout"], "nested\n");
2059        } else {
2060            panic!("Expected success result");
2061        }
2062    }
2063
2064    // ========================================================================
2065    // Paths outside /workspace resolve into the backend (EVE-660)
2066    // ========================================================================
2067    //
2068    // `/workspace` is just the shell's cwd; the root mount makes any path
2069    // addressable. A path like `/tmp/x` resolves into the backend rather than
2070    // being rejected. For a host-backed store this stays contained under the
2071    // store's root (with symlink rejection) — it is never the host `/tmp`.
2072
2073    #[tokio::test]
2074    async fn test_bash_write_from_root_succeeds() {
2075        let (context, _) = create_context_with_mock_store();
2076        let tool = BashTool::default();
2077
2078        // Writing and reading back a path outside /workspace round-trips.
2079        let result = tool
2080            .execute_with_context(
2081                json!({"commands": "echo hi > /tmp/note.txt && cat /tmp/note.txt"}),
2082                &context,
2083            )
2084            .await;
2085
2086        match result {
2087            ToolExecutionResult::Success(output) => {
2088                assert_eq!(output["exit_code"], 0, "got: {:?}", output);
2089                assert_eq!(output["stdout"], "hi\n");
2090            }
2091            other => panic!("expected success, got: {:?}", other),
2092        }
2093    }
2094
2095    #[tokio::test]
2096    async fn test_bash_read_missing_file_fails_as_not_found() {
2097        let (context, _) = create_context_with_mock_store();
2098        let tool = BashTool::default();
2099
2100        // A nonexistent path resolves but has no file — `cat` fails with a
2101        // non-zero exit, not a containment error.
2102        let result = tool
2103            .execute_with_context(json!({"commands": "cat /etc/passwd"}), &context)
2104            .await;
2105
2106        match result {
2107            ToolExecutionResult::Success(output) => {
2108                assert_ne!(
2109                    output["exit_code"], 0,
2110                    "missing file should fail: {:?}",
2111                    output
2112                );
2113            }
2114            ToolExecutionResult::ToolError(msg) => {
2115                assert!(
2116                    msg.contains("not found") || msg.contains("No such"),
2117                    "got: {}",
2118                    msg
2119                );
2120            }
2121            _ => panic!("Unexpected result type"),
2122        }
2123    }
2124
2125    #[tokio::test]
2126    async fn test_bash_mkdir_from_root_succeeds() {
2127        let (context, _) = create_context_with_mock_store();
2128        let tool = BashTool::default();
2129
2130        let result = tool
2131            .execute_with_context(json!({"commands": "mkdir /tmp/sub && echo done"}), &context)
2132            .await;
2133
2134        match result {
2135            ToolExecutionResult::Success(output) => {
2136                assert_eq!(output["exit_code"], 0, "got: {:?}", output);
2137                assert_eq!(output["stdout"], "done\n");
2138            }
2139            other => panic!("expected success, got: {:?}", other),
2140        }
2141    }
2142
2143    // ========================================================================
2144    // Working directory tests
2145    // ========================================================================
2146
2147    #[tokio::test]
2148    async fn test_bash_custom_working_dir() {
2149        let (context, _) = create_context_with_mock_store();
2150        let tool = BashTool::default();
2151
2152        // First create the directory
2153        let result = tool
2154            .execute_with_context(json!({"commands": "mkdir -p /workspace/mydir"}), &context)
2155            .await;
2156        assert!(matches!(result, ToolExecutionResult::Success(_)));
2157
2158        // Run pwd with custom working directory
2159        let result = tool
2160            .execute_with_context(
2161                json!({
2162                    "commands": "pwd",
2163                    "working_dir": "/workspace/mydir"
2164                }),
2165                &context,
2166            )
2167            .await;
2168
2169        if let ToolExecutionResult::Success(output) = result {
2170            assert_eq!(output["stdout"], "/workspace/mydir\n");
2171        } else {
2172            panic!("Expected success result");
2173        }
2174    }
2175
2176    // ========================================================================
2177    // Exit code tests
2178    // ========================================================================
2179
2180    #[tokio::test]
2181    async fn test_bash_false_command_exit_code() {
2182        let (context, _) = create_context_with_mock_store();
2183        let tool = BashTool::default();
2184
2185        let result = tool
2186            .execute_with_context(json!({"commands": "false"}), &context)
2187            .await;
2188
2189        if let ToolExecutionResult::Success(output) = result {
2190            assert_eq!(output["exit_code"], 1);
2191            assert_eq!(output["success"], false);
2192        } else {
2193            panic!("Expected success result with non-zero exit code");
2194        }
2195    }
2196
2197    #[tokio::test]
2198    async fn test_bash_true_command_exit_code() {
2199        let (context, _) = create_context_with_mock_store();
2200        let tool = BashTool::default();
2201
2202        let result = tool
2203            .execute_with_context(json!({"commands": "true"}), &context)
2204            .await;
2205
2206        if let ToolExecutionResult::Success(output) = result {
2207            assert_eq!(output["exit_code"], 0);
2208            assert_eq!(output["success"], true);
2209        } else {
2210            panic!("Expected success result");
2211        }
2212    }
2213
2214    // ========================================================================
2215    // FileSystem adapter direct tests
2216    // ========================================================================
2217
2218    #[tokio::test]
2219    async fn test_adapter_read_write_workspace_file() {
2220        let session_id = SessionId::new();
2221        let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2222        let adapter = SessionFileSystemAdapter::new(session_id, store);
2223
2224        // Write a file
2225        adapter
2226            .write_file(Path::new("/workspace/test.txt"), b"hello")
2227            .await
2228            .unwrap();
2229
2230        // Read it back
2231        let content = adapter
2232            .read_file(Path::new("/workspace/test.txt"))
2233            .await
2234            .unwrap();
2235        assert_eq!(content, b"hello");
2236    }
2237
2238    #[tokio::test]
2239    async fn test_adapter_read_missing_file_is_not_found() {
2240        let adapter = mount_adapter();
2241
2242        // A path outside /workspace resolves but has no file: NotFound, not a
2243        // containment rejection.
2244        let result = adapter.read_file(Path::new("/tmp/file.txt")).await;
2245        let err = result.unwrap_err();
2246        assert!(
2247            matches!(&err, bashkit::Error::Io(io) if io.kind() == std::io::ErrorKind::NotFound),
2248            "expected NotFound, got: {err}"
2249        );
2250    }
2251
2252    #[tokio::test]
2253    async fn test_adapter_write_from_root_succeeds() {
2254        let adapter = mount_adapter();
2255
2256        // Writing outside /workspace now resolves into the backend and reads back.
2257        adapter
2258            .write_file(Path::new("/tmp/file.txt"), b"data")
2259            .await
2260            .unwrap();
2261        assert_eq!(
2262            adapter.read_file(Path::new("/tmp/file.txt")).await.unwrap(),
2263            b"data"
2264        );
2265    }
2266
2267    #[tokio::test]
2268    async fn test_adapter_stat_workspace_root() {
2269        let session_id = SessionId::new();
2270        let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2271        let adapter = SessionFileSystemAdapter::new(session_id, store);
2272
2273        let stat = adapter.stat(Path::new("/workspace")).await.unwrap();
2274        assert!(stat.file_type.is_dir());
2275    }
2276
2277    #[tokio::test]
2278    async fn test_adapter_stat_directory_returns_dir_type() {
2279        let session_id = SessionId::new();
2280        let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2281        let adapter = SessionFileSystemAdapter::new(session_id, store);
2282
2283        // Create a directory
2284        adapter
2285            .mkdir(Path::new("/workspace/mydir"), false)
2286            .await
2287            .unwrap();
2288
2289        // stat should report it as a directory, not a file
2290        let stat = adapter.stat(Path::new("/workspace/mydir")).await.unwrap();
2291        assert!(
2292            stat.file_type.is_dir(),
2293            "Expected directory but got file type for /workspace/mydir"
2294        );
2295    }
2296
2297    #[tokio::test]
2298    async fn test_adapter_stat_file_returns_file_type() {
2299        let session_id = SessionId::new();
2300        let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2301        let adapter = SessionFileSystemAdapter::new(session_id, store);
2302
2303        // Write a file
2304        adapter
2305            .write_file(Path::new("/workspace/test.txt"), b"hello")
2306            .await
2307            .unwrap();
2308
2309        // stat should report it as a file
2310        let stat = adapter
2311            .stat(Path::new("/workspace/test.txt"))
2312            .await
2313            .unwrap();
2314        assert!(
2315            stat.file_type.is_file(),
2316            "Expected file but got directory type for /workspace/test.txt"
2317        );
2318        assert_eq!(stat.size, 5);
2319    }
2320
2321    #[tokio::test]
2322    async fn test_adapter_exists_workspace() {
2323        let session_id = SessionId::new();
2324        let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2325        let adapter = SessionFileSystemAdapter::new(session_id, store);
2326
2327        // /workspace always exists
2328        assert!(adapter.exists(Path::new("/workspace")).await.unwrap());
2329
2330        // /tmp does not exist (outside workspace)
2331        assert!(!adapter.exists(Path::new("/tmp")).await.unwrap());
2332    }
2333
2334    #[tokio::test]
2335    async fn test_adapter_mkdir_and_list() {
2336        let session_id = SessionId::new();
2337        let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2338        let adapter = SessionFileSystemAdapter::new(session_id, store.clone());
2339
2340        // Create a directory
2341        adapter
2342            .mkdir(Path::new("/workspace/mydir"), false)
2343            .await
2344            .unwrap();
2345
2346        // Write a file in it
2347        adapter
2348            .write_file(Path::new("/workspace/mydir/file.txt"), b"content")
2349            .await
2350            .unwrap();
2351
2352        // List should include the file
2353        let entries = adapter
2354            .read_dir(Path::new("/workspace/mydir"))
2355            .await
2356            .unwrap();
2357        assert_eq!(entries.len(), 1);
2358        assert_eq!(entries[0].name, "file.txt");
2359    }
2360
2361    #[tokio::test]
2362    async fn test_adapter_rename_file() {
2363        let session_id = SessionId::new();
2364        let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2365        let adapter = SessionFileSystemAdapter::new(session_id, store);
2366
2367        // Write original file
2368        adapter
2369            .write_file(Path::new("/workspace/old.txt"), b"data")
2370            .await
2371            .unwrap();
2372
2373        // Rename it
2374        adapter
2375            .rename(
2376                Path::new("/workspace/old.txt"),
2377                Path::new("/workspace/new.txt"),
2378            )
2379            .await
2380            .unwrap();
2381
2382        // Old file should not exist
2383        let old_result = adapter.read_file(Path::new("/workspace/old.txt")).await;
2384        assert!(old_result.is_err());
2385
2386        // New file should have the content
2387        let new_content = adapter
2388            .read_file(Path::new("/workspace/new.txt"))
2389            .await
2390            .unwrap();
2391        assert_eq!(new_content, b"data");
2392    }
2393
2394    #[tokio::test]
2395    async fn test_adapter_copy_file() {
2396        let session_id = SessionId::new();
2397        let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2398        let adapter = SessionFileSystemAdapter::new(session_id, store);
2399
2400        // Write original file
2401        adapter
2402            .write_file(Path::new("/workspace/source.txt"), b"copy me")
2403            .await
2404            .unwrap();
2405
2406        // Copy it
2407        adapter
2408            .copy(
2409                Path::new("/workspace/source.txt"),
2410                Path::new("/workspace/dest.txt"),
2411            )
2412            .await
2413            .unwrap();
2414
2415        // Both files should exist with same content
2416        let source = adapter
2417            .read_file(Path::new("/workspace/source.txt"))
2418            .await
2419            .unwrap();
2420        let dest = adapter
2421            .read_file(Path::new("/workspace/dest.txt"))
2422            .await
2423            .unwrap();
2424        assert_eq!(source, dest);
2425        assert_eq!(source, b"copy me");
2426    }
2427
2428    #[tokio::test]
2429    async fn test_adapter_append_file() {
2430        let session_id = SessionId::new();
2431        let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2432        let adapter = SessionFileSystemAdapter::new(session_id, store);
2433
2434        // Write initial content
2435        adapter
2436            .write_file(Path::new("/workspace/log.txt"), b"line1\n")
2437            .await
2438            .unwrap();
2439
2440        // Append more
2441        adapter
2442            .append_file(Path::new("/workspace/log.txt"), b"line2\n")
2443            .await
2444            .unwrap();
2445
2446        // Read combined content
2447        let content = adapter
2448            .read_file(Path::new("/workspace/log.txt"))
2449            .await
2450            .unwrap();
2451        assert_eq!(content, b"line1\nline2\n");
2452    }
2453
2454    #[tokio::test]
2455    async fn test_adapter_symlink_not_supported() {
2456        let session_id = SessionId::new();
2457        let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2458        let adapter = SessionFileSystemAdapter::new(session_id, store);
2459
2460        let result = adapter
2461            .symlink(Path::new("/workspace/target"), Path::new("/workspace/link"))
2462            .await;
2463        assert!(result.is_err());
2464        assert!(result.unwrap_err().to_string().contains("not supported"));
2465    }
2466
2467    #[tokio::test]
2468    async fn test_adapter_chmod_is_noop() {
2469        let session_id = SessionId::new();
2470        let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2471        let adapter = SessionFileSystemAdapter::new(session_id, store);
2472
2473        // chmod should succeed as a no-op
2474        let result = adapter.chmod(Path::new("/workspace/file.txt"), 0o755).await;
2475        assert!(result.is_ok());
2476    }
2477
2478    // ========================================================================
2479    // Security limit tests (bashkit 0.1.0)
2480    // ========================================================================
2481
2482    #[tokio::test]
2483    async fn test_bash_max_input_bytes_limit() {
2484        let (context, _) = create_context_with_mock_store();
2485        let tool = BashTool::default();
2486
2487        // Create a script larger than 1MB limit
2488        let large_script = "echo ".to_string() + &"x".repeat(1_100_000);
2489
2490        let result = tool
2491            .execute_with_context(json!({"commands": large_script}), &context)
2492            .await;
2493
2494        // Should fail due to input size limit
2495        match result {
2496            ToolExecutionResult::ToolError(msg) => {
2497                assert!(
2498                    msg.contains("too large") || msg.contains("input") || msg.contains("limit"),
2499                    "Expected input size error, got: {}",
2500                    msg
2501                );
2502            }
2503            ToolExecutionResult::Success(output) => {
2504                panic!(
2505                    "Expected error for oversized script, got success: {:?}",
2506                    output
2507                );
2508            }
2509            _ => panic!("Unexpected result type"),
2510        }
2511    }
2512
2513    #[tokio::test]
2514    async fn test_bash_loop_within_limit() {
2515        let (context, _) = create_context_with_mock_store();
2516        let tool = BashTool::default();
2517
2518        // Execute a loop within the 10000 iteration limit
2519        let command = "i=0; while [ $i -lt 100 ]; do i=$((i + 1)); done; echo $i";
2520
2521        let result = tool
2522            .execute_with_context(json!({"commands": command}), &context)
2523            .await;
2524
2525        // Should succeed within limits
2526        if let ToolExecutionResult::Success(output) = result {
2527            assert_eq!(output["exit_code"], 0);
2528            assert_eq!(output["stdout"].as_str().unwrap_or("").trim(), "100");
2529        } else {
2530            panic!("Expected success for loop within limit: {:?}", result);
2531        }
2532    }
2533
2534    #[tokio::test]
2535    async fn test_bash_function_calls() {
2536        let (context, _) = create_context_with_mock_store();
2537        let tool = BashTool::default();
2538
2539        // Test basic function definition and calls (non-recursive to avoid stack issues)
2540        let command = r#"
2541            greet() {
2542                echo "Hello, $1!"
2543            }
2544            greet world
2545        "#;
2546
2547        let result = tool
2548            .execute_with_context(json!({"commands": command}), &context)
2549            .await;
2550
2551        // Should succeed
2552        if let ToolExecutionResult::Success(output) = result {
2553            assert_eq!(output["exit_code"], 0);
2554            assert!(
2555                output["stdout"]
2556                    .as_str()
2557                    .unwrap_or("")
2558                    .contains("Hello, world!")
2559            );
2560        } else {
2561            panic!("Expected success for function call: {:?}", result);
2562        }
2563    }
2564
2565    #[tokio::test]
2566    async fn test_bash_arithmetic_expressions() {
2567        let (context, _) = create_context_with_mock_store();
2568        let tool = BashTool::default();
2569
2570        // Test various arithmetic expressions (shallow nesting to avoid stack issues)
2571        let command = "echo $((1 + 2 * 3))";
2572
2573        let result = tool
2574            .execute_with_context(json!({"commands": command}), &context)
2575            .await;
2576
2577        // Should succeed
2578        if let ToolExecutionResult::Success(output) = result {
2579            assert_eq!(output["exit_code"], 0);
2580            assert_eq!(output["stdout"].as_str().unwrap_or("").trim(), "7");
2581        } else {
2582            panic!("Expected success for arithmetic expression: {:?}", result);
2583        }
2584    }
2585
2586    #[tokio::test]
2587    async fn test_bash_commands_within_limit() {
2588        let (context, _) = create_context_with_mock_store();
2589        let tool = BashTool::default();
2590
2591        // Execute multiple commands within the 1000 command limit
2592        let command = "for i in $(seq 1 100); do true; done; echo done";
2593
2594        let result = tool
2595            .execute_with_context(json!({"commands": command}), &context)
2596            .await;
2597
2598        // Should succeed within limits
2599        if let ToolExecutionResult::Success(output) = result {
2600            assert_eq!(output["exit_code"], 0);
2601            assert!(output["stdout"].as_str().unwrap_or("").contains("done"));
2602        } else {
2603            panic!("Expected success for commands within limit: {:?}", result);
2604        }
2605    }
2606
2607    // ========================================================================
2608    // Script file execution tests
2609    // ========================================================================
2610
2611    #[tokio::test]
2612    async fn test_bash_execute_script_by_absolute_path() {
2613        let (context, _) = create_context_with_mock_store();
2614        let tool = BashTool::default();
2615
2616        // Create a script file
2617        let result = tool
2618            .execute_with_context(
2619                json!({"commands": "cat > /workspace/test.sh << 'EOF'\n#!/bin/bash\necho hello\nEOF"}),
2620                &context,
2621            )
2622            .await;
2623        assert!(
2624            matches!(result, ToolExecutionResult::Success(_)),
2625            "Failed to create script: {:?}",
2626            result
2627        );
2628
2629        // Execute by absolute path
2630        let result = tool
2631            .execute_with_context(json!({"commands": "/workspace/test.sh"}), &context)
2632            .await;
2633
2634        if let ToolExecutionResult::Success(output) = result {
2635            assert_eq!(output["exit_code"], 0);
2636            assert_eq!(output["stdout"], "hello\n");
2637        } else {
2638            panic!("Expected success, got: {:?}", result);
2639        }
2640    }
2641
2642    #[tokio::test]
2643    async fn test_bash_execute_script_with_args() {
2644        let (context, _) = create_context_with_mock_store();
2645        let tool = BashTool::default();
2646
2647        // Create a script that uses arguments
2648        let result = tool
2649            .execute_with_context(
2650                json!({"commands": "cat > /workspace/greet.sh << 'EOF'\n#!/bin/bash\necho \"Hello, $1! You are $2.\"\nEOF"}),
2651                &context,
2652            )
2653            .await;
2654        assert!(matches!(result, ToolExecutionResult::Success(_)));
2655
2656        // Execute with arguments
2657        let result = tool
2658            .execute_with_context(
2659                json!({"commands": "/workspace/greet.sh world awesome"}),
2660                &context,
2661            )
2662            .await;
2663
2664        if let ToolExecutionResult::Success(output) = result {
2665            assert_eq!(output["exit_code"], 0);
2666            assert_eq!(output["stdout"], "Hello, world! You are awesome.\n");
2667        } else {
2668            panic!("Expected success, got: {:?}", result);
2669        }
2670    }
2671
2672    #[tokio::test]
2673    async fn test_bash_execute_script_without_shebang() {
2674        let (context, _) = create_context_with_mock_store();
2675        let tool = BashTool::default();
2676
2677        // Create a script without shebang
2678        let result = tool
2679            .execute_with_context(
2680                json!({"commands": "cat > /workspace/simple.sh << 'EOF'\necho simple\nEOF"}),
2681                &context,
2682            )
2683            .await;
2684        assert!(matches!(result, ToolExecutionResult::Success(_)));
2685
2686        // Execute - should still work
2687        let result = tool
2688            .execute_with_context(json!({"commands": "/workspace/simple.sh"}), &context)
2689            .await;
2690
2691        if let ToolExecutionResult::Success(output) = result {
2692            assert_eq!(output["exit_code"], 0);
2693            assert_eq!(output["stdout"], "simple\n");
2694        } else {
2695            panic!("Expected success, got: {:?}", result);
2696        }
2697    }
2698
2699    #[tokio::test]
2700    async fn test_bash_execute_nonexistent_script() {
2701        let (context, _) = create_context_with_mock_store();
2702        let tool = BashTool::default();
2703
2704        // Try to execute a script that doesn't exist
2705        let result = tool
2706            .execute_with_context(json!({"commands": "/workspace/nonexistent.sh"}), &context)
2707            .await;
2708
2709        if let ToolExecutionResult::Success(output) = result {
2710            assert_ne!(output["exit_code"], 0, "Should fail with non-zero exit");
2711            let stderr = output["stderr"].as_str().unwrap_or("");
2712            assert!(
2713                stderr.contains("No such file") || stderr.contains("not found"),
2714                "Expected file not found error, got stderr: {}",
2715                stderr
2716            );
2717        } else {
2718            panic!(
2719                "Expected success result with error output, got: {:?}",
2720                result
2721            );
2722        }
2723    }
2724
2725    #[tokio::test]
2726    async fn test_bash_execute_script_in_nested_dir() {
2727        let (context, _) = create_context_with_mock_store();
2728        let tool = BashTool::default();
2729
2730        // Create nested directory structure and script
2731        let setup = tool
2732            .execute_with_context(
2733                json!({"commands": "mkdir -p /workspace/.agents/skills/nav/scripts && cat > /workspace/.agents/skills/nav/scripts/nav.sh << 'EOF'\n#!/bin/bash\necho \"navigating $1\"\nEOF"}),
2734                &context,
2735            )
2736            .await;
2737        assert!(matches!(setup, ToolExecutionResult::Success(_)));
2738
2739        // Execute by absolute path (the exact scenario from the bug report)
2740        let result = tool
2741            .execute_with_context(
2742                json!({"commands": "/workspace/.agents/skills/nav/scripts/nav.sh dist"}),
2743                &context,
2744            )
2745            .await;
2746
2747        if let ToolExecutionResult::Success(output) = result {
2748            assert_eq!(output["exit_code"], 0);
2749            assert_eq!(output["stdout"], "navigating dist\n");
2750        } else {
2751            panic!("Expected success, got: {:?}", result);
2752        }
2753    }
2754
2755    #[tokio::test]
2756    async fn test_bash_file_mode_is_executable() {
2757        let (context, _) = create_context_with_mock_store();
2758        let tool = BashTool::default();
2759
2760        // Write a file and check that test -x reports it as executable
2761        let result = tool
2762            .execute_with_context(
2763                json!({"commands": "echo 'echo hi' > /workspace/check.sh && test -x /workspace/check.sh && echo 'executable' || echo 'not executable'"}),
2764                &context,
2765            )
2766            .await;
2767
2768        if let ToolExecutionResult::Success(output) = result {
2769            assert_eq!(output["exit_code"], 0);
2770            assert!(
2771                output["stdout"]
2772                    .as_str()
2773                    .unwrap_or("")
2774                    .contains("executable"),
2775                "File should be reported as executable, got: {}",
2776                output["stdout"]
2777            );
2778        } else {
2779            panic!("Expected success, got: {:?}", result);
2780        }
2781    }
2782
2783    #[tokio::test]
2784    async fn test_bash_execute_script_with_exit_code() {
2785        let (context, _) = create_context_with_mock_store();
2786        let tool = BashTool::default();
2787
2788        // Create a script that exits with a specific code
2789        let result = tool
2790            .execute_with_context(
2791                json!({"commands": "cat > /workspace/fail.sh << 'EOF'\n#!/bin/bash\necho failing\nexit 42\nEOF"}),
2792                &context,
2793            )
2794            .await;
2795        assert!(matches!(result, ToolExecutionResult::Success(_)));
2796
2797        // Execute and check exit code propagation
2798        let result = tool
2799            .execute_with_context(
2800                json!({"commands": "/workspace/fail.sh; echo \"code: $?\""}),
2801                &context,
2802            )
2803            .await;
2804
2805        if let ToolExecutionResult::Success(output) = result {
2806            let stdout = output["stdout"].as_str().unwrap_or("");
2807            assert!(stdout.contains("failing"), "Script should have run");
2808            assert!(
2809                stdout.contains("code: 42"),
2810                "Exit code should propagate, got: {}",
2811                stdout
2812            );
2813        } else {
2814            panic!("Expected success, got: {:?}", result);
2815        }
2816    }
2817
2818    // ========================================================================
2819    // Overwrite / existing-file tests
2820    // ========================================================================
2821
2822    #[tokio::test]
2823    async fn test_bash_overwrite_existing_file() {
2824        let (context, _) = create_context_with_mock_store();
2825        let tool = BashTool::default();
2826
2827        // Write a file
2828        let result = tool
2829            .execute_with_context(
2830                json!({"commands": "echo 'first' > /workspace/overwrite.txt"}),
2831                &context,
2832            )
2833            .await;
2834        assert!(matches!(result, ToolExecutionResult::Success(_)));
2835
2836        // Overwrite with new content
2837        let result = tool
2838            .execute_with_context(
2839                json!({"commands": "echo 'second' > /workspace/overwrite.txt"}),
2840                &context,
2841            )
2842            .await;
2843        if let ToolExecutionResult::Success(output) = &result {
2844            assert_eq!(output["exit_code"], 0, "Overwrite should succeed");
2845        } else {
2846            panic!("Expected success on overwrite, got: {:?}", result);
2847        }
2848
2849        // Read back — should have new content
2850        let result = tool
2851            .execute_with_context(
2852                json!({"commands": "cat /workspace/overwrite.txt"}),
2853                &context,
2854            )
2855            .await;
2856        if let ToolExecutionResult::Success(output) = result {
2857            assert_eq!(output["stdout"], "second\n");
2858        } else {
2859            panic!("Expected success on read, got: {:?}", result);
2860        }
2861    }
2862
2863    #[tokio::test]
2864    async fn test_bash_append_to_existing_file() {
2865        let (context, _) = create_context_with_mock_store();
2866        let tool = BashTool::default();
2867
2868        // Create file
2869        let result = tool
2870            .execute_with_context(
2871                json!({"commands": "echo 'line1' > /workspace/append.txt"}),
2872                &context,
2873            )
2874            .await;
2875        assert!(matches!(result, ToolExecutionResult::Success(_)));
2876
2877        // Append
2878        let result = tool
2879            .execute_with_context(
2880                json!({"commands": "echo 'line2' >> /workspace/append.txt"}),
2881                &context,
2882            )
2883            .await;
2884        if let ToolExecutionResult::Success(output) = &result {
2885            assert_eq!(output["exit_code"], 0, "Append should succeed");
2886        } else {
2887            panic!("Expected success on append, got: {:?}", result);
2888        }
2889
2890        // Verify combined content
2891        let result = tool
2892            .execute_with_context(json!({"commands": "cat /workspace/append.txt"}), &context)
2893            .await;
2894        if let ToolExecutionResult::Success(output) = result {
2895            assert_eq!(output["stdout"], "line1\nline2\n");
2896        } else {
2897            panic!("Expected success on read");
2898        }
2899    }
2900
2901    #[tokio::test]
2902    async fn test_adapter_overwrite_existing_file() {
2903        let session_id = SessionId::new();
2904        let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2905        let adapter = SessionFileSystemAdapter::new(session_id, store);
2906
2907        // Write initial
2908        adapter
2909            .write_file(Path::new("/workspace/ow.txt"), b"original")
2910            .await
2911            .unwrap();
2912
2913        // Overwrite
2914        adapter
2915            .write_file(Path::new("/workspace/ow.txt"), b"updated")
2916            .await
2917            .unwrap();
2918
2919        // Verify new content
2920        let content = adapter
2921            .read_file(Path::new("/workspace/ow.txt"))
2922            .await
2923            .unwrap();
2924        assert_eq!(content, b"updated");
2925    }
2926
2927    #[tokio::test]
2928    async fn test_adapter_append_to_existing_file() {
2929        let session_id = SessionId::new();
2930        let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
2931        let adapter = SessionFileSystemAdapter::new(session_id, store);
2932
2933        // Write initial
2934        adapter
2935            .write_file(Path::new("/workspace/ap.txt"), b"AAA")
2936            .await
2937            .unwrap();
2938
2939        // Append
2940        adapter
2941            .append_file(Path::new("/workspace/ap.txt"), b"BBB")
2942            .await
2943            .unwrap();
2944
2945        // Verify combined
2946        let content = adapter
2947            .read_file(Path::new("/workspace/ap.txt"))
2948            .await
2949            .unwrap();
2950        assert_eq!(content, b"AAABBB");
2951    }
2952
2953    #[tokio::test]
2954    async fn test_bash_redirect_creates_parent_dirs() {
2955        let (context, _) = create_context_with_mock_store();
2956        let tool = BashTool::default();
2957
2958        // Write to a nested path — parent dirs should be auto-created
2959        let result = tool
2960            .execute_with_context(
2961                json!({"commands": "echo 'deep' > /workspace/a/b/c/deep.txt"}),
2962                &context,
2963            )
2964            .await;
2965        if let ToolExecutionResult::Success(output) = &result {
2966            assert_eq!(output["exit_code"], 0, "Nested write should succeed");
2967        } else {
2968            panic!("Expected success, got: {:?}", result);
2969        }
2970
2971        // Read back
2972        let result = tool
2973            .execute_with_context(
2974                json!({"commands": "cat /workspace/a/b/c/deep.txt"}),
2975                &context,
2976            )
2977            .await;
2978        if let ToolExecutionResult::Success(output) = result {
2979            assert_eq!(output["stdout"], "deep\n");
2980        } else {
2981            panic!("Expected success on read");
2982        }
2983    }
2984
2985    // ========================================================================
2986    // bashkit API smoke tests
2987    // ========================================================================
2988
2989    #[test]
2990    fn test_bashkit_tool_description_is_nonempty() {
2991        let desc = BASHKIT_TOOL.description();
2992        assert!(
2993            !desc.is_empty(),
2994            "bashkit tool description should not be empty"
2995        );
2996        // Should mention bash or command execution
2997        assert!(
2998            desc.to_lowercase().contains("bash") || desc.to_lowercase().contains("command"),
2999            "description should mention bash or command, got: {}",
3000            desc
3001        );
3002    }
3003
3004    #[test]
3005    fn test_bashkit_tool_system_prompt_is_nonempty() {
3006        let prompt = BASHKIT_TOOL.system_prompt();
3007        assert!(
3008            !prompt.is_empty(),
3009            "bashkit system prompt should not be empty"
3010        );
3011        assert!(
3012            prompt.contains("everruns"),
3013            "system prompt should contain configured identity 'everruns', got: {}",
3014            prompt
3015        );
3016    }
3017
3018    #[test]
3019    fn test_bashkit_static_description_matches_tool() {
3020        // Verify the LazyLock statics produce the same values as direct calls
3021        let direct_desc = BASHKIT_TOOL.description();
3022        let static_desc: &str = &TOOL_DESCRIPTION;
3023        assert_eq!(static_desc, direct_desc);
3024
3025        let direct_prompt = BASHKIT_TOOL.system_prompt();
3026        let static_prompt: &str = &TOOL_SYSTEM_PROMPT;
3027        // TOOL_SYSTEM_PROMPT = bashkit prompt + EXEC_OUTPUT_HINT (EVE-223)
3028        assert!(
3029            static_prompt.starts_with(&direct_prompt),
3030            "system prompt should start with bashkit prompt"
3031        );
3032        assert!(
3033            static_prompt.contains("Output economy"),
3034            "system prompt should include output economy hint"
3035        );
3036    }
3037
3038    #[test]
3039    fn test_bashkit_tool_builder_configuration() {
3040        // Verify the static BASHKIT_TOOL was built with our custom settings
3041        // by checking that description/system_prompt are accessible (non-panicking)
3042        let _desc = BASHKIT_TOOL.description();
3043        let _prompt = BASHKIT_TOOL.system_prompt();
3044        // If we got here without panic, the builder configuration is valid
3045    }
3046
3047    #[test]
3048    fn test_bash_tool_display_name() {
3049        let tool = BashTool::default();
3050        assert_eq!(tool.display_name(), Some("Bash"));
3051    }
3052
3053    #[test]
3054    fn test_bash_tool_parameters_schema_structure() {
3055        let tool = BashTool::default();
3056        let schema = tool.parameters_schema();
3057
3058        // Verify required fields
3059        assert_eq!(schema["type"], "object");
3060        assert!(schema["properties"]["commands"].is_object());
3061
3062        // Verify optional fields
3063        assert!(schema["properties"]["working_dir"].is_object());
3064        assert!(schema["properties"]["timeout_ms"].is_object());
3065
3066        // Verify "commands" is required
3067        let required = schema["required"].as_array().unwrap();
3068        assert!(required.contains(&json!("commands")));
3069    }
3070
3071    #[test]
3072    fn test_execution_limits_configuration() {
3073        let limits = execution_limits();
3074        // Just verify it doesn't panic and returns a valid object
3075        // The limits are used by both BASHKIT_TOOL and per-execution Bash instances
3076        let _ = limits;
3077    }
3078
3079    // ========================================================================
3080    // SearchCapable / indexed search tests
3081    // ========================================================================
3082
3083    #[test]
3084    fn test_adapter_is_search_capable() {
3085        let session_id = SessionId::new();
3086        let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
3087        let adapter = SessionFileSystemAdapter::new(session_id, store);
3088
3089        let sc = adapter.as_search_capable();
3090        assert!(
3091            sc.is_some(),
3092            "SessionFileSystemAdapter should be SearchCapable"
3093        );
3094
3095        let provider = sc.unwrap().search_provider(Path::new("/workspace"));
3096        assert!(provider.is_some(), "Should return a SearchProvider");
3097
3098        let caps = provider.unwrap().capabilities();
3099        assert!(caps.content_search, "Should support content search");
3100        assert!(caps.regex, "Should support regex patterns");
3101    }
3102
3103    #[tokio::test]
3104    async fn test_search_provider_returns_grep_results() {
3105        let session_id = SessionId::new();
3106        let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
3107        let adapter = SessionFileSystemAdapter::new(session_id, store.clone());
3108
3109        // Write files via the adapter
3110        adapter
3111            .write_file(
3112                Path::new("/workspace/hello.txt"),
3113                b"hello world\ngoodbye world",
3114            )
3115            .await
3116            .unwrap();
3117        adapter
3118            .write_file(Path::new("/workspace/other.txt"), b"no match here")
3119            .await
3120            .unwrap();
3121
3122        let sc = adapter.as_search_capable().unwrap();
3123        let provider = sc.search_provider(Path::new("/workspace")).unwrap();
3124
3125        let results = provider
3126            .search(&SearchQuery {
3127                pattern: "hello".into(),
3128                is_regex: false,
3129                case_insensitive: false,
3130                root: PathBuf::from("/workspace"),
3131                glob_filter: None,
3132                max_results: None,
3133            })
3134            .unwrap();
3135
3136        assert_eq!(results.matches.len(), 1);
3137        assert_eq!(
3138            results.matches[0].path,
3139            PathBuf::from("/workspace/hello.txt")
3140        );
3141        assert_eq!(results.matches[0].line_number, 1);
3142        assert_eq!(results.matches[0].line_content, "hello world");
3143    }
3144
3145    #[tokio::test]
3146    async fn test_search_provider_truncates_at_max_results() {
3147        let session_id = SessionId::new();
3148        let store: Arc<dyn SessionFileSystem> = Arc::new(MockFileStore::new());
3149        let adapter = SessionFileSystemAdapter::new(session_id, store.clone());
3150
3151        adapter
3152            .write_file(
3153                Path::new("/workspace/many.txt"),
3154                b"match line 1\nmatch line 2\nmatch line 3\nmatch line 4",
3155            )
3156            .await
3157            .unwrap();
3158
3159        let sc = adapter.as_search_capable().unwrap();
3160        let provider = sc.search_provider(Path::new("/workspace")).unwrap();
3161
3162        let results = provider
3163            .search(&SearchQuery {
3164                pattern: "match".into(),
3165                is_regex: false,
3166                case_insensitive: false,
3167                root: PathBuf::from("/workspace"),
3168                glob_filter: None,
3169                max_results: Some(2),
3170            })
3171            .unwrap();
3172
3173        assert_eq!(results.matches.len(), 2);
3174        assert!(results.truncated);
3175    }
3176
3177    #[tokio::test]
3178    async fn test_bash_grep_uses_indexed_search() {
3179        let (context, _) = create_context_with_mock_store();
3180        let tool = BashTool::default();
3181
3182        // Create files
3183        tool.execute_with_context(
3184            json!({"commands": "mkdir -p /workspace/src && echo 'fn main() { println!(\"hello\"); }' > /workspace/src/main.rs && echo 'fn test() {}' > /workspace/src/test.rs"}),
3185            &context,
3186        )
3187        .await;
3188
3189        // Run grep -r which should use indexed search via SearchCapable
3190        let result = tool
3191            .execute_with_context(json!({"commands": "grep -r 'fn' /workspace/src"}), &context)
3192            .await;
3193
3194        if let ToolExecutionResult::Success(output) = result {
3195            assert_eq!(output["exit_code"], 0);
3196            let stdout = output["stdout"].as_str().unwrap_or("");
3197            assert!(
3198                stdout.contains("fn main") || stdout.contains("fn test"),
3199                "grep -r should find matches via indexed search, got: {}",
3200                stdout
3201            );
3202        } else {
3203            panic!("Expected success result, got: {:?}", result);
3204        }
3205    }
3206
3207    #[test]
3208    fn test_parameters_schema_delegates_to_bashkit() {
3209        let tool = BashTool::default();
3210        let schema = tool.parameters_schema();
3211        let bashkit_schema = BASHKIT_TOOL.input_schema();
3212
3213        // All bashkit properties must be present in our schema
3214        let bashkit_props = bashkit_schema["properties"].as_object().unwrap();
3215        let our_props = schema["properties"].as_object().unwrap();
3216        for key in bashkit_props.keys() {
3217            assert!(
3218                our_props.contains_key(key),
3219                "bashkit property '{key}' missing from parameters_schema"
3220            );
3221        }
3222
3223        // Required fields from bashkit must be preserved
3224        let bashkit_required = bashkit_schema["required"].as_array().unwrap();
3225        let our_required = schema["required"].as_array().unwrap();
3226        for req in bashkit_required {
3227            assert!(
3228                our_required.contains(req),
3229                "bashkit required field {req} missing from parameters_schema"
3230            );
3231        }
3232
3233        // Everruns extension: working_dir must be present
3234        assert!(
3235            our_props.contains_key("working_dir"),
3236            "working_dir must be in parameters_schema"
3237        );
3238    }
3239
3240    // ========================================================================
3241    // Observability hooks (EVE-299)
3242    // ========================================================================
3243
3244    #[test]
3245    fn truncate_for_log_returns_short_strings_unchanged() {
3246        assert_eq!(truncate_for_log("hello", 100), "hello");
3247        assert_eq!(truncate_for_log("", 100), "");
3248    }
3249
3250    #[test]
3251    fn truncate_for_log_stays_within_budget_and_marks() {
3252        let input = "a".repeat(500);
3253        let out = truncate_for_log(&input, 100);
3254        assert!(
3255            out.len() <= 100,
3256            "output exceeded budget: {} bytes",
3257            out.len()
3258        );
3259        assert!(out.ends_with("…[truncated]"));
3260        assert!(out.starts_with('a'));
3261    }
3262
3263    #[test]
3264    fn truncate_for_log_respects_utf8_boundaries() {
3265        // Each '🦀' is 4 bytes; marker is 14 bytes. Budget 20 leaves 6 for content,
3266        // which backs off to a 4-byte char boundary (one crab).
3267        let input = "🦀🦀🦀🦀🦀🦀🦀🦀🦀🦀";
3268        let out = truncate_for_log(input, 20);
3269        assert!(out.len() <= 20);
3270        assert!(out.starts_with('🦀'));
3271        assert!(out.ends_with("…[truncated]"));
3272    }
3273
3274    #[test]
3275    fn truncate_for_log_omits_marker_when_budget_is_too_small() {
3276        // Budget smaller than the marker -> marker is dropped, content is still
3277        // cut on a valid UTF-8 boundary and fits within max_bytes.
3278        let input = "abcdefghijklmnop";
3279        let out = truncate_for_log(input, 4);
3280        assert_eq!(out, "abcd");
3281        assert!(out.len() <= 4);
3282    }
3283
3284    #[tokio::test]
3285    async fn install_observability_hooks_fires_on_builtin_and_preserves_exit() {
3286        use bashkit::hooks::{HookAction, ToolResult};
3287        use std::sync::Arc;
3288        use std::sync::atomic::{AtomicU64, Ordering};
3289
3290        let tool_calls = Arc::new(AtomicU64::new(0));
3291        let counter = tool_calls.clone();
3292
3293        // Start from the shared hook installer, then stack a test observer.
3294        // This proves the installer leaves the builtin pipeline intact and
3295        // that additional hooks compose cleanly.
3296        let session_id: SessionId = "session_0197a4a4c0c0780180000000000000ff".parse().unwrap();
3297        let builder = install_observability_hooks(Bash::builder(), session_id).after_tool(
3298            Box::new(move |r: ToolResult| {
3299                counter.fetch_add(1, Ordering::Relaxed);
3300                HookAction::Continue(r)
3301            }),
3302        );
3303
3304        let mut bash = builder.build();
3305        let result = bash.exec("echo hook-smoke").await.unwrap();
3306
3307        assert_eq!(result.exit_code, 0);
3308        assert_eq!(result.stdout.trim(), "hook-smoke");
3309        assert!(
3310            tool_calls.load(Ordering::Relaxed) >= 1,
3311            "after_tool hook should fire at least once for `echo`"
3312        );
3313    }
3314
3315    // ========================================================================
3316    // Outbound HTTP via egress (enable_http config)
3317    // ========================================================================
3318
3319    mod http_tests {
3320        use super::*;
3321        use crate::capabilities::bashkit_shell::egress_transport::tests::MockEgress;
3322        use crate::egress::{EgressError, EgressRequestKind, EgressSigning};
3323        use crate::network_access::NetworkAccessList;
3324
3325        fn http_context(egress: Option<Arc<MockEgress>>) -> ToolContext {
3326            let (mut context, _) = create_context_with_mock_store();
3327            if let Some(egress) = egress {
3328                context.egress_service = Some(egress);
3329            }
3330            context
3331        }
3332
3333        #[tokio::test]
3334        async fn http_disabled_by_default_even_with_egress_available() {
3335            let egress = Arc::new(MockEgress::with_responses(vec![]));
3336            let context = http_context(Some(egress.clone()));
3337            let tool = BashTool::default();
3338
3339            let result = tool
3340                .execute_with_context(
3341                    json!({"commands": "curl -s http://93.184.216.34/ 2>&1; echo rc=$?"}),
3342                    &context,
3343                )
3344                .await;
3345
3346            let ToolExecutionResult::Success(output) = result else {
3347                panic!("expected success result");
3348            };
3349            let combined = format!("{}{}", output["stdout"], output["stderr"]);
3350            assert!(
3351                !combined.contains("rc=0"),
3352                "curl must fail without enable_http, got: {combined}"
3353            );
3354            assert!(
3355                egress.requests.lock().unwrap().is_empty(),
3356                "no request may reach egress when HTTP is disabled"
3357            );
3358        }
3359
3360        #[tokio::test]
3361        async fn http_enable_without_egress_service_stays_offline() {
3362            let context = http_context(None);
3363            let tool = BashTool { enable_http: true };
3364
3365            let result = tool
3366                .execute_with_context(
3367                    json!({"commands": "curl -s http://93.184.216.34/ 2>&1; echo rc=$?"}),
3368                    &context,
3369                )
3370                .await;
3371
3372            let ToolExecutionResult::Success(output) = result else {
3373                panic!("expected success result");
3374            };
3375            let combined = format!("{}{}", output["stdout"], output["stderr"]);
3376            assert!(
3377                !combined.contains("rc=0"),
3378                "curl must fail without an egress service, got: {combined}"
3379            );
3380        }
3381
3382        #[tokio::test]
3383        async fn curl_routes_through_egress_and_forwards_policy_metadata() {
3384            let egress = Arc::new(MockEgress::with_responses(vec![MockEgress::ok(
3385                200,
3386                &[("content-type", "text/plain")],
3387                "egress-ok",
3388            )]));
3389            let acl = NetworkAccessList::allow_only(["93.184.216.34"]);
3390            let mut context = http_context(Some(egress.clone()));
3391            context.network_access = Some(acl.clone());
3392            let tool = BashTool { enable_http: true };
3393
3394            let result = tool
3395                .execute_with_context(
3396                    json!({"commands": "curl -s http://93.184.216.34/data"}),
3397                    &context,
3398                )
3399                .await;
3400
3401            let ToolExecutionResult::Success(output) = result else {
3402                panic!("expected success result");
3403            };
3404            assert_eq!(output["exit_code"], 0, "stderr: {}", output["stderr"]);
3405            assert!(
3406                output["stdout"].as_str().unwrap().contains("egress-ok"),
3407                "stdout: {}",
3408                output["stdout"]
3409            );
3410
3411            assert_eq!(*egress.send_calls.lock().unwrap(), 0);
3412            assert_eq!(*egress.stream_calls.lock().unwrap(), 1);
3413            let requests = egress.requests.lock().unwrap();
3414            assert_eq!(requests.len(), 1);
3415            let request = &requests[0];
3416            assert_eq!(request.method, "GET");
3417            assert_eq!(request.url, "http://93.184.216.34/data");
3418            assert_eq!(request.kind, EgressRequestKind::Capability);
3419            assert_eq!(request.signing, EgressSigning::PlatformDefault);
3420            assert_eq!(request.network_access, Some(acl));
3421            assert!(request.timeout_ms.is_some(), "deadline must be forwarded");
3422            // IP-literal host: bashkit's SSRF precheck pins the validated
3423            // address so the egress boundary can enforce resolve-then-check.
3424            let (host, addrs) = request.pinned_addrs.as_ref().expect("pinned addrs");
3425            assert_eq!(host, "93.184.216.34");
3426            assert_eq!(addrs[0].ip().to_string(), "93.184.216.34");
3427            assert_eq!(addrs[0].port(), 80);
3428        }
3429
3430        #[tokio::test]
3431        async fn egress_denial_surfaces_as_curl_access_denied_exit_7() {
3432            let egress = Arc::new(MockEgress::with_responses(vec![Err(
3433                EgressError::NetworkAccessDenied {
3434                    url: "http://93.184.216.34/blocked".to_string(),
3435                },
3436            )]));
3437            let context = http_context(Some(egress));
3438            let tool = BashTool { enable_http: true };
3439
3440            let result = tool
3441                .execute_with_context(
3442                    json!({"commands": "curl -s http://93.184.216.34/blocked"}),
3443                    &context,
3444                )
3445                .await;
3446
3447            let ToolExecutionResult::Success(output) = result else {
3448                panic!("expected success result");
3449            };
3450            assert_eq!(output["exit_code"], 7, "stderr: {}", output["stderr"]);
3451            assert!(
3452                output["stderr"].as_str().unwrap().contains("access denied"),
3453                "stderr: {}",
3454                output["stderr"]
3455            );
3456            assert!(
3457                output["stderr"]
3458                    .as_str()
3459                    .unwrap()
3460                    .contains("blocked by network policy"),
3461                "stderr: {}",
3462                output["stderr"]
3463            );
3464        }
3465
3466        #[tokio::test]
3467        async fn oversized_egress_response_surfaces_as_curl_exit_63() {
3468            // 11 MB body exceeds bashkit's 10 MB default cap; the transport
3469            // maps it to TooLarge before the interpreter sees the body.
3470            let big = "x".repeat(11 * 1024 * 1024);
3471            let egress = Arc::new(MockEgress::with_responses(vec![MockEgress::ok(
3472                200,
3473                &[("content-type", "text/plain")],
3474                &big,
3475            )]));
3476            let context = http_context(Some(egress.clone()));
3477            let tool = BashTool { enable_http: true };
3478
3479            let result = tool
3480                .execute_with_context(
3481                    json!({"commands": "curl -s http://93.184.216.34/huge"}),
3482                    &context,
3483                )
3484                .await;
3485
3486            let ToolExecutionResult::Success(output) = result else {
3487                panic!("expected success result");
3488            };
3489            assert_eq!(*egress.send_calls.lock().unwrap(), 0);
3490            assert_eq!(*egress.stream_calls.lock().unwrap(), 1);
3491            assert_eq!(output["exit_code"], 63, "stderr: {}", output["stderr"]);
3492            assert!(
3493                output["stderr"]
3494                    .as_str()
3495                    .unwrap()
3496                    .contains("response too large"),
3497                "stderr: {}",
3498                output["stderr"]
3499            );
3500        }
3501
3502        #[test]
3503        fn validate_config_accepts_bool_and_rejects_other_types() {
3504            let cap = BashkitShellCapability;
3505            assert!(cap.validate_config(&serde_json::Value::Null).is_ok());
3506            assert!(cap.validate_config(&json!({})).is_ok());
3507            assert!(cap.validate_config(&json!({"enable_http": true})).is_ok());
3508            assert!(cap.validate_config(&json!({"enable_http": "yes"})).is_err());
3509            assert!(cap.validate_config(&json!("nope")).is_err());
3510        }
3511
3512        #[test]
3513        fn config_schema_exposes_enable_http() {
3514            let schema = BashkitShellCapability.config_schema().unwrap();
3515            assert!(schema["properties"]["enable_http"]["type"] == "boolean");
3516        }
3517    }
3518}