Documentation
¶
Overview ¶
Package notify delivers gate events to operators.
The gate fails closed: a new or changed capability is withheld until a human approves it. Without a signal, the observed symptom is "my tool vanished" and nobody knows to look. Notification is therefore availability-critical — and must never become integrity-critical.
The shape of the package follows from that second half. Events are queued by an INSERT inside the transaction that records the manifest (see database.Store.EnqueueNotification); everything network-facing happens later, in a Dispatcher goroutine with its own context. No exported function here is called from a request path, so no target — slow, broken, or hostile — can block, delay, or crash a gate decision.
Index ¶
Constants ¶
const ( FormatJSON = "" // the Event struct verbatim FormatSlack = "slack" // Slack/Discord-compatible {"text": ...} )
Rendering formats a webhook target can request.
const ( HeaderSignature = "X-MCPShield-Signature" HeaderTimestamp = "X-MCPShield-Timestamp" )
Signature headers. The scheme is the Stripe/GitHub webhook pattern: sha256=hex(HMAC-SHA256(secret, timestamp + "." + body)). Binding the timestamp into the MAC is what makes the skew check meaningful — an attacker replaying a captured body cannot move it forward in time without invalidating the signature.
const DefaultMaxAttempts = 6
DefaultMaxAttempts is how many failed deliveries a target gets before the dispatcher stops retrying and the row becomes visible as permanently failed. Six attempts spans roughly 15 hours on the default backoff.
const MaxClockSkew = 5 * time.Minute
MaxClockSkew is how far a payload's timestamp may be from the receiver's clock, in either direction. Beyond it the payload is a replay (or the clocks disagree badly enough that replay protection is not working).
const MaxRequestTimeout = 10 * time.Second
MaxRequestTimeout is the hard ceiling on one delivery attempt. It is a cap, not a default: no configuration can raise it, because a target that can hold a connection open indefinitely is a target that can starve the dispatcher.
const SchemaVersion = 1
SchemaVersion is the version of the Event payload. Receivers should reject payloads whose schema they do not recognise rather than guessing.
Variables ¶
var ErrSignatureInvalid = errors.New("notify: signature verification failed")
ErrSignatureInvalid is returned by VerifySignature for any payload that fails authentication, whatever the reason. Receivers should treat every failure identically; distinguishing "bad MAC" from "stale" tells an attacker which half to work on.
Functions ¶
func VerifySignature ¶
func VerifySignature(secret, timestamp string, body []byte, signatureHeader string, now time.Time) error
VerifySignature authenticates a received payload: constant-time MAC comparison plus a MaxClockSkew freshness window. It lives here, exported and tested, so the verification snippet in docs/notifications.md is code that runs in CI rather than prose nobody checked.
timestamp and signature are the raw header values; now is the receiver's clock (time.Now() in production, fixed in tests).
Types ¶
type Config ¶
type Config struct {
Webhooks []WebhookConfig `json:"webhooks"`
Events []string `json:"events"` // event types to deliver; default ["manifest.pending"]
MaxAttempts int `json:"max_attempts"` // default DefaultMaxAttempts
// DashboardURL is the externally reachable base URL of the approval
// dashboard, used to build the deep link in each event. Empty omits the
// link rather than guessing a hostname the approver cannot reach.
DashboardURL string `json:"dashboard_url"`
}
Config is the notification configuration, loaded from a file that holds webhook URLs and HMAC secrets — both capability-bearing credentials. Keep it out of version control and mode 0600.
func LoadConfig ¶
LoadConfig reads the notification config. A missing file is not an error: notifications are opt-in, and an operator who never configured them gets (nil, nil) — disabled.
Everything else is an error at startup. A target the dispatcher cannot use is worse than no target at all, because it looks configured; the whole point of this feature is that nobody silently hears nothing.
type Dispatcher ¶
type Dispatcher struct {
// contains filtered or unexported fields
}
Dispatcher drains the notification outbox to its targets.
It is the only part of this package that does network I/O, and nothing in a gate decision's path ever calls it. It owns its own context and goroutine, recovers from panicking targets, and treats every delivery failure as "retry later" — there is no error it can produce that reaches the gate.
func NewDispatcher ¶
func NewDispatcher(store database.Store, targets []Notifier, cfg *Config) *Dispatcher
NewDispatcher builds a dispatcher for the configured targets. cfg must be non-nil; a nil config means notifications are disabled, and the caller should not build a dispatcher at all.
func (*Dispatcher) Run ¶
func (d *Dispatcher) Run(ctx context.Context)
Run polls the outbox until ctx is cancelled. Call it in a goroutine; it returns only on cancellation.
type Event ¶
type Event struct {
Schema int `json:"schema"`
Event string `json:"event"`
EventID int64 `json:"event_id"` // outbox row id: the receiver's idempotency key
Server string `json:"server"`
ManifestID int64 `json:"manifest_id"`
Hash string `json:"hash"`
Changes []string `json:"changes"` // diff.Summarize lines
DashboardURL string `json:"dashboard_url,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
Event is the delivered payload. It is composed from the manifest at delivery time rather than stored, so changing this struct never requires migrating queued outbox rows.
There is deliberately no risk field: risk classification was removed from this project, and a field here would reintroduce it as a public contract.
type Notifier ¶
Notifier is one delivery target. Implementations must be safe to call concurrently and must return rather than block indefinitely; the dispatcher bounds them with a context, but a target that ignores it will hold a dispatcher slot until its own timeout fires.
Name identifies the target in logs and errors. It must never be derived from the target's URL: a Slack or Discord webhook URL is itself a capability-bearing credential.
func NewWebhooks ¶
func NewWebhooks(cfgs []WebhookConfig) []Notifier
NewWebhooks builds one target per configured webhook.
type Webhook ¶
type Webhook struct {
// contains filtered or unexported fields
}
Webhook POSTs events to one HTTP endpoint. It is safe for concurrent use.
func NewWebhook ¶
func NewWebhook(cfg WebhookConfig) *Webhook
NewWebhook builds a target from its configuration. The timeout is fixed at MaxRequestTimeout; the field exists so tests can shorten it, never so configuration can lengthen it.
type WebhookConfig ¶
type WebhookConfig struct {
Name string `json:"name"`
URL string `json:"url"`
Secret string `json:"secret"`
Format string `json:"format"`
}
WebhookConfig describes one delivery target. URL and Secret run through os.ExpandEnv, so the file can name environment variables instead of embedding the credentials.