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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ Traditional secrets management relies on returning credentials directly to the c

Agent Vault takes a different approach: **Agent Vault never reveals vault-stored credentials to agents**. Instead, agents route HTTP requests through a local proxy that injects the right credentials at the network layer.

- **Brokered access, not retrieval** - Your agent gets a scoped session and a local `HTTPS_PROXY`. It calls target APIs normally, and Agent Vault injects the right credential at the network layer (headers, or — for APIs like Twilio that need the value in the URL — declared placeholder rewrites in the path or query string). Credentials are never returned to the agent.
- **Brokered access, not retrieval** - Your agent gets a scoped session and a local `HTTPS_PROXY`. It calls target APIs normally, and Agent Vault injects the right credential at the network layer (headers, or — for APIs like Twilio that need the value in the URL — declared placeholder rewrites in the path or query string). Credentials are never returned to the agent. Hosts without a configured service forward as plain proxy traffic by default; flip a vault into strict deny mode (`unmatched_host_policy=deny`) to reject them with 403 instead.
- **Works with any agent** - Custom Python/TypeScript agents, sandboxed processes, and coding agents like Claude Code, Cursor, and Codex. Anything that speaks HTTP — including streaming responses and WebSocket-based voice/realtime APIs (e.g. OpenAI Realtime).
- **Encrypted at rest** - Credentials are encrypted with AES-256-GCM using a random data encryption key (DEK). An optional master password wraps the DEK via Argon2id, so rotating the password does not re-encrypt credentials. A passwordless mode is available for PaaS deploys.
- **Request logs** - Every proxied request is persisted per vault with method, host, path, status, latency, and the credential key names involved. Bodies, headers, and query strings are not recorded. Retention is configurable per vault.
Expand Down
6 changes: 4 additions & 2 deletions cmd/skill_cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ You have access to Agent Vault, a transparent HTTPS proxy that injects credentia

**Not every HTTP request needs Agent Vault credentials.** Unauthenticated requests or requests to hosts not configured in Agent Vault still pass through the proxy unmodified -- no special handling required.

By default each vault forwards unmatched hosts as plain proxy traffic (no credential injection). An operator may flip a vault into **strict deny mode** (`unmatched_host_policy=deny`), in which case requests to hosts that aren't in discover return `403 forbidden` with a `proposal_hint`. If you see that error, propose the service rather than retrying.

## Environment Variables

| Variable | Description |
Expand Down Expand Up @@ -88,7 +90,7 @@ Proposals are the primary way to exchange credentials with a human operator. Use
- **Want to store a credential back** -- include the value in a credential slot and the human confirms it at approval.
- **Need proxy access to a new host** -- propose a service with an `auth` config so Agent Vault can authenticate on your behalf.

When you get a `403` for a host not in discover, the response includes a `proposal_hint` with the denied host.
When you get a `403` for a host not in discover (only happens under strict deny mode), the response includes a `proposal_hint` with the denied host.

## Choosing the Right Auth Method

Expand Down Expand Up @@ -254,7 +256,7 @@ Prints the raw value to stdout (pipe-friendly). Useful for configuration tasks w
## Error Handling

- 401: Invalid or expired token -- check `AGENT_VAULT_SESSION_TOKEN`
- 403 `forbidden`: Host not allowed -- create a proposal
- 403 `forbidden`: Host not allowed (only fires under `unmatched_host_policy=deny`) -- create a proposal
- 403 `service_disabled`: Host is configured but currently disabled by an operator. Don't create a new proposal; surface the error to the user so they can re-enable it (UI toggle, or `agent-vault vault service enable <host>`)
- 429: Rate limited. The response carries a `Retry-After` header (seconds) and a JSON body `{"error":"too_many_requests", ...}`. Respect `Retry-After` — wait that many seconds before retrying. Don't tight-loop. If this trips on normal work, ask the instance owner to raise the limit in **Manage Instance → Settings → Rate Limiting**.
- 502: Missing credential or upstream unreachable, tell user a credential may need to be added
Expand Down
6 changes: 4 additions & 2 deletions cmd/skill_http.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ You have access to Agent Vault, a transparent HTTPS proxy that injects credentia

