secret : trousseau OS + injection @secret (issue #36) #86
Generated
+12
-1
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
+29
@@ -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 {
|
||||
|
||||
@@ -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<i32> {
|
||||
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 })
|
||||
}
|
||||
|
||||
@@ -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
@@ -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} <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 {
|
||||
name: "history",
|
||||
category: "Commands",
|
||||
|
||||
@@ -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;
|
||||
|
||||
+19
-1
@@ -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"
|
||||
|
||||
+151
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user