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

approvals

package
v0.26.0 Latest Latest
Warning

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

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

Documentation

Overview

Package approvals is the intake an application serves so that approvers can decide on gated calls over HTTP: an http.Handler that shows a pending call from the journal, and takes an approver's signed decision, verifies it against the recorded call, and records it through the application's own journal.

The application stays the only writer of its journal and the only signer of its audit log. A decision that arrives here is written by the same process that holds the log key (an audit.AuditedStore signs a head over it as it is written), so there is no window in which a decision sits in the journal under no signed head. The approver's authority is their signature, not the connection: the handler needs no authentication to be correct, because a decision that does not verify against the call and an enrolled key records nothing, and the gate re-verifies every record when the run resumes. What it adds around that is availability and confidentiality: strict, size-bounded requests, rate limits, a cap on concurrent work, and an authorization hook (WithAuth) for deployments that want mutual TLS or an identity check as well.

Mount it under a prefix with http.StripPrefix:

h, err := approvals.New(journal, verifierFor,
    approvals.WithGatePolicy(func(tool string) (*agent.ApprovalPolicy, bool) { p, ok := policies[tool]; return p, ok }),
    approvals.OnRecorded(func(ctx context.Context, r approvals.Recorded) {
        if r.Decided {
            _, _ = agent.Run(ctx, r.RootRunID /* ... */)
        }
    }))
mux.Handle("/approvals/", http.StripPrefix("/approvals", h))

Wire format

GET /pending?run=<id>&call=<tool-use id> returns the call as the journal records it (Pending). POST /decisions takes one JSON object (Request) and returns the decision's place in the journal and the gate's standing (Response). Every error is {"error":{"code":...,"message":...}} with a stable code; a message never carries a store error.

A client signs agent.ApprovalDecisionBytes over the subject it was shown (run, call, tool, and the arguments it displayed), so what it signs is what it saw; the handler verifies the signature against the call in the journal, so a client shown other arguments than the journal holds produces a signature that does not verify and records nothing.

Index

Examples

Constants

View Source
const (
	// StateWaiting: the run is paused at the gate and takes decisions.
	StateWaiting = "waiting"
	// StateDecided: the gate has an outcome (a tally, or the call has a result); it takes no more.
	StateDecided = "decided"
	// StateEnded: the run ended; it takes no more.
	StateEnded = "ended"
	// StateSingle: a one-decision gate (agent.Approve), which takes no signed decision.
	StateSingle = "single"
	// StateExpired: the gate's deadline (agent.ApprovalPolicy.Timeout) has passed, by the handler's
	// clock, or its recorded outcome is the expiry; it takes no more decisions, and the call never
	// runs.
	StateExpired = "expired"
	// StateError: the gate would refuse to evaluate (an invalid policy, approvers on one key, a
	// requester the run does not record): the run fails at the call, and the intake takes no
	// decision. Pending.Reason says why.
	StateError = "error"
)

The states of a gated call (Pending.State).

View Source
const (
	// DefaultMaxBodyBytes bounds a request body: a hybrid signature in base64 is about 4.7 KiB.
	DefaultMaxBodyBytes = 64 << 10
	// DefaultReadTimeout bounds how long a POST may take to send its body.
	DefaultReadTimeout = 10 * time.Second
	// DefaultMaxReaders and DefaultMaxReadersPerClient bound the request bodies being read at once.
	DefaultMaxReaders          = 32
	DefaultMaxReadersPerClient = 4
	// DefaultRate and DefaultBurst are the per-client request rate and burst.
	DefaultRate  = 2.0
	DefaultBurst = 10
	// DefaultGlobalRate and DefaultGlobalBurst are the limit over all clients together.
	DefaultGlobalRate  = 50.0
	DefaultGlobalBurst = 100
	// DefaultMaxClients bounds the per-client buckets kept.
	DefaultMaxClients = 10000
	// DefaultMaxInFlight bounds the requests that read or write the journal at once, and the
	// OnRecorded hooks running at once.
	DefaultMaxInFlight = 16
)

Defaults of the handler's bounds.

Variables

