@matt82198/aesop 0.1.0-beta.3 → 0.1.0-beta.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +79 -95
- package/CLAUDE-TEMPLATE.md +115 -0
- package/README.md +126 -245
- package/aesop.config.example.json +34 -26
- package/bin/CLAUDE.md +67 -0
- package/bin/cli.js +593 -36
- package/daemons/CLAUDE.md +36 -0
- package/daemons/backup-fleet.sh +92 -21
- package/daemons/run-watchdog.sh +95 -12
- package/dash/CLAUDE.md +32 -0
- package/dash/dash-extra.mjs +194 -21
- package/dash/watchdog-gui.sh +23 -6
- package/docs/BEHAVIORAL-PR-REVIEW.md +86 -0
- package/docs/CHECKPOINTING.md +100 -0
- package/docs/DISPATCH-MODEL.md +11 -7
- package/docs/FORENSICS.md +204 -0
- package/docs/GOVERNANCE.md +4 -2
- package/docs/HOOK-INSTALL.md +307 -0
- package/docs/HOW-THE-LOOP-WORKS.md +175 -0
- package/docs/MEMORY-TEMPLATE.md +61 -0
- package/docs/README.md +44 -0
- package/docs/RELEASING.md +159 -0
- package/docs/RELIABILITY.md +61 -0
- package/docs/RESTORE.md +397 -0
- package/docs/SCRIPTS-POLICY.md +97 -0
- package/docs/archive/README.md +3 -0
- package/docs/archive/spikes/tiered-cognition/ACTIVATION.md +125 -0
- package/docs/archive/spikes/tiered-cognition/DESIGN.md +287 -0
- package/docs/archive/spikes/tiered-cognition/FINDINGS.md +113 -0
- package/docs/archive/spikes/tiered-cognition/README.md +27 -0
- package/docs/archive/spikes/tiered-cognition/aesop-cognition.example.md +32 -0
- package/docs/archive/spikes/tiered-cognition/force-model-policy.merged.mjs +673 -0
- package/docs/archive/spikes/tiered-cognition/strip-tools-hook.mjs +434 -0
- package/docs/case-study-portfolio.md +61 -0
- package/docs/self-stats-data.json +11 -0
- package/docs/templates/FLEET-OPS-ANALYSIS.example.md +27 -0
- package/docs/templates/FLEET-OPS-RECOMMENDATIONS.example.md +24 -0
- package/docs/templates/PROPOSALS-LOG.example.md +64 -0
- package/hooks/CLAUDE.md +61 -0
- package/hooks/claude/force-model-policy.mjs +159 -0
- package/hooks/pre-push-policy.sh +753 -0
- package/mcp/CLAUDE.md +213 -0
- package/mcp/package.json +26 -0
- package/mcp/server.mjs +543 -0
- package/monitor/.signal-state.json +3 -0
- package/monitor/ACTIONS.log +1 -0
- package/monitor/BRIEF.md +24 -0
- package/monitor/CHARTER.md +75 -88
- package/monitor/CLAUDE.md +40 -0
- package/monitor/SIGNALS.json +54 -0
- package/monitor/collect-signals.mjs +613 -61
- package/package.json +15 -3
- package/scan/fleet-scan.example.mjs +292 -0
- package/skills/CLAUDE.md +3 -0
- package/skills/healthcheck/SKILL.md +44 -0
- package/skills/power/SKILL.md +161 -0
- package/state_store/CLAUDE.md +39 -0
- package/state_store/__init__.py +24 -0
- package/state_store/__pycache__/__init__.cpython-314.pyc +0 -0
- package/state_store/__pycache__/api.cpython-314.pyc +0 -0
- package/state_store/__pycache__/export.cpython-314.pyc +0 -0
- package/state_store/__pycache__/ingest.cpython-314.pyc +0 -0
- package/state_store/__pycache__/projections.cpython-314.pyc +0 -0
- package/state_store/__pycache__/store.cpython-314.pyc +0 -0
- package/state_store/api.py +35 -0
- package/state_store/export.py +19 -0
- package/state_store/ingest.py +24 -0
- package/state_store/projections.py +52 -0
- package/state_store/store.py +102 -0
- package/tools/CLAUDE.md +72 -0
- package/tools/__pycache__/alert_bridge.cpython-314.pyc +0 -0
- package/tools/__pycache__/buildlog.cpython-314.pyc +0 -0
- package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
- package/tools/__pycache__/ensure_state.cpython-314.pyc +0 -0
- package/tools/__pycache__/eod_sweep.cpython-314.pyc +0 -0
- package/tools/__pycache__/fleet_ledger.cpython-314.pyc +0 -0
- package/tools/__pycache__/fleet_prompt_extractor.cpython-314.pyc +0 -0
- package/tools/__pycache__/healthcheck.cpython-314.pyc +0 -0
- package/tools/__pycache__/heartbeat.cpython-314.pyc +0 -0
- package/tools/__pycache__/inbox_drain.cpython-314.pyc +0 -0
- package/tools/__pycache__/launch_tui.cpython-314.pyc +0 -0
- package/tools/__pycache__/metrics_gate.cpython-314.pyc +0 -0
- package/tools/__pycache__/orchestrator_status.cpython-314.pyc +0 -0
- package/tools/__pycache__/power_selftest.cpython-314.pyc +0 -0
- package/tools/__pycache__/prepublish_scan.cpython-314.pyc +0 -0
- package/tools/__pycache__/rotate_logs.cpython-314.pyc +0 -0
- package/tools/__pycache__/scanner_selftest.cpython-314.pyc +0 -0
- package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
- package/tools/__pycache__/self_stats.cpython-314.pyc +0 -0
- package/tools/__pycache__/session_usage_summary.cpython-314.pyc +0 -0
- package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
- package/tools/__pycache__/transcript_replay.cpython-314.pyc +0 -0
- package/tools/__pycache__/transcript_timeline.cpython-314.pyc +0 -0
- package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
- package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
- package/tools/agent-forensics.sh +186 -0
- package/tools/alert_bridge.py +449 -0
- package/tools/buildlog.py +182 -0
- package/tools/ci_merge_wait.py +317 -0
- package/tools/ensure_state.py +94 -0
- package/tools/eod_sweep.py +239 -0
- package/tools/fleet_ledger.py +272 -0
- package/tools/fleet_prompt_extractor.py +134 -0
- package/tools/healthcheck.py +296 -0
- package/tools/heartbeat.py +131 -0
- package/tools/inbox_drain.py +249 -0
- package/tools/launch_tui.py +51 -3
- package/tools/lock.mjs +211 -0
- package/tools/metrics_gate.py +205 -0
- package/tools/orchestrator_status.py +99 -0
- package/tools/power_selftest.py +386 -0
- package/tools/prepublish_scan.py +84 -0
- package/tools/proposals.mjs +248 -0
- package/tools/reconstitute.sh +467 -0
- package/tools/rotate_logs.py +228 -0
- package/tools/scanner_selftest.py +303 -0
- package/tools/secret_scan.py +138 -31
- package/tools/self_stats.py +509 -0
- package/tools/session_usage_summary.py +198 -0
- package/tools/stall_check.py +172 -0
- package/tools/svg_to_png.mjs +50 -0
- package/tools/transcript_replay.py +236 -0
- package/tools/transcript_timeline.py +184 -0
- package/tools/verify_dash.py +513 -0
- package/tools/verify_submit_encoding.py +202 -0
- package/ui/CLAUDE.md +125 -0
- package/ui/__pycache__/agents.cpython-314.pyc +0 -0
- package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
- package/ui/__pycache__/config.cpython-314.pyc +0 -0
- package/ui/__pycache__/cost.cpython-314.pyc +0 -0
- package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
- package/ui/__pycache__/handler.cpython-314.pyc +0 -0
- package/ui/__pycache__/render.cpython-314.pyc +0 -0
- package/ui/__pycache__/serve.cpython-314.pyc +0 -0
- package/ui/__pycache__/sse.cpython-314.pyc +0 -0
- package/ui/agents.py +183 -0
- package/ui/api/__init__.py +84 -0
- package/ui/api/__pycache__/__init__.cpython-314.pyc +0 -0
- package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
- package/ui/api/__pycache__/tracker.cpython-314.pyc +0 -0
- package/ui/api/submit.py +97 -0
- package/ui/api/tracker.py +116 -0
- package/ui/collectors.py +735 -0
- package/ui/config.py +123 -0
- package/ui/cost.py +260 -0
- package/ui/csrf.py +144 -0
- package/ui/handler.py +676 -0
- package/ui/render.py +49 -0
- package/ui/serve.py +68 -796
- package/ui/sse.py +183 -0
- package/ui/web/.gitattributes +13 -0
- package/ui/web/dist/assets/index-2LZDQirC.js +9 -0
- package/ui/web/dist/assets/index-D4M1qyOv.css +1 -0
- package/ui/web/dist/index.html +14 -0
- package/ui/web/index.html +13 -0
- package/ui/web/package-lock.json +2225 -0
- package/ui/web/package.json +26 -0
- package/ui/web/src/App.test.tsx +74 -0
- package/ui/web/src/App.tsx +142 -0
- package/ui/web/src/CONTRIBUTING-UI.md +49 -0
- package/ui/web/src/components/AgentRow.css +187 -0
- package/ui/web/src/components/AgentRow.test.tsx +209 -0
- package/ui/web/src/components/AgentRow.tsx +207 -0
- package/ui/web/src/components/AgentsPanel.css +108 -0
- package/ui/web/src/components/AgentsPanel.test.tsx +41 -0
- package/ui/web/src/components/AgentsPanel.tsx +58 -0
- package/ui/web/src/components/AlertsPanel.css +88 -0
- package/ui/web/src/components/AlertsPanel.test.tsx +51 -0
- package/ui/web/src/components/AlertsPanel.tsx +67 -0
- package/ui/web/src/components/BacklogPanel.test.tsx +126 -0
- package/ui/web/src/components/BacklogPanel.tsx +122 -0
- package/ui/web/src/components/CostChart.css +110 -0
- package/ui/web/src/components/CostChart.test.tsx +144 -0
- package/ui/web/src/components/CostChart.tsx +152 -0
- package/ui/web/src/components/CostTable.css +93 -0
- package/ui/web/src/components/CostTable.test.tsx +165 -0
- package/ui/web/src/components/CostTable.tsx +94 -0
- package/ui/web/src/components/EventsFeed.css +68 -0
- package/ui/web/src/components/EventsFeed.test.tsx +36 -0
- package/ui/web/src/components/EventsFeed.tsx +31 -0
- package/ui/web/src/components/HealthHeader.css +137 -0
- package/ui/web/src/components/HealthHeader.test.tsx +278 -0
- package/ui/web/src/components/HealthHeader.tsx +281 -0
- package/ui/web/src/components/InboxForm.css +135 -0
- package/ui/web/src/components/InboxForm.test.tsx +208 -0
- package/ui/web/src/components/InboxForm.tsx +116 -0
- package/ui/web/src/components/MessagesTail.module.css +144 -0
- package/ui/web/src/components/MessagesTail.test.tsx +176 -0
- package/ui/web/src/components/MessagesTail.tsx +94 -0
- package/ui/web/src/components/ReposPanel.css +90 -0
- package/ui/web/src/components/ReposPanel.test.tsx +45 -0
- package/ui/web/src/components/ReposPanel.tsx +67 -0
- package/ui/web/src/components/Scorecard.css +106 -0
- package/ui/web/src/components/Scorecard.test.tsx +117 -0
- package/ui/web/src/components/Scorecard.tsx +85 -0
- package/ui/web/src/components/Timeline.module.css +151 -0
- package/ui/web/src/components/Timeline.test.tsx +215 -0
- package/ui/web/src/components/Timeline.tsx +99 -0
- package/ui/web/src/components/TrackerBoard.test.tsx +121 -0
- package/ui/web/src/components/TrackerBoard.tsx +107 -0
- package/ui/web/src/components/TrackerCard.test.tsx +180 -0
- package/ui/web/src/components/TrackerCard.tsx +160 -0
- package/ui/web/src/components/TrackerForm.test.tsx +189 -0
- package/ui/web/src/components/TrackerForm.tsx +144 -0
- package/ui/web/src/lib/api.ts +218 -0
- package/ui/web/src/lib/format.test.ts +89 -0
- package/ui/web/src/lib/format.ts +103 -0
- package/ui/web/src/lib/sanitizeUrl.test.ts +84 -0
- package/ui/web/src/lib/sanitizeUrl.ts +38 -0
- package/ui/web/src/lib/types.ts +230 -0
- package/ui/web/src/lib/useHashRoute.test.ts +60 -0
- package/ui/web/src/lib/useHashRoute.ts +23 -0
- package/ui/web/src/lib/useSSE.ts +175 -0
- package/ui/web/src/main.tsx +10 -0
- package/ui/web/src/styles/global.css +179 -0
- package/ui/web/src/styles/theme.css +184 -0
- package/ui/web/src/styles/work.css +572 -0
- package/ui/web/src/test/fixtures.ts +385 -0
- package/ui/web/src/test/setup.ts +49 -0
- package/ui/web/src/views/Activity.module.css +43 -0
- package/ui/web/src/views/Activity.test.tsx +89 -0
- package/ui/web/src/views/Activity.tsx +31 -0
- package/ui/web/src/views/Cost.css +87 -0
- package/ui/web/src/views/Cost.test.tsx +142 -0
- package/ui/web/src/views/Cost.tsx +54 -0
- package/ui/web/src/views/Overview.css +51 -0
- package/ui/web/src/views/Overview.test.tsx +76 -0
- package/ui/web/src/views/Overview.tsx +46 -0
- package/ui/web/src/views/Work.test.tsx +82 -0
- package/ui/web/src/views/Work.tsx +79 -0
- package/ui/web/src/vite-env.d.ts +10 -0
- package/ui/web/tsconfig.json +22 -0
- package/ui/web/vite.config.ts +25 -0
- package/ui/web/vitest.config.ts +12 -0
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Aesop Alert Bridge — Slack/Discord webhook integration for fleet alerts.
|
|
4
|
+
|
|
5
|
+
Bridges SECURITY-ALERTS.log and watchdog heartbeat stalls to Slack/Discord webhooks.
|
|
6
|
+
Modes:
|
|
7
|
+
--scan Check SECURITY-ALERTS.log and heartbeat, send alerts (default)
|
|
8
|
+
--test-message Send a test ping to webhook
|
|
9
|
+
--dry-run Print masked payload instead of posting
|
|
10
|
+
|
|
11
|
+
Configuration: reads from aesop.config.json at runtime (not imported at module load).
|
|
12
|
+
alerts: {
|
|
13
|
+
webhook_url: null, # If null/absent: no-op exit 0 (feature opt-in)
|
|
14
|
+
provider: "slack"|"discord",
|
|
15
|
+
min_severity: "LOW"|"MEDIUM"|"HIGH"|"CRITICAL",
|
|
16
|
+
heartbeat_stall_s: 600 # Check heartbeat staleness; null = skip
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
Idempotency: cursor file (state/.alert-bridge-cursor) tracks last sent line.
|
|
20
|
+
Never logs or echoes webhook URL (masked to last 6 chars in output).
|
|
21
|
+
|
|
22
|
+
Stdlib-only (urllib, json, sys, os, pathlib, time).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import sys
|
|
28
|
+
import time
|
|
29
|
+
import urllib.request
|
|
30
|
+
import urllib.error
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ==============================================================================
|
|
35
|
+
# Configuration Loading
|
|
36
|
+
# ==============================================================================
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def load_config():
|
|
40
|
+
"""Load aesop.config.json from current directory, return config dict."""
|
|
41
|
+
config_file = Path("aesop.config.json")
|
|
42
|
+
if not config_file.exists():
|
|
43
|
+
return {}
|
|
44
|
+
try:
|
|
45
|
+
with open(config_file, "r", encoding="utf-8") as f:
|
|
46
|
+
return json.load(f)
|
|
47
|
+
except Exception as e:
|
|
48
|
+
print(f"[alert_bridge] Failed to load config: {e}", file=sys.stderr)
|
|
49
|
+
return {}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get_alerts_config(config):
|
|
53
|
+
"""Extract alerts config from aesop.config.json."""
|
|
54
|
+
alerts = config.get("alerts", {})
|
|
55
|
+
if not isinstance(alerts, dict):
|
|
56
|
+
return {}
|
|
57
|
+
return alerts
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def get_state_root(config):
|
|
61
|
+
"""Get state directory from config or default to ./state."""
|
|
62
|
+
return Path(config.get("state_root", "./state"))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ==============================================================================
|
|
66
|
+
# Severity Levels
|
|
67
|
+
# ==============================================================================
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
SEVERITY_ORDER = {"LOW": 0, "MEDIUM": 1, "HIGH": 2, "CRITICAL": 3}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def parse_severity(text):
|
|
74
|
+
"""Extract severity from alert text (e.g., 'HIGH: ...' or '[2026-01-15] HIGH: ...') or return None."""
|
|
75
|
+
text = text.strip()
|
|
76
|
+
# Handle timestamps like "[2026-01-15 10:30]" at start
|
|
77
|
+
if text.startswith("["):
|
|
78
|
+
end_bracket = text.find("]")
|
|
79
|
+
if end_bracket >= 0:
|
|
80
|
+
text = text[end_bracket + 1:].strip()
|
|
81
|
+
|
|
82
|
+
# Now check for severity prefix
|
|
83
|
+
for severity in SEVERITY_ORDER.keys():
|
|
84
|
+
if text.startswith(severity + ":"):
|
|
85
|
+
return severity
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def should_send_alert(alert_text, min_severity):
|
|
90
|
+
"""Check if alert meets minimum severity threshold."""
|
|
91
|
+
severity = parse_severity(alert_text)
|
|
92
|
+
if severity is None:
|
|
93
|
+
return False # Unknown severity format
|
|
94
|
+
min_level = SEVERITY_ORDER.get(min_severity, 0)
|
|
95
|
+
return SEVERITY_ORDER.get(severity, 0) >= min_level
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ==============================================================================
|
|
99
|
+
# Cursor Management (Idempotency)
|
|
100
|
+
# ==============================================================================
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def get_cursor_path(state_root):
|
|
104
|
+
"""Return cursor file path."""
|
|
105
|
+
return state_root / ".alert-bridge-cursor"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def read_cursor(state_root):
|
|
109
|
+
"""Read last sent line number from cursor file (default 0)."""
|
|
110
|
+
cursor_file = get_cursor_path(state_root)
|
|
111
|
+
if not cursor_file.exists():
|
|
112
|
+
return 0
|
|
113
|
+
try:
|
|
114
|
+
return int(cursor_file.read_text(encoding="utf-8").strip())
|
|
115
|
+
except (ValueError, IOError):
|
|
116
|
+
return 0
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def write_cursor(state_root, line_number):
|
|
120
|
+
"""Write line number to cursor file."""
|
|
121
|
+
cursor_file = get_cursor_path(state_root)
|
|
122
|
+
state_root.mkdir(parents=True, exist_ok=True)
|
|
123
|
+
try:
|
|
124
|
+
cursor_file.write_text(str(line_number), encoding="utf-8")
|
|
125
|
+
except IOError as e:
|
|
126
|
+
print(f"[alert_bridge] Failed to write cursor: {e}", file=sys.stderr)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ==============================================================================
|
|
130
|
+
# Alert Collection
|
|
131
|
+
# ==============================================================================
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def collect_new_alerts(state_root, min_severity, since_line):
|
|
135
|
+
"""
|
|
136
|
+
Read SECURITY-ALERTS.log, filter by severity, return new alerts since cursor.
|
|
137
|
+
|
|
138
|
+
Returns: (new_alerts: list[str], total_lines: int)
|
|
139
|
+
"""
|
|
140
|
+
alerts_file = state_root / "SECURITY-ALERTS.log"
|
|
141
|
+
if not alerts_file.exists():
|
|
142
|
+
return [], 0
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
content = alerts_file.read_text(encoding="utf-8")
|
|
146
|
+
except IOError:
|
|
147
|
+
return [], 0
|
|
148
|
+
|
|
149
|
+
lines = content.strip().split("\n") if content.strip() else []
|
|
150
|
+
new_alerts = []
|
|
151
|
+
|
|
152
|
+
for i, line in enumerate(lines, start=1):
|
|
153
|
+
# Skip if before cursor
|
|
154
|
+
if i <= since_line:
|
|
155
|
+
continue
|
|
156
|
+
line = line.strip()
|
|
157
|
+
if not line:
|
|
158
|
+
continue
|
|
159
|
+
# Skip marked entries
|
|
160
|
+
if "NOTE:" in line or "RESOLVED-FP" in line:
|
|
161
|
+
continue
|
|
162
|
+
# Check severity
|
|
163
|
+
if should_send_alert(line, min_severity):
|
|
164
|
+
new_alerts.append(line)
|
|
165
|
+
|
|
166
|
+
return new_alerts, len(lines)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ==============================================================================
|
|
170
|
+
# Heartbeat Staleness Check
|
|
171
|
+
# ==============================================================================
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def check_heartbeat_staleness(state_root, stall_threshold_s):
|
|
175
|
+
"""
|
|
176
|
+
Check if watchdog heartbeat is stale.
|
|
177
|
+
|
|
178
|
+
Returns: (is_stale: bool, heartbeat_info: str or None)
|
|
179
|
+
"""
|
|
180
|
+
if stall_threshold_s is None:
|
|
181
|
+
return False, None
|
|
182
|
+
|
|
183
|
+
hb_file = state_root / ".watchdog-heartbeat"
|
|
184
|
+
if not hb_file.exists():
|
|
185
|
+
return True, "Watchdog heartbeat missing"
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
timestamp = int(hb_file.read_text(encoding="utf-8").strip())
|
|
189
|
+
except (ValueError, IOError):
|
|
190
|
+
return True, "Watchdog heartbeat unreadable"
|
|
191
|
+
|
|
192
|
+
age_seconds = int(time.time()) - timestamp
|
|
193
|
+
if age_seconds >= stall_threshold_s:
|
|
194
|
+
return True, f"Watchdog heartbeat stale ({age_seconds}s >= {stall_threshold_s}s)"
|
|
195
|
+
|
|
196
|
+
return False, None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# ==============================================================================
|
|
200
|
+
# Payload Formatting
|
|
201
|
+
# ==============================================================================
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def format_slack_payload(alerts, heartbeat_info):
|
|
205
|
+
"""Format Slack block-kit payload."""
|
|
206
|
+
blocks = []
|
|
207
|
+
|
|
208
|
+
# Header block
|
|
209
|
+
blocks.append(
|
|
210
|
+
{
|
|
211
|
+
"type": "header",
|
|
212
|
+
"text": {"type": "plain_text", "text": "🚨 Aesop Fleet Alert"},
|
|
213
|
+
}
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
# Section for alerts
|
|
217
|
+
if alerts:
|
|
218
|
+
alert_text = "\n".join([f"• {alert}" for alert in alerts[:10]])
|
|
219
|
+
blocks.append(
|
|
220
|
+
{
|
|
221
|
+
"type": "section",
|
|
222
|
+
"text": {"type": "mrkdwn", "text": alert_text},
|
|
223
|
+
}
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
# Section for heartbeat
|
|
227
|
+
if heartbeat_info:
|
|
228
|
+
blocks.append(
|
|
229
|
+
{
|
|
230
|
+
"type": "section",
|
|
231
|
+
"text": {
|
|
232
|
+
"type": "mrkdwn",
|
|
233
|
+
"text": f"⚠️ *Heartbeat*: {heartbeat_info}",
|
|
234
|
+
},
|
|
235
|
+
}
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
return {"blocks": blocks}
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def format_discord_payload(alerts, heartbeat_info):
|
|
242
|
+
"""Format Discord embed payload."""
|
|
243
|
+
embeds = []
|
|
244
|
+
|
|
245
|
+
description_parts = []
|
|
246
|
+
if alerts:
|
|
247
|
+
for alert in alerts[:10]:
|
|
248
|
+
description_parts.append(alert)
|
|
249
|
+
if heartbeat_info:
|
|
250
|
+
description_parts.append(f"⚠️ **Heartbeat**: {heartbeat_info}")
|
|
251
|
+
|
|
252
|
+
embed = {
|
|
253
|
+
"title": "🚨 Aesop Fleet Alert",
|
|
254
|
+
"description": "\n".join(description_parts) or "Alert triggered",
|
|
255
|
+
"color": 16711680, # Red
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
embeds.append(embed)
|
|
259
|
+
return {"embeds": embeds}
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# ==============================================================================
|
|
263
|
+
# Webhook Sending
|
|
264
|
+
# ==============================================================================
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def mask_webhook_url(webhook_url):
|
|
268
|
+
"""Mask webhook URL to last 6 chars for logging."""
|
|
269
|
+
if not webhook_url or len(webhook_url) < 6:
|
|
270
|
+
return "***"
|
|
271
|
+
return "***" + webhook_url[-6:]
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def send_webhook(webhook_url, payload):
|
|
275
|
+
"""POST payload to webhook URL. Returns (success: bool, status_code: int or None)."""
|
|
276
|
+
if not webhook_url:
|
|
277
|
+
return False, None
|
|
278
|
+
|
|
279
|
+
try:
|
|
280
|
+
data = json.dumps(payload).encode("utf-8")
|
|
281
|
+
req = urllib.request.Request(
|
|
282
|
+
webhook_url,
|
|
283
|
+
data=data,
|
|
284
|
+
headers={"Content-Type": "application/json"},
|
|
285
|
+
method="POST",
|
|
286
|
+
)
|
|
287
|
+
response = urllib.request.urlopen(req, timeout=10)
|
|
288
|
+
status_code = response.getcode()
|
|
289
|
+
return status_code in (200, 204), status_code
|
|
290
|
+
except urllib.error.HTTPError as e:
|
|
291
|
+
return False, e.code
|
|
292
|
+
except Exception as e:
|
|
293
|
+
print(
|
|
294
|
+
f"[alert_bridge] Failed to send webhook: {e}",
|
|
295
|
+
file=sys.stderr,
|
|
296
|
+
)
|
|
297
|
+
return False, None
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
# ==============================================================================
|
|
301
|
+
# Main Modes
|
|
302
|
+
# ==============================================================================
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def mode_scan(config):
|
|
306
|
+
"""Scan SECURITY-ALERTS.log and heartbeat, send alerts if new."""
|
|
307
|
+
alerts_config = get_alerts_config(config)
|
|
308
|
+
state_root = get_state_root(config)
|
|
309
|
+
|
|
310
|
+
webhook_url = alerts_config.get("webhook_url")
|
|
311
|
+
provider = alerts_config.get("provider", "slack")
|
|
312
|
+
min_severity = alerts_config.get("min_severity", "HIGH")
|
|
313
|
+
heartbeat_stall_s = alerts_config.get("heartbeat_stall_s")
|
|
314
|
+
|
|
315
|
+
# No-op if webhook disabled
|
|
316
|
+
if not webhook_url:
|
|
317
|
+
return 0
|
|
318
|
+
|
|
319
|
+
# Read cursor
|
|
320
|
+
cursor = read_cursor(state_root)
|
|
321
|
+
|
|
322
|
+
# Collect new alerts
|
|
323
|
+
new_alerts, total_lines = collect_new_alerts(state_root, min_severity, cursor)
|
|
324
|
+
|
|
325
|
+
# Check heartbeat staleness
|
|
326
|
+
is_stale, heartbeat_info = check_heartbeat_staleness(state_root, heartbeat_stall_s)
|
|
327
|
+
|
|
328
|
+
# Nothing to send
|
|
329
|
+
if not new_alerts and not is_stale:
|
|
330
|
+
return 0
|
|
331
|
+
|
|
332
|
+
# Format payload
|
|
333
|
+
if provider == "discord":
|
|
334
|
+
payload = format_discord_payload(new_alerts, heartbeat_info)
|
|
335
|
+
else: # slack or default
|
|
336
|
+
payload = format_slack_payload(new_alerts, heartbeat_info)
|
|
337
|
+
|
|
338
|
+
# Send webhook
|
|
339
|
+
success, status_code = send_webhook(webhook_url, payload)
|
|
340
|
+
if success:
|
|
341
|
+
# Update cursor only on success
|
|
342
|
+
write_cursor(state_root, total_lines)
|
|
343
|
+
print(
|
|
344
|
+
f"[alert_bridge] Sent {len(new_alerts)} alert(s) to {mask_webhook_url(webhook_url)} (status: {status_code})"
|
|
345
|
+
)
|
|
346
|
+
return 0
|
|
347
|
+
else:
|
|
348
|
+
print(
|
|
349
|
+
f"[alert_bridge] Failed to send webhook (status: {status_code})",
|
|
350
|
+
file=sys.stderr,
|
|
351
|
+
)
|
|
352
|
+
return 1
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def mode_test_message(config):
|
|
356
|
+
"""Send a test ping to webhook."""
|
|
357
|
+
alerts_config = get_alerts_config(config)
|
|
358
|
+
webhook_url = alerts_config.get("webhook_url")
|
|
359
|
+
provider = alerts_config.get("provider", "slack")
|
|
360
|
+
|
|
361
|
+
if not webhook_url:
|
|
362
|
+
print("[alert_bridge] Webhook URL not configured (no-op)", file=sys.stderr)
|
|
363
|
+
return 0
|
|
364
|
+
|
|
365
|
+
# Format test message
|
|
366
|
+
if provider == "discord":
|
|
367
|
+
payload = {"embeds": [{"title": "✅ Alert Bridge Test", "description": "Connection OK"}]}
|
|
368
|
+
else: # slack or default
|
|
369
|
+
payload = {
|
|
370
|
+
"blocks": [
|
|
371
|
+
{"type": "header", "text": {"type": "plain_text", "text": "✅ Alert Bridge Test"}},
|
|
372
|
+
{
|
|
373
|
+
"type": "section",
|
|
374
|
+
"text": {"type": "mrkdwn", "text": "Connection OK"},
|
|
375
|
+
},
|
|
376
|
+
]
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
success, status_code = send_webhook(webhook_url, payload)
|
|
380
|
+
if success:
|
|
381
|
+
print(f"[alert_bridge] Test message sent to {mask_webhook_url(webhook_url)} (status: {status_code})")
|
|
382
|
+
return 0
|
|
383
|
+
else:
|
|
384
|
+
print(
|
|
385
|
+
f"[alert_bridge] Test message failed (status: {status_code})",
|
|
386
|
+
file=sys.stderr,
|
|
387
|
+
)
|
|
388
|
+
return 1
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def mode_dry_run(config):
|
|
392
|
+
"""Print masked payload without sending."""
|
|
393
|
+
alerts_config = get_alerts_config(config)
|
|
394
|
+
state_root = get_state_root(config)
|
|
395
|
+
|
|
396
|
+
webhook_url = alerts_config.get("webhook_url")
|
|
397
|
+
provider = alerts_config.get("provider", "slack")
|
|
398
|
+
min_severity = alerts_config.get("min_severity", "HIGH")
|
|
399
|
+
heartbeat_stall_s = alerts_config.get("heartbeat_stall_s")
|
|
400
|
+
|
|
401
|
+
if not webhook_url:
|
|
402
|
+
print("[alert_bridge] Webhook URL not configured (no-op)", file=sys.stderr)
|
|
403
|
+
return 0
|
|
404
|
+
|
|
405
|
+
cursor = read_cursor(state_root)
|
|
406
|
+
new_alerts, _ = collect_new_alerts(state_root, min_severity, cursor)
|
|
407
|
+
is_stale, heartbeat_info = check_heartbeat_staleness(state_root, heartbeat_stall_s)
|
|
408
|
+
|
|
409
|
+
if not new_alerts and not is_stale:
|
|
410
|
+
print(f"[alert_bridge] No alerts to send (webhook: {mask_webhook_url(webhook_url)})")
|
|
411
|
+
return 0
|
|
412
|
+
|
|
413
|
+
# Format payload
|
|
414
|
+
if provider == "discord":
|
|
415
|
+
payload = format_discord_payload(new_alerts, heartbeat_info)
|
|
416
|
+
else: # slack or default
|
|
417
|
+
payload = format_slack_payload(new_alerts, heartbeat_info)
|
|
418
|
+
|
|
419
|
+
print(f"[alert_bridge] DRY-RUN payload (webhook: {mask_webhook_url(webhook_url)})")
|
|
420
|
+
print(json.dumps(payload, indent=2))
|
|
421
|
+
return 0
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
# ==============================================================================
|
|
425
|
+
# Entry Point
|
|
426
|
+
# ==============================================================================
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def main(args=None):
|
|
430
|
+
"""Main entry point."""
|
|
431
|
+
if args is None:
|
|
432
|
+
args = sys.argv[1:]
|
|
433
|
+
|
|
434
|
+
mode = "--scan" # default
|
|
435
|
+
if args:
|
|
436
|
+
mode = args[0]
|
|
437
|
+
|
|
438
|
+
config = load_config()
|
|
439
|
+
|
|
440
|
+
if mode == "--test-message":
|
|
441
|
+
return mode_test_message(config)
|
|
442
|
+
elif mode == "--dry-run":
|
|
443
|
+
return mode_dry_run(config)
|
|
444
|
+
else: # --scan or default
|
|
445
|
+
return mode_scan(config)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
if __name__ == "__main__":
|
|
449
|
+
sys.exit(main())
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
r"""
|
|
3
|
+
Append uniform BUILDLOG entries with optional git HEAD reference.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
python buildlog.py "<message>" [--state-dir DIR] [--head] [--repo-path PATH]
|
|
7
|
+
|
|
8
|
+
Purpose:
|
|
9
|
+
Ensures consistent BUILDLOG.md formatting across agents: one line per entry,
|
|
10
|
+
timestamped, with optional git HEAD reference. Prevents hand-formatting variance.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
message: Entry text to append (positional, required).
|
|
14
|
+
--state-dir: Path to state directory for BUILDLOG.md (default: AESOP_STATE_ROOT or ./state).
|
|
15
|
+
--head: Include git HEAD (short-hash subject) from specified repo or current directory.
|
|
16
|
+
--repo-path: Path to git repo for HEAD extraction (default: current working directory).
|
|
17
|
+
|
|
18
|
+
Output:
|
|
19
|
+
Prints the exact line appended to stderr; exit 0 always.
|
|
20
|
+
|
|
21
|
+
Behavior:
|
|
22
|
+
- Appends ONE line formatted: ### [YYYY-MM-DD HH:MM] <message>
|
|
23
|
+
- With --head, appends: | HEAD: <short-hash> <subject>
|
|
24
|
+
- If repo not found or not a git repo, omits HEAD and notes "(no-repo)".
|
|
25
|
+
- Creates BUILDLOG.md with append-only header if missing.
|
|
26
|
+
- Never overwrites; always returns the line appended.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
import argparse
|
|
30
|
+
import os
|
|
31
|
+
import subprocess
|
|
32
|
+
import sys
|
|
33
|
+
from datetime import datetime
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_state_dir():
|
|
38
|
+
"""Resolve state directory from env var or current working directory."""
|
|
39
|
+
if os.environ.get("AESOP_STATE_ROOT"):
|
|
40
|
+
return Path(os.environ["AESOP_STATE_ROOT"])
|
|
41
|
+
# Default to ./state (relative to cwd)
|
|
42
|
+
return Path.cwd() / "state"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_git_head(repo_path):
|
|
46
|
+
"""Get short hash and subject from git HEAD.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
repo_path: Path to git repository.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
Tuple of (short_hash, subject) or (None, None) if not a git repo.
|
|
53
|
+
"""
|
|
54
|
+
try:
|
|
55
|
+
if not Path(repo_path / ".git").exists():
|
|
56
|
+
return None, None
|
|
57
|
+
|
|
58
|
+
result = subprocess.run(
|
|
59
|
+
["git", "log", "-1", "--pretty=%h %s"],
|
|
60
|
+
cwd=repo_path,
|
|
61
|
+
capture_output=True,
|
|
62
|
+
text=True,
|
|
63
|
+
timeout=5,
|
|
64
|
+
)
|
|
65
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
66
|
+
parts = result.stdout.strip().split(None, 1)
|
|
67
|
+
if len(parts) == 2:
|
|
68
|
+
return parts[0], parts[1]
|
|
69
|
+
elif len(parts) == 1:
|
|
70
|
+
return parts[0], "(no subject)"
|
|
71
|
+
except Exception:
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
return None, None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def build_entry_line(message, short_hash=None, subject=None):
|
|
78
|
+
"""Format BUILDLOG entry line.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
message: Entry message.
|
|
82
|
+
short_hash: Git short hash (optional).
|
|
83
|
+
subject: Git subject (optional).
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
Formatted entry line.
|
|
87
|
+
"""
|
|
88
|
+
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M")
|
|
89
|
+
line = f"### [{timestamp}] {message}"
|
|
90
|
+
|
|
91
|
+
if short_hash and subject:
|
|
92
|
+
line += f" | HEAD: {short_hash} {subject}"
|
|
93
|
+
elif short_hash:
|
|
94
|
+
line += f" | HEAD: {short_hash} (no subject)"
|
|
95
|
+
|
|
96
|
+
return line
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def ensure_buildlog_header(buildlog_path):
|
|
100
|
+
"""Create BUILDLOG.md with header if missing.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
buildlog_path: Path to BUILDLOG.md file.
|
|
104
|
+
"""
|
|
105
|
+
if buildlog_path.exists():
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
header = "# Build Log (append-only)\n"
|
|
109
|
+
buildlog_path.parent.mkdir(parents=True, exist_ok=True)
|
|
110
|
+
buildlog_path.write_text(header)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def append_entry(buildlog_path, line):
|
|
114
|
+
"""Append line to BUILDLOG.md.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
buildlog_path: Path to BUILDLOG.md file.
|
|
118
|
+
line: Entry line to append.
|
|
119
|
+
"""
|
|
120
|
+
ensure_buildlog_header(buildlog_path)
|
|
121
|
+
|
|
122
|
+
with open(buildlog_path, "a", encoding="utf-8") as f:
|
|
123
|
+
f.write(line + "\n")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def main():
|
|
127
|
+
"""Main entry point."""
|
|
128
|
+
parser = argparse.ArgumentParser(
|
|
129
|
+
description="Append uniform BUILDLOG entries with optional git HEAD reference.",
|
|
130
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
131
|
+
)
|
|
132
|
+
parser.add_argument("message", help="Entry message to append")
|
|
133
|
+
parser.add_argument(
|
|
134
|
+
"--state-dir",
|
|
135
|
+
default=None,
|
|
136
|
+
help="Path to state directory (default: AESOP_STATE_ROOT or ./state)",
|
|
137
|
+
)
|
|
138
|
+
parser.add_argument(
|
|
139
|
+
"--head",
|
|
140
|
+
action="store_true",
|
|
141
|
+
help="Include git HEAD (short-hash subject) reference",
|
|
142
|
+
)
|
|
143
|
+
parser.add_argument(
|
|
144
|
+
"--repo-path",
|
|
145
|
+
default=None,
|
|
146
|
+
help="Path to git repo for HEAD extraction (default: current working directory)",
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
args = parser.parse_args()
|
|
150
|
+
|
|
151
|
+
# Resolve state directory
|
|
152
|
+
if args.state_dir:
|
|
153
|
+
state_dir = Path(args.state_dir)
|
|
154
|
+
else:
|
|
155
|
+
state_dir = get_state_dir()
|
|
156
|
+
|
|
157
|
+
# Derive repo path and get git HEAD if requested
|
|
158
|
+
short_hash = None
|
|
159
|
+
subject = None
|
|
160
|
+
repo_note = ""
|
|
161
|
+
|
|
162
|
+
if args.head:
|
|
163
|
+
repo_path = Path(args.repo_path) if args.repo_path else Path.cwd()
|
|
164
|
+
short_hash, subject = get_git_head(repo_path)
|
|
165
|
+
|
|
166
|
+
if not short_hash:
|
|
167
|
+
repo_note = " (no-repo)"
|
|
168
|
+
|
|
169
|
+
# Build and format entry
|
|
170
|
+
line = build_entry_line(args.message, short_hash, subject)
|
|
171
|
+
|
|
172
|
+
# Append to BUILDLOG.md
|
|
173
|
+
buildlog_path = state_dir / "BUILDLOG.md"
|
|
174
|
+
append_entry(buildlog_path, line)
|
|
175
|
+
|
|
176
|
+
# Print the exact line appended (with repo note if --head requested and failed)
|
|
177
|
+
output_line = line + repo_note if repo_note else line
|
|
178
|
+
print(output_line, file=sys.stderr)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
if __name__ == "__main__":
|
|
182
|
+
main()
|