From 354c914c4330a8f023bc886772acd93aaeddb394 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Mon, 17 Aug 2026 09:09:04 -0400 Subject: [PATCH] secret : trousseau OS (keyring) + injection @secret au start/run (issue #36) - valeurs jamais affichees ni journalisees, mock testable, fallback clair sans trousseau --- Cargo.lock | 13 +++- Cargo.toml | 1 + src/cli.rs | 29 +++++++ src/commands/mod.rs | 7 ++ src/commands/secret_cmd.rs | 55 ++++++++++++++ src/help.rs | 18 +++++ src/lib.rs | 1 + src/repl.rs | 20 ++++- src/secrets.rs | 151 +++++++++++++++++++++++++++++++++++++ 9 files changed, 293 insertions(+), 2 deletions(-) create mode 100644 src/commands/secret_cmd.rs create mode 100644 src/secrets.rs diff --git a/Cargo.lock b/Cargo.lock index 076a9f1..92e7e96 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21,7 +21,7 @@ dependencies = [ [[package]] name = "agent-manager" -version = "0.2.14" +version = "0.3.0" dependencies = [ "anyhow", "chrono", @@ -29,6 +29,7 @@ dependencies = [ "clap_complete", "colored", "flate2", + "keyring", "rustyline", "semver", "serde", @@ -715,6 +716,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "log", + "zeroize", +] + [[package]] name = "lazy_static" version = "1.5.0" diff --git a/Cargo.toml b/Cargo.toml index 07951f1..fd992f3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ tar = "0.4" tempfile = "3" ureq = { version = "2", default-features = false, features = ["tls"] } wait-timeout = "0.2" +keyring = "3" which = "7" zip = "0.6" diff --git a/src/cli.rs b/src/cli.rs index 175df89..1059411 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -108,6 +108,9 @@ pub enum Command { /// Manage command aliases (cc -> claude-code) #[command(subcommand)] Alias(AliasCmd), + /// Manage secrets in the OS keyring (never in plaintext config) + #[command(subcommand)] + Secret(SecretCmd), /// Start an agent (foreground by default, or detached with --background) Start(StartArgs), /// Stop a background agent (SIGTERM, then SIGKILL after the timeout) @@ -369,6 +372,32 @@ pub struct StartArgs { pub notify: bool, } +/// Secret management subcommands. +#[derive(Subcommand, Debug, Clone)] +pub enum SecretCmd { + /// Store a secret for an agent + Set { + /// Secret name (the environment variable name) + name: String, + /// Agent it belongs to + #[arg(long, value_name = "AGENT")] + agent: String, + /// Secret value (prefer --value over shell history; never logged) + #[arg(long, value_name = "VALUE")] + value: String, + }, + /// Remove a secret + Unset { + /// Secret name + name: String, + /// Agent it belongs to + #[arg(long, value_name = "AGENT")] + agent: String, + }, + /// List stored secret names (values are never shown) + List, +} + /// Alias management subcommands. #[derive(Subcommand, Debug, Clone)] pub enum AliasCmd { diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 4d6dec1..bb8ce74 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -17,6 +17,7 @@ pub mod open_cmd; pub mod projects_cmd; pub mod run_cmd; pub mod search_cmd; +pub mod secret_cmd; pub mod self_uninstall; pub mod sessions_cmd; pub mod stats_cmd; @@ -65,6 +66,7 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result { interval, } => watch_cmd::run(app, agent, *restart, *notify, *interval), Command::Alias(sub) => alias_cmd::run(app, sub), + Command::Secret(sub) => secret_cmd::run(app, sub), Command::Start(a) => run_cmd::start(app, a), Command::Stop { agent, @@ -202,6 +204,11 @@ pub fn resolve_exec( args.extend(extra_args.iter().cloned()); let mut env = agent.env.clone(); env.extend(extra_env.clone()); + let warnings = + crate::secrets::resolve_env_secrets(&crate::secrets::store(), &agent.name, &mut env); + for w in warnings { + app.log.warn(&w); + } let (program, args) = route_cmd_script(program, args); Ok(ResolvedExec { program, args, env }) } diff --git a/src/commands/secret_cmd.rs b/src/commands/secret_cmd.rs new file mode 100644 index 0000000..7908b3c --- /dev/null +++ b/src/commands/secret_cmd.rs @@ -0,0 +1,55 @@ +//! secret: manage secrets in the OS keyring (values never shown or logged). + +use super::*; +use crate::cli::SecretCmd; +use crate::output::print_json; +use crate::secrets::{self, SecretStore}; + +pub fn run(app: &App, sub: &SecretCmd) -> Result { + match sub { + SecretCmd::Set { name, agent, value } => { + if app.dry_run() { + app.log.dry(format!("would store secret {name} for {agent} in the OS keyring")); + return Ok(0); + } + let key = secrets::key_for(agent, name); + secrets::store().set(&key, value)?; + app.log.success(&format!("secret {name} stored for {agent}")); + app.log.info("use it with --env NAME=@secret (injected at start/run)"); + Ok(0) + } + SecretCmd::Unset { name, agent } => { + let key = secrets::key_for(agent, name); + if app.dry_run() { + app.log.dry(format!("would remove secret {key}")); + return Ok(0); + } + let removed = secrets::store().remove(&key)?; + if removed { + app.log.success(&format!("secret {name} removed for {agent}")); + } else { + app.log.info(&format!("secret {name} does not exist for {agent}")); + } + Ok(0) + } + SecretCmd::List => { + let all = secrets::store().list().unwrap_or_default(); + if app.json() { + print_json(&all); + return Ok(0); + } + if all.is_empty() { + app.log.info("no secrets stored — see: am secret set NAME --agent AGENT --value ..."); + return Ok(0); + } + let mut table = crate::output::Table::new(vec!["SECRET"]); + for k in all { + if k != "__index__" { + table.row(vec![k]); + } + } + print!("{}", table.render()); + Ok(0) + } + } +} diff --git a/src/help.rs b/src/help.rs index f7fd75f..af8da92 100644 --- a/src/help.rs +++ b/src/help.rs @@ -385,6 +385,24 @@ pub static HELP_SPECS: &[HelpSpec] = &[ HelpExample { desc: "List every alias.", code: "alias list" }, ], }, + HelpSpec { + name: "secret", + category: "Commands", + usage: "secret {flags} ", + about: "Manage secrets in the OS keyring (values never shown or logged).", + search_terms: &["keyring", "token", "password"], + flags: &[], + subcommands: &[ + ("set", "Store a secret for an agent"), + ("unset", "Remove a secret"), + ], + parameters: &[], + io: None, + examples: &[ + HelpExample { desc: "Store a token.", code: "secret set OPENAI_KEY --agent claude-code --value sk-..." }, + HelpExample { desc: "Use it at launch.", code: "start claude-code --env OPENAI_KEY=@secret" }, + ], + }, HelpSpec { name: "history", category: "Commands", diff --git a/src/lib.rs b/src/lib.rs index ff93485..0a911b4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -33,6 +33,7 @@ pub mod ps; pub mod process; pub mod repl; pub mod runner; +pub mod secrets; pub mod sessions; pub mod shell; pub mod state; diff --git a/src/repl.rs b/src/repl.rs index f7e5e03..1f39604 100644 --- a/src/repl.rs +++ b/src/repl.rs @@ -71,6 +71,7 @@ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[ ("history", "search command history"), ("init", "generate a project config"), ("alias", "manage aliases"), + ("secret", "manage secrets"), ("open", "open the install dir"), ("watch", "supervise an agent"), ("search", "search the catalog"), @@ -149,7 +150,7 @@ impl AmCompleter { Self { theme: std::cell::Cell::new(crate::theme::default_theme()), commands: vec![ - "list", "status", "sessions", "stats", "top", "report", "projects", "timeline", "log", "logs", "history", "init", "alias", "open", "watch", "search", "info", "install", "uninstall", "update", + "list", "status", "sessions", "stats", "top", "report", "projects", "timeline", "log", "logs", "history", "init", "alias", "secret", "open", "watch", "search", "info", "install", "uninstall", "update", "start", "stop", "restart", "run", "doctor", "config", "completion", "self-update", "self-uninstall", "export", "import", "shell", "theme", "ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit", @@ -1239,6 +1240,22 @@ fn handle_line( notify: flag("--notify"), interval: opt_value("--interval").and_then(|v| v.parse().ok()).unwrap_or(5), }, + "secret" => match rest.first().map(|s| s.as_str()) { + Some("set") if rest.len() >= 2 => Command::Secret(crate::cli::SecretCmd::Set { + name: rest[1].clone(), + agent: opt_value("--agent").unwrap_or_default(), + value: opt_value("--value").unwrap_or_default(), + }), + Some("unset") if rest.len() >= 2 => Command::Secret(crate::cli::SecretCmd::Unset { + name: rest[1].clone(), + agent: opt_value("--agent").unwrap_or_default(), + }), + Some("list") | None => Command::Secret(crate::cli::SecretCmd::List), + _ => { + app.log.error("usage: secret set NAME --agent A --value V | secret unset NAME --agent A | secret list"); + return Ok(false); + } + }, "alias" => match rest.first().map(|s| s.as_str()) { Some("add") if rest.len() >= 3 => Command::Alias(crate::cli::AliasCmd::Add { name: rest[1].clone(), @@ -1417,6 +1434,7 @@ fn is_am_command(word: &str) -> bool { | "history" | "init" | "alias" + | "secret" | "open" | "watch" | "search" diff --git a/src/secrets.rs b/src/secrets.rs new file mode 100644 index 0000000..bc05fb6 --- /dev/null +++ b/src/secrets.rs @@ -0,0 +1,151 @@ +//! Secrets: OS keyring storage (never plaintext in config, logs or env_keys). + +use anyhow::{anyhow, Result}; +use std::collections::BTreeMap; + +pub trait SecretStore { + fn set(&self, key: &str, value: &str) -> Result<()>; + fn get(&self, key: &str) -> Result>; + fn remove(&self, key: &str) -> Result; + fn list(&self) -> Result>; +} + +const SERVICE: &str = "agent-manager"; +const INDEX_KEY: &str = "__index__"; + +/// OS keyring backend (Windows Credential Manager, macOS Keychain, Linux +/// Secret Service when available). +pub struct KeyringStore; + +impl SecretStore for KeyringStore { + fn set(&self, key: &str, value: &str) -> Result<()> { + let entry = keyring::Entry::new(SERVICE, key)?; + entry.set_password(value).map_err(|e| anyhow!("keyring: {e}"))?; + let mut idx = self.list().unwrap_or_default(); + if !idx.iter().any(|k| k == key) { + idx.push(key.to_string()); + } + let index = keyring::Entry::new(SERVICE, INDEX_KEY)?; + let _ = index.set_password(&idx.join(",")); + Ok(()) + } + + fn get(&self, key: &str) -> Result> { + let entry = keyring::Entry::new(SERVICE, key)?; + match entry.get_password() { + Ok(v) => Ok(Some(v)), + Err(keyring::Error::NoEntry) => Ok(None), + Err(e) => Err(anyhow!("keyring: {e}")), + } + } + + fn remove(&self, key: &str) -> Result { + let entry = keyring::Entry::new(SERVICE, key)?; + match entry.delete_credential() { + Ok(()) => Ok(true), + Err(keyring::Error::NoEntry) => Ok(false), + Err(e) => Err(anyhow!("keyring: {e}")), + } + } + + fn list(&self) -> Result> { + let index = keyring::Entry::new(SERVICE, INDEX_KEY)?; + match index.get_password() { + Ok(v) => Ok(v.split(',').filter(|s| !s.is_empty()).map(String::from).collect()), + Err(keyring::Error::NoEntry) => Ok(Vec::new()), + Err(e) => Err(anyhow!("keyring: {e}")), + } + } +} + +/// The production store. +pub fn store() -> KeyringStore { + KeyringStore +} + +/// Key for an agent-scoped secret name: "claude-code/OPENAI_KEY". +pub fn key_for(agent: &str, name: &str) -> String { + format!("{agent}/{name}") +} + +/// Resolve "@secret" placeholders in an env map: a value equal to +/// "@secret" pulls the secret named by the variable name for this agent. +/// Returns human warnings (names only — values never leak). +pub fn resolve_env_secrets( + store: &dyn SecretStore, + agent: &str, + env: &mut BTreeMap, +) -> Vec { + let mut warnings = Vec::new(); + let keys: Vec = env.keys().cloned().collect(); + for k in keys { + let placeholder = env.get(&k).map(|v| v == "@secret").unwrap_or(false); + if !placeholder { + continue; + } + let key = key_for(agent, &k); + match store.get(&key) { + Ok(Some(v)) => { + env.insert(k.clone(), v); + } + Ok(None) => warnings.push(format!( + "no secret for {k} — use: am secret set {k} --agent {agent} --value ..." + )), + Err(e) => warnings.push(format!("cannot read secret {k}: {e:#}")), + } + } + warnings +} + +/// In-memory store for tests. +#[derive(Default)] +pub struct MockStore { + pub map: std::sync::Mutex>, +} + +impl SecretStore for MockStore { + fn set(&self, key: &str, value: &str) -> Result<()> { + self.map.lock().unwrap().insert(key.to_string(), value.to_string()); + Ok(()) + } + fn get(&self, key: &str) -> Result> { + Ok(self.map.lock().unwrap().get(key).cloned()) + } + fn remove(&self, key: &str) -> Result { + Ok(self.map.lock().unwrap().remove(key).is_some()) + } + fn list(&self) -> Result> { + Ok(self.map.lock().unwrap().keys().cloned().collect()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mock_roundtrip() { + let s = MockStore::default(); + s.set("claude-code/OPENAI_KEY", "sk-123").unwrap(); + assert_eq!(s.get("claude-code/OPENAI_KEY").unwrap().as_deref(), Some("sk-123")); + assert_eq!(s.list().unwrap(), vec!["claude-code/OPENAI_KEY".to_string()]); + assert!(s.remove("claude-code/OPENAI_KEY").unwrap()); + assert_eq!(s.get("claude-code/OPENAI_KEY").unwrap(), None); + } + + #[test] + fn resolves_placeholders_and_warns() { + let s = MockStore::default(); + s.set("aider/TOKEN", "secret-value").unwrap(); + let mut env = BTreeMap::new(); + env.insert("TOKEN".to_string(), "@secret".to_string()); + env.insert("PLAIN".to_string(), "kept".to_string()); + env.insert("MISSING".to_string(), "@secret".to_string()); + let warnings = resolve_env_secrets(&s, "aider", &mut env); + assert_eq!(env.get("TOKEN").map(String::as_str), Some("secret-value")); + assert_eq!(env.get("PLAIN").map(String::as_str), Some("kept")); + assert_eq!(env.get("MISSING").map(String::as_str), Some("@secret")); + assert_eq!(warnings.len(), 1, "warning sans valeur secrete: {warnings:?}"); + assert!(!warnings[0].contains("secret-value")); + } +} -- 2.54.0