secret : trousseau OS (keyring) + injection @secret au start/run (issue #36) - valeurs jamais affichees ni journalisees, mock testable, fallback clair sans trousseau

This commit is contained in:
2026-08-17 09:09:04 -04:00
parent c57d3a333d
commit 354c914c43
9 changed files with 293 additions and 2 deletions
Generated
+12 -1
View File
@@ -21,7 +21,7 @@ dependencies = [
[[package]] [[package]]
name = "agent-manager" name = "agent-manager"
version = "0.2.14" version = "0.3.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
@@ -29,6 +29,7 @@ dependencies = [
"clap_complete", "clap_complete",
"colored", "colored",
"flate2", "flate2",
"keyring",
"rustyline", "rustyline",
"semver", "semver",
"serde", "serde",
@@ -715,6 +716,16 @@ dependencies = [
"wasm-bindgen", "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]] [[package]]
name = "lazy_static" name = "lazy_static"
version = "1.5.0" version = "1.5.0"
+1
View File
@@ -32,6 +32,7 @@ tar = "0.4"
tempfile = "3" tempfile = "3"
ureq = { version = "2", default-features = false, features = ["tls"] } ureq = { version = "2", default-features = false, features = ["tls"] }
wait-timeout = "0.2" wait-timeout = "0.2"
keyring = "3"
which = "7" which = "7"
zip = "0.6" zip = "0.6"
+29
View File
@@ -108,6 +108,9 @@ pub enum Command {
/// Manage command aliases (cc -> claude-code) /// Manage command aliases (cc -> claude-code)
#[command(subcommand)] #[command(subcommand)]
Alias(AliasCmd), 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 an agent (foreground by default, or detached with --background)
Start(StartArgs), Start(StartArgs),
/// Stop a background agent (SIGTERM, then SIGKILL after the timeout) /// Stop a background agent (SIGTERM, then SIGKILL after the timeout)
@@ -369,6 +372,32 @@ pub struct StartArgs {
pub notify: bool, 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. /// Alias management subcommands.
#[derive(Subcommand, Debug, Clone)] #[derive(Subcommand, Debug, Clone)]
pub enum AliasCmd { pub enum AliasCmd {
+7
View File
@@ -17,6 +17,7 @@ pub mod open_cmd;
pub mod projects_cmd; pub mod projects_cmd;
pub mod run_cmd; pub mod run_cmd;
pub mod search_cmd; pub mod search_cmd;
pub mod secret_cmd;
pub mod self_uninstall; pub mod self_uninstall;
pub mod sessions_cmd; pub mod sessions_cmd;
pub mod stats_cmd; pub mod stats_cmd;
@@ -65,6 +66,7 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
interval, interval,
} => watch_cmd::run(app, agent, *restart, *notify, *interval), } => watch_cmd::run(app, agent, *restart, *notify, *interval),
Command::Alias(sub) => alias_cmd::run(app, sub), 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::Start(a) => run_cmd::start(app, a),
Command::Stop { Command::Stop {
agent, agent,
@@ -202,6 +204,11 @@ pub fn resolve_exec(
args.extend(extra_args.iter().cloned()); args.extend(extra_args.iter().cloned());
let mut env = agent.env.clone(); let mut env = agent.env.clone();
env.extend(extra_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); let (program, args) = route_cmd_script(program, args);
Ok(ResolvedExec { program, args, env }) Ok(ResolvedExec { program, args, env })
} }
+55
View File
@@ -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<i32> {
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)
}
}
}
+18
View File
@@ -385,6 +385,24 @@ pub static HELP_SPECS: &[HelpSpec] = &[
HelpExample { desc: "List every alias.", code: "alias list" }, HelpExample { desc: "List every alias.", code: "alias list" },
], ],
}, },
HelpSpec {
name: "secret",
category: "Commands",
usage: "secret {flags} <set|unset|list>",
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 { HelpSpec {
name: "history", name: "history",
category: "Commands", category: "Commands",
+1
View File
@@ -33,6 +33,7 @@ pub mod ps;
pub mod process; pub mod process;
pub mod repl; pub mod repl;
pub mod runner; pub mod runner;
pub mod secrets;
pub mod sessions; pub mod sessions;
pub mod shell; pub mod shell;
pub mod state; pub mod state;
+19 -1
View File
@@ -71,6 +71,7 @@ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[
("history", "search command history"), ("history", "search command history"),
("init", "generate a project config"), ("init", "generate a project config"),
("alias", "manage aliases"), ("alias", "manage aliases"),
("secret", "manage secrets"),
("open", "open the install dir"), ("open", "open the install dir"),
("watch", "supervise an agent"), ("watch", "supervise an agent"),
("search", "search the catalog"), ("search", "search the catalog"),
@@ -149,7 +150,7 @@ impl AmCompleter {
Self { Self {
theme: std::cell::Cell::new(crate::theme::default_theme()), theme: std::cell::Cell::new(crate::theme::default_theme()),
commands: vec![ 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", "start", "stop", "restart", "run", "doctor", "config", "completion",
"self-update", "self-uninstall", "export", "import", "shell", "theme", "self-update", "self-uninstall", "export", "import", "shell", "theme",
"ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit", "ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit",
@@ -1239,6 +1240,22 @@ fn handle_line(
notify: flag("--notify"), notify: flag("--notify"),
interval: opt_value("--interval").and_then(|v| v.parse().ok()).unwrap_or(5), 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()) { "alias" => match rest.first().map(|s| s.as_str()) {
Some("add") if rest.len() >= 3 => Command::Alias(crate::cli::AliasCmd::Add { Some("add") if rest.len() >= 3 => Command::Alias(crate::cli::AliasCmd::Add {
name: rest[1].clone(), name: rest[1].clone(),
@@ -1417,6 +1434,7 @@ fn is_am_command(word: &str) -> bool {
| "history" | "history"
| "init" | "init"
| "alias" | "alias"
| "secret"
| "open" | "open"
| "watch" | "watch"
| "search" | "search"
+151
View File
@@ -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<Option<String>>;
fn remove(&self, key: &str) -> Result<bool>;
fn list(&self) -> Result<Vec<String>>;
}
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<Option<String>> {
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<bool> {
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<Vec<String>> {
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<String, String>,
) -> Vec<String> {
let mut warnings = Vec::new();
let keys: Vec<String> = 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<BTreeMap<String, String>>,
}
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<Option<String>> {
Ok(self.map.lock().unwrap().get(key).cloned())
}
fn remove(&self, key: &str) -> Result<bool> {
Ok(self.map.lock().unwrap().remove(key).is_some())
}
fn list(&self) -> Result<Vec<String>> {
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"));
}
}