1Password + Docker Sandboxes: Keeping Secrets Out of the Box

Running an AI agent in a Docker Sandbox raises one question first: where do the API keys live. This hands-on guide wires the 1Password CLI into Docker Sandboxes so credentials resolve from your vault on demand and never sit in plaintext inside the container.

Share
1Password + Docker Sandboxes: Keeping Secrets Out of the Box

The Problem Statement

You want to run an AI coding agent inside a Docker Sandbox. The sandbox keeps the agent isolated. Good. But the agent still needs API keys to do its work.

So where do those keys go?

It's always recommended not to hardcode tokens into a script. You shouldn't drop them into a plaintext .env file inside the workspace. The agent can read those files. So can anything the agent installs. If your secrets sit in plaintext inside the box, the isolation you wanted is already gone.

This guide wires the 1Password CLI into Docker Sandboxes. Your keys stay in your vault. They get pulled out only when they are needed. They never sit in plaintext inside the container.

๐Ÿ’ก
One naming note: sbx is just short for docker sandbox. Both commands do the same thing. The examples use sbx.

What you need

  • sbx installed, up and running (use nightly build)
  • A 1Password account, with the desktop app installed and unlocked.
  • The agent CLI you want to run (claude, codex, copilot, and so on).
  • A terminal on macOS, Linux, or Windows.

How the sandbox handles secrets

Before you set anything up, understand the one piece that surprises people.

Docker Sandboxes run a credential proxy. In plain terms:

  • The real secret stays on your host machine.
  • The sandbox never gets the real token. It gets a fake stand-in called a sentinel.
  • When a tool inside the sandbox calls out to a known provider (like Anthropic or OpenAI), the proxy catches that request on its way out.
  • The proxy swaps the fake placeholder for the real key at that moment.
  • The key is added at the network boundary. It never has to live inside the container.

That is the whole trick. And there is one edge to remember.

The placeholder is only swapped for outbound calls to recognized providers. If code inside the box reads the environment variable directly, for a local job rather than an outbound call, it sees the fake placeholder, not your real key. So if a library needs the raw secret in hand (to sign something locally, or to reach a provider the proxy does not know), it gets the placeholder and fails. The error looks like a login failure. Keep this in mind when "the key is set but the call still fails."

Step 1: Install the 1Password CLI

The CLI is a tool called op.

  • macOS: brew install --cask 1password-cli
  • Linux (Debian/Ubuntu): add the 1Password apt repo, then sudo apt install 1password-cli
  • Windows: winget install AgileBits.1Password.CLI

Confirm it:

op --version

Step 2: Sign in and verify the session

This is the step the happy-path tutorials skip โ€” and the first place things break.

The cleanest setup is the desktop-app integration, so you unlock with the same fingerprint or face prompt you already use:

  1. Open and unlock the 1Password desktop app.
  2. Go to Settings > Developer.
  3. Turn on Integrate with 1Password CLI.
  4. Turn on biometric unlock if you want it.

If you don't use the desktop integration, sign in manually:

eval $(op signin)

Either way, verify the session before you go further:

op whoami

If this prints your account (URL, email, user ID), the session is live. If it says "You are not currently signed in", stop and fix that first, every op read will fail until op whoami works.

Two things that bite people here:

  • Multiple accounts. If you have both a personal and a work 1Password account on the machine, op can get ambiguous. Add --account <shorthand> (for example --account dockerteam) or export OP_ACCOUNT=<shorthand>.
  • The silent-pipe trap. When you pipe op read into another command and op read fails (not signed in, bad reference), it sends nothing down the pipe. The downstream command then shows its own confusing error โ€” for sbx, that's Enter secret: ERROR: input cannot be empty. Bash reports the last command's exit code, so the real failure hides. Always set pipefail so the pipeline fails loudly at the real cause:
set -o pipefail

