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
23 changes: 22 additions & 1 deletion docs/guides/secrets/index.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: "Managing Secrets"
description: "How to securely provide API keys and credentials to docker-agent using environment variables, env files, Docker Compose secrets, macOS Keychain, and pass."
description: "How to securely provide API keys and credentials to docker-agent using environment variables, env files, Docker Compose secrets, macOS Keychain, pass, and 1Password references."
permalink: /guides/secrets/
---

Expand All @@ -23,6 +23,8 @@ docker-agent needs API keys to talk to model providers (OpenAI, Anthropic, etc.)

The first provider that has a value wins. You can mix and match — for example, use environment variables for one key and Keychain for another.

Whatever provider returns the value, if that value looks like a [1Password secret reference](#1password-references) (it starts with `op://`), docker-agent resolves it through the `op` CLI before handing it to a model provider or tool.

When docker-agent runs inside a Docker sandbox (detected via `SANDBOX_VM_ID`), a sandbox token provider is prepended to the chain so that `DOCKER_TOKEN` is read from a continuously-refreshed file instead of a stale environment variable.

## Environment Variables
Expand Down Expand Up @@ -216,6 +218,24 @@ security delete-generic-password -s ANTHROPIC_API_KEY

Once stored, docker-agent finds the secret automatically — no flags or config needed.

## 1Password References

Any secret value resolved through the chain above can be a **1Password secret reference** instead of the literal secret. If the value starts with `op://`, docker-agent resolves it by invoking the [1Password CLI](https://developer.1password.com/docs/cli/) (`op read <reference>`) and uses the result.

This works with every provider — most commonly an environment variable or env file:

```bash
export OPENAI_API_KEY="op://Personal/OpenAI/api-key"
docker agent run agent.yaml
```

References follow the `op://<vault>/<item>/<field>` format. Make sure the `op` CLI is installed and you are signed in (`op signin`) so that non-interactive reads succeed.

<div class="callout callout-warning" markdown="1">
<div class="callout-title">Behaviour when resolution fails</div>
<p>If the value starts with <code>op://</code> but the <code>op</code> CLI is not installed, or the reference cannot be read (not signed in, wrong path, locked vault), docker-agent logs a warning and treats the variable as <strong>unset</strong> — it never forwards the raw <code>op://</code> reference to a model provider or tool.</p>
</div>

## Choosing a Method

| Method | Best for | Setup effort |
Expand All @@ -225,6 +245,7 @@ Once stored, docker-agent finds the secret automatically — no flags or config
| Docker Compose secrets | Containerized deployments, CI/CD | Medium |
| `pass` | Linux/macOS, GPG-based workflows | Medium |
| macOS Keychain | macOS local development | Low |
| 1Password references (`op://`) | Teams already using 1Password | Low |

You can combine methods. For example, store long-lived provider keys in macOS Keychain and pass project-specific MCP tokens via env files.

Expand Down
15 changes: 12 additions & 3 deletions pkg/environment/cmd_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"errors"
"log/slog"
"os/exec"
"path/filepath"
"strings"
)

Expand Down Expand Up @@ -34,10 +35,18 @@ func runCommand(ctx context.Context, logLabel, name string, args ...string) (str
// to avoid TOCTOU races and PATH hijacking.
func lookupBinary(name string, notFoundErr error) (string, error) {
path, err := exec.LookPath(name)
if err != nil && !errors.Is(err, exec.ErrNotFound) {
slog.Warn("failed to lookup `"+name+"` binary", "error", err)
if err != nil {
// exec.ErrDot means the binary was only found via an unsafe relative
// PATH entry (or "."). Treat it like "not found" so we never run an
// attacker-controlled binary from the working directory (CWE-426).
if !errors.Is(err, exec.ErrNotFound) && !errors.Is(err, exec.ErrDot) {
slog.Warn("failed to lookup `"+name+"` binary", "error", err)
}
return "", notFoundErr
}
if path == "" {
// Defensively require an absolute path so the resolved binary cannot be
// hijacked via PATH or the current working directory.
if !filepath.IsAbs(path) {
return "", notFoundErr
}
return path, nil
Expand Down
6 changes: 5 additions & 1 deletion pkg/environment/default.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (

// NewDefaultProvider creates a provider chain with OS env, run secrets,
// credential helper (if configured), Docker Desktop, pass, and keychain providers.
// The whole chain is wrapped so that values shaped like "op://..." are resolved
// as 1Password secret references through the `op` CLI.
//
// When running inside a Docker sandbox (detected via SANDBOX_VM_ID), a
// [SandboxTokenProvider] is prepended so that DOCKER_TOKEN is read from the
Expand Down Expand Up @@ -48,5 +50,7 @@ func NewDefaultProvider() Provider {
providers = append(providers, keychainProvider)
}

return NewMultiProvider(providers...)
// Resolve any "op://" secret references through the 1Password CLI,
// regardless of which provider returned the value.
return NewOnePasswordProvider(NewMultiProvider(providers...))
}
64 changes: 64 additions & 0 deletions pkg/environment/onepassword.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package environment

import (
"context"
"log/slog"
"strings"
)

// onePasswordPrefix marks an environment value as a 1Password secret reference
// (e.g. "op://vault/item/field") that must be resolved with the `op` CLI.
const onePasswordPrefix = "op://"

// OnePasswordProvider decorates another Provider and resolves 1Password secret
// references. When the wrapped provider returns a value starting with "op://",
// the value is treated as a secret reference and resolved using the `op read`
// CLI command. All other values are passed through unchanged.
type OnePasswordProvider struct {
provider Provider
// resolve turns a "op://..." reference into its secret value. It is a field
// so tests can inject a fake resolver without relying on the `op` binary.
resolve func(ctx context.Context, reference string) (string, bool)
}

type OnePasswordNotAvailableError struct{}

func (OnePasswordNotAvailableError) Error() string {
return "op (1Password CLI) is not installed"
}

// NewOnePasswordProvider wraps provider so that "op://" references are resolved
// with the `op` CLI. The `op` binary is looked up lazily, only when a reference
// is actually encountered, so an "op://" value is never silently passed through
// as if it were a real secret.
func NewOnePasswordProvider(provider Provider) Provider {
return &OnePasswordProvider{
provider: provider,
resolve: resolveOnePasswordReference,
}
}

func resolveOnePasswordReference(ctx context.Context, reference string) (string, bool) {
path, err := lookupBinary("op", OnePasswordNotAvailableError{})
if err != nil {
slog.WarnContext(ctx, "Cannot resolve 1Password secret reference: op (1Password CLI) is not installed")
return "", false
}

return runCommand(ctx, "1password", path, "read", reference)
}

func (p *OnePasswordProvider) Get(ctx context.Context, name string) (string, bool) {
value, found := p.provider.Get(ctx, name)
if !found || !strings.HasPrefix(value, onePasswordPrefix) {
return value, found
}

resolved, ok := p.resolve(ctx, value)
if !ok {
slog.WarnContext(ctx, "Failed to resolve 1Password secret reference", "name", name)
return "", false
}

return resolved, true
}
93 changes: 93 additions & 0 deletions pkg/environment/onepassword_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package environment

import (
"context"
"testing"

"github.com/stretchr/testify/assert"
)

func TestOnePasswordProvider_Get(t *testing.T) {
t.Parallel()

tests := []struct {
name string
stored map[string]string
resolve func(ctx context.Context, reference string) (string, bool)
lookup string
wantValue string
wantFound bool
wantRefSeen string
}{
{
name: "plain value is passed through",
stored: map[string]string{"API_KEY": "plain-secret"},
lookup: "API_KEY",
wantValue: "plain-secret",
wantFound: true,
},
{
name: "op reference is resolved",
stored: map[string]string{"API_KEY": "op://vault/item/field"},
lookup: "API_KEY",
wantValue: "resolved-secret",
wantFound: true,
wantRefSeen: "op://vault/item/field",
},
{
name: "missing variable is not resolved",
stored: map[string]string{},
lookup: "API_KEY",
wantFound: false,
},
{
name: "failed resolution reports not found",
stored: map[string]string{"API_KEY": "op://vault/item/field"},
resolve: func(context.Context, string) (string, bool) {
return "", false
},
lookup: "API_KEY",
wantFound: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

var refSeen string
resolve := tt.resolve
if resolve == nil {
resolve = func(_ context.Context, reference string) (string, bool) {
refSeen = reference
return "resolved-secret", true
}
}

provider := &OnePasswordProvider{
provider: NewMapEnvProvider(tt.stored),
resolve: resolve,
}

value, found := provider.Get(t.Context(), tt.lookup)
assert.Equal(t, tt.wantFound, found)
assert.Equal(t, tt.wantValue, value)
if tt.wantRefSeen != "" {
assert.Equal(t, tt.wantRefSeen, refSeen)
}
})
}
}

func TestNewOnePasswordProvider_AlwaysWraps(t *testing.T) {
t.Parallel()

// The constructor must always wrap so that "op://" references are never
// silently passed through as if they were real secrets, regardless of
// whether the `op` binary is installed on the host.
base := NewMapEnvProvider(map[string]string{"API_KEY": "plain"})
provider := NewOnePasswordProvider(base)

_, ok := provider.(*OnePasswordProvider)
assert.True(t, ok)
}
Loading