1use std::path::{Component, Path, PathBuf};
21
22use serde::Serialize;
23
24use crate::error::{Result, TuffError};
25use crate::policy::{PolicyConfig, PolicyEffect, PolicyRule, PolicySubject};
26
27const MAX_DEPTH: usize = 8;
30
31#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum PolicyAction {
34 Shell(String),
36 Read(String),
38 Edit(String),
40 Mcp { server: String, tool: String },
42}
43
44#[derive(Debug, Clone, Default, PartialEq, Eq)]
46pub struct PolicyHookRequest {
47 pub event: String,
49 pub cwd: Option<PathBuf>,
51 pub roots: Vec<PathBuf>,
53 pub actions: Vec<PolicyAction>,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct PolicyHookAnswer {
59 pub stdout: String,
60 pub exit_code: i32,
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum PolicyHookUse {
66 Never,
68 Required,
70 Optional,
73}
74
75#[derive(Debug, Clone, Copy)]
77pub enum PolicyVerdict<'a> {
78 NoMatch,
80 Matched(&'a PolicyDecision),
81 Failed(&'a str),
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
87pub struct PolicyDecision {
88 pub effect: PolicyEffect,
89 pub policy: String,
90 pub rule: usize,
92 pub description: String,
93 #[serde(skip_serializing_if = "Option::is_none")]
94 pub reason: Option<String>,
95}
96
97impl PolicyDecision {
98 pub fn message(&self) -> String {
100 let verb = match self.effect {
101 PolicyEffect::Deny => "denies",
102 PolicyEffect::Ask => "asks before",
103 };
104 let mut message = format!(
105 "Tuff policy '{}' {verb} this call: rule {} ({})",
106 self.policy, self.rule, self.description
107 );
108 if let Some(reason) = &self.reason {
109 message.push_str(": ");
110 message.push_str(reason);
111 }
112 message
113 }
114}
115
116#[derive(Debug, Clone, Copy)]
119pub struct EvalContext<'a> {
120 pub root: &'a Path,
121 pub cwd: &'a Path,
122}
123
124pub fn evaluate(
127 policy_id: &str,
128 policy: &PolicyConfig,
129 actions: &[PolicyAction],
130 context: EvalContext<'_>,
131) -> Result<Option<PolicyDecision>> {
132 let facts = Facts::gather(actions, context);
133 let mut best: Option<PolicyDecision> = None;
134 for (index, rule) in policy.rules.iter().enumerate() {
135 let effect = rule.effect()?;
136 if best
137 .as_ref()
138 .is_some_and(|best| best.effect == PolicyEffect::Deny || effect == PolicyEffect::Ask)
139 {
140 continue;
141 }
142 if facts.matches(rule)? {
143 best = Some(PolicyDecision {
144 effect,
145 policy: policy_id.to_string(),
146 rule: index + 1,
147 description: rule.describe(),
148 reason: rule.reason.clone(),
149 });
150 }
151 }
152 Ok(best)
153}
154
155#[derive(Debug, Default)]
157struct Facts {
158 commands: Vec<Vec<String>>,
160 reads: Vec<String>,
162 edits: Vec<String>,
164 mcp: Vec<(String, String)>,
165}
166
167impl Facts {
168 fn gather(actions: &[PolicyAction], context: EvalContext<'_>) -> Self {
169 let mut facts = Self::default();
170 let mut reads = Vec::new();
171 let mut edits = Vec::new();
172 for action in actions {
173 match action {
174 PolicyAction::Shell(script) => {
175 for command in shell_commands(script) {
176 let (command_reads, command_edits) = command_files(&command);
177 reads.extend(command_reads);
178 edits.extend(command_edits);
179 if !command.argv.is_empty() {
180 facts.commands.push(command.argv);
181 }
182 }
183 }
184 PolicyAction::Read(path) => reads.push(path.clone()),
185 PolicyAction::Edit(path) => edits.push(path.clone()),
186 PolicyAction::Mcp { server, tool } => {
187 facts.mcp.push((server.clone(), tool.clone()));
188 }
189 }
190 }
191 facts.reads = reads
192 .iter()
193 .filter_map(|path| project_relative(path, context))
194 .collect();
195 facts.edits = edits
196 .iter()
197 .filter_map(|path| project_relative(path, context))
198 .collect();
199 facts
200 }
201
202 fn matches(&self, rule: &PolicyRule) -> Result<bool> {
203 Ok(match rule.subject()? {
204 PolicySubject::Command(words) => self
205 .commands
206 .iter()
207 .any(|argv| command_matches(words, argv)),
208 PolicySubject::Read(patterns) => self
209 .reads
210 .iter()
211 .any(|path| patterns.iter().any(|pattern| path_matches(pattern, path))),
212 PolicySubject::Edit(patterns) => self
213 .edits
214 .iter()
215 .any(|path| patterns.iter().any(|pattern| path_matches(pattern, path))),
216 PolicySubject::Mcp { server, tool } => self
217 .mcp
218 .iter()
219 .any(|(s, t)| wildcard_matches(server, s) && wildcard_matches(tool, t)),
220 })
221 }
222}
223
224pub fn find_policy_root(starts: &[PathBuf], dir_prefix: &str, policy_id: &str) -> Option<PathBuf> {
227 let record = Path::new(dir_prefix)
228 .join("policies")
229 .join(policy_id)
230 .join("policy.toml");
231 starts.iter().find_map(|start| {
232 start
233 .ancestors()
234 .find(|dir| dir.join(&record).is_file())
235 .map(Path::to_path_buf)
236 })
237}
238
239pub fn load_installed_policy(path: &Path) -> Result<PolicyConfig> {
241 #[derive(serde::Deserialize)]
242 struct Record {
243 policy: PolicyConfig,
244 }
245 let text = std::fs::read_to_string(path).map_err(|error| {
246 TuffError::not_found(format!("cannot read {}: {error}", path.display()))
247 })?;
248 let record: Record = toml::from_str(&text).map_err(|error| {
249 TuffError::corrupt(format!(
250 "{} is not a policy record: {error}",
251 path.display()
252 ))
253 })?;
254 crate::policy::validate_policy(&record.policy)?;
255 Ok(record.policy)
256}
257
258pub fn command_matches(rule: &[String], argv: &[String]) -> bool {
272 let (Some(program), Some(invoked)) = (rule.first(), argv.first()) else {
273 return false;
274 };
275 if program_name(invoked) != *program {
276 return false;
277 }
278 let rest = &rule[1..];
279 let Some(first) = rest.first() else {
280 return true;
281 };
282 let mut position = 1;
283 if !first.starts_with('-') {
284 while let Some(arg) = argv.get(position) {
285 if arg == first || !arg.starts_with('-') {
286 break;
287 }
288 if arg == "--" {
289 position += 1;
290 break;
291 }
292 position += if option_takes_value(program, arg) {
293 2
294 } else {
295 1
296 };
297 }
298 if argv.get(position) != Some(first) {
299 return false;
300 }
301 position += 1;
302 return words_follow(&rest[1..], &argv[position.min(argv.len())..]);
303 }
304 words_follow(rest, &argv[position..])
305}
306
307fn words_follow(words: &[String], args: &[String]) -> bool {
308 let mut remaining = args;
309 for word in words {
310 if let Some(letters) = short_cluster(word) {
311 let set: Vec<char> = remaining
312 .iter()
313 .filter_map(|arg| short_cluster(arg))
314 .flat_map(str::chars)
315 .collect();
316 if !letters.chars().all(|letter| set.contains(&letter)) {
317 return false;
318 }
319 continue;
320 }
321 match remaining.iter().position(|arg| arg == word) {
322 Some(found) => remaining = &remaining[found + 1..],
323 None => return false,
324 }
325 }
326 true
327}
328
329fn short_cluster(word: &str) -> Option<&str> {
331 let letters = word.strip_prefix('-')?;
332 (!letters.is_empty()
333 && !letters.starts_with('-')
334 && letters.chars().all(|c| c.is_ascii_alphanumeric()))
335 .then_some(letters)
336}
337
338fn option_takes_value(program: &str, option: &str) -> bool {
341 if option.contains('=') {
342 return false;
343 }
344 let options: &[&str] = match program {
345 "git" => &[
346 "-C",
347 "-c",
348 "--git-dir",
349 "--work-tree",
350 "--namespace",
351 "--super-prefix",
352 "--config-env",
353 ],
354 "docker" | "podman" => &[
355 "-H",
356 "--host",
357 "-c",
358 "--context",
359 "--config",
360 "-l",
361 "--log-level",
362 ],
363 "kubectl" | "oc" => &[
364 "-n",
365 "--namespace",
366 "--context",
367 "--cluster",
368 "--kubeconfig",
369 "-s",
370 "--server",
371 "--user",
372 "--token",
373 ],
374 "helm" => &["-n", "--namespace", "--kube-context", "--kubeconfig"],
375 "npm" | "pnpm" | "yarn" => &[
376 "-C",
377 "--prefix",
378 "--dir",
379 "--cwd",
380 "--filter",
381 "-w",
382 "--workspace",
383 ],
384 "cargo" => &["-C", "--config", "-Z", "--color"],
385 "gh" => &["-R", "--repo"],
386 "aws" => &["--profile", "--region", "--output", "--endpoint-url"],
387 "gcloud" => &["--project", "--account", "--configuration"],
388 _ => &[],
389 };
390 options.contains(&option)
391}
392
393fn program_name(word: &str) -> &str {
394 word.rsplit('/').next().unwrap_or(word)
395}
396
397#[derive(Debug, Default, Clone, PartialEq, Eq)]
399struct ShellCommand {
400 argv: Vec<String>,
401 inputs: Vec<String>,
403 outputs: Vec<String>,
405}
406
407fn shell_commands(script: &str) -> Vec<ShellCommand> {
410 let mut commands = Vec::new();
411 collect_commands(script, 0, &mut commands);
412 commands
413}
414
415fn collect_commands(script: &str, depth: usize, out: &mut Vec<ShellCommand>) {
416 let (parsed, substitutions) = parse_script(script);
417 if depth < MAX_DEPTH {
418 for substitution in substitutions {
419 collect_commands(&substitution, depth + 1, out);
420 }
421 }
422 for command in parsed {
423 unwrap_command(command, depth, out);
424 }
425}
426
427const RESERVED: &[&str] = &[
429 "if", "then", "else", "elif", "fi", "do", "done", "while", "until", "!", "{", "}", "case",
430 "esac", "for", "select", "function", "time", "coproc",
431];
432
433fn unwrap_command(mut command: ShellCommand, depth: usize, out: &mut Vec<ShellCommand>) {
434 loop {
435 let skip = command
436 .argv
437 .iter()
438 .take_while(|word| RESERVED.contains(&word.as_str()) || is_assignment(word))
439 .count();
440 command.argv.drain(..skip);
441 let Some(first) = command.argv.first() else {
442 out.push(command);
443 return;
444 };
445 let name = program_name(first).to_string();
446 let args = &command.argv[1..];
447 let inner: Option<Unwrapped> = match name.as_str() {
448 "env" => Some(unwrap_env(args)),
449 "sudo" => Some(Unwrapped::Argv(skip_options(
450 args,
451 &["-u", "-g", "-h", "-p", "-C", "-D", "-r", "-t", "-U", "-T"],
452 ))),
453 "doas" => Some(Unwrapped::Argv(skip_options(args, &["-u", "-C"]))),
454 "nice" => Some(Unwrapped::Argv(skip_options(args, &["-n"]))),
455 "ionice" => Some(Unwrapped::Argv(skip_options(args, &["-c", "-n", "-p"]))),
456 "stdbuf" => Some(Unwrapped::Argv(skip_options(args, &["-i", "-o", "-e"]))),
457 "exec" => Some(Unwrapped::Argv(skip_options(args, &["-a"]))),
458 "command" | "builtin" | "nohup" | "setsid" | "unbuffer" | "caffeinate" => {
459 Some(Unwrapped::Argv(skip_options(args, &[])))
460 }
461 "timeout" | "gtimeout" => {
462 let rest = skip_options(args, &["-s", "--signal", "-k", "--kill-after"]);
463 Some(Unwrapped::Argv(rest.into_iter().skip(1).collect()))
464 }
465 "xargs" => Some(Unwrapped::Argv(skip_options(
466 args,
467 &[
468 "-I", "-i", "-n", "-P", "-d", "-L", "-l", "-s", "-E", "-e", "-a",
469 ],
470 ))),
471 "watch" => Some(Unwrapped::Script(
472 skip_options(args, &["-n", "--interval", "-d"]).join(" "),
473 )),
474 "busybox" => Some(Unwrapped::Argv(args.to_vec())),
475 "eval" => Some(Unwrapped::Script(args.join(" "))),
476 "sh" | "bash" | "zsh" | "dash" | "ksh" | "mksh" | "ash" | "fish" => {
477 shell_script_argument(args).map(Unwrapped::Script)
478 }
479 _ => None,
480 };
481 match inner {
482 None => {
483 out.push(command);
484 return;
485 }
486 Some(Unwrapped::Argv(argv)) => {
487 out.push(ShellCommand {
489 argv: command.argv.clone(),
490 ..ShellCommand::default()
491 });
492 command.argv = argv;
493 }
494 Some(Unwrapped::Script(script)) => {
495 out.push(command);
496 if depth < MAX_DEPTH {
497 collect_commands(&script, depth + 1, out);
498 }
499 return;
500 }
501 }
502 }
503}
504
505enum Unwrapped {
506 Argv(Vec<String>),
507 Script(String),
508}
509
510fn is_assignment(word: &str) -> bool {
511 word.split_once('=').is_some_and(|(name, _)| {
512 !name.is_empty()
513 && !name.starts_with(|c: char| c.is_ascii_digit())
514 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
515 })
516}
517
518fn skip_options(args: &[String], with_value: &[&str]) -> Vec<String> {
521 let mut index = 0;
522 while let Some(arg) = args.get(index) {
523 if arg == "--" {
524 index += 1;
525 break;
526 }
527 if !arg.starts_with('-') || arg == "-" {
528 break;
529 }
530 index += if with_value.contains(&arg.as_str()) {
531 2
532 } else {
533 1
534 };
535 }
536 args.get(index..)
537 .map(<[String]>::to_vec)
538 .unwrap_or_default()
539}
540
541fn unwrap_env(args: &[String]) -> Unwrapped {
542 let mut index = 0;
543 while let Some(arg) = args.get(index) {
544 match arg.as_str() {
545 "-S" | "--split-string" => {
546 return Unwrapped::Script(args[index + 1..].join(" "));
547 }
548 "-u" | "--unset" | "-C" | "--chdir" => index += 2,
549 "--" => {
550 index += 1;
551 break;
552 }
553 _ if arg.starts_with('-') || is_assignment(arg) => index += 1,
554 _ => break,
555 }
556 }
557 Unwrapped::Argv(
558 args.get(index..)
559 .map(<[String]>::to_vec)
560 .unwrap_or_default(),
561 )
562}
563
564fn shell_script_argument(args: &[String]) -> Option<String> {
566 let mut reads_script = false;
567 for arg in args {
568 if reads_script {
569 return Some(arg.clone());
570 }
571 if arg == "-c" || (short_cluster(arg).is_some_and(|letters| letters.contains('c'))) {
572 reads_script = true;
573 } else if arg == "-o" || arg == "+o" {
574 continue;
575 } else if !arg.starts_with('-') && !arg.starts_with('+') {
576 return None;
577 }
578 }
579 None
580}
581
582fn parse_script(script: &str) -> (Vec<ShellCommand>, Vec<String>) {
587 let chars: Vec<char> = script.chars().collect();
588 let mut commands = Vec::new();
589 let mut substitutions = Vec::new();
590 let mut current = ShellCommand::default();
591 let mut word = String::new();
592 let mut in_word = false;
593 let mut redirect: Option<bool> = None; let mut heredocs: Vec<(String, bool)> = Vec::new();
595 let mut i = 0;
596
597 let finish_word = |word: &mut String,
598 in_word: &mut bool,
599 redirect: &mut Option<bool>,
600 current: &mut ShellCommand,
601 heredocs: &mut Vec<(String, bool)>,
602 heredoc_pending: &mut Option<bool>| {
603 if !*in_word {
604 return;
605 }
606 let text = std::mem::take(word);
607 *in_word = false;
608 if let Some(strip_tabs) = heredoc_pending.take() {
609 heredocs.push((text, strip_tabs));
610 return;
611 }
612 match redirect.take() {
613 Some(true) => current.outputs.push(text),
614 Some(false) => current.inputs.push(text),
615 None => current.argv.push(text),
616 }
617 };
618 let mut heredoc_pending: Option<bool> = None;
619
620 while i < chars.len() {
621 let c = chars[i];
622 match c {
623 ' ' | '\t' => {
624 finish_word(
625 &mut word,
626 &mut in_word,
627 &mut redirect,
628 &mut current,
629 &mut heredocs,
630 &mut heredoc_pending,
631 );
632 i += 1;
633 }
634 '\n' | ';' | '&' | '|' | '(' | ')' => {
635 finish_word(
636 &mut word,
637 &mut in_word,
638 &mut redirect,
639 &mut current,
640 &mut heredocs,
641 &mut heredoc_pending,
642 );
643 if c == '&' && chars.get(i + 1) == Some(&'>') {
644 i += if chars.get(i + 2) == Some(&'>') { 3 } else { 2 };
646 redirect = Some(true);
647 continue;
648 }
649 if c == '|' && chars.get(i + 1) == Some(&'&') {
650 i += 1;
651 }
652 commands.push(std::mem::take(&mut current));
653 redirect = None;
654 i += 1;
655 if c == '\n' {
656 i = skip_heredoc_bodies(&chars, i, &mut heredocs);
657 }
658 }
659 '#' if !in_word => {
660 while i < chars.len() && chars[i] != '\n' {
661 i += 1;
662 }
663 }
664 '<' | '>' => {
665 if in_word && word.chars().all(|d| d.is_ascii_digit()) {
667 word.clear();
668 in_word = false;
669 } else {
670 finish_word(
671 &mut word,
672 &mut in_word,
673 &mut redirect,
674 &mut current,
675 &mut heredocs,
676 &mut heredoc_pending,
677 );
678 }
679 let output = c == '>';
680 i += 1;
681 if !output && chars.get(i) == Some(&'<') {
682 i += 1;
683 if chars.get(i) == Some(&'<') {
684 i += 1;
686 redirect = None;
687 heredoc_pending = None;
688 let (_, next) = read_word(&chars, i, &mut substitutions);
690 i = next;
691 continue;
692 }
693 let strip_tabs = chars.get(i) == Some(&'-');
694 if strip_tabs {
695 i += 1;
696 }
697 heredoc_pending = Some(strip_tabs);
698 continue;
699 }
700 if matches!(chars.get(i), Some('>' | '|')) {
701 i += 1;
702 }
703 if chars.get(i) == Some(&'&') {
704 i += 1;
706 while i < chars.len() && (chars[i].is_ascii_digit() || chars[i] == '-') {
707 i += 1;
708 }
709 continue;
710 }
711 redirect = Some(output);
712 }
713 _ => {
714 let (text, next) = read_word(&chars, i, &mut substitutions);
715 word.push_str(&text);
716 in_word = true;
717 i = next;
718 }
719 }
720 }
721 finish_word(
722 &mut word,
723 &mut in_word,
724 &mut redirect,
725 &mut current,
726 &mut heredocs,
727 &mut heredoc_pending,
728 );
729 commands.push(current);
730 commands.retain(|command| {
731 !command.argv.is_empty() || !command.inputs.is_empty() || !command.outputs.is_empty()
732 });
733 (commands, substitutions)
734}
735
736fn skip_heredoc_bodies(chars: &[char], mut i: usize, heredocs: &mut Vec<(String, bool)>) -> usize {
738 for (delimiter, strip_tabs) in heredocs.drain(..) {
739 loop {
740 if i >= chars.len() {
741 return i;
742 }
743 let end = chars[i..]
744 .iter()
745 .position(|&c| c == '\n')
746 .map_or(chars.len(), |p| i + p);
747 let line: String = chars[i..end].iter().collect();
748 i = (end + 1).min(chars.len());
749 let line = if strip_tabs {
750 line.trim_start_matches('\t')
751 } else {
752 line.as_str()
753 };
754 if line == delimiter {
755 break;
756 }
757 }
758 }
759 i
760}
761
762fn read_word(chars: &[char], mut i: usize, substitutions: &mut Vec<String>) -> (String, usize) {
766 let mut text = String::new();
767 while i < chars.len() {
768 let c = chars[i];
769 match c {
770 ' ' | '\t' | '\n' | ';' | '&' | '|' | '(' | ')' | '<' | '>' => break,
771 '\\' => {
772 if let Some(&next) = chars.get(i + 1)
773 && next != '\n'
774 {
775 text.push(next);
776 }
777 i += 2;
778 }
779 '\'' => {
780 i += 1;
781 while i < chars.len() && chars[i] != '\'' {
782 text.push(chars[i]);
783 i += 1;
784 }
785 i += 1;
786 }
787 '"' => {
788 i += 1;
789 while i < chars.len() && chars[i] != '"' {
790 match chars[i] {
791 '\\' if matches!(chars.get(i + 1), Some('"' | '\\' | '$' | '`' | '\n')) => {
792 if chars[i + 1] != '\n' {
793 text.push(chars[i + 1]);
794 }
795 i += 2;
796 }
797 '$' if chars.get(i + 1) == Some(&'(') && chars.get(i + 2) != Some(&'(') => {
798 let (inner, next) = balanced(chars, i + 2);
799 text.push_str(&format!("$({inner})"));
800 substitutions.push(inner);
801 i = next;
802 }
803 '`' => {
804 let (inner, next) = backquoted(chars, i + 1);
805 text.push_str(&format!("`{inner}`"));
806 substitutions.push(inner);
807 i = next;
808 }
809 other => {
810 text.push(other);
811 i += 1;
812 }
813 }
814 }
815 i += 1;
816 }
817 '$' if chars.get(i + 1) == Some(&'\'') => {
818 i += 2;
819 while i < chars.len() && chars[i] != '\'' {
820 if chars[i] == '\\' && i + 1 < chars.len() {
821 text.push(chars[i + 1]);
822 i += 2;
823 } else {
824 text.push(chars[i]);
825 i += 1;
826 }
827 }
828 i += 1;
829 }
830 '$' if chars.get(i + 1) == Some(&'(') => {
831 if chars.get(i + 2) == Some(&'(') {
832 let (inner, next) = balanced(chars, i + 2);
834 text.push_str(&format!("$({inner})"));
835 i = next;
836 } else {
837 let (inner, next) = balanced(chars, i + 2);
838 text.push_str(&format!("$({inner})"));
839 substitutions.push(inner);
840 i = next;
841 }
842 }
843 '`' => {
844 let (inner, next) = backquoted(chars, i + 1);
845 text.push_str(&format!("`{inner}`"));
846 substitutions.push(inner);
847 i = next;
848 }
849 other => {
850 text.push(other);
851 i += 1;
852 }
853 }
854 }
855 (text, i)
856}
857
858fn balanced(chars: &[char], mut i: usize) -> (String, usize) {
861 let start = i;
862 let mut depth = 1;
863 let mut quote: Option<char> = None;
864 while i < chars.len() {
865 let c = chars[i];
866 match (quote, c) {
867 (Some(q), _) if c == q => quote = None,
868 (Some('"'), '\\') => i += 1,
869 (Some(_), _) => {}
870 (None, '\\') => i += 1,
871 (None, '\'' | '"') => quote = Some(c),
872 (None, '(') => depth += 1,
873 (None, ')') => {
874 depth -= 1;
875 if depth == 0 {
876 return (chars[start..i].iter().collect(), i + 1);
877 }
878 }
879 _ => {}
880 }
881 i += 1;
882 }
883 (
884 chars[start..chars.len().min(i)].iter().collect(),
885 chars.len(),
886 )
887}
888
889fn backquoted(chars: &[char], mut i: usize) -> (String, usize) {
890 let mut inner = String::new();
891 while i < chars.len() && chars[i] != '`' {
892 if chars[i] == '\\' && i + 1 < chars.len() {
893 inner.push(chars[i + 1]);
894 i += 2;
895 } else {
896 inner.push(chars[i]);
897 i += 1;
898 }
899 }
900 (inner, (i + 1).min(chars.len()))
901}
902
903const READERS: &[&str] = &[
907 "cat",
908 "tac",
909 "head",
910 "tail",
911 "less",
912 "more",
913 "bat",
914 "batcat",
915 "nl",
916 "od",
917 "xxd",
918 "hexdump",
919 "strings",
920 "base64",
921 "sed",
922 "awk",
923 "gawk",
924 "grep",
925 "egrep",
926 "fgrep",
927 "rg",
928 "ag",
929 "cut",
930 "sort",
931 "uniq",
932 "wc",
933 "diff",
934 "cmp",
935 "md5sum",
936 "md5",
937 "sha1sum",
938 "sha256sum",
939 "shasum",
940 "file",
941 "jq",
942 "yq",
943 "source",
944 ".",
945 "column",
946 "fold",
947 "paste",
948 "iconv",
949 "openssl",
950 "gpg",
951 "zcat",
952 "view",
953 "vim",
954 "vi",
955 "nano",
956 "emacs",
957 "open",
958];
959
960const WRITERS: &[&str] = &[
962 "tee", "touch", "rm", "unlink", "truncate", "shred", "rmdir", "mkdir",
963];
964
965fn command_files(command: &ShellCommand) -> (Vec<String>, Vec<String>) {
968 let mut reads = command.inputs.clone();
969 let mut edits: Vec<String> = command
970 .outputs
971 .iter()
972 .filter(|target| !target.starts_with("/dev/"))
973 .cloned()
974 .collect();
975 let Some(first) = command.argv.first() else {
976 return (reads, edits);
977 };
978 let name = program_name(first);
979 let operands: Vec<&String> = command.argv[1..]
980 .iter()
981 .filter(|arg| !arg.starts_with('-'))
982 .collect();
983 let owned = |items: &[&String]| items.iter().map(|item| (*item).clone()).collect::<Vec<_>>();
984 if READERS.contains(&name) {
985 reads.extend(owned(&operands));
986 let in_place = command.argv[1..].iter().any(|arg| {
987 arg == "-i" || (arg.starts_with("-i") && name == "sed") || arg == "--in-place"
988 });
989 if in_place {
990 edits.extend(owned(&operands));
991 }
992 } else if WRITERS.contains(&name)
993 || name == "mv"
994 || (name == "perl"
995 && command
996 .argv
997 .iter()
998 .any(|arg| arg.starts_with("-i") || arg.starts_with("-pi")))
999 {
1000 edits.extend(owned(&operands));
1001 } else if matches!(name, "cp" | "rsync" | "scp" | "install" | "ln") {
1002 if let Some((last, sources)) = operands.split_last() {
1003 reads.extend(owned(sources));
1004 edits.push((*last).clone());
1005 }
1006 } else if name == "dd" {
1007 for arg in &command.argv[1..] {
1008 if let Some(path) = arg.strip_prefix("if=") {
1009 reads.push(path.to_string());
1010 } else if let Some(path) = arg.strip_prefix("of=") {
1011 edits.push(path.to_string());
1012 }
1013 }
1014 }
1015 (reads, edits)
1016}
1017
1018fn project_relative(path: &str, context: EvalContext<'_>) -> Option<String> {
1023 let path = path.strip_prefix("file://").unwrap_or(path);
1024 let joined = if Path::new(path).is_absolute() {
1025 PathBuf::from(path)
1026 } else {
1027 context.cwd.join(path)
1028 };
1029 let normalized = lexical_normalize(&joined);
1030 let mut roots = vec![lexical_normalize(context.root)];
1031 if let Ok(canonical) = context.root.canonicalize() {
1032 roots.push(canonical);
1033 }
1034 let mut candidates = vec![normalized.clone()];
1035 if let Some(canonical) = canonicalize_existing_prefix(&normalized) {
1036 candidates.push(canonical);
1037 }
1038 for candidate in &candidates {
1039 for root in &roots {
1040 if let Ok(relative) = candidate.strip_prefix(root) {
1041 let parts: Vec<String> = relative
1042 .components()
1043 .filter_map(|component| match component {
1044 Component::Normal(part) => Some(part.to_string_lossy().into_owned()),
1045 _ => None,
1046 })
1047 .collect();
1048 if parts.is_empty() {
1049 return None;
1050 }
1051 return Some(parts.join("/"));
1052 }
1053 }
1054 }
1055 None
1056}
1057
1058fn lexical_normalize(path: &Path) -> PathBuf {
1059 let mut out = PathBuf::new();
1060 for component in path.components() {
1061 match component {
1062 Component::CurDir => {}
1063 Component::ParentDir => {
1064 out.pop();
1065 }
1066 other => out.push(other),
1067 }
1068 }
1069 out
1070}
1071
1072fn canonicalize_existing_prefix(path: &Path) -> Option<PathBuf> {
1075 let mut existing = path.to_path_buf();
1076 let mut rest = Vec::new();
1077 while !existing.exists() {
1078 rest.push(existing.file_name()?.to_os_string());
1079 existing.pop();
1080 }
1081 let mut resolved = existing.canonicalize().ok()?;
1082 for part in rest.into_iter().rev() {
1083 resolved.push(part);
1084 }
1085 Some(resolved)
1086}
1087
1088pub fn path_matches(pattern: &str, path: &str) -> bool {
1095 let pattern = pattern.trim_start_matches("./");
1096 let directory_only = pattern.ends_with('/');
1097 let pattern = pattern.trim_end_matches('/');
1098 let mut pattern_segments: Vec<&str> = pattern.split('/').collect();
1099 if !pattern.contains('/') {
1100 pattern_segments.insert(0, "**");
1101 }
1102 let path_segments: Vec<&str> = path.split('/').collect();
1103 let last = if directory_only {
1104 path_segments.len().saturating_sub(1)
1105 } else {
1106 path_segments.len()
1107 };
1108 (1..=last).any(|end| segments_match(&pattern_segments, &path_segments[..end]))
1109}
1110
1111fn segments_match(pattern: &[&str], path: &[&str]) -> bool {
1112 match pattern.split_first() {
1113 None => path.is_empty(),
1114 Some((&"**", rest)) => (0..=path.len()).any(|skip| segments_match(rest, &path[skip..])),
1115 Some((first, rest)) => path.split_first().is_some_and(|(segment, path_rest)| {
1116 wildcard_matches(first, segment) && segments_match(rest, path_rest)
1117 }),
1118 }
1119}
1120
1121pub fn wildcard_matches(pattern: &str, text: &str) -> bool {
1123 let pattern: Vec<char> = pattern.chars().collect();
1124 let text: Vec<char> = text.chars().collect();
1125 let (mut p, mut t) = (0, 0);
1126 let mut star: Option<(usize, usize)> = None;
1127 while t < text.len() {
1128 if p < pattern.len() && (pattern[p] == '?' || pattern[p] == text[t]) {
1129 p += 1;
1130 t += 1;
1131 } else if p < pattern.len() && pattern[p] == '*' {
1132 star = Some((p, t));
1133 p += 1;
1134 } else if let Some((star_p, star_t)) = star {
1135 p = star_p + 1;
1136 t = star_t + 1;
1137 star = Some((star_p, star_t + 1));
1138 } else {
1139 return false;
1140 }
1141 }
1142 pattern[p..].iter().all(|&c| c == '*')
1143}
1144
1145#[cfg(test)]
1146mod tests {
1147 use super::*;
1148
1149 fn words(text: &str) -> Vec<String> {
1150 text.split_whitespace().map(str::to_string).collect()
1151 }
1152
1153 fn argvs(script: &str) -> Vec<Vec<String>> {
1154 shell_commands(script)
1155 .into_iter()
1156 .map(|command| command.argv)
1157 .filter(|argv| !argv.is_empty())
1158 .collect()
1159 }
1160
1161 fn denies(rule: &str, script: &str) -> bool {
1162 let rule = words(rule);
1163 argvs(script)
1164 .iter()
1165 .any(|argv| command_matches(&rule, argv))
1166 }
1167
1168 #[test]
1169 fn reworded_commands_from_issue_41_are_matched() {
1170 let rule = "git push --force";
1171 assert!(denies(rule, "git push --force"));
1172 assert!(denies(rule, "/usr/bin/git push --force"));
1173 assert!(denies(rule, "sh -c \"git push --force\""));
1174 assert!(denies(rule, "bash -lc 'git push --force origin main'"));
1175 assert!(denies(rule, "git -C . push --force"));
1176 assert!(denies(
1177 rule,
1178 "git -c core.pager=cat --no-pager push --force"
1179 ));
1180 assert!(denies(rule, "git push origin main --force"));
1181 }
1182
1183 #[test]
1184 fn compound_commands_are_split() {
1185 let rule = "git push --force";
1186 assert!(denies(rule, "cd repo && git push --force"));
1187 assert!(denies(rule, "true || git push --force"));
1188 assert!(denies(rule, "echo hi; git push --force"));
1189 assert!(denies(rule, "echo hi | git push --force"));
1190 assert!(denies(rule, "(cd repo; git push --force) &"));
1191 assert!(denies(rule, "echo $(git push --force)"));
1192 assert!(denies(rule, "echo \"`git push --force`\""));
1193 assert!(denies(rule, "if true; then git push --force; fi"));
1194 }
1195
1196 #[test]
1197 fn wrappers_are_unwrapped() {
1198 let rule = "git push --force";
1199 assert!(denies(rule, "env GIT_TRACE=1 git push --force"));
1200 assert!(denies(rule, "GIT_TRACE=1 git push --force"));
1201 assert!(denies(rule, "sudo -u deploy git push --force"));
1202 assert!(denies(rule, "nohup timeout 30 git push --force"));
1203 assert!(denies(rule, "command git push --force"));
1204 assert!(denies(rule, "eval git push --force"));
1205 assert!(denies(rule, "env -S 'git push --force'"));
1206 assert!(denies(rule, "xargs -n 1 git push --force"));
1207 assert!(denies("sudo", "sudo -u deploy ls"));
1208 }
1209
1210 #[test]
1211 fn other_commands_are_left_alone() {
1212 let rule = "git push --force";
1213 assert!(!denies(rule, "git push"));
1214 assert!(!denies(rule, "git status --force"));
1215 assert!(!denies(rule, "echo 'git push --force'"));
1216 assert!(!denies(rule, "git push --force-with-lease"));
1217 assert!(!denies(rule, "legit push --force"));
1218 assert!(!denies(rule, "cat <<EOF\ngit push --force\nEOF"));
1219 assert!(!denies(rule, "# git push --force"));
1220 }
1221
1222 #[test]
1223 fn short_option_clusters_match_in_any_spelling() {
1224 assert!(denies("rm -rf", "rm -rf build"));
1225 assert!(denies("rm -rf", "rm -fr build"));
1226 assert!(denies("rm -rf", "rm -r -f build"));
1227 assert!(!denies("rm -rf", "rm -r build"));
1228 assert!(denies(
1229 "terraform apply",
1230 "terraform -chdir=infra apply -auto-approve"
1231 ));
1232 assert!(denies(
1233 "kubectl delete namespace",
1234 "kubectl -n prod delete namespace prod"
1235 ));
1236 }
1237
1238 #[test]
1239 fn files_named_on_the_command_line_are_found() {
1240 let files = |script: &str| {
1241 let mut reads = Vec::new();
1242 let mut edits = Vec::new();
1243 for command in shell_commands(script) {
1244 let (r, e) = command_files(&command);
1245 reads.extend(r);
1246 edits.extend(e);
1247 }
1248 (reads, edits)
1249 };
1250 assert_eq!(files("cat .env").0, vec![".env"]);
1251 assert_eq!(files("head -n 5 config/.env").0, vec!["5", "config/.env"]);
1252 assert_eq!(files("wc -l < secrets/key").0, vec!["secrets/key"]);
1253 assert_eq!(files("echo x > .env 2>/dev/null").1, vec![".env"]);
1254 assert_eq!(files("echo x >> out.txt").1, vec!["out.txt"]);
1255 assert_eq!(files("echo x 2>&1").1, Vec::<String>::new());
1256 assert_eq!(files("sed -i 's/a/b/' .env").1, vec!["s/a/b/", ".env"]);
1257 assert_eq!(
1258 files("cp .env /tmp/x"),
1259 (vec![".env".to_string()], vec!["/tmp/x".to_string()])
1260 );
1261 assert_eq!(files("sh -c 'cat .env'").0, vec![".env"]);
1262 }
1263
1264 #[test]
1265 fn path_patterns_follow_gitignore_rules() {
1266 assert!(path_matches(".env", ".env"));
1267 assert!(path_matches(".env", "app/.env"));
1268 assert!(!path_matches(".env", ".env.example"));
1269 assert!(path_matches(".env*", ".env.local"));
1270 assert!(path_matches("secrets/**", "secrets/prod/key"));
1271 assert!(path_matches("secrets/", "secrets/key"));
1272 assert!(!path_matches("secrets/", "secrets"));
1273 assert!(path_matches("secrets", "secrets/key"));
1274 assert!(!path_matches("secrets/**", "app/secrets/key"));
1275 assert!(path_matches("config/*.pem", "config/tls.pem"));
1276 assert!(!path_matches("config/*.pem", "config/sub/tls.pem"));
1277 assert!(path_matches("**/*.pem", "a/b/c.pem"));
1278 assert!(path_matches("infra/", "infra/main.tf"));
1279 }
1280
1281 #[test]
1282 fn paths_are_judged_relative_to_the_project() {
1283 let root = Path::new("/work/project");
1284 let context = EvalContext {
1285 root,
1286 cwd: Path::new("/work/project/app"),
1287 };
1288 assert_eq!(
1289 project_relative(".env", context).as_deref(),
1290 Some("app/.env")
1291 );
1292 assert_eq!(
1293 project_relative("../.env", context).as_deref(),
1294 Some(".env")
1295 );
1296 assert_eq!(
1297 project_relative("/work/project/secrets/key", context).as_deref(),
1298 Some("secrets/key")
1299 );
1300 assert_eq!(project_relative("/etc/passwd", context), None);
1301 assert_eq!(project_relative("../../other/.env", context), None);
1302 }
1303
1304 fn policy(toml_rules: &str) -> PolicyConfig {
1305 toml::from_str(toml_rules).unwrap()
1306 }
1307
1308 #[test]
1309 fn deny_wins_over_ask_and_the_first_rule_is_named() {
1310 let policy = policy(
1311 r#"
1312 [[rules]]
1313 effect = "ask"
1314 command = ["git", "push"]
1315
1316 [[rules]]
1317 effect = "deny"
1318 command = ["git", "push", "--force"]
1319 reason = "rewrites shared history"
1320
1321 [[rules]]
1322 effect = "deny"
1323 read = [".env"]
1324
1325 [[rules]]
1326 effect = "deny"
1327 mcp = "github:delete_*"
1328 "#,
1329 );
1330 let root = Path::new("/work/project");
1331 let context = EvalContext { root, cwd: root };
1332 let decide =
1333 |actions: &[PolicyAction]| evaluate("infra", &policy, actions, context).unwrap();
1334
1335 let decision = decide(&[PolicyAction::Shell("git push --force".into())]).unwrap();
1336 assert_eq!((decision.effect, decision.rule), (PolicyEffect::Deny, 2));
1337 assert_eq!(
1338 decision.message(),
1339 "Tuff policy 'infra' denies this call: rule 2 (deny command \"git push --force\"): rewrites shared history"
1340 );
1341 let decision = decide(&[PolicyAction::Shell("git push".into())]).unwrap();
1342 assert_eq!((decision.effect, decision.rule), (PolicyEffect::Ask, 1));
1343 assert_eq!(decide(&[PolicyAction::Shell("git status".into())]), None);
1344 assert_eq!(
1345 decide(&[PolicyAction::Read("/work/project/app/.env".into())])
1346 .unwrap()
1347 .rule,
1348 3
1349 );
1350 assert_eq!(
1351 decide(&[PolicyAction::Shell("cat app/.env".into())])
1352 .unwrap()
1353 .rule,
1354 3
1355 );
1356 assert_eq!(
1357 decide(&[PolicyAction::Read("/elsewhere/.env".into())]),
1358 None
1359 );
1360 let mcp = |server: &str, tool: &str| PolicyAction::Mcp {
1361 server: server.into(),
1362 tool: tool.into(),
1363 };
1364 assert_eq!(decide(&[mcp("github", "delete_repo")]).unwrap().rule, 4);
1365 assert_eq!(decide(&[mcp("github", "create_issue")]), None);
1366 }
1367}