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.
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.
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 --versionStep 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:
- Open and unlock the 1Password desktop app.
- Go to Settings > Developer.
- Turn on Integrate with 1Password CLI.
- 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 whoamiIf 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,
opcan get ambiguous. Add--account <shorthand>(for example--account dockerteam) orexport OP_ACCOUNT=<shorthand>. - The silent-pipe trap. When you pipe
op readinto another command andop readfails (not signed in, bad reference), it sends nothing down the pipe. The downstream command then shows its own confusing error โ forsbx, that'sEnter secret: ERROR: input cannot be empty. Bash reports the last command's exit code, so the real failure hides. Always setpipefailso the pipeline fails loudly at the real cause:
set -o pipefailStep 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 listOn 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 EmployeeThis 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 EmployeeItems 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 openaiIf 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 openaiop 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
-gor a sandbox name,sbxfalls 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 claudeop 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/credentialLaunch with the file:
op run --env-file=.sbx-secrets.env -- sbx run claudeop 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-testTroubleshooting: the errors you'll actually hit
These are the real failures from setting this up, each isolating one segment of the path.
| Error | What it means | Fix |
|---|---|---|
You are not currently signed in | No live op session | eval $(op signin) or enable desktop integration; verify with op whoami |
Enter secret: input cannot be empty | op read failed upstream and piped nothing | set -o pipefail; fix the real cause (usually auth) |
"โฆ" isn't an item in the "Work" vault | The Work vault doesn't exist on your account | op vault list; use your real vault name |
"GitHub" isn't an item in the โฆ vault | Wrong item name | op item list --vault <vault> |
invalid character in secret reference: '(' | You hand-built a reference from a title with parentheses | Rename the item, use the UUID, or Copy Secret Reference |
Wrong field (/token fails) | API Credential items use the credential field | op 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 nightlysbxbuild. Confirm the exact flag withsbx secret set-custom --helpbefore relying on it;set-customis 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 runopinside 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 runwith 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.