Files
CASAN/AINative_OKR_CASAN5/packages/casan-harness/scripts/bash/alert-dispatch.sh
T
thanhnvandClaude Opus 4.8 3adc48ed82 feat(plan-01): Phase 4a — make harness location-independent (facade-free capable)
Resolve every root by marker walk-up instead of a fixed depth that only lands on the
app via the .specify compat symlink, so the harness runs correctly when invoked by its
real packages/casan-harness path — proven by a full gate run via that path: 64/0/0.

- 95 scripts/tests: PROJECT_ROOT/ROOT "$SCRIPT_DIR/../.."-style computations -> $CASAN_APP_ROOT.
- 6 leaf scripts (infra-lab, context-validate, secrets-scan, path-guard, toolchain-verify,
  phase2-sourcegen) now source casan-paths + use CASAN_APP_ROOT.
- run-casan4: source casan-paths as a package sibling (facade-independent), PROJECT_ROOT=CASAN_APP_ROOT.
- 8 Python files: project_root()/REPO_ROOT/bundle_root walk UP for the .specify marker
  (control-plane-settings, loop_common, model-call, context-compress, test-integrity,
  bundle-integrity, traceability-matrix; generate-* fixed earlier).
- evidence-pack-build.py + traceability-matrix.py: domain refs -> apps/okr/domain
  (input/, corpus/redteam-vectors.jsonl, traceability-map.json).
- ci-harness-gate.sh: export CASAN_TESTS_DIR/CASAN_TEST_MANIFEST/CASAN_BUNDLE_ROOT so the
  integrity Python resolves via the harness root regardless of invocation path; ROOT=CASAN_APP_ROOT.
- Remove the domain compat symlinks from packages/casan-harness/security (redteam-corpus,
  redteam-vectors, benign-corpus) — packages now holds NO domain data.

Both invocation paths pass (compat facade still present): .specify/... and packages/...

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 10:50:50 +09:00

147 lines
4.6 KiB
Bash

