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

webhook

package
v0.5.0 Latest Latest
Warning

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

Go to latest
Published: Oct 8, 2026 License: MIT Imports: 20 Imported by: 0

Documentation

Overview

Package webhook is the reference HTTP approval channel over approver.Approver: an http.Handler that shows one run's pending approvals, shows one approval as JSON or as sanitised plain text, and takes a decision bound to the hash it showed. It is what a chat bot or a browser page talks to; examples/approver is a thin client of it.

Routes

Every route names the run, and nothing lists runs: a caller holding one run's id learns nothing about another's.

GET  /runs/{run}/approvals                 the run's pending approvals (JSON)
GET  /runs/{run}/approvals/{id}            one approval; JSON, or text with
                                           Accept: text/plain or ?format=text
POST /runs/{run}/approvals/{id}/approve    {"run_id","approval_id","hash","by","note"}
POST /runs/{run}/approvals/{id}/reject     the same body
POST /runs/{run}/cancel                    {"run_id","by","note"}

A decision is accepted only when its body names the run and approval of the path, the hash the caller was shown, and an identity. approve answers 202: the run stays waiting until its owner resumes it. reject and cancel answer 200 with the run cancelled. Errors are JSON, {"error": text, "code": name}: 400 for a body that is not a decision, including one with no hash or no identity, which never reaches the Approver; 401 for a request that does not verify; 404 for a run or approval that does not exist under the run named, or any other route or method; 409 approval_changed when the stored approval is not the one shown, 409 not_pending when it is already decided, 409 run_not_waiting (run_finished for cancel) when the run is in another state, 409 approval_hash when the stored row no longer matches its own hash; 410 expired when the approval's expiry has passed; 413 for a body over Config.MaxBody; and 429 when Config.Rate is exceeded.

Verification

Every request, GET included, carries three headers: HeaderTimestamp, the sender's clock as Unix seconds; HeaderNonce, a value the sender does not reuse, up to 64 characters; and HeaderSignature, "v1=" followed by the hex HMAC-SHA256 under Config.Secret of

timestamp "\n" nonce "\n" method "\n" path "\n" body

where path is the request path as the handler sees it (after any http.StripPrefix) and body is the raw body, empty for GET. Sign and NewRequest produce it. The timestamp must be within Config.Skew of the handler's clock, the comparison is constant-time, and a signature seen once is refused again for as long as its timestamp could be accepted, so a captured request cannot be replayed inside the window; the nonce is what lets a client send the same decision twice in one second and have the second judged on its own. Requests are rate limited before they are read, and bodies are bounded.

Expiry

A pending approval whose Limits.ApprovalTTL has passed is shown as 410 without writing anything. A decision on it goes to the runtime, whose own expiry path marks it expired and cancels the run as approval_expired, and answers 410.

What this package does not do

It has no users and no sessions: "by" is a label, recorded as given and never verified, exactly as cmd/agentrt's -by. The shared secret is the only authentication, and whoever holds it can decide any approval of any run whose id they know; hold it where the operator's chat or web front end runs, not in a browser. The secret never travels in a URL. Anyone who can write the database can approve anything without the secret, because the database, not this handler, is the trust boundary. It speaks plain HTTP: put it behind TLS and a network boundary the operator controls, and if a proxy adds a path prefix, strip it before the handler so the signed path is the one it sees. It does not resume runs, which needs the consumer's own process.

Index

Examples

Constants

View Source
const (
	HeaderTimestamp = "X-Approval-Timestamp"
	HeaderNonce     = "X-Approval-Nonce"
	HeaderSignature = "X-Approval-Signature"
)

The headers every request carries.

View Source
const (
	// EventDecision records every approve, reject, or cancel request that
	// verified, with the status it was answered and the error if refused.
	EventDecision = "webhook.decision"
	// EventRefused records a request that did not verify, or exceeded the
	// rate, with the path and the reason.
	EventRefused = "webhook.refused"
)

Event types the handler delivers to Config.Observer. Neither is persisted (Seq is 0): the runtime's own approval.decided arrives through the Approver's observer when a decision commits.

Variables

This section is empty.

Functions

func NewRequest

func NewRequest(ctx context.Context, secret []byte, method, url string, body []byte) (*http.Request, error)

NewRequest builds a signed request for a client of the handler: the method and URL as given, body as the body, and the three headers set from the wall clock and a random nonce. The signed path is the URL's path, so a client of a handler mounted behind a prefix the server strips must sign the stripped path and send the full one itself.

func Sign

func Sign(secret []byte, timestamp time.Time, nonce, method, path string, body []byte) string

Sign computes the HeaderSignature value for a request: "v1=" and the hex HMAC-SHA256 of timestamp, nonce, method, path, and body joined by newlines.

func Text

func Text(p approver.Pending) string

Text renders an approval for a terminal or a chat message, in the shape cmd/agentrt prints: the kind, the tool, the policy's reason, the expiry, then each argument and presentation field on its own lines with its byte size, every value escaped with trace.Sanitize so model-chosen text cannot move the cursor, clear the screen, or forge the look of the prompt, and a closing line the renderer writes itself, so the last thing read is not text the model or the policy chose. A string over view.MaxFieldBytes is shown by its head and tail. It is what GET ...?format=text serves.

Types

type Config

type Config struct {
	Approver approver.Approver
	// Secret signs every request; at least 32 bytes.
	Secret []byte
	// Skew is how far a request's timestamp may be from the handler's
	// clock, either way. Default 5 minutes.
	Skew time.Duration
	// Rate and Burst bound requests per second over the whole handler,
	// before anything is read. Defaults 10 and 20.
	Rate, Burst int
	// MaxBody bounds a request body in bytes. Default 16 KiB.
	MaxBody int64
	// Limit is how many pending approvals a listing returns. Default 20.
	Limit int
	// Observer, which may be nil, receives EventDecision and EventRefused.
	Observer agentrt.Observer
}

