safe-chains 0.125.0

Auto-allow safe, read-only bash commands in agentic coding tools
Documentation
pub(crate) mod check;
#[cfg(test)]
mod display;
mod eval;
mod parse;
#[cfg(test)]
mod proptests;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Script(pub Vec<Stmt>);

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Stmt {
    pub pipeline: Pipeline,
    pub op: Option<ListOp>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ListOp {
    And,
    Or,
    Semi,
    Amp,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Pipeline {
    pub bang: bool,
    pub commands: Vec<Cmd>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Cmd {
    Simple(SimpleCmd),
    Subshell(Script),
    For {
        var: String,
        items: Vec<Word>,
        body: Script,
    },
    While {
        cond: Script,
        body: Script,
    },
    Until {
        cond: Script,
        body: Script,
    },
    If {
        branches: Vec<Branch>,
        else_body: Option<Script>,
    },
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Branch {
    pub cond: Script,
    pub body: Script,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SimpleCmd {
    pub env: Vec<(String, Word)>,
    pub words: Vec<Word>,
    pub redirs: Vec<Redir>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Word(pub Vec<WordPart>);

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WordPart {
    Lit(String),
    Escape(char),
    SQuote(String),
    DQuote(Word),
    CmdSub(Script),
    Backtick(String),
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Redir {
    Write {
        fd: u32,
        target: Word,
        append: bool,
    },
    Read {
        fd: u32,
        target: Word,
    },
    HereStr(Word),
    HereDoc {
        delimiter: String,
        strip_tabs: bool,
    },
    DupFd {
        src: u32,
        dst: String,
    },
}

pub use check::{command_verdict, is_safe_command, is_safe_pipeline};
pub use parse::parse;

impl Word {
    pub fn eval(&self) -> String {
        eval::eval_word(self)
    }

    pub fn literal(s: &str) -> Self {
        Word(vec![WordPart::Lit(s.to_string())])
    }

    pub fn normalize(&self) -> Self {
        let mut parts = Vec::new();
        for part in &self.0 {
            let part = match part {
                WordPart::DQuote(inner) => WordPart::DQuote(inner.normalize()),
                WordPart::CmdSub(s) => WordPart::CmdSub(s.normalize()),
                other => other.clone(),
            };
            if let WordPart::Lit(s) = &part
                && let Some(WordPart::Lit(prev)) = parts.last_mut()
            {
                prev.push_str(s);
                continue;
            }
            parts.push(part);
        }
        Word(parts)
    }
}

impl Script {
    pub fn is_empty(&self) -> bool {
        self.0.is_empty()
    }

    pub fn normalize(&self) -> Self {
        Script(
            self.0
                .iter()
                .map(|stmt| Stmt {
                    pipeline: stmt.pipeline.normalize(),
                    op: stmt.op,
                })
                .collect(),
        )
    }

    pub fn normalize_as_body(&self) -> Self {
        let mut s = self.normalize();
        if let Some(last) = s.0.last_mut()
            && last.op.is_none()
        {
            last.op = Some(ListOp::Semi);
        }
        s
    }
}

impl Pipeline {
    fn normalize(&self) -> Self {
        Pipeline {
            bang: self.bang,
            commands: self.commands.iter().map(|c| c.normalize()).collect(),
        }
    }
}

impl Cmd {
    fn normalize(&self) -> Self {
        match self {
            Cmd::Simple(s) => Cmd::Simple(s.normalize()),
            Cmd::Subshell(s) => Cmd::Subshell(s.normalize()),
            Cmd::For { var, items, body } => Cmd::For {
                var: var.clone(),
                items: items.iter().map(|w| w.normalize()).collect(),
                body: body.normalize_as_body(),
            },
            Cmd::While { cond, body } => Cmd::While {
                cond: cond.normalize_as_body(),
                body: body.normalize_as_body(),
            },
            Cmd::Until { cond, body } => Cmd::Until {
                cond: cond.normalize_as_body(),
                body: body.normalize_as_body(),
            },
            Cmd::If { branches, else_body } => Cmd::If {
                branches: branches
                    .iter()
                    .map(|b| Branch {
                        cond: b.cond.normalize_as_body(),
                        body: b.body.normalize_as_body(),
                    })
                    .collect(),
                else_body: else_body.as_ref().map(|e| e.normalize_as_body()),
            },
        }
    }
}

impl SimpleCmd {
    fn normalize(&self) -> Self {
        SimpleCmd {
            env: self
                .env
                .iter()
                .map(|(k, v)| (k.clone(), v.normalize()))
                .collect(),
            words: self.words.iter().map(|w| w.normalize()).collect(),
            redirs: self
                .redirs
                .iter()
                .map(|r| match r {
                    Redir::Write { fd, target, append } => Redir::Write {
                        fd: *fd,
                        target: target.normalize(),
                        append: *append,
                    },
                    Redir::Read { fd, target } => Redir::Read {
                        fd: *fd,
                        target: target.normalize(),
                    },
                    Redir::HereStr(w) => Redir::HereStr(w.normalize()),
                    Redir::HereDoc { .. } | Redir::DupFd { .. } => r.clone(),
                })
                .collect(),
        }
    }
}