Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> -- <cmd>`) resolves every var in a new
`[exec.profiles.<name>]` 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: `<item name>` (password
field, the default) or `<item name>#<field>` where `<field>` is
`username`/`notes`/`totp`/`custom:<name>` — 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
Expand Down
4 changes: 3 additions & 1 deletion PRD.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| -------------------------------------------- | --------------------------------------------- |
Expand All @@ -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] -- <cmd>` | Launch `<cmd>` with those vars injected as env — see `docs/exec.md` |
| `vault stop-agent` | Kill the daemon |

**Standard-mandated flags on every subcommand:**
Expand Down
30 changes: 28 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:<name>` 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
Expand Down
18 changes: 18 additions & 0 deletions crates/vault-agent/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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?;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()),
Expand Down
117 changes: 117 additions & 0 deletions crates/vault-cli/src/env_exec.rs
Original file line number Diff line number Diff line change
@@ -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 = "<item spec>"`. The spec
//! names a vault item and, optionally, which field to pull the value from:
//! `<item name>` (defaults to the password field) or `<item name>#<field>`
//! where `<field>` is `password`/`username`/`notes`/`totp`/`custom:<name>`.
//! 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 (`"<item name>"` or `"<item name>#<field>"`).
///
/// # 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:<name>`.
pub fn parse_item_spec(raw: &str) -> Result<FieldSpec, String> {
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<Field, String> {
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:<name>)"
)),
}
}

#[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());
}
}
Loading
Loading