Skip to content

Commit b9a82e0

Browse files
UnbreakableMJclaude
andcommitted
feat(cli,core,agent,ipc,config): vault exec — inject secrets as env vars at launch
Adds `vault exec [--profile P] -- <cmd>`, which resolves every var in a configurable `[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 exporting API keys into a shell session for its lifetime. Mappings are edited via `vault config exec set/unset/list`. Each mapping is an item spec: `<item name>` (password field, 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) plus 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. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent d17c643 commit b9a82e0

14 files changed

Lines changed: 679 additions & 29 deletions

File tree

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,23 @@ range may break in any release.
7676

7777
### Added
7878

79+
- **`vault exec` — inject secrets as env vars at launch.** `vault exec --
80+
claude` (or `--profile <name> -- <cmd>`) resolves every var in a new
81+
`[exec.profiles.<name>]` mapping against the agent and injects the
82+
plaintext only into the launched child's environment — never the invoking
83+
shell, never disk — instead of `export`ing API keys into a shell session for
84+
its lifetime. Mappings are edited via `vault config exec set/unset/list`
85+
(no manual TOML). Each mapping is an item spec: `<item name>` (password
86+
field, the default) or `<item name>#<field>` where `<field>` is
87+
`username`/`notes`/`totp`/`custom:<name>` — the last resolves a named
88+
custom field, which required a new `Field::Custom(String)` wire variant
89+
(`vault-ipc`) and custom-field decryption support in `vault-core`
90+
(`PlainCipher::fields`, `DecryptOptions::custom_fields`) and `vault-agent`.
91+
Every var is resolved *before* the child spawns — a missing item, missing
92+
field, or ambiguous name aborts first, so the child never sees a
93+
partially-populated environment. See `docs/exec.md` for the grammar and
94+
what env-var injection does and doesn't protect against.
95+
7996
- **Fingerprint unlock (Linux, off by default).** With `agent.session_keyring`
8097
and the new `agent.fingerprint_unlock` enabled, `vault unlock --fingerprint`
8198
(and a TUI unlock-screen mode) re-unlock the keyring-held session after a

PRD.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ keeps the crypto, sync, and storage code consolidated and auditable.
105105
### 7.1 CLI surface
106106

107107
Verbs are flat, matching `rbw`'s muscle memory; flags follow the Spacecraft
108-
Software CLI Standard (SFRS v1.0.0).
108+
Software CLI Standard (v1.0.0).
109109

110110
| Command | Purpose |
111111
| -------------------------------------------- | --------------------------------------------- |
@@ -120,6 +120,8 @@ Software CLI Standard (SFRS v1.0.0).
120120
| `vault generate [--length N] [--symbols]` | Password generation |
121121
| `vault purge` | Wipe local cache |
122122
| `vault config get`/`set`/`unset` | Settings |
123+
| `vault config exec set`/`unset`/`list` | `[exec.profiles.*]` env-var-to-item mappings |
124+
| `vault exec [--profile P] -- <cmd>` | Launch `<cmd>` with those vars injected as env — see `docs/exec.md` |
123125
| `vault stop-agent` | Kill the daemon |
124126

125127
**Standard-mandated flags on every subcommand:**

README.md

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ The full product requirements live in [`PRD.md`](./PRD.md).
1717

1818
Pre-alpha, M5. The read and write paths are wired end-to-end against a live
1919
agent: `status` / `unlock` / `lock` / `sync` / `list` / `get` / `add` /
20-
`edit` / `remove` / `generate` / `stop-agent` on the CLI (with `--json`
21-
everywhere), and a three-pane `vault-tui` with search, reveal/copy
20+
`edit` / `remove` / `generate` / `exec` / `stop-agent` on the CLI (with
21+
`--json` everywhere), and a three-pane `vault-tui` with search, reveal/copy
2222
(agent-side clipboard, 30 s auto-clear), a generator overlay, a `:` command
2323
line, add/edit/delete, and an About overlay (`?` / `:about`). The CLI
2424
auto-starts the agent when needed, and
@@ -208,6 +208,32 @@ The key is protected at rest by filesystem permissions (`0600`) only: it must
208208
be usable *before* the vault is unlocked, so it can't be encrypted under your
209209
key — the same trust level as the stored refresh token or an SSH private key.
210210

