Skip to main content

harness_bash/
run.rs

1use harness_core::{ToolError, ToolErrorCode};
2use serde_json::Value;
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5use std::sync::{Arc, Mutex};
6
7use crate::constants::{
8    BACKGROUND_MAX_JOBS, DEFAULT_INACTIVITY_TIMEOUT_MS, DEFAULT_WALLCLOCK_BACKSTOP_MS,
9    KILL_GRACE_MS, MAX_OUTPUT_BYTES_FILE, MAX_OUTPUT_BYTES_INLINE, SENSITIVE_ENV_PREFIXES,
10};
11use crate::executor::BashRunInput;
12use crate::fence::{fence_bash, resolve_cwd};
13use crate::format::{
14    format_background_started_text, format_bash_kill_text, format_bash_output_text,
15    format_result_text, format_timeout_text, FormatBashOutputArgs, FormatResultArgs,
16    FormatTimeoutArgs, HeadTailBuffer,
17};
18use crate::schema::{
19    safe_parse_bash_kill_params, safe_parse_bash_output_params, safe_parse_bash_params,
20};
21use crate::types::{
22    BashBackgroundStarted, BashError, BashKillResult, BashNonzeroExit, BashOk,
23    BashOutputResult, BashResult, BashSessionConfig, BashTimeout, TimeoutReason,
24};
25
26fn err<T: From<BashError>>(e: ToolError) -> T {
27    T::from(BashError { error: e })
28}
29
30impl From<BashError> for BashResult {
31    fn from(e: BashError) -> Self {
32        BashResult::Error(e)
33    }
34}
35impl From<BashError> for BashOutputResult {
36    fn from(e: BashError) -> Self {
37        BashOutputResult::Error(e)
38    }
39}
40impl From<BashError> for BashKillResult {
41    fn from(e: BashError) -> Self {
42        BashKillResult::Error(e)
43    }
44}
45
46/// Top-level grammar check for a `cd <path>` command. Returns the target
47/// only for a single, unambiguous cd with no shell operators.
48pub fn detect_top_level_cd(command: &str) -> Option<String> {
49    let trimmed = command.trim();
50    if trimmed.is_empty() {
51        return None;
52    }
53    // Reject any shell metachar that would make this a compound command.
54    if !trimmed.starts_with("cd ") {
55        return None;
56    }
57    let rest = trimmed[3..].trim_start();
58    if rest.is_empty() {
59        return None;
60    }
61    for ch in rest.chars() {
62        if matches!(ch, '&' | '|' | ';' | '`' | '$' | '(' | ')') {
63            return None;
64        }
65        if ch.is_whitespace() {
66            return None;
67        }
68    }
69    let stripped = if (rest.starts_with('"') && rest.ends_with('"'))
70        || (rest.starts_with('\'') && rest.ends_with('\''))
71    {
72        rest[1..rest.len() - 1].to_string()
73    } else {
74        rest.to_string()
75    };
76    Some(stripped)
77}
78
79#[derive(Debug, Clone, Copy)]
80pub struct CwdCarryOutcome {
81    pub changed: bool,
82    pub escaped: bool,
83}
84
85/// Mirror of the TS `applyCwdCarry`. Takes the executed command, its
86/// exit code, and mutates `session.logical_cwd` if a top-level `cd`
87/// landed inside the workspace. Returns a summary so the caller can
88/// annotate the tool result if an escape attempt was blocked.
89pub fn apply_cwd_carry(
90    session: &BashSessionConfig,
91    command: &str,
92    exit_code: Option<i32>,
93) -> CwdCarryOutcome {
94    if exit_code != Some(0) {
95        return CwdCarryOutcome { changed: false, escaped: false };
96    }
97    let Some(target) = detect_top_level_cd(command) else {
98        return CwdCarryOutcome { changed: false, escaped: false };
99    };
100    let Some(logical) = &session.logical_cwd else {
101        return CwdCarryOutcome { changed: false, escaped: false };
102    };
103    let base = logical.get();
104    let resolved: PathBuf = if Path::new(&target).is_absolute() {
105        Path::new(&target).to_path_buf()
106    } else {
107        Path::new(&base).join(&target)
108    };
109    let resolved = resolved
110        .canonicalize()
111        .unwrap_or_else(|_| resolved.clone());
112    let path_str = resolved.to_string_lossy().into_owned();
113    let inside = session
114        .permissions
115        .inner
116        .roots
117        .iter()
118        .any(|root| path_str == *root || path_str.starts_with(&format!("{}/", root)));
119    if !inside && !session.permissions.inner.bypass_workspace_guard {
120        return CwdCarryOutcome { changed: false, escaped: true };
121    }
122    logical.set(path_str);
123    CwdCarryOutcome { changed: true, escaped: false }
124}
125
126fn check_env(env: &HashMap<String, String>) -> Option<String> {
127    for key in env.keys() {
128        for prefix in SENSITIVE_ENV_PREFIXES {
129            let hit = if prefix.ends_with('_') {
130                key.starts_with(prefix)
131            } else {
132                key == prefix
133            };
134            if hit {
135                return Some(format!(
136                    "env may not set sensitive-prefix variable '{}' (prefix '{}').",
137                    key, prefix
138                ));
139            }
140        }
141    }
142    None
143}
144
145pub async fn bash_run(input: Value, session: &BashSessionConfig) -> BashResult {
146    let params = match safe_parse_bash_params(&input) {
147        Ok(v) => v,
148        Err(e) => return err(ToolError::new(ToolErrorCode::InvalidParam, e.to_string())),
149    };
150
151    let background = params.background.unwrap_or(false);
152    if background && params.timeout_ms.is_some() {
153        return err(ToolError::new(
154            ToolErrorCode::InvalidParam,
155            "timeout_ms does not apply to background jobs; they have their own lifecycle (bash_kill). Drop timeout_ms or set background: false.",
156        ));
157    }
158
159    let env = params.env.unwrap_or_default();
160    if let Some(msg) = check_env(&env) {
161        return err(ToolError::new(ToolErrorCode::InvalidParam, msg));
162    }
163
164    // Fail-closed if no hook AND not explicitly bypassed.
165    if session.permissions.inner.hook.is_none()
166        && !session.permissions.unsafe_allow_bash_without_hook
167    {
168        return err(ToolError::new(
169            ToolErrorCode::PermissionDenied,
170            "bash tool has no permission hook configured; refusing to run untrusted commands. Wire a hook or set permissions.unsafe_allow_bash_without_hook for test fixtures.",
171        ));
172    }
173
174    let logical = session.logical_cwd.as_ref().map(|l| l.get());
175    let resolved = resolve_cwd(&session.cwd, params.cwd.as_deref(), logical.as_deref());
176    if let Some(fe) = fence_bash(&session.permissions.inner, &resolved) {
177        return err(fe);
178    }
179    let stat = std::fs::metadata(&resolved);
180    match stat {
181        Err(_) => {
182            return err(ToolError::new(
183                ToolErrorCode::NotFound,
184                format!("cwd does not exist: {}", resolved.to_string_lossy()),
185            ));
186        }
187        Ok(m) if !m.is_dir() => {
188            return err(ToolError::new(
189                ToolErrorCode::IoError,
190                format!(
191                    "cwd is not a directory: {}",
192                    resolved.to_string_lossy()
193                ),
194            ));
195        }
196        _ => {}
197    }
198
199    let cwd_str = resolved.to_string_lossy().into_owned();
200
201    // Merge session env + call env. Session env None → inherit process env.
202    let merged_env: HashMap<String, String> = {
203        let base: HashMap<String, String> = match &session.env {
204            Some(e) => e.clone(),
205            None => std::env::vars().collect(),
206        };
207        let mut out = base;
208        for (k, v) in env {
209            out.insert(k, v);
210        }
211        out
212    };
213
214    if background {
215        return run_background(session, params.command, cwd_str, merged_env).await;
216    }
217
218    run_foreground(
219        session,
220        params.command,
221        cwd_str,
222        merged_env,
223        params
224            .timeout_ms
225            .or(session.default_inactivity_timeout_ms)
226            .unwrap_or(DEFAULT_INACTIVITY_TIMEOUT_MS),
227    )
228    .await
229}
230
231async fn run_background(
232    session: &BashSessionConfig,
233    command: String,
234    cwd: String,
235    env: HashMap<String, String>,
236) -> BashResult {
237    let max_jobs = session.max_background_jobs.unwrap_or(BACKGROUND_MAX_JOBS);
238    let _ = max_jobs; // Enforcement responsibility belongs to the executor;
239                     // core behavior is forwarded. The LocalBashExecutor
240                     // doesn't enforce the cap today — callers that need
241                     // it substitute a wrapping executor.
242    match session
243        .executor
244        .spawn_background(command.clone(), cwd, env)
245        .await
246    {
247        Ok(job_id) => BashResult::BackgroundStarted(BashBackgroundStarted {
248            output: format_background_started_text(&command, &job_id),
249            job_id,
250        }),
251        Err(e) => err(ToolError::new(
252            ToolErrorCode::IoError,
253            format!("spawn_background failed: {}", e),
254        )),
255    }
256}
257
258async fn run_foreground(
259    session: &BashSessionConfig,
260    command: String,
261    cwd: String,
262    env: HashMap<String, String>,
263    inactivity_ms: u64,
264) -> BashResult {
265    let wallclock_ms = session
266        .wallclock_backstop_ms
267        .unwrap_or(DEFAULT_WALLCLOCK_BACKSTOP_MS);
268    let max_inline = session
269        .max_output_bytes_inline
270        .unwrap_or(MAX_OUTPUT_BYTES_INLINE);
271    let max_file = session
272        .max_output_bytes_file
273        .unwrap_or(MAX_OUTPUT_BYTES_FILE);
274    let spill_dir = std::env::temp_dir().join("agent-sh-bash-spill");
275
276    let stdout_buf = Arc::new(Mutex::new(HeadTailBuffer::new(
277        max_inline,
278        max_file,
279        "out",
280        spill_dir.clone(),
281    )));
282    let stderr_buf = Arc::new(Mutex::new(HeadTailBuffer::new(
283        max_inline,
284        max_file,
285        "err",
286        spill_dir.clone(),
287    )));
288
289    let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false);
290    let timed_out_flag = Arc::new(Mutex::new(None::<TimeoutReason>));
291    let inactivity_reset_tx = Arc::new(tokio::sync::Notify::new());
292
293    // Wall-clock backstop.
294    let timed_out_clone = Arc::clone(&timed_out_flag);
295    let cancel_tx_clone = cancel_tx.clone();
296    let wall_task = tokio::spawn(async move {
297        tokio::time::sleep(std::time::Duration::from_millis(wallclock_ms)).await;
298        *timed_out_clone.lock().unwrap() = Some(TimeoutReason::WallClockBackstop);
299        let _ = cancel_tx_clone.send(true);
300    });
301
302    // Inactivity timer.
303    let timed_out_clone = Arc::clone(&timed_out_flag);
304    let cancel_tx_clone = cancel_tx.clone();
305    let inactivity_reset = Arc::clone(&inactivity_reset_tx);
306    let inactivity_task = tokio::spawn(async move {
307        loop {
308            tokio::select! {
309                _ = inactivity_reset.notified() => continue,
310                _ = tokio::time::sleep(std::time::Duration::from_millis(inactivity_ms)) => {
311                    *timed_out_clone.lock().unwrap() = Some(TimeoutReason::InactivityTimeout);
312                    let _ = cancel_tx_clone.send(true);
313                    break;
314                }
315            }
316        }
317    });
318
319    let started = std::time::Instant::now();
320
321    let stdout_clone = Arc::clone(&stdout_buf);
322    let stderr_clone = Arc::clone(&stderr_buf);
323    let reset_clone_out = Arc::clone(&inactivity_reset_tx);
324    let reset_clone_err = Arc::clone(&inactivity_reset_tx);
325
326    let input = BashRunInput {
327        command: command.clone(),
328        cwd,
329        env,
330        cancel: cancel_rx,
331        on_stdout: Box::new(move |chunk: &[u8]| {
332            stdout_clone.lock().unwrap().write(chunk);
333            reset_clone_out.notify_waiters();
334        }),
335        on_stderr: Box::new(move |chunk: &[u8]| {
336            stderr_clone.lock().unwrap().write(chunk);
337            reset_clone_err.notify_waiters();
338        }),
339    };
340
341    let result = session.executor.run(input).await;
342    let duration = started.elapsed().as_millis() as u64;
343    wall_task.abort();
344    inactivity_task.abort();
345    let _ = KILL_GRACE_MS;
346
347    let stdout_render = stdout_buf.lock().unwrap().render();
348    let stderr_render = stderr_buf.lock().unwrap().render();
349    let byte_cap = stdout_render.byte_cap || stderr_render.byte_cap;
350    let log_path = stdout_render
351        .log_path
352        .clone()
353        .or(stderr_render.log_path.clone());
354
355    let timed_out = *timed_out_flag.lock().unwrap();
356    if let Some(reason) = timed_out {
357        let partial = stdout_buf.lock().unwrap().bytes_total()
358            + stderr_buf.lock().unwrap().bytes_total();
359        return BashResult::Timeout(BashTimeout {
360            output: format_timeout_text(FormatTimeoutArgs {
361                command: &command,
362                stdout: &stdout_render.text,
363                stderr: &stderr_render.text,
364                reason,
365                duration_ms: duration,
366                partial_bytes: partial,
367                log_path: log_path.as_deref(),
368            }),
369            stdout: stdout_render.text,
370            stderr: stderr_render.text,
371            reason,
372            duration_ms: duration,
373            log_path,
374        });
375    }
376
377    let exit_code = result.exit_code.unwrap_or(-1);
378    let kind_ok = exit_code == 0;
379    let output = format_result_text(FormatResultArgs {
380        command: &command,
381        exit_code,
382        stdout: &stdout_render.text,
383        stderr: &stderr_render.text,
384        duration_ms: duration,
385        byte_cap,
386        log_path: log_path.as_deref(),
387        kind_ok,
388    });
389
390    if kind_ok {
391        BashResult::Ok(BashOk {
392            output,
393            exit_code,
394            stdout: stdout_render.text,
395            stderr: stderr_render.text,
396            duration_ms: duration,
397            log_path,
398            byte_cap,
399        })
400    } else {
401        BashResult::NonzeroExit(BashNonzeroExit {
402            output,
403            exit_code,
404            stdout: stdout_render.text,
405            stderr: stderr_render.text,
406            duration_ms: duration,
407            log_path,
408            byte_cap,
409        })
410    }
411}
412
413pub async fn bash_output_run(
414    input: Value,
415    session: &BashSessionConfig,
416) -> BashOutputResult {
417    let params = match safe_parse_bash_output_params(&input) {
418        Ok(v) => v,
419        Err(e) => {
420            return err::<BashOutputResult>(ToolError::new(
421                ToolErrorCode::InvalidParam,
422                e.to_string(),
423            ));
424        }
425    };
426    let since = params.since_byte.unwrap_or(0);
427    let head_limit = params.head_limit.unwrap_or(30_720);
428    match session
429        .executor
430        .read_background(&params.job_id, since, head_limit)
431        .await
432    {
433        Err(e) => err(ToolError::new(ToolErrorCode::NotFound, e)),
434        Ok(r) => {
435            let returned = r.stdout.len() as u64 + r.stderr.len() as u64;
436            let total = r.total_bytes_stdout + r.total_bytes_stderr;
437            BashOutputResult::Output {
438                output: format_bash_output_text(FormatBashOutputArgs {
439                    job_id: &params.job_id,
440                    running: r.running,
441                    exit_code: r.exit_code,
442                    stdout: &r.stdout,
443                    stderr: &r.stderr,
444                    since_byte: since,
445                    returned_bytes: returned,
446                    total_bytes: total,
447                }),
448                running: r.running,
449                exit_code: r.exit_code,
450                stdout: r.stdout,
451                stderr: r.stderr,
452                total_bytes_stdout: r.total_bytes_stdout,
453                total_bytes_stderr: r.total_bytes_stderr,
454                next_since_byte: since + returned,
455            }
456        }
457    }
458}
459
460pub async fn bash_kill_run(
461    input: Value,
462    session: &BashSessionConfig,
463) -> BashKillResult {
464    let params = match safe_parse_bash_kill_params(&input) {
465        Ok(v) => v,
466        Err(e) => {
467            return err::<BashKillResult>(ToolError::new(
468                ToolErrorCode::InvalidParam,
469                e.to_string(),
470            ));
471        }
472    };
473    let signal = params.signal.unwrap_or_else(|| "SIGTERM".to_string());
474    match session
475        .executor
476        .kill_background(&params.job_id, &signal)
477        .await
478    {
479        Err(e) => err(ToolError::new(ToolErrorCode::NotFound, e)),
480        Ok(()) => BashKillResult::Killed {
481            output: format_bash_kill_text(&params.job_id, &signal),
482            job_id: params.job_id,
483            signal,
484        },
485    }
486}