Help us shape the future of Go! Take the 2026 Go Developer Survey

notify

package
v0.18.0 Latest Latest
Warning

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

Go to latest
Published: Oct 16, 2026 License: Apache-2.0 Imports: 23 Imported by: 0

Documentation

Overview

Package notify ships bide's webhook notifier: a signed HTTPS POST of a structured notification, and the receiver-side Verify that checks one.

The package is stdlib only. A request carries a Bide-Delivery header (the notification ID, the dedupe key, identical on every attempt of an event), a Bide-Timestamp (Unix seconds at send time) and a Bide-Signature: "v1=" and the hex HMAC-SHA256 of timestamp + "." + body, once per secret. The signature is always on. Mutual TLS, a private CA and a key pin are optional and add to it; they never replace it.

The payload is structured fields only. It never carries error text, tool arguments, results or model output unless the operator names them in WithFields, and it carries a view pointer, never a link that approves or resolves anything.

Index

Constants

View Source
const (
	HeaderDelivery  = "Bide-Delivery"
	HeaderTimestamp = "Bide-Timestamp"
	HeaderSignature = "Bide-Signature"
)

Header names of a webhook request.

View Source
const (
	DefaultTimeout = 10 * time.Second // per request
	MaxRequests    = 3                // the most requests one Notify call may make (WithRequestsPerCall)

)

Defaults of a Webhook.

View Source
const DefaultTolerance = 5 * time.Minute

DefaultTolerance is how far a request's timestamp may be from the receiver's clock before Verify rejects it, when the caller passes a tolerance of zero.

View Source
const Schema = "bide.notification.v1"

Schema is the value of the payload's "schema" field. New fields are additive within v1; a receiver must ignore fields it does not know. A breaking change is a new schema string.

Variables

View Source
var (
	ErrNoSignature  = errors.New("notify: missing signature or timestamp header")
	ErrBadTimestamp = errors.New("notify: timestamp is not a Unix time in seconds")
	ErrStale        = errors.New("notify: timestamp outside the tolerance")
	ErrBadSignature = errors.New("notify: no signature matches")
	ErrNoSecrets    = errors.New("notify: no secrets to verify with")
	ErrBodyTooLarge = errors.New("notify: request body too large to verify")
	ErrDeliveryID   = errors.New("notify: Bide-Delivery is missing or does not match the signed id")
)

Verification errors. A receiver can tell them apart, but should answer all of them with the same 4xx so a sender learns nothing about which check failed.

View Source
var ErrorText = Field{func(f *fieldSet) { f.errorText = true }}

ErrorText allows the typed error's text ("error") in the payload. The text can embed tool arguments, account or card numbers and personal data, so a deployment under PCI-style rules should leave it out and put detail behind the viewer, where access control applies.

Functions

func SPKIHash

func SPKIHash(c *x509.Certificate) string

SPKIHash returns the hex SHA-256 of a certificate's SubjectPublicKeyInfo: the value WithServerPin takes, and the value recorded as the client certificate in a delivery's audit record.

func Sign

func Sign(secrets []string, ts int64, body []byte) string

Sign returns the Bide-Signature header value for body sent at ts: one "v1=<hex>" entry per secret, space separated, where each is HMAC-SHA256(secret, ts + "." + body). Empty secrets are skipped. With two secrets (current first, previous second) a receiver holding either verifies the request, which is how a secret is rotated without dropping deliveries.

func Verify

func Verify(r *http.Request, secrets []string, tolerance time.Duration) error

Verify checks a request from a bide webhook in one call, for use in a receiver. It reads the whole body (up to 1 MiB), checks that the Bide-Timestamp is within tolerance of now (zero means DefaultTolerance, 5 minutes), and that at least one v1 signature in Bide-Signature equals the HMAC-SHA256 of timestamp + "." + body under one of secrets, compared in constant time. Pass both the new and the old secret during a rotation.

On return r.Body is replaced by a reader over the same bytes, so the handler can decode it. Verify returns nil only when the request is authentic and fresh. Treat the delivery ID header (Bide-Delivery) as the dedupe key: the same event can be delivered more than once.

func VerifyBody

func VerifyBody(timestamp, signature string, body []byte, secrets []string, tolerance time.Duration, now time.Time) error

VerifyBody is Verify over parts the caller already holds, such as a stored body and its headers when checking a delivery later as evidence. now is the time to judge freshness against; pass the time of receipt, or a very large tolerance to check the signature alone.

Types

type Abort

type Abort struct {
	Reason string `json:"reason"`
}

Abort is the detail of a run_aborted event; Reason is a code from a closed set, not text.

type Approval

type Approval struct {
	ToolUseID string   `json:"tool_use_id"`
	Tool      string   `json:"tool"`
	Policy    Policy   `json:"policy"`
	Approvers []string `json:"approvers"`
	Approved  int      `json:"approved"`
	// Excluded are the approvers who may not decide (separation of duties: requesters of the run,
	// agent.ApprovalPolicy.ExcludePrincipal); omitted when there are none.
	Excluded []string `json:"excluded,omitempty"`
	// Reask says why the gate asks again over a recorded tally (agent.PendingReapproval,
	// agent.PendingSoDReapproval); omitted on the gate's first ask.
	Reask string          `json:"reask,omitempty"`
	Args  json.RawMessage `json:"args,omitempty"` // only with WithFields(Args(tool))
}

Approval is the detail of an approval_pending event: who may decide and how many must.

type Clears added in v0.17.0

type Clears struct {
	ID   string `json:"id"`
	Key  string `json:"key"`
	Kind string `json:"kind"`
}

Clears is the detail of a resolved notification, the all-clear: the event that stopped being current. ID and Key are those of that event's own notifications (their "id" and "key"). An event does not become current again and its key is never reused, so a receiver may treat every delivery of ID, before or after this one, as superseded: the all-clear can arrive first.

type Escalates added in v0.17.0

type Escalates struct {
	ID    string `json:"id"`
	Key   string `json:"key"`
	Kind  string `json:"kind"`
	Level int    `json:"level"`
}

Escalates is the detail of an escalated notification. ID and Key are those of the event's own notifications (their "id" and "key"); Level is the step of the escalation chain, from 1.

type Failing

type Failing struct {
	Cause string `json:"cause"`
}

Failing is the detail of a recovery_failing event; Cause is a class, not text.

type Field

type Field struct {
	// contains filtered or unexported fields
}

A Field is a payload field that is left out unless named in WithFields.

func Args

func Args(tools ...string) Field

Args allows the arguments of an approval-pending call ("approval.args") for the named tools only. Arguments of any other tool are never sent.

type Halt

type Halt struct {
	Cause       string     `json:"cause"`
	AttemptedAt *time.Time `json:"attempted_at"`
}

Halt is the detail of an outcome_unknown event.

type Option

type Option func(*Webhook) error

An Option configures NewWebhook.

func WithClientCert

func WithClientCert(certFile, keyFile string) Option

WithClientCert presents a client certificate for mutual TLS, read from PEM files. The files are re-read when they change (their modification time is checked at most once a minute, and again after a TLS handshake failure), so a rotated or short-lived certificate is picked up without a restart. A read that fails keeps the last good pair and reports the error to the handler set by WithOnError.

func WithClientCertPEM

func WithClientCertPEM(certPEM, keyPEM []byte) Option

WithClientCertPEM is WithClientCert for a pair held in memory, such as one read from the environment. It is not reloaded.

func WithFields

func WithFields(fs ...Field) Option

WithFields allows payload fields that are left out by default. See Field, ErrorText and Args.

func WithOnError

func WithOnError(f func(error)) Option

WithOnError sets where non-fatal problems are reported, such as a client certificate that failed to reload (the last good pair stays in use). Pass the function given to agent.WithRecoverErrors.

func WithProxy

func WithProxy(f func(*http.Request) (*url.URL, error)) Option

WithProxy sets how the webhook reaches the receiver through a proxy (nil means directly). Without it the environment's proxy settings (HTTPS_PROXY, NO_PROXY) apply, except when WithServerCA or WithServerPin is set: a deployment that pins or privately roots its receiver is assumed to mean to reach it directly, so it never silently goes through an environment proxy. Either way the proxy only tunnels the TLS session (CONNECT); certificate verification, pins and the client certificate are still checked end to end against the receiver, but the proxy sees the receiver's address and the timing.

func WithRequestsPerCall

func WithRequestsPerCall(n int) Option

WithRequestsPerCall sets how many requests one Notify call may make, with backoff of 1 s then 2 s between them (default 1, at most MaxRequests). The notifier core already runs several calls per delivery round, so the default leaves the retrying to it; raise this for a caller that uses the Webhook without the core. A Retry-After on a 429 or 5xx is honored either way: Notify waits it out (up to 60 s, and never past the context's deadline) before returning, so the core's next call does not arrive early.

func WithSecrets

func WithSecrets(current, previous string) Option

WithSecrets sets the signing secrets. current always signs. previous, if not empty, signs too, so a receiver that has not yet been given the new secret (or has not yet dropped the old one) verifies either way: rotate by deploying previous=old, current=new, updating the receiver, then dropping previous. Secrets are never logged, journaled, put in a payload or put in an error.

func WithServerCA

func WithServerCA(file string) Option

WithServerCA verifies the receiver against the CA certificates in a PEM file instead of the system roots. It replaces them rather than adding to them, so a private receiver cannot be impersonated by a certificate from a public CA.

func WithServerCAPEM

func WithServerCAPEM(pem []byte) Option

WithServerCAPEM is WithServerCA for a bundle held in memory.

func WithServerPin

func WithServerPin(pins ...string) Option

WithServerPin pins the receiver's key: after normal certificate verification, the handshake fails unless a certificate in the verified chain has a SubjectPublicKeyInfo whose SHA-256 is one of the pins (64 hex characters or base64; see SPKIHash). Give two while the receiver rotates its key. Pinning is in addition to normal verification, never instead of it.

func WithTimeout

func WithTimeout(d time.Duration) Option

WithTimeout sets the timeout of each request (default 10 seconds).

func WithViewDB

func WithViewDB(path string) Option

WithViewDB names the SQLite journal file the deployment's runs are in, so the payload's view.command is a command that runs: `bide view -db <path> -run <id>`. Without it the command carries the marked template `-db <journal.db>`, which the reader fills in; bide view needs a store and the webhook cannot guess one. For a Postgres journal the DSN is a credential and never goes in a payload: an adapter swaps `-db <journal.db>` for `-postgres "$DSN"` itself.

func WithViewURL

func WithViewURL(base string) Option

WithViewURL sets the base URL of the deployment's run viewer; the payload's view.url is then base + "/" + run ID, escaped. It is a view pointer: no approve or resolve link is ever built.

type Payload

type Payload struct {
	Schema     string     `json:"schema"`
	ID         string     `json:"id"`
	Key        string     `json:"key"`
	Kind       string     `json:"kind"`
	RunID      string     `json:"run_id"`
	EventAt    *time.Time `json:"event_at"`
	ObservedAt *time.Time `json:"observed_at"`
	DeadlineAt *time.Time `json:"deadline_at"`
	Attempt    int        `json:"attempt"`
	Summary    string     `json:"summary"`
	Step       *Step      `json:"step"`
	Halt       *Halt      `json:"halt"`
	Principal  *Principal `json:"principal"`
	Approval   *Approval  `json:"approval"`
	Abort      *Abort     `json:"abort"`
	Failing    *Failing   `json:"failing"`
	// Clears is present for a resolved notification: the event it ends.
	Clears *Clears `json:"clears"`
	// Escalates is present for an escalated notification: the event that was not acknowledged (an
	// approval: not decided) by its deadline, and the step of the chain this is. The event's own
	// detail (halt, approval, step) is filled in as the event's own notifications carry it.
	Escalates *Escalates `json:"escalates"`
	// Receiver is the name of the receiver this request is to ("default" for the notifier's own).
	Receiver string `json:"receiver,omitempty"`
	Reason   string `json:"reason,omitempty"`
	View     View   `json:"view"`
	// Error is present only with WithFields(ErrorText).
	Error string `json:"error,omitempty"`
}

Payload is the JSON body of a webhook request, schema bide.notification.v1. Exactly one of Halt, Approval, Abort, Failing and Clears is non-nil, where the kind has one. It is exported so a receiver can decode it; a receiver must ignore unknown fields.

type PermanentError

type PermanentError struct{ Status int }

PermanentError is returned for a response that retrying inside the round cannot fix: any 4xx other than 429, or a redirect. The caller's outer backoff still tries again later, since a receiver's configuration can change.

func (*PermanentError) Error

func (e *PermanentError) Error() string

Error names the status only: no URL, no body.

func (*PermanentError) Unwrap

func (e *PermanentError) Unwrap() error

Unwrap makes errors.Is(err, agent.ErrNotifyPermanent) true.

type Policy

type Policy struct {
	Need int `json:"need"`
	Of   int `json:"of"`
}

Policy is the gate's k of n.

type Principal

type Principal struct {
	OnBehalfOf   string `json:"on_behalf_of"`
	AuthorityRef string `json:"authority_ref"`
}

Principal is the party the run acts for. A receiver routes away from it as one input.

type ProbeResult added in v0.17.0

type ProbeResult struct {
	// Status is the receiver's HTTP status, 0 if there was no response.
	Status int
	// TLS is whether the request went over TLS. Handshake, HandshakeErr, HandshakeTime and
	// TLSVersion are meaningful only then.
	TLS bool
	// Handshake is true when the TLS handshake completed; with TLS true and Handshake false,
	// HandshakeErr says why (an unknown CA, a pin mismatch, a rejected client certificate).
	Handshake     bool
	HandshakeErr  error
	HandshakeTime time.Duration
	TLSVersion    string
	// Auth is how the delivery was authenticated, set when the receiver answered 2xx.
	Auth *agent.NotifyAuth
	// Requests is how many requests the delivery made, and Elapsed the time all of them took.
	Requests int
	Elapsed  time.Duration
}

ProbeResult is what one delivery looked like from the sender's side, for a tool that tests a receiver (bide notify test). It carries no secret and no URL.

type Step

type Step struct {
	OpKind string `json:"op_kind"`
	OpID   string `json:"op_id"`
	Tool   string `json:"tool,omitempty"`
}

Step names the operation an event is about.

type View

type View struct {
	Command string  `json:"command"`
	URL     *string `json:"url"`
}

View points at where to look. It never carries an approve or resolve link: acting from a notification is a non-goal, and a link that decides on a click is a phishing target.

type Webhook

type Webhook struct {
	// contains filtered or unexported fields
}

A Webhook delivers notifications as signed HTTPS POSTs. Build one with NewWebhook. It is safe for concurrent use.

func NewWebhook

func NewWebhook(rawURL string, opts ...Option) (*Webhook, error)

NewWebhook returns a Webhook that posts to rawURL. The URL must be https, except for localhost, 127.0.0.1 and [::1], which exist for development and tests; anything else is agent.ErrConfig. WithSecrets is required: an unsigned webhook is not offered.

func (*Webhook) Notify

func (w *Webhook) Notify(ctx context.Context, n agent.Notification) error

Notify delivers n as one signed POST, retrying inside the round (see MaxRequests). It returns nil only once the receiver answered 2xx, and reports how the delivery was authenticated through agent.ReportNotifyAck, for the audit record of the acknowledgement. A refusal that retrying in the round cannot fix (a 4xx other than 429, a redirect) is a *PermanentError, which wraps agent.ErrNotifyPermanent.

func (*Webhook) Probe added in v0.17.0

Probe delivers n exactly as Notify does (the same payload, signature, TLS options and request rules) and reports how the delivery went, without the audit acknowledgement Notify makes. The error is the one Notify would return. It is for tools that test a receiver; the notifier itself uses Notify.

Directories

Path Synopsis
Package relay receives bide's signed webhooks and forwards each verified notification to Slack, PagerDuty, or any other Sink.
Package relay receives bide's signed webhooks and forwards each verified notification to Slack, PagerDuty, or any other Sink.

Jump to

Keyboard shortcuts

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