fix: Secure action runtime — ephemeral container with vault-injected secrets (#748)

Split secrets into two SOPS-encrypted files:
- .env.enc for agent secrets (FORGE_TOKEN, CLAUDE_API_KEY, etc.)
- .env.vault.enc for vault secrets (GITHUB_TOKEN, deploy keys, etc.)

Add ephemeral vault-runner container (profiles: ["vault"]) that receives
only vault secrets at runtime. Agents never see vault secrets; vault-runner
never sees agent secrets.

Key changes:
- bin/disinto: vault-run subcommand, dual-file secrets management,
  vault-runner service in compose template
- vault/vault-fire.sh: delegates action execution to vault-runner
  container via disinto vault-run (bare-metal fallback preserved)
- vault/vault-poll.sh: new phase 5 detects vault-bot authorized
  comments on issues with action label
- vault/vault-run-action.sh: entrypoint for ephemeral container,
  dispatches to action handlers

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
openhands 2026-03-26 16:41:27 +00:00
parent ac4eaf93d6
commit cb5252588c
6 changed files with 326 additions and 82 deletions

View file

@ -9,7 +9,8 @@
# disinto logs [service] Tail service logs
# disinto shell Shell into the agent container
# disinto status Show factory status
# disinto secrets <edit|show|migrate> Manage encrypted secrets
# disinto secrets <subcommand> Manage encrypted secrets
# disinto vault-run <action-id> Run action in ephemeral vault container
#
# Usage:
# disinto init https://github.com/user/repo
@ -37,7 +38,8 @@ Usage:
disinto logs [service] Tail service logs
disinto shell Shell into the agent container
disinto status Show factory status
disinto secrets <edit|show|migrate> Manage encrypted secrets (.env.enc)
disinto secrets <subcommand> Manage encrypted secrets
disinto vault-run <action-id> Run action in ephemeral vault container
Init options:
--branch <name> Primary branch (default: auto-detect)
@ -107,7 +109,7 @@ write_sops_yaml() {
local pub_key="$1"
cat > "${FACTORY_ROOT}/.sops.yaml" <<EOF
creation_rules:
- path_regex: \.env\.enc$
- path_regex: \.env(\.vault)?\.enc$
age: "${pub_key}"
EOF
}
@ -237,6 +239,23 @@ services:
networks:
- disinto-net
vault-runner:
build: ./docker/agents
profiles: ["vault"]
security_opt:
- apparmor=unconfined
volumes:
- ./vault:/home/agent/disinto/vault
- ./lib:/home/agent/disinto/lib:ro
- ./formulas:/home/agent/disinto/formulas:ro
environment:
FORGE_URL: http://forgejo:3000
DISINTO_CONTAINER: "1"
# env_file set at runtime by: disinto vault-run --env-file <tmpfile>
entrypoint: ["bash", "/home/agent/disinto/vault/vault-run-action.sh"]
networks:
- disinto-net
volumes:
forgejo-data:
woodpecker-data:
@ -1370,6 +1389,26 @@ disinto_secrets() {
local subcmd="${1:-}"
local enc_file="${FACTORY_ROOT}/.env.enc"
local env_file="${FACTORY_ROOT}/.env"
local vault_enc_file="${FACTORY_ROOT}/.env.vault.enc"
local vault_env_file="${FACTORY_ROOT}/.env.vault"
# Shared helper: ensure sops+age and .sops.yaml exist
_secrets_ensure_sops() {
if ! command -v sops &>/dev/null || ! command -v age-keygen &>/dev/null; then
echo "Error: sops and age are required." >&2
echo " Install sops: https://github.com/getsops/sops/releases" >&2
echo " Install age: apt install age / brew install age" >&2
exit 1
fi
if ! ensure_age_key; then
echo "Error: failed to generate age key" >&2
exit 1
fi
if [ ! -f "${FACTORY_ROOT}/.sops.yaml" ]; then
write_sops_yaml "$AGE_PUBLIC_KEY"
echo "Created: .sops.yaml"
fi
}
case "$subcmd" in
edit)
@ -1391,31 +1430,110 @@ disinto_secrets() {
echo "Error: ${env_file} not found — nothing to migrate." >&2
exit 1
fi
if ! command -v sops &>/dev/null || ! command -v age-keygen &>/dev/null; then
echo "Error: sops and age are required for migration." >&2
echo " Install sops: https://github.com/getsops/sops/releases" >&2
echo " Install age: apt install age / brew install age" >&2
exit 1
fi
if ! ensure_age_key; then
echo "Error: failed to generate age key" >&2
exit 1
fi
if [ ! -f "${FACTORY_ROOT}/.sops.yaml" ]; then
write_sops_yaml "$AGE_PUBLIC_KEY"
echo "Created: .sops.yaml"
fi
_secrets_ensure_sops
encrypt_env_file "$env_file" "$enc_file"
rm -f "$env_file"
echo "Migrated: .env -> .env.enc (plaintext removed)"
;;
edit-vault)
if [ ! -f "$vault_enc_file" ]; then
echo "Error: ${vault_enc_file} not found. Run 'disinto secrets migrate-vault' first." >&2
exit 1
fi
sops "$vault_enc_file"
;;
show-vault)
if [ ! -f "$vault_enc_file" ]; then
echo "Error: ${vault_enc_file} not found." >&2
exit 1
fi
sops -d "$vault_enc_file"
;;
migrate-vault)
if [ ! -f "$vault_env_file" ]; then
echo "Error: ${vault_env_file} not found — nothing to migrate." >&2
echo " Create .env.vault with vault secrets (GITHUB_TOKEN, deploy keys, etc.)" >&2
exit 1
fi
_secrets_ensure_sops
encrypt_env_file "$vault_env_file" "$vault_enc_file"
rm -f "$vault_env_file"
echo "Migrated: .env.vault -> .env.vault.enc (plaintext removed)"
;;
*)
echo "Usage: disinto secrets <edit|show|migrate>" >&2
cat <<EOF >&2
Usage: disinto secrets <subcommand>
Agent secrets (.env.enc):
edit Edit agent secrets (FORGE_TOKEN, CLAUDE_API_KEY, etc.)
show Show decrypted agent secrets
migrate Encrypt .env -> .env.enc
Vault secrets (.env.vault.enc):
edit-vault Edit vault secrets (GITHUB_TOKEN, deploy keys, etc.)
show-vault Show decrypted vault secrets
migrate-vault Encrypt .env.vault -> .env.vault.enc
EOF
exit 1
;;
esac
}
# ── vault-run command ─────────────────────────────────────────────────────────
disinto_vault_run() {
local action_id="${1:?Usage: disinto vault-run <action-id>}"
local compose_file="${FACTORY_ROOT}/docker-compose.yml"
local vault_enc="${FACTORY_ROOT}/.env.vault.enc"
if [ ! -f "$compose_file" ]; then
echo "Error: docker-compose.yml not found" >&2
echo " Run 'disinto init <repo-url>' first (without --bare)" >&2
exit 1
fi
if [ ! -f "$vault_enc" ]; then
echo "Error: .env.vault.enc not found — create vault secrets first" >&2
echo " Run 'disinto secrets migrate-vault' after creating .env.vault" >&2
exit 1
fi
if ! command -v sops &>/dev/null; then
echo "Error: sops not found — required to decrypt vault secrets" >&2
exit 1
fi
# Decrypt vault secrets to temp file
local tmp_env
tmp_env=$(mktemp /tmp/disinto-vault-XXXXXX)
trap 'rm -f "$tmp_env"' EXIT
if ! sops -d --output-type dotenv "$vault_enc" > "$tmp_env" 2>/dev/null; then
rm -f "$tmp_env"
echo "Error: failed to decrypt .env.vault.enc" >&2
exit 1
fi
echo "Vault secrets decrypted to tmpfile"
# Run action in ephemeral vault-runner container
local rc=0
docker compose -f "$compose_file" \
run --rm --env-file "$tmp_env" \
vault-runner "$action_id" || rc=$?
# Clean up — secrets gone
rm -f "$tmp_env"
echo "Vault tmpfile removed"
if [ "$rc" -eq 0 ]; then
echo "Vault action ${action_id} completed successfully"
else
echo "Vault action ${action_id} failed (exit ${rc})" >&2
fi
return "$rc"
}
# ── up command ────────────────────────────────────────────────────────────────
disinto_up() {
@ -1484,13 +1602,14 @@ disinto_shell() {
# ── Main dispatch ────────────────────────────────────────────────────────────
case "${1:-}" in
init) shift; disinto_init "$@" ;;
up) shift; disinto_up "$@" ;;
down) shift; disinto_down "$@" ;;
logs) shift; disinto_logs "$@" ;;
shell) shift; disinto_shell ;;
status) shift; disinto_status "$@" ;;
secrets) shift; disinto_secrets "$@" ;;
init) shift; disinto_init "$@" ;;
up) shift; disinto_up "$@" ;;
down) shift; disinto_down "$@" ;;
logs) shift; disinto_logs "$@" ;;
shell) shift; disinto_shell ;;
status) shift; disinto_status "$@" ;;
secrets) shift; disinto_secrets "$@" ;;
vault-run) shift; disinto_vault_run "$@" ;;
-h|--help) usage ;;
*) usage ;;
esac