Skip to main content

Credential Management — Agents in Containers

Agent Quick Reference

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

ConstraintImpact
OpenClaw sanitizer strips _TOKEN, _KEY, _SECRET from docker.envCannot inject sensitive credentials via env var config
Rootless Podman (no daemon)No privileged volume plugins; bind-mounts are the tool
Agents are fully automatedNo interactive unlock (rules out GPG agent, gopass interactive flow)
SOPS+age is the infra standardEncryption at rest already solved
Containers reuse across sessionsSecrets 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:

  1. Host vault directory — secrets materialized from SOPS on the host, organized by service
  2. Per-agent mount declarations — each agent declares which vault paths it needs
  3. Standardized in-container paths — agents read from /run/secrets/
  4. 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

caution

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

PathContentsUsed by
/run/secrets/github/app.envGitHub App env varsgh-wrapper.sh
/run/secrets/github/private.pemGitHub App RSA keygh-wrapper.sh via GH_APP_PRIVATE_KEY_PATH
/run/secrets/anthropic/api.envANTHROPIC_API_KEYClaude Code
/run/secrets/agent/extra.envPer-agent extrasAgent-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:

  1. Add to SOPS vault — add the variable to ~/.config/b4arena/secrets/hosts/<hostname>.sops.yml
  2. Update provisioning step — add materialization logic to the relevant step in src/ludus_cli/provision/steps/
  3. Declare the mount — add the bind-mount to the relevant agent in ludus/cli.py sandbox config
  4. Write a wrapper or document the path — update the agent's TOOLS.md with the secret path
  5. Run deploymentludus 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, files 600
  • 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

tip

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.

  1. Update the SOPS-encrypted secret in ~/.config/b4arena/secrets/hosts/<hostname>.sops.yml
  2. Run ludus ops provision --host <hostname> --continue-from github_credentials
  3. 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

caution

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.

PropertyStatus
Secrets not in gitVault dir never committed; SOPS-encrypted in infra
Secrets not in env vars at container config levelFile bind-mounts only
Secrets not in container imageBuild-time args contain no secrets
Least-privilege mountsPer-agent mount declarations (future: per-service)
Secrets not in logsWrapper scripts cache tokens at /tmp, never log raw values
Secrets readable only by openclawHost chmod 600, owned by openclaw
Read-only mounts:ro flag on all secret mounts
Encrypted at restSOPS+age in ~/.config/b4arena/secrets/
In-container path is tmpfs/run/ is tmpfs — no disk persistence

10. Open Questions

#QuestionContext
Q-1Should 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/_SECRET names (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