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 @@ -21,7 +21,7 @@ Agent Vault takes a different approach: **Agent Vault never reveals vault-stored

- **Brokered access, not retrieval** - Your agent gets a token and a proxy URL. It sends requests to `proxy/{host}/{path}` and Agent Vault authenticates them. Credentials stored in the vault are never returned to the agent. [Learn more](https://docs.agent-vault.dev/learn/security)
- **Works with any agent** - Custom Python/TypeScript agents, sandboxed processes, coding agents (Claude Code, Cursor, Codex), anything that can make HTTP requests. [Learn more](https://docs.agent-vault.dev/quickstart)
- **Self-service access** - Agents discover available services at runtime and [propose access](https://docs.agent-vault.dev/learn/proposals) for anything missing. You review and approve in your browser with one click.
- **Self-service access** - Agents discover available services at runtime and [propose access](https://docs.agent-vault.dev/learn/proposals) for anything missing. You review and approve in your browser with one click. Any service can be toggled on/off without losing its configuration — disabled services return `403 service_disabled` until re-enabled.
- **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 — change the password without re-encrypting credentials. Passwordless mode available for PaaS deploys. [Learn more](https://docs.agent-vault.dev/learn/credentials)
- **Multi-user, multi-vault** - Role-based access control with instance and vault-level [permissions](https://docs.agent-vault.dev/learn/permissions). Invite teammates, scope agents to specific [vaults](https://docs.agent-vault.dev/learn/vaults), and audit everything.

Expand Down
2 changes: 1 addition & 1 deletion cmd/cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ func TestServiceSubcommandsRegistered(t *testing.T) {
registered[c.Name()] = true
}

expected := []string{"list", "set", "add", "remove", "clear"}
expected := []string{"list", "set", "add", "enable", "disable", "remove", "clear"}
for _, name := range expected {
if !registered[name] {
t.Errorf("expected service subcommand %q to be registered, but it was not", name)
Expand Down
64 changes: 64 additions & 0 deletions cmd/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,10 @@ File mode (upsert, not replace-all):
if desc, _ := cmd.Flags().GetString("description"); desc != "" {
svc.Description = &desc
}
if disabled, _ := cmd.Flags().GetBool("disabled"); disabled {
f := false
svc.Enabled = &f
}
services = []broker.Service{svc}
}

Expand Down Expand Up @@ -273,6 +277,63 @@ var serviceRemoveCmd = &cobra.Command{
},
}

var serviceEnableCmd = &cobra.Command{
Use: "enable <host>",
Short: "Enable a service so proxy traffic to the host resumes",
Long: `Re-enable a previously disabled service. Idempotent — no error if already enabled.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return patchServiceEnabled(cmd, args[0], true)
},
}

var serviceDisableCmd = &cobra.Command{
Use: "disable <host>",
Short: "Disable a service so proxy requests to the host return 403",
Long: `Disable a service while preserving its configuration. Agents proxying
to the host receive 403 with error code "service_disabled" until the
service is re-enabled. Idempotent.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return patchServiceEnabled(cmd, args[0], false)
},
}

func patchServiceEnabled(cmd *cobra.Command, host string, enabled bool) error {
vault := resolveVault(cmd)

sess, err := ensureSession()
if err != nil {
return err
}

body, err := json.Marshal(map[string]bool{"enabled": enabled})
if err != nil {
return err
}

reqURL := fmt.Sprintf("%s/v1/vaults/%s/services/%s", sess.Address, vault, url.PathEscape(host))
respBody, err := doAdminRequestWithBody("PATCH", reqURL, sess.Token, body)
if err != nil {
return err
}

var resp struct {
Host string `json:"host"`
Enabled bool `json:"enabled"`
}
if err := json.Unmarshal(respBody, &resp); err != nil {
return fmt.Errorf("parsing response: %w", err)
}

verb := "disabled"
if resp.Enabled {
verb = "enabled"
}
fmt.Fprintf(cmd.OutOrStdout(), "%s Service %s: %s\n", successText("✓"), verb, resp.Host)
return nil
}

// loadServicesFromFile reads and validates a broker config from a YAML file path ("-" for stdin).
func loadServicesFromFile(filePath, vault string) ([]broker.Service, error) {
var data []byte
Expand Down Expand Up @@ -324,13 +385,16 @@ func init() {
serviceAddCmd.Flags().String("api-key-key", "", "Credential key for api-key auth")
serviceAddCmd.Flags().String("api-key-header", "", "Header name for api-key (default Authorization)")
serviceAddCmd.Flags().String("api-key-prefix", "", "Prefix for api-key value")
serviceAddCmd.Flags().Bool("disabled", false, "Create the service in a disabled state (proxy traffic returns 403 until enabled)")

// service remove flags
serviceRemoveCmd.Flags().Bool("yes", false, "Skip confirmation prompt")

serviceCmd.AddCommand(serviceListCmd)
serviceCmd.AddCommand(serviceSetCmd)
serviceCmd.AddCommand(serviceAddCmd)
serviceCmd.AddCommand(serviceEnableCmd)
serviceCmd.AddCommand(serviceDisableCmd)
serviceCmd.AddCommand(serviceRemoveCmd)
serviceCmd.AddCommand(serviceClearCmd)
vaultCmd.AddCommand(serviceCmd)
Expand Down
6 changes: 4 additions & 2 deletions cmd/skill_cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,8 +127,9 @@ Flag-driven auth flags by type:
Other flags: `--description` (service description), `--user-message` (shown on browser approval page), `--credential KEY=description` (repeatable).

Key fields (JSON mode):
- `services[].action` -- `"set"` (upsert, needs `host` + `auth`) or `"delete"` (needs `host` only)
- `services[].action` -- `"set"` (upsert, needs `host` + `auth` **or** an `enabled` change) or `"delete"` (needs `host` only)
- `services[].auth` -- authentication config. Types: `bearer` (`token`), `basic` (`username`, optional `password`), `api-key` (`key` + `header`, optional `prefix`), `custom` (`headers` map with `{{ KEY }}` templates), `passthrough` (no credential fields)
- `services[].enabled` -- optional boolean. Omitted means "enabled" for new services. A `"set"` proposal may supply `enabled` alone (no `auth`) to toggle an existing service's state without replacing its auth config -- useful for staged rollouts
- `credentials[].action` -- `"set"` (omit `value` for human to supply; include `value` to store back) or `"delete"`
- `credentials` -- only declare credentials not already in `available_credentials`. Every credential referenced in auth configs must resolve to a slot or existing credential (400 otherwise)
- `message` -- developer-facing explanation; `user_message` -- shown on the browser approval page
Expand Down Expand Up @@ -189,7 +190,8 @@ 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: Host not allowed -- create a proposal
- 403 `forbidden`: Host not allowed -- 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: Too many pending proposals -- wait for review
- 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 @@ -117,8 +117,9 @@ Content-Type: application/json
```

Key fields:
- `services[].action` -- `"set"` (upsert, needs `host` + `auth`) or `"delete"` (needs `host` only)
- `services[].action` -- `"set"` (upsert, needs `host` + `auth` **or** an `enabled` change) or `"delete"` (needs `host` only)
- `services[].auth` -- authentication config. Types: `bearer` (`token`), `basic` (`username`, optional `password`), `api-key` (`key` + `header`, optional `prefix`), `custom` (`headers` map with `{{ KEY }}` templates), `passthrough` (no credential fields)
- `services[].enabled` -- optional boolean. Omitted means "enabled" for new services. A `"set"` proposal may supply `enabled` alone (no `auth`) to flip an existing service's state without replacing its auth config -- useful for staged rollouts where the operator wires credentials before flipping traffic on
- `credentials[].action` -- `"set"` (omit `value` for human to supply; include `value` to store back) or `"delete"`
- `credentials` -- only declare credentials not already in `available_credentials`. Every credential referenced in auth configs must resolve to a slot or existing credential (400 otherwise)
- `message` -- developer-facing explanation; `user_message` -- shown on the browser approval page
Expand Down Expand Up @@ -178,7 +179,8 @@ Content-Type: application/json
## Error Handling

- 401: Invalid or expired token -- check `AGENT_VAULT_SESSION_TOKEN`
- 403: Host not allowed -- create a proposal
- 403 `forbidden`: Host not allowed -- 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: Too many pending proposals -- wait for review
- 502: Missing credential or upstream unreachable, tell user a credential may need to be added

Expand Down
27 changes: 27 additions & 0 deletions docs/reference/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -422,8 +422,35 @@ description: "Complete reference for all Agent Vault CLI commands."
| `--api-key-key` | | Credential key for api-key auth |
| `--api-key-header` | `Authorization` | Header name for api-key |
| `--api-key-prefix` | | Prefix for api-key value |
| `--disabled` | `false` | Create the service in a disabled state (proxy traffic returns 403 until enabled) |

The `passthrough` auth type accepts no credential flags; Agent Vault allowlists the host and forwards the client's request headers unchanged, stripping only hop-by-hop and broker-scoped headers (`X-Vault`, `Proxy-Authorization`).

New services are **enabled by default**. Pass `--disabled` to create the service in a disabled state, or use `agent-vault vault service disable <host>` after creation.
</Accordion>

<Accordion title="agent-vault vault service enable">
```bash
agent-vault vault service enable <host> [flags]
```

Enable a service so proxy traffic to the host resumes. Idempotent — no error if the service is already enabled.

| Flag | Default | Description |
|------|---------|-------------|
| `--vault` | `default` | Target vault |
</Accordion>

<Accordion title="agent-vault vault service disable">
```bash
agent-vault vault service disable <host> [flags]
```

Disable a service while preserving its configuration. Agents proxying to the host receive `403` with error code `service_disabled` until the service is re-enabled. Idempotent.

| Flag | Default | Description |
|------|---------|-------------|
| `--vault` | `default` | Target vault |
</Accordion>

<Accordion title="agent-vault vault service remove">
Expand Down
13 changes: 13 additions & 0 deletions internal/broker/broker.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,25 @@ type Config struct {
}

// Service defines a host-matching service with credential attachment.
//
// Enabled is a nullable toggle. nil means "not set" and is treated as
// enabled so existing persisted services (which predate this field) stay
// live after upgrade. Callers should use IsEnabled() rather than
// dereferencing the pointer.
type Service struct {
Host string `yaml:"host" json:"host"`
Description *string `yaml:"description,omitempty" json:"description"`
Enabled *bool `yaml:"enabled,omitempty" json:"enabled,omitempty"`
Auth Auth `yaml:"auth" json:"auth"`
}

// IsEnabled reports whether the service should serve proxy traffic. A
// nil Enabled field (missing from the stored JSON) is treated as enabled
// so services persisted before this field existed stay live after upgrade.
func (s *Service) IsEnabled() bool {
return s.Enabled == nil || *s.Enabled
}

// Auth describes how credentials are attached for a broker service.
// Each service must specify a Type and the fields relevant to that type.
//
Expand Down
3 changes: 3 additions & 0 deletions internal/brokercore/brokercore.go
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,9 @@ func WriteInjectError(w http.ResponseWriter, err error, targetHost, vaultName, b
switch {
case errors.Is(err, ErrServiceNotFound):
WriteForbiddenHint(w, targetHost, vaultName, baseURL)
case errors.Is(err, ErrServiceDisabled):
writeProxyErrorWithHelp(w, http.StatusForbidden, "service_disabled",
fmt.Sprintf("Broker service matching host %q in vault %q is currently disabled", targetHost, vaultName), baseURL)
case errors.Is(err, ErrCredentialMissing):
writeProxyErrorWithHelp(w, http.StatusBadGateway, "credential_not_found",
"A required credential could not be resolved; check vault configuration", baseURL)
Expand Down
3 changes: 3 additions & 0 deletions internal/brokercore/credential.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ func (p *StoreCredentialProvider) Inject(ctx context.Context, vaultID, targetHos
if matched == nil {
return nil, ErrServiceNotFound
}
if !matched.IsEnabled() {
return nil, ErrServiceDisabled
}

// Passthrough services opt out of credential injection entirely. No
// vault read, no CredentialKeys (nothing to resolve). The ingress
Expand Down
57 changes: 57 additions & 0 deletions internal/brokercore/credential_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,63 @@ func TestInject_Passthrough(t *testing.T) {
}
}

func TestInject_ServiceDisabled(t *testing.T) {
key32 := make32(0xEE)
disabled := false
f := newFakeCredStore()
f.setServices(t, "v1", []broker.Service{{
Host: "api.example.com",
Enabled: &disabled,
Auth: broker.Auth{Type: "bearer", Token: "TOK"},
}})
f.setCred(t, key32, "v1", "TOK", "x")

p := NewStoreCredentialProvider(f, key32)
_, err := p.Inject(context.Background(), "v1", "api.example.com")
if !errors.Is(err, ErrServiceDisabled) {
t.Fatalf("expected ErrServiceDisabled, got %v", err)
}
if f.getCredentialCalls != 0 {
t.Fatalf("expected no credential lookup when disabled, got %d calls", f.getCredentialCalls)
}
}

func TestInject_ServiceDisabled_Passthrough(t *testing.T) {
disabled := false
f := newFakeCredStore()
f.setServices(t, "v1", []broker.Service{{
Host: "api.example.com",
Enabled: &disabled,
Auth: broker.Auth{Type: "passthrough"},
}})
p := NewStoreCredentialProvider(f, make32(0xEF))
_, err := p.Inject(context.Background(), "v1", "api.example.com")
if !errors.Is(err, ErrServiceDisabled) {
t.Fatalf("expected ErrServiceDisabled for disabled passthrough, got %v", err)
}
}

func TestInject_EnabledExplicitTrue(t *testing.T) {
key32 := make32(0xF0)
enabled := true
f := newFakeCredStore()
f.setServices(t, "v1", []broker.Service{{
Host: "api.example.com",
Enabled: &enabled,
Auth: broker.Auth{Type: "bearer", Token: "TOK"},
}})
f.setCred(t, key32, "v1", "TOK", "v")

p := NewStoreCredentialProvider(f, key32)
res, err := p.Inject(context.Background(), "v1", "api.example.com")
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if res.Headers["Authorization"] != "Bearer v" {
t.Fatalf("got %q", res.Headers["Authorization"])
}
}

func TestInject_PassthroughPortStripped(t *testing.T) {
f := newFakeCredStore()
f.setServices(t, "v1", []broker.Service{{
Expand Down
6 changes: 6 additions & 0 deletions internal/brokercore/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,10 @@ var (
// service's auth config is not set or could not be decrypted. Callers
// surface 502 so agents retry only after the credential is provisioned.
ErrCredentialMissing = errors.New("brokercore: referenced credential missing or undecryptable")

// ErrServiceDisabled means a configured broker service matched the
// target host but has been toggled off by an operator. Distinct from
// ErrServiceNotFound so agents can tell "configured but off" from "not
// configured". Callers surface 403 with error code "service_disabled".
ErrServiceDisabled = errors.New("brokercore: broker service is disabled")
)
18 changes: 11 additions & 7 deletions internal/proposal/merge.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,17 @@ func MergeServices(existing []broker.Service, proposed []Service) ([]broker.Serv
delete(hostIndex, p.Host)

default: // ActionSet: upsert
svc := toBrokerService(p)
if idx, exists := hostIndex[p.Host]; exists {
// Replace existing service in place.
merged[idx] = svc
} else {
// Append new service.
idx, exists := hostIndex[p.Host]
switch {
case exists && p.Auth == nil && p.Enabled != nil:
// Enable/disable-only change on an existing service:
// preserve Auth and Description, overlay just the flag.
merged[idx].Enabled = p.Enabled
case exists:
merged[idx] = toBrokerService(p)
default:
hostIndex[p.Host] = len(merged)
merged = append(merged, svc)
merged = append(merged, toBrokerService(p))
}
}
}
Expand Down Expand Up @@ -70,6 +73,7 @@ func toBrokerService(p Service) broker.Service {
svc := broker.Service{
Host: p.Host,
Description: desc,
Enabled: p.Enabled,
}
if p.Auth != nil {
svc.Auth = *p.Auth
Expand Down
24 changes: 24 additions & 0 deletions internal/proposal/merge_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,30 @@ func TestMergeServicesSetAppend(t *testing.T) {
}
}

func TestMergeServicesSetEnabledOnlyPreservesAuth(t *testing.T) {
disabled := false
existing := []broker.Service{
{Host: "api.stripe.com", Auth: broker.Auth{Type: "bearer", Token: "OLD"}},
}
proposed := []Service{
{Action: ActionSet, Host: "api.stripe.com", Enabled: &disabled},
}

merged, warnings := MergeServices(existing, proposed)
if len(warnings) != 0 {
t.Fatalf("expected no warnings, got %v", warnings)
}
if len(merged) != 1 {
t.Fatalf("expected 1 service, got %d", len(merged))
}
if merged[0].Auth.Token != "OLD" {
t.Fatalf("expected Auth preserved (token OLD), got %q", merged[0].Auth.Token)
}
if merged[0].Enabled == nil || *merged[0].Enabled != false {
t.Fatalf("expected Enabled=false, got %v", merged[0].Enabled)
}
}

func TestMergeServicesSetReplacesExisting(t *testing.T) {
existing := []broker.Service{
{Host: "api.stripe.com", Auth: broker.Auth{Type: "bearer", Token: "OLD"}},
Expand Down
6 changes: 6 additions & 0 deletions internal/proposal/proposal.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,16 @@ const (
)

// Service is a proposed broker service change.
//
// For "set" actions, at least one of Auth or Enabled must be specified.
// When Enabled is provided without Auth and the host already exists,
// the merge preserves the existing service's Auth/Description and
// overlays only the Enabled flag — this is the enable/disable flow.
type Service struct {
Action Action `json:"action"`
Host string `json:"host"`
Description string `json:"description,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
Auth *broker.Auth `json:"auth,omitempty"`
}

Expand Down
Loading