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

@ -3,6 +3,8 @@
#
# Handles two pipelines:
# A. Action gating (*.json): pending/ → approved/ → fired/
# Execution delegated to ephemeral vault-runner container via disinto vault-run.
# The vault-runner gets vault secrets (.env.vault.enc); this script does NOT.
# B. Procurement (*.md): approved/ → fired/ (writes RESOURCES.md entry)
#
# If item is in pending/, moves to approved/ first.
@ -98,70 +100,30 @@ if [ "$IS_PROCUREMENT" = true ]; then
fi
# =============================================================================
# Pipeline B: Action gating — dispatch to handler
# Pipeline B: Action gating — delegate to ephemeral vault-runner container
# =============================================================================
ACTION_TYPE=$(jq -r '.type // ""' < "$ACTION_FILE")
ACTION_SOURCE=$(jq -r '.source // ""' < "$ACTION_FILE")
PAYLOAD=$(jq -c '.payload // {}' < "$ACTION_FILE")
if [ -z "$ACTION_TYPE" ]; then
log "ERROR: $ACTION_ID has no type field"
exit 1
fi
log "$ACTION_ID: firing type=$ACTION_TYPE source=$ACTION_SOURCE"
log "$ACTION_ID: firing type=$ACTION_TYPE source=$ACTION_SOURCE via vault-runner"
FIRE_EXIT=0
case "$ACTION_TYPE" in
webhook-call)
# Universal handler: HTTP call to endpoint with optional method/headers/body
ENDPOINT=$(echo "$PAYLOAD" | jq -r '.endpoint // ""')
METHOD=$(echo "$PAYLOAD" | jq -r '.method // "POST"')
REQ_BODY=$(echo "$PAYLOAD" | jq -r '.body // ""')
HEADERS=$(echo "$PAYLOAD" | jq -r '.headers // {} | to_entries[] | "-H\n\(.key): \(.value)"' 2>/dev/null || true)
if [ -z "$ENDPOINT" ]; then
log "ERROR: $ACTION_ID webhook-call missing endpoint"
exit 1
fi
# Build curl args
CURL_ARGS=(-sf -X "$METHOD" -o /dev/null -w "%{http_code}")
if [ -n "$HEADERS" ]; then
while IFS= read -r header; do
[ -n "$header" ] && CURL_ARGS+=(-H "$header")
done < <(echo "$PAYLOAD" | jq -r '.headers // {} | to_entries[] | "\(.key): \(.value)"' 2>/dev/null || true)
fi
if [ -n "$REQ_BODY" ] && [ "$REQ_BODY" != "null" ]; then
CURL_ARGS+=(-d "$REQ_BODY")
fi
HTTP_CODE=$(curl "${CURL_ARGS[@]}" "$ENDPOINT" 2>/dev/null) || HTTP_CODE="000"
if [[ "$HTTP_CODE" =~ ^2 ]]; then
log "$ACTION_ID: webhook-call → HTTP $HTTP_CODE OK"
else
log "ERROR: $ACTION_ID webhook-call → HTTP $HTTP_CODE"
FIRE_EXIT=1
fi
;;
blog-post|social-post|email-blast|pricing-change|dns-change|stripe-charge)
# Check for a handler script
HANDLER="${VAULT_DIR}/handlers/${ACTION_TYPE}.sh"
if [ -x "$HANDLER" ]; then
bash "$HANDLER" "$ACTION_ID" "$PAYLOAD" >> "$LOGFILE" 2>&1 || FIRE_EXIT=$?
else
log "ERROR: $ACTION_ID no handler for type '$ACTION_TYPE' (${HANDLER} not found)"
FIRE_EXIT=1
fi
;;
*)
log "ERROR: $ACTION_ID unknown action type '$ACTION_TYPE'"
FIRE_EXIT=1
;;
esac
# Delegate execution to the ephemeral vault-runner container.
# The vault-runner gets vault secrets (.env.vault.enc) injected at runtime;
# this host process never sees those secrets.
if [ -f "${FACTORY_ROOT}/.env.vault.enc" ] && [ -f "${FACTORY_ROOT}/docker-compose.yml" ]; then
bash "${FACTORY_ROOT}/bin/disinto" vault-run "$ACTION_ID" >> "$LOGFILE" 2>&1 || FIRE_EXIT=$?
else
# Fallback for bare-metal or pre-migration setups: run action handler directly
log "$ACTION_ID: no .env.vault.enc or docker-compose.yml — running action directly"
bash "${VAULT_DIR}/vault-run-action.sh" "$ACTION_ID" >> "$LOGFILE" 2>&1 || FIRE_EXIT=$?
fi
# =============================================================================
# Move to fired/ or leave in approved/ on failure

