Credential Management — Agents in Containers
Key files: ludus/containers/scripts/gh-wrapper.sh, ~/.config/b4arena/secrets/hosts/*.sops.yml
Commands: ludus ops provision, just vault-check
Last verified: 2026-04-01
1. Problem Statement
Agents run inside sandbox containers on the ludus host and need credentials to perform work:
- Push code to GitHub repositories
- Call the Anthropic API (Claude Code)
- Access web services (future: Telegram, Slack, external APIs)
Credentials must be available inside the container, not on the host. They must not be committed to git. They should follow least-privilege: an agent gets only what it needs.
2. Constraints
| Constraint | Impact |
|---|---|
OpenClaw sanitizer strips _TOKEN, _KEY, _SECRET from docker.env | Cannot inject sensitive credentials via env var config |
| Rootless Podman (no daemon) | No privileged volume plugins; bind-mounts are the tool |
| Agents are fully automated | No interactive unlock (rules out GPG agent, gopass interactive flow) |
| SOPS+age is the infra standard | Encryption at rest already solved |
| Containers reuse across sessions | Secrets must survive container reuse; rotation must not require container restart |
3. Architecture: Structured Vault Bind-Mount
The existing gh-wrapper.sh pattern is correct and general. It extends into a first-class architecture:
- Host vault directory — secrets materialized from SOPS on the host, organized by service
- Per-agent mount declarations — each agent declares which vault paths it needs
- Standardized in-container paths — agents read from
/run/secrets/ - Source-secrets convention — env files are sourced at tool invocation time, not at container start
Host (ludus host): Container:
/etc/openclaw/vault/
├── github/
│ ├── app.env ──────────► /run/secrets/github/app.env
│ └── private.pem ──────────► /run/secrets/github/private.pem
├── anthropic/
│ └── api.env ──────────► /run/secrets/anthropic/api.env
└── per-agent/
├── dev/
│ └── extra.env ──────────► /run/secrets/agent/extra.env (dev only)
└── main/
└── extra.env ──────────► /run/secrets/agent/extra.env (main only)
All mounts are read-only inside the container. Directory permissions on host: chmod 700, owned by openclaw.
Backward Compatibility
The existing /etc/openclaw/env bind-mount is kept as-is for the gh-wrapper.sh pattern. It remains the canonical source for GitHub App credentials. The new vault structure lives alongside it — /etc/openclaw/vault/ is additive.
4. Host-Side Setup
4a. Vault directory structure
/etc/openclaw/vault/
├── github/
│ ├── app.env # GH_APP_ID, GH_APP_INSTALLATION_ID, GH_APP_INSTALLATION_ID_MAP
│ └── private.pem # GitHub App RSA private key
├── anthropic/
│ └── api.env # ANTHROPIC_API_KEY (used by Claude Code inside container)
└── per-agent/
├── dev/
└── main/
app.env format (shell-sourceable):
GH_APP_ID=12345
GH_APP_INSTALLATION_ID=67890
GH_APP_INSTALLATION_ID_MAP='{"b4arena":"67890","other-org":"11111"}'
GH_APP_PRIVATE_KEY_PATH=/run/secrets/github/private.pem
api.env format:
ANTHROPIC_API_KEY=sk-ant-...
4b. Encryption at rest
Secrets are stored SOPS-encrypted in ~/.config/b4arena/secrets/ and decrypted to the vault directory during deployment. The vault directory is never committed to git. It is a runtime artifact materialized by ludus ops provision on the host.
~/.config/b4arena/secrets/hosts/<hostname>.sops.yml
→ ludus ops provision decrypts
→ /etc/openclaw/vault/github/app.env (chmod 600, owner openclaw)
→ /etc/openclaw/vault/github/private.pem
→ /etc/openclaw/vault/anthropic/api.env
4c. Provisioning
Vault directories and credentials are materialized by ludus ops provision (the github_credentials and env_file steps). Run:
ludus ops provision --host <hostname>
# or, to re-run only credential-related steps:
ludus ops provision --host <hostname> --continue-from github_credentials
5. Container-Side Access Patterns
5a. Mount declarations
The following is a proposed example of how these mounts can be declared in the ludus sandbox-configure justfile recipe (and mirrored in ludus/cli.py). Adapt paths, agent IDs, and bind lists to match your environment:
# justfile sandbox-configure — vault bind-mounts (all agents, read-only + SELinux relabel)
VAULT_PATH="/etc/openclaw/vault"
ssh {{ MIMAS_OC }} "jq \
--arg gh '${VAULT_PATH}/github:/run/secrets/github:ro,z' \
--arg ant '${VAULT_PATH}/anthropic:/run/secrets/anthropic:ro,z' \
'.agents.defaults.sandbox.docker.binds += [$gh, $ant]' \
${CONFIG_FILE} > ${CONFIG_FILE}.tmp && mv ${CONFIG_FILE}.tmp ${CONFIG_FILE}"
# Per-agent vault mounts (added alongside per-agent resource overrides)
ssh {{ MIMAS_OC }} "jq \
--arg v '${VAULT_PATH}/per-agent/dev:/run/secrets/agent:ro,z' \
'(.agents.list[] | select(.id == \"b4-dev\")).sandbox.docker.binds += [$v]' \
${CONFIG_FILE} > ${CONFIG_FILE}.tmp && mv ${CONFIG_FILE}.tmp ${CONFIG_FILE}"
ssh {{ MIMAS_OC }} "jq \
--arg v '${VAULT_PATH}/per-agent/main:/run/secrets/agent:ro,z' \
'(.agents.list[] | select(.id == \"main\")).sandbox.docker.binds += [$v]' \
${CONFIG_FILE} > ${CONFIG_FILE}.tmp && mv ${CONFIG_FILE}.tmp ${CONFIG_FILE}"
The :ro,z flags mean read-only + Podman SELinux shared relabeling. No chcon call is needed — Podman applies container_file_t:s0 at mount time on every container start, making the label self-healing.
5b. How agents read credentials
Option A: Source at invocation (preferred)
Tools like gh-wrapper.sh source the relevant env file when called:
# gh-wrapper.sh (already does this for /etc/openclaw/env)
[ -f /run/secrets/github/app.env ] && { set -a; source /run/secrets/github/app.env; set +a; }
This is lazy — credentials are only loaded when the tool is invoked, not at container start. Avoids polluting the global environment.
Option B: CLAUDE.md environment setup
For Claude Code agents, CLAUDE.md can instruct the agent to source secrets before API calls:
## Credentials
Source `/run/secrets/anthropic/api.env` before calling the Anthropic API.
Option C: Container entrypoint sourcing
If an agent needs credentials globally (e.g., for ANTHROPIC_API_KEY in every shell), the container entrypoint can source them:
# /usr/local/bin/agent-entrypoint.sh
if [ -d /run/secrets ]; then
for f in /run/secrets/*/*.env; do
[ -f "$f" ] && { set -a; source "$f"; set +a; }
done
fi
exec "$@"
Recommendation: Use Option A (lazy source) for tool wrappers, Option C for well-known global env vars like ANTHROPIC_API_KEY.
5c. Standard paths inside containers
| Path | Contents | Used by |
|---|---|---|
/run/secrets/github/app.env | GitHub App env vars | gh-wrapper.sh |
/run/secrets/github/private.pem | GitHub App RSA key | gh-wrapper.sh via GH_APP_PRIVATE_KEY_PATH |
/run/secrets/anthropic/api.env | ANTHROPIC_API_KEY | Claude Code |
/run/secrets/agent/extra.env | Per-agent extras | Agent-specific tools |
/run/ is a tmpfs on Linux — secrets never touch the container's writable layer.
6. Adding a New Credential
When an agent needs a new external service credential:
- Add to SOPS vault — add the variable to
~/.config/b4arena/secrets/hosts/<hostname>.sops.yml - Update provisioning step — add materialization logic to the relevant step in
src/ludus_cli/provision/steps/ - Declare the mount — add the bind-mount to the relevant agent in
ludus/cli.pysandbox config - Write a wrapper or document the path — update the agent's
TOOLS.mdwith the secret path - Run deployment —
ludus ops provision --host <hostname>to materialize the new credential
7. Pre-flight Check
Before starting agents, verify all expected secrets are materialized on the host:
just vault-check
This SSHes to the ludus host and checks each expected vault directory and file for:
- Existence — directory or file is present
- Ownership — owned by
openclaw - Permissions — directories
700, files600 - Non-empty — files contain data (catches partial Ansible runs)
Example output:
=== Vault check on <host> ===
✓ /etc/openclaw/vault (openclaw:700)
✓ /etc/openclaw/vault/github (openclaw:700)
✓ /etc/openclaw/vault/github/app.env (openclaw:600, 142B)
✓ /etc/openclaw/vault/github/private.pem (openclaw:600, 1679B)
✓ /etc/openclaw/vault/anthropic (openclaw:700)
✓ /etc/openclaw/vault/anthropic/api.env (openclaw:600, 56B)
...
=== All vault secrets present ===
On failure it exits non-zero and prints the fix: ludus ops provision --continue-from openclaw_config. Suitable as a gate in just deploy or CI.
8. Credential Rotation
Rotation does not require container restart. Since secrets are bind-mounted files, updated values are visible immediately to any process that reads the file at invocation time.
- Update the SOPS-encrypted secret in
~/.config/b4arena/secrets/hosts/<hostname>.sops.yml - Run
ludus ops provision --host <hostname> --continue-from github_credentials - New value is visible immediately to any process inside the container that reads the file (not the cached env var)
For gh-wrapper.sh: the token cache at /tmp/gh-app-token-*.json will expire naturally (within 1 hour). To force immediate rotation, delete the cache file inside the container.
For ANTHROPIC_API_KEY: if sourced at entrypoint, the container must be restarted. If sourced lazily (Option A), the new value takes effect on next tool invocation.
9. Security Properties
The OpenClaw sanitizer strips any env var containing _TOKEN, _KEY, or _SECRET from docker.env. This is a security feature, not a bug. Secrets must be delivered via file bind-mounts, never via environment variable configuration.
| Property | Status |
|---|---|
| Secrets not in git | Vault dir never committed; SOPS-encrypted in infra |
| Secrets not in env vars at container config level | File bind-mounts only |
| Secrets not in container image | Build-time args contain no secrets |
| Least-privilege mounts | Per-agent mount declarations (future: per-service) |
| Secrets not in logs | Wrapper scripts cache tokens at /tmp, never log raw values |
| Secrets readable only by openclaw | Host chmod 600, owned by openclaw |
| Read-only mounts | :ro flag on all secret mounts |
| Encrypted at rest | SOPS+age in ~/.config/b4arena/secrets/ |
| In-container path is tmpfs | /run/ is tmpfs — no disk persistence |
10. Open Questions
| # | Question | Context |
|---|---|---|
| Q-1 | Should ANTHROPIC_API_KEY be injected by OpenClaw itself (not by ludus)? | Claude Code may already pick it up from its own config; check if double-injection is needed. |
Rejected Options (historical context)
gopass bind-mount
Install gopass inside containers, bind-mount the gopass store from the host.
Why rejected:
- gopass requires a running GPG agent with an unlocked key — hard to automate in rootless containers
- GPG agent socket cannot be easily shared from host into container
- Adds a complex runtime dependency to every image
- No benefit over simpler file-based approach for automated agents
Kubernetes-style env var injection via docker.env
Pass secrets as environment variables via agents.*.sandbox.docker.env.
Why rejected:
- OpenClaw sanitizer strips
_TOKEN/_KEY/_SECRETnames (security feature, not a bug) - Env vars leak through
ps,/proc/1/environ, error logs,printenv - Cannot express structured secrets (multi-line PEM keys, JSON maps)
HashiCorp Vault / secret manager in container
Run a Vault agent sidecar or install a secret manager inside the container.
Why rejected:
- Requires running a Vault server (heavy infrastructure dependency)
- Adds operational complexity without commensurate benefit at current scale
- Revisit if/when the number of services or rotation frequency demands it
References
- architecture.md — Sandbox container architecture
- CLAUDE.md — Container credential gotchas
containers/scripts/gh-wrapper.sh— Reference implementation of the source-at-invocation patternludus secretssubcommands — SOPS+age workflow for ludus-native secret management