feat: vault — publishing gate for external-facing agent actions (#19)
Implements the vault subsystem: a JSONL queue and gate agent that sits between agent output and irreversible external actions (emails, posts, API calls, charges). New files: - vault/vault-poll.sh: cron entry (*/30), three phases: retry approved, timeout escalations (48h), invoke vault-agent for new pending actions - vault/vault-agent.sh: claude -p wrapper that classifies and routes actions based on risk × reversibility routing table - vault/vault-fire.sh: two-phase dispatcher (pending→approved→fired) with per-action locking and webhook-call handler - vault/vault-reject.sh: moves actions to rejected/ with reason + timestamp - vault/PROMPT.md: vault-agent system prompt with routing table Modified: - lib/matrix_listener.sh: new vault dispatch branch for APPROVE/REJECT replies to escalation threads Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5aa0b42481
commit
e503273fba
11 changed files with 555 additions and 0 deletions
137
vault/vault-fire.sh
Executable file
137
vault/vault-fire.sh
Executable file
|
|
@ -0,0 +1,137 @@
|
|||
#!/usr/bin/env bash
|
||||
# vault-fire.sh — Execute an approved vault action by ID
|
||||
#
|
||||
# Two-phase: pending/ → approved/ → fired/
|
||||
# If action is in pending/, moves to approved/ first.
|
||||
# If action is already in approved/, fires directly (crash recovery).
|
||||
#
|
||||
# Usage: bash vault-fire.sh <action-id>
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
source "${SCRIPT_DIR}/../lib/env.sh"
|
||||
|
||||
VAULT_DIR="${FACTORY_ROOT}/vault"
|
||||
LOCKS_DIR="${VAULT_DIR}/.locks"
|
||||
LOGFILE="${VAULT_DIR}/vault.log"
|
||||
|
||||
log() {
|
||||
printf '[%s] vault-fire: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" >> "$LOGFILE"
|
||||
}
|
||||
|
||||
ACTION_ID="${1:?Usage: vault-fire.sh <action-id>}"
|
||||
|
||||
# Locate the action file
|
||||
ACTION_FILE=""
|
||||
if [ -f "${VAULT_DIR}/approved/${ACTION_ID}.json" ]; then
|
||||
ACTION_FILE="${VAULT_DIR}/approved/${ACTION_ID}.json"
|
||||
elif [ -f "${VAULT_DIR}/pending/${ACTION_ID}.json" ]; then
|
||||
# Phase 1: move pending → approved
|
||||
mv "${VAULT_DIR}/pending/${ACTION_ID}.json" "${VAULT_DIR}/approved/${ACTION_ID}.json"
|
||||
ACTION_FILE="${VAULT_DIR}/approved/${ACTION_ID}.json"
|
||||
# Update status in the file
|
||||
TMP=$(mktemp)
|
||||
jq '.status = "approved"' "$ACTION_FILE" > "$TMP" && mv "$TMP" "$ACTION_FILE"
|
||||
log "$ACTION_ID: pending → approved"
|
||||
else
|
||||
log "ERROR: action $ACTION_ID not found in pending/ or approved/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Acquire lock
|
||||
mkdir -p "$LOCKS_DIR"
|
||||
LOCKFILE="${LOCKS_DIR}/${ACTION_ID}.lock"
|
||||
if [ -f "$LOCKFILE" ]; then
|
||||
LOCK_PID=$(cat "$LOCKFILE" 2>/dev/null || true)
|
||||
if [ -n "$LOCK_PID" ] && kill -0 "$LOCK_PID" 2>/dev/null; then
|
||||
log "$ACTION_ID: already being fired by PID $LOCK_PID"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
echo $$ > "$LOCKFILE"
|
||||
trap 'rm -f "$LOCKFILE"' EXIT
|
||||
|
||||
# Read action metadata
|
||||
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"
|
||||
|
||||
# =============================================================================
|
||||
# Dispatch to handler
|
||||
# =============================================================================
|
||||
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
|
||||
|
||||
# =============================================================================
|
||||
# Move to fired/ or leave in approved/ on failure
|
||||
# =============================================================================
|
||||
if [ "$FIRE_EXIT" -eq 0 ]; then
|
||||
# Update with fired timestamp and move to fired/
|
||||
TMP=$(mktemp)
|
||||
jq --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" '.status = "fired" | .fired_at = $ts' "$ACTION_FILE" > "$TMP" \
|
||||
&& mv "$TMP" "${VAULT_DIR}/fired/${ACTION_ID}.json"
|
||||
rm -f "$ACTION_FILE"
|
||||
log "$ACTION_ID: approved → fired"
|
||||
matrix_send "vault" "✅ Vault fired: ${ACTION_ID} (${ACTION_TYPE} from ${ACTION_SOURCE})" 2>/dev/null || true
|
||||
else
|
||||
log "ERROR: $ACTION_ID fire failed (exit $FIRE_EXIT) — stays in approved/ for retry"
|
||||
matrix_send "vault" "❌ Vault fire failed: ${ACTION_ID} (${ACTION_TYPE}) — will retry" 2>/dev/null || true
|
||||
exit "$FIRE_EXIT"
|
||||
fi
|
||||
Loading…
Add table
Add a link
Reference in a new issue