Config configures a Handler. Approver and Secret are required.

type Handler

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

Handler is the http.Handler. Use New.

Example

A chat bot holding the secret shows an approval as text, posts the operator's decision bound to the hash it showed, and the consumer's own process resumes the run. A second decision on the same approval is refused, and a request signed with another secret never reaches the approver.

package main

import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"
	"time"

	agentrt "github.com/joeylking/agent-runtime"
	"github.com/joeylking/agent-runtime/approver"
	"github.com/joeylking/agent-runtime/approver/webhook"
	"github.com/joeylking/agent-runtime/scripted"
)

// A chat bot holding the secret shows an approval as text, posts the
// operator's decision bound to the hash it showed, and the consumer's own
// process resumes the run. A second decision on the same approval is
// refused, and a request signed with another secret never reaches the
// approver.
func main() {
	ctx := context.Background()
	store, err := agentrt.OpenStore(":memory:")
	if err != nil {
		panic(err)
	}
	defer store.Close()
	push := &echo{spec: agentrt.ToolSpec{Name: "push", Description: "push", SideEffect: agentrt.RemoteMutation, Timeout: time.Second,
		InputSchema: []byte(`{"type":"object","properties":{"branch":{"type":"string"}},"additionalProperties":false}`)}}
	agent := &scripted.Agent{Decisions: []agentrt.Decision{scripted.ToolCall("push", `{"branch":"release"}`, "publish"), scripted.Complete(`{}`)}}
	n := 0
	d, err := agentrt.NewDriver(agentrt.Config{Store: store, Agent: agent, Policy: agentrt.DefaultPolicy(), Tools: []agentrt.Tool{push},
		NewID: func() string { n++; return fmt.Sprintf("id-%d", n) }})
	if err != nil {
		panic(err)
	}
	run, err := d.Start(ctx, "release", agentrt.DefaultLimits())
	if err != nil {
		panic(err)
	}

	secret := []byte("an operator-configured secret of 32+ bytes")
	h, err := webhook.New(webhook.Config{Approver: approver.New(store, nil), Secret: secret})
	if err != nil {
		panic(err)
	}
	srv := httptest.NewServer(h)
	defer srv.Close()

	// The bot lists the run's pending approvals, then shows the one it
	// found as text for the channel.
	var list struct {
		Pending []approver.Pending `json:"pending"`
	}
	call(ctx, secret, "GET", srv.URL+"/runs/"+run.ID+"/approvals", nil, &list)
	a := list.Pending[0]
	var text strings.Builder
	call(ctx, secret, "GET", srv.URL+"/runs/"+run.ID+"/approvals/"+a.ID+"?format=text", nil, &text)
	for _, line := range strings.Split(strings.TrimSpace(text.String()), "\n") {
		// The run id and the hash are stable too, but long; the rest is
		// the rendering.
		if !strings.HasPrefix(line, "APPROVAL") && !strings.HasPrefix(line, "run ") && !strings.HasPrefix(line, "hash ") {
			fmt.Println(line)
		}
	}

	// The operator says yes in the channel; the bot posts the decision
	// with the hash it showed.
	decision, _ := json.Marshal(approver.Decision{RunID: run.ID, ApprovalID: a.ID, Hash: a.Hash, By: "chat:joey", Note: "ship it"})
	var out map[string]any
	status := call(ctx, secret, "POST", srv.URL+"/runs/"+run.ID+"/approvals/"+a.ID+"/approve", decision, &out)
	fmt.Println(status, out["status"], out["by"])
	status = call(ctx, secret, "POST", srv.URL+"/runs/"+run.ID+"/approvals/"+a.ID+"/approve", decision, &out)
	fmt.Println(status, out["code"])
	status = call(ctx, []byte("some other secret, also 32+ bytes long"), "POST", srv.URL+"/runs/"+run.ID+"/approvals/"+a.ID+"/reject", decision, &out)
	fmt.Println(status, out["code"])

	got, err := d.Resume(ctx, run.ID)
	fmt.Println(got.Status, len(push.calls), string(push.calls[0].Args), err)
}

// call is the whole of a client: a signed request and its decoded reply.
func call(ctx context.Context, secret []byte, method, url string, body []byte, out any) int {
	req, err := webhook.NewRequest(ctx, secret, method, url, body)
	if err != nil {
		panic(err)
	}
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()
	if w, ok := out.(io.Writer); ok {
		io.Copy(w, res.Body)
		return res.StatusCode
	}
	json.NewDecoder(res.Body).Decode(out)
	return res.StatusCode
}
Output:
kind    remote_mutation
tool    push
reason  side effect remote_mutation is require_approval by policy
arguments (the tool call the model made; one field per line, byte count is the field's own size)
  branch (9 bytes)
    "release"
presentation (supplied by the consumer's policy; it may contain model-chosen text)
  args (20 bytes)
    {
      "branch": "release"
    }
  tool (6 bytes)
    "push"
----- end of approval id-3 for tool push -----
202 approved chat:joey
409 not_pending
401 unauthorized
COMPLETED 1 {"branch":"release"} <nil>

func New

func New(cfg Config) (*Handler, error)

New builds a Handler, refusing a Config without an Approver or with a secret under 32 bytes.

func (*Handler) ServeHTTP

func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request)

ServeHTTP rate limits, bounds and reads the body, verifies the request, and only then routes it.

Jump to

Keyboard shortcuts

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