| 1 | //! Deny rules hold against commands whose word the shell resolves at run |
| 2 | //! time, and allow rules only cover the command as written. |
| 3 | |
| 4 | use codewhale_execpolicy::{ |
| 5 | AskForApproval, ExecApprovalRequirement, ExecPolicyContext, ExecPolicyEngine, PermissionAction, |
| 6 | Ruleset, ToolAskRule, |
| 7 | bash_arity::BashArityDict, |
| 8 | command_safety::{is_agent_readonly_shell_command, is_parallel_readonly_command}, |
| 9 | toml_rules::{ExecPolicyConfig, RuleDecision}, |
| 10 | }; |
| 11 | |
| 12 | fn context(command: &str, approval: AskForApproval) -> ExecPolicyContext<'_> { |
| 13 | ExecPolicyContext { |
| 14 | command, |
| 15 | cwd: "/workspace", |
| 16 | tool: Some("exec_shell"), |
| 17 | path: None, |
| 18 | ask_for_approval: approval, |
| 19 | sandbox_mode: None, |
| 20 | } |
| 21 | } |
| 22 | |
| 23 | fn deny_rm_engines() -> [ExecPolicyEngine; 2] { |
| 24 | [ |
| 25 | ExecPolicyEngine::new(vec![], vec!["rm".to_string()]), |
| 26 | ExecPolicyEngine::with_rulesets(vec![Ruleset::user(vec![], vec![]).with_ask_rules(vec![ |
| 27 | ToolAskRule { |
| 28 | action: PermissionAction::Deny, |
| 29 | ..ToolAskRule::exec_shell("rm") |
| 30 | }, |
| 31 | ])]), |
| 32 | ] |
| 33 | } |
| 34 | |
| 35 | /// Spellings whose command word is only known at run time, or which run a |
| 36 | /// command behind a reserved word or a wrapper's operands. |
| 37 | const HIDDEN_RM: &[&str] = &[ |
| 38 | "v=rm; $v -f f", |
| 39 | "v=rm; \"$v\" -f f", |
| 40 | "v=rm; ${v} -f f", |
| 41 | "sudo $v f", |
| 42 | "bash -c '$v f'", |
| 43 | "eval \"$v f\"", |
| 44 | "$(echo rm) -f f", |
| 45 | "`echo rm` f", |
| 46 | "rm${IFS}x", |
| 47 | "x=r; ${x}m f", |
| 48 | "IFS=,; c=rm,x; $c", |
| 49 | "/bin/r[m] -f f", |
| 50 | "{rm,-f,f}", |
| 51 | "$'\\x72m' f", |
| 52 | "printf rm | sh", |
| 53 | "echo rm x | bash", |
| 54 | "sh <<< 'rm x'", |
| 55 | "source <(echo rm x)", |
| 56 | "find . -exec rm {} +", |
| 57 | "find . -execdir rm {} \\;", |
| 58 | "if true; then rm x; fi", |
| 59 | "while rm x; do :; done", |
| 60 | "until rm x; do :; done", |
| 61 | "! rm x", |
| 62 | "function f { rm x; }", |
| 63 | "chroot /newroot rm -rf /", |
| 64 | "chroot /newroot sh -c 'rm -rf /'", |
| 65 | "sudo --user root bash -c 'rm -rf /'", |
| 66 | "sudo --user root rm -rf /", |
| 67 | "timeout -s KILL 5 rm x", |
| 68 | "env -S'rm x'", |
| 69 | // Wrapper options missing from the option tables, read both ways. |
| 70 | "env -P /usr/bin rm -rf /", |
| 71 | "ls | xargs -J % rm -rf %", |
| 72 | "proot -R /x rm -rf /", |
| 73 | "chroot -u root / rm -rf /", |
| 74 | "doas -a style rm x", |
| 75 | "sudo -a type rm x", |
| 76 | // Substitution bodies read past quotes, and a `case` inside one. |
| 77 | "echo $(case x in x) rm -rf /;; esac)", |
| 78 | "echo $(echo \")\"; rm -rf /)", |
| 79 | "echo 'unterminated $(rm x)", |
| 80 | // `<<` inside arithmetic is a shift, not a heredoc. |
| 81 | "((x = 1 << 2))\nrm -rf /", |
| 82 | "let x=1<<2\nrm x", |
| 83 | // Replacement strings are only known at run time. |
| 84 | "echo 'rm -rf /' | xargs -I{} sh -c {}", |
| 85 | "echo rm | xargs -I CMD CMD -rf /", |
| 86 | "find . -exec sh -c {} \\;", |
| 87 | // More wrappers, shells and code-as-string commands. |
| 88 | "bash.exe -c 'rm -rf /'", |
| 89 | "caffeinate -i rm -rf /", |
| 90 | "arch -arm64 rm -rf /", |
| 91 | "noglob rm -rf /", |
| 92 | "nsenter -t 1 -m rm x", |
| 93 | "unshare -r rm x", |
| 94 | "sandbox-exec -n no-network rm x", |
| 95 | "runuser -u root -- rm x", |
| 96 | "trap 'rm -rf /' EXIT", |
| 97 | "su -c 'rm -rf /' root", |
| 98 | "flock /tmp/lock -c 'rm x'", |
| 99 | "script -qc 'rm x' /dev/null", |
| 100 | "watch 'ls; rm -rf /'", |
| 101 | "cmd /c rm x", |
| 102 | "pwsh -NoProfile -Command rm x", |
| 103 | "powershell -enc cgBtACAAeAA=", |
| 104 | "wsl -e rm x", |
| 105 | // Options may follow `-c`; the command string is the first operand. |
| 106 | "bash -c -e 'rm -rf /'", |
| 107 | "sh -c -- 'rm -rf /'", |
| 108 | "bash -c -o pipefail 'rm x'", |
| 109 | // A script operand that names stdin reads the pipe or here-string. |
| 110 | "echo 'rm x' | bash /dev/stdin", |
| 111 | "bash /dev/stdin <<< 'rm x'", |
| 112 | ". /dev/stdin <<< 'rm x'", |
| 113 | "sh /proc/self/fd/0 <<< 'rm x'", |
| 114 | // Launchers that run their operands as a command. |
| 115 | "pkexec rm x", |
| 116 | "pkexec --user root rm x", |
| 117 | "run0 -u root rm x", |
| 118 | "fakeroot rm -rf /", |
| 119 | "taskset -c 0 rm x", |
| 120 | "taskset 0x3 rm x", |
| 121 | "strace -f -o /tmp/t rm x", |
| 122 | "ltrace rm x", |
| 123 | "chrt 1 rm -rf /", |
| 124 | "chrt -r 10 rm x", |
| 125 | "prlimit --nofile=10 rm -rf /", |
| 126 | "systemd-run --user --scope rm x", |
| 127 | "numactl -N 0 rm x", |
| 128 | "firejail --noprofile rm x", |
| 129 | "xvfb-run -a rm x", |
| 130 | "dbus-launch --exit-with-session rm x", |
| 131 | "proxychains -q rm x", |
| 132 | "eatmydata rm x", |
| 133 | "cpulimit -l 50 rm x", |
| 134 | "sg wheel -c 'rm x'", |
| 135 | "sg wheel 'rm x'", |
| 136 | "gtimeout 5 rm x", |
| 137 | "gnice -n 5 rm x", |
| 138 | ]; |
| 139 | |
| 140 | /// Literal spellings that were already denied and must stay denied. |
| 141 | const LITERAL_RM: &[&str] = &[ |
| 142 | "rm -f f", |
| 143 | "\\rm x", |
| 144 | "r''m x", |
| 145 | "/bin/rm x", |
| 146 | "(rm x)", |
| 147 | "{ rm x; }", |
| 148 | "case a in a) rm x;; esac", |
| 149 | "f(){ rm -f x; }; f", |
| 150 | "xargs rm", |
| 151 | "command -p rm x", |
| 152 | ]; |
| 153 | |
| 154 | /// Commands whose parse runs into a budget: past the budget, nothing was |
| 155 | /// checked, so the command is unresolved. |
| 156 | fn budget_commands() -> Vec<String> { |
| 157 | vec![ |
| 158 | format!("sudo {}rm -rf /", "-H ".repeat(600)), |
| 159 | format!("env {}rm -rf /", "-i ".repeat(600)), |
| 160 | (1..=256) |
| 161 | .map(|index| format!("true {index}")) |
| 162 | .chain(["rm -rf /".to_string()]) |
| 163 | .collect::<Vec<_>>() |
| 164 | .join("; "), |
| 165 | ] |
| 166 | } |
| 167 | |
| 168 | fn denied(engine: &ExecPolicyEngine, command: &str) -> bool { |
| 169 | let decision = engine |
| 170 | .check(context(command, AskForApproval::Never)) |
| 171 | .expect("policy check"); |
| 172 | !decision.allow |
| 173 | && matches!( |
| 174 | decision.requirement, |
| 175 | ExecApprovalRequirement::Forbidden { .. } |
| 176 | ) |
| 177 | } |
| 178 | |
| 179 | #[test] |
| 180 | fn deny_rules_hold_against_runtime_resolved_and_reserved_word_spellings() { |
| 181 | let budget = budget_commands(); |
| 182 | let mut missed = Vec::new(); |
| 183 | for engine in deny_rm_engines() { |
| 184 | for command in HIDDEN_RM |
| 185 | .iter() |
| 186 | .chain(LITERAL_RM) |
| 187 | .copied() |
| 188 | .chain(budget.iter().map(String::as_str)) |
| 189 | { |
| 190 | if !denied(&engine, command) { |
| 191 | missed.push(command.chars().take(80).collect::<String>()); |
| 192 | } |
| 193 | } |
| 194 | } |
| 195 | assert!(missed.is_empty(), "not denied: {missed:#?}"); |
| 196 | } |
| 197 | |
| 198 | #[test] |
| 199 | fn unresolved_words_prompt_only_where_a_person_always_sees_the_prompt() { |
| 200 | let engine = ExecPolicyEngine::new(vec![], vec!["rm".to_string()]); |
| 201 | let requirement = |approval| { |
| 202 | engine |
| 203 | .check(context("v=rm; $v x", approval)) |
| 204 | .expect("policy check") |
| 205 | .requirement |
| 206 | }; |
| 207 | assert!(matches!( |
| 208 | requirement(AskForApproval::OnRequest), |
| 209 | ExecApprovalRequirement::NeedsApproval { .. } |
| 210 | )); |
| 211 | assert!(matches!( |
| 212 | requirement(AskForApproval::UnlessTrusted), |
| 213 | ExecApprovalRequirement::NeedsApproval { .. } |
| 214 | )); |
| 215 | // `OnFailure` is also the posture of sessions that approve on their own. |
| 216 | for approval in [AskForApproval::OnFailure, AskForApproval::Never] { |
| 217 | assert!(matches!( |
| 218 | requirement(approval), |
| 219 | ExecApprovalRequirement::Forbidden { .. } |
| 220 | )); |
| 221 | } |
| 222 | } |
| 223 | |
| 224 | #[test] |
| 225 | fn ordinary_commands_stay_allowed_next_to_a_deny_rule() { |
| 226 | for engine in deny_rm_engines() { |
| 227 | for command in [ |
| 228 | "ls *.rs", |
| 229 | "echo $HOME", |
| 230 | "[ -f x ] && ls", |
| 231 | "find . -name '*.rs'", |
| 232 | "if true; then ls; fi", |
| 233 | "rmdir x", |
| 234 | "sudo -u root ls", |
| 235 | "command -v rm", |
| 236 | "command -pV rm", |
| 237 | "sudo -E ls $f", |
| 238 | "nice -5 ls", |
| 239 | "timeout -v 5 ls", |
| 240 | "xargs -I{} echo {}", |
| 241 | "find . -exec grep -l x {} +", |
| 242 | "cat <<EOF\nhello\nEOF\nls", |
| 243 | "for ((i = 0; i < 3; i++)); do echo $i; done", |
| 244 | "echo $(echo \")\")", |
| 245 | "watch -n 5 ls", |
| 246 | ] { |
| 247 | let decision = engine |
| 248 | .check(context(command, AskForApproval::Never)) |
| 249 | .expect("policy check"); |
| 250 | assert!(decision.allow, "{command:?} was denied: {decision:?}"); |
| 251 | } |
| 252 | } |
| 253 | // Without any deny rule, a runtime-resolved word is left to the mode. |
| 254 | let open = ExecPolicyEngine::new(vec![], vec![]); |
| 255 | for command in ["v=ls; $v", "echo $HOME", "ls *.rs"] { |
| 256 | let decision = open |
| 257 | .check(context(command, AskForApproval::Never)) |
| 258 | .expect("policy check"); |
| 259 | assert!(decision.allow, "{command:?} was denied: {decision:?}"); |
| 260 | } |
| 261 | } |
| 262 | |
| 263 | #[test] |
| 264 | fn trusted_prefix_does_not_cover_interposed_options_or_nested_code() { |
| 265 | let engine = ExecPolicyEngine::new(vec!["git status".to_string(), "ls".to_string()], vec![]); |
| 266 | let trusted = |command: &str| { |
| 267 | matches!( |
| 268 | engine |
| 269 | .check(context(command, AskForApproval::UnlessTrusted)) |
| 270 | .expect("policy check") |
| 271 | .requirement, |
| 272 | ExecApprovalRequirement::Skip { .. } |
| 273 | ) |
| 274 | }; |
| 275 | assert!(trusted("git status")); |
| 276 | assert!(trusted("git status -s --porcelain")); |
| 277 | assert!(trusted("ls -la")); |
| 278 | for command in [ |
| 279 | "git -ccore.fsmonitor=x status", |
| 280 | "git -c core.fsmonitor=x status", |
| 281 | "git --exec-path=/x status", |
| 282 | "git -C /elsewhere status", |
| 283 | "ls $(touch x)", |
| 284 | "ls `touch x`", |
| 285 | "$L -la", |
| 286 | ] { |
| 287 | assert!(!trusted(command), "{command:?} was auto-approved"); |
| 288 | } |
| 289 | |
| 290 | let dict = BashArityDict::new(); |
| 291 | assert!(!dict.allow_rule_matches("git status", "git --exec-path=/x status")); |
| 292 | assert!(dict.allow_rule_matches("python -m pytest", "python -m pytest -x")); |
| 293 | assert!(!dict.allow_rule_matches("python -m pytest", "python -m pip install x")); |
| 294 | } |
| 295 | |
| 296 | #[test] |
| 297 | fn file_rules_fail_closed_on_runtime_resolved_words() { |
| 298 | let config = ExecPolicyConfig::parse( |
| 299 | r#" |
| 300 | [rules.shell] |
| 301 | allow = ["git status", "ls"] |
| 302 | deny = ["rm", "rm *"] |
| 303 | "#, |
| 304 | ) |
| 305 | .expect("parse rules"); |
| 306 | let budget = budget_commands(); |
| 307 | let missed: Vec<&str> = HIDDEN_RM |
| 308 | .iter() |
| 309 | .copied() |
| 310 | .chain(budget.iter().map(String::as_str)) |
| 311 | .filter(|command| !matches!(config.evaluate(command), RuleDecision::Deny(_))) |
| 312 | .collect(); |
| 313 | assert!(missed.is_empty(), "not denied: {missed:#?}"); |
| 314 | assert_eq!(config.evaluate("git status -s"), RuleDecision::Allow); |
| 315 | for command in ["git -ccore.fsmonitor=x status", "ls $(touch x)"] { |
| 316 | assert!( |
| 317 | matches!(config.evaluate(command), RuleDecision::AskUser(_)), |
| 318 | "{command:?} was auto-approved" |
| 319 | ); |
| 320 | } |
| 321 | } |
| 322 | |
| 323 | #[test] |
| 324 | fn agent_read_only_rejects_a_glob_that_can_expand_to_an_option() { |
| 325 | for command in ["rg foo *", "ls *", "git log ''*", "cat *.md"] { |
| 326 | assert!( |
| 327 | !is_agent_readonly_shell_command(command), |
| 328 | "{command:?} was classified read-only" |
| 329 | ); |
| 330 | } |
| 331 | for command in [ |
| 332 | "ls src/*", |
| 333 | "rg foo ./*", |
| 334 | "find . -name '*.rs'", |
| 335 | "cat README.md", |
| 336 | ] { |
| 337 | assert!( |
| 338 | is_agent_readonly_shell_command(command), |
| 339 | "{command:?} was rejected" |
| 340 | ); |
| 341 | } |
| 342 | } |
| 343 | |
| 344 | #[test] |
| 345 | fn parallel_read_only_rejects_parentheses() { |
| 346 | assert!(is_parallel_readonly_command("cat README.md")); |
| 347 | for command in [ |
| 348 | "cat .(e:'touch pwned':)", |
| 349 | "ls foo(e:'id':)", |
| 350 | "rg needle .(+cmd)", |
| 351 | "cat (id)", |
| 352 | "gh pr view 1(e:'id':)", |
| 353 | ] { |
| 354 | assert!( |
| 355 | !is_parallel_readonly_command(command), |
| 356 | "{command:?} was classified read-only" |
| 357 | ); |
| 358 | } |
| 359 | } |
| 360 | |
| 361 | #[test] |
| 362 | fn typed_deny_rule_skips_global_options_before_the_subcommand() { |
| 363 | let engine = ExecPolicyEngine::with_rulesets(vec![ |
| 364 | Ruleset::user(vec![], vec![]).with_ask_rules(vec![ToolAskRule { |
| 365 | action: PermissionAction::Deny, |
| 366 | workspace: Some("/workspace".to_string()), |
| 367 | ..ToolAskRule::exec_shell("git push") |
| 368 | }]), |
| 369 | ]); |
| 370 | for command in [ |
| 371 | "git push", |
| 372 | "git -C . push", |
| 373 | "git -c a=b push origin main", |
| 374 | "git --no-pager push", |
| 375 | ] { |
| 376 | assert!(denied(&engine, command), "{command} must be denied"); |
| 377 | } |
| 378 | assert!(!denied(&engine, "git -C . status")); |
| 379 | // The rule stays scoped to its workspace. |
| 380 | let elsewhere = ExecPolicyContext { |
| 381 | cwd: "/other", |
| 382 | ..context("git -C . push", AskForApproval::Never) |
| 383 | }; |
| 384 | assert!(engine.check(elsewhere).expect("policy check").allow); |
| 385 | } |
| 386 |