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
|
|
@ -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 <id> or REJECT <id> 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 <id> or REJECT <id>" "$THREAD_ROOT" >/dev/null 2>&1 || true
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
log "no handler for agent '${AGENT}'"
|
||||
;;
|
||||
|
|
|
|||
0
vault/.locks/.gitkeep
Normal file
0
vault/.locks/.gitkeep
Normal file
90
vault/PROMPT.md
Normal file
90
vault/PROMPT.md
Normal file
|
|
@ -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 <action-id>
|
||||
```
|
||||
|
||||
### Escalate via Matrix
|
||||
```bash
|
||||
matrix_send "vault" "🔒 VAULT — approval required
|
||||
|
||||
Source: <source>
|
||||
Type: <type>
|
||||
Risk: <risk> / <reversible|irreversible>
|
||||
Created: <created>
|
||||
|
||||
<one-line summary of what the action does>
|
||||
|
||||
Reply APPROVE <id> or REJECT <id>" 2>/dev/null
|
||||
```
|
||||
|
||||
### Reject
|
||||
```bash
|
||||
bash ${FACTORY_ROOT}/vault/vault-reject.sh <action-id> "<reason>"
|
||||
```
|
||||
|
||||
## Output Format
|
||||
|
||||
After processing each action, print exactly:
|
||||
|
||||
```
|
||||
ROUTE: <action-id> → <auto-approve|escalate|reject> — <reason>
|
||||
```
|
||||
|
||||
## 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.
|
||||
0
vault/approved/.gitkeep
Normal file
0
vault/approved/.gitkeep
Normal file
0
vault/fired/.gitkeep
Normal file
0
vault/fired/.gitkeep
Normal file
0
vault/pending/.gitkeep
Normal file
0
vault/pending/.gitkeep
Normal file
0
vault/rejected/.gitkeep
Normal file
0
vault/rejected/.gitkeep
Normal file
91
vault/vault-agent.sh
Executable file
91
vault/vault-agent.sh
Executable file
|
|
@ -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 <action-id>
|
||||
- vault-reject.sh: bash ${VAULT_DIR}/vault-reject.sh <action-id> \"<reason>\"
|
||||
- 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
|
||||
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
|
||||
168
vault/vault-poll.sh
Executable file
168
vault/vault-poll.sh
Executable file
|
|
@ -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"
|
||||
46
vault/vault-reject.sh
Executable file
46
vault/vault-reject.sh
Executable file
|
|
@ -0,0 +1,46 @@
|
|||
#!/usr/bin/env bash
|
||||
# vault-reject.sh — Move a vault action to rejected/ with reason
|
||||
#
|
||||
# Usage: bash vault-reject.sh <action-id> "<reason>"
|
||||
|
||||
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 <action-id> \"<reason>\"}"
|
||||
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue