Skip to content

Commit cd9d463

Browse files
Gemini CLIclaude
andcommitted
feat: add notification system, compose orchestration, and extended manifest
- Notification system: webhook (HMAC-signed) and Slack transports with event filtering - Dispatcher fans out to configured notifiers for approval_pending, action_denied, emergency_lockdown, skill_completed, secret_leak events - Docker Compose executor: isolated networks, abort-on-container-exit, automatic teardown - Extended skill manifest: platform (docker/docker-compose), compose_file, per-service scopes - Config: added notifications section for webhook/slack channel configuration Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 7d91761 commit cd9d463

File tree

5 files changed

+594
-9
lines changed

5 files changed

+594
-9
lines changed

internal/config/config.go

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,22 @@ import (
1111

1212
// Config represents the main AegisClaw configuration
1313
type Config struct {
14-
Version string `yaml:"version"`
15-
Agent AgentConfig `yaml:"agent"`
16-
Security SecurityConfig `yaml:"security"`
17-
Network NetworkConfig `yaml:"network"`
18-
Registry RegistryConfig `yaml:"registry"`
19-
Telemetry TelemetryConfig `yaml:"telemetry"`
14+
Version string `yaml:"version"`
15+
Agent AgentConfig `yaml:"agent"`
16+
Security SecurityConfig `yaml:"security"`
17+
Network NetworkConfig `yaml:"network"`
18+
Registry RegistryConfig `yaml:"registry"`
19+
Telemetry TelemetryConfig `yaml:"telemetry"`
20+
Notifications []NotificationConfig `yaml:"notifications,omitempty"`
21+
}
22+
23+
// NotificationConfig defines a notification channel.
24+
type NotificationConfig struct {
25+
Type string `yaml:"type"` // "webhook" or "slack"
26+
URL string `yaml:"url,omitempty"` // webhook URL
27+
Secret string `yaml:"secret,omitempty"` // HMAC signing secret
28+
WebhookURL string `yaml:"webhook_url,omitempty"` // Slack webhook URL
29+
Events []string `yaml:"events"` // events to subscribe to
2030
}
2131

2232
// TelemetryConfig contains observability settings

internal/notifications/notifier.go

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
// Package notifications provides event alerting for AegisClaw.
2+
// It supports webhook and Slack transports, dispatching events like
3+
// pending approvals, denied actions, and emergency lockdowns.
4+
package notifications
5+
6+
import (
7+
"bytes"
8+
"context"
9+
"crypto/hmac"
10+
"crypto/sha256"
11+
"encoding/hex"
12+
"encoding/json"
13+
"fmt"
14+
"net/http"
15+
"time"
16+
)
17+
18+
// Event represents a notification event type.
19+
type Event string
20+
21+
const (
22+
EventApprovalPending Event = "approval_pending"
23+
EventActionDenied Event = "action_denied"
24+
EventEmergencyLockdown Event = "emergency_lockdown"
25+
EventSkillCompleted Event = "skill_completed"
26+
EventSecretLeakDetected Event = "secret_leak"
27+
)
28+
29+
// Payload carries the notification data.
30+
type Payload struct {
31+
Event Event `json:"event"`
32+
Timestamp time.Time `json:"timestamp"`
33+
Skill string `json:"skill,omitempty"`
34+
Command string `json:"command,omitempty"`
35+
Decision string `json:"decision,omitempty"`
36+
Scopes []string `json:"scopes,omitempty"`
37+
Details map[string]any `json:"details,omitempty"`
38+
}
39+
40+
// Notifier is the interface for sending notifications.
41+
type Notifier interface {
42+
Send(ctx context.Context, payload Payload) error
43+
Handles(event Event) bool
44+
}
45+
46+
// Dispatcher fans out notifications to all registered notifiers.
47+
type Dispatcher struct {
48+
notifiers []Notifier
49+
}
50+
51+
// NewDispatcher creates a dispatcher from configuration.
52+
func NewDispatcher(configs []NotifierConfig) *Dispatcher {
53+
d := &Dispatcher{}
54+
for _, cfg := range configs {
55+
switch cfg.Type {
56+
case "webhook":
57+
d.notifiers = append(d.notifiers, NewWebhookNotifier(cfg.URL, cfg.Secret, cfg.Events))
58+
case "slack":
59+
d.notifiers = append(d.notifiers, NewSlackNotifier(cfg.WebhookURL, cfg.Events))
60+
}
61+
}
62+
return d
63+
}
64+
65+
// Notify sends a payload to all notifiers that handle this event type.
66+
func (d *Dispatcher) Notify(ctx context.Context, payload Payload) {
67+
for _, n := range d.notifiers {
68+
if n.Handles(payload.Event) {
69+
// Fire and forget — don't block the caller.
70+
go n.Send(ctx, payload)
71+
}
72+
}
73+
}
74+
75+
// NotifierConfig represents a notification channel from config.yaml.
76+
type NotifierConfig struct {
77+
Type string `yaml:"type"`
78+
URL string `yaml:"url,omitempty"`
79+
Secret string `yaml:"secret,omitempty"`
80+
WebhookURL string `yaml:"webhook_url,omitempty"`
81+
Events []Event `yaml:"events"`
82+
}
83+
84+
// --- Webhook Notifier ---
85+
86+
// WebhookNotifier sends HMAC-signed HTTP POST payloads.
87+
type WebhookNotifier struct {
88+
url string
89+
secret string
90+
events map[Event]bool
91+
client *http.Client
92+
}
93+
94+
// NewWebhookNotifier creates a new WebhookNotifier.
95+
func NewWebhookNotifier(url, secret string, events []Event) *WebhookNotifier {
96+
m := make(map[Event]bool)
97+
for _, e := range events {
98+
m[e] = true
99+
}
100+
return &WebhookNotifier{
101+
url: url,
102+
secret: secret,
103+
events: m,
104+
client: &http.Client{Timeout: 10 * time.Second},
105+
}
106+
}
107+
108+
// Handles returns true if this notifier is subscribed to the event.
109+
func (w *WebhookNotifier) Handles(event Event) bool {
110+
return len(w.events) == 0 || w.events[event]
111+
}
112+
113+
// Send dispatches the payload via HTTP POST with HMAC signature.
114+
func (w *WebhookNotifier) Send(ctx context.Context, payload Payload) error {
115+
body, err := json.Marshal(payload)
116+
if err != nil {
117+
return fmt.Errorf("webhook: marshal failed: %w", err)
118+
}
119+
120+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, w.url, bytes.NewReader(body))
121+
if err != nil {
122+
return fmt.Errorf("webhook: request creation failed: %w", err)
123+
}
124+
req.Header.Set("Content-Type", "application/json")
125+
req.Header.Set("User-Agent", "AegisClaw-Notification/1.0")
126+
127+
if w.secret != "" {
128+
mac := hmac.New(sha256.New, []byte(w.secret))
129+
mac.Write(body)
130+
sig := hex.EncodeToString(mac.Sum(nil))
131+
req.Header.Set("X-AegisClaw-Signature", sig)
132+
}
133+
134+
resp, err := w.client.Do(req)
135+
if err != nil {
136+
return fmt.Errorf("webhook: send failed: %w", err)
137+
}
138+
defer resp.Body.Close()
139+
140+
if resp.StatusCode >= 400 {
141+
return fmt.Errorf("webhook: server returned %d", resp.StatusCode)
142+
}
143+
return nil
144+
}
145+
146+
// --- Slack Notifier ---
147+
148+
// SlackNotifier sends messages to a Slack incoming webhook.
149+
type SlackNotifier struct {
150+
webhookURL string
151+
events map[Event]bool
152+
client *http.Client
153+
}
154+
155+
// NewSlackNotifier creates a new SlackNotifier.
156+
func NewSlackNotifier(webhookURL string, events []Event) *SlackNotifier {
157+
m := make(map[Event]bool)
158+
for _, e := range events {
159+
m[e] = true
160+
}
161+
return &SlackNotifier{
162+
webhookURL: webhookURL,
163+
events: m,
164+
client: &http.Client{Timeout: 10 * time.Second},
165+
}
166+
}
167+
168+
// Handles returns true if this notifier is subscribed to the event.
169+
func (s *SlackNotifier) Handles(event Event) bool {
170+
return len(s.events) == 0 || s.events[event]
171+
}
172+
173+
// Send dispatches the notification as a Slack message.
174+
func (s *SlackNotifier) Send(ctx context.Context, payload Payload) error {
175+
msg := formatSlackMessage(payload)
176+
177+
body, err := json.Marshal(msg)
178+
if err != nil {
179+
return fmt.Errorf("slack: marshal failed: %w", err)
180+
}
181+
182+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.webhookURL, bytes.NewReader(body))
183+
if err != nil {
184+
return fmt.Errorf("slack: request creation failed: %w", err)
185+
}
186+
req.Header.Set("Content-Type", "application/json")
187+
188+
resp, err := s.client.Do(req)
189+
if err != nil {
190+
return fmt.Errorf("slack: send failed: %w", err)
191+
}
192+
defer resp.Body.Close()
193+
194+
if resp.StatusCode >= 400 {
195+
return fmt.Errorf("slack: server returned %d", resp.StatusCode)
196+
}
197+
return nil
198+
}
199+
200+
type slackMessage struct {
201+
Text string `json:"text"`
202+
Blocks json.RawMessage `json:"blocks,omitempty"`
203+
}
204+
205+
func formatSlackMessage(p Payload) slackMessage {
206+
var icon, title string
207+
switch p.Event {
208+
case EventApprovalPending:
209+
icon = ":warning:"
210+
title = "Approval Pending"
211+
case EventActionDenied:
212+
icon = ":no_entry:"
213+
title = "Action Denied"
214+
case EventEmergencyLockdown:
215+
icon = ":rotating_light:"
216+
title = "EMERGENCY LOCKDOWN"
217+
case EventSkillCompleted:
218+
icon = ":white_check_mark:"
219+
title = "Skill Completed"
220+
case EventSecretLeakDetected:
221+
icon = ":lock:"
222+
title = "Secret Leak Detected"
223+
default:
224+
icon = ":bell:"
225+
title = string(p.Event)
226+
}
227+
228+
text := fmt.Sprintf("%s *AegisClaw — %s*", icon, title)
229+
if p.Skill != "" {
230+
text += fmt.Sprintf("\nSkill: `%s`", p.Skill)
231+
}
232+
if p.Command != "" {
233+
text += fmt.Sprintf(" | Command: `%s`", p.Command)
234+
}
235+
if p.Decision != "" {
236+
text += fmt.Sprintf("\nDecision: `%s`", p.Decision)
237+
}
238+
239+
return slackMessage{Text: text}
240+
}

0 commit comments

Comments
 (0)