use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ShellMode {
Full,
ReadOnly,
}
const MUTATING_COMMANDS: &[&str] = &[
"rm",
"rmdir",
"unlink",
"shred",
"cp",
"mv",
"touch",
"mkdir",
"mkfifo",
"mknod",
"ln",
"install",
"truncate",
"fallocate",
"tee",
"split",
"csplit",
"patch",
"scp",
"sftp",
"chmod",
"chown",
"chattr",
"chflags",
"setfacl",
"rsync",
"zip",
"unzip",
"vim",
"vi",
"nvim",
"nano",
"pico",
"emacs",
"ed",
"code",
"gedit",
"sponge",
"kill",
"pkill",
"killall",
"shutdown",
"reboot",
"halt",
"poweroff",
"make",
"cmake",
"wget",
"gzip",
"gunzip",
"bzip2",
"xz",
"zstd",
"npm",
"yarn",
"pnpm",
"pip",
"pip3",
"pipenv",
"poetry",
"brew",
"port",
];
const GIT_SAFE_SUBCOMMANDS: &[&str] = &[
"status",
"log",
"diff",
"show",
"blame",
"annotate",
"shortlog",
"describe",
"ls-files",
"ls-tree",
"rev-parse",
"rev-list",
"for-each-ref",
"grep",
"help",
"version",
"name-rev",
"count-objects",
"verify-pack",
"verify-commit",
"verify-tag",
"check-attr",
"check-ignore",
"check-mailmap",
"check-ref-format",
"cat-file",
"cherry",
"diff-files",
"diff-index",
"diff-tree",
"fmt-merge-msg",
"fsck",
"merge-base",
"merge-file",
"merge-tree",
"whatchanged",
"reflog",
"range-diff",
"request-pull",
"worktree list",
"config --list",
"config --get",
"config --get-all",
"hash-object",
"mktag",
"mktree",
"stripspace",
"remote",
"branch",
"tag",
];
const CARGO_SAFE_SUBCOMMANDS: &[&str] = &[
"build",
"check",
"test",
"clippy",
"rustc",
"metadata",
"tree",
"locate-project",
"pkgid",
"report",
"search",
"info",
"clean",
"doc",
"fmt",
"generate-lockfile",
"update",
"version",
"verify-project",
"read-manifest",
"help",
"bench",
];
fn strip_heredoc_bodies(command: &str) -> String {
let mut out = String::new();
let mut i = 0;
let chars: Vec<(usize, char)> = command.char_indices().collect();
while i < chars.len() {
if i + 1 < chars.len() && chars[i].1 == '<' && chars[i + 1].1 == '<' {
out.push(' ');
i += 2;
while i < chars.len() && chars[i].1.is_whitespace() {
i += 1;
}
if i < chars.len() && chars[i].1 == '-' {
i += 1;
}
while i < chars.len() && chars[i].1.is_whitespace() {
i += 1;
}
let (delimiter, delim_end) = parse_heredoc_delimiter(command, chars[i].0);
i = chars
.iter()
.position(|(byte, _)| *byte >= delim_end)
.unwrap_or(chars.len());
while i < chars.len() && chars[i].1 != '\n' {
i += 1;
}
if i < chars.len() {
i += 1;
}
let delim_bytes = delimiter.as_bytes();
while i < chars.len() {
let line_start = chars[i].0;
if command[line_start..].starts_with(&delimiter)
&& (command.len() == line_start + delimiter.len()
|| matches!(
command.as_bytes().get(line_start + delimiter.len()),
Some(b'\n' | b'\r')
))
{
i = chars
.iter()
.position(|(byte, _)| *byte > line_start + delimiter.len())
.unwrap_or(chars.len());
while i < chars.len() && chars[i].1 != '\n' {
i += 1;
}
if i < chars.len() {
i += 1;
}
break;
}
while i < chars.len() && chars[i].1 != '\n' {
i += 1;
}
if i < chars.len() {
i += 1;
}
}
let _ = delim_bytes; continue;
}
out.push(chars[i].1);
i += 1;
}
out
}
fn parse_heredoc_delimiter(command: &str, start: usize) -> (String, usize) {
let rest = &command[start..];
if let Some(rest) = rest.strip_prefix('\'') {
if let Some(end) = rest.find('\'') {
let delim = &rest[..end];
return (delim.to_string(), start + 1 + end + 1);
}
} else if let Some(rest) = rest.strip_prefix('"')
&& let Some(end) = rest.find('"')
{
let delim = &rest[..end];
return (delim.to_string(), start + 1 + end + 1);
}
let end = rest.find(|c: char| c.is_whitespace()).unwrap_or(rest.len());
(rest[..end].to_string(), start + end)
}
fn has_disallowed_redirect(command_str: &str) -> bool {
let scan_str = strip_heredoc_bodies(command_str);
let mut in_single = false;
let mut in_double = false;
let mut escaped = false;
let mut chars = scan_str.char_indices();
while let Some((i, c)) = chars.next() {
if escaped {
escaped = false;
continue;
}
if c == '\\' && !in_single {
escaped = true;
continue;
}
if !super::check_outside_quotes(c, &mut in_single, &mut in_double) {
continue;
}
if scan_str[i..].starts_with("2>&1") || scan_str[i..].starts_with("1>&2") {
chars.nth(2);
continue;
}
let redirect_len = if scan_str[i..].starts_with(">&")
|| scan_str[i..].starts_with(">>")
|| scan_str[i..].starts_with(">|")
|| scan_str[i..].starts_with("2>")
{
2
} else if c == '>' {
1
} else {
continue;
};
if redirect_len > 1 {
chars.next();
}
let after = &scan_str[i + redirect_len..].trim_start();
let target = after
.split(|ch: char| ch.is_whitespace() || ch == '&' || ch == ';' || ch == '|')
.next()
.unwrap_or("");
if target.is_empty() {
return true;
}
if target == "/dev/null" {
continue;
}
let target_path = Path::new(target);
if target_path.is_absolute() {
if crate::tools::path::is_path_under_allowed_temp(target_path) {
continue;
}
return true;
}
return true;
}
false
}
pub(super) fn check_command(command_str: &str) -> Result<(), String> {
let trimmed = command_str.trim();
if trimmed.is_empty() {
return Ok(());
}
if has_disallowed_redirect(trimmed) {
return Err(format!(
"⚠️ Read-only mode: command contains a disallowed output redirect.\n\
Command: `{trimmed}`\n\
Redirects are only allowed to /dev/null, 2>&1, 1>&2, or paths under /tmp, /var/tmp, or the OS temp directory.\n\
Suggestion: pipe to a pager (e.g., `| less`) or use `| head` to limit output."
));
}
let segments = super::extract_command_segments(trimmed);
for segment in &segments {
check_segment(segment)?;
}
Ok(())
}
fn reject(cmd: &str, why: &str, suggestion: &str) -> Result<(), String> {
Err(format!(
"⚠️ Read-only mode: {why}\n\
Command: `{cmd}`\n\
Suggestion: {suggestion}"
))
}
const SCRATCH_MUTATORS: &[&str] = &["tee", "touch", "mkdir"];
fn non_flag_path_args(segment: &str) -> Vec<String> {
let canonical = super::canonical_command(segment);
let parts: Vec<&str> = canonical.split_whitespace().collect();
if parts.len() <= 1 {
return vec![];
}
let mut paths = Vec::new();
let mut i = 1;
while i < parts.len() {
let p = parts[i];
if p == "-p" {
i += 1;
continue;
}
if p.starts_with('-') {
i += 1;
continue;
}
paths.push(p.to_string());
i += 1;
}
paths
}
fn scratch_paths_under_temp(segment: &str) -> bool {
let paths = non_flag_path_args(segment);
!paths.is_empty()
&& paths.iter().all(|p| {
let path = Path::new(p);
path.is_absolute() && crate::tools::path::is_path_under_allowed_temp(path)
})
}
fn check_segment(segment: &str) -> Result<(), String> {
let trimmed = segment.trim();
if trimmed.is_empty() {
return Ok(());
}
let first_word = super::first_command_word(trimmed);
if first_word.is_empty() {
return Ok(());
}
if first_word == "mktemp" {
return Ok(());
}
if MUTATING_COMMANDS.contains(&first_word) {
if SCRATCH_MUTATORS.contains(&first_word) && scratch_paths_under_temp(trimmed) {
return Ok(());
}
return reject(
trimmed,
&format!("`{first_word}` is not allowed — it modifies the workspace."),
"use read-only alternatives to inspect files, e.g. `cat`, `head`, `tail`, `ls`, `file`, `stat`.",
);
}
if first_word == "git" {
return check_git_segment(trimmed);
}
if first_word == "cargo" {
return check_cargo_segment(trimmed);
}
match first_word {
"sed" if has_flag(trimmed, "i") => {
return reject(
trimmed,
"`sed -i` is not allowed — it modifies files in-place.",
"use `sed` without `-i` to output to stdout, e.g. `sed 's/a/b/' file`.",
);
}
"awk" if has_inplace(trimmed) => {
return reject(
trimmed,
"`awk -i inplace` is not allowed — it modifies files in-place.",
"use `awk` without `-i inplace` to output to stdout.",
);
}
"dd" if has_dd_of(trimmed) => {
return reject(
trimmed,
"`dd of=...` is not allowed — it writes to a file.",
"use `dd` without `of=` to output to stdout.",
);
}
"curl" if has_output_flag(trimmed) => {
return reject(
trimmed,
"`curl` with output flags (`-o`, `--output`, `-O`, `--remote-name`) is not allowed.",
"use `curl` without output flags to display content in stdout.",
);
}
"tar" if !is_tar_list_only(trimmed) => {
return reject(
trimmed,
"`tar` is only allowed with `-t`/`--list` (list) mode.",
"use `tar -tf archive.tar` to list contents.",
);
}
"base64" if has_base64_decode_output(trimmed) => {
return reject(
trimmed,
"`base64 -d` with `-o` is not allowed — it writes decoded output to a file.",
"use `base64 -d` without `-o` to output to stdout.",
);
}
_ => {}
}
Ok(())
}
const GIT_BRANCH_MUTATIONS: &[&str] = &[
"-d",
"-D",
"-m",
"-M",
"-c",
"-C",
"--delete",
"--move",
"--copy",
"--edit-description",
];
const GIT_TAG_MUTATIONS: &[&str] = &[
"-d",
"--delete",
"-a",
"-s",
"-u",
"--annotate",
"--sign",
"--local-user",
];
const GIT_REMOTE_MUTATIONS: &[&str] = &[
"add",
"remove",
"rm",
"rename",
"set-url",
"set-head",
"set-branches",
"update",
"prune",
];
fn check_git_segment(segment: &str) -> Result<(), String> {
let trimmed = segment.trim();
if trimmed.contains("stash list") {
return Ok(());
}
let subcommand = extract_git_subcommand(trimmed);
if subcommand.is_empty() || subcommand == "git" {
return Ok(());
}
if subcommand.starts_with("stash") && !subcommand.contains("stash list") {
return reject(
trimmed,
"`git stash` is not allowed — it modifies the working tree.",
"use `git stash list` to view stashes, or `git diff` to preview changes.",
);
}
let mut matched_safe = "";
for safe in GIT_SAFE_SUBCOMMANDS {
if subcommand == *safe || subcommand.starts_with(&format!("{safe} ")) {
matched_safe = safe;
break;
}
}
if matched_safe.is_empty() {
return Err(format!(
"⚠️ Read-only mode: the `git {subcommand}` subcommand is not allowed — it may mutate the repository.\n\
Command: `{trimmed}`\n\
Allowed git subcommands for read-only mode: status, log, diff, show, blame, branch, tag, remote, stash list,\n\
and other inspection-only commands. Suggestion: use these for repository exploration."
));
}
match matched_safe {
"branch" => check_git_subcommand_mutation(&subcommand, "branch", GIT_BRANCH_MUTATIONS)?,
"tag" => check_git_subcommand_mutation(&subcommand, "tag", GIT_TAG_MUTATIONS)?,
"remote" => check_git_subcommand_mutation(&subcommand, "remote", GIT_REMOTE_MUTATIONS)?,
_ => {}
}
Ok(())
}
fn check_git_subcommand_mutation(
subcommand: &str,
subcommand_name: &str,
mutation_tokens: &[&str],
) -> Result<(), String> {
let words: Vec<&str> = subcommand.split_whitespace().collect();
if let Some(first_arg) = words.get(1)
&& mutation_tokens.contains(first_arg)
{
return Err(format!(
"⚠️ Read-only mode: `git {subcommand}` is not allowed — it mutates.\n\
Suggestion: use `git {subcommand_name}` without mutation flags to list/inspect."
));
}
Ok(())
}
fn extract_git_subcommand(segment: &str) -> String {
let words: Vec<&str> = segment.split_whitespace().collect();
let git_idx = words.iter().position(|w| !super::is_env_assignment(w));
if git_idx.is_none_or(|idx| words[idx] != "git") {
return String::new();
}
let git_idx = git_idx.unwrap();
let remaining = &words[git_idx + 1..];
if let Some(sub_start) = super::find_first_non_flag_index(remaining, true) {
remaining[sub_start..].join(" ")
} else {
String::new()
}
}
fn check_cargo_segment(segment: &str) -> Result<(), String> {
let trimmed = segment.trim();
let canonical = super::canonical_command(trimmed);
let subcommand = canonical.strip_prefix("cargo ").unwrap_or(&canonical);
if subcommand.is_empty() || subcommand == "cargo" {
return Ok(());
}
let base = subcommand.split_whitespace().next().unwrap_or("");
let is_safe = CARGO_SAFE_SUBCOMMANDS.contains(&base);
if !is_safe {
return Err(format!(
"⚠️ Read-only mode: `cargo {base}` is not in the allowed cargo subcommands list.\n\
Command: `{trimmed}`\n\
Allowed cargo subcommands: {}\n\
Suggestion: use `cargo check`, `cargo test`, `cargo clippy`, `cargo doc`, etc.",
CARGO_SAFE_SUBCOMMANDS.join(", ")
));
}
if base == "clippy" && has_clippy_fix(trimmed) {
return Err(format!(
"⚠️ Read-only mode: `cargo clippy --fix` is not allowed — it auto-applies fixes.\n\
Command: `{trimmed}`\n\
Suggestion: use `cargo clippy` without `--fix` to see warnings only,\n\
or use `cargo clippy -- --fix` to pass `--fix` as a lint name (not auto-fix)."
));
}
if base == "fmt" && !has_cargo_fmt_check(trimmed) {
return reject(
trimmed,
"`cargo fmt` without `--check` is not allowed — it reformats files.",
"use `cargo fmt --check` to verify formatting without modifying files.",
);
}
Ok(())
}
fn has_flag(command: &str, flag: &str) -> bool {
let parts: Vec<&str> = command.split_whitespace().collect();
let dash_flag = format!("-{flag}");
for part in &parts {
if *part == dash_flag || part.starts_with(&format!("-{flag}.")) {
return true;
}
}
false
}
fn has_inplace(command: &str) -> bool {
let parts: Vec<&str> = command.split_whitespace().collect();
for i in 0..parts.len().saturating_sub(1) {
if parts[i] == "-i" && parts[i + 1] == "inplace" {
return true;
}
}
false
}
fn has_dd_of(command: &str) -> bool {
command.split_whitespace().any(|p| p.starts_with("of="))
}
fn has_output_flag(command: &str) -> bool {
let parts: Vec<&str> = command.split_whitespace().collect();
parts
.iter()
.any(|p| *p == "-o" || *p == "--output" || *p == "-O" || *p == "--remote-name")
}
fn is_tar_list_only(command: &str) -> bool {
let parts: Vec<&str> = command.split_whitespace().collect();
for part in &parts {
if *part == "--list" {
return true;
}
if part.starts_with('-') && !part.starts_with("--") {
if *part == "-v" || *part == "-f" || *part == "-z" || *part == "-j" || *part == "-J" {
continue;
}
let ops: String = part
.chars()
.skip(1) .filter(|c| !['v', 'f', 'z', 'j', 'J'].contains(c))
.collect();
if !ops.is_empty() {
return ops == "t";
}
}
}
false
}
fn has_base64_decode_output(command: &str) -> bool {
let parts: Vec<&str> = command.split_whitespace().collect();
let has_d = parts.iter().any(|p| *p == "-d" || *p == "--decode");
let has_o = parts.iter().any(|p| *p == "-o" || *p == "--output");
has_d && has_o
}
fn has_clippy_fix(command: &str) -> bool {
let parts: Vec<&str> = command.split_whitespace().collect();
let dashdash_pos = parts.iter().position(|p| *p == "--");
for (i, part) in parts.iter().enumerate() {
if *part == "--fix" {
if let Some(dd_pos) = dashdash_pos
&& i > dd_pos
{
return false; }
return true; }
}
false
}
fn has_cargo_fmt_check(command: &str) -> bool {
command.split_whitespace().any(|p| p == "--check")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::shell::SHELL_PREFIXES;
fn ok(cmd: &str) {
assert!(
check_command(cmd).is_ok(),
"expected ALLOW but got REJECT for: `{cmd}`"
);
}
fn assert_rejected(cmd: &str) {
assert!(
check_command(cmd).is_err(),
"expected REJECT but got ALLOW for: `{cmd}`"
);
}
#[test]
fn empty_command() {
ok("");
}
#[test]
fn whitespace_only() {
ok(" ");
}
#[test]
fn unknown_command_allowed() {
ok("some_obscure_tool --flag");
}
#[test]
fn all_git_safe_subcommands_allowed() {
for subcmd in GIT_SAFE_SUBCOMMANDS {
ok(&format!("git {subcmd}"));
}
}
#[test]
fn git_commit_rejected() {
assert_rejected("git commit -m test");
}
#[test]
fn git_push_rejected() {
assert_rejected("git push");
}
#[test]
fn git_stash_rejected() {
assert_rejected("git stash");
}
#[test]
fn git_stash_list_allowed() {
ok("git stash list");
}
#[test]
fn git_merge_rejected() {
assert_rejected("git merge feature");
}
#[test]
fn git_rebase_rejected() {
assert_rejected("git rebase main");
}
#[test]
fn all_cargo_safe_subcommands_allowed() {
for subcmd in CARGO_SAFE_SUBCOMMANDS {
if *subcmd == "fmt" {
continue; }
ok(&format!("cargo {subcmd}"));
}
}
#[test]
fn cargo_clippy_fix_rejected() {
assert_rejected("cargo clippy --fix");
}
#[test]
fn cargo_clippy_fix_after_dd_allowed() {
ok("cargo clippy -- --fix");
}
#[test]
fn cargo_fmt_rejected() {
assert_rejected("cargo fmt");
}
#[test]
fn cargo_fmt_check_allowed() {
ok("cargo fmt --check");
}
#[test]
fn cargo_fmt_dd_check_allowed() {
ok("cargo fmt -- --check");
}
#[test]
fn cargo_fix_rejected() {
assert_rejected("cargo fix");
}
#[test]
fn all_mutating_commands_rejected() {
for cmd in MUTATING_COMMANDS {
assert_rejected(&format!("{cmd} arg"));
}
}
#[test]
fn git_branch_mutation_flags_rejected() {
for flag in GIT_BRANCH_MUTATIONS {
assert_rejected(&format!("git branch {flag} feature"));
}
}
#[test]
fn git_tag_mutation_flags_rejected() {
for flag in GIT_TAG_MUTATIONS {
assert_rejected(&format!("git tag {flag} v1.0"));
}
}
#[test]
fn git_remote_mutation_verbs_rejected() {
for verb in GIT_REMOTE_MUTATIONS {
assert_rejected(&format!("git remote {verb} origin"));
}
}
#[test]
fn sed_stdout_allowed() {
ok("sed 's/a/b/' file");
}
#[test]
fn sed_inplace_rejected() {
assert_rejected("sed -i 's/a/b/' file");
}
#[test]
fn sed_inplace_bak_rejected() {
assert_rejected("sed -i.bak 's/a/b/' file");
}
#[test]
fn awk_stdout_allowed() {
ok("awk '{print $1}' file");
}
#[test]
fn awk_inplace_rejected() {
assert_rejected("awk -i inplace '{print $1}' file");
}
#[test]
fn dd_stdout_allowed() {
ok("dd if=/dev/zero bs=1 count=10");
}
#[test]
fn dd_of_rejected() {
assert_rejected("dd if=/dev/zero of=file bs=1 count=10");
}
#[test]
fn curl_allowed() {
ok("curl https://example.com");
}
#[test]
fn curl_output_rejected() {
assert_rejected("curl -o file https://example.com");
}
#[test]
fn curl_remote_name_rejected() {
assert_rejected("curl -O https://example.com/file");
}
#[test]
fn tar_list_allowed() {
ok("tar -tf archive.tar.gz");
}
#[test]
fn tar_extract_rejected() {
assert_rejected("tar -xzf archive.tar.gz");
}
#[test]
fn tar_create_rejected() {
assert_rejected("tar -czf archive.tar.gz dir/");
}
#[test]
fn base64_decode_stdout_allowed() {
ok("base64 -d file.txt");
}
#[test]
fn base64_decode_with_output_rejected() {
assert_rejected("base64 -d -o out.bin file.txt");
}
#[test]
fn base64_decode_long_output_rejected() {
assert_rejected("base64 --decode --output out.bin file.txt");
}
#[test]
fn chained_all_safe() {
ok("cargo check && cargo test");
}
#[test]
fn chained_second_mutates() {
assert_rejected("cargo check && rm file");
}
#[test]
fn chained_second_mutates_fmt() {
assert_rejected("git status && cargo fmt");
}
#[test]
fn piped_all_safe() {
ok("git log --oneline | head -20");
}
#[test]
fn semicolon_second_mutates() {
assert_rejected("cargo check; rm file");
}
#[test]
fn redirect_to_workspace_rejected() {
assert_rejected("echo hello > file.txt");
}
#[test]
fn redirect_to_devnull_allowed() {
ok("echo hello > /dev/null");
}
#[test]
fn redirect_to_tmp_allowed() {
ok("echo hello > /tmp/output.txt");
}
#[test]
fn stderr_to_stdout_allowed() {
ok("cmd 2>&1");
}
#[test]
fn redirect_in_quotes_allowed() {
ok("echo \"hello > world\"");
}
#[test]
fn append_to_tmp_allowed() {
ok("echo hello >> /tmp/log");
}
#[test]
fn noclobber_to_tmp_allowed() {
ok("echo hello >| /tmp/force");
}
#[test]
fn stdout_stderr_to_devnull() {
ok("cargo build > /dev/null 2>&1");
}
#[test]
fn mktemp_allowed() {
ok("mktemp");
}
#[test]
fn mktemp_with_template_allowed() {
ok("mktemp -t mahbot.XXXXXX");
}
#[test]
fn shell_prefix_mutating_rejected() {
for prefix in SHELL_PREFIXES {
match *prefix {
"cd" | "pushd" | "popd" | "export" | "source" | "." => {}
_ => assert_rejected(&format!("{prefix} rm file")),
}
}
}
#[test]
fn sudo_flag_rm_rejected() {
assert_rejected("sudo -E rm file");
}
#[test]
fn sudo_git_status_allowed() {
ok("sudo git status");
}
#[test]
fn sudo_cargo_check_allowed() {
ok("sudo cargo check");
}
#[test]
fn pure_prefix_allowed() {
ok("cd"); }
#[test]
fn cd_some_dir_allowed() {
ok("cd .."); }
#[test]
fn env_var_rm_rejected() {
assert_rejected("FOO=bar rm file");
}
#[test]
fn env_var_sudo_rm_rejected() {
assert_rejected("VAR=val sudo rm -rf /");
}
#[test]
fn env_var_git_status_allowed() {
ok("GIT_DIR=/tmp git status");
}
#[test]
fn python3_version_allowed() {
ok("python3 --version");
}
#[test]
fn python3_print_allowed() {
ok("python3 -c \"print('hello')\"");
}
#[test]
fn node_eval_allowed() {
ok("node -e \"console.log('hi')\"");
}
#[test]
fn bash_echo_allowed() {
ok("bash -c \"echo hello\"");
}
#[test]
fn docker_ps_allowed() {
ok("docker ps");
}
#[test]
fn kubectl_get_allowed() {
ok("kubectl get pods");
}
#[test]
fn tar_long_list_allowed() {
ok("tar --list -f archive.tar.gz");
}
#[test]
fn redirect_to_var_tmp_allowed() {
ok("echo hello > /var/tmp/output.txt");
}
#[test]
fn append_to_var_tmp_allowed() {
ok("echo hello >> /var/tmp/log");
}
#[test]
fn redirect_bare_gt_rejected() {
assert_rejected("cmd > output.txt");
}
#[test]
fn redirect_fd_merge_stderr_to_stdout_allowed() {
ok("cmd 1>&2");
}
#[test]
fn redirect_2gt_to_tmp_allowed() {
ok("cmd 2> /tmp/errors.log");
}
#[test]
fn redirect_2gt_to_workspace_rejected() {
assert_rejected("cmd 2> errors.log");
}
#[test]
fn redirect_gt_ampersand_rejected() {
assert_rejected("cmd >&2");
}
#[test]
fn backslash_escaped_redirect_not_detected() {
ok("echo \\> /tmp/file");
}
#[test]
fn backslash_escaped_redirect_not_detected_no_target() {
ok("echo \\>");
}
#[test]
fn multiple_consecutive_backslash_escapes() {
ok("echo \\\\\\> file");
}
#[test]
fn unclosed_double_quote_hides_redirect() {
ok("echo \"> /tmp/foo");
}
#[test]
fn unclosed_single_quote_hides_redirect() {
ok("echo '> /tmp/foo");
}
#[test]
fn extract_git_subcommand_basic() {
assert_eq!(extract_git_subcommand("git status"), "status");
}
#[test]
fn extract_git_subcommand_with_global_flag() {
assert_eq!(extract_git_subcommand("git -C /repo diff"), "diff");
}
#[test]
fn extract_git_subcommand_with_config() {
assert_eq!(extract_git_subcommand("git -c user.name=me log"), "log");
}
#[test]
fn extract_git_subcommand_with_git_dir() {
assert_eq!(
extract_git_subcommand("git --git-dir /repo status"),
"status"
);
}
#[test]
fn extract_git_subcommand_env_assignment() {
assert_eq!(extract_git_subcommand("GIT_DIR=/tmp git status"), "status");
}
#[test]
fn extract_git_subcommand_no_git() {
assert_eq!(extract_git_subcommand("cargo build"), "");
}
#[test]
fn extract_git_subcommand_git_only() {
assert_eq!(extract_git_subcommand("git"), "");
}
#[test]
fn extract_git_subcommand_full_subcommand() {
assert_eq!(
extract_git_subcommand("git branch -d feature"),
"branch -d feature"
);
}
#[test]
fn extract_git_subcommand_with_double_dash() {
assert_eq!(extract_git_subcommand("git -- diff"), "diff");
}
#[test]
fn extract_git_subcommand_stash_list() {
assert_eq!(extract_git_subcommand("git stash list"), "stash list");
}
#[test]
fn extract_git_subcommand_multiple_env() {
assert_eq!(
extract_git_subcommand("CC=gcc CXX=g++ git status"),
"status"
);
}
#[test]
fn extract_git_subcommand_multiple_flags() {
assert_eq!(
extract_git_subcommand("git -C /repo --git-dir /other status"),
"status"
);
}
#[test]
fn extract_git_subcommand_shell_prefix_not_skipped() {
assert_eq!(extract_git_subcommand("sudo git status"), "");
}
#[test]
fn extract_git_subcommand_flag_with_multiple_args() {
assert_eq!(
extract_git_subcommand("git branch --merged master"),
"branch --merged master"
);
}
#[test]
fn heredoc_to_tmp_with_rust_body_allowed() {
ok("cat > /tmp/test_match.rs << 'EOF'\nfn test() { match x { \"a\" => 1, _ => 0 } }\nEOF");
}
#[test]
fn redirect_to_private_tmp_allowed() {
ok("echo hello > /private/tmp/mahbot_test_out.txt");
}
#[test]
fn tee_under_tmp_allowed() {
ok("tee /tmp/scratch.log");
}
#[test]
fn touch_under_tmp_allowed() {
ok("touch /tmp/scratch.txt");
}
#[test]
fn mkdir_p_under_tmp_allowed() {
ok("mkdir -p /tmp/scratch_dir");
}
#[test]
fn tee_workspace_rejected() {
assert_rejected("tee output.log");
}
#[test]
fn rm_under_tmp_still_rejected() {
assert_rejected("rm /tmp/scratch.txt");
}
#[test]
fn scratch_mutators_are_subset_of_mutating_commands() {
for cmd in SCRATCH_MUTATORS {
assert!(
MUTATING_COMMANDS.contains(cmd),
"SCRATCH_MUTATORS entry '{cmd}' must also be in MUTATING_COMMANDS"
);
}
}
}