View Source
var (
	// ErrUnauthenticated is what a WithAuth hook returns for a request with no valid credentials:
	// the handler answers 401.
	ErrUnauthenticated = errors.New("approvals: unauthenticated")
	// ErrForbidden is what a WithAuth hook returns for an authenticated request it refuses: the
	// handler answers 403. Any other error from the hook is answered the same.
	ErrForbidden = errors.New("approvals: forbidden")
)

Functions

func PageCSP

func PageCSP(intakeOrigin string) string

PageCSP returns the Content-Security-Policy a host of the page should send with it, for an intake at intakeOrigin ("" when the intake is the page's own origin): the policy WithPage serves, with connect-src naming the intake.

func PageFiles

func PageFiles() map[string][]byte

PageFiles returns the files of the approve page by name: index.html, page.js and app.css. The build is deterministic: the same release always yields the same bytes, and index.html carries Subresource Integrity for the script and the stylesheet. A deployment that hosts the page itself serves these files (cmd/approvepage writes them with their SHA-256 sums) and may add a config.json naming the intake, {"intake": "https://app.example/approvals"}.

func PageSHA256

func PageSHA256() map[string]string

PageSHA256 returns the SHA-256 of each file of PageFiles, in lowercase hex: what a release publishes and a deployment verifies before it serves the page.

Types

type Access

type Access struct {
	// Phase says which call of the hook this is: PhaseRequest for the request's own, PhaseIdentity
	// for the call a POST /decisions gets before its body is read.
	Phase     Phase
	Method    string // http.MethodGet or http.MethodPost
	RunID     string
	ToolUseID string
	Approver  string // the approver a POST names; empty for a GET
}

Access is what an authorization hook (WithAuth) is asked to allow.

type Gate

type Gate struct {
	Need      int            `json:"need"`
	Approvers []string       `json:"approvers"`
	Approved  int            `json:"approved"`
	Denied    int            `json:"denied"`
	Pending   []string       `json:"pending,omitempty"`  // approvers with no counted decision
	Excluded  []string       `json:"excluded,omitempty"` // approvers who fill no seat (separation of duties, or a key problem)
	Passed    bool           `json:"passed"`             // approved >= need
	Failed    bool           `json:"failed"`             // too few approvers left to reach need
	Expired   bool           `json:"expired,omitempty"`  // the gate's recorded outcome is its expiry
	Decisions []GateDecision `json:"decisions,omitempty"`
	// Identity is, for each certificate-bound approver, the canonical identifier its certificate must carry
	// (the gate's identity rule), so a client can check its own certificate before it signs. Bare-key
	// approvers are not listed.
	Identity map[string]string `json:"identity,omitempty"`
}

Gate is the standing of an m-of-n gate, counted as the gate counts: only decisions that verify under the enrolled keys, the first of each approver.

type GateDecision

type GateDecision struct {
	Approver string `json:"approver"`
	Approved bool   `json:"approved"`
	Counted  bool   `json:"counted"`
	Reason   string `json:"reason,omitempty"` // why it does not count
	// For a decision of a certificate-bound approver, what the count rests on. Counted is true for a
	// decision the gate counts, and that is not the same as final: Provisional says a later status could
	// still change the answer (the status in hand predates the decision, as it usually does at intake, or
	// its window is short), and Tier ("provisional", "logclock-final" or "independent-final") and Basis
	// ("at-or-after-decision" or "live-before-decision") say how. Only a final tier is a verified one; an
	// audit (bide audit verify-approvals) recomputes it. Absent for a bare-key approver's decision.
	Tier        string `json:"tier,omitempty"`
	Provisional bool   `json:"provisional,omitempty"`
	Basis       string `json:"basis,omitempty"`
}

GateDecision is one decision record the gate read.

type Handler

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

Handler is the intake. Create it with New; it is safe for concurrent use.

func New

func New(j *agent.Journal, verifierFor agent.ApproverVerifierFor, opts ...Option) (*Handler, error)

New returns the intake handler over j, the application's own journal: for decisions to be signed as they are written, j's store must be the audit.AuditedStore (or wrap it). verifierFor resolves an approver id to the key enrolled for it, as agent.WithApproverVerifiers does; a decision whose approver has no key, or whose signature does not verify under it, is refused.

WithGatePolicy is required: New returns an error wrapping agent.ErrConfig without it, because the handler counts a gate under the policy the agent is built with. WithAuth is required only with WithPage or WithCORS, which expose a call's arguments to a browser.

Example

Mount the intake on the application's server. The journal is the application's own, so an audit.AuditedStore around its store signs each decision as it is written.

package main

import (
	"context"
	"fmt"
	"net/http"

	"github.com/bide-ai/bide/agent"
	"github.com/bide-ai/bide/agent/agenttest"
	"github.com/bide-ai/bide/approvals"
)

func main() {
	journal := agenttest.MemJournal()
	keys := func(id string) (agent.ApproverVerifier, bool) { return nil, false } // the enrolled keys

	policies := map[string]*agent.ApprovalPolicy{
		"charge": {Need: 2, Approvers: []string{"ops", "finance", "risk"}}, // the policy the tool was built with
	}
	h, err := approvals.New(journal, keys,
		approvals.WithGatePolicy(func(tool string) (*agent.ApprovalPolicy, bool) { p, ok := policies[tool]; return p, ok }),
		approvals.OnRecorded(func(ctx context.Context, r approvals.Recorded) {
			if r.Decided {
				// Re-run r.RootRunID now, or leave it to agent.RecoverLoop.
			}
		}),
		approvals.WithAuth(func(req *http.Request, a approvals.Access) error {
			if req.TLS == nil || len(req.TLS.PeerCertificates) == 0 {
				return approvals.ErrUnauthenticated // require a client certificate
			}
			return nil
		}))
	if err != nil {
		fmt.Println(err)
		return
	}
	mux := http.NewServeMux()
	mux.Handle("/approvals/", http.StripPrefix("/approvals", h))
	fmt.Println("mounted")
}
Output:
mounted

func (*Handler) ServeHTTP

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

ServeHTTP serves GET /pending and POST /decisions.

func (*Handler) Wait

func (h *Handler) Wait()

Wait blocks until the OnRecorded hooks running now have returned, for a graceful shutdown: stop the server, then Wait.

type Option

type Option func(*Handler) error

Option configures New.

func OnRecorded

func OnRecorded(fn func(context.Context, Recorded)) Option

OnRecorded sets the function called, in a goroutine of its own and with a context that outlives the request, once for each decision this handler wrote (never for a replay or a refusal). It is where the application re-runs the run, or schedules it, by its own policy:

if r.Decided { _, err := agent.Run(ctx, r.RootRunID, ...) }

It is an accelerator, not the guarantee: a run waiting on a decision is also picked up by agent.RecoverLoop, and the hook is dropped, with a report to WithOnError, when WithMaxInFlight hooks are already running. Wait waits for the running hooks.

func WithAuth

func WithAuth(fn func(*http.Request, Access) error) Option

WithAuth sets a hook that runs for every request, after the rate limit and before the journal is read or written, with what the request asks for. A POST /decisions calls it twice, and Access.Phase says which call it is: PhaseIdentity first, from the headers and the connection, before the body is read (RunID, ToolUseID and Approver empty: it must only authenticate), so a client that fails it is refused without costing a stalled read; then PhaseRequest with the decision's run, call and approver once the body is parsed, where a hook that ties an approver to a client certificate enforces the binding. The hook must be idempotent: both calls are made for one request. Return nil to allow it, ErrUnauthenticated for a request with no valid credentials (401), or ErrForbidden, or any other error, to refuse it (403). It is where a deployment checks a client certificate (r.TLS.PeerCertificates) or an identity, and ties an approver id to it (Access.Approver). The signature is the authorization of a decision; this hook decides who may reach the endpoint at all, and who may read a call's arguments, which GET /pending shows to any caller it allows.

func WithCORS

func WithCORS(origin string) Option

WithCORS lets a page hosted at origin (an exact origin such as "https://approve.example.com", never "*") call the intake from a browser: it answers the CORS preflight (OPTIONS) before any authentication, since a preflight carries no credentials and a browser can never reach an endpoint that refuses it, and adds the Access-Control headers to responses to that origin. It requires WithAuth. Credentials are not allowed cross-origin unless WithCORSCredentials says so; a deployment that authenticates with a cookie or a client certificate from another origin sets it, and the page's config.json asks for credentials "include". A deployment that needs neither serves the page from the intake's origin.

func WithCORSCredentials

func WithCORSCredentials() Option

WithCORSCredentials lets the page send credentials cross-origin (fetch's credentials mode "include", which config.json asks for): cookies, HTTP authentication and, for mutual TLS, the browser's client certificate, which a browser does not present on a cross-origin request made with credentials omitted. It adds Access-Control-Allow-Credentials, which a browser accepts only with the exact origin WithCORS names. It requires WithCORS.

func WithClientKey

func WithClientKey(fn func(*http.Request) string) Option

WithClientKey names the client a request is limited as (and the client whose concurrent body reads are capped). By default it is the host of r.RemoteAddr, an IPv6 address standing for its /64 (the unit an operator assigns to one host or site) and an IPv4-mapped one for the IPv4 address; behind a reverse proxy or load balancer that is the proxy's address, so every client shares one bucket: set it there, to a function that reads the address the proxy vouches for (and only that header, which the proxy must overwrite, never pass through from the client).

func WithClock

func WithClock(now func() time.Time) Option

WithClock sets the clock the rate limits run on and the decision's recorded_at is read from.

func WithGatePolicy

func WithGatePolicy(fn func(tool string) (*agent.ApprovalPolicy, bool)) Option

WithGatePolicy gives the handler each gated tool's live approval policy, as the application's agent is built with it (the ToolSpec's Approval). It is required: New returns an error wrapping agent.ErrConfig without it. A gate's policy is also in the pause note, which the run writes once when it first pauses, so a redeploy that changes the policy afterwards (turning on ExcludePrincipal, say) is enforced by the gate when the run resumes but is not in the note. If the gate's recorded tally passed under the old policy and the call has not run, the gate asks again; a handler that read the old policy from the note would show the gate as decided and refuse the new approvals (not_waiting), and the run would wait for ever. So a gate on a tool fn knows is counted under the live policy, by agent.EvaluateGate, the function the agent's own gate evaluates with, so the two cannot disagree: the policy's shape (a one-decision gate is not served), its validity and keys (a gate the gate would refuse is refused here, state "error"), separation of duties, a recorded tally it supersedes, and the count. fn returns ok false for a tool it does not know, and the pause note is then the fallback; ok true with a nil policy says the tool has no gate now. fn is called for every request that reads a gate, so it should be a lookup:

approvals.WithGatePolicy(func(tool string) (*agent.ApprovalPolicy, bool) {
    p, ok := policies[tool] // the policies the tools were built with agent.WithApproval
    return p, ok
})

func WithGlobalRateLimit

func WithGlobalRateLimit(rate float64, burst int) Option

WithGlobalRateLimit sets the request rate and burst over all clients together; DefaultGlobalRate and DefaultGlobalBurst by default. A rate of 0 turns it off.

func WithMaxBodyBytes

func WithMaxBodyBytes(n int64) Option

WithMaxBodyBytes bounds a request body (DefaultMaxBodyBytes by default). A larger body is 413.

func WithMaxClients

func WithMaxClients(n int) Option

WithMaxClients bounds how many clients' buckets are kept (DefaultMaxClients). Past it, new clients share one bucket, so a flood of sources is limited as one.

func WithMaxInFlight

func WithMaxInFlight(n int) Option

WithMaxInFlight bounds the requests working on the journal at once, and the OnRecorded hooks running at once (DefaultMaxInFlight). A request past it is answered 503 with Retry-After.

func WithMaxReaders

func WithMaxReaders(total, perClient int) Option

WithMaxReaders bounds the request bodies being read at once, overall and for one client (DefaultMaxReaders, DefaultMaxReadersPerClient). A body is read before the request takes a slot for work on the journal, in a pool of its own, so a client that stalls while sending cannot starve requests that are working; past either bound a request is refused (503 overall, 429 for a client over its own) with Retry-After. Authorization has run by then (see WithAuth).

func WithOnError

func WithOnError(fn func(error)) Option

WithOnError sets the function the handler tells of what it does not show a caller: a store error, a refused hook. It may be called from several goroutines.

func WithPage

func WithPage() Option

WithPage makes the handler serve the approve page under /page/: an approver opens <mount>/page/#run=<id>&call=<id> and signs in the browser. It requires WithAuth (New returns an error without it): the page reads a call's arguments through GET /pending, so the endpoint must know who is asking, and the same hook authorizes the page's own files.

The page is then served by the application, so the application can change what it does and can serve a script that signs anything the approver's key will sign; the page says so on its face. It is for development and for low-assurance use. For anything that matters, host PageFiles on an origin of the approver's organization (a dedicated one: IndexedDB and the key in it are scoped to the origin), pin the bundle by PageSHA256, and allow it with WithCORS. See docs/design/approve-page.md.

func WithRateLimit

func WithRateLimit(rate float64, burst int) Option

WithRateLimit sets the per-client request rate (a second) and burst; DefaultRate and DefaultBurst by default. A rate of 0 turns the per-client limit off.

func WithReadTimeout

func WithReadTimeout(d time.Duration) Option

WithReadTimeout bounds how long a POST /decisions may take to send its body (DefaultReadTimeout). The body is read in the read pool (WithMaxReaders), so a stalled client costs one reader for at most this long and no slot for work on the journal. It does not replace http.Server.ReadHeaderTimeout, which bounds the headers before the handler runs: set both.

type Pending

type Pending struct {
	Run            string          `json:"run"`
	Call           string          `json:"call"`
	Tool           string          `json:"tool"`
	Args           json.RawMessage `json:"args,omitempty"`      // the call's arguments, as recorded (JSON)
	ArgsText       string          `json:"args_text,omitempty"` // the arguments when they are not JSON: shown as text
	ArgsSHA256     string          `json:"args_sha256"`         // SHA-256 of the canonical arguments, lowercase hex: what a signature binds
	DecisionFormat string          `json:"decision_format"`     // agent.DecisionFormat: the format a signature must be in
	State          string          `json:"state"`               // StateWaiting, StateDecided, StateExpired, StateEnded, StateSingle or StateError
	AskedAt        string          `json:"asked_at,omitempty"`  // when the run paused at the gate, by the run's clock
	// ExpiresAt is the gate's recorded deadline (agent.ApprovalDeadline), empty for a gate with none.
	ExpiresAt string `json:"expires_at,omitempty"`
	// Reason says why the call waits again after a tally: agent.PendingReapproval or
	// agent.PendingSoDReapproval. Empty on a call's first ask.
	Reason string `json:"reason,omitempty"`
	// Why says why the call needs this gate beyond its tool's own: for the escalation instance of a tainted
	// call, the untrusted content its turn had read; for a gate a cap rule holds, the rule and this call's
	// figures as of the ask. It is the gate-reason record the engine wrote: the application's claim, shown to
	// help an approver decide, read by no gate and not bound by the signed decision. If two records of the run
	// name the call and disagree, it is the one reason of kind "conflict". Omitted when there is none. Experimental.
	Why  []agent.GateReason `json:"why,omitempty"`
	Gate Gate               `json:"gate"`
}

Pending is the body of GET /pending: a gated call, from the journal.

type Phase

type Phase int

Phase is which call of the authorization hook (WithAuth) an Access belongs to.

const (
	// PhaseRequest is the hook's call with everything the request asks for: the method, and for a
	// call, a decision or a page the run, call and approver it names. A GET makes this call only.
	// It is the zero value, so a hook that ignores Phase sees full requests as before.
	PhaseRequest Phase = iota
	// PhaseIdentity is the call a POST /decisions gets first, before its body is read: RunID,
	// ToolUseID and Approver are empty, and the hook must only authenticate (who is this, from the
	// headers and the connection: a bearer token, a client certificate), never decide on what the
	// request asks for, which it does not yet know. It is refused here without the body being read.
	// The hook is called again with PhaseRequest once the body is parsed, so it must be idempotent:
	// the same request makes both calls, and a hook that counts or consumes anything per call
	// counts a decision twice.
	PhaseIdentity
)

type Recorded

type Recorded struct {
	RunID     string // the run whose journal holds the call
	RootRunID string // the run to re-invoke to continue it (agent.RootRunID)
	ToolUseID string
	Approver  string
	Approved  bool
	Record    string // the journal record's name
	Index     int    // its position in the run's journal
	// Decided is true when the gate now has enough decisions to settle: it passed, or can no longer
	// pass. A hook that re-runs the run only when Decided avoids a pointless re-drive after each of
	// the first k-1 decisions.
	Decided bool
}

Recorded is what OnRecorded is told about a decision this handler wrote.

type Request

type Request struct {
	Run       string `json:"run"`        // the run whose journal holds the call: the pause's run ID
	Call      string `json:"call"`       // the call's tool-use ID
	Approver  string `json:"approver"`   // one of the gate's approvers, exactly
	Approved  bool   `json:"approved"`   // true to approve, false to reject
	DecidedAt string `json:"decided_at"` // agent.FormatTimestamp form
	Alg       string `json:"alg"`        // the signature scheme, e.g. "ed25519"
	Signature []byte `json:"signature"`
	// Cert is the leaf-first DER certificate chain of a certificate-bound approver, each certificate base64
	// (standard alphabet, padded) as encoding/json writes a [][]byte, and Signature is then over
	// agent.ApprovalDecisionBytesCert with the SHA-256 of Cert[0] (bide.approval.v4). The chain is verified
	// before anything is written, with the approver's certificate-bound verifier, the same rule the gate
	// counts by. At most agent.MaxDecisionCertChain certificates of agent.MaxDecisionCertDER bytes; the
	// body bound (WithMaxBodyBytes, 64 KiB by default) applies to the whole request, so a deployment whose
	// chains approach the limits raises it. A bare-key approver sends none.
	Cert [][]byte `json:"cert,omitempty"`
}

Request is the body of POST /decisions: one approver's signed decision. Signature is the approver's signature over agent.ApprovalDecisionBytes of the call they were shown, base64 (standard alphabet, padded) as encoding/json writes a []byte. DecidedAt is when the approver decided, by their clock, in agent.FormatTimestamp's form; it is signed.

type Response

type Response struct {
	// Recorded is true when this request wrote the decision's record, false when the journal already
	// held it (a replay, or an identical request that won a race): the same decision again changes
	// nothing, and OnRecorded runs for the request that wrote it only.
	Recorded   bool   `json:"recorded"`
	Record     string `json:"record"`      // the name of the journal record that holds the decision (found, not derived: see agent.HeldDecisionStep)
	Index      int    `json:"index"`       // its position in the run's journal (0 is the header)
	RecordedAt string `json:"recorded_at"` // when it was journaled, by the recording process's clock
	Gate       Gate   `json:"gate"`        // the gate's standing after the decision, verified
	// Counted is whether this decision is the one the gate counts for its approver. It is false for a
	// decision recorded beside an earlier valid one of the same approver (an approve and a reject
	// sent at once: both are recorded, and the first in journal order counts), and Reason says why. A
	// client that reports success must check it.
	Counted bool   `json:"counted"`
	Reason  string `json:"reason,omitempty"`
	// Anchored is, for a certificate-bound decision, whether a signed head of the run covers it yet. A
	// decision no head covers is recorded and does not count until one does (Counted false, Reason says
	// so); the gate anchors before it tallies, so it is not lost. Absent for a bare-key decision.
	Anchored *bool `json:"anchored,omitempty"`
	// Receipt is the decision's receipt (audit.DecisionReceipt), built from the journal record, for
	// the approver to keep: it carries the signed decision, the call's arguments, and where the
	// journal holds the record. Record, Index, RecordedAt and LeafHash are the application's word. It
	// is absent when the record does not verify under the key the handler resolves now (a replay
	// after the approver's key was rotated, answered from the gate's recorded tally): a receipt the
	// approver could not verify would be no receipt.
	Receipt *audit.DecisionReceipt `json:"receipt,omitempty"`
}

Response is the body of a successful POST /decisions.

Directories

Path Synopsis
cmd
approvepage command
Command approvepage writes the approve page's files (approvals.PageFiles) to a directory, with a SHA256SUMS file in the format sha256sum -c reads, for an organization that hosts the page on an origin of its own.
Command approvepage writes the approve page's files (approvals.PageFiles) to a directory, with a SHA256SUMS file in the format sha256sum -c reads, for an organization that hosts the page on an origin of its own.

Jump to

Keyboard shortcuts

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