safe-chains 0.125.0

Auto-allow safe, read-only bash commands in agentic coding tools
Documentation
use crate::handlers;
use crate::parse::WordSet;

pub struct CommandDoc {
    pub name: String,
    pub kind: DocKind,
    pub url: &'static str,
    pub description: String,
    pub aliases: Vec<String>,
}

pub enum DocKind {
    Handler,
}

impl CommandDoc {
    pub fn handler(name: &'static str, url: &'static str, description: impl Into<String>) -> Self {
        let raw = description.into();
        let description = raw
            .lines()
            .map(|line| {
                if line.is_empty() || line.starts_with("- ") {
                    line.to_string()
                } else {
                    format!("- {line}")
                }
            })
            .collect::<Vec<_>>()
            .join("\n");
        Self { name: name.to_string(), kind: DocKind::Handler, url, description, aliases: Vec::new() }
    }

    pub fn wordset(name: &'static str, url: &'static str, words: &WordSet) -> Self {
        Self::handler(name, url, doc(words).build())
    }

    pub fn wordset_multi(name: &'static str, url: &'static str, words: &WordSet, multi: &[(&str, WordSet)]) -> Self {
        Self::handler(name, url, doc_multi(words, multi).build())
    }


}

#[derive(Default)]
pub struct DocBuilder {
    subcommands: Vec<String>,
    flags: Vec<String>,
    sections: Vec<String>,
}

impl DocBuilder {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn wordset(mut self, words: &WordSet) -> Self {
        for item in words.iter() {
            if item.starts_with('-') {
                self.flags.push(item.to_string());
            } else {
                self.subcommands.push(item.to_string());
            }
        }
        self
    }

    pub fn multi_word(mut self, multi: &[(&str, WordSet)]) -> Self {
        for (prefix, actions) in multi {
            for action in actions.iter() {
                self.subcommands.push(format!("{prefix} {action}"));
            }
        }
        self
    }

    pub fn triple_word(mut self, triples: &[(&str, &str, WordSet)]) -> Self {
        for (a, b, actions) in triples {
            for action in actions.iter() {
                self.subcommands.push(format!("{a} {b} {action}"));
            }
        }
        self
    }

    pub fn subcommand(mut self, name: impl Into<String>) -> Self {
        self.subcommands.push(name.into());
        self
    }

    pub fn section(mut self, text: impl Into<String>) -> Self {
        let s = text.into();
        if !s.is_empty() {
            self.sections.push(s);
        }
        self
    }

    pub fn build(self) -> String {
        let mut lines = Vec::new();
        if !self.subcommands.is_empty() {
            let mut subs = self.subcommands;
            subs.sort();
            lines.push(format!("- Subcommands: {}", subs.join(", ")));
        }
        if !self.flags.is_empty() {
            lines.push(format!("- Flags: {}", self.flags.join(", ")));
        }
        for s in self.sections {
            if s.starts_with("- ") {
                lines.push(s);
            } else {
                lines.push(format!("- {s}"));
            }
        }
        lines.join("\n")
    }
}

pub fn doc(words: &WordSet) -> DocBuilder {
    DocBuilder::new().wordset(words)
}

pub fn doc_multi(words: &WordSet, multi: &[(&str, WordSet)]) -> DocBuilder {
    DocBuilder::new().wordset(words).multi_word(multi)
}

pub fn wordset_items(words: &WordSet) -> String {
    let items: Vec<&str> = words.iter().collect();
    items.join(", ")
}


pub fn all_command_docs() -> Vec<CommandDoc> {
    let mut docs = handlers::handler_docs();
    docs.sort_by(|a, b| a.name.to_ascii_lowercase().cmp(&b.name.to_ascii_lowercase()));
    docs
}

