feat: add HashiCorp Vault credential backend#9
Conversation
Spawn `vault kv get -format=json` via CLI, matching existing backend pattern (op, bw, pass). Zero new Go dependencies. - KV-v2 and KV-v1 auto-detection (checks for both data + metadata) - jq pipe support: `vault:secret/app/creds | .password` - Config fields: vault_binary, vault_addr, vault_skip_verify, vault_cacert, vault_namespace, vault_token_file - Hot-reload of all vault settings via fsnotify - Wired through daemon, executor, and HTTP proxy - Secret backends table added to README
There was a problem hiding this comment.
Pull request overview
Adds HashiCorp Vault as an additional credential backend, fetched via the vault CLI, and wires the new backend through daemon execution, HTTP proxy injection, config surface area, and documentation.
Changes:
- Introduce
vault:credential source parsing + Vault CLI fetcher with KV-v1/KV-v2 auto-detection and optional jq extraction. - Add Vault-related config fields/getters and propagate them through daemon/executor/proxy setup (including config hot-reload hooks).
- Update docs/README and expand test coverage for the new backend.
Reviewed changes
Copilot reviewed 15 out of 16 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| internal/httpproxy/proxy.go | Adds WithVaultBinary option and passes Vault binary override into credential fetch options for proxy injections. |
| internal/daemon/executor_env_test.go | Formatting-only alignment change in executor env tests. |
| internal/daemon/executor.go | Threads Vault binary override into credential fetching for tool env building and config file generation. |
| internal/daemon/daemon_test.go | Removes trailing whitespace line. |
| internal/daemon/daemon.go | Adds hot-reload wiring for Vault settings + passes Vault binary override into HTTP proxy construction and admin fetches. |
| internal/credentials/vault_test.go | New unit tests for Vault fetcher (KV-v1/KV-v2), jq extraction, env builder, and error cases. |
| internal/credentials/vault.go | New Vault backend implementation (CLI spawn, env overrides, KV response parsing). |
| internal/credentials/parser_test.go | Adds parser tests for vault: sources and jq pipe syntax. |
| internal/credentials/parser.go | Introduces BackendVault and parsing support for the vault: prefix. |
| internal/credentials/credentials_test.go | Adds a unit test for the new WithVaultBinary fetch option. |
| internal/credentials/credentials.go | Adds VaultBinary to fetch options and dispatches BackendVault to the Vault fetcher. |
| internal/credentials/cache.go | Allows credential caching for Vault backend (alongside 1Password and Bitwarden). |
| internal/config/config_test.go | Adds tests for Vault config getters (binary/addr/skip-verify/CA cert/namespace/token file). |
| internal/config/config.go | Adds Vault config fields to ProxyConfig and implements Vault config getters. |
| docs/CONFIG.md | Documents the vault: backend, config knobs, and cache scope update. |
| README.md | Adds a “Secret Backends” table including Vault and jq extraction note. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
internal/credentials/vault.go
Outdated
| ctx, cancel := context.WithTimeout(ctx, vaultCommandTimeout) | ||
| defer cancel() | ||
|
|
||
| cmd := exec.CommandContext(ctx, vaultBinary, "kv", "get", "-format=json", parsed.Path) |
There was a problem hiding this comment.
parsed.Path is user-controlled config and is passed as the final CLI argument without a -- end-of-options marker. If the path begins with -, the Vault CLI may treat it as a flag (option injection) instead of a secret path. Add -- before parsed.Path (similar to fetchFromPass) to ensure the path is always interpreted as a positional argument.
| cmd := exec.CommandContext(ctx, vaultBinary, "kv", "get", "-format=json", parsed.Path) | |
| cmd := exec.CommandContext(ctx, vaultBinary, "kv", "get", "-format=json", "--", parsed.Path) |
There was a problem hiding this comment.
Fixed in 4e9362a — added -- before parsed.Path, matching the pass backend pattern at credentials.go:262.
| func vaultEnv() []string { | ||
| env := os.Environ() | ||
| addr, skipVerify, caCert, namespace, tokenFile := getVaultSettings() | ||
|
|
||
| if addr != "" { | ||
| env = append(env, "VAULT_ADDR="+addr) | ||
| } | ||
| if skipVerify { | ||
| env = append(env, "VAULT_SKIP_VERIFY=1") | ||
| } |
There was a problem hiding this comment.
vaultEnv() appends VAULT_* entries to os.Environ() without removing existing keys. Vault is a Go binary and os.LookupEnv returns the first occurrence, so if the daemon already has VAULT_ADDR/VAULT_TOKEN_FILE/etc set, these appended values will not override them (contrary to the intended config behavior). Build the env by filtering out any existing VAULT_* keys first (or use a map-based merge) before appending the configured overrides.
There was a problem hiding this comment.
Fixed in 4e9362a — vaultEnv() now strips the specific VAULT_* keys it's about to set before appending overrides. Unconfigured fields (empty string / nil) inherit ambient env.
| // GetVaultSkipVerify returns whether to skip Vault TLS verification. | ||
| func (c *Config) GetVaultSkipVerify() bool { | ||
| return c.Proxy != nil && c.Proxy.VaultSkipVerify | ||
| } |
There was a problem hiding this comment.
vault_skip_verify is a plain bool, so config cannot distinguish “unset” vs “explicitly false”. If the daemon environment already contains VAULT_SKIP_VERIFY=1, setting vault_skip_verify: false in config won’t be able to force verification on (especially once vaultEnv() is fixed to override env vars). Consider making this a *bool (tri-state) and, when non-nil, explicitly set VAULT_SKIP_VERIFY to 0/1 in the child env.
There was a problem hiding this comment.
Fixed in 4e9362a — vault_skip_verify is now *bool. nil = inherit ambient env, true sets VAULT_SKIP_VERIFY=1, false sets VAULT_SKIP_VERIFY=0. Matches existing pattern (RequireAuth, UsePTY, etc.).
| proxy := httpproxy.New(httpCfg, cfg.Credentials, | ||
| httpproxy.WithPassBinary(cfg.GetPassBinary()), | ||
| httpproxy.WithOPBinary(cfg.GetOPBinary()), | ||
| httpproxy.WithBWBinary(cfg.GetBWBinary()), | ||
| httpproxy.WithVaultBinary(cfg.GetVaultBinary()), | ||
| httpproxy.WithAuthToken(d.proxyAuthToken), | ||
| httpproxy.WithRequireAuth(requireAuth), |
There was a problem hiding this comment.
The HTTP proxy is constructed with WithVaultBinary(cfg.GetVaultBinary()), but Daemon.reloadConfig() only restarts the proxy on listen/auth/CA changes and Proxy.ReloadConfig() does not update credOpts. This means changes to proxy.vault_binary (and other *_binary settings) won’t take effect until the proxy is restarted for other reasons. Either include these binary paths in the “needsRestart” check or add a way to update credential fetch options during reload.
There was a problem hiding this comment.
This is a pre-existing limitation for all backends — op_binary, bw_binary, and pass_binary all have the same issue in Proxy.ReloadConfig(). Not addressing in this PR to keep scope contained, but noted for a follow-up.
…tate skip_verify - Add `--` end-of-options guard before path argument (prevents option injection if path starts with `-`) - Rewrite vaultEnv() to strip managed VAULT_* keys before appending configured overrides, so config values actually take precedence - Change vault_skip_verify to *bool tri-state: nil inherits ambient env, explicit true/false overrides VAULT_SKIP_VERIFY
Summary
vault kv get -format=jsonvia CLI (zero new Go deps)data+metadatafields)vault:secret/app/creds | .passwordvault_binary,vault_addr,vault_skip_verify,vault_cacert,vault_namespace,vault_token_fileDesign
Follows the existing CLI-spawning pattern (like
op,bw,pass). User managesvault loginexternally — claw-wrap never authenticates itself. Tokens are read from~/.vault-tokenorVAULT_TOKEN_FILE.Test plan
go test ./...— all greenmake dev(fmt + vet + build) — cleanclaw-wrap daemon+claw-wrap checkagainst a real Vault instance (KV-v1)