Step 3: Find your real secret reference (don't assume it)

Every reference uses one format:

op://<vault>/<item>/<field>

Most tutorials hard-code op://Work/GitHub/token as if it's universal. It isn't. Work is 1Password's default personal vault name โ€” it usually doesn't exist on a business account. Build your reference from your values, verifying one segment at a time.

1. The vault. List what you actually have:

op vault list

On a business account you'll see names like Employee, Marketing, or Shared CI/CD deployment credentials โ€” not Work. Pick the one holding your key.

2. The item. Don't assume GitHub (or anything else):

op item list --vault Employee

This shows the real item titles. In this example the relevant one is an OpenAI API key.

3. The field. Check the field name โ€” it's often not token:

op item get "OpenAI API Key (docker work)" --vault Employee

Items created as 1Password's API Credential category (Category: API_CREDENTIAL) store the secret in a field literally named credential. That's why provider references end in /credential, not /token. Note that op item get shows [use 'โ€ฆ --reveal' to reveal] instead of the value โ€” inspecting an item is safe to screenshot; only --reveal or op read exposes the secret.

Don't hand-build references from display titles

Here's the trap that costs the most time. A title that's perfectly valid in the 1Password app can be an invalid secret reference. Spaces are tolerated, but parentheses are not:

op read "op://Employee/OpenAI API Key (docker work)/credential"
# ERROR: invalid character in secret reference: '('

So don't assemble op:// paths by hand from titles. Use one of these instead:

  • Rename the item to a clean handle (best for tutorials and scripts):
  op item edit "OpenAI API Key (docker work)" --vault Employee --title "OpenAI"

Your reference becomes the tidy op://Employee/OpenAI/credential.

  • Use the item's UUID (script-safe; it's a 26-character alphanumeric string with no special characters, and it survives renames):
  op://Employee/<item-uuid>/credential
  • Copy Secret Reference from the desktop app (right-click the field โ†’ Copy Secret Reference) โ€” 1Password builds a valid reference for you.

Verify the reference resolves โ€” without showing the value

Once the path is clean, confirm it works. But don't op read to a bare terminal โ€” that prints your key to the screen, your scrollback, and any screen-share. The goal was never to see the key; it was to confirm the reference resolves and then hand it straight to the consumer. Verify safely with op item get (which hides the value), and reserve op read for piping:

# safe: confirms the item/field exist, value stays hidden
op item get "OpenAI" --vault Employee

# the value goes straight into sbx โ€” never to your screen
op read "op://Employee/OpenAI/credential" | sbx secret set -g openai
If a secret ever does print to your terminal (or into a chat, a log, a screenshot), treat it as compromised and rotate it. Because the value lives in 1Password, rotation is painless: update the item, and the same op:// reference keeps working.

Three ways to inject secrets

There are three patterns. They differ in how long the secret sticks around.

Pattern 1: Persistent โ€” set it once, reuse everywhere

Use this when you want a key available to every sandbox you create from now on. Pass the value over stdin (the documented form), and always specify a scope (-g or a sandbox name):

set -o pipefail
op read "op://Employee/OpenAI/credential" | sbx secret set -g openai

op read pulls the value from your vault; sbx secret set -g stores it once for all future sandboxes. The -g means global.

Two things to know:

  • "Global" means future sandboxes. A sandbox that is already running won't pick up the new value. To update a running one, scope it by name: op read "op://Employee/OpenAI/credential" | sbx secret set <sandbox-name> openai.
  • Always specify the scope on the pipe. If you don't pass -g or a sandbox name, sbx falls back to an interactive prompt reading from the same stdin and can swallow your value.

Pattern 2: Ephemeral โ€” resolve fresh every launch

The strongest setup. The key is never stored. It's pulled fresh each time you start the sandbox:

OPENAI_API_KEY="op://Employee/OpenAI/credential" op run -- sbx run codex
ANTHROPIC_API_KEY="op://Employee/Anthropic/credential" op run -- sbx run claude

op run resolves the reference, runs your command with the value present, then clears it on exit.

One catch: the sandbox only forwards variables it recognizes โ€” the built-in service variables like ANTHROPIC_API_KEY and OPENAI_API_KEY. It will not pass an arbitrary variable it doesn't know. So MY_CUSTOM_TOKEN="op://โ€ฆ" resolves on your host and then goes nowhere. (Handling truly custom variables is the experimental section at the end.)

The payoff: the key is never written to the sandbox secret store and never appears inside the box as a real value.

Pattern 3: Many providers โ€” use an env file

When an agent needs several keys at once, inline gets messy. Put them in a file that holds only references โ€” never the secrets:

# .sbx-secrets.env
ANTHROPIC_API_KEY=op://Employee/Anthropic/credential
OPENAI_API_KEY=op://Employee/OpenAI/credential

Launch with the file:

op run --env-file=.sbx-secrets.env -- sbx run claude

