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
46pub fn detect_top_level_cd(command: &str) -> Option<String> {
49 let trimmed = command.trim();
50 if trimmed.is_empty() {
51 return None;
52 }
53 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
85pub 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 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 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; 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 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 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(¶ms.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: ¶ms.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(¶ms.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(¶ms.job_id, &signal),
482 job_id: params.job_id,
483 signal,
484 },
485 }
486}