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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,20 @@ claw-wrap supports two approaches for credential injection:
- You want route-based credential injection by host/path
- Multiple tools need the same API credentials

## Secret Backends

| Backend | Prefix | Example | Notes |
| ------- | ------ | ------- | ----- |
| [pass](https://www.passwordstore.org/) | `pass:` | `pass:cli/github/token` | Default when no prefix given |
| Environment | `env:` | `env:MY_TOKEN` | Reads from daemon environment |
| [1Password](https://1password.com/) | `op://` | `op://Vault/Item/field` | Requires `op` CLI, session auth |
| [Bitwarden](https://bitwarden.com/) | `bw:` | `bw:item-uuid` | Requires `bw` CLI, session managed |
| [macOS Keychain](https://support.apple.com/guide/keychain-access/) | `keychain:` | `keychain:service-name` | macOS only |
| [age](https://age-encryption.org/) | `age:` | `age:/path/to/file.age` | File-level encryption |
| [HashiCorp Vault](https://www.vaultproject.io/) | `vault:` | `vault:secret/myapp/key` | KV-v1 & KV-v2, external auth |

All backends except `env:` support jq extraction: `vault:secret/app/creds \| .password`

## Quick Start

This example sets up `gh` (GitHub CLI) as a proxied tool.
Expand Down
48 changes: 47 additions & 1 deletion docs/CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ Optional in-memory TTL cache for credential fetch results.

- Default: `0` (disabled)
- Format: Go duration (`30s`, `2m`, `1h`)
- Scope: only `op://` (1Password) and `bw:` (Bitwarden) credential sources
- Scope: `op://` (1Password), `bw:` (Bitwarden), and `vault:` (HashiCorp Vault) credential sources
- `claw-wrap check` always bypasses this cache and fetches credentials live

Use this to reduce repeated upstream secret-store latency for frequently-invoked tools.
Expand Down Expand Up @@ -685,6 +685,52 @@ If `bw_binary` is unset, claw-wrap only auto-detects `bw` in trusted directories
- Session token passed via environment variable, not command line
- Session cleaned up on daemon shutdown

### HashiCorp Vault (`vault:`)

```yaml
credentials:
api-key:
source: vault:secret/myapp/api-key

# With jq extraction from secret JSON
db-password:
source: vault:secret/myapp/database | .password
```

Fetches secrets from HashiCorp Vault using the `vault` CLI. Supports both KV-v2 (default) and KV-v1 engines.

Use natural paths (e.g., `secret/myapp/key`) — the `vault kv get` command handles the KV-v2 `/data/` path prefix internally.

Optional CLI and connection overrides:

```yaml
proxy:
vault_binary: /usr/bin/vault
vault_addr: https://127.0.0.1:8200
vault_skip_verify: false
vault_cacert: /etc/vault/ca.pem
vault_namespace: ""
vault_token_file: /home/bot/.vault-token
```

If `vault_binary` is unset, claw-wrap only auto-detects `vault` in trusted directories:
`/usr/bin`, `/usr/local/bin`, `/opt/homebrew/bin`, `/home/linuxbrew/.linuxbrew/bin`.

Connection settings (`vault_addr`, `vault_skip_verify`, `vault_cacert`, `vault_namespace`) override the corresponding `VAULT_ADDR`, `VAULT_SKIP_VERIFY`, `VAULT_CACERT`, and `VAULT_NAMESPACE` environment variables when set.

**Authentication model:**

claw-wrap does **not** authenticate with Vault itself. The user (or operator) must run `vault login` externally, which stores a token at `~/.vault-token`. The `vault` CLI reads this token automatically. This supports time-scoped access: configure TTL on the Vault user so tokens expire after a set window (15 minutes, 1 hour, etc.).

Use `vault_token_file` to point to a non-default token file location (requires Vault CLI 1.10+).

**Security:**

- Secrets never stored in plaintext config — fetched on-demand via CLI
- Token managed externally; claw-wrap cannot refresh or extend access
- Expired tokens produce a generic "vault read failed" error
- Supports self-signed certs via `vault_cacert` or `vault_skip_verify`

### jq Extraction

All backends support jq extraction using the pipe syntax:
Expand Down
71 changes: 69 additions & 2 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,12 @@ type ProxyConfig struct {
ReplayCacheTTL string `yaml:"replay_cache_ttl"` // e.g., "2m"
ReplayCacheMax int `yaml:"replay_cache_max_entries"`
CredentialCacheTTL string `yaml:"credential_cache_ttl"` // e.g., "30s" (0/empty disables)
VaultBinary string `yaml:"vault_binary"` // e.g., "/usr/bin/vault"
VaultAddr string `yaml:"vault_addr"` // e.g., "https://127.0.0.1:8200"
VaultSkipVerify *bool `yaml:"vault_skip_verify"` // skip TLS verification (nil = inherit env)
VaultCACert string `yaml:"vault_cacert"` // e.g., "/path/to/ca.pem"
VaultNamespace string `yaml:"vault_namespace"` // enterprise namespace
VaultTokenFile string `yaml:"vault_token_file"` // override default ~/.vault-token
}

// SecurityConfig holds security policy flags.
Expand Down Expand Up @@ -185,8 +191,8 @@ type ToolDef struct {
AllowedArgs []BlockedArg `yaml:"allowed_args,omitempty"`
RedactOutput []ToolRedactRule `yaml:"redact_output,omitempty"`
ConfigFile *ConfigFileDef `yaml:"config_file,omitempty"`
UseProxy bool `yaml:"use_proxy,omitempty"` // Enable HTTP proxy for this tool
UsePTY *bool `yaml:"use_pty,omitempty"` // PTY mode: nil=default on, false=opt out
UseProxy bool `yaml:"use_proxy,omitempty"` // Enable HTTP proxy for this tool
UsePTY *bool `yaml:"use_pty,omitempty"` // PTY mode: nil=default on, false=opt out
}

// GetUsePTY returns whether PTY mode is enabled for this tool.
Expand Down Expand Up @@ -826,6 +832,67 @@ func (c *Config) GetBWBinary() string {
return ""
}

// GetVaultBinary returns the configured Vault CLI binary path or empty for trusted-directory lookup.
func (c *Config) GetVaultBinary() string {
if c.Proxy != nil && c.Proxy.VaultBinary != "" {
if !filepath.IsAbs(c.Proxy.VaultBinary) {
log.Printf("[WARN] vault_binary %q is not absolute, using trusted-directory lookup", c.Proxy.VaultBinary)
return ""
}
return c.Proxy.VaultBinary
}
return ""
}

// GetVaultAddr returns the configured Vault server address (empty = use VAULT_ADDR env).
func (c *Config) GetVaultAddr() string {
if c.Proxy != nil {
return c.Proxy.VaultAddr
}
return ""
}

// GetVaultSkipVerify returns whether to skip Vault TLS verification.
// nil = not configured (inherit ambient env), non-nil = explicit override.
func (c *Config) GetVaultSkipVerify() *bool {
if c.Proxy != nil {
return c.Proxy.VaultSkipVerify
}
return nil
}
Comment on lines 855 to 862
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4e9362avault_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.).


// GetVaultCACert returns the Vault CA cert path (empty = use VAULT_CACERT env).
func (c *Config) GetVaultCACert() string {
if c.Proxy != nil && c.Proxy.VaultCACert != "" {
if !filepath.IsAbs(c.Proxy.VaultCACert) {
log.Printf("[WARN] vault_cacert %q is not absolute, ignoring", c.Proxy.VaultCACert)
return ""
}
return c.Proxy.VaultCACert
}
return ""
}

// GetVaultNamespace returns the Vault enterprise namespace (empty = none).
func (c *Config) GetVaultNamespace() string {
if c.Proxy != nil {
return c.Proxy.VaultNamespace
}
return ""
}

// GetVaultTokenFile returns the Vault token file path (empty = default ~/.vault-token).
func (c *Config) GetVaultTokenFile() string {
if c.Proxy != nil && c.Proxy.VaultTokenFile != "" {
if !filepath.IsAbs(c.Proxy.VaultTokenFile) {
log.Printf("[WARN] vault_token_file %q is not absolute, ignoring", c.Proxy.VaultTokenFile)
return ""
}
return c.Proxy.VaultTokenFile
}
return ""
}

// GetOPTokenFile returns the configured 1Password token file path or the default.
// The returned path is always absolute.
func (c *Config) GetOPTokenFile() string {
Expand Down
129 changes: 129 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -662,6 +662,135 @@ func TestGetBWBinary(t *testing.T) {
}
}

func TestGetVaultBinary(t *testing.T) {
tests := []struct {
name string
cfg Config
want string
}{
{"nil proxy returns empty", Config{}, ""},
{"empty vault_binary returns empty", Config{Proxy: &ProxyConfig{}}, ""},
{"configured absolute path returned", Config{Proxy: &ProxyConfig{VaultBinary: "/usr/bin/vault"}}, "/usr/bin/vault"},
{"relative path rejected", Config{Proxy: &ProxyConfig{VaultBinary: "vault"}}, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.cfg.GetVaultBinary(); got != tt.want {
t.Errorf("GetVaultBinary() = %q, want %q", got, tt.want)
}
})
}
}

func TestGetVaultAddr(t *testing.T) {
tests := []struct {
name string
cfg Config
want string
}{
{"nil proxy", Config{}, ""},
{"empty", Config{Proxy: &ProxyConfig{}}, ""},
{"configured", Config{Proxy: &ProxyConfig{VaultAddr: "https://vault:8200"}}, "https://vault:8200"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.cfg.GetVaultAddr(); got != tt.want {
t.Errorf("GetVaultAddr() = %q, want %q", got, tt.want)
}
})
}
}

func TestGetVaultSkipVerify(t *testing.T) {
boolPtr := func(v bool) *bool { return &v }

t.Run("nil proxy", func(t *testing.T) {
cfg := Config{}
if got := cfg.GetVaultSkipVerify(); got != nil {
t.Errorf("GetVaultSkipVerify() = %v, want nil", *got)
}
})
t.Run("unset returns nil", func(t *testing.T) {
cfg := Config{Proxy: &ProxyConfig{}}
if got := cfg.GetVaultSkipVerify(); got != nil {
t.Errorf("GetVaultSkipVerify() = %v, want nil", *got)
}
})
t.Run("explicit true", func(t *testing.T) {
cfg := Config{Proxy: &ProxyConfig{VaultSkipVerify: boolPtr(true)}}
got := cfg.GetVaultSkipVerify()
if got == nil || !*got {
t.Errorf("GetVaultSkipVerify() = %v, want *true", got)
}
})
t.Run("explicit false", func(t *testing.T) {
cfg := Config{Proxy: &ProxyConfig{VaultSkipVerify: boolPtr(false)}}
got := cfg.GetVaultSkipVerify()
if got == nil || *got {
t.Errorf("GetVaultSkipVerify() = %v, want *false", got)
}
})
}

func TestGetVaultCACert(t *testing.T) {
tests := []struct {
name string
cfg Config
want string
}{
{"nil proxy", Config{}, ""},
{"empty", Config{Proxy: &ProxyConfig{}}, ""},
{"absolute path", Config{Proxy: &ProxyConfig{VaultCACert: "/etc/vault/ca.pem"}}, "/etc/vault/ca.pem"},
{"relative path rejected", Config{Proxy: &ProxyConfig{VaultCACert: "ca.pem"}}, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.cfg.GetVaultCACert(); got != tt.want {
t.Errorf("GetVaultCACert() = %q, want %q", got, tt.want)
}
})
}
}

func TestGetVaultNamespace(t *testing.T) {
tests := []struct {
name string
cfg Config
want string
}{
{"nil proxy", Config{}, ""},
{"empty", Config{Proxy: &ProxyConfig{}}, ""},
{"configured", Config{Proxy: &ProxyConfig{VaultNamespace: "team-a"}}, "team-a"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.cfg.GetVaultNamespace(); got != tt.want {
t.Errorf("GetVaultNamespace() = %q, want %q", got, tt.want)
}
})
}
}

func TestGetVaultTokenFile(t *testing.T) {
tests := []struct {
name string
cfg Config
want string
}{
{"nil proxy", Config{}, ""},
{"empty returns empty", Config{Proxy: &ProxyConfig{}}, ""},
{"absolute path", Config{Proxy: &ProxyConfig{VaultTokenFile: "/home/bot/.vault-token"}}, "/home/bot/.vault-token"},
{"relative path rejected", Config{Proxy: &ProxyConfig{VaultTokenFile: ".vault-token"}}, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.cfg.GetVaultTokenFile(); got != tt.want {
t.Errorf("GetVaultTokenFile() = %q, want %q", got, tt.want)
}
})
}
}

func TestLoad_PassBinaryFromYAML(t *testing.T) {
tmpDir := t.TempDir()

Expand Down
2 changes: 1 addition & 1 deletion internal/credentials/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ func sweepInterval(ttl time.Duration) time.Duration {
}

func isCredentialCacheableBackend(backend Backend) bool {
return backend == Backend1Password || backend == BackendBitwarden
return backend == Backend1Password || backend == BackendBitwarden || backend == BackendVault
}

func credentialCacheKey(parsed *ParsedSource) string {
Expand Down
15 changes: 15 additions & 0 deletions internal/credentials/credentials.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ type FetchOptions struct {
PassBinary string
OPBinary string
BWBinary string
VaultBinary string
BypassCache bool
}

Expand Down Expand Up @@ -52,6 +53,13 @@ func WithBWBinary(path string) FetchOption {
}
}

// WithVaultBinary sets the path to the HashiCorp Vault CLI binary.
func WithVaultBinary(path string) FetchOption {
return func(o *FetchOptions) {
o.VaultBinary = path
}
}

// WithBypassCache forces live credential fetches and bypasses result caching.
func WithBypassCache() FetchOption {
return func(o *FetchOptions) {
Expand All @@ -67,6 +75,7 @@ func WithBypassCache() FetchOption {
// - age:/path/to/file.age - decrypt age-encrypted file
// - keychain:service-name - fetch from macOS Keychain
// - bw:item-uuid - fetch from Bitwarden
// - vault:secret/path - fetch from HashiCorp Vault
// - path/in/store - legacy format, assumed to be pass
//
// All sources optionally support jq extraction: "source | .jq_expr"
Expand Down Expand Up @@ -145,6 +154,12 @@ func Fetch(source string, opts ...FetchOption) (string, error) {
return "", err
}

case BackendVault:
result, err = fetchFromVault(ctx, parsed, options.VaultBinary)
if err != nil {
return "", err
}

default:
return "", fmt.Errorf("unknown credential backend")
}
Expand Down
8 changes: 8 additions & 0 deletions internal/credentials/credentials_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,14 @@ func TestWithBWBinary(t *testing.T) {
}
}

func TestWithVaultBinary(t *testing.T) {
opts := &FetchOptions{}
WithVaultBinary("/custom/vault")(opts)
if opts.VaultBinary != "/custom/vault" {
t.Errorf("VaultBinary = %q, want %q", opts.VaultBinary, "/custom/vault")
}
}

func TestWithBypassCache(t *testing.T) {
opts := &FetchOptions{}
WithBypassCache()(opts)
Expand Down
Loading