Skip to main content

Credential Management — Agents in Containers

Agent Quick Reference

Key files: ludus/skills/gh-credentials/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. Current Implementation: /etc/openclaw/env Bind-Mount​

Today, all credentials are delivered through a single env file bind-mounted into containers:

Host:                                      Container:
~/.openclaw/env ────► /etc/openclaw/env (read-only)

The gh-wrapper.sh script (at skills/gh-credentials/, bind-mounted to /usr/local/bin/gh) sources /etc/openclaw/env on every invocation to obtain GitHub App credentials (GH_APP_ID, GH_APP_INSTALLATION_ID, GH_APP_PRIVATE_KEY_PATH). It then uses gh-token to generate short-lived installation tokens, cached at /tmp/gh-app-token-*.json.

Per-agent overrides are supported via /home/openclaw/.openclaw/agent-github.env (bind-mounted from openclaw_state_dir). If this file sets GH_TOKEN, the GitHub App token generation is skipped entirely.

The ANTHROPIC_API_KEY is injected via docker.env in the sandbox configuration.

4. Target Architecture: Structured Vault Bind-Mount​

Aspirational

The vault structure below is a design target — it is not yet implemented. The current production system uses the single /etc/openclaw/env pattern described above. This section documents the planned evolution.

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.

5. Host-Side Setup (Target)​

5a. 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-...

5b. 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

5c. 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

6. Container-Side Access Patterns (Target)​

6a. 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.

6b. 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.

6c. 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.

7. 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 deployment — ludus ops provision --host <hostname> to materialize the new credential

8. 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.

9. 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.

10. 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

11. 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​