Skip to main content

tuff_core/
policy_eval.rs

1//! `tuff policy evaluate`: matching one tool call against a policy at run
2//! time (RFC-107 D3).
3//!
4//! Where a harness has no native setting for a rule, it can run a hook
5//! before each tool call and let the hook answer. The harness adapter turns
6//! its hook input into [`PolicyAction`]s and the decision back into the
7//! harness's answer; the matching lives here, once.
8//!
9//! A shell command is read the way a shell would split it, then reduced to
10//! the programs it runs: a path becomes the program name
11//! (`/usr/bin/git` is `git`), wrappers such as `env`, `sudo`, and
12//! `sh -c "..."` are unwrapped, command substitutions are read as commands
13//! of their own, and options before a subcommand are skipped
14//! (`git -C . push`). The files a command names on its command line, as
15//! arguments of programs such as `cat` or as redirections, are checked
16//! against `read` and `edit` rules. A script or program that runs a
17//! command or opens a file itself is not seen, which is why coverage
18//! through the hook stays `partial`.
19
20use std::path::{Component, Path, PathBuf};
21
22use serde::Serialize;
23
24use crate::error::{Result, TuffError};
25use crate::policy::{PolicyConfig, PolicyEffect, PolicyRule, PolicySubject};
26
27/// How deep wrappers and substitutions are unwrapped. A command nested
28/// deeper than this is still checked at the depth reached.
29const MAX_DEPTH: usize = 8;
30
31/// One thing a tool call is about to do.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum PolicyAction {
34    /// A shell command line, as the agent wrote it.
35    Shell(String),
36    /// Reading a file, by path as the harness gave it.
37    Read(String),
38    /// Writing, editing, or deleting a file.
39    Edit(String),
40    /// Calling a tool of an MCP server.
41    Mcp { server: String, tool: String },
42}
43
44/// A tool call, as a harness adapter reads it from the hook input.
45#[derive(Debug, Clone, Default, PartialEq, Eq)]
46pub struct PolicyHookRequest {
47    /// The native event the harness ran the hook for.
48    pub event: String,
49    /// The working directory of the call, when the input says.
50    pub cwd: Option<PathBuf>,
51    /// Workspace roots the harness names, when it does.
52    pub roots: Vec<PathBuf>,
53    pub actions: Vec<PolicyAction>,
54}
55
56/// What the hook prints and the status it exits with.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct PolicyHookAnswer {
59    pub stdout: String,
60    pub exit_code: i32,
61}
62
63/// How a harness uses the `tuff policy evaluate` hook for one rule.
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum PolicyHookUse {
66    /// The rule compiles to a native setting only.
67    Never,
68    /// The hook is how the harness enforces the rule.
69    Required,
70    /// A native setting enforces the rule, and the hook, registered with
71    /// `--runtime-hook`, also catches forms of it the setting misses.
72    Optional,
73}
74
75/// What `tuff policy evaluate` concluded about one call.
76#[derive(Debug, Clone, Copy)]
77pub enum PolicyVerdict<'a> {
78    /// No rule matched; the harness decides as it would without the hook.
79    NoMatch,
80    Matched(&'a PolicyDecision),
81    /// The input or the policy could not be read; the call is refused.
82    Failed(&'a str),
83}
84
85/// The rule a call matched.
86#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
87pub struct PolicyDecision {
88    pub effect: PolicyEffect,
89    pub policy: String,
90    /// One-based position of the rule in the policy.
91    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    /// The message shown to the agent and the user.
99    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/// Where a call happens: the project the policy governs and the directory
117/// relative paths in the call start from.
118#[derive(Debug, Clone, Copy)]
119pub struct EvalContext<'a> {
120    pub root: &'a Path,
121    pub cwd: &'a Path,
122}
123
124/// The strongest rule of `policy` that any of `actions` matches: a deny
125/// over an ask, and the first rule of that effect in the policy.
126pub 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/// Everything a set of actions does, reduced to the terms rules match.
156#[derive(Debug, Default)]
157struct Facts {
158    /// Every program invocation, program name first.
159    commands: Vec<Vec<String>>,
160    /// Project-relative paths read.
161    reads: Vec<String>,
162    /// Project-relative paths written, edited, or deleted.
163    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
224/// Find the project a policy was installed into: the nearest directory, at
225/// or above one of `starts`, holding `<dir_prefix>/policies/<id>/policy.toml`.
226pub 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
239/// Read the `[policy]` section of an installed `policy.toml` record.
240pub 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
258// ── commands ───────────────────────────────────────────────────────────
259
260/// Whether a policy command rule, such as `["git", "push", "--force"]`,
261/// matches one program invocation.
262///
263/// The program is compared by name. Options between the program and the
264/// rule's first word are skipped, with the value of an option known to
265/// take one (`git -C <dir>`), so `git -C . push --force` matches. The
266/// rule's first word after the program must be the first argument left;
267/// its later words must follow in order, anywhere after it, so
268/// `git push origin --force` matches too. A rule word that is a cluster of
269/// short options, such as `-rf`, matches when each letter is set by a short
270/// option cluster after it (`-r -f`, `-fr`).
271pub 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
329/// The letters of a short option cluster such as `-rf`.
330fn 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
338/// Options that come before a subcommand and take the next argument as
339/// their value, for the programs policies most often name.
340fn 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/// One program invocation in a shell command line.
398#[derive(Debug, Default, Clone, PartialEq, Eq)]
399struct ShellCommand {
400    argv: Vec<String>,
401    /// Targets of `<` redirections.
402    inputs: Vec<String>,
403    /// Targets of `>`, `>>`, and similar redirections.
404    outputs: Vec<String>,
405}
406
407/// Every program invocation a command line runs, as far as the command
408/// line itself says: wrappers are unwrapped and substitutions read.
409fn 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
427/// Words that start or shape a compound command rather than name a program.
428const 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                // Keep the wrapper itself too: a rule may name `sudo`.
488                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
518/// The arguments after a wrapper's own options, skipping the value of each
519/// option in `with_value`.
520fn 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
564/// The script of `sh -c <script>` and its spellings (`bash -lc`, `-e -c`).
565fn 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
582/// Split a command line into simple commands the way a POSIX shell reads
583/// it: quotes and escapes, the operators that separate commands, and
584/// redirections. Returns the commands and the text of every `$(...)` and
585/// backquote substitution, to be read as command lines of their own.
586fn 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; // Some(true) = output target next
594    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                    // `&>` and `&>>`: both streams to a file.
645                    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                // A word of digits right before is a file descriptor.
666                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                        // `<<<` here-string: its word is data.
685                        i += 1;
686                        redirect = None;
687                        heredoc_pending = None;
688                        // Read the word and drop it.
689                        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                    // `>&2`, `<&0`: duplicating a descriptor, no file.
705                    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
736/// After a newline, skip the bodies of pending here-documents.
737fn 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
762/// Read the part of a word that starts at `i` and runs to the next
763/// unquoted blank or operator, removing quotes and escapes. Substitutions
764/// are recorded and kept in the word as written.
765fn 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                    // Arithmetic `$((...))`: no command inside to read.
833                    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
858/// The text up to the parenthesis that closes one opened just before `i`,
859/// and the position after it.
860fn 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
903// ── files a command names ──────────────────────────────────────────────
904
905/// Programs whose arguments are files they read.
906const 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
960/// Programs whose arguments are files they write, create, or remove.
961const WRITERS: &[&str] = &[
962    "tee", "touch", "rm", "unlink", "truncate", "shred", "rmdir", "mkdir",
963];
964
965/// The files one program invocation reads and writes, as its command line
966/// names them.
967fn 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
1018// ── paths ──────────────────────────────────────────────────────────────
1019
1020/// A path as the project-relative, `/`-separated form policy patterns are
1021/// written against, or `None` for a path outside the project.
1022fn 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
1072/// Resolve symbolic links in the part of `path` that exists, so a link
1073/// into or out of the project is judged by where it leads.
1074fn 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
1088/// Whether a policy path pattern covers a project-relative path, read the
1089/// way `.gitignore` reads a pattern in a file at the project root: a
1090/// pattern with a `/` before its end is anchored at the root, one without
1091/// matches at any depth, a trailing `/` names a directory's contents, and a
1092/// pattern that matches a directory covers everything in it. `*` and `?`
1093/// stay within one path segment; `**` spans any number of them.
1094pub 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
1121/// `*` for any run of characters and `?` for one, within one segment.
1122pub 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}