use super::check::*;
use super::*;
#[allow(unused_imports)]
use crate::sync_util::LockExt;
use tokio::process::Command;
use tokio::time::Duration;
#[tokio::test]
async fn aborted_drain_task_still_finalizes_status() {
use crate::agent::tools::bg_shell::BackgroundShellStore;
let store = BackgroundShellStore::new();
let id = "abort-backstop-test".to_string();
store.register(id.clone(), "sleep 30".into());
let mut cmd = Command::new("sleep");
cmd.arg("30");
let handle = super::exec::spawn_streaming_shell(cmd, store.clone(), id.clone(), None);
tokio::time::sleep(Duration::from_millis(200)).await;
let (_, status) = store.read_new(&id).expect("shell registered");
assert!(status.is_running(), "shell should be running pre-abort");
handle.abort();
let _ = handle.await;
let (_, status) = store.read_new(&id).expect("shell still tracked");
assert!(
!status.is_running(),
"an aborted drain task must finalize the shell status",
);
}
#[tokio::test]
async fn background_bash_registers_shell_and_streams_output() {
use crate::agent::tools::BashArgs;
use crate::agent::tools::bg_shell::{BackgroundShellStore, ShellStatus};
let store = BackgroundShellStore::new();
let tool = BashTool::new(
None,
None,
crate::sandbox::Sandbox::new(crate::sandbox::SandboxMode::Off),
)
.with_shell_store(Some(store.clone()));
let res = tool
.call(BashArgs {
command: "echo bg-hello".to_string(),
timeout: None,
background: Some(true),
})
.await
.expect("background bash call");
assert!(
res.contains("background shell started"),
"expected an immediate start message, got: {res}"
);
let id = res
.split("id: ")
.nth(1)
.and_then(|s| s.split(['(', ' ']).next())
.expect("id in start message")
.to_string();
let mut out = String::new();
let mut exited = false;
for _ in 0..200 {
if let Some((chunk, status)) = store.read_new(&id) {
out.push_str(&chunk);
if !status.is_running() {
assert!(
matches!(status, ShellStatus::Exited(0)),
"status: {status:?}"
);
exited = true;
break;
}
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
assert!(exited, "background shell should exit");
assert!(
out.contains("bg-hello"),
"expected streamed output, got: {out}"
);
assert_eq!(store.running_count(), 0);
}
#[cfg(feature = "semantic-bash")]
fn rule(
op: crate::permission::OpSpec,
pattern: &str,
effect: crate::permission::Action,
) -> crate::permission::RuleConfig {
crate::permission::RuleConfig {
op,
pattern: pattern.to_string(),
effect,
tool: None,
}
}
#[tokio::test]
async fn run_with_timeout_kills_orphaned_child() {
let start = std::time::Instant::now();
let cmd = {
let mut c = Command::new("bash");
c.arg("-c").arg("sleep 5");
c
};
let result = run_with_timeout(cmd, 1).await;
let elapsed = start.elapsed();
assert!(result.is_err(), "expected timeout error, got {:?}", result);
let msg = format!("{:?}", result);
assert!(
msg.contains("timed out"),
"expected 'timed out' in error: {msg}",
);
assert!(
elapsed < Duration::from_secs(3),
"took too long to return: {:?}",
elapsed,
);
}
#[tokio::test]
async fn run_with_timeout_returns_output_on_success() {
let cmd = {
let mut c = Command::new("bash");
c.arg("-c").arg("echo hi");
c
};
let out = run_with_timeout(cmd, 5).await.expect("should succeed");
assert_eq!(out.merged.trim(), "hi");
}
#[tokio::test]
async fn run_with_timeout_interleaves_stdout_stderr() {
let cmd = {
let mut c = Command::new("bash");
c.arg("-c")
.arg(
"echo OUT-A; \
sleep 0.05; \
echo ERR-1 >&2; \
sleep 0.05; \
echo OUT-B; \
sleep 0.05; \
echo ERR-2 >&2",
);
c
};
let out = run_with_timeout(cmd, 5).await.expect("should succeed");
let lines: Vec<&str> = out.merged.lines().collect();
assert_eq!(
lines,
vec!["OUT-A", "ERR-1", "OUT-B", "ERR-2"],
"stdout/stderr should interleave by arrival",
);
}
#[cfg(unix)]
#[tokio::test]
async fn child_runs_in_its_own_session() {
use std::process::Stdio;
let mut cmd = Command::new("sleep");
cmd.arg("2")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
super::exec::detach_session(&mut cmd);
let child = cmd.spawn().expect("spawn sleep");
let pid = child.id().expect("child pid") as libc::pid_t;
let sid = unsafe { libc::getsid(pid) };
assert_eq!(sid, pid, "child must be its own session leader (setsid)");
let my_sid = unsafe { libc::getsid(0) };
assert_ne!(sid, my_sid, "child session must differ from parent's");
unsafe {
libc::kill(pid, libc::SIGKILL);
}
}
#[tokio::test]
async fn reading_dev_tty_fails_fast_not_timeout() {
let start = std::time::Instant::now();
let cmd = {
let mut c = Command::new("bash");
c.arg("-c").arg("cat /dev/tty");
c
};
let result = run_with_timeout(cmd, 10).await;
let elapsed = start.elapsed();
assert!(
elapsed < Duration::from_secs(3),
"reading /dev/tty should fail fast, not block to timeout (took {elapsed:?})",
);
match result {
Ok(out) => assert_ne!(out.exit_code, 0, "cat /dev/tty should fail"),
Err(e) => panic!("expected fast non-zero exit, got error: {e:?}"),
}
}
#[test]
fn quote_aware_split_keeps_semi_in_double_quotes() {
let segments = quote_aware_split(r#"echo "; rm -rf /""#);
assert_eq!(segments.len(), 1);
assert!(segments[0].contains("rm -rf /"));
}
#[test]
fn quote_aware_split_keeps_compound_in_single_quotes() {
let segments = quote_aware_split("echo 'a && b'");
assert_eq!(segments.len(), 1);
}
#[test]
fn quote_aware_split_respects_backslash_escape() {
let segments = quote_aware_split(r"echo \; ls");
assert_eq!(segments.len(), 1, "got: {:?}", segments);
}
#[test]
fn quote_aware_split_splits_unquoted_compounds() {
let segments = quote_aware_split("cmd1 && cmd2; cmd3 || cmd4");
assert_eq!(segments.len(), 4);
assert_eq!(segments[0], "cmd1");
assert_eq!(segments[1], "cmd2");
assert_eq!(segments[2], "cmd3");
assert_eq!(segments[3], "cmd4");
}
#[test]
fn quote_aware_split_splits_background_ampersand() {
let segments = quote_aware_split("safe_cmd & rm -rf /tmp/x");
assert_eq!(segments.len(), 2);
assert_eq!(segments[0], "safe_cmd");
assert_eq!(segments[1], "rm -rf /tmp/x");
}
#[test]
fn quote_aware_split_keeps_logical_and_separate_from_background() {
let segments = quote_aware_split("a && b & c");
assert_eq!(segments, vec!["a", "b", "c"]);
}
#[test]
fn quote_aware_split_splits_on_bare_pipe() {
let segments = quote_aware_split("safe_cmd | rm -rf /tmp/x");
assert_eq!(segments.len(), 2);
assert_eq!(segments[0].trim(), "safe_cmd");
assert_eq!(segments[1].trim(), "rm -rf /tmp/x");
}
#[test]
fn quote_aware_split_or_and_pipe_distinct() {
let segments = quote_aware_split("a || b | c");
assert_eq!(segments.len(), 3, "got {segments:?}");
assert_eq!(segments[0].trim(), "a");
assert_eq!(segments[1].trim(), "b");
assert_eq!(segments[2].trim(), "c");
}
#[test]
fn quote_aware_split_drops_empty_segments() {
let segments = quote_aware_split(";; cmd ;");
assert_eq!(segments, vec!["cmd"]);
}
#[test]
fn quote_aware_split_mixed_quoted_and_unquoted() {
let segments = quote_aware_split(r#"echo "a; b" ; ls"#);
assert_eq!(segments.len(), 2);
assert!(segments[0].contains("a; b"));
assert_eq!(segments[1], "ls");
}
#[cfg(feature = "semantic-bash")]
#[tokio::test]
async fn compound_command_denies_dangerous_segment() {
use crate::permission::{PermissionConfig, SecurityMode, checker::PermissionChecker};
let config = PermissionConfig::default();
let checker = PermissionChecker::new(&config, SecurityMode::Standard, None);
let perm = std::sync::Arc::new(std::sync::Mutex::new(checker));
let result = check_bash_segments(&Some(perm), &None, "git diff && rm -rf /").await;
assert!(
result.is_err(),
"compound: rm segment must hit deny rule even after safe git segment; got {result:?}",
);
let msg = format!("{:?}", result);
assert!(
msg.contains("denied") || msg.contains("Denied"),
"expected 'denied' in error: {msg}",
);
}
#[cfg(feature = "semantic-bash")]
#[tokio::test]
async fn redirect_target_routes_through_write_rules() {
use crate::permission::{
Action, OpSpec, PermissionConfig, SecurityMode, checker::PermissionChecker,
};
let config = PermissionConfig {
rules: vec![rule(OpSpec::Edit, "/etc/**", Action::Deny)],
..Default::default()
};
let checker = PermissionChecker::new(&config, SecurityMode::Standard, None);
let perm = std::sync::Arc::new(std::sync::Mutex::new(checker));
let result = check_bash_segments(&Some(perm), &None, "echo hi > /etc/passwd").await;
assert!(
result.is_err(),
"redirect to /etc/passwd should be denied by write rules; got {result:?}",
);
}
#[cfg(feature = "semantic-bash")]
#[tokio::test]
async fn rm_arg_path_routes_through_write_rules() {
use crate::permission::{
Action, OpSpec, PermissionConfig, SecurityMode, checker::PermissionChecker,
};
let config = PermissionConfig {
rules: vec![
rule(OpSpec::Execute, "rm *", Action::Allow),
rule(OpSpec::Edit, "/etc/**", Action::Deny),
],
..Default::default()
};
let checker = PermissionChecker::new(&config, SecurityMode::Standard, None);
let perm = std::sync::Arc::new(std::sync::Mutex::new(checker));
let result = check_bash_segments(&Some(perm), &None, "rm /etc/passwd").await;
assert!(
result.is_err(),
"rm /etc/passwd must hit write deny rule even when bash rule allows; got {result:?}",
);
}
#[cfg(feature = "semantic-bash")]
#[tokio::test]
async fn chmod_skips_mode_spec_routes_paths() {
use crate::permission::{
Action, OpSpec, PermissionConfig, SecurityMode, checker::PermissionChecker,
};
let config = PermissionConfig {
rules: vec![
rule(OpSpec::Execute, "chmod *", Action::Allow),
rule(OpSpec::Edit, "/etc/**", Action::Deny),
],
..Default::default()
};
let checker = PermissionChecker::new(&config, SecurityMode::Standard, None);
let perm = std::sync::Arc::new(std::sync::Mutex::new(checker));
let result = check_bash_segments(&Some(perm), &None, "chmod 777 /etc/passwd").await;
assert!(
result.is_err(),
"chmod 777 /etc/passwd: mode skipped, path arg gated; got {result:?}",
);
}
#[cfg(feature = "semantic-bash")]
#[tokio::test]
async fn flags_skipped_when_extracting_paths() {
use crate::permission::{
Action, OpSpec, PermissionConfig, SecurityMode, checker::PermissionChecker,
};
let config = PermissionConfig {
rules: vec![
rule(OpSpec::Execute, "rm *", Action::Allow),
rule(OpSpec::Edit, "/etc/**", Action::Deny),
],
..Default::default()
};
let checker = PermissionChecker::new(&config, SecurityMode::Standard, None);
let perm = std::sync::Arc::new(std::sync::Mutex::new(checker));
let result = check_bash_segments(&Some(perm), &None, "rm -rf /etc/passwd").await;
assert!(
result.is_err(),
"rm -rf /etc/passwd: flag skipped, path arg gated; got {result:?}",
);
}
#[cfg(feature = "semantic-bash")]
#[tokio::test]
async fn redirect_target_allowed_when_write_permits() {
use crate::permission::{
Action, OpSpec, PermissionConfig, SecurityMode, checker::PermissionChecker,
};
let config = PermissionConfig {
rules: vec![rule(OpSpec::Edit, "**", Action::Allow)],
..Default::default()
};
let checker = PermissionChecker::new(&config, SecurityMode::Standard, None);
let perm = std::sync::Arc::new(std::sync::Mutex::new(checker));
let result = check_bash_segments(&Some(perm), &None, "echo hi > target/test-out.txt").await;
assert!(
result.is_ok(),
"redirect to an explicitly-allowed target should pass; got {result:?}",
);
}
#[cfg(feature = "semantic-bash")]
#[tokio::test]
async fn bash_dev_null_target_adds_no_prompt() {
use crate::permission::{PermissionConfig, SecurityMode, checker::PermissionChecker};
let allowed_cases = [
"git status -s > /dev/null",
"git status -s 2> /dev/null",
"git status -s &> /dev/null",
"git status -s > /dev/null 2>&1",
];
for cmd in &allowed_cases {
let checker =
PermissionChecker::new(&PermissionConfig::default(), SecurityMode::Standard, None);
let perm = std::sync::Arc::new(std::sync::Mutex::new(checker));
let result = check_bash_segments(&Some(perm), &None, cmd).await;
assert!(
result.is_ok(),
"{cmd:?}: allowed command + /dev/null target must not prompt; got {result:?}",
);
}
let checker =
PermissionChecker::new(&PermissionConfig::default(), SecurityMode::Standard, None);
let perm = std::sync::Arc::new(std::sync::Mutex::new(checker));
let result = check_bash_segments(&Some(perm), &None, "unfamiliar_cmd > /dev/null").await;
assert!(
result.is_err(),
"unfamiliar command still needs Execute permission even redirecting to /dev/null; got {result:?}",
);
}
#[cfg(feature = "semantic-bash")]
#[tokio::test]
async fn bash_redirect_to_file_and_dev_null_still_prompts() {
use crate::permission::{PermissionConfig, SecurityMode, checker::PermissionChecker};
let config = PermissionConfig::default();
let checker = PermissionChecker::new(&config, SecurityMode::Standard, None);
let perm = std::sync::Arc::new(std::sync::Mutex::new(checker));
let result = check_bash_segments(
&Some(perm),
&None,
"unfamiliar_cmd > /tmp/dirge-mzs4-real.log 2> /dev/null",
)
.await;
assert!(
result.is_err(),
"compound redirect (real file + /dev/null) must NOT auto-allow; got {result:?}",
);
}
#[cfg(feature = "semantic-bash")]
#[tokio::test]
async fn bash_other_destination_still_prompts() {
use crate::permission::{PermissionConfig, SecurityMode, checker::PermissionChecker};
let config = PermissionConfig::default();
let checker = PermissionChecker::new(&config, SecurityMode::Standard, None);
let perm = std::sync::Arc::new(std::sync::Mutex::new(checker));
let result =
check_bash_segments(&Some(perm), &None, "unfamiliar_cmd > /tmp/elsewhere.log").await;
assert!(
result.is_err(),
"non-/dev/null redirect must still prompt; got {result:?}",
);
}
#[cfg(feature = "semantic-bash")]
#[tokio::test]
async fn bash_dev_null_does_not_bypass_deny_rules() {
use crate::permission::{PermissionConfig, SecurityMode, checker::PermissionChecker};
let config = PermissionConfig::default();
let checker = PermissionChecker::new(&config, SecurityMode::Standard, None);
let perm = std::sync::Arc::new(std::sync::Mutex::new(checker));
let result = check_bash_segments(&Some(perm), &None, "rm -rf / > /dev/null").await;
assert!(
result.is_err(),
"dev/null redirect must not bypass `rm -rf /**` deny; got {result:?}",
);
}
#[cfg(feature = "semantic-bash")]
#[tokio::test]
async fn bash_dev_null_per_segment_scope() {
use crate::permission::{PermissionConfig, SecurityMode, checker::PermissionChecker};
let config = PermissionConfig::default();
let checker = PermissionChecker::new(&config, SecurityMode::Standard, None);
let perm = std::sync::Arc::new(std::sync::Mutex::new(checker));
let result = check_bash_segments(
&Some(perm),
&None,
"unfamiliar_cmd > /dev/null && other_unfamiliar_cmd",
)
.await;
assert!(
result.is_err(),
"second segment without /dev/null redirect must still prompt; got {result:?}",
);
}
#[cfg(feature = "semantic-bash")]
#[test]
fn bash_mutation_targets_heredoc_create() {
let cmd = "cat > voxel.html <<'EOF'\n<html></html>\nEOF";
let t = bash_mutation_targets(cmd);
assert!(t.iter().any(|p| p == "voxel.html"), "got {t:?}");
}
#[cfg(feature = "semantic-bash")]
#[test]
fn bash_mutation_targets_redirect_create() {
let t = bash_mutation_targets("echo hi > notes.txt");
assert!(t.iter().any(|p| p == "notes.txt"), "got {t:?}");
}
#[cfg(feature = "semantic-bash")]
#[test]
fn bash_mutation_targets_rm_delete() {
let t = bash_mutation_targets("rm -rf build/old.o");
assert!(t.iter().any(|p| p == "build/old.o"), "got {t:?}");
}
#[cfg(feature = "semantic-bash")]
#[test]
fn bash_mutation_targets_mv_rename() {
let t = bash_mutation_targets("mv a.txt b.txt");
assert!(t.iter().any(|p| p == "a.txt"), "src missing, got {t:?}");
assert!(t.iter().any(|p| p == "b.txt"), "dst missing, got {t:?}");
}
#[cfg(feature = "semantic-bash")]
#[tokio::test]
async fn bash_create_propagates_to_modified_tracker() {
use crate::agent::tools::BashArgs;
let dir = std::env::temp_dir().join("dirge-sb2n-bash-create");
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("created-by-bash.txt");
let _ = std::fs::remove_file(&file);
let tool = BashTool::new(
None,
None,
crate::sandbox::Sandbox::new(crate::sandbox::SandboxMode::Off),
);
tool.call(BashArgs {
command: format!("echo hi > {}", file.display()),
timeout: None,
background: None,
})
.await
.expect("bash create");
let canonical = std::fs::canonicalize(&file).expect("file should exist");
let recent = crate::agent::tools::modified::recent(256);
assert!(
recent.contains(&canonical),
"bash-created file should be tracked; looking for {canonical:?} in {recent:?}",
);
let _ = std::fs::remove_file(&file);
}
#[cfg(feature = "semantic-bash")]
mod gating_corpus {
use super::*;
use crate::permission::{PermissionConfig, SecurityMode, checker::PermissionChecker};
use std::sync::{Arc, Mutex};
fn checker() -> Arc<Mutex<PermissionChecker>> {
let config = PermissionConfig::default();
let c = PermissionChecker::new(
&config,
SecurityMode::Standard,
Some(std::path::PathBuf::from("/work/proj")),
);
Arc::new(Mutex::new(c))
}
async fn gated(cmd: &str) -> bool {
check_bash_segments(&Some(checker()), &None, cmd)
.await
.is_ok()
}
async fn grant_then_recheck(cmd: &str) -> bool {
let perm = checker();
let pat = crate::ui::permission_ui::suggest_pattern("bash", cmd);
perm.lock_ignore_poison()
.add_session_allowlist("bash".to_string(), &pat);
check_bash_segments(&Some(perm), &None, cmd).await.is_ok()
}
#[tokio::test]
async fn reported_multiline_npx_compound_prompts_then_grant_sticks() {
let cmd = "cd /Users/yogthos/src/rignet && npx tsx -e \"\
import { readFileSync } from 'fs';\n\
import { runRiggingTest } from './src/index.ts';\n\
runRiggingTest();\"";
assert!(
!gated(cmd).await,
"npx runs arbitrary code — it must prompt the first time"
);
assert!(
grant_then_recheck(cmd).await,
"ALLOW-ALWAYS MUST STICK on the multi-line compound (the reported bug)"
);
}
#[tokio::test]
async fn multiline_interpreter_scripts_prompt_then_grant_sticks() {
for cmd in [
"npx tsx -e \"console.log(1)\"",
"npx tsx -e \"const a = 1;\nconsole.log(a)\"",
"node -e \"const x = 1;\nconsole.log(x)\"",
"python3 -c \"import sys\nprint(sys.argv)\"",
"python -c \"x = 1\nprint(x)\"",
] {
assert!(
!gated(cmd).await,
"interpreter must prompt (not default-allowed): {cmd:?}"
);
assert!(
grant_then_recheck(cmd).await,
"allow-always must stick on multi-line interpreter cmd: {cmd:?}"
);
}
}
#[tokio::test]
async fn all_default_compounds_auto_allowed() {
for cmd in [
"git add . && git commit -m \"msg\"",
"cargo fmt && cargo test",
"cd subdir && npm run build",
"ls -la | grep foo",
"cat a.txt; echo done",
"cargo build || echo failed",
"export RUST_LOG=debug && cargo test",
"pushd app && npm run build && popd",
] {
assert!(
gated(cmd).await,
"all-default compound must auto-allow: {cmd:?}"
);
}
}
#[tokio::test]
async fn allow_always_sticks_for_custom_commands() {
for cmd in [
"mycli run --fast",
"mycli gen -e \"line1\nline2\nline3\"",
"cd /some/external/project && mycli build -e \"a\nb\"",
"export TOKEN=x && mycli deploy",
] {
assert!(
!gated(cmd).await,
"expected an initial prompt (not in defaults): {cmd:?}"
);
assert!(
grant_then_recheck(cmd).await,
"ALLOW-ALWAYS MUST STICK — command still prompts after grant: {cmd:?}"
);
}
}
#[tokio::test]
async fn source_is_gated_but_grant_sticks() {
let cmd = "source ./env.sh && cargo test";
assert!(!gated(cmd).await, "source must prompt by default");
assert!(
grant_then_recheck(cmd).await,
"granting the suggested `source *` must make the command pass"
);
}
#[tokio::test]
async fn dangerous_segments_stay_gated_even_after_grant() {
for cmd in [
"rm -rf /",
"npx foo && rm -rf /",
"cargo build && sudo rm -rf /var",
] {
assert!(!gated(cmd).await, "must not auto-allow: {cmd:?}");
assert!(
!grant_then_recheck(cmd).await,
"allow-always must NOT unlock a denied/dangerous segment: {cmd:?}"
);
}
}
#[tokio::test]
async fn quoted_operators_do_not_split_into_claims() {
assert!(
gated("echo \"a && rm -rf /\"").await,
"quoted operator is literal — echo is allowed as one segment"
);
}
#[tokio::test]
async fn cd_outside_project_gates_relative_redirect() {
assert!(
!gated("cd /etc && echo pwned > passwd").await,
"cd /etc + relative `> passwd` writes /etc/passwd — must prompt"
);
assert!(
gated("cd subdir && echo ok > out.txt").await,
"in-project cd + relative write is in-tree, stays allowed"
);
assert!(
gated("echo ok > local.txt").await,
"plain in-project relative write stays allowed"
);
assert!(
!gated("echo pwned > /etc/passwd").await,
"absolute external redirect must prompt"
);
}
use crate::permission::approval::{ApprovalDecision, ApprovalFn, ApprovalRequest};
use std::future::Future;
use std::pin::Pin;
fn checker_with_approval(stub: ApprovalFn) -> Arc<Mutex<PermissionChecker>> {
let config = PermissionConfig::default();
let mut c = PermissionChecker::new(
&config,
SecurityMode::Standard,
Some(std::path::PathBuf::from("/work/proj")),
);
c.set_approval_fn(stub);
Arc::new(Mutex::new(c))
}
fn approve_always() -> ApprovalFn {
std::sync::Arc::new(|_req: ApprovalRequest| {
Box::pin(async { Ok(ApprovalDecision::Allow) })
as Pin<Box<dyn Future<Output = anyhow::Result<ApprovalDecision>> + Send>>
})
}
#[tokio::test]
async fn approval_provider_allows_a_prompting_command() {
let perm = checker_with_approval(approve_always());
assert!(
check_bash_segments(&Some(perm), &None, "npx foo")
.await
.is_ok(),
"evaluator ALLOW must auto-approve"
);
}
#[tokio::test]
async fn approval_provider_denies_with_reason() {
let stub: ApprovalFn = std::sync::Arc::new(|_req: ApprovalRequest| {
Box::pin(async { Ok(ApprovalDecision::Deny("writes outside project".into())) })
as Pin<Box<dyn Future<Output = anyhow::Result<ApprovalDecision>> + Send>>
});
let perm = checker_with_approval(stub);
let res = check_bash_segments(&Some(perm), &None, "npx foo").await;
assert!(res.is_err(), "evaluator DENY must reject");
assert!(
format!("{res:?}").contains("writes outside project"),
"rejection must carry the evaluator's reason: {res:?}"
);
}
#[tokio::test]
async fn approval_provider_cannot_override_a_hard_deny() {
let perm = checker_with_approval(approve_always());
assert!(
check_bash_segments(&Some(perm), &None, "rm -rf /")
.await
.is_err(),
"a hard deny must not be reachable by the approval evaluator"
);
}
#[tokio::test]
async fn approval_provider_receives_command_and_resources() {
let seen: Arc<Mutex<Option<(String, usize)>>> = Arc::new(Mutex::new(None));
let seen2 = seen.clone();
let stub: ApprovalFn = std::sync::Arc::new(move |req: ApprovalRequest| {
*seen2.lock_ignore_poison() = Some((req.command.clone(), req.resources.len()));
Box::pin(async { Ok(ApprovalDecision::Allow) })
as Pin<Box<dyn Future<Output = anyhow::Result<ApprovalDecision>> + Send>>
});
let perm = checker_with_approval(stub);
let _ = check_bash_segments(&Some(perm), &None, "npx foo && mycli bar").await;
let (cmd, n) = seen
.lock_ignore_poison()
.clone()
.expect("evaluator should have been called");
assert_eq!(cmd, "npx foo && mycli bar");
assert!(
n >= 2,
"both command segments should be summarized; got {n}"
);
}
}
#[cfg(not(feature = "semantic-bash"))]
#[test]
fn coarse_redirect_targets_extracts_external_write() {
assert_eq!(
coarse_redirect_targets("echo x > /etc/passwd"),
vec!["/etc/passwd".to_string()]
);
assert_eq!(
coarse_redirect_targets("cmd >> /var/log/x"),
vec!["/var/log/x".to_string()]
);
assert_eq!(
coarse_redirect_targets("cmd >| out.txt"),
vec!["out.txt".to_string()]
);
assert_eq!(
coarse_redirect_targets("cmd 2> err.log"),
vec!["err.log".to_string()]
);
assert!(coarse_redirect_targets("echo \">notaredirect\"").is_empty());
assert!(coarse_redirect_targets("cmd 1>&2").is_empty());
}
#[cfg(not(feature = "semantic-bash"))]
#[test]
fn coarse_mutation_paths_extracts_targets() {
assert_eq!(
coarse_mutation_paths("rm -rf /tmp/x"),
vec!["/tmp/x".to_string()]
);
assert_eq!(
coarse_mutation_paths("cp a b"),
vec!["a".to_string(), "b".to_string()]
);
assert_eq!(
coarse_mutation_paths("dd if=/dev/zero of=/etc/wipe bs=1"),
vec!["/etc/wipe".to_string()]
);
assert_eq!(
coarse_mutation_paths("/bin/rm /etc/hosts"),
vec!["/etc/hosts".to_string()]
);
assert!(coarse_mutation_paths("echo hello").is_empty());
}
#[cfg(not(feature = "semantic-bash"))]
#[tokio::test]
async fn coarse_external_redirect_is_gated() {
use crate::permission::engine::classify_path;
let targets = coarse_redirect_targets("echo pwned > /etc/passwd");
assert_eq!(targets, vec!["/etc/passwd".to_string()]);
let r = classify_path("/etc/passwd", "/home/user/project");
match r {
crate::permission::engine::types::Resource::Path { in_cwd, .. } => {
assert!(!in_cwd, "/etc/passwd must classify as outside the cwd");
}
other => panic!("expected a Path resource, got {other:?}"),
}
}
#[tokio::test]
async fn bash_description_has_exactly_one_contract_line() {
use crate::sandbox::{Sandbox, SandboxMode};
let tool = BashTool::new(None, None, Sandbox::new(SandboxMode::Off));
let def = tool.definition("".to_string()).await;
let count = def.description.matches("CONTRACT:").count();
assert_eq!(
count, 1,
"bash description must have exactly one CONTRACT: line, got {count}:\n{}",
def.description
);
}