notify

package
v0.1.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 27, 2026 License: Apache-2.0 Imports: 18 Imported by: 0

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

View Source
const (
	FormatJSON  = ""      // the Event struct verbatim
	FormatSlack = "slack" // Slack/Discord-compatible {"text": ...}
)

Rendering formats a webhook target can request.

View Source
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.

View Source
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.

View Source
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).

View Source
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.

View Source
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

View Source
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

func LoadConfig(path string) (*Config, error)

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

type Notifier interface {
	Name() string
	Notify(ctx context.Context, ev Event) error
}

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.

func (*Webhook) Name

func (w *Webhook) Name() string

func (*Webhook) Notify

func (w *Webhook) Notify(ctx context.Context, ev Event) error

Notify delivers one event. Every error it returns names the target and omits the URL: a Slack or Discord webhook URL grants anyone holding it the ability to post, and error strings end up in logs and issue trackers.

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL