diff --git a/CHANGELOG.md b/CHANGELOG.md index ecbea4d..803a196 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,23 @@ range may break in any release. ### Added +- **`vault exec` — inject secrets as env vars at launch.** `vault exec -- + claude` (or `--profile -- `) resolves every var in a new + `[exec.profiles.]` mapping against the agent and injects the + plaintext only into the launched child's environment — never the invoking + shell, never disk — instead of `export`ing API keys into a shell session for + its lifetime. Mappings are edited via `vault config exec set/unset/list` + (no manual TOML). Each mapping is an item spec: `` (password + field, the default) or `#` where `` is + `username`/`notes`/`totp`/`custom:` — the last resolves a named + custom field, which required a new `Field::Custom(String)` wire variant + (`vault-ipc`) and custom-field decryption support in `vault-core` + (`PlainCipher::fields`, `DecryptOptions::custom_fields`) and `vault-agent`. + Every var is resolved *before* the child spawns — a missing item, missing + field, or ambiguous name aborts first, so the child never sees a + partially-populated environment. See `docs/exec.md` for the grammar and + what env-var injection does and doesn't protect against. + - **Fingerprint unlock (Linux, off by default).** With `agent.session_keyring` and the new `agent.fingerprint_unlock` enabled, `vault unlock --fingerprint` (and a TUI unlock-screen mode) re-unlock the keyring-held session after a diff --git a/PRD.md b/PRD.md index 435749b..fb1a121 100644 --- a/PRD.md +++ b/PRD.md @@ -105,7 +105,7 @@ keeps the crypto, sync, and storage code consolidated and auditable. ### 7.1 CLI surface Verbs are flat, matching `rbw`'s muscle memory; flags follow the Spacecraft -Software CLI Standard (SFRS v1.0.0). +Software CLI Standard (v1.0.0). | Command | Purpose | | -------------------------------------------- | --------------------------------------------- | @@ -120,6 +120,8 @@ Software CLI Standard (SFRS v1.0.0). | `vault generate [--length N] [--symbols]` | Password generation | | `vault purge` | Wipe local cache | | `vault config get`/`set`/`unset` | Settings | +| `vault config exec set`/`unset`/`list` | `[exec.profiles.*]` env-var-to-item mappings | +| `vault exec [--profile P] -- ` | Launch `` with those vars injected as env — see `docs/exec.md` | | `vault stop-agent` | Kill the daemon | **Standard-mandated flags on every subcommand:** diff --git a/README.md b/README.md index d36e6e3..a9f4b9c 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,8 @@ The full product requirements live in [`PRD.md`](./PRD.md). Pre-alpha, M5. The read and write paths are wired end-to-end against a live agent: `status` / `unlock` / `lock` / `sync` / `list` / `get` / `add` / -`edit` / `remove` / `generate` / `stop-agent` on the CLI (with `--json` -everywhere), and a three-pane `vault-tui` with search, reveal/copy +`edit` / `remove` / `generate` / `exec` / `stop-agent` on the CLI (with +`--json` everywhere), and a three-pane `vault-tui` with search, reveal/copy (agent-side clipboard, 30 s auto-clear), a generator overlay, a `:` command line, add/edit/delete, and an About overlay (`?` / `:about`). The CLI auto-starts the agent when needed, and @@ -208,6 +208,32 @@ The key is protected at rest by filesystem permissions (`0600`) only: it must be usable *before* the vault is unlocked, so it can't be encrypted under your key — the same trust level as the stored refresh token or an SSH private key. +### Inject secrets into other tools (`vault exec`) + +Rather than `export`ing API keys into your shell for the session, resolve them +from Vault at launch and inject them only into one child process: + +```sh +vault config exec set ANTHROPIC_API_KEY "Anthropic API Key (Claude Code)" +vault exec -- claude +``` + +Mappings live in named profiles (`--profile`, default `default`), so different +tools can pull the same env var from different items: + +```sh +vault config exec set --profile claude ANTHROPIC_API_KEY "Anthropic API Key (Claude Code)" +vault config exec set --profile openclaude ANTHROPIC_API_KEY "Anthropic API Key (OpenClaude)" +vault exec --profile claude -- claude +vault exec --profile openclaude -- openclaude +``` + +Every mapped var is resolved (and validated) before the child spawns — a +missing item, missing field, or ambiguous name aborts first, so the child +never sees a partially-populated environment. See [`docs/exec.md`](./docs/exec.md) +for the full item-spec grammar (`custom:` fields, etc.) and what +env-var injection does and doesn't protect against. + ### Stay unlocked across restarts (Linux, opt-in) By default the agent holds the key only in its own memory, so any restart diff --git a/crates/vault-agent/src/state.rs b/crates/vault-agent/src/state.rs index 56a00bf..c2e812e 100644 --- a/crates/vault-agent/src/state.rs +++ b/crates/vault-agent/src/state.rs @@ -649,6 +649,7 @@ impl AgentState { primary_uri: w.uri, card, identity, + fields: None, }; let mut cipher = Cipher::from_plain(&plain, &v.user_enc, &v.user_mac); v.ensure_online().await?; @@ -865,6 +866,10 @@ impl AgentState { primary_uri: true, ..DecryptOptions::default() }, + Field::Custom(_) => DecryptOptions { + custom_fields: true, + ..DecryptOptions::default() + }, Field::CardCardholder | Field::CardNumber | Field::CardBrand @@ -915,6 +920,19 @@ impl AgentState { }, Field::Notes => plain.notes.clone(), Field::Uri => plain.primary_uri.clone(), + Field::Custom(ref want) => { + let want_lower = want.to_lowercase(); + plain.fields.as_ref().and_then(|fields| { + fields + .iter() + .find(|f| { + f.name + .as_deref() + .is_some_and(|n| n.to_lowercase() == want_lower) + }) + .and_then(|f| f.value.clone()) + }) + } Field::CardCardholder => plain.card.as_ref().and_then(|c| c.cardholder_name.clone()), Field::CardNumber => plain.card.as_ref().and_then(|c| c.number.clone()), Field::CardBrand => plain.card.as_ref().and_then(|c| c.brand.clone()), diff --git a/crates/vault-cli/src/env_exec.rs b/crates/vault-cli/src/env_exec.rs new file mode 100644 index 0000000..e5f0dbc --- /dev/null +++ b/crates/vault-cli/src/env_exec.rs @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Item-spec grammar for `vault exec` / `vault config exec`. +//! +//! An `[exec.profiles.*]` mapping stores `ENV_VAR = ""`. The spec +//! names a vault item and, optionally, which field to pull the value from: +//! `` (defaults to the password field) or `#` +//! where `` is `password`/`username`/`notes`/`totp`/`custom:`. +//! This module only parses that string into a [`vault_ipc::proto::Field`] +//! selector — resolving it against the agent is `cmd_exec`'s job. + +use vault_ipc::proto::Field; + +/// A parsed item spec: which item, and which of its fields. +#[derive(Debug, PartialEq, Eq)] +pub struct FieldSpec { + /// Item name (decrypted form), matched like every other CLI selector. + pub name: String, + /// Which field to pull the value from. + pub field: Field, +} + +/// Parse an item spec (`""` or `"#"`). +/// +/// # Errors +/// +/// Returns a user-facing message when the item name is empty or the field +/// keyword after `#` isn't one of `password`/`username`/`notes`/`totp`/ +/// `custom:`. +pub fn parse_item_spec(raw: &str) -> Result { + let (name, field_str) = match raw.split_once('#') { + Some((name, field_str)) => (name.trim(), Some(field_str.trim())), + None => (raw.trim(), None), + }; + if name.is_empty() { + return Err(format!("empty item name in exec spec '{raw}'")); + } + let field = field_str.map_or(Ok(Field::Password), parse_field)?; + Ok(FieldSpec { + name: name.to_owned(), + field, + }) +} + +fn parse_field(spec: &str) -> Result { + let lower = spec.to_ascii_lowercase(); + match lower.as_str() { + "password" => Ok(Field::Password), + "username" => Ok(Field::Username), + "notes" => Ok(Field::Notes), + "totp" => Ok(Field::Totp), + _ if lower.starts_with("custom:") => { + let custom_name = spec["custom:".len()..].trim(); + if custom_name.is_empty() { + Err("custom field spec must include a name, e.g. 'custom:api_key'".to_owned()) + } else { + Ok(Field::Custom(custom_name.to_owned())) + } + } + _ => Err(format!( + "unknown exec field '{spec}' (expected password/username/notes/totp/custom:)" + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bare_name_defaults_to_password() { + let spec = parse_item_spec("Anthropic API Key").expect("parse"); + assert_eq!(spec.name, "Anthropic API Key"); + assert_eq!(spec.field, Field::Password); + } + + #[test] + fn explicit_field_keywords_parse() { + assert_eq!( + parse_item_spec("Item#username").expect("parse").field, + Field::Username + ); + assert_eq!( + parse_item_spec("Item#notes").expect("parse").field, + Field::Notes + ); + assert_eq!( + parse_item_spec("Item#totp").expect("parse").field, + Field::Totp + ); + // Case-insensitive keyword matching. + assert_eq!( + parse_item_spec("Item#PASSWORD").expect("parse").field, + Field::Password + ); + } + + #[test] + fn custom_field_preserves_name_case() { + let spec = parse_item_spec("Brave Search#custom:API_Key").expect("parse"); + assert_eq!(spec.field, Field::Custom("API_Key".to_owned())); + } + + #[test] + fn whitespace_around_name_and_field_is_trimmed() { + let spec = parse_item_spec(" Item Name # username ").expect("parse"); + assert_eq!(spec.name, "Item Name"); + assert_eq!(spec.field, Field::Username); + } + + #[test] + fn rejects_empty_name_and_unknown_field() { + assert!(parse_item_spec("#password").is_err()); + assert!(parse_item_spec("Item#bogus").is_err()); + assert!(parse_item_spec("Item#custom:").is_err()); + } +} diff --git a/crates/vault-cli/src/main.rs b/crates/vault-cli/src/main.rs index 16e0dbb..3d4e2fc 100644 --- a/crates/vault-cli/src/main.rs +++ b/crates/vault-cli/src/main.rs @@ -10,6 +10,7 @@ #![forbid(unsafe_code)] +mod env_exec; mod spawn; use vault_config as config; @@ -316,6 +317,20 @@ enum Cmd { #[command(subcommand)] action: ConfigAction, }, + /// Run a command with env vars injected from a `[exec.profiles.*]` + /// mapping (see `vault config exec` and `docs/exec.md`) — e.g. + /// `vault exec -- claude` or `vault exec --profile llm-agents -- opencode`. + /// Every configured var is resolved before the child spawns; a missing or + /// ambiguous item aborts first, so the child never sees a + /// partially-populated environment. + Exec { + /// Named profile from `[exec.profiles.]`. Defaults to `default`. + #[arg(long)] + profile: Option, + /// Command (and its arguments) to run. + #[arg(trailing_var_arg = true, allow_hyphen_values = true, required = true)] + command: Vec, + }, /// Wipe the local item cache from disk (and lock a running agent). Purge { /// Skip the confirmation prompt. Required when stdin is not a TTY. @@ -376,6 +391,49 @@ enum ConfigAction { #[arg(long)] json: bool, }, + /// Manage `vault exec` env-var-to-item mappings (`[exec.profiles.*]`). + Exec { + #[command(subcommand)] + action: ExecConfigAction, + }, +} + +#[derive(Subcommand, Debug)] +enum ExecConfigAction { + /// Map an env var to a vault item within a profile (profile created if new). + Set { + /// Profile name. + #[arg(long, default_value = "default")] + profile: String, + /// Env var name, e.g. `ANTHROPIC_API_KEY`. + var: String, + /// Item spec: `` or `#` (field is + /// `password`/`username`/`notes`/`totp`/`custom:`; defaults to + /// `password`). + item_spec: String, + /// Emit JSON instead of staying silent on success. + #[arg(long)] + json: bool, + }, + /// Remove an env var mapping from a profile (drops the profile once empty). + Unset { + /// Profile name. + #[arg(long, default_value = "default")] + profile: String, + /// Env var name to remove. + var: String, + /// Emit JSON instead of staying silent on success. + #[arg(long)] + json: bool, + }, + /// List every mapping in a profile, or every profile when none is given. + List { + /// Profile to list; omit to list every profile. + profile: Option, + /// Emit JSON instead of a human-readable listing. + #[arg(long)] + json: bool, + }, } #[derive(Subcommand, Debug)] @@ -809,6 +867,7 @@ async fn run(cmd: Cmd, ep: Endpoint<'_>) -> Result<(), u8> { } => cmd_remove(ep, selector, force, json).await, Cmd::StopAgent { json } => cmd_ack(ep.no_spawn(), Request::Quit, "stopped", json).await, Cmd::Config { action } => cmd_config(action), + Cmd::Exec { profile, command } => cmd_exec(ep, profile, command).await, Cmd::Purge { force, json } => cmd_purge(ep, force, json).await, Cmd::Generate { length, @@ -1524,9 +1583,88 @@ fn cmd_config(action: ConfigAction) -> Result<(), u8> { } Ok(()) } + ConfigAction::Exec { action } => cmd_config_exec(action), + } +} + +/// `vault config exec set/unset/list`. Same local-file-only shape as +/// `cmd_config`, just against the `[exec.profiles.*]` table instead of the +/// scalar `KNOWN_KEYS` registry. +fn cmd_config_exec(action: ExecConfigAction) -> Result<(), u8> { + match action { + ExecConfigAction::Set { + profile, + var, + item_spec, + json, + } => { + // Validate the spec parses before persisting a mapping `vault + // exec` could never resolve. + if let Err(msg) = env_exec::parse_item_spec(&item_spec) { + eprintln!("vault: {msg}"); + return Err(2); + } + let mut cfg = load_config()?; + cfg.exec_set(&profile, &var, &item_spec); + save_config(&cfg)?; + if json { + println!( + "{}", + serde_json::json!({ "profile": profile, "set": var, "item_spec": item_spec }) + ); + } + Ok(()) + } + ExecConfigAction::Unset { profile, var, json } => { + let mut cfg = load_config()?; + if let Err(msg) = cfg.exec_unset(&profile, &var) { + eprintln!("vault: {msg}"); + return Err(2); + } + save_config(&cfg)?; + if json { + println!( + "{}", + serde_json::json!({ "profile": profile, "unset": var }) + ); + } + Ok(()) + } + ExecConfigAction::List { profile, json } => cmd_config_exec_list(profile.as_deref(), json), } } +fn cmd_config_exec_list(profile: Option<&str>, json: bool) -> Result<(), u8> { + let cfg = load_config()?; + let profiles: Vec<(&String, &std::collections::BTreeMap)> = match profile { + Some(name) => cfg.exec.profiles.get_key_value(name).into_iter().collect(), + None => cfg.exec.profiles.iter().collect(), + }; + if json { + let map: serde_json::Map = profiles + .iter() + .map(|(name, vars)| { + let inner: serde_json::Map = vars + .iter() + .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone()))) + .collect(); + ((*name).clone(), serde_json::Value::Object(inner)) + }) + .collect(); + println!("{}", serde_json::Value::Object(map)); + } else if profiles.is_empty() { + println!("(no exec profiles configured)"); + } else { + for (name, vars) in profiles { + println!("[{name}]"); + for (var, spec) in vars { + println!(" {var} = {spec}"); + } + } + } + Ok(()) +} + fn cmd_config_get(key: Option<&str>, json: bool) -> Result<(), u8> { let cfg = load_config()?; if let Some(key) = key { @@ -1920,6 +2058,82 @@ async fn cmd_get(ep: Endpoint<'_>, name: String, field: Field, json: bool) -> Re } } +/// `vault exec` — resolve every `[exec.profiles.]` mapping to a +/// plaintext value, then launch `command` with those values injected as env +/// vars. Every var is resolved *before* the child spawns (fail closed): a +/// missing item, missing field, or ambiguous name aborts without ever +/// starting the child, so it never sees a partially-populated environment. +/// +/// The resolved values live only in this process's memory long enough to +/// spawn the child (which inherits them into its own environment — see +/// `docs/exec.md` for what that does and doesn't protect against); they're +/// zeroized here immediately after. +async fn cmd_exec( + ep: Endpoint<'_>, + profile: Option, + command: Vec, +) -> Result<(), u8> { + let Some((program, args)) = command.split_first() else { + eprintln!("vault: exec requires a command, e.g. `vault exec -- claude`"); + return Err(2); + }; + let profile_name = profile.as_deref().unwrap_or("default"); + let cfg = load_config()?; + let Some(vars) = cfg.exec_profile(profile_name) else { + eprintln!( + "vault: no exec profile named '{profile_name}' — configure one with \ + `vault config exec set --profile {profile_name} `" + ); + return Err(2); + }; + if vars.is_empty() { + eprintln!("vault: exec profile '{profile_name}' has no mappings"); + return Err(2); + } + + let mut stream = connect(ep).await?; + let mut resolved: Vec<(String, String)> = Vec::with_capacity(vars.len()); + for (env_var, spec) in vars { + let field_spec = env_exec::parse_item_spec(spec).map_err(|msg| { + eprintln!("vault: exec profile '{profile_name}': {env_var}: {msg}"); + 2u8 + })?; + let req = Request::Get { + id: None, + name: field_spec.name, + field: Some(field_spec.field), + }; + match exchange(&mut stream, &req).await? { + Response::Item(item) => resolved.push((env_var.clone(), item.value.clone())), + Response::Error(e) => { + eprintln!("vault: exec: resolving {env_var}: {e}"); + return Err(error_exit_code(&e)); + } + other => return unexpected(&other), + } + } + + let status = std::process::Command::new(program) + .args(args) + .envs(resolved.iter().map(|(k, v)| (k.as_str(), v.as_str()))) + .status(); + for (_, v) in &mut resolved { + v.zeroize(); + } + + match status { + Ok(status) if status.success() => Ok(()), + Ok(status) => Err(status + .code() + .and_then(|c| u8::try_from(c).ok()) + .unwrap_or(1)), + Err(e) => { + eprintln!("vault: failed to run '{program}': {e}"); + Err(127) + } + } +} + async fn connect(ep: Endpoint<'_>) -> Result { match UnixStream::connect(ep.socket).await { Ok(s) => Ok(s), @@ -1961,8 +2175,12 @@ async fn exchange(stream: &mut UnixStream, req: &Request) -> Result Result<(), u8> { - let code = match e { +/// The exit code a given agent error maps to. Factored out of +/// [`report_error`] so callers that need to add their own context line (e.g. +/// `cmd_exec`, which names the env var that failed to resolve) can still +/// reuse the same mapping instead of duplicating it. +const fn error_exit_code(e: &IpcError) -> u8 { + match e { IpcError::Locked => 4, IpcError::BadPassword => 5, IpcError::TwoFactorRequired => 6, @@ -1979,9 +2197,12 @@ fn report_error(e: &IpcError) -> Result<(), u8> { | IpcError::Internal(_) | IpcError::Decrypt(_) | IpcError::ClipboardUnavailable => 9, - }; + } +} + +fn report_error(e: &IpcError) -> Result<(), u8> { eprintln!("vault: {e}"); - Err(code) + Err(error_exit_code(e)) } fn unexpected(other: &Response) -> Result<(), u8> { diff --git a/crates/vault-config/src/lib.rs b/crates/vault-config/src/lib.rs index 9ce121b..de90eff 100644 --- a/crates/vault-config/src/lib.rs +++ b/crates/vault-config/src/lib.rs @@ -14,6 +14,7 @@ //! `[account]` profile to drive in-place unlock). The registry is shaped to //! grow into the rest of PRD §7.1's keys without disturbing callers. +use std::collections::BTreeMap; use std::ffi::OsString; use std::io::Write; use std::path::PathBuf; @@ -58,6 +59,11 @@ pub struct Config { /// empty `[account]` table. #[serde(skip_serializing_if = "AccountCfg::is_empty")] pub account: AccountCfg, + /// `vault exec` env-var-to-item-spec profiles. Skipped from the file until + /// a profile is configured, so a config with no `exec` use carries no + /// empty `[exec]` table. + #[serde(skip_serializing_if = "ExecCfg::is_empty")] + pub exec: ExecCfg, } /// `[clipboard]` table. @@ -150,6 +156,30 @@ impl AccountCfg { } } +/// `[exec.profiles.*]` tables — env-var-name → item-spec mappings. +/// +/// Consumed by `vault exec`. Unlike the scalar `KNOWN_KEYS` registry, this is +/// a table of tables, so it's edited via its own `exec_set`/`exec_unset` +/// methods rather than the generic `Config::set`. `BTreeMap` keeps both the +/// profile names and the env vars within a profile in a stable, sorted order +/// for `config.toml` and `vault config exec list`. +#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)] +#[serde(default, deny_unknown_fields)] +pub struct ExecCfg { + /// Profile name → (env var name → item spec, e.g. `"Anthropic API Key"` or + /// `"Anthropic API Key#custom:api_key"`). Item-spec grammar is parsed by + /// `vault-cli`, not here — this crate only stores and round-trips strings. + pub profiles: BTreeMap>, +} + +impl ExecCfg { + /// Whether no profile is set (drives skipping the table on write). + #[must_use] + pub fn is_empty(&self) -> bool { + self.profiles.is_empty() + } +} + impl Config { /// Effective `clipboard.clear_secs`, if set. #[must_use] @@ -222,6 +252,43 @@ impl Config { } } + /// The `[exec.profiles.]` mapping, if any entry is configured for + /// it. Used by `vault exec` to resolve every env var it must inject. + #[must_use] + pub fn exec_profile(&self, profile: &str) -> Option<&BTreeMap> { + self.exec.profiles.get(profile) + } + + /// Map `env_var` to `item_spec` within `profile` (created if new). + pub fn exec_set(&mut self, profile: &str, env_var: &str, item_spec: &str) { + self.exec + .profiles + .entry(profile.to_owned()) + .or_default() + .insert(env_var.to_owned(), item_spec.to_owned()); + } + + /// Remove `env_var` from `profile`. Drops the profile entirely once its + /// last mapping is removed, so an unused profile doesn't linger empty in + /// `config.toml`. + /// + /// # Errors + /// + /// Returns a user-facing message when `profile` or `env_var` isn't set. + pub fn exec_unset(&mut self, profile: &str, env_var: &str) -> Result<(), String> { + let vars = self + .exec + .profiles + .get_mut(profile) + .ok_or_else(|| format!("no such exec profile '{profile}'"))?; + vars.remove(env_var) + .ok_or_else(|| format!("'{env_var}' is not set in exec profile '{profile}'"))?; + if vars.is_empty() { + self.exec.profiles.remove(profile); + } + Ok(()) + } + /// Current value of `key` as a display string, `None` when unset. /// /// # Errors @@ -569,6 +636,57 @@ mod tests { assert_eq!(back.account(), c.account()); } + #[test] + fn exec_set_get_unset_round_trips() { + let mut c = Config::default(); + assert_eq!(c.exec_profile("default"), None); + + c.exec_set("default", "ANTHROPIC_API_KEY", "Anthropic API Key"); + c.exec_set( + "default", + "BRAVE_API_KEY", + "Brave Search API Key#custom:api_key", + ); + let profile = c.exec_profile("default").expect("profile present"); + assert_eq!( + profile.get("ANTHROPIC_API_KEY").map(String::as_str), + Some("Anthropic API Key") + ); + assert_eq!( + profile.get("BRAVE_API_KEY").map(String::as_str), + Some("Brave Search API Key#custom:api_key") + ); + + // Survives a toml round-trip. + let text = toml::to_string_pretty(&c).expect("serialise"); + let back: Config = toml::from_str(&text).expect("parse"); + assert_eq!(back.exec_profile("default"), c.exec_profile("default")); + + // Unset removes just that var; the profile survives with the other. + c.exec_unset("default", "ANTHROPIC_API_KEY").expect("unset"); + assert_eq!( + c.exec_profile("default").map(BTreeMap::len), + Some(1), + "profile should still hold BRAVE_API_KEY" + ); + + // Unsetting the last var drops the whole profile. + c.exec_unset("default", "BRAVE_API_KEY").expect("unset"); + assert_eq!(c.exec_profile("default"), None); + + // Unknown profile/var are user-facing errors, not panics. + assert!(c.exec_unset("no-such-profile", "X").is_err()); + } + + #[test] + fn exec_profiles_absent_until_set() { + let empty = toml::to_string_pretty(&Config::default()).expect("serialise"); + assert!( + !empty.contains("[exec"), + "empty config grew an [exec] table:\n{empty}" + ); + } + #[test] fn agent_args_emits_only_set_keys_in_order() { let mut c = Config::default(); diff --git a/crates/vault-core/src/cipher.rs b/crates/vault-core/src/cipher.rs index 8d99b45..4aa2c50 100644 --- a/crates/vault-core/src/cipher.rs +++ b/crates/vault-core/src/cipher.rs @@ -187,6 +187,25 @@ pub struct CustomField { pub field_type: u8, } +/// One decrypted `Fields[]` entry. +#[derive(Clone, Debug, Default)] +pub struct PlainCustomField { + /// Field name (as the user labeled it). + pub name: Option, + /// Field value (sensitive when `field_type == 1`, hidden — zeroized on drop + /// regardless, since a text-type field can still hold a secret in + /// practice). + pub value: Option, +} + +impl Drop for PlainCustomField { + fn drop(&mut self) { + if let Some(s) = self.value.as_mut() { + s.zeroize(); + } + } +} + /// Decrypted card fields (cipher type 3). #[derive(Clone, Debug, Default)] pub struct PlainCard { @@ -296,6 +315,9 @@ pub struct PlainCipher { pub card: Option, /// Decrypted identity fields (identity items only, when asked for). pub identity: Option, + /// Decrypted custom fields, when asked for. `PlainCustomField` zeroizes + /// its own value on drop, so no extra scrubbing is needed here. + pub fields: Option>, } impl Drop for PlainCipher { @@ -330,6 +352,10 @@ pub struct DecryptOptions { pub card: bool, /// Decrypt the `identity` sub-object (all its fields). Default `false`. pub identity: bool, + /// Decrypt every custom `Fields[]` entry (name + value). Default `false`. + /// There is no way to decrypt a single custom field by name without first + /// decrypting every field's name to search it, so this is all-or-nothing. + pub custom_fields: bool, } impl DecryptOptions { @@ -344,6 +370,7 @@ impl DecryptOptions { primary_uri: true, card: true, identity: true, + custom_fields: true, } } /// Decrypt only `username` — useful for list views. @@ -357,6 +384,7 @@ impl DecryptOptions { primary_uri: false, card: false, identity: false, + custom_fields: false, } } } @@ -451,6 +479,7 @@ impl Cipher { primary_uri: None, card: None, identity: None, + fields: None, }; if opts.card @@ -493,6 +522,23 @@ impl Cipher { }); } + if opts.custom_fields + && let Some(fields) = self.fields.as_ref() + { + let d = |s: Option<&str>| decrypt_optional(s, enc_key, mac_key); + out.fields = Some( + fields + .iter() + .map(|f| { + Ok(PlainCustomField { + name: d(f.name.as_deref())?, + value: d(f.value.as_deref())?, + }) + }) + .collect::>>()?, + ); + } + if let Some(login) = self.login.as_ref() { if opts.username { out.username = decrypt_optional(login.username.as_deref(), enc_key, mac_key)?; diff --git a/crates/vault-core/tests/cipher_encrypt.rs b/crates/vault-core/tests/cipher_encrypt.rs index e40f3b0..8f3e50f 100644 --- a/crates/vault-core/tests/cipher_encrypt.rs +++ b/crates/vault-core/tests/cipher_encrypt.rs @@ -22,6 +22,7 @@ fn from_plain_then_decrypt_round_trips_login() { primary_uri: Some("https://github.com".into()), card: None, identity: None, + fields: None, }; let cipher = Cipher::from_plain(&plain, &enc, &mac); @@ -57,6 +58,7 @@ fn from_plain_secure_note_has_no_login_object() { primary_uri: None, card: None, identity: None, + fields: None, }; let cipher = Cipher::from_plain(&plain, &enc, &mac); @@ -82,6 +84,7 @@ fn from_plain_omits_absent_login_fields() { primary_uri: None, card: None, identity: None, + fields: None, }; let cipher = Cipher::from_plain(&plain, &enc, &mac); @@ -117,6 +120,7 @@ fn from_plain_then_decrypt_round_trips_card() { code: Some("123".into()), }), identity: None, + fields: None, }; let cipher = Cipher::from_plain(&plain, &enc, &mac); @@ -184,6 +188,7 @@ fn from_plain_then_decrypt_round_trips_identity() { postal_code: None, country: None, }), + fields: None, }; let cipher = Cipher::from_plain(&plain, &enc, &mac); diff --git a/crates/vault-ipc/src/proto.rs b/crates/vault-ipc/src/proto.rs index e864388..dc674a2 100644 --- a/crates/vault-ipc/src/proto.rs +++ b/crates/vault-ipc/src/proto.rs @@ -533,7 +533,7 @@ impl Drop for Item { } /// Selectable field on `Request::Get`. -#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] +#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum Field { /// `Login.Password`. The default. @@ -547,6 +547,10 @@ pub enum Field { Notes, /// First `Login.Uris[].Uri`. Uri, + /// A named custom field (any cipher type), matched case-insensitively + /// against `CustomField.name`. Used by `vault exec` to pull an API key + /// out of a custom field rather than the password slot. + Custom(String), /// `Card.CardholderName`. CardCardholder, /// `Card.Number`. diff --git a/crates/vault-tui/src/app.rs b/crates/vault-tui/src/app.rs index 55881d4..f32440d 100644 --- a/crates/vault-tui/src/app.rs +++ b/crates/vault-tui/src/app.rs @@ -393,7 +393,7 @@ pub const fn primary_copy_field(cipher_type: u8) -> Option<(Field, &'static str) /// One navigable field in the detail pane: its label, the agent selector to /// reveal/copy it, and whether it renders masked until revealed. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq)] pub struct DetailField { /// Label shown in the detail pane. pub label: &'static str, @@ -1500,7 +1500,7 @@ impl App { #[must_use] pub fn selected_detail_field(&self) -> Option { let e = self.selected_entry()?; - detail_fields(e.cipher_type).get(self.detail_field).copied() + detail_fields(e.cipher_type).get(self.detail_field).cloned() } /// The `(id, name, field)` that `Space` should reveal given the current @@ -1535,10 +1535,10 @@ impl App { /// Whether `field` of the item with `entry_id` is currently revealed. #[must_use] - pub fn is_revealed(&self, entry_id: &str, field: Field) -> bool { + pub fn is_revealed(&self, entry_id: &str, field: &Field) -> bool { self.revealed .as_ref() - .is_some_and(|r| r.entry_id == entry_id && r.field == field) + .is_some_and(|r| r.entry_id == entry_id && &r.field == field) } /// Reveal a freshly-fetched secret in the detail pane. @@ -2285,7 +2285,9 @@ mod tests { assert!(f.masked, "{label} must be masked"); } assert_eq!( - id.iter().find(|f| f.label == "SSN").map(|f| f.field), + id.iter() + .find(|f| f.label == "SSN") + .map(|f| f.field.clone()), Some(Field::IdentitySsn) ); } @@ -2336,18 +2338,18 @@ mod tests { #[test] fn reveal_is_tracked_per_item_and_field() { let mut app = App::browsing(status(), vec![entry("a", None)]); - assert!(!app.is_revealed("id-a", Field::Password)); + assert!(!app.is_revealed("id-a", &Field::Password)); app.reveal(RevealedSecret::new( "id-a".to_owned(), Field::Password, "hunter2".to_owned(), )); - assert!(app.is_revealed("id-a", Field::Password)); + assert!(app.is_revealed("id-a", &Field::Password)); // A different item or field is not considered revealed. - assert!(!app.is_revealed("id-b", Field::Password)); - assert!(!app.is_revealed("id-a", Field::Username)); + assert!(!app.is_revealed("id-b", &Field::Password)); + assert!(!app.is_revealed("id-a", &Field::Username)); app.hide_revealed(); - assert!(!app.is_revealed("id-a", Field::Password)); + assert!(!app.is_revealed("id-a", &Field::Password)); } #[test] diff --git a/crates/vault-tui/src/main.rs b/crates/vault-tui/src/main.rs index 9a69ac7..eca4be4 100644 --- a/crates/vault-tui/src/main.rs +++ b/crates/vault-tui/src/main.rs @@ -732,14 +732,14 @@ async fn toggle_reveal(state: &mut App, socket: &Path) { return; }; - if state.is_revealed(&id, field) { + if state.is_revealed(&id, &field) { state.hide_revealed(); return; } let req = Request::Get { id: Some(id.clone()), name: name.clone(), - field: Some(field), + field: Some(field.clone()), }; match client::request(socket, &req).await { Ok(Response::Item(item)) => { @@ -752,11 +752,11 @@ async fn toggle_reveal(state: &mut App, socket: &Path) { } /// Fetch one field's value for an item, or `None` on a missing field / error. -async fn fetch_field(socket: &Path, id: &str, name: &str, field: Field) -> Option { +async fn fetch_field(socket: &Path, id: &str, name: &str, field: &Field) -> Option { let req = Request::Get { id: Some(id.to_owned()), name: name.to_owned(), - field: Some(field), + field: Some(field.clone()), }; match client::request(socket, &req).await { Ok(Response::Item(item)) => Some(item.value.clone()), @@ -812,7 +812,7 @@ async fn ensure_detail(state: &mut App, socket: &Path) { }; let mut lines = Vec::new(); for (label, field) in specs { - if let Some(value) = fetch_field(socket, &sel.id, &sel.name, *field).await { + if let Some(value) = fetch_field(socket, &sel.id, &sel.name, field).await { lines.push(((*label).to_owned(), value)); } } @@ -835,13 +835,13 @@ async fn copy_field(state: &mut App, socket: &Path, field: Field, label: &str) { let req = Request::Copy { id: Some(sel.id.clone()), name: sel.name.clone(), - field: Some(field), + field: Some(field.clone()), clear_after_secs: None, }; match client::request(socket, &req).await { Ok(Response::Copied(c)) => state.set_toast(copied_toast(label, c.clear_after_secs)), Ok(Response::Error(IpcError::ClipboardUnavailable)) => { - osc52_field_fallback(state, socket, &sel.id, &sel.name, field, label).await; + osc52_field_fallback(state, socket, &sel.id, &sel.name, &field, label).await; } Ok(Response::Error(e)) => state.set_toast(format!("copy failed: {e}")), Ok(other) => state.set_toast(format!("unexpected response: {other:?}")), @@ -866,13 +866,13 @@ async fn osc52_field_fallback( socket: &Path, id: &str, name: &str, - field: Field, + field: &Field, label: &str, ) { let req = Request::Get { id: Some(id.to_owned()), name: name.to_owned(), - field: Some(field), + field: Some(field.clone()), }; match client::request(socket, &req).await { Ok(Response::Item(item)) => osc52_copy(state, &item.value, label), diff --git a/crates/vault-tui/src/ui.rs b/crates/vault-tui/src/ui.rs index 02cfde0..78ce4fb 100644 --- a/crates/vault-tui/src/ui.rs +++ b/crates/vault-tui/src/ui.rs @@ -259,7 +259,7 @@ fn render_detail(frame: &mut Frame, app: &App, area: Rect) { ]; // A masked field shown in amber when revealed, steel when hidden. let masked = |lines: &mut Vec, label: &str, field: Field| { - if app.is_revealed(&e.id, field) { + if app.is_revealed(&e.id, &field) { let value = app.revealed.as_ref().map_or(MASK, |r| r.value()); lines.push(field_line(label, value, amber)); } else { @@ -281,7 +281,7 @@ fn render_detail(frame: &mut Frame, app: &App, area: Rect) { // (`app.detail_field`) selects one for reveal/copy. 3 | 4 => { for (i, fd) in detail_fields(e.cipher_type).iter().enumerate() { - let revealed = fd.masked && app.is_revealed(&e.id, fd.field); + let revealed = fd.masked && app.is_revealed(&e.id, &fd.field); let value = if fd.masked { if revealed { app.revealed.as_ref().map_or(MASK, |r| r.value()).to_owned() diff --git a/docs/exec.md b/docs/exec.md new file mode 100644 index 0000000..462be8a --- /dev/null +++ b/docs/exec.md @@ -0,0 +1,79 @@ + + +# `vault exec` — inject secrets as env vars at launch + +Run a command with env vars resolved from vault items at launch time, instead +of exporting API keys into your shell for the session (`export +ANTHROPIC_API_KEY=…`) where they sit in shell history, `env`, and +`/proc//environ` for as long as the shell lives. + +```sh +vault exec -- claude +vault exec --profile llm-agents -- opencode +``` + +Each configured env var is resolved with one `Get` against the agent and +injected only into the child process's environment — never into the invoking +shell, and never written to disk in plaintext. + +## Configure a profile + +Mappings live in `[exec.profiles.]` in `config.toml`, edited via `vault +config exec` (no manual TOML editing needed): + +```sh +vault config exec set ANTHROPIC_API_KEY "Anthropic API Key" +vault config exec set --profile llm-agents ANTHROPIC_API_KEY "Anthropic API Key" +vault config exec set --profile llm-agents BRAVE_API_KEY "Brave Search API Key#custom:api_key" + +vault config exec list # every profile +vault config exec list default # one profile +vault config exec unset BRAVE_API_KEY # drops the profile once its last var is gone +``` + +`--profile` defaults to `default` on both `exec` and `config exec` when +omitted. Give each LLM agent its own vault item (`Anthropic API Key (Claude +Code)`, `OpenAI API Key (Codex)`, …) so keys can be rotated or revoked +independently — that's the point of routing through Vault instead of one +shared shell export. + +## Item-spec grammar + +The right-hand side of each mapping is `` or `#`: + +| Spec | Resolves to | +|--------------------------------|-------------------------------------------| +| `Anthropic API Key` | the item's password field (the default) | +| `Item#username` | the item's username field | +| `Item#notes` | the item's notes field | +| `Item#totp` | a freshly generated TOTP code | +| `Item#custom:api_key` | the custom field named `api_key` (case-insensitive) | + +Use a custom field when the key doesn't naturally live in the password slot — +e.g. an item that also stores an org id in the password field and the API key +in a custom field. + +## Failure is closed, not partial + +`vault exec` resolves every mapped var *before* launching the child. If any +item is missing, a field is absent, or a name matches more than one item, the +command aborts with a clear error and the child never starts — you never get +a half-populated environment silently missing one key. + +## What this does and doesn't protect against + +Once injected, the values are ordinary env vars in the child process: visible +to that process, to anything it in turn execs, and — for other processes +running as the same user — via `/proc//environ` for the child's +lifetime. That's inherent to how OS environment variables work, the same as +`envchain`, `direnv exec`, or `vaultenv`; `vault exec` isn't a secret-holding +channel once a value crosses into the child's environment. What it *does* +give you over a shell `export`: + +- The secret is resolved fresh at launch, not left sitting in the invoking + shell's environment (or its history) for the rest of the session. +- Nothing is written to disk in plaintext — it's decrypted in the agent and + crosses the local UDS socket only as far as this one process. +- Per-agent items mean revoking or rotating one key doesn't require touching + the others.