#!/usr/bin/env bash
set -uo pipefail
# CASAN H6 — live alert dispatch (D1).
# Pushes AgentOps alerts to a real HTTP webhook (Slack/Teams/PagerDuty-style
# endpoint) instead of only appending to a local log file. Undelivered alerts
# are queued to a dead-letter file so no alert is silently lost.
#
# Usage:
# alert-dispatch.sh <alert-json-file> dispatch one alert (JSON object)
# alert-dispatch.sh --flush-deadletter retry alerts that failed delivery
#
# Env:
# CASAN_ALERT_WEBHOOK webhook URL (required to dispatch)
# CASAN_ALERT_STRICT=1 delivery failure => exit 1 (fail-loud); default warn
# CASAN_ALERT_DEDUP_WINDOW_S suppress same service/step/type within N s (default 300)
# CASAN_AGENTOPS_DIR state dir override (default .specify/agentops)
#
# Greppable outputs:
# ALERT_DISPATCHED | ALERT_DEDUP_SUPPRESSED | ALERT_DELIVERY_FAILED |
# ALERT_DEADLETTER_FLUSHED | ALERT_WEBHOOK_UNSET
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/casan-paths.sh"
PROJECT_ROOT="$CASAN_APP_ROOT"
AGENTOPS_DIR="${CASAN_AGENTOPS_DIR:-$CASAN_HARNESS_ROOT/agentops}"
STATE="$AGENTOPS_DIR/alert-dispatch-state.jsonl"
DEADLETTER="$AGENTOPS_DIR/alert-deadletter.jsonl"
WEBHOOK="${CASAN_ALERT_WEBHOOK:-}"
STRICT="${CASAN_ALERT_STRICT:-0}"
DEDUP_S="${CASAN_ALERT_DEDUP_WINDOW_S:-300}"
mkdir -p "$AGENTOPS_DIR"
post_payload() { # <json-payload> — 0 = delivered
curl -sS -m 5 --retry 2 --retry-delay 1 \
-H 'Content-Type: application/json' \
-d "$1" "$WEBHOOK" >/dev/null 2>&1
}
if [[ "${1:-}" == "--flush-deadletter" ]]; then
if [[ -z "$WEBHOOK" ]]; then
echo "ALERT_WEBHOOK_UNSET cannot flush dead-letter queue" >&2
exit 1
fi
if [[ ! -s "$DEADLETTER" ]]; then
echo "ALERT_DEADLETTER_FLUSHED redelivered=0 remaining=0"
exit 0
fi
TMP="$DEADLETTER.tmp"
: > "$TMP"
sent=0; kept=0
while IFS= read -r line; do
[[ -z "$line" ]] && continue
if post_payload "$line"; then
sent=$((sent + 1))
else
printf '%s\n' "$line" >> "$TMP"
kept=$((kept + 1))
fi
done < "$DEADLETTER"
mv "$TMP" "$DEADLETTER"
echo "ALERT_DEADLETTER_FLUSHED redelivered=$sent remaining=$kept"
[[ "$kept" -eq 0 ]] || exit 1
exit 0
fi
ALERT_FILE="${1:-}"
if [[ -z "$ALERT_FILE" || ! -f "$ALERT_FILE" ]]; then
echo "Usage: alert-dispatch.sh <alert-json-file> | --flush-deadletter" >&2
exit 64
fi
if [[ -z "$WEBHOOK" ]]; then
echo "ALERT_WEBHOOK_UNSET alert not dispatched (set CASAN_ALERT_WEBHOOK)" >&2
[[ "$STRICT" == "1" ]] && exit 1
exit 0
fi
# Normalize the alert, decide severity, and apply the dedup window.
DECISION="$(python - "$ALERT_FILE" "$STATE" "$DEDUP_S" <<'PY'
import json, sys, time
alert_path, state_path, window = sys.argv[1], sys.argv[2], int(sys.argv[3])
alert = json.load(open(alert_path, encoding="utf-8"))
body = alert.get("body", {}) if isinstance(alert.get("body"), dict) else {}
resource = alert.get("resource", {}) if isinstance(alert.get("resource"), dict) else {}
atype = body.get("alert.type") or alert.get("alert_type") or "unknown"
step = body.get("step.name") or alert.get("step") or "unknown"
service = resource.get("service.name") or alert.get("agent") or "unknown"
critical = {"execution-failed", "circuit-open", "cost-spike", "token-overuse", "audit-gap"}
severity = "CRITICAL" if atype in critical else "WARN"
key = f"{service}/{step}/{atype}"
now = int(time.time())
last = None
try:
with open(state_path, encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if not line:
continue
rec = json.loads(line)
if rec.get("key") == key:
last = rec.get("ts")
except OSError:
pass
if last is not None and now - last < window:
print("SUPPRESS " + key)
raise SystemExit(0)
payload = json.dumps({
"source": "casan-agentops",
"severity": severity,
"alert_type": atype,
"step": step,
"service": service,
"dedup_key": key,
"alert": alert,
})
with open(state_path, "a", encoding="utf-8") as fh:
fh.write(json.dumps({"key": key, "ts": now}) + "\n")
print("SEND " + payload)
PY
)"
case "$DECISION" in
SUPPRESS*)
echo "ALERT_DEDUP_SUPPRESSED key=${DECISION#SUPPRESS } window_s=$DEDUP_S"
exit 0
;;
SEND*)
PAYLOAD="${DECISION#SEND }"
;;
*)
echo "ALERT_DISPATCH_ERROR unparsable alert file: $ALERT_FILE" >&2
exit 1
;;
esac
if post_payload "$PAYLOAD"; then
echo "ALERT_DISPATCHED webhook=$WEBHOOK"
exit 0
fi
printf '%s\n' "$PAYLOAD" >> "$DEADLETTER"
echo "ALERT_DELIVERY_FAILED queued=dead-letter webhook=$WEBHOOK" >&2
[[ "$STRICT" == "1" ]] && exit 1
exit 0