View file

@ -221,8 +221,80 @@ for req_file in "${VAULT_DIR}/pending/"*.md; do
unlock_action "$REQ_ID"
done
if [ "$PENDING_COUNT" -eq 0 ] && [ "$PROCURE_COUNT" -eq 0 ]; then
# =============================================================================
# PHASE 5: Detect vault-bot authorized comments on issues
# =============================================================================
status "phase 5: scanning for vault-bot authorized comments"
COMMENT_COUNT=0
if [ -n "${FORGE_REPO:-}" ] && [ -n "${FORGE_TOKEN:-}" ]; then
# Get open issues with action label
ACTION_ISSUES=$(curl -sf \
-H "Authorization: token ${FORGE_TOKEN}" \
"${FORGE_URL}/api/v1/repos/${FORGE_REPO}/issues?state=open&labels=action&limit=50" 2>/dev/null) || ACTION_ISSUES="[]"
ISSUE_COUNT=$(printf '%s' "$ACTION_ISSUES" | jq 'length')
for idx in $(seq 0 $((ISSUE_COUNT - 1))); do
ISSUE_NUM=$(printf '%s' "$ACTION_ISSUES" | jq -r ".[$idx].number")
# Skip if already processed
if [ -f "${VAULT_DIR}/.locks/issue-${ISSUE_NUM}.vault-fired" ]; then
continue
fi
# Get comments on this issue
COMMENTS=$(curl -sf \
-H "Authorization: token ${FORGE_TOKEN}" \
"${FORGE_URL}/api/v1/repos/${FORGE_REPO}/issues/${ISSUE_NUM}/comments?limit=50" 2>/dev/null) || continue
# Look for vault-bot comments containing VAULT:APPROVED with a JSON action spec
APPROVED_BODY=$(printf '%s' "$COMMENTS" | jq -r '
[.[] | select(.user.login == "vault-bot") | select(.body | test("VAULT:APPROVED"))] | last | .body // empty
' 2>/dev/null) || continue
[ -z "$APPROVED_BODY" ] && continue
# Extract JSON action spec from fenced code block in the comment
ACTION_JSON=$(printf '%s' "$APPROVED_BODY" | sed -n '/^```json$/,/^```$/p' | sed '1d;$d')
[ -z "$ACTION_JSON" ] && continue
# Validate JSON
if ! printf '%s' "$ACTION_JSON" | jq empty 2>/dev/null; then
log "malformed action JSON in vault-bot comment on issue #${ISSUE_NUM}"
continue
fi
ACTION_ID=$(printf '%s' "$ACTION_JSON" | jq -r '.id // empty')
if [ -z "$ACTION_ID" ]; then
ACTION_ID="issue-${ISSUE_NUM}-$(date +%s)"
ACTION_JSON=$(printf '%s' "$ACTION_JSON" | jq --arg id "$ACTION_ID" '.id = $id')
fi
# Skip if this action already exists in any stage
if [ -f "${VAULT_DIR}/approved/${ACTION_ID}.json" ] || \
[ -f "${VAULT_DIR}/fired/${ACTION_ID}.json" ] || \
[ -f "${VAULT_DIR}/rejected/${ACTION_ID}.json" ]; then
continue
fi
log "vault-bot authorized action on issue #${ISSUE_NUM}: ${ACTION_ID}"
printf '%s' "$ACTION_JSON" | jq '.status = "approved"' > "${VAULT_DIR}/approved/${ACTION_ID}.json"
COMMENT_COUNT=$((COMMENT_COUNT + 1))
# Fire the action
if bash "${VAULT_DIR}/vault-fire.sh" "$ACTION_ID" >> "$LOGFILE" 2>&1; then
log "fired ${ACTION_ID} from issue #${ISSUE_NUM}"
# Mark issue as processed
touch "${VAULT_DIR}/.locks/issue-${ISSUE_NUM}.vault-fired"
else
log "ERROR: fire failed for ${ACTION_ID} from issue #${ISSUE_NUM}"
fi
done
fi
if [ "$PENDING_COUNT" -eq 0 ] && [ "$PROCURE_COUNT" -eq 0 ] && [ "$COMMENT_COUNT" -eq 0 ]; then
status "all clear — no pending items"
else
status "poll complete — ${PENDING_COUNT} action(s), ${PROCURE_COUNT} procurement request(s)"
status "poll complete — ${PENDING_COUNT} action(s), ${PROCURE_COUNT} procurement(s), ${COMMENT_COUNT} comment-authorized"
fi

90
vault/vault-run-action.sh Executable file
View file

@ -0,0 +1,90 @@
#!/usr/bin/env bash
# vault-run-action.sh — Execute an action inside the ephemeral vault-runner container
#
# This script is the entrypoint for the vault-runner container. It runs with
# vault secrets injected as environment variables (GITHUB_TOKEN, CLAWHUB_TOKEN,
# deploy keys, etc.) and dispatches to the appropriate action handler.
#
# The vault-runner container is ephemeral: it starts, runs the action, and is
# destroyed. Secrets exist only in container memory, never on disk.
#
# Usage: vault-run-action.sh <action-id>
set -euo pipefail
VAULT_DIR="${DISINTO_VAULT_DIR:-/home/agent/disinto/vault}"
LOGFILE="${VAULT_DIR}/vault.log"
ACTION_ID="${1:?Usage: vault-run-action.sh <action-id>}"
log() {
printf '[%s] vault-runner: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" >> "$LOGFILE" 2>/dev/null || \
printf '[%s] vault-runner: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" >&2
}
# Find action file in approved/
ACTION_FILE="${VAULT_DIR}/approved/${ACTION_ID}.json"
if [ ! -f "$ACTION_FILE" ]; then
log "ERROR: action file not found: ${ACTION_FILE}"
echo "ERROR: action file not found: ${ACTION_FILE}" >&2
exit 1
fi
ACTION_TYPE=$(jq -r '.type // ""' < "$ACTION_FILE")
ACTION_SOURCE=$(jq -r '.source // ""' < "$ACTION_FILE")
PAYLOAD=$(jq -c '.payload // {}' < "$ACTION_FILE")
if [ -z "$ACTION_TYPE" ]; then
log "ERROR: ${ACTION_ID} has no type field"
exit 1
fi
log "${ACTION_ID}: executing type=${ACTION_TYPE} source=${ACTION_SOURCE}"
FIRE_EXIT=0
case "$ACTION_TYPE" in
webhook-call)
# HTTP call to endpoint with optional method/headers/body
ENDPOINT=$(echo "$PAYLOAD" | jq -r '.endpoint // ""')
METHOD=$(echo "$PAYLOAD" | jq -r '.method // "POST"')
REQ_BODY=$(echo "$PAYLOAD" | jq -r '.body // ""')
if [ -z "$ENDPOINT" ]; then
log "ERROR: ${ACTION_ID} webhook-call missing endpoint"
exit 1
fi
CURL_ARGS=(-sf -X "$METHOD" -o /dev/null -w "%{http_code}")
while IFS= read -r header; do
[ -n "$header" ] && CURL_ARGS+=(-H "$header")
done < <(echo "$PAYLOAD" | jq -r '.headers // {} | to_entries[] | "\(.key): \(.value)"' 2>/dev/null || true)
if [ -n "$REQ_BODY" ] && [ "$REQ_BODY" != "null" ]; then
CURL_ARGS+=(-d "$REQ_BODY")
fi
HTTP_CODE=$(curl "${CURL_ARGS[@]}" "$ENDPOINT" 2>/dev/null) || HTTP_CODE="000"
if [[ "$HTTP_CODE" =~ ^2 ]]; then
log "${ACTION_ID}: webhook-call -> HTTP ${HTTP_CODE} OK"
else
log "ERROR: ${ACTION_ID} webhook-call -> HTTP ${HTTP_CODE}"
FIRE_EXIT=1
fi
;;
blog-post|social-post|email-blast|pricing-change|dns-change|stripe-charge)
HANDLER="${VAULT_DIR}/handlers/${ACTION_TYPE}.sh"
if [ -x "$HANDLER" ]; then
bash "$HANDLER" "$ACTION_ID" "$PAYLOAD" 2>&1 || FIRE_EXIT=$?
else
log "ERROR: ${ACTION_ID} no handler for type '${ACTION_TYPE}' (${HANDLER} not found)"
FIRE_EXIT=1
fi
;;
*)
log "ERROR: ${ACTION_ID} unknown action type '${ACTION_TYPE}'"
FIRE_EXIT=1
;;
esac
exit "$FIRE_EXIT"