use crate::tools::types::{
Tool, ToolContext, ToolErrorKind, ToolEventSender, ToolOutput, ToolStreamEvent,
};
use crate::workspace::{CommandOutputObserver, CommandOutputSummary, CommandRequest};
use anyhow::Result;
use async_trait::async_trait;
use std::collections::HashMap;
#[cfg(windows)]
use std::ffi::OsStr;
use std::process::Stdio;
use std::sync::{Arc, Mutex};
use tokio::process::Command;
#[cfg(windows)]
pub(crate) mod windows;
#[cfg(windows)]
pub(crate) use windows::maybe_execute_simple_windows_http_command;
#[cfg(windows)]
pub(crate) use windows::{build_powershell_command, encode_powershell_command, CREATE_NO_WINDOW};
#[cfg(all(test, windows))]
use windows::{
normalize_json_like_literal, parse_simple_windows_http_command, preprocess_windows_command,
};
pub(crate) const DEFAULT_TIMEOUT_MS: u64 = 120_000;
const MIN_TIMEOUT_MS: u64 = 1_000;
struct ToolEventObserver {
tx: Option<ToolEventSender>,
summary: Mutex<Option<CommandOutputSummary>>,
}
#[async_trait]
impl CommandOutputObserver for ToolEventObserver {
async fn on_output_delta(&self, delta: &str) {
if let Some(tx) = &self.tx {
tx.send(ToolStreamEvent::OutputDelta(delta.to_string()))
.await
.ok();
}
}
async fn on_output_complete(&self, summary: &CommandOutputSummary) {
*self.summary.lock().unwrap() = Some(*summary);
}
}
fn with_changed_paths(metadata: serde_json::Value, paths: &[String]) -> serde_json::Value {
let mut wrapped = Some(metadata);
crate::porcelain::attach(&mut wrapped, paths);
wrapped.unwrap_or_else(|| serde_json::json!({}))
}
pub struct BashTool;
async fn workspace_watch(root: &std::path::Path) -> crate::porcelain::Watch {
crate::porcelain::Watch::start(root).await
}
fn claim_dirtied_paths(ctx: &ToolContext, paths: &[String]) {
let Some(session_id) = ctx.session_id.as_deref().filter(|id| !id.trim().is_empty()) else {
return;
};
for path in paths {
let _ = crate::external_observation::claim_bound_write(
Some(session_id),
ctx.workspace.as_path(),
path,
);
}
}
async fn observed_changes(ctx: &ToolContext, before: crate::porcelain::Watch) -> Vec<String> {
let paths = before.finish(ctx.workspace.as_path()).await;
claim_dirtied_paths(ctx, &paths);
paths
}
#[cfg(test)]
fn changed_paths_from_porcelain(before: &[String], after: &[String]) -> Vec<String> {
crate::porcelain::changed_paths(before, after)
}
#[cfg(windows)]
fn prepare_windows_command(
command: &mut Command,
workspace: &std::path::Path,
command_env: Option<&HashMap<String, String>>,
) {
command
.current_dir(workspace)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true)
.creation_flags(CREATE_NO_WINDOW);
if let Some(env) = command_env {
command.envs(env);
}
}
#[cfg(windows)]
fn spawn_windows_shell(
powershell_program: &OsStr,
command: &str,
workspace: &std::path::Path,
command_env: Option<&HashMap<String, String>>,
) -> std::io::Result<tokio::process::Child> {
let wrapped_command = build_powershell_command(command);
let encoded_command = encode_powershell_command(&wrapped_command);
let mut powershell = Command::new(powershell_program);
powershell.args([
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-EncodedCommand",
&encoded_command,
]);
prepare_windows_command(&mut powershell, workspace, command_env);
crate::tools::process::spawn_tokio_with_native_gate(&mut powershell).map_err(|source| {
std::io::Error::new(
source.kind(),
format!(
"failed to spawn PowerShell executable {powershell_program:?}: {source}; refusing to reinterpret the command with another shell"
),
)
})
}
pub(crate) fn spawn_shell(
command: &str,
workspace: &std::path::Path,
command_env: Option<&HashMap<String, String>>,
) -> std::io::Result<tokio::process::Child> {
#[cfg(windows)]
{
spawn_windows_shell(
OsStr::new("powershell.exe"),
command,
workspace,
command_env,
)
}
#[cfg(not(windows))]
{
let mut cmd = Command::new("bash");
cmd.arg("-c")
.arg(command)
.current_dir(workspace)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
crate::tools::process::configure_process_group(&mut cmd);
if let Some(env) = command_env {
cmd.envs(env);
}
crate::tools::process::spawn_tokio_with_native_gate(&mut cmd)
}
}
#[async_trait]
impl Tool for BashTool {
fn name(&self) -> &str {
"bash"
}
fn description(&self) -> &str {
"Execute a shell command in the workspace directory. On Windows this runs in a hidden PowerShell session, not GNU bash. Use for running commands, installing packages, and running tests."
}
fn parameters(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"additionalProperties": false,
"properties": {
"command": {
"type": "string",
"description": "Required. The exact shell command to execute. Always provide this exact field name: 'command'. On Windows the command must be PowerShell-compatible; the tool provides a small compatibility shim for curl, wget, bare HTTP verbs (GET/POST/PUT/PATCH/DELETE/OPTIONS), which, and head."
},
"timeout": {
"type": "integer",
"description": "Optional. Timeout in milliseconds. Default: 120000. Values below 1000 are clamped to 1000 to avoid accidental immediate timeouts."
},
"sandbox_permissions": {
"type": "string",
"enum": ["use_default", "require_escalated"],
"default": "use_default",
"description": "Execution boundary. Omit or use 'use_default' for the configured workspace sandbox; this fails closed when no sandbox is installed. Use 'require_escalated' only after a sandbox denial when host execution is necessary; interactive hosts must authorize that request."
},
"justification": {
"type": "string",
"description": "Required with sandbox_permissions='require_escalated'. Briefly explain why the command cannot run inside the workspace sandbox."
}
},
"required": ["command"],
"examples": [
{
"command": "cargo test -p a3s-code-core skill::"
},
{
"command": "npm test",
"timeout": 300000
}
]
})
}
fn requires_confirmation(&self, args: &serde_json::Value) -> bool {
args.get("sandbox_permissions")
.and_then(serde_json::Value::as_str)
== Some("require_escalated")
}
async fn execute(&self, args: &serde_json::Value, ctx: &ToolContext) -> Result<ToolOutput> {
if let Some(output) = observe_detached_job(args, ctx).await {
return Ok(output);
}
if let Some(output) = session_shell_control(args, ctx) {
return Ok(output);
}
let command = match args.get("command").and_then(|v| v.as_str()) {
Some(c) => c,
None => return Ok(ToolOutput::error("command parameter is required")),
};
let command = prefix_session_cwd(command, ctx);
let command = command.as_str();
let require_escalated = match args
.get("sandbox_permissions")
.and_then(serde_json::Value::as_str)
.unwrap_or("use_default")
{
"use_default" => false,
"require_escalated" => true,
value => {
return Ok(ToolOutput::error(format!(
"unsupported sandbox_permissions value: {value}"
)))
}
};
if require_escalated
&& args
.get("justification")
.and_then(serde_json::Value::as_str)
.is_none_or(|value| value.trim().is_empty())
{
return Ok(ToolOutput::error(
"justification is required when sandbox_permissions is require_escalated",
));
}
if let Some(denied) = refuse_hidden_foreign_write(ctx) {
return Ok(denied);
}
let requested_timeout_ms = args
.get("timeout")
.and_then(|v| v.as_u64())
.unwrap_or(DEFAULT_TIMEOUT_MS);
let timeout_ms = requested_timeout_ms.max(MIN_TIMEOUT_MS);
let event_observer = Arc::new(ToolEventObserver {
tx: ctx.event_tx.clone(),
summary: Mutex::new(None),
});
let output_observer = Some(Arc::clone(&event_observer) as Arc<dyn CommandOutputObserver>);
if !require_escalated
&& ctx.sandbox.is_none()
&& ctx.workspace_services.local_root().is_some()
&& ctx.has_run_governance()
{
let message = "default bash execution requires a configured sandbox; refusing to execute the command on the host";
let mut denied = ToolOutput::error(message);
denied.metadata = Some(serde_json::json!({
"exit_code": null,
"sandboxed": false,
"sandbox_available": false,
}));
denied.error_kind = Some(ToolErrorKind::Unsupported {
message: message.to_string(),
});
return Ok(denied);
}
if !require_escalated {
if let Some(ref sandbox) = ctx.sandbox {
let before_porcelain = workspace_watch(ctx.workspace.as_path()).await;
let execution = sandbox.exec(crate::sandbox::SandboxCommandRequest {
command: command.to_string(),
guest_workspace: "/workspace".to_string(),
timeout_ms,
output_observer: output_observer.clone(),
env: ctx.command_env.clone(),
});
let result = match tokio::time::timeout(
std::time::Duration::from_millis(timeout_ms),
execution,
)
.await
{
Ok(result) => result
.map_err(|e| anyhow::anyhow!("Sandbox bash execution failed: {}", e))?,
Err(_) => {
let capture_summary = *event_observer.summary.lock().unwrap();
let capture_metadata = capture_summary.map(|summary| {
serde_json::json!({
"total_bytes": summary.total_bytes,
"captured_bytes": summary.captured_bytes,
"truncated": summary.truncated,
"timed_out": true,
})
});
let mut timed_out = ToolOutput::error(format!(
"[Command timed out after {}ms]",
timeout_ms
));
timed_out.metadata = Some(with_changed_paths(
serde_json::json!({
"exit_code": null,
"timeout_ms": timeout_ms,
"sandboxed": true,
"output": capture_metadata,
}),
&observed_changes(ctx, before_porcelain).await,
));
timed_out.error_kind = Some(ToolErrorKind::Timeout {
op: "bash".to_string(),
duration_ms: timeout_ms,
});
return Ok(timed_out);
}
};
let mut output = result.stdout;
if !result.stderr.is_empty() {
output.push_str(&result.stderr);
}
let capture_summary = *event_observer.summary.lock().unwrap();
let capture_metadata = capture_summary.map(|summary| {
serde_json::json!({
"total_bytes": summary.total_bytes,
"captured_bytes": summary.captured_bytes,
"truncated": summary.truncated,
"timed_out": summary.timed_out,
})
});
if result.timed_out {
let mut timed_out = ToolOutput::error(format!(
"{}\n\n[Command timed out after {}ms]",
output, timeout_ms
));
timed_out.metadata = Some(with_changed_paths(
serde_json::json!({
"exit_code": result.exit_code,
"timeout_ms": timeout_ms,
"sandboxed": true,
"output": capture_metadata,
}),
&observed_changes(ctx, before_porcelain).await,
));
timed_out.error_kind = Some(ToolErrorKind::Timeout {
op: "bash".to_string(),
duration_ms: timeout_ms,
});
return Ok(timed_out);
}
let changed_paths = observed_changes(ctx, before_porcelain).await;
return Ok(ToolOutput {
content: output,
success: result.exit_code == 0,
metadata: crate::verification::merge_shell_verification_metadata(
Some(with_changed_paths(
serde_json::json!({
"exit_code": result.exit_code,
"sandboxed": true,
"output": capture_metadata,
}),
&changed_paths,
)),
Some(ctx.workspace.as_path()),
command,
result.exit_code,
None,
),
images: vec![],
error_kind: None,
trust: crate::tools::ToolResultTrustV1::WorkspaceData,
});
}
}
let runner = ctx
.workspace_services
.command_runner()
.expect("bash registered without workspace command runner");
let before_porcelain = workspace_watch(ctx.workspace.as_path()).await;
let result = runner
.exec(CommandRequest {
command: command.to_string(),
timeout_ms,
output_observer,
env: ctx.command_env.clone(),
})
.await
.map_err(|e| anyhow::anyhow!("Workspace bash execution failed: {}", e))?;
let changed_paths = observed_changes(ctx, before_porcelain).await;
let capture_summary = *event_observer.summary.lock().unwrap();
let capture_metadata = capture_summary.map(|summary| {
serde_json::json!({
"total_bytes": summary.total_bytes,
"captured_bytes": summary.captured_bytes,
"truncated": summary.truncated,
"timed_out": summary.timed_out,
})
});
if result.timed_out {
let mut output = ToolOutput::error(format!(
"{}\n\n[Command timed out after {}ms]",
result.output, timeout_ms
));
output.metadata = crate::verification::merge_shell_verification_metadata(
Some(with_changed_paths(
serde_json::json!({
"exit_code": result.exit_code,
"timeout_ms": timeout_ms,
"sandboxed": false,
"output": capture_metadata,
}),
&changed_paths,
)),
Some(ctx.workspace.as_path()),
command,
result.exit_code,
Some("command timed out"),
);
output.error_kind = Some(ToolErrorKind::Timeout {
op: "bash".to_string(),
duration_ms: timeout_ms,
});
return Ok(output);
}
Ok(ToolOutput {
content: result.output,
success: result.exit_code == 0,
metadata: crate::verification::merge_shell_verification_metadata(
Some(with_changed_paths(
serde_json::json!({
"exit_code": result.exit_code,
"sandboxed": false,
"output": capture_metadata,
}),
&changed_paths,
)),
Some(ctx.workspace.as_path()),
command,
result.exit_code,
None,
),
images: vec![],
error_kind: None,
trust: crate::tools::ToolResultTrustV1::WorkspaceData,
})
}
}
fn refuse_hidden_foreign_write(ctx: &ToolContext) -> Option<ToolOutput> {
match crate::external_observation::refuse_foreign_workspace_owner(
ctx.session_id.as_deref(),
&ctx.workspace,
) {
Ok(()) => None,
Err(error) => Some(ToolOutput::error(error)),
}
}
async fn observe_detached_job(args: &serde_json::Value, ctx: &ToolContext) -> Option<ToolOutput> {
let session_id = ctx.session_id.as_deref()?;
crate::shell_session::cwd(session_id)?;
if args.get("job_action").and_then(|value| value.as_str()) != Some("detach") {
return None;
}
let command = args.get("command").and_then(|value| value.as_str())?;
if let Some(denied) = refuse_hidden_foreign_write(ctx) {
return Some(denied);
}
let watch = crate::porcelain::Watch::start(ctx.workspace.as_path()).await;
match crate::shell_session::detach(session_id, command, shell_admission(ctx, command)) {
Ok(job_id) => {
crate::porcelain::install_workspace_child(&job_id, watch);
let session = session_id.to_string();
let job = job_id.clone();
let workspace = ctx.workspace.clone();
tokio::spawn(async move {
let mut guard = crate::porcelain::WorkspaceChildGuard::new(&job);
loop {
match crate::shell_session::poll(&session, &job) {
Ok(status) if status == "running" => {
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
_ => break,
}
}
crate::porcelain::settle_workspace_child_guard(&mut guard, &workspace).await;
if let Some(paths) = crate::porcelain::peek_settled_workspace_child(&job) {
claim_dirtied_paths(
&ToolContext::new(workspace.as_path().to_path_buf())
.with_session_id(&session),
&paths,
);
}
});
Some(
ToolOutput::success(job_id.clone()).with_metadata(serde_json::json!({
"job_id": job_id,
"workspace_child": job_id,
})),
)
}
Err(error) => {
drop(watch);
Some(ToolOutput::error(error.to_string()))
}
}
}
fn session_shell_control(args: &serde_json::Value, ctx: &ToolContext) -> Option<ToolOutput> {
let session_id = ctx.session_id.as_deref()?;
crate::shell_session::cwd(session_id)?;
let action = args.get("job_action").and_then(|value| value.as_str())?;
let job_id = args
.get("job_id")
.and_then(|value| value.as_str())
.unwrap_or("");
let result = match action {
"poll" => crate::shell_session::poll(session_id, job_id),
"kill" => crate::shell_session::kill(session_id, job_id).map(|()| "killed".to_string()),
_ => {
return Some(ToolOutput::error(format!(
"unsupported job_action: {action}"
)))
}
};
Some(match result {
Ok(text) => ToolOutput::success(text),
Err(error) => ToolOutput::error(error.to_string()),
})
}
fn prefix_session_cwd(command: &str, ctx: &ToolContext) -> String {
let Some(session_id) = ctx.session_id.as_deref() else {
return command.to_string();
};
if let Ok(admitted) =
crate::shell_session::admit(session_id, command, shell_admission(ctx, command))
{
if command.trim().starts_with("cd ") {
return format!("cd {}", admitted.cwd.display());
}
return format!("cd {} && {command}", admitted.cwd.display());
}
command.to_string()
}
fn shell_admission(ctx: &ToolContext, command: &str) -> crate::shell_session::CommandAdmission {
let Some(checker) = ctx.run_permission_checker() else {
return crate::shell_session::CommandAdmission::Allow;
};
if checker.check("bash", &serde_json::json!({ "command": command }))
== crate::permissions::PermissionDecision::Deny
{
crate::shell_session::CommandAdmission::Deny
} else {
crate::shell_session::CommandAdmission::Allow
}
}
#[cfg(test)]
#[path = "bash/tests.rs"]
mod tests;