pub fn render_markdown(docs: &[CommandDoc]) -> String {
    let mut out = String::from(
        "# Supported Commands\n\
         \n\
         Auto-generated by `safe-chains --list-commands`. These commands, subcommands, and flags are safe to run individually or in combination.\n\
         \n\
         ## Glossary\n\
         \n\
         | Term | Meaning |\n\
         |------|---------|\n\
         | **Allowed standalone flags** | Flags that take no value (`--verbose`, `-v`). Listed on flat commands. |\n\
         | **Flags** | Same as standalone flags, but in the shorter format used within subcommand entries. |\n\
         | **Allowed valued flags** | Flags that require a value (`--output file`, `-j 4`). |\n\
         | **Valued** | Same as valued flags, in shorter format within subcommand entries. |\n\
         | **Bare invocation allowed** | The command can be run with no arguments at all. |\n\
         | **Subcommands** | Named subcommands that are allowed (e.g. `git log`, `cargo test`). |\n\
         | **Positional arguments only** | No specific flags are listed; only positional arguments are accepted. |\n\
         | **(requires --flag)** | A guarded subcommand that is only allowed when a specific flag is present (e.g. `cargo fmt` requires `--check`). |\n\
         \n\
         Unlisted flags, subcommands, and commands are not allowed.\n\n",
    );

    for doc in docs {
        if doc.aliases.is_empty() {
            out.push_str(&format!("### `{}` ({})\n\n", doc.name, doc.url));
        } else {
            let alias_str: Vec<String> = doc.aliases.iter().map(|a| format!("`{a}`")).collect();
            out.push_str(&format!(
                "### `{}` ({})\n\nAliases: {}\n\n",
                doc.name, doc.url, alias_str.join(", ")
            ));
        }
        out.push_str(&format!("{}\n\n", doc.description));
    }

    out
}

pub fn render_opencode_json(patterns: &[String]) -> String {
    use serde_json::{Map, Value};
    use std::fs;

    let mut root: Map<String, Value> = fs::read_to_string("opencode.json")
        .ok()
        .and_then(|s| serde_json::from_str(&s).ok())
        .and_then(|v: Value| v.as_object().cloned())
        .unwrap_or_else(|| {
            let mut m = Map::new();
            m.insert(
                "$schema".to_string(),
                Value::String("https://opencode.ai/config.json".to_string()),
            );
            m
        });

    let mut bash = Map::new();
    bash.insert("*".to_string(), Value::String("ask".to_string()));
    for pat in patterns {
        bash.insert(pat.clone(), Value::String("allow".to_string()));
    }

    let permission = root
        .entry("permission")
        .or_insert_with(|| Value::Object(Map::new()));
    if !permission.is_object() {
        *permission = Value::Object(Map::new());
    }
    if let Value::Object(perm_map) = permission {
        perm_map.insert("bash".to_string(), Value::Object(bash));
    }

    let mut out = serde_json::to_string_pretty(&Value::Object(root)).unwrap_or_default();
    out.push('\n');
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn all_commands_have_url() {
        for doc in all_command_docs() {
            assert!(!doc.url.is_empty(), "{} has no documentation URL", doc.name);
            assert!(
                doc.url.starts_with("https://"),
                "{} URL must use https: {}",
                doc.name,
                doc.url
            );
        }
    }

    #[test]
    fn builder_two_sections() {
        let ws = WordSet::new(&["--version", "list", "show"]);
        assert_eq!(doc(&ws).build(), "- Subcommands: list, show\n- Flags: --version");
    }

    #[test]
    fn builder_subcommands_only() {
        let ws = WordSet::new(&["list", "show"]);
        assert_eq!(doc(&ws).build(), "- Subcommands: list, show");
    }

    #[test]
    fn builder_flags_only() {
        let ws = WordSet::new(&["--check", "--version"]);
        assert_eq!(doc(&ws).build(), "- Flags: --check, --version");
    }

    #[test]
    fn builder_three_sections() {
        let ws = WordSet::new(&["--version", "list", "show"]);
        assert_eq!(
            doc(&ws).section("Guarded: foo (bar only).").build(),
            "- Subcommands: list, show\n- Flags: --version\n- Guarded: foo (bar only)."
        );
    }

    #[test]
    fn builder_multi_word_merged() {
        let ws = WordSet::new(&["--version", "info", "show"]);
        let multi: &[(&str, WordSet)] =
            &[("config", WordSet::new(&["get", "list"]))];
        assert_eq!(
            doc_multi(&ws, multi).build(),
            "- Subcommands: config get, config list, info, show\n- Flags: --version"
        );
    }

    #[test]
    fn builder_multi_word_with_extra_section() {
        let ws = WordSet::new(&["--version", "show"]);
        let multi: &[(&str, WordSet)] =
            &[("config", WordSet::new(&["get", "list"]))];
        assert_eq!(
            doc_multi(&ws, multi).section("Guarded: foo.").build(),
            "- Subcommands: config get, config list, show\n- Flags: --version\n- Guarded: foo."
        );
    }

    #[test]
    fn builder_no_flags_with_extra() {
        let ws = WordSet::new(&["list", "show"]);
        assert_eq!(
            doc(&ws).section("Also: foo.").build(),
            "- Subcommands: list, show\n- Also: foo."
        );
    }

    #[test]
    fn builder_custom_sections_only() {
        assert_eq!(
            DocBuilder::new()
                .section("Read-only: foo.")
                .section("Always safe: bar.")
                .section("Guarded: baz.")
                .build(),
            "- Read-only: foo.\n- Always safe: bar.\n- Guarded: baz."
        );
    }

    #[test]
    fn builder_triple_word() {
        let ws = WordSet::new(&["--version", "diff"]);
        let triples: &[(&str, &str, WordSet)] =
            &[("git", "remote", WordSet::new(&["list"]))];
        assert_eq!(
            doc(&ws).triple_word(triples).build(),
            "- Subcommands: diff, git remote list\n- Flags: --version"
        );
    }

    #[test]
    fn builder_subcommand_method() {
        let ws = WordSet::new(&["--version", "list"]);
        assert_eq!(
            doc(&ws).subcommand("plugin-list").build(),
            "- Subcommands: list, plugin-list\n- Flags: --version"
        );
    }

    #[test]
    fn render_opencode_json_valid() {
        let patterns = vec!["grep".to_string(), "grep *".to_string(), "ls".to_string()];
        let json = render_opencode_json(&patterns);
        let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
        let bash = &parsed["permission"]["bash"];
        assert_eq!(bash["*"], "ask");
        assert_eq!(bash["grep"], "allow");
        assert_eq!(bash["grep *"], "allow");
        assert_eq!(bash["ls"], "allow");
        assert!(bash["rm"].is_null());
    }

    #[test]
    fn render_opencode_json_has_schema() {
        let json = render_opencode_json(&[]);
        let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
        assert_eq!(parsed["$schema"], "https://opencode.ai/config.json");
    }

    #[test]
    fn render_opencode_json_trailing_newline() {
        let json = render_opencode_json(&[]);
        assert!(json.ends_with('\n'));
    }

    #[test]
    fn render_opencode_json_merges_existing() {
        use std::fs;
        let dir = tempfile::tempdir().expect("tmpdir");
        let config_path = dir.path().join("opencode.json");
        fs::write(
            &config_path,
            r#"{"$schema":"https://opencode.ai/config.json","model":"claude-sonnet-4-6","permission":{"bash":{"rm *":"deny"}}}"#,
        )
        .expect("write");

        let prev = std::env::current_dir().expect("cwd");
        std::env::set_current_dir(dir.path()).expect("cd");
        let json = render_opencode_json(&["ls".to_string()]);
        std::env::set_current_dir(prev).expect("cd back");

        let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
        assert_eq!(parsed["model"], "claude-sonnet-4-6", "existing keys preserved");
        assert_eq!(parsed["permission"]["bash"]["*"], "ask");
        assert_eq!(parsed["permission"]["bash"]["ls"], "allow");
        assert!(
            parsed["permission"]["bash"]["rm *"].is_null(),
            "old bash rules replaced, not merged"
        );
    }

}