211+
### Inject secrets into other tools (`vault exec`)
212+
213+
Rather than `export`ing API keys into your shell for the session, resolve them
214+
from Vault at launch and inject them only into one child process:
215+
216+
```sh
217+
vault config exec set ANTHROPIC_API_KEY "Anthropic API Key (Claude Code)"
218+
vault exec -- claude
219+
```
220+
221+
Mappings live in named profiles (`--profile`, default `default`), so different
222+
tools can pull the same env var from different items:
223+
224+
```sh
225+
vault config exec set --profile claude ANTHROPIC_API_KEY "Anthropic API Key (Claude Code)"
226+
vault config exec set --profile openclaude ANTHROPIC_API_KEY "Anthropic API Key (OpenClaude)"
227+
vault exec --profile claude -- claude
228+
vault exec --profile openclaude -- openclaude
229+
```
230+
231+
Every mapped var is resolved (and validated) before the child spawns — a
232+
missing item, missing field, or ambiguous name aborts first, so the child
233+
never sees a partially-populated environment. See [`docs/exec.md`](./docs/exec.md)
234+
for the full item-spec grammar (`custom:<name>` fields, etc.) and what
235+
env-var injection does and doesn't protect against.
236+
211237
### Stay unlocked across restarts (Linux, opt-in)
212238

213239
By default the agent holds the key only in its own memory, so any restart

crates/vault-agent/src/state.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -649,6 +649,7 @@ impl AgentState {
649649
primary_uri: w.uri,
650650
card,
651651
identity,
652+
fields: None,
652653
};
653654
let mut cipher = Cipher::from_plain(&plain, &v.user_enc, &v.user_mac);
654655
v.ensure_online().await?;
@@ -865,6 +866,10 @@ impl AgentState {
865866
primary_uri: true,
866867
..DecryptOptions::default()
867868
},
869+
Field::Custom(_) => DecryptOptions {
870+
custom_fields: true,
871+
..DecryptOptions::default()
872+
},
868873
Field::CardCardholder
869874
| Field::CardNumber
870875
| Field::CardBrand
@@ -915,6 +920,19 @@ impl AgentState {
915920
},
916921
Field::Notes => plain.notes.clone(),
917922
Field::Uri => plain.primary_uri.clone(),
923+
Field::Custom(ref want) => {
924+
let want_lower = want.to_lowercase();
925+
plain.fields.as_ref().and_then(|fields| {
926+
fields
927+
.iter()
928+
.find(|f| {
929+
f.name
930+
.as_deref()
931+
.is_some_and(|n| n.to_lowercase() == want_lower)
932+
})
933+
.and_then(|f| f.value.clone())
934+
})
935+
}
918936
Field::CardCardholder => plain.card.as_ref().and_then(|c| c.cardholder_name.clone()),
919937
Field::CardNumber => plain.card.as_ref().and_then(|c| c.number.clone()),
920938
Field::CardBrand => plain.card.as_ref().and_then(|c| c.brand.clone()),

crates/vault-cli/src/env_exec.rs

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// SPDX-License-Identifier: GPL-3.0-or-later
2+
3+
//! Item-spec grammar for `vault exec` / `vault config exec`.
4+
//!
5+
//! An `[exec.profiles.*]` mapping stores `ENV_VAR = "<item spec>"`. The spec
6+
//! names a vault item and, optionally, which field to pull the value from:
7+
//! `<item name>` (defaults to the password field) or `<item name>#<field>`
8+
//! where `<field>` is `password`/`username`/`notes`/`totp`/`custom:<name>`.
9+
//! This module only parses that string into a [`vault_ipc::proto::Field`]
10+
//! selector — resolving it against the agent is `cmd_exec`'s job.
11+
12+
use vault_ipc::proto::Field;
13+
14+
/// A parsed item spec: which item, and which of its fields.
15+
#[derive(Debug, PartialEq, Eq)]
16+
pub struct FieldSpec {
17+
/// Item name (decrypted form), matched like every other CLI selector.
18+
pub name: String,
19+
/// Which field to pull the value from.
20+
pub field: Field,
21+
}
22+
23+
/// Parse an item spec (`"<item name>"` or `"<item name>#<field>"`).
24+
///
25+
/// # Errors
26+
///
27+
/// Returns a user-facing message when the item name is empty or the field
28+
/// keyword after `#` isn't one of `password`/`username`/`notes`/`totp`/
29+
/// `custom:<name>`.
30+
pub fn parse_item_spec(raw: &str) -> Result<FieldSpec, String> {
31+
let (name, field_str) = match raw.split_once('#') {
32+
Some((name, field_str)) => (name.trim(), Some(field_str.trim())),
33+
None => (raw.trim(), None),
34+
};
35+
if name.is_empty() {
36+
return Err(format!("empty item name in exec spec '{raw}'"));
37+
}
38+
let field = field_str.map_or(Ok(Field::Password), parse_field)?;
39+
Ok(FieldSpec {
40+
name: name.to_owned(),
41+
field,
42+
})
43+
}
44+
45+
fn parse_field(spec: &str) -> Result<Field, String> {
46+
let lower = spec.to_ascii_lowercase();
47+
match lower.as_str() {
48+
"password" => Ok(Field::Password),
49+
"username" => Ok(Field::Username),
50+
"notes" => Ok(Field::Notes),
51+
"totp" => Ok(Field::Totp),
52+
_ if lower.starts_with("custom:") => {
53+
let custom_name = spec["custom:".len()..].trim();
54+
if custom_name.is_empty() {
55+
Err("custom field spec must include a name, e.g. 'custom:api_key'".to_owned())
56+
} else {
57+
Ok(Field::Custom(custom_name.to_owned()))
58+
}
59+
}
60+
_ => Err(format!(
61+
"unknown exec field '{spec}' (expected password/username/notes/totp/custom:<name>)"
62+
)),
63+
}
64+
}
65+
66+
#[cfg(test)]
67+
mod tests {
68+
use super::*;
69+
70+
#[test]
71+
fn bare_name_defaults_to_password() {
72+
let spec = parse_item_spec("Anthropic API Key").expect("parse");
73+
assert_eq!(spec.name, "Anthropic API Key");
74+
assert_eq!(spec.field, Field::Password);
75+
}
76+
77+
#[test]
78+
fn explicit_field_keywords_parse() {
79+
assert_eq!(
80+
parse_item_spec("Item#username").expect("parse").field,
81+
Field::Username
82+
);
83+
assert_eq!(
84+
parse_item_spec("Item#notes").expect("parse").field,
85+
Field::Notes
86+
);
87+
assert_eq!(
88+
parse_item_spec("Item#totp").expect("parse").field,
89+
Field::Totp
90+
);
91+
// Case-insensitive keyword matching.
92+
assert_eq!(
93+
parse_item_spec("Item#PASSWORD").expect("parse").field,
94+
Field::Password
95+
);
96+
}
97+
98+
#[test]
99+
fn custom_field_preserves_name_case() {
100+
let spec = parse_item_spec("Brave Search#custom:API_Key").expect("parse");
101+
assert_eq!(spec.field, Field::Custom("API_Key".to_owned()));
102+
}
103+
104+
#[test]
105+
fn whitespace_around_name_and_field_is_trimmed() {
106+
let spec = parse_item_spec(" Item Name # username ").expect("parse");
107+
assert_eq!(spec.name, "Item Name");
108+
assert_eq!(spec.field, Field::Username);
109+
}
110+
111+
#[test]
112+
fn rejects_empty_name_and_unknown_field() {
113+
assert!(parse_item_spec("#password").is_err());
114+
assert!(parse_item_spec("Item#bogus").is_err());
115+
assert!(parse_item_spec("Item#custom:").is_err());
116+
}
117+
}

0 commit comments

Comments
 (0)