diff --git a/lib/matrix_listener.sh b/lib/matrix_listener.sh index 01d0748..5e0a82f 100755 --- a/lib/matrix_listener.sh +++ b/lib/matrix_listener.sh @@ -141,6 +141,29 @@ while true; do printf '%s\t%s\t%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$SENDER" "$BODY" >> /tmp/gardener-escalation-reply matrix_send "gardener" "✓ received, will act on next poll" "$THREAD_ROOT" >/dev/null 2>&1 || true ;; + vault) + # Parse APPROVE or REJECT from reply + VAULT_CMD=$(echo "$BODY" | tr '[:lower:]' '[:upper:]' | grep -oP '^\s*(APPROVE|REJECT)\s+\S+' | head -1 || true) + if [ -n "$VAULT_CMD" ]; then + VAULT_ACTION=$(echo "$VAULT_CMD" | awk '{print $1}') + VAULT_ID=$(echo "$BODY" | awk '{print $2}') # preserve original case for ID + log "vault dispatch: $VAULT_ACTION $VAULT_ID" + VAULT_DIR="${FACTORY_ROOT}/vault" + if [ "$VAULT_ACTION" = "APPROVE" ]; then + if bash "${VAULT_DIR}/vault-fire.sh" "$VAULT_ID" >> "${VAULT_DIR}/vault.log" 2>&1; then + matrix_send "vault" "✓ approved and fired: ${VAULT_ID}" "$THREAD_ROOT" >/dev/null 2>&1 || true + else + matrix_send "vault" "✓ approved but fire failed — will retry: ${VAULT_ID}" "$THREAD_ROOT" >/dev/null 2>&1 || true + fi + else + bash "${VAULT_DIR}/vault-reject.sh" "$VAULT_ID" "rejected by ${SENDER}" >> "${VAULT_DIR}/vault.log" 2>&1 || true + matrix_send "vault" "✓ rejected: ${VAULT_ID}" "$THREAD_ROOT" >/dev/null 2>&1 || true + fi + else + log "vault: unrecognized reply format: ${BODY:0:100}" + matrix_send "vault" "⚠️ Reply with APPROVE or REJECT " "$THREAD_ROOT" >/dev/null 2>&1 || true + fi + ;; *) log "no handler for agent '${AGENT}'" ;; diff --git a/vault/.locks/.gitkeep b/vault/.locks/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/vault/PROMPT.md b/vault/PROMPT.md new file mode 100644 index 0000000..3dec30c --- /dev/null +++ b/vault/PROMPT.md @@ -0,0 +1,90 @@ +# Vault Agent + +You are the vault agent for `$CODEBERG_REPO`. You were called by +`vault-poll.sh` because one or more actions in `vault/pending/` need +classification and routing. + +## Your Job + +For each pending action, decide: **auto-approve**, **escalate**, or **reject**. + +## Routing Table (risk × reversibility) + +| Risk | Reversible | Route | +|----------|------------|---------------------------------------------| +| low | true | auto-approve → fire immediately | +| low | false | auto-approve → fire, log prominently | +| medium | true | auto-approve → fire, matrix notify | +| medium | false | escalate via matrix → wait for human reply | +| high | any | always escalate → wait for human reply | + +## Rules + +1. **Never lower risk.** You may override the source agent's self-assessed + risk *upward*, never downward. If a `blog-post` looks like it contains + pricing claims, bump it to `medium` or `high`. +2. **`requires_human: true` always escalates.** Regardless of risk level. +3. **Unknown action types → reject** with reason `unknown_type`. +4. **Malformed JSON → reject** with reason `malformed`. +5. **Payload validation:** Check that the payload has the minimum required + fields for the action type. Missing fields → reject with reason. + +## Action Type Defaults + +| Type | Default Risk | Default Reversible | +|------------------|-------------|-------------------| +| `blog-post` | low | yes | +| `social-post` | medium | yes | +| `email-blast` | high | no | +| `pricing-change` | high | partial | +| `dns-change` | high | partial | +| `webhook-call` | medium | depends | +| `stripe-charge` | high | no | + +## Available Tools + +You have shell access. Use these for routing decisions: + +```bash +source ${FACTORY_ROOT}/lib/env.sh +``` + +### Auto-approve and fire +```bash +bash ${FACTORY_ROOT}/vault/vault-fire.sh +``` + +### Escalate via Matrix +```bash +matrix_send "vault" "🔒 VAULT — approval required + +Source: +Type: +Risk: / +Created: + + + +Reply APPROVE or REJECT " 2>/dev/null +``` + +### Reject +```bash +bash ${FACTORY_ROOT}/vault/vault-reject.sh "" +``` + +## Output Format + +After processing each action, print exactly: + +``` +ROUTE: +``` + +## Important + +- Process ALL pending actions in the batch. Never skip silently. +- For auto-approved actions, fire them immediately via `vault-fire.sh`. +- For escalated actions, move to `vault/approved/` only AFTER human approval + (vault-poll handles this via matrix_listener dispatch). +- Read the action JSON carefully. Check the payload, not just the metadata. diff --git a/vault/approved/.gitkeep b/vault/approved/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/vault/fired/.gitkeep b/vault/fired/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/vault/pending/.gitkeep b/vault/pending/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/vault/rejected/.gitkeep b/vault/rejected/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/vault/vault-agent.sh b/vault/vault-agent.sh new file mode 100755 index 0000000..8e2b813 --- /dev/null +++ b/vault/vault-agent.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# vault-agent.sh — Invoke claude -p to classify and route pending vault actions +# +# Called by vault-poll.sh when pending actions exist. Reads all pending/*.json, +# builds a prompt with action summaries, and lets the LLM decide routing. +# +# The LLM can call vault-fire.sh (auto-approve) or vault-reject.sh (reject) +# directly. For escalations, it sends a Matrix message and marks the action +# as "escalated" in pending/ so vault-poll skips it on future runs. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "${SCRIPT_DIR}/../lib/env.sh" + +VAULT_DIR="${FACTORY_ROOT}/vault" +PROMPT_FILE="${VAULT_DIR}/PROMPT.md" +LOGFILE="${VAULT_DIR}/vault.log" +CLAUDE_TIMEOUT="${CLAUDE_TIMEOUT:-3600}" + +log() { + printf '[%s] vault-agent: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" >> "$LOGFILE" +} + +# Collect all pending actions (skip already-escalated) +ACTIONS_BATCH="" +ACTION_COUNT=0 + +for action_file in "${VAULT_DIR}/pending/"*.json; do + [ -f "$action_file" ] || continue + + ACTION_STATUS=$(jq -r '.status // ""' < "$action_file" 2>/dev/null) + [ "$ACTION_STATUS" = "escalated" ] && continue + + # Validate JSON + if ! jq empty < "$action_file" 2>/dev/null; then + ACTION_ID=$(basename "$action_file" .json) + log "malformed JSON: $action_file — rejecting" + bash "${VAULT_DIR}/vault-reject.sh" "$ACTION_ID" "malformed JSON" 2>/dev/null || true + continue + fi + + ACTION_JSON=$(cat "$action_file") + ACTIONS_BATCH="${ACTIONS_BATCH} +--- ACTION --- +$(echo "$ACTION_JSON" | jq '.') +--- END ACTION --- +" + ACTION_COUNT=$((ACTION_COUNT + 1)) +done + +if [ "$ACTION_COUNT" -eq 0 ]; then + log "no actionable pending items" + exit 0 +fi + +log "processing $ACTION_COUNT pending action(s) via claude -p" + +# Build the prompt +SYSTEM_PROMPT=$(cat "$PROMPT_FILE" 2>/dev/null || echo "You are a vault agent. Classify and route actions.") + +PROMPT="${SYSTEM_PROMPT} + +## Pending Actions (${ACTION_COUNT} total) +${ACTIONS_BATCH} + +## Environment +- FACTORY_ROOT=${FACTORY_ROOT} +- Vault directory: ${VAULT_DIR} +- vault-fire.sh: bash ${VAULT_DIR}/vault-fire.sh +- vault-reject.sh: bash ${VAULT_DIR}/vault-reject.sh \"\" +- matrix_send is available after: source ${FACTORY_ROOT}/lib/env.sh + +Process each action now. For auto-approve, fire immediately. For escalate, +send Matrix message and mark as escalated. For reject, call vault-reject.sh." + +CLAUDE_OUTPUT=$(timeout "$CLAUDE_TIMEOUT" claude -p "$PROMPT" \ + --model sonnet \ + --dangerously-skip-permissions \ + --max-turns 20 \ + 2>/dev/null) || true + +log "claude finished ($(echo "$CLAUDE_OUTPUT" | wc -c) bytes)" + +# Log routing decisions +ROUTES=$(echo "$CLAUDE_OUTPUT" | grep "^ROUTE:" || true) +if [ -n "$ROUTES" ]; then + echo "$ROUTES" | while read -r line; do + log " $line" + done +fi diff --git a/vault/vault-fire.sh b/vault/vault-fire.sh new file mode 100755 index 0000000..e943327 --- /dev/null +++ b/vault/vault-fire.sh @@ -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 + +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 }" + +# 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 diff --git a/vault/vault-poll.sh b/vault/vault-poll.sh new file mode 100755 index 0000000..13e01ef --- /dev/null +++ b/vault/vault-poll.sh @@ -0,0 +1,168 @@ +#!/usr/bin/env bash +# vault-poll.sh — Vault gate agent: process pending actions, retry approved, timeout escalations +# +# Runs every 30min via cron. Processes actions through the vault pipeline: +# 1. Retry any approved/ actions that weren't fired (crash recovery) +# 2. Auto-reject escalations with no reply for 48h +# 3. Invoke vault-agent.sh for new pending/ actions +# +# Cron: */30 * * * * /path/to/disinto/vault/vault-poll.sh +# +# Peek: cat /tmp/vault-status +# Log: tail -f /path/to/disinto/vault/vault.log + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "${SCRIPT_DIR}/../lib/env.sh" + +LOGFILE="${FACTORY_ROOT}/vault/vault.log" +STATUSFILE="/tmp/vault-status" +LOCKFILE="/tmp/vault-poll.lock" +VAULT_DIR="${FACTORY_ROOT}/vault" +LOCKS_DIR="${VAULT_DIR}/.locks" + +TIMEOUT_HOURS=48 + +# Prevent overlapping runs +if [ -f "$LOCKFILE" ]; then + LOCK_PID=$(cat "$LOCKFILE" 2>/dev/null) + if kill -0 "$LOCK_PID" 2>/dev/null; then + exit 0 + fi + rm -f "$LOCKFILE" +fi +echo $$ > "$LOCKFILE" +trap 'rm -f "$LOCKFILE" "$STATUSFILE"' EXIT + +log() { + printf '[%s] vault: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" >> "$LOGFILE" +} + +status() { + printf '[%s] vault: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" > "$STATUSFILE" + log "$*" +} + +# Acquire per-action lock (returns 0 if acquired, 1 if already locked) +lock_action() { + local action_id="$1" + local lockfile="${LOCKS_DIR}/${action_id}.lock" + mkdir -p "$LOCKS_DIR" + if [ -f "$lockfile" ]; then + local lock_pid + lock_pid=$(cat "$lockfile" 2>/dev/null || true) + if [ -n "$lock_pid" ] && kill -0 "$lock_pid" 2>/dev/null; then + return 1 + fi + rm -f "$lockfile" + fi + echo $$ > "$lockfile" + return 0 +} + +unlock_action() { + local action_id="$1" + rm -f "${LOCKS_DIR}/${action_id}.lock" +} + +# ============================================================================= +# PHASE 1: Retry approved actions (crash recovery) +# ============================================================================= +status "phase 1: retrying approved actions" + +for action_file in "${VAULT_DIR}/approved/"*.json; do + [ -f "$action_file" ] || continue + ACTION_ID=$(jq -r '.id // ""' < "$action_file" 2>/dev/null) + [ -z "$ACTION_ID" ] && continue + + if ! lock_action "$ACTION_ID"; then + log "skip $ACTION_ID — locked by another process" + continue + fi + + log "retrying approved action: $ACTION_ID" + if bash "${VAULT_DIR}/vault-fire.sh" "$ACTION_ID" >> "$LOGFILE" 2>&1; then + log "fired $ACTION_ID (retry)" + else + log "ERROR: fire failed for $ACTION_ID (retry)" + matrix_send "vault" "❌ Vault fire failed on retry: ${ACTION_ID}" 2>/dev/null || true + fi + + unlock_action "$ACTION_ID" +done + +# ============================================================================= +# PHASE 2: Timeout escalations (48h no reply → auto-reject) +# ============================================================================= +status "phase 2: checking escalation timeouts" + +NOW_EPOCH=$(date +%s) +TIMEOUT_SECS=$((TIMEOUT_HOURS * 3600)) + +for action_file in "${VAULT_DIR}/pending/"*.json; do + [ -f "$action_file" ] || continue + + ACTION_STATUS=$(jq -r '.status // ""' < "$action_file" 2>/dev/null) + [ "$ACTION_STATUS" != "escalated" ] && continue + + ACTION_ID=$(jq -r '.id // ""' < "$action_file" 2>/dev/null) + ESCALATED_AT=$(jq -r '.escalated_at // ""' < "$action_file" 2>/dev/null) + [ -z "$ESCALATED_AT" ] && continue + + ESCALATED_EPOCH=$(date -d "$ESCALATED_AT" +%s 2>/dev/null || echo 0) + AGE_SECS=$((NOW_EPOCH - ESCALATED_EPOCH)) + + if [ "$AGE_SECS" -gt "$TIMEOUT_SECS" ]; then + AGE_HOURS=$((AGE_SECS / 3600)) + log "timeout: $ACTION_ID escalated ${AGE_HOURS}h ago with no reply — auto-rejecting" + bash "${VAULT_DIR}/vault-reject.sh" "$ACTION_ID" "timeout (${AGE_HOURS}h, no human reply)" >> "$LOGFILE" 2>&1 || true + matrix_send "vault" "⏰ Vault auto-rejected ${ACTION_ID} — no reply after ${AGE_HOURS}h" 2>/dev/null || true + fi +done + +# ============================================================================= +# PHASE 3: Process new pending actions +# ============================================================================= +status "phase 3: processing pending actions" + +PENDING_COUNT=0 +PENDING_SUMMARY="" + +for action_file in "${VAULT_DIR}/pending/"*.json; do + [ -f "$action_file" ] || continue + + ACTION_STATUS=$(jq -r '.status // ""' < "$action_file" 2>/dev/null) + # Skip already-escalated actions (waiting for human reply) + [ "$ACTION_STATUS" = "escalated" ] && continue + + ACTION_ID=$(jq -r '.id // ""' < "$action_file" 2>/dev/null) + [ -z "$ACTION_ID" ] && continue + + if ! lock_action "$ACTION_ID"; then + log "skip $ACTION_ID — locked" + continue + fi + + PENDING_COUNT=$((PENDING_COUNT + 1)) + ACTION_TYPE=$(jq -r '.type // "unknown"' < "$action_file" 2>/dev/null) + ACTION_SOURCE=$(jq -r '.source // "unknown"' < "$action_file" 2>/dev/null) + PENDING_SUMMARY="${PENDING_SUMMARY} ${ACTION_ID} [${ACTION_TYPE}] from ${ACTION_SOURCE}\n" + + unlock_action "$ACTION_ID" +done + +if [ "$PENDING_COUNT" -eq 0 ]; then + status "all clear — no pending actions" + exit 0 +fi + +log "found $PENDING_COUNT pending action(s), invoking vault-agent" +status "invoking vault-agent for $PENDING_COUNT action(s)" + +bash "${VAULT_DIR}/vault-agent.sh" >> "$LOGFILE" 2>&1 || { + log "ERROR: vault-agent failed" + matrix_send "vault" "❌ vault-agent.sh failed — check vault.log" 2>/dev/null || true +} + +status "poll complete" diff --git a/vault/vault-reject.sh b/vault/vault-reject.sh new file mode 100755 index 0000000..f5c15c1 --- /dev/null +++ b/vault/vault-reject.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# vault-reject.sh — Move a vault action to rejected/ with reason +# +# Usage: bash vault-reject.sh "" + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "${SCRIPT_DIR}/../lib/env.sh" + +VAULT_DIR="${FACTORY_ROOT}/vault" +LOGFILE="${VAULT_DIR}/vault.log" + +log() { + printf '[%s] vault-reject: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" >> "$LOGFILE" +} + +ACTION_ID="${1:?Usage: vault-reject.sh \"\"}" +REASON="${2:-unspecified}" + +# Find the action file +ACTION_FILE="" +if [ -f "${VAULT_DIR}/pending/${ACTION_ID}.json" ]; then + ACTION_FILE="${VAULT_DIR}/pending/${ACTION_ID}.json" +elif [ -f "${VAULT_DIR}/approved/${ACTION_ID}.json" ]; then + ACTION_FILE="${VAULT_DIR}/approved/${ACTION_ID}.json" +else + log "ERROR: action $ACTION_ID not found in pending/ or approved/" + exit 1 +fi + +ACTION_TYPE=$(jq -r '.type // "unknown"' < "$ACTION_FILE" 2>/dev/null) +ACTION_SOURCE=$(jq -r '.source // "unknown"' < "$ACTION_FILE" 2>/dev/null) + +# Update with rejection metadata and move to rejected/ +TMP=$(mktemp) +jq --arg reason "$REASON" --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + '.status = "rejected" | .rejected_at = $ts | .reject_reason = $reason' \ + "$ACTION_FILE" > "$TMP" && mv "$TMP" "${VAULT_DIR}/rejected/${ACTION_ID}.json" +rm -f "$ACTION_FILE" + +# Clean up lock if present +rm -f "${VAULT_DIR}/.locks/${ACTION_ID}.lock" + +log "$ACTION_ID: rejected — $REASON" +matrix_send "vault" "🚫 Vault rejected: ${ACTION_ID} (${ACTION_TYPE} from ${ACTION_SOURCE}) — ${REASON}" 2>/dev/null || true