op run reads the file, resolves every reference, and passes the values as temporary variables. The file is safe to keep because it holds pointers, not secrets. Still, add it to .gitignore so nobody mistakes it for a template to fill with real keys.

One mapping to keep straight: Pattern 1 stores secrets by service name (openai), while op run and the env file use environment-variable names (OPENAI_API_KEY). If you mix them, confirm which name your tool reads inside the container.

Prove it: the secret never enters the box

This is the demonstration the whole exercise exists for. Store the key (Pattern 1), start a shell sandbox, and read the variable from inside:

sbx run --name op-test shell -d
sbx exec op-test -- bash -lc 'echo "OPENAI_API_KEY=$OPENAI_API_KEY"'

You should see the sentinel โ€” OPENAI_API_KEY=proxy-managed โ€” not your real key. The agent inside never holds the credential; the proxy injects the real value only on the outbound call to the provider. That single line is the proof.

Clean up when you're done:

sbx rm op-test

Troubleshooting: the errors you'll actually hit

These are the real failures from setting this up, each isolating one segment of the path.

ErrorWhat it meansFix
You are not currently signed inNo live op sessioneval $(op signin) or enable desktop integration; verify with op whoami
Enter secret: input cannot be emptyop read failed upstream and piped nothingset -o pipefail; fix the real cause (usually auth)
"โ€ฆ" isn't an item in the "Work" vaultThe Work vault doesn't exist on your accountop vault list; use your real vault name
"GitHub" isn't an item in the โ€ฆ vaultWrong item nameop item list --vault <vault>
invalid character in secret reference: '('You hand-built a reference from a title with parenthesesRename the item, use the UUID, or Copy Secret Reference
Wrong field (/token fails)API Credential items use the credential fieldop item get <item> to see field names

(Experimental) Custom variables the proxy doesn't know

The steps in this section are not yet validated in the walkthrough above and require a nightly sbx build. Confirm the exact flag with sbx secret set-custom --help before relying on it; set-custom is experimental and its syntax may change.

The patterns above cover built-in services. If your app needs a credential for a service the proxy doesn't recognize โ€” an internal gateway, Slack, a non-built-in provider โ€” there are two cases:

  • The variable authenticates an outbound HTTPS call to a host you can name. Register a custom placeholder mapped to that host. On recent builds the host-side value can be backed by an op:// reference, so the secret still never enters the box. Conceptually:
  # syntax is build-dependent โ€” check: sbx secret set-custom --help
  sbx secret set-custom -g \
    --host api.example.com \
    --env MY_APP_TOKEN \
    --placeholder proxy-managed \
    --value "op://Employee/MyApp/credential"

Inside the box, MY_APP_TOKEN is the sentinel; the proxy swaps in the real value on outbound calls to api.example.com.

  • The app reads the literal value in-process (a database URL, a signing key, anything non-HTTP). The proxy can't help here by design โ€” there is no outbound request to intercept. Your options are to write the value into the sandbox (for example via /etc/sandbox-persistent.sh) or run op inside the sandbox, and accept that the value then lives in the VM.

So before reaching for set-custom, ask: what does the app actually do with the variable? That answer decides whether the secret can stay out of the box.

Secrets are only half of trust

Keeping keys out of the box solves one problem. It does not control what the agent can do to the files you mounted.

By default, the agent trusts its mounted workspace. It can write to any file, including hidden ones. Some of those changes won't show up in a normal review. For example, changes to .git/hooks/ don't appear in a regular git diff, but a hook runs the next time you commit on your host.

So review before you act:

git status
git diff
ls -la .git/hooks/

That last command catches what the first two miss.

The bottom line

The three patterns map to how long you want a secret to exist:

  • op read | sbx secret set -g โ€” resolve once, reuse across every future sandbox.
  • op run with inline references โ€” resolve fresh each launch, never stored.
  • op run --env-file โ€” several providers at once.

In all three, the sandbox proxy does the final injection. Your vault items reach the provider without ever sitting in plaintext inside the box.

And two habits that the debugging above earns the hard way: don't hand-build op:// references from display titles (rename to clean handles or copy the reference), and never op read a secret to a bare terminal โ€” verify with op item get, then pipe straight into the consumer.

Set it up once. After that, "where do my keys live?" has one answer: in your vault, pulled on demand, never in the box.