**Not every HTTP request needs Agent Vault credentials.** Unauthenticated requests or requests to hosts not configured in Agent Vault still pass through the proxy unmodified -- no special handling required.

By default each vault forwards unmatched hosts as plain proxy traffic (no credential injection). An operator may flip a vault into **strict deny mode** (`unmatched_host_policy=deny`), in which case requests to hosts that aren't in `/discover` return `403 forbidden` with a `proposal_hint`. If you see that error, propose the service rather than retrying.

## Environment Variables

| Variable | Description |
Expand Down Expand Up @@ -97,7 +99,7 @@ Proposals are the primary way to exchange credentials with a human operator. Use
- **Want to store a credential back** -- include the value in a credential slot and the human confirms it at approval.
- **Need proxy access to a new host** -- propose a service with an `auth` config so Agent Vault can authenticate on your behalf.

When you get a `403` for a host not in `/discover`, the response includes a `proposal_hint` with the denied host.
When you get a `403` for a host not in `/discover` (only happens when the vault is in strict deny mode), the response includes a `proposal_hint` with the denied host.

## Choosing the Right Auth Method

Expand Down Expand Up @@ -250,7 +252,7 @@ Content-Type: application/json
## Error Handling

- 401: Invalid or expired token -- check `AGENT_VAULT_SESSION_TOKEN`
- 403 `forbidden`: Host not allowed -- create a proposal
- 403 `forbidden`: Host not allowed (only fires under `unmatched_host_policy=deny`) -- create a proposal
- 403 `service_disabled`: Host is configured but currently disabled by an operator. Don't create a new proposal; surface the error to the user so they can re-enable it
- 429: Rate limited. The response carries a `Retry-After` header (seconds) and a JSON body `{"error":"too_many_requests", ...}`. Respect `Retry-After` — wait that many seconds before retrying. Do **not** tight-loop. If the limit trips repeatedly on normal work, ask the instance owner to raise the limit in **Manage Instance → Settings → Rate Limiting**.
- 502: Missing credential or upstream unreachable, tell user a credential may need to be added
Expand Down
2 changes: 1 addition & 1 deletion docs/agents/protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ See [Proposals](/learn/proposals) for the full proposal lifecycle, including sto
| Status | Meaning | What the agent does |
|---|---|---|
| 401 | Invalid or expired token | Re-check `AGENT_VAULT_SESSION_TOKEN`. Contact operator for a new token or [rotation](/agents/overview#rotating-an-agent-token). |
| 403 `forbidden` | Host not allowed | Create a proposal to request access. The response includes a `proposal_hint`. |
| 403 `forbidden` | Host not allowed (only fires when the vault has `unmatched_host_policy=deny`; the default is passthrough) | Create a proposal to request access. The response includes a `proposal_hint`. |
| 403 `service_disabled` | Host is configured but disabled | Surface to the user — don't create a duplicate proposal. |
| 429 | Rate limited | Respect the `Retry-After` header. |
| 502 | Missing credential or upstream unreachable | Tell the user a credential may need to be added. |
Expand Down
2 changes: 1 addition & 1 deletion docs/guides/connect-custom-agent.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ Discovery is optional. If your agent already knows which hosts are configured, i
| Status | Meaning | What to do |
|---|---|---|
| 401 | Invalid or expired token | Re-authenticate. Contact the operator for a [token rotation](/agents/overview#rotating-an-agent-token). |
| 403 | Host not allowed | Create a [proposal](/learn/proposals) to request access, or ask the vault admin to add it. |
| 403 | Host not allowed (only fires when the vault is in strict deny mode) | Create a [proposal](/learn/proposals) to request access, or ask the vault admin to add the service. |
| 429 | Too many requests | Respect the `Retry-After` header. |
| 502 | Missing credential or upstream error | A credential may need to be added to the vault. |

Expand Down
2 changes: 1 addition & 1 deletion docs/learn/services.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ title: "Services"
description: "Define which services your agents can access and how credentials are attached."
---

A `Service` is a per-[vault](/learn/vaults) configuration that defines which hosts [agents](/agents/overview) can reach through the proxy and how Agent Vault authenticates to each one. When an agent makes a proxied request, Agent Vault matches the target host against the vault's services, attaches the configured [credentials](/learn/credentials), and forwards the request. If no service matches, Agent Vault returns `403`.
A `Service` is a per-[vault](/learn/vaults) configuration that defines which hosts [agents](/agents/overview) can reach through the proxy and how Agent Vault authenticates to each one. When an agent makes a proxied request, Agent Vault matches the target host against the vault's services, attaches the configured [credentials](/learn/credentials), and forwards the request. If no service matches, the request is forwarded as plain proxy traffic by default; vault admins can flip a vault into **strict deny mode** (`unmatched_host_policy=deny` in vault settings) to reject unmatched hosts with `403` instead.

Each service contains:

Expand Down
43 changes: 38 additions & 5 deletions internal/brokercore/credential.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package brokercore

import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"net"

Expand All @@ -11,6 +13,20 @@ import (
"github.com/Infisical/agent-vault/internal/store"
)

// UnmatchedHostPolicy controls what happens when a request's target host
// does not match any configured broker service. PolicyPassthrough is the
// system-wide default; PolicyDeny is the opt-in strict mode.
type UnmatchedHostPolicy string

const (
PolicyPassthrough UnmatchedHostPolicy = "passthrough"
PolicyDeny UnmatchedHostPolicy = "deny"
)

func IsValidUnmatchedHostPolicy(p UnmatchedHostPolicy) bool {
return p == PolicyPassthrough || p == PolicyDeny
}

// InjectResult is the outcome of matching a target host and resolving
// credentials to ready-to-attach HTTP headers.
type InjectResult struct {
Expand All @@ -21,7 +37,8 @@ type InjectResult struct {
Headers map[string]string

// MatchedHost is the broker service host pattern that matched the
// target (e.g. "api.github.com"). Safe to log.
// target (e.g. "api.github.com"). Safe to log. Empty when the request
// is forwarded under the unmatched-host passthrough policy.
MatchedHost string

// CredentialKeys are the credential key names referenced by the
Expand All @@ -34,6 +51,10 @@ type InjectResult struct {
// apply via ApplySubstitutions before forwarding. Each entry carries
// a SECRET Value — never log; placeholder names are safe.
Substitutions []ResolvedSubstitution

// Passthrough is set when no service matched but the vault's
// unmatched-host policy permitted forwarding. Audited.
Passthrough bool
}

// CredentialProvider resolves a broker service for targetHost inside vaultID
Expand All @@ -46,6 +67,7 @@ type CredentialProvider interface {
type CredentialStore interface {
GetBrokerConfig(ctx context.Context, vaultID string) (*store.BrokerConfig, error)
GetCredential(ctx context.Context, vaultID, key string) (*store.Credential, error)
UnmatchedHostPolicy(ctx context.Context, vaultID string) (UnmatchedHostPolicy, error)
}

// StoreCredentialProvider injects credentials using a CredentialStore and a
Expand All @@ -66,14 +88,19 @@ func NewStoreCredentialProvider(s CredentialStore, encKey []byte) *StoreCredenti
// targetHost may include a port; the port is stripped before matching so
// services configured as bare hostnames match `api.github.com:443`.
func (p *StoreCredentialProvider) Inject(ctx context.Context, vaultID, targetHost string) (*InjectResult, error) {
// A missing row is equivalent to an empty services list — fall
// through to the unmatched-host policy. Any other error fails closed
// so a transient store failure can't silently strip enforcement.
cfg, err := p.Store.GetBrokerConfig(ctx, vaultID)
if err != nil || cfg == nil {
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, ErrServiceNotFound
}

var services []broker.Service
if err := json.Unmarshal([]byte(cfg.ServicesJSON), &services); err != nil {
return nil, fmt.Errorf("brokercore: parsing broker services: %w", err)
if cfg != nil && cfg.ServicesJSON != "" {
if err := json.Unmarshal([]byte(cfg.ServicesJSON), &services); err != nil {
return nil, fmt.Errorf("brokercore: parsing broker services: %w", err)
}
}

matchHost := targetHost
Expand All @@ -82,7 +109,13 @@ func (p *StoreCredentialProvider) Inject(ctx context.Context, vaultID, targetHos
}
matched := broker.MatchHost(matchHost, services)
if matched == nil {
return nil, ErrServiceNotFound
// Fail closed on policy lookup errors so a transient store
// failure can't silently strip enforcement.
policy, err := p.Store.UnmatchedHostPolicy(ctx, vaultID)
if err != nil || policy == PolicyDeny {
return nil, ErrServiceNotFound
}
return &InjectResult{Passthrough: true}, nil
}
if !matched.IsEnabled() {
return nil, ErrServiceDisabled
Expand Down
89 changes: 81 additions & 8 deletions internal/brokercore/credential_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@ import (

// fakeCredStore satisfies CredentialStore for tests.
type fakeCredStore struct {
brokerCfg map[string]*store.BrokerConfig // vaultID → config
creds map[string]*store.Credential // key = vaultID+"|"+key
missKey string // if set, GetCredential for this key returns nil/err
brokerCfg map[string]*store.BrokerConfig // vaultID → config
creds map[string]*store.Credential // key = vaultID+"|"+key
missKey string // if set, GetCredential for this key returns nil/err
policy UnmatchedHostPolicy // unmatched-host policy returned by UnmatchedHostPolicy
brokerCfgErr error // if non-nil, GetBrokerConfig returns this error

getCredentialCalls int // call count — used by passthrough tests to assert no lookup
}
Expand All @@ -25,13 +27,17 @@ func newFakeCredStore() *fakeCredStore {
return &fakeCredStore{
brokerCfg: map[string]*store.BrokerConfig{},
creds: map[string]*store.Credential{},
policy: PolicyPassthrough,
}
}

func (f *fakeCredStore) GetBrokerConfig(_ context.Context, vaultID string) (*store.BrokerConfig, error) {
if f.brokerCfgErr != nil {
return nil, f.brokerCfgErr
}
c, ok := f.brokerCfg[vaultID]
if !ok {
return nil, errors.New("not found")
return nil, nil
}
return c, nil
}
Expand All @@ -47,6 +53,13 @@ func (f *fakeCredStore) GetCredential(_ context.Context, vaultID, key string) (*
return c, nil
}

func (f *fakeCredStore) UnmatchedHostPolicy(_ context.Context, _ string) (UnmatchedHostPolicy, error) {
if f.policy == "" {
return PolicyPassthrough, nil
}
return f.policy, nil
}

// make32 returns a deterministic 32-byte key for tests.
func make32(b byte) []byte {
k := make([]byte, 32)
Expand Down Expand Up @@ -199,18 +212,64 @@ func TestInject_WildcardMatch(t *testing.T) {
}
}

func TestInject_ServiceNotFound_NoConfig(t *testing.T) {
func TestInject_UnmatchedHost_DefaultPassthrough(t *testing.T) {
// With the default unmatched-host policy (passthrough), a host with
// no matching service forwards without injection.
f := newFakeCredStore()
p := NewStoreCredentialProvider(f, make32(0x77))
res, err := p.Inject(context.Background(), "v1", "api.example.com")
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if res == nil || !res.Passthrough {
t.Fatalf("expected passthrough result, got %+v", res)
}
if len(res.Headers) != 0 {
t.Fatalf("expected no injected headers, got %v", res.Headers)
}
if res.MatchedHost != "" {
t.Fatalf("expected empty MatchedHost, got %q", res.MatchedHost)
}
}

func TestInject_UnmatchedHost_HostMissPassthrough(t *testing.T) {
// Even with services configured, a host that matches none of them
// passes through under the default policy.
key32 := make32(0x88)
f := newFakeCredStore()
f.setServices(t, "v1", []broker.Service{{
Host: "api.example.com",
Auth: broker.Auth{Type: "bearer", Token: "T"},
}})
f.setCred(t, key32, "v1", "T", "x")

p := NewStoreCredentialProvider(f, key32)
res, err := p.Inject(context.Background(), "v1", "other.example.com")
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if res == nil || !res.Passthrough {
t.Fatalf("expected passthrough result, got %+v", res)
}
if f.getCredentialCalls != 0 {
t.Fatalf("passthrough should not resolve credentials, got %d calls", f.getCredentialCalls)
}
}

func TestInject_UnmatchedHost_DenyPolicy(t *testing.T) {
f := newFakeCredStore()
f.policy = PolicyDeny
p := NewStoreCredentialProvider(f, make32(0x77))
_, err := p.Inject(context.Background(), "v1", "api.example.com")
if !errors.Is(err, ErrServiceNotFound) {
t.Fatalf("expected ErrServiceNotFound, got %v", err)
t.Fatalf("expected ErrServiceNotFound under deny policy, got %v", err)
}
}

func TestInject_ServiceNotFound_HostMiss(t *testing.T) {
func TestInject_UnmatchedHost_HostMissDeny(t *testing.T) {
key32 := make32(0x88)
f := newFakeCredStore()
f.policy = PolicyDeny
f.setServices(t, "v1", []broker.Service{{
Host: "api.example.com",
Auth: broker.Auth{Type: "bearer", Token: "T"},
Expand All @@ -220,7 +279,21 @@ func TestInject_ServiceNotFound_HostMiss(t *testing.T) {
p := NewStoreCredentialProvider(f, key32)
_, err := p.Inject(context.Background(), "v1", "other.example.com")
if !errors.Is(err, ErrServiceNotFound) {
t.Fatalf("expected ErrServiceNotFound, got %v", err)
t.Fatalf("expected ErrServiceNotFound under deny policy, got %v", err)
}
}

// Regression: a non-ErrNoRows GetBrokerConfig error must fail closed
// (ErrServiceNotFound), not fall through to passthrough. Otherwise a
// transient store failure silently strips credential injection from a
// vault that has services configured.
func TestInject_GetBrokerConfigError_FailsClosed(t *testing.T) {
f := newFakeCredStore()
f.brokerCfgErr = errors.New("transient sqlite I/O error")
p := NewStoreCredentialProvider(f, make32(0xAB))
_, err := p.Inject(context.Background(), "v1", "api.example.com")
if !errors.Is(err, ErrServiceNotFound) {
t.Fatalf("expected ErrServiceNotFound on store error, got %v", err)
}
}

Expand Down
2 changes: 2 additions & 0 deletions internal/brokercore/logging.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ type ProxyEvent struct {
Status int // upstream status; 0 if never dispatched
TotalMs int64 // handler entry → emit, in milliseconds
Err string // short error code, or "" on success
Passthrough bool // see InjectResult.Passthrough
}

// Emit fills in the terminal fields (Status, Err, TotalMs measured from
Expand Down Expand Up @@ -66,6 +67,7 @@ func LogProxyEvent(logger *slog.Logger, e ProxyEvent) {
slog.Int("status", e.Status),
slog.Int64("total_ms", e.TotalMs),
slog.String("err", e.Err),
slog.Bool("passthrough", e.Passthrough),
)
}

Expand Down
1 change: 1 addition & 0 deletions internal/mitm/forward.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ func (p *Proxy) forwardHandler(target, host string, scope *brokercore.ProxyScope
if inject != nil {
event.MatchedService = inject.MatchedHost
event.CredentialKeys = inject.CredentialKeys
event.Passthrough = inject.Passthrough
}
if err != nil {
errCode := "no_match"
Expand Down
Loading