use std::collections::HashMap;
use anyhow::{bail, Result};
use colored::Colorize;
use tsafe_core::{audit::AuditEntry, env as tsenv};
use crate::helpers::*;
struct KeyMap {
vault_key: &'static str,
env_var: &'static str,
required: bool,
}
struct Plugin {
name: &'static str,
binary: &'static str,
keys: &'static [KeyMap],
description: &'static str,
}
const PLUGINS: &[Plugin] = &[
Plugin {
name: "gh",
binary: "gh",
description: "GitHub CLI — injects GH_TOKEN",
keys: &[
KeyMap {
vault_key: "GH_TOKEN",
env_var: "GH_TOKEN",
required: false,
},
KeyMap {
vault_key: "GITHUB_TOKEN",
env_var: "GITHUB_TOKEN",
required: false,
},
],
},
Plugin {
name: "aws",
binary: "aws",
description: "AWS CLI — injects AWS_ACCESS_KEY_ID / SECRET / REGION",
keys: &[
KeyMap {
vault_key: "AWS_ACCESS_KEY_ID",
env_var: "AWS_ACCESS_KEY_ID",
required: true,
},
KeyMap {
vault_key: "AWS_SECRET_ACCESS_KEY",
env_var: "AWS_SECRET_ACCESS_KEY",
required: true,
},
KeyMap {
vault_key: "AWS_DEFAULT_REGION",
env_var: "AWS_DEFAULT_REGION",
required: false,
},
KeyMap {
vault_key: "AWS_SESSION_TOKEN",
env_var: "AWS_SESSION_TOKEN",
required: false,
},
],
},
Plugin {
name: "az",
binary: "az",
description: "Azure CLI — injects AZURE_CLIENT_ID / SECRET / TENANT",
keys: &[
KeyMap {
vault_key: "AZURE_CLIENT_ID",
env_var: "AZURE_CLIENT_ID",
required: false,
},
KeyMap {
vault_key: "AZURE_CLIENT_SECRET",
env_var: "AZURE_CLIENT_SECRET",
required: false,
},
KeyMap {
vault_key: "AZURE_TENANT_ID",
env_var: "AZURE_TENANT_ID",
required: false,
},
KeyMap {
vault_key: "AZURE_SUBSCRIPTION_ID",
env_var: "AZURE_SUBSCRIPTION_ID",
required: false,
},
],
},
Plugin {
name: "docker",
binary: "docker",
description: "Docker CLI — injects DOCKER_USERNAME / DOCKER_PASSWORD",
keys: &[
KeyMap {
vault_key: "DOCKER_USERNAME",
env_var: "DOCKER_USERNAME",
required: false,
},
KeyMap {
vault_key: "DOCKER_PASSWORD",
env_var: "DOCKER_PASSWORD",
required: false,
},
KeyMap {
vault_key: "DOCKER_TOKEN",
env_var: "DOCKER_TOKEN",
required: false,
},
],
},
Plugin {
name: "npm",
binary: "npm",
description: "npm / pnpm / yarn — injects NPM_TOKEN",
keys: &[KeyMap {
vault_key: "NPM_TOKEN",
env_var: "NPM_TOKEN",
required: false,
}],
},
Plugin {
name: "pypi",
binary: "twine",
description: "PyPI publish (twine) — injects TWINE_USERNAME / TWINE_PASSWORD",
keys: &[
KeyMap {
vault_key: "PYPI_TOKEN",
env_var: "TWINE_PASSWORD",
required: false,
},
KeyMap {
vault_key: "TWINE_USERNAME",
env_var: "TWINE_USERNAME",
required: false,
},
],
},
Plugin {
name: "terraform",
binary: "terraform",
description: "Terraform — injects TF_TOKEN_* and cloud provider vars",
keys: &[
KeyMap {
vault_key: "TF_TOKEN_app_terraform_io",
env_var: "TF_TOKEN_app_terraform_io",
required: false,
},
KeyMap {
vault_key: "ARM_CLIENT_ID",
env_var: "ARM_CLIENT_ID",
required: false,
},
KeyMap {
vault_key: "ARM_CLIENT_SECRET",
env_var: "ARM_CLIENT_SECRET",
required: false,
},
KeyMap {
vault_key: "ARM_TENANT_ID",
env_var: "ARM_TENANT_ID",
required: false,
},
KeyMap {
vault_key: "ARM_SUBSCRIPTION_ID",
env_var: "ARM_SUBSCRIPTION_ID",
required: false,
},
],
},
];
#[derive(Debug, Clone)]
pub(crate) struct RegistryEntry {
pub(crate) name: String,
pub(crate) command: String,
pub(crate) description: Option<String>,
pub(crate) args: Vec<String>,
pub(crate) url: Option<String>,
}
#[cfg(feature = "plugins")]
#[derive(serde::Deserialize)]
struct RawRegistryEntry {
name: Option<String>,
command: Option<String>,
description: Option<String>,
args: Option<Vec<String>>,
url: Option<String>,
}
#[cfg(feature = "plugins")]
#[derive(serde::Deserialize)]
struct RegistryFile {
#[serde(default)]
plugins: Vec<RawRegistryEntry>,
}
#[cfg(feature = "plugins")]
pub(crate) fn load_registry_plugins() -> Result<Vec<RegistryEntry>> {
let path = match std::env::var("TSAFE_PLUGIN_REGISTRY") {
Ok(p) if !p.is_empty() => p,
_ => return Ok(Vec::new()),
};
let raw = std::fs::read_to_string(&path)
.map_err(|e| anyhow::anyhow!("tsafe: plugin registry: cannot read '{path}': {e}"))?;
let doc: RegistryFile = toml::from_str(&raw).map_err(|e| {
anyhow::anyhow!("tsafe: plugin registry: TOML parse error in '{path}': {e}")
})?;
let mut entries = Vec::with_capacity(doc.plugins.len());
for raw_entry in doc.plugins {
let name = match raw_entry.name {
Some(n) if !n.is_empty() => n,
_ => {
eprintln!("tsafe: plugin registry: skipping entry with missing 'name' field");
continue;
}
};
let command = match raw_entry.command {
Some(c) if !c.is_empty() => c,
_ => {
eprintln!(
"tsafe: plugin registry: skipping entry '{name}' with missing 'command' field"
);
continue;
}
};
entries.push(RegistryEntry {
name,
command,
description: raw_entry.description,
args: raw_entry.args.unwrap_or_default(),
url: raw_entry.url,
});
}
Ok(entries)
}
fn find_builtin(name: &str) -> Option<&'static Plugin> {
PLUGINS.iter().find(|p| p.name == name)
}
enum ResolvedPlugin<'a> {
BuiltIn(&'a Plugin),
#[cfg(feature = "plugins")]
Registry(RegistryEntry),
}
fn builtin_list_lines() -> String {
let mut out = String::from("This build includes these plugin launchers:\n\n");
for p in PLUGINS {
out.push_str(&format!(" {:<12} {} [built-in]\n", p.name, p.description));
}
out
}
fn append_list_footer(out: &mut String) {
out.push_str("\nUsage:\n");
out.push_str(" tsafe plugin list\n");
out.push_str(" tsafe plugin <name> [tool args...]\n");
out.push_str(" tsafe plugin <name> --help\n");
}
#[cfg_attr(feature = "plugins", allow(dead_code))]
fn plugin_list_text_builtin_only() -> String {
let mut out = builtin_list_lines();
append_list_footer(&mut out);
out
}
#[cfg(feature = "plugins")]
fn plugin_list_text_with_registry(registry: &[RegistryEntry]) -> String {
let mut out = builtin_list_lines();
for r in registry {
let desc = r.description.as_deref().unwrap_or("");
out.push_str(&format!(" {:<12} {} [registry]\n", r.name, desc));
if let Some(ref u) = r.url {
out.push_str(&format!(" {:<12} {}\n", "", u));
}
}
append_list_footer(&mut out);
out
}
#[cfg(not(feature = "plugins"))]
fn print_plugin_list() {
print!("{}", plugin_list_text_builtin_only().cyan());
}
#[cfg(feature = "plugins")]
fn print_plugin_list(registry: &[RegistryEntry]) {
print!("{}", plugin_list_text_with_registry(registry).cyan());
}
#[cfg(not(feature = "plugins"))]
pub(crate) fn cmd_plugin(profile: &str, tool: Option<&str>, args: &[String]) -> Result<()> {
let tool_name = match tool {
None => {
print_plugin_list();
return Ok(());
}
Some(t) if t == "--list" || t == "list" => {
print_plugin_list();
return Ok(());
}
Some(t) => t,
};
let plugin = match find_builtin(tool_name) {
Some(p) => p,
None => {
bail!("unknown plugin '{tool_name}'. Run `tsafe plugin` to list available plugins.");
}
};
run_builtin_plugin(profile, plugin, args)
}
#[cfg(feature = "plugins")]
pub(crate) fn cmd_plugin(profile: &str, tool: Option<&str>, args: &[String]) -> Result<()> {
let raw_registry = match load_registry_plugins() {
Ok(entries) => entries,
Err(e) => {
eprintln!("{} {e}", "error:".red());
std::process::exit(1);
}
};
let registry: Vec<RegistryEntry> = raw_registry
.into_iter()
.filter(|entry| {
if find_builtin(&entry.name).is_some() {
eprintln!(
"{} registry entry '{}' conflicts with a built-in static entry; \
built-in wins (ADR-031)",
"warn:".yellow(),
entry.name,
);
false
} else {
true
}
})
.collect();
let tool_name = match tool {
None => {
print_plugin_list(®istry);
return Ok(());
}
Some(t) if t == "--list" || t == "list" => {
print_plugin_list(®istry);
return Ok(());
}
Some(t) => t,
};
let resolved = if let Some(p) = find_builtin(tool_name) {
ResolvedPlugin::BuiltIn(p)
} else if let Some(r) = registry.into_iter().find(|e| e.name == tool_name) {
ResolvedPlugin::Registry(r)
} else {
bail!("unknown plugin '{tool_name}'. Run `tsafe plugin` to list available plugins.");
};
match resolved {
ResolvedPlugin::BuiltIn(p) => run_builtin_plugin(profile, p, args),
ResolvedPlugin::Registry(r) => run_registry_plugin(profile, &r, args),
}
}
fn run_builtin_plugin(profile: &str, plugin: &Plugin, args: &[String]) -> Result<()> {
let vault = open_vault(profile)?;
let mut env_overrides: Vec<(String, String)> = Vec::new();
let mut missing_required: Vec<&str> = Vec::new();
for km in plugin.keys {
match vault.get(km.vault_key) {
Ok(val) => {
env_overrides.push((km.env_var.to_string(), val.to_string()));
}
Err(_) => {
if km.required {
missing_required.push(km.vault_key);
}
}
}
}
if !missing_required.is_empty() {
bail!(
"plugin '{}' requires vault keys that are missing: {}\n\
Hint: tsafe set {} <value>",
plugin.name,
missing_required.join(", "),
missing_required[0],
);
}
if env_overrides.is_empty() {
eprintln!(
"{} no matching vault keys found for plugin '{}'; running {} without extra env vars",
"warn:".yellow(),
plugin.name,
plugin.binary,
);
}
drop(vault);
let binary = which_binary(plugin.binary).unwrap_or_else(|| plugin.binary.to_string());
let plugin_env_names: Vec<String> = plugin
.keys
.iter()
.map(|mapping| mapping.env_var.to_string())
.collect();
let mut command = build_plugin_command(&binary, args, &plugin_env_names, &env_overrides)?;
let status = command
.status()
.map_err(|e| anyhow::anyhow!("failed to run '{binary}': {e}"))?;
let exit_code = status.code().unwrap_or(1);
if exit_code == 0 {
audit(profile)
.append(&AuditEntry::success(profile, "plugin", Some(plugin.name)))
.ok();
} else {
audit(profile)
.append(&AuditEntry::failure(
profile,
"plugin",
Some(plugin.name),
&format!("exited with code {exit_code}"),
))
.ok();
}
std::process::exit(exit_code);
}
#[cfg(feature = "plugins")]
fn run_registry_plugin(profile: &str, entry: &RegistryEntry, user_args: &[String]) -> Result<()> {
let all_args: Vec<String> = entry
.args
.iter()
.cloned()
.chain(user_args.iter().cloned())
.collect();
let binary = which_binary(&entry.command).unwrap_or_else(|| entry.command.clone());
let mut cmd = std::process::Command::new(&binary);
cmd.args(&all_args);
let status = cmd
.status()
.map_err(|e| anyhow::anyhow!("failed to run '{}': {e}", entry.command))?;
let exit_code = status.code().unwrap_or(1);
if exit_code == 0 {
audit(profile)
.append(&AuditEntry::success(profile, "plugin", Some(&entry.name)))
.ok();
} else {
audit(profile)
.append(&AuditEntry::failure(
profile,
"plugin",
Some(&entry.name),
&format!("exited with code {exit_code}"),
))
.ok();
}
std::process::exit(exit_code);
}
fn build_plugin_command(
binary: &str,
args: &[String],
plugin_env_names: &[String],
env_overrides: &[(String, String)],
) -> Result<std::process::Command> {
let cmd_parts: Vec<String> = std::iter::once(binary.to_string())
.chain(args.iter().cloned())
.collect();
let secrets: HashMap<String, String> = env_overrides.iter().cloned().collect();
tsenv::command_with_secrets_and_extra_strips(&secrets, plugin_env_names, &cmd_parts)
.map_err(|e| anyhow::anyhow!("{e}"))
}
fn which_binary(name: &str) -> Option<String> {
std::env::var_os("PATH").and_then(|path| {
std::env::split_paths(&path).find_map(|dir| {
let candidate = dir.join(name);
if candidate.is_file() {
candidate.to_str().map(|s| s.to_string())
} else {
None
}
})
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plugin_list_text_is_build_local_and_command_local() {
let help = plugin_list_text_builtin_only();
assert!(help.contains("This build includes these plugin launchers:"));
assert!(help.contains("tsafe plugin list"));
assert!(help.contains("tsafe plugin <name> --help"));
assert!(!help.contains("tsafe plugin gh repo list"));
assert!(!help.contains("tsafe plugin aws s3 ls --bucket my-bucket"));
}
#[test]
fn builtin_entries_tagged_in_list() {
let help = plugin_list_text_builtin_only();
assert!(help.contains("[built-in]"));
}
#[test]
fn build_plugin_command_strips_sensitive_parent_env_and_applies_overrides() {
temp_env::with_vars(
[
(
"GITHUB_TOKEN",
Some(std::ffi::OsStr::new("ambient-gh-token")),
),
(
"AZURE_CLIENT_SECRET",
Some(std::ffi::OsStr::new("ambient-azure-secret")),
),
],
|| {
let env_overrides = vec![
("GH_TOKEN".to_string(), "vault-gh-token".to_string()),
("AWS_DEFAULT_REGION".to_string(), "eu-west-2".to_string()),
];
let plugin_env_names = vec![
"GH_TOKEN".to_string(),
"GITHUB_TOKEN".to_string(),
"DOCKER_PASSWORD".to_string(),
];
let command = build_plugin_command(
"echo",
&["hello".to_string()],
&plugin_env_names,
&env_overrides,
)
.unwrap();
let envs: HashMap<String, Option<String>> = command
.get_envs()
.map(|(key, value)| {
(
key.to_string_lossy().into_owned(),
value.map(|item| item.to_string_lossy().into_owned()),
)
})
.collect();
assert_eq!(envs.get("GH_TOKEN"), Some(&Some("vault-gh-token".into())));
assert_eq!(
envs.get("AWS_DEFAULT_REGION"),
Some(&Some("eu-west-2".into()))
);
assert_eq!(envs.get("GITHUB_TOKEN"), Some(&None));
assert_eq!(envs.get("AZURE_CLIENT_SECRET"), Some(&None));
assert_eq!(envs.get("DOCKER_PASSWORD"), Some(&None));
},
);
}
#[cfg(feature = "plugins")]
mod registry_tests {
use super::*;
use std::io::Write;
fn write_registry(content: &str) -> tempfile::NamedTempFile {
let mut f = tempfile::NamedTempFile::new().unwrap();
f.write_all(content.as_bytes()).unwrap();
f
}
#[test]
fn registry_file_forgery_attempt_command_stored_not_executed() {
let malicious_command = "/bin/sh -c 'rm -rf /'";
let toml = format!("[[plugins]]\nname = \"evil\"\ncommand = {malicious_command:?}\n");
let f = write_registry(&toml);
temp_env::with_var(
"TSAFE_PLUGIN_REGISTRY",
Some(f.path().to_str().unwrap()),
|| {
let entries = load_registry_plugins().expect("load must succeed");
assert_eq!(entries.len(), 1, "one entry must be loaded");
let entry = &entries[0];
assert_eq!(entry.command, malicious_command);
assert_eq!(entry.name, "evil");
},
);
}
#[test]
fn missing_required_field_command_skips_entry_others_load() {
let toml = r#"
[[plugins]]
name = "bad-entry"
# command is intentionally missing
[[plugins]]
name = "good-entry"
command = "/usr/local/bin/good"
"#;
let f = write_registry(toml);
temp_env::with_var(
"TSAFE_PLUGIN_REGISTRY",
Some(f.path().to_str().unwrap()),
|| {
let entries = load_registry_plugins().expect("load must succeed");
assert_eq!(entries.len(), 1, "only one valid entry should load");
assert_eq!(entries[0].name, "good-entry");
},
);
}
#[test]
fn missing_required_field_name_skips_entry() {
let toml = r#"
[[plugins]]
command = "/usr/local/bin/nameless"
# name is intentionally missing
[[plugins]]
name = "ok-entry"
command = "/usr/local/bin/ok"
"#;
let f = write_registry(toml);
temp_env::with_var(
"TSAFE_PLUGIN_REGISTRY",
Some(f.path().to_str().unwrap()),
|| {
let entries = load_registry_plugins().expect("load must succeed");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, "ok-entry");
},
);
}
#[test]
fn missing_registry_file_returns_error() {
temp_env::with_var(
"TSAFE_PLUGIN_REGISTRY",
Some("/nonexistent/path/plugins.toml"),
|| {
let result = load_registry_plugins();
assert!(result.is_err(), "missing file must return Err");
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("plugin registry"),
"error must mention 'plugin registry'; got: {msg}"
);
},
);
}
#[test]
fn name_conflict_registry_entry_loads_but_static_wins_at_lookup() {
let toml = r#"
[[plugins]]
name = "gh"
command = "/attacker/fake-gh"
"#;
let f = write_registry(toml);
temp_env::with_var(
"TSAFE_PLUGIN_REGISTRY",
Some(f.path().to_str().unwrap()),
|| {
let entries = load_registry_plugins().expect("load must succeed");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, "gh");
let builtin = find_builtin("gh");
assert!(builtin.is_some(), "static 'gh' must still be found");
assert_eq!(builtin.unwrap().binary, "gh");
},
);
}
#[test]
fn optional_fields_are_loaded() {
let toml = r#"
[[plugins]]
name = "full-entry"
command = "/usr/local/bin/full"
description = "A fully specified entry"
args = ["--mode", "safe"]
url = "https://example.com/full"
"#;
let f = write_registry(toml);
temp_env::with_var(
"TSAFE_PLUGIN_REGISTRY",
Some(f.path().to_str().unwrap()),
|| {
let entries = load_registry_plugins().expect("load must succeed");
assert_eq!(entries.len(), 1);
let e = &entries[0];
assert_eq!(e.description.as_deref(), Some("A fully specified entry"));
assert_eq!(e.args, vec!["--mode", "safe"]);
assert_eq!(e.url.as_deref(), Some("https://example.com/full"));
},
);
}
#[test]
fn no_registry_env_var_returns_empty() {
temp_env::with_var("TSAFE_PLUGIN_REGISTRY", None::<&str>, || {
let entries = load_registry_plugins().expect("must succeed when var is unset");
assert!(
entries.is_empty(),
"no implicit registry path should be searched"
);
});
}
#[test]
fn registry_entries_tagged_in_list() {
let registry = vec![RegistryEntry {
name: "my-tool".to_string(),
command: "/usr/local/bin/my-tool".to_string(),
description: Some("Test tool".to_string()),
args: vec![],
url: None,
}];
let text = plugin_list_text_with_registry(®istry);
assert!(text.contains("[registry]"));
assert!(text.contains("[built-in]"));
assert!(text.contains("my-tool"));
}
}
}