use super::*;
use crate::sandbox::{BashSandbox, SandboxCommandRequest, SandboxExecutionOutput, SandboxOutput};
use crate::workspace::CommandOutputSummary;
use async_trait::async_trait;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
const TEST_ESCALATION_JUSTIFICATION: &str =
"This test explicitly exercises the approved host command runner.";
fn escalated_args(command: impl Into<String>) -> serde_json::Value {
serde_json::json!({
"command": command.into(),
"sandbox_permissions": "require_escalated",
"justification": TEST_ESCALATION_JUSTIFICATION,
})
}
fn escalated_args_with_timeout(command: impl Into<String>, timeout: u64) -> serde_json::Value {
let mut args = escalated_args(command);
args["timeout"] = serde_json::json!(timeout);
args
}
struct MockSandbox {
stdout: String,
stderr: String,
exit_code: i32,
}
struct HangingSandbox;
#[async_trait]
impl BashSandbox for HangingSandbox {
async fn exec_command(
&self,
_command: &str,
_guest_workspace: &str,
) -> anyhow::Result<SandboxOutput> {
std::future::pending().await
}
async fn shutdown(&self) {}
}
#[async_trait]
impl BashSandbox for MockSandbox {
async fn exec_command(
&self,
_command: &str,
_guest_workspace: &str,
) -> anyhow::Result<SandboxOutput> {
Ok(SandboxOutput {
stdout: self.stdout.clone(),
stderr: self.stderr.clone(),
exit_code: self.exit_code,
})
}
async fn shutdown(&self) {}
}
#[derive(Debug, PartialEq, Eq)]
struct RecordedSandboxRequest {
command: String,
guest_workspace: String,
timeout_ms: u64,
env: Option<HashMap<String, String>>,
had_output_observer: bool,
}
struct RecordingExtendedSandbox {
called: Arc<AtomicBool>,
request: Arc<std::sync::Mutex<Option<RecordedSandboxRequest>>>,
output: SandboxExecutionOutput,
summary: CommandOutputSummary,
}
#[async_trait]
impl BashSandbox for RecordingExtendedSandbox {
async fn exec_command(
&self,
_command: &str,
_guest_workspace: &str,
) -> anyhow::Result<SandboxOutput> {
anyhow::bail!("the bash tool must use the extended sandbox contract")
}
async fn exec(&self, request: SandboxCommandRequest) -> anyhow::Result<SandboxExecutionOutput> {
self.called.store(true, Ordering::SeqCst);
*self.request.lock().unwrap() = Some(RecordedSandboxRequest {
command: request.command,
guest_workspace: request.guest_workspace,
timeout_ms: request.timeout_ms,
env: request.env.as_deref().cloned(),
had_output_observer: request.output_observer.is_some(),
});
if let Some(observer) = request.output_observer {
observer.on_output_delta("streamed sandbox output").await;
observer.on_output_complete(&self.summary).await;
}
Ok(SandboxExecutionOutput {
stdout: self.output.stdout.clone(),
stderr: self.output.stderr.clone(),
exit_code: self.output.exit_code,
timed_out: self.output.timed_out,
})
}
async fn shutdown(&self) {}
}
#[tokio::test]
async fn test_bash_delegates_to_sandbox() {
let tool = BashTool;
let sandbox = Arc::new(MockSandbox {
stdout: "sandbox output\n".into(),
stderr: String::new(),
exit_code: 0,
});
let workspace = tempfile::tempdir().unwrap();
let ctx = ToolContext::new(workspace.path().to_path_buf()).with_sandbox(sandbox);
let result = tool
.execute(&serde_json::json!({"command": "echo ignored"}), &ctx)
.await
.unwrap();
assert!(
result.success,
"sandbox delegation should succeed: {}",
result.content
);
assert!(result.content.contains("sandbox output"));
let metadata = result.metadata.unwrap();
assert_eq!(metadata["exit_code"], 0);
assert_eq!(metadata["sandboxed"], true);
}
struct WorkspaceWritingSandbox {
root: PathBuf,
}
#[async_trait]
impl BashSandbox for WorkspaceWritingSandbox {
async fn exec_command(
&self,
_command: &str,
_guest_workspace: &str,
) -> anyhow::Result<SandboxOutput> {
anyhow::bail!("the bash tool must use the extended sandbox contract")
}
async fn exec(
&self,
_request: SandboxCommandRequest,
) -> anyhow::Result<SandboxExecutionOutput> {
std::fs::write(self.root.join("guest.txt"), "from sandbox\n").unwrap();
Ok(SandboxExecutionOutput {
stdout: "wrote".into(),
stderr: String::new(),
exit_code: 0,
timed_out: false,
})
}
async fn shutdown(&self) {}
}
#[tokio::test]
async fn sandbox_bash_records_changed_paths_from_the_workspace_not_the_command() {
let root = tempfile::tempdir().unwrap();
let git = |args: &[&str]| {
let status = std::process::Command::new("git")
.args(args)
.current_dir(root.path())
.status()
.unwrap();
assert!(status.success(), "{args:?}");
};
git(&["init"]);
git(&["config", "user.email", "test@example.com"]);
git(&["config", "user.name", "test"]);
std::fs::write(root.path().join("README.md"), "base\n").unwrap();
git(&["add", "README.md"]);
git(&["commit", "-m", "base"]);
let tool = BashTool;
let ctx = ToolContext::new(root.path().to_path_buf()).with_sandbox(Arc::new(
WorkspaceWritingSandbox {
root: root.path().to_path_buf(),
},
));
let result = tool
.execute(&serde_json::json!({"command": "printf done"}), &ctx)
.await
.unwrap();
assert!(result.success);
let paths = result.metadata.unwrap()["changed_paths"]
.as_array()
.cloned()
.unwrap_or_default();
assert!(
paths.iter().any(|path| path == "guest.txt"),
"sandbox mutation must be recorded without parsing the command"
);
}
#[tokio::test]
async fn default_sandbox_execution_preserves_timeout_env_and_streaming_contract() {
let tool = BashTool;
let called = Arc::new(AtomicBool::new(false));
let request = Arc::new(std::sync::Mutex::new(None));
let sandbox = Arc::new(RecordingExtendedSandbox {
called: Arc::clone(&called),
request: Arc::clone(&request),
output: SandboxExecutionOutput {
stdout: "sandbox result".to_string(),
stderr: String::new(),
exit_code: 0,
timed_out: false,
},
summary: CommandOutputSummary {
total_bytes: 23,
captured_bytes: 23,
truncated: false,
timed_out: false,
},
});
let temp = tempfile::tempdir().unwrap();
let (event_tx, mut event_rx) = tokio::sync::mpsc::channel(4);
let ctx = ToolContext::new(temp.path().to_path_buf())
.with_sandbox(sandbox)
.with_command_env(Arc::new(HashMap::from([(
"A3S_TEST_ENV".to_string(),
"visible".to_string(),
)])))
.with_event_tx(event_tx);
let result = tool
.execute(
&serde_json::json!({
"command": "printf result",
"timeout": 42,
"sandbox_permissions": "use_default"
}),
&ctx,
)
.await
.unwrap();
assert!(result.success, "{}", result.content);
assert!(called.load(Ordering::SeqCst));
assert_eq!(
request.lock().unwrap().as_ref(),
Some(&RecordedSandboxRequest {
command: "printf result".to_string(),
guest_workspace: "/workspace".to_string(),
timeout_ms: MIN_TIMEOUT_MS,
env: Some(HashMap::from([(
"A3S_TEST_ENV".to_string(),
"visible".to_string(),
)])),
had_output_observer: true,
})
);
assert!(matches!(
event_rx.recv().await,
Some(ToolStreamEvent::OutputDelta(delta))
if delta == "streamed sandbox output"
));
let metadata = result.metadata.unwrap();
assert_eq!(metadata["sandboxed"], true);
assert_eq!(metadata["output"]["total_bytes"], 23);
}
#[tokio::test]
async fn escalated_execution_requires_a_justification() {
let tool = BashTool;
let temp = tempfile::tempdir().unwrap();
let ctx = ToolContext::new(temp.path().to_path_buf());
let result = tool
.execute(
&serde_json::json!({
"command": "printf host",
"sandbox_permissions": "require_escalated"
}),
&ctx,
)
.await
.unwrap();
assert!(!result.success);
assert!(result.content.contains("justification is required"));
}
#[tokio::test]
async fn default_execution_fails_closed_without_a_sandbox() {
let tool = BashTool;
let temp = tempfile::tempdir().unwrap();
let marker = temp.path().join("must-not-exist");
let ctx = ToolContext::new(temp.path().to_path_buf()).with_run_governance(None, None);
let result = tool
.execute(
&serde_json::json!({"command": "printf escaped > must-not-exist"}),
&ctx,
)
.await
.unwrap();
assert!(!result.success);
assert!(result.content.contains("requires a configured sandbox"));
assert!(!marker.exists());
let metadata = result.metadata.unwrap();
assert_eq!(metadata["sandbox_available"], false);
assert_eq!(metadata["sandboxed"], false);
}
#[tokio::test]
#[cfg(not(windows))]
async fn escalated_execution_skips_the_configured_sandbox() {
let tool = BashTool;
let called = Arc::new(AtomicBool::new(false));
let request = Arc::new(std::sync::Mutex::new(None));
let sandbox = Arc::new(RecordingExtendedSandbox {
called: Arc::clone(&called),
request,
output: SandboxExecutionOutput {
stdout: "wrong boundary".to_string(),
stderr: String::new(),
exit_code: 0,
timed_out: false,
},
summary: CommandOutputSummary {
total_bytes: 0,
captured_bytes: 0,
truncated: false,
timed_out: false,
},
});
let temp = tempfile::tempdir().unwrap();
let ctx = ToolContext::new(temp.path().to_path_buf()).with_sandbox(sandbox);
let result = tool
.execute(
&serde_json::json!({
"command": "printf host",
"sandbox_permissions": "require_escalated",
"justification": "The exact command needs an approved host capability."
}),
&ctx,
)
.await
.unwrap();
assert!(result.success, "{}", result.content);
assert_eq!(result.content, "host");
assert!(!called.load(Ordering::SeqCst));
assert_eq!(result.metadata.unwrap()["sandboxed"], false);
}
#[tokio::test]
async fn sandbox_timeout_uses_the_sandbox_result_and_metadata() {
let tool = BashTool;
let sandbox = Arc::new(RecordingExtendedSandbox {
called: Arc::new(AtomicBool::new(false)),
request: Arc::new(std::sync::Mutex::new(None)),
output: SandboxExecutionOutput {
stdout: "partial".to_string(),
stderr: String::new(),
exit_code: -1,
timed_out: true,
},
summary: CommandOutputSummary {
total_bytes: 7,
captured_bytes: 7,
truncated: false,
timed_out: true,
},
});
let temp = tempfile::tempdir().unwrap();
let ctx = ToolContext::new(temp.path().to_path_buf()).with_sandbox(sandbox);
let result = tool
.execute(
&serde_json::json!({"command": "slow", "timeout": 1_500}),
&ctx,
)
.await
.unwrap();
assert!(!result.success);
assert!(result.content.contains("timed out after 1500ms"));
assert!(matches!(
result.error_kind,
Some(ToolErrorKind::Timeout {
ref op,
duration_ms: 1_500
}) if op == "bash"
));
let metadata = result.metadata.unwrap();
assert_eq!(metadata["sandboxed"], true);
assert_eq!(metadata["timeout_ms"], 1_500);
assert_eq!(metadata["output"]["timed_out"], true);
}
#[tokio::test(start_paused = true)]
async fn sandbox_execution_is_bounded_when_the_backend_ignores_the_timeout() {
let tool = BashTool;
let temp = tempfile::tempdir().unwrap();
let ctx = ToolContext::new(temp.path().to_path_buf()).with_sandbox(Arc::new(HangingSandbox));
const PORCELAIN_GIT_BUDGET_MS: u64 = 400 * 4;
let result = tokio::time::timeout(
std::time::Duration::from_millis(MIN_TIMEOUT_MS + PORCELAIN_GIT_BUDGET_MS),
tool.execute(
&serde_json::json!({"command": "hang forever", "timeout": MIN_TIMEOUT_MS}),
&ctx,
),
)
.await
.expect("the bash tool must enforce its own sandbox deadline")
.unwrap();
assert!(!result.success);
assert!(result.content.contains("timed out after 1000ms"));
assert!(matches!(
result.error_kind,
Some(ToolErrorKind::Timeout {
ref op,
duration_ms: MIN_TIMEOUT_MS
}) if op == "bash"
));
let metadata = result.metadata.unwrap();
assert_eq!(metadata["sandboxed"], true);
assert_eq!(metadata["timeout_ms"], MIN_TIMEOUT_MS);
}
#[tokio::test]
async fn test_bash_sandbox_combines_stderr() {
let tool = BashTool;
let sandbox = Arc::new(MockSandbox {
stdout: "out\n".into(),
stderr: "err\n".into(),
exit_code: 0,
});
let workspace = tempfile::tempdir().unwrap();
let ctx = ToolContext::new(workspace.path().to_path_buf()).with_sandbox(sandbox);
let result = tool
.execute(&serde_json::json!({"command": "ls"}), &ctx)
.await
.unwrap();
assert!(
result.content.contains("out"),
"sandbox stdout should be returned: {}",
result.content
);
assert!(result.content.contains("err"));
}
#[tokio::test]
async fn test_bash_sandbox_nonzero_exit() {
let tool = BashTool;
let sandbox = Arc::new(MockSandbox {
stdout: String::new(),
stderr: "not found\n".into(),
exit_code: 127,
});
let workspace = tempfile::tempdir().unwrap();
let ctx = ToolContext::new(workspace.path().to_path_buf()).with_sandbox(sandbox);
let result = tool
.execute(&serde_json::json!({"command": "nonexistent"}), &ctx)
.await
.unwrap();
assert!(!result.success);
assert_eq!(result.metadata.unwrap()["exit_code"], 127);
}
#[tokio::test]
async fn test_bash_echo() {
#[cfg(windows)]
let _permit = crate::test_support::resource_intensive_test_permit().await;
let tool = BashTool;
let temp = tempfile::tempdir().unwrap();
let ctx = ToolContext::new(temp.path().to_path_buf());
let result = tool
.execute(&escalated_args("echo hello"), &ctx)
.await
.unwrap();
assert!(result.success);
assert!(result.content.contains("hello"));
}
#[tokio::test]
#[cfg(not(windows))]
async fn test_bash_tiny_timeout_is_clamped() {
let tool = BashTool;
let temp = tempfile::tempdir().unwrap();
let ctx = ToolContext::new(temp.path().to_path_buf());
let result = tool
.execute(
&escalated_args_with_timeout("sleep 0.05; printf done", 1),
&ctx,
)
.await
.unwrap();
assert!(result.success, "{}", result.content);
assert_eq!(result.content.trim_end(), "done");
}
#[tokio::test]
#[cfg(not(windows))]
async fn test_dropping_bash_execution_kills_shell_before_later_side_effects() {
let _permit = crate::test_support::resource_intensive_test_permit().await;
let tool = BashTool;
let temp = tempfile::tempdir().unwrap();
let ctx = ToolContext::new(temp.path().to_path_buf());
let started = temp.path().join("started");
let leaked = temp.path().join("leaked");
let execution = tokio::spawn(async move {
tool.execute(
&escalated_args("printf started > started; (sleep 1; printf leaked > leaked) & wait"),
&ctx,
)
.await
});
tokio::time::timeout(std::time::Duration::from_secs(2), async {
while !started.exists() {
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
})
.await
.expect("shell should start before cancellation");
execution.abort();
let _ = execution.await;
tokio::time::sleep(std::time::Duration::from_millis(1_200)).await;
assert!(
!leaked.exists(),
"a cancelled bash execution must not continue later shell side effects"
);
}
#[tokio::test]
#[cfg(not(windows))]
async fn test_bash_bounds_long_single_line_and_reports_exact_capture_metadata() {
let tool = BashTool;
let temp = tempfile::tempdir().unwrap();
let ctx = ToolContext::new(temp.path().to_path_buf());
let result = tool
.execute(&escalated_args("printf '%*s' 120000 '' | tr ' ' x"), &ctx)
.await
.unwrap();
assert!(result.success, "{}", result.content);
assert!(result.content.contains("command output truncated"));
assert!(result.content.len() < 110_000);
let output = &result.metadata.unwrap()["output"];
assert_eq!(output["total_bytes"], 120_000);
assert_eq!(output["captured_bytes"], crate::tools::MAX_OUTPUT_SIZE);
assert_eq!(output["truncated"], true);
assert_eq!(output["timed_out"], false);
}
#[tokio::test]
#[cfg(windows)]
async fn test_bash_head_compat_shim() {
let _permit = crate::test_support::resource_intensive_test_permit().await;
let tool = BashTool;
let temp = tempfile::tempdir().unwrap();
let ctx = ToolContext::new(temp.path().to_path_buf());
let result = tool
.execute(&escalated_args("1..5 | head -2"), &ctx)
.await
.unwrap();
assert!(result.success);
assert_eq!(result.content.lines().collect::<Vec<_>>(), vec!["1", "2"]);
}
#[tokio::test]
#[cfg(windows)]
async fn missing_powershell_fails_closed_without_cmd_fallback() {
let temp = tempfile::tempdir().unwrap();
let marker = temp.path().join("cmd-fallback-marker.txt");
let missing = temp.path().join("missing-powershell.exe");
let result = spawn_windows_shell(
missing.as_os_str(),
"echo fallback>cmd-fallback-marker.txt",
temp.path(),
None,
);
let error = match result {
Ok(mut child) => {
let _ = child.wait().await;
panic!(
"a missing PowerShell executable must fail closed; cmd fallback created marker: {}",
marker.exists()
);
}
Err(error) => error,
};
assert_eq!(error.kind(), std::io::ErrorKind::NotFound);
assert!(error.to_string().contains("refusing to reinterpret"));
assert!(!marker.exists());
}
#[tokio::test]
#[cfg(windows)]
async fn test_bash_json_normalizer_repairs_unquoted_object_literal() {
let _permit = crate::test_support::resource_intensive_test_permit().await;
let tool = BashTool;
let temp = tempfile::tempdir().unwrap();
let ctx = ToolContext::new(temp.path().to_path_buf());
let result = tool
.execute(
&escalated_args("Write-Output (__a3s_normalize_json_like '{image:nginx:alpine,name:mock-nginx,port_map:[18080:80],start:true}')"),
&ctx,
)
.await
.unwrap();
assert!(result.success);
assert_eq!(
result.content.trim_end(),
r#"{"image":"nginx:alpine","name":"mock-nginx","port_map":["18080:80"],"start":true}"#
);
}
#[tokio::test]
#[cfg(windows)]
async fn test_bash_json_normalizer_preserves_valid_json() {
let _permit = crate::test_support::resource_intensive_test_permit().await;
let tool = BashTool;
let temp = tempfile::tempdir().unwrap();
let ctx = ToolContext::new(temp.path().to_path_buf());
let result = tool
.execute(
&escalated_args(r#"Write-Output (__a3s_normalize_json_like '{"image":"nginx:alpine","name":"mock-nginx","port_map":["18080:80"],"start":true}')"#),
&ctx,
)
.await
.unwrap();
assert!(result.success);
assert_eq!(
result.content.trim_end(),
r#"{"image":"nginx:alpine","name":"mock-nginx","port_map":["18080:80"],"start":true}"#
);
}
#[tokio::test]
#[cfg(windows)]
async fn test_bash_curl_json_literal_is_normalized_end_to_end() {
use std::io::{Read, Write};
use std::net::TcpListener;
use std::time::{Duration, Instant};
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
let port = listener.local_addr().unwrap().port();
let server = std::thread::spawn(move || {
let deadline = Instant::now() + Duration::from_secs(15);
let mut stream = loop {
match listener.accept() {
Ok((stream, _)) => break stream,
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
assert!(
Instant::now() < deadline,
"curl never connected to test server"
);
std::thread::sleep(Duration::from_millis(10));
}
Err(error) => panic!("test server accept failed: {error}"),
}
};
stream
.set_read_timeout(Some(Duration::from_secs(5)))
.unwrap();
let mut buf = vec![0u8; 8192];
let n = stream.read(&mut buf).unwrap();
let request = String::from_utf8_lossy(&buf[..n]).to_string();
let split = request.find("\r\n\r\n").unwrap();
let payload = request[(split + 4)..].to_string();
let response =
b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\n\r\n{\"ok\":true}";
stream.write_all(response).unwrap();
payload
});
let tool = BashTool;
let temp = tempfile::tempdir().unwrap();
let ctx = ToolContext::new(temp.path().to_path_buf());
let command = format!(
"curl.exe -sS -X POST \"http://127.0.0.1:{port}/capture\" -H \"Content-Type: application/json\" --data-raw '{{image:nginx:alpine,name:mock-nginx,port_map:[18080:80],start:true}}'"
);
assert!(parse_simple_windows_http_command(&command).is_some());
let result = tool
.execute(&escalated_args_with_timeout(command, 15_000), &ctx)
.await
.unwrap();
let body = server.join().unwrap();
assert!(result.success, "{}", result.content);
assert_eq!(
body,
r#"{"image":"nginx:alpine","name":"mock-nginx","port_map":["18080:80"],"start":true}"#
);
}
#[tokio::test]
async fn test_bash_inherits_command_env() {
#[cfg(windows)]
let _permit = crate::test_support::resource_intensive_test_permit().await;
let tool = BashTool;
let temp = tempfile::tempdir().unwrap();
let ctx = ToolContext::new(temp.path().to_path_buf()).with_command_env(std::sync::Arc::new(
HashMap::from([("A3S_TEST_ENV".to_string(), "visible".to_string())]),
));
#[cfg(windows)]
let command = "Write-Output $env:A3S_TEST_ENV";
#[cfg(not(windows))]
let command = "printf '%s' \"$A3S_TEST_ENV\"";
let result = tool.execute(&escalated_args(command), &ctx).await.unwrap();
assert!(result.success);
assert_eq!(result.content.trim_end(), "visible");
}
#[tokio::test]
#[cfg(not(windows))]
async fn test_bash_exit_code() {
let tool = BashTool;
let temp = tempfile::tempdir().unwrap();
let ctx = ToolContext::new(temp.path().to_path_buf());
let result = tool.execute(&escalated_args("exit 1"), &ctx).await.unwrap();
assert!(!result.success);
assert_eq!(
result.metadata.as_ref().unwrap()["exit_code"]
.as_i64()
.unwrap(),
1
);
}
#[tokio::test]
#[cfg(windows)]
async fn test_bash_exit_code() {
let _permit = crate::test_support::resource_intensive_test_permit().await;
let tool = BashTool;
let temp = tempfile::tempdir().unwrap();
let ctx = ToolContext::new(temp.path().to_path_buf());
let result = tool.execute(&escalated_args("exit 1"), &ctx).await.unwrap();
assert!(!result.success);
assert_eq!(
result.metadata.as_ref().unwrap()["exit_code"]
.as_i64()
.unwrap(),
1
);
}
#[tokio::test]
async fn test_bash_missing_command() {
let tool = BashTool;
let temp = tempfile::tempdir().unwrap();
let ctx = ToolContext::new(temp.path().to_path_buf());
let result = tool.execute(&serde_json::json!({}), &ctx).await.unwrap();
assert!(!result.success);
assert!(result.content.contains("command"));
}
#[test]
fn test_bash_schema_is_canonical() {
let tool = BashTool;
let params = tool.parameters();
assert_eq!(params["additionalProperties"], false);
assert_eq!(params["required"], serde_json::json!(["command"]));
let examples = params["examples"].as_array().unwrap();
assert_eq!(
examples[0]["command"],
"cargo test -p a3s-code-core skill::"
);
assert!(examples[0].get("cmd").is_none());
assert!(!tool.requires_confirmation(&serde_json::json!({
"command": "cargo test",
"sandbox_permissions": "use_default"
})));
assert!(tool.requires_confirmation(&serde_json::json!({
"command": "cargo test",
"sandbox_permissions": "require_escalated",
"justification": "Needs an approved host capability."
})));
}
#[test]
#[cfg(windows)]
fn test_build_powershell_command_wraps_with_compat_shim() {
let wrapped = build_powershell_command("curl -s http://127.0.0.1:18790/health | head -5");
assert!(wrapped.contains("function curl"));
assert!(wrapped.contains("function GET"));
assert!(wrapped.contains("function head"));
assert!(wrapped.contains("curl --% -s http://127.0.0.1:18790/health | head -5"));
}
#[test]
#[cfg(windows)]
fn test_preprocess_windows_command_wraps_curl_json_literal() {
let command = r#"curl.exe -sS -X POST "http://127.0.0.1:18790/api" -H "Content-Type: application/json" --data-raw {image:nginx:alpine,name:mock-nginx,port_map:[18080:80],start:true}"#;
let processed = preprocess_windows_command(command);
assert!(processed.contains(
r#"curl.exe --% -sS -X POST "http://127.0.0.1:18790/api" -H "Content-Type: application/json" --data-raw {"image":"nginx:alpine","name":"mock-nginx","port_map":["18080:80"],"start":true}"#
));
}
#[test]
#[cfg(windows)]
fn test_normalize_json_like_literal_repairs_object_literal() {
let normalized = normalize_json_like_literal(
"{image:nginx:alpine,name:mock-nginx,port_map:[18080:80],start:true}",
)
.unwrap();
assert_eq!(
normalized,
r#"{"image":"nginx:alpine","name":"mock-nginx","port_map":["18080:80"],"start":true}"#
);
}
#[tokio::test]
#[cfg(not(windows))]
async fn test_bash_workspace_dir() {
let temp = tempfile::tempdir().unwrap();
let tool = BashTool;
let ctx = ToolContext::new(temp.path().to_path_buf());
let result = tool.execute(&escalated_args("pwd"), &ctx).await.unwrap();
assert!(result.success);
let canonical = temp.path().canonicalize().unwrap();
assert!(result
.content
.contains(&canonical.to_string_lossy().to_string()));
}
#[tokio::test]
#[cfg(windows)]
async fn test_bash_workspace_dir() {
let _permit = crate::test_support::resource_intensive_test_permit().await;
let temp = tempfile::tempdir().unwrap();
let tool = BashTool;
let ctx = ToolContext::new(temp.path().to_path_buf());
let result = tool
.execute(
&escalated_args("Get-Location | Select-Object -ExpandProperty Path"),
&ctx,
)
.await
.unwrap();
assert!(result.success);
let canonical = temp.path().canonicalize().unwrap();
let canonical_str = canonical
.to_string_lossy()
.trim_start_matches(r"\\?\")
.to_lowercase();
assert!(result.content.to_lowercase().contains(&canonical_str));
}
#[test]
fn prefix_session_cwd_without_session_leaves_command_unmodified() {
let temp = tempfile::tempdir().unwrap();
let ctx = ToolContext::new(temp.path().to_path_buf());
assert_eq!(prefix_session_cwd("pwd", &ctx), "pwd");
}
#[test]
fn shell_admission_denies_when_run_checker_denies() {
struct DenyBash;
impl crate::permissions::PermissionChecker for DenyBash {
fn check(&self, _: &str, _: &serde_json::Value) -> crate::permissions::PermissionDecision {
crate::permissions::PermissionDecision::Deny
}
}
let temp = tempfile::tempdir().unwrap();
let ctx = ToolContext::new(temp.path().to_path_buf())
.with_run_governance(Some(Arc::new(DenyBash)), None);
assert!(matches!(
shell_admission(&ctx, "pwd"),
crate::shell_session::CommandAdmission::Deny
));
}
#[test]
fn porcelain_diff_records_only_paths_that_changed() {
let before = vec![" M keep.rs".to_string()];
let after = vec![" M keep.rs".to_string(), "?? src/new.rs".to_string()];
assert_eq!(
changed_paths_from_porcelain(&before, &after),
vec!["src/new.rs".to_string()]
);
assert!(
changed_paths_from_porcelain(&["?? README.md".to_string()], &[])
.contains(&"README.md".to_string())
);
}
#[cfg(test)]
struct DenyNamedCommand;
#[cfg(test)]
impl crate::permissions::PermissionChecker for DenyNamedCommand {
fn check(&self, _: &str, args: &serde_json::Value) -> crate::permissions::PermissionDecision {
let command = args
.get("command")
.and_then(|value| value.as_str())
.unwrap_or("");
if command.contains("leaked") {
crate::permissions::PermissionDecision::Deny
} else {
crate::permissions::PermissionDecision::Allow
}
}
}
#[test]
fn denied_second_command_does_not_move_the_same_shell() {
let root = tempfile::tempdir().unwrap();
let nested = root.path().join("nested");
std::fs::create_dir(&nested).unwrap();
let session = format!("bash-deny-{}", std::process::id());
crate::shell_session::bind_session(&session, root.path());
let allow = ToolContext::new(root.path().to_path_buf()).with_session_id(&session);
prefix_session_cwd("cd nested", &allow);
assert_eq!(crate::shell_session::cwd(&session).unwrap(), nested);
let deny = allow.with_run_governance(Some(Arc::new(DenyNamedCommand)), None);
let rejected = prefix_session_cwd("cd leaked", &deny);
assert_eq!(rejected, "cd leaked");
assert_eq!(crate::shell_session::cwd(&session).unwrap(), nested);
assert!(!nested.join("leaked").exists());
crate::shell_session::drop_session(&session);
}
#[tokio::test]
async fn detached_job_write_opens_the_parent_gate() {
let root = tempfile::tempdir().unwrap();
let session = format!("bash-detach-{}", std::process::id());
crate::shell_session::bind_session(&session, root.path());
let tool = BashTool;
let ctx = ToolContext::new(root.path().to_path_buf()).with_session_id(&session);
let output = tool
.execute(
&serde_json::json!({
"job_action": "detach",
"command": "sleep 0.2; printf 'hello\\n' > guest.txt"
}),
&ctx,
)
.await
.unwrap();
assert!(output.success, "detach failed: {output:#?}");
let metadata = output.metadata.expect("detach metadata");
let job_id = metadata["workspace_child"]
.as_str()
.expect("workspace_child");
let mut ledger = crate::harness_loop::MutationLedger::default();
ledger.observe_tool("bash", 0, Some(&metadata));
assert!(
ledger.has_open_children() || !ledger.is_empty(),
"detached job did not open a parent effect: {metadata}"
);
if ledger.has_open_children() {
assert!(matches!(
crate::harness_loop::decide_completion(&ledger, &[], &[], true),
crate::harness_loop::CompletionGate::Incomplete { .. }
));
}
tokio::time::timeout(
std::time::Duration::from_secs(5),
crate::harness_loop::absorb_open_workspace_children(
&mut ledger,
root.path(),
&tokio_util::sync::CancellationToken::new(),
),
)
.await
.expect("detached job should settle");
crate::shell_session::drop_session(&session);
assert!(root.path().join("guest.txt").is_file(), "job did not write");
assert!(
ledger.paths().any(|path| path == "guest.txt"),
"parent gate missed detached write {job_id}; ledger paths missing"
);
assert!(!ledger.has_open_children());
}
#[tokio::test]
async fn detached_job_write_stays_owned_by_the_parent_session() {
let root = tempfile::tempdir().unwrap();
let session = format!("bash-detach-own-{}", std::process::id());
crate::shell_session::bind_session(&session, root.path());
let ctx = ToolContext::new(root.path().to_path_buf()).with_session_id(&session);
let output = BashTool
.execute(
&serde_json::json!({
"job_action": "detach",
"command": "printf 'owned-by-detach\\n' > guest.txt"
}),
&ctx,
)
.await
.unwrap();
assert!(output.success, "detach failed: {output:#?}");
let appeared = tokio::time::timeout(std::time::Duration::from_secs(5), async {
loop {
if root.path().join("guest.txt").is_file() {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
})
.await;
assert!(appeared.is_ok(), "detached job did not write");
let owned = tokio::time::timeout(std::time::Duration::from_secs(5), async {
loop {
if crate::external_observation::session_owns_write(&session, root.path(), "guest.txt") {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
})
.await;
let late = format!("bash-detach-late-{}", std::process::id());
let blocked =
crate::external_observation::claim_bound_write(Some(&late), root.path(), "guest.txt");
crate::external_observation::release_session(&session);
crate::external_observation::release_session(&late);
crate::shell_session::drop_session(&session);
assert!(
owned.is_ok(),
"a finished detached write must stay owned by the parent session"
);
assert!(
blocked.is_err(),
"another session must not claim a path a detached job already wrote"
);
}
struct OverwriteIfCalled {
root: PathBuf,
called: Arc<AtomicBool>,
bytes: &'static str,
}
#[async_trait]
impl BashSandbox for OverwriteIfCalled {
async fn exec_command(
&self,
_command: &str,
_guest_workspace: &str,
) -> anyhow::Result<SandboxOutput> {
anyhow::bail!("the bash tool must use the extended sandbox contract")
}
async fn exec(
&self,
_request: SandboxCommandRequest,
) -> anyhow::Result<SandboxExecutionOutput> {
self.called.store(true, Ordering::SeqCst);
std::fs::write(self.root.join("guest.txt"), self.bytes).unwrap();
Ok(SandboxExecutionOutput {
stdout: "wrote".into(),
stderr: String::new(),
exit_code: 0,
timed_out: false,
})
}
async fn shutdown(&self) {}
}
#[tokio::test]
async fn bash_owns_a_path_it_dirtied_so_another_session_cannot_overwrite_it() {
let root = tempfile::tempdir().unwrap();
let called = Arc::new(AtomicBool::new(false));
let owner = ToolContext::new(root.path().to_path_buf())
.with_session_id("bash-writer")
.with_sandbox(Arc::new(OverwriteIfCalled {
root: root.path().to_path_buf(),
called: Arc::clone(&called),
bytes: "bash-token-4c91",
}));
let written = BashTool
.execute(&serde_json::json!({"command": "overwrite"}), &owner)
.await
.unwrap();
assert!(written.success, "{}", written.content);
assert!(called.load(Ordering::SeqCst));
assert_eq!(
std::fs::read_to_string(root.path().join("guest.txt")).unwrap(),
"bash-token-4c91"
);
let blocked =
crate::external_observation::claim_bound_write(Some("bash-late"), root.path(), "guest.txt");
assert!(
blocked.is_err(),
"bash must own the path it changed, not leave it for another session"
);
assert_eq!(
std::fs::read_to_string(root.path().join("guest.txt")).unwrap(),
"bash-token-4c91"
);
crate::external_observation::release_session("bash-writer");
crate::external_observation::release_session("bash-late");
}
#[tokio::test]
async fn bash_does_not_hide_another_sessions_dirty_path() {
let root = tempfile::tempdir().unwrap();
let guest = root.path().join("guest.txt");
std::fs::write(&guest, "owned-token-7e2c").unwrap();
crate::external_observation::claim_bound_write(Some("bash-owner"), root.path(), "guest.txt")
.unwrap();
let hidden = Arc::new(AtomicBool::new(false));
let other = ToolContext::new(root.path().to_path_buf())
.with_session_id("bash-other")
.with_sandbox(Arc::new(OverwriteIfCalled {
root: root.path().to_path_buf(),
called: Arc::clone(&hidden),
bytes: "hidden-token-7e2c",
}));
let denied = BashTool
.execute(&serde_json::json!({"command": "overwrite"}), &other)
.await
.unwrap();
assert!(!denied.success);
assert!(!hidden.load(Ordering::SeqCst), "foreign bash ran");
assert_eq!(std::fs::read_to_string(&guest).unwrap(), "owned-token-7e2c");
let unbound_called = Arc::new(AtomicBool::new(false));
let unbound =
ToolContext::new(root.path().to_path_buf()).with_sandbox(Arc::new(OverwriteIfCalled {
root: root.path().to_path_buf(),
called: Arc::clone(&unbound_called),
bytes: "unbound-token-7e2c",
}));
let unbound_denied = BashTool
.execute(&serde_json::json!({"command": "overwrite"}), &unbound)
.await
.unwrap();
assert!(!unbound_denied.success);
assert!(
!unbound_called.load(Ordering::SeqCst),
"unbound bash hid a claimed path"
);
assert_eq!(std::fs::read_to_string(&guest).unwrap(), "owned-token-7e2c");
let owner_called = Arc::new(AtomicBool::new(false));
let owner = ToolContext::new(root.path().to_path_buf())
.with_session_id("bash-owner")
.with_sandbox(Arc::new(OverwriteIfCalled {
root: root.path().to_path_buf(),
called: Arc::clone(&owner_called),
bytes: "owner-applied-7e2c",
}));
let applied = BashTool
.execute(&serde_json::json!({"command": "overwrite"}), &owner)
.await
.unwrap();
assert!(
applied.success,
"owner bash should still apply: {applied:?}"
);
assert!(owner_called.load(Ordering::SeqCst));
assert_eq!(
std::fs::read_to_string(&guest).unwrap(),
"owner-applied-7e2c"
);
crate::external_observation::release_session("bash-owner");
}
#[tokio::test]
async fn bash_existence_check_metadata_enables_verified_completion() {
use crate::harness_loop::{
decide_completion, CompletionGate, CompletionTerminal, MutationLedger,
};
use crate::verification::host_report_for_verified_mutation_path_with_content;
let root = tempfile::tempdir().unwrap();
std::fs::write(root.path().join("guest.txt"), "hello\n").unwrap();
let output = BashTool
.execute(
&serde_json::json!({ "command": "test -f guest.txt" }),
&ToolContext::new(root.path().to_path_buf()),
)
.await
.unwrap();
assert!(output.success, "existence check failed: {output:#?}");
let metadata = output.metadata.expect("bash metadata");
assert_eq!(
metadata
.get("verification_shell_command")
.and_then(|v| v.as_str()),
Some("test -f guest.txt"),
"bash must retain the exact host check command for gate binding: {metadata}"
);
let mut ledger = MutationLedger::default();
ledger.observe_tool(
"write",
0,
Some(&serde_json::json!({"file_path": "guest.txt", "after": "hello\n"})),
);
let digest = ledger.digest().to_string();
let expected = ledger
.content_digest_for_path("guest.txt")
.expect("write records content digest")
.to_string();
let report = host_report_for_verified_mutation_path_with_content(
metadata["verification_shell_command"].as_str().unwrap(),
0,
&["guest.txt".to_string()],
&digest,
Some((expected.as_str(), expected.as_str())),
)
.expect("mutated-path existence check should synthesize a host report");
match decide_completion(&ledger, &[report], &[], false) {
CompletionGate::Allow(CompletionTerminal::Verified { effect_digest }) => {
assert_eq!(effect_digest, digest);
}
other => panic!("expected Allow(Verified), got {other:?}"),
}
}
#[test]
fn bash_tool_name_and_description_are_stable() {
let tool = BashTool;
assert_eq!(tool.name(), "bash");
assert!(tool.description().contains("shell command"));
let params = tool.parameters();
assert_eq!(params["required"], serde_json::json!(["command"]));
assert!(tool.requires_confirmation(&serde_json::json!({
"sandbox_permissions": "require_escalated"
})));
assert!(!tool.requires_confirmation(&serde_json::json!({
"sandbox_permissions": "use_default"
})));
}
#[tokio::test]
async fn unsupported_sandbox_permissions_value_is_rejected() {
let tool = BashTool;
let temp = tempfile::tempdir().unwrap();
let ctx = ToolContext::new(temp.path().to_path_buf());
let result = tool
.execute(
&serde_json::json!({
"command": "printf host",
"sandbox_permissions": "totally_custom"
}),
&ctx,
)
.await
.unwrap();
assert!(!result.success);
assert!(result
.content
.contains("unsupported sandbox_permissions value"));
}
#[tokio::test]
async fn unsupported_job_action_requires_bound_shell_session() {
let tool = BashTool;
let root = tempfile::tempdir().unwrap();
let session = format!("bash-unsupported-action-{}", std::process::id());
crate::shell_session::bind_session(&session, root.path());
let ctx = ToolContext::new(root.path().to_path_buf()).with_session_id(&session);
let result = tool
.execute(&serde_json::json!({"job_action": "restart"}), &ctx)
.await
.unwrap();
assert!(!result.success);
assert!(result.content.contains("unsupported job_action"));
crate::shell_session::drop_session(&session);
}
#[tokio::test]
#[cfg(not(windows))]
async fn poll_and_kill_job_actions_work_for_detached_jobs() {
let root = tempfile::tempdir().unwrap();
let session = format!("bash-poll-kill-{}", std::process::id());
crate::shell_session::bind_session(&session, root.path());
let tool = BashTool;
let ctx = ToolContext::new(root.path().to_path_buf()).with_session_id(&session);
let detached = tool
.execute(
&serde_json::json!({
"job_action": "detach",
"command": "sleep 30"
}),
&ctx,
)
.await
.unwrap();
assert!(detached.success, "{detached:?}");
let job_id = detached.metadata.unwrap()["job_id"]
.as_str()
.expect("job_id")
.to_string();
let polled = tool
.execute(
&serde_json::json!({"job_action": "poll", "job_id": job_id}),
&ctx,
)
.await
.unwrap();
assert!(polled.success, "{polled:?}");
assert!(
polled.content == "running" || polled.content == "done",
"unexpected poll status: {}",
polled.content
);
let killed = tool
.execute(
&serde_json::json!({"job_action": "kill", "job_id": job_id}),
&ctx,
)
.await
.unwrap();
assert!(killed.success, "{killed:?}");
assert_eq!(killed.content.trim(), "killed");
crate::shell_session::drop_session(&session);
}
#[tokio::test]
async fn require_escalated_without_justification_is_rejected() {
let tool = BashTool;
let temp = tempfile::tempdir().unwrap();
let ctx = ToolContext::new(temp.path().to_path_buf());
let result = tool
.execute(
&serde_json::json!({
"command": "printf host",
"sandbox_permissions": "require_escalated",
"justification": " "
}),
&ctx,
)
.await
.unwrap();
assert!(!result.success);
assert!(result
.content
.contains("justification is required when sandbox_permissions is require_escalated"));
}
#[tokio::test]
async fn job_actions_without_bound_session_fail_closed() {
let tool = BashTool;
let temp = tempfile::tempdir().unwrap();
let ctx = ToolContext::new(temp.path().to_path_buf());
let result = tool
.execute(
&serde_json::json!({ "job_action": "poll", "job_id": "missing" }),
&ctx,
)
.await
.unwrap();
assert!(!result.success);
}
#[tokio::test]
async fn missing_command_parameter_returns_tool_error() {
let tool = BashTool;
let temp = tempfile::tempdir().unwrap();
let ctx = ToolContext::new(temp.path().to_path_buf());
let result = tool.execute(&serde_json::json!({}), &ctx).await.unwrap();
assert!(!result.success);
assert!(result.content.contains("command parameter is required"));
}