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, windows_host_powershell, 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)]
const MAX_POWERSHELL_COMMAND_CHARS: usize = 30_000;
#[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",
]);
let encoded_line = format!("{powershell_program:?} -EncodedCommand {encoded_command}");
let script_file = if encoded_line.encode_utf16().count() <= MAX_POWERSHELL_COMMAND_CHARS {
powershell.arg("-EncodedCommand").arg(&encoded_command);
None
} else {
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|elapsed| elapsed.as_nanos())
.unwrap_or(0);
let path =
std::env::temp_dir().join(format!("a3s-host-{}-{unique}.ps1", std::process::id()));
let literal = path.to_string_lossy().replace('\'', "''");
let body = format!(
"trap {{ exit 1 }}\nRegister-EngineEvent -SourceIdentifier PowerShell.Exiting -Action {{ Remove-Item -LiteralPath '{literal}' -Force -ErrorAction SilentlyContinue }} | Out-Null\n{wrapped_command}\nif (-not $?) {{ exit 1 }}\n"
);
std::fs::write(&path, body.as_bytes())?;
powershell.arg("-File").arg(&path);
Some(path)
};
prepare_windows_command(&mut powershell, workspace, command_env);
match crate::tools::process::spawn_tokio_child(&mut powershell) {
Ok(child) => {
if let Err(error) = bind_host_shell_job(&child) {
drop(child);
if let Some(path) = script_file {
let _ = std::fs::remove_file(path);
}
return Err(error);
}
Ok(child)
}
Err(source) => {
if let Some(path) = script_file {
let _ = std::fs::remove_file(path);
}
Err(std::io::Error::new(
source.kind(),
format!(
"failed to spawn PowerShell executable {powershell_program:?}: {source}; refusing to reinterpret the command with another shell"
),
))
}
}
}
#[cfg(windows)]
pub(crate) fn bind_windows_process_tree(
raw_process: std::os::windows::io::RawHandle,
pid: u32,
) -> std::io::Result<()> {
use std::ffi::c_void;
use std::mem::{size_of, zeroed};
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
use windows_sys::Win32::System::JobObjects::{
AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation,
SetInformationJobObject, TerminateJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
};
use windows_sys::Win32::System::Threading::{OpenProcess, WaitForSingleObject};
const SYNCHRONIZE: u32 = 0x0010_0000;
let raw_job = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) };
if raw_job.is_null() {
return Err(std::io::Error::last_os_error());
}
let job = unsafe { OwnedHandle::from_raw_handle(raw_job) };
let mut limits = unsafe { zeroed::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() };
limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
let size = u32::try_from(size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>())
.map_err(|_| std::io::Error::other("Job Object limit structure size overflowed"))?;
if unsafe {
SetInformationJobObject(
job.as_raw_handle(),
JobObjectExtendedLimitInformation,
(&limits as *const JOBOBJECT_EXTENDED_LIMIT_INFORMATION).cast::<c_void>(),
size,
)
} == 0
{
return Err(std::io::Error::last_os_error());
}
if unsafe { AssignProcessToJobObject(job.as_raw_handle(), raw_process) } == 0 {
return Err(std::io::Error::last_os_error());
}
let raw_wait = unsafe { OpenProcess(SYNCHRONIZE, 0, pid) };
if raw_wait.is_null() {
return Err(std::io::Error::last_os_error());
}
let wait = unsafe { OwnedHandle::from_raw_handle(raw_wait) };
std::thread::Builder::new()
.name("a3s-host-shell-job".to_string())
.spawn(move || {
if unsafe { WaitForSingleObject(wait.as_raw_handle(), u32::MAX) } == 0 {
let _ = unsafe { TerminateJobObject(job.as_raw_handle(), 1) };
}
})
.map(|_| ())
}
#[cfg(windows)]
fn bind_host_shell_job(child: &tokio::process::Child) -> std::io::Result<()> {
let Some(raw_process) = child.raw_handle() else {
return Ok(());
};
let Some(pid) = child.id() else {
return Ok(());
};
bind_windows_process_tree(raw_process, pid)
}
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)]
{
let powershell = windows_host_powershell(workspace).map_err(|error| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
format!(
"failed to resolve PowerShell 7 for workspace {}: {error}",
workspace.display()
),
)
})?;
spawn_windows_shell(powershell.as_os_str(), 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_child(&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", "request_network_grant"],
"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. Use 'request_network_grant' after a network denial: it asks the user to allow exactly one origin (network_grant.host/port) inside the sandbox and then runs the command sandboxed."
},
"justification": {
"type": "string",
"description": "Required with sandbox_permissions='require_escalated'. Briefly explain why the command cannot run inside the workspace sandbox."
},
"network_grant": {
"type": "object",
"properties": {
"host": {
"type": "string",
"description": "Exact host to allow (no wildcards). Grant semantics never alias localhost with 127.0.0.1."
},
"port": {
"type": "integer",
"description": "Optional port pin. Omit to allow any port on the host."
}
},
"required": ["host"],
"description": "Required with sandbox_permissions='request_network_grant'. The user must authorize this exact origin."
}
},
"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 {
matches!(
args.get("sandbox_permissions").and_then(|v| v.as_str()),
Some("require_escalated") | Some("request_network_grant")
)
}
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 raw_mode = args
.get("sandbox_permissions")
.and_then(serde_json::Value::as_str)
.unwrap_or("use_default");
let require_escalated = match raw_mode {
"use_default" => false,
"require_escalated" => {
if 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",
));
}
true
}
"request_network_grant" => false,
value => {
return Ok(ToolOutput::error(format!(
"unsupported sandbox_permissions value: {value}"
)))
}
};
let request_network_grant = raw_mode == "request_network_grant";
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 request_network_grant && ctx.sandbox.is_none() {
let message = "request_network_grant requires a configured sandbox";
let mut denied = ToolOutput::error(message);
denied.metadata = Some(serde_json::json!({
"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 grant_metadata = if request_network_grant {
match resolve_network_grant(args, Some(sandbox.as_ref())) {
Ok(value) => Some(value),
Err(output) => return Ok(output),
}
} else {
None
};
let before_porcelain = workspace_watch(ctx.workspace.as_path()).await;
let execution = sandbox.exec(crate::sandbox::SandboxCommandRequest {
command: command_for_sandbox(command),
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 mut base_metadata = serde_json::json!({
"exit_code": result.exit_code,
"sandboxed": true,
"output": capture_metadata,
});
if let Some(grant) = grant_metadata {
base_metadata["network_grant"] = grant;
}
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(base_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)),
}
}
#[allow(clippy::result_large_err)]
fn resolve_network_grant(
args: &serde_json::Value,
sandbox: Option<&dyn crate::sandbox::BashSandbox>,
) -> Result<serde_json::Value, ToolOutput> {
let Some(sandbox) = sandbox else {
return Err(ToolOutput::error(
"request_network_grant requires a configured sandbox",
));
};
let grant_args = args.get("network_grant");
let host = grant_args
.and_then(|g| g.get("host"))
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|host| !host.is_empty());
let Some(host) = host else {
return Err(ToolOutput::error(
"network_grant.host is required when sandbox_permissions is request_network_grant",
));
};
let port = match grant_args
.and_then(|g| g.get("port"))
.and_then(|v| v.as_u64())
{
Some(raw) => match u16::try_from(raw) {
Ok(port) => Some(port),
Err(_) => {
return Err(ToolOutput::error(format!(
"network_grant.port {raw} is out of range"
)))
}
},
None => None,
};
let grant = match a3s_sandbox::NetworkGrant::new(host, port) {
Ok(grant) => grant,
Err(error) => return Err(ToolOutput::error(error.to_string())),
};
let Some(base) = sandbox.policy_digest() else {
return Err(ToolOutput::error(
"this sandbox backend does not expose a policy digest; grants are unavailable",
));
};
match sandbox.apply_network_grant(grant, &base) {
Ok(digest) => Ok(serde_json::json!({
"host": host,
"port": port,
"policy_digest": digest,
})),
Err(error) => Err(ToolOutput::error(format!(
"network grant refused: {error}. Re-check the current policy and re-request."
))),
}
}
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 command_for_sandbox(command: &str) -> String {
#[cfg(windows)]
{
windows_existence_check(command).unwrap_or_else(|| command.to_string())
}
#[cfg(not(windows))]
{
command.to_string()
}
}
#[cfg(windows)]
fn windows_existence_check(command: &str) -> Option<String> {
let tail = command.rsplit("&&").next()?.trim();
if tail.contains(['\n', ';', '|', '>']) {
return None;
}
let path = crate::verification::path_from_existence_check_command(tail)?;
if path.contains(['\'', '\n', '\r']) {
return None;
}
let path_type =
if tail.contains(" -e ") || tail.starts_with("test -e") || tail.contains("[ -e ") {
""
} else {
" -PathType Leaf"
};
Some(format!(
"if (Test-Path -LiteralPath '{path}'{path_type}) {{ exit 0 }} else {{ exit 1 }}"
))
}
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;