返回 CodeWhale
shell_parse_policy.rs
根目录 / crates / execpolicy / tests / shell_parse_policy.rs
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
386 lines RUST