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
|
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
|
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}'"
|
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