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 ¶
- Constants
- Variables
- func PageCSP(intakeOrigin string) string
- func PageFiles() map[string][]byte
- func PageSHA256() map[string]string
- type Access
- type Gate
- type GateDecision
- type Handler
- type Option
- func OnRecorded(fn func(context.Context, Recorded)) Option
- func WithAuth(fn func(*http.Request, Access) error) Option
- func WithCORS(origin string) Option
- func WithCORSCredentials() Option
- func WithClientKey(fn func(*http.Request) string) Option
- func WithClock(now func() time.Time) Option
- func WithGatePolicy(fn func(tool string) (*agent.ApprovalPolicy, bool)) Option
- func WithGlobalRateLimit(rate float64, burst int) Option
- func WithMaxBodyBytes(n int64) Option
- func WithMaxClients(n int) Option
- func WithMaxInFlight(n int) Option
- func WithMaxReaders(total, perClient int) Option
- func WithOnError(fn func(error)) Option
- func WithPage() Option
- func WithRateLimit(rate float64, burst int) Option
- func WithReadTimeout(d time.Duration) Option
- type Pending
- type Phase
- type Recorded
- type Request
- type Response
Examples ¶
Constants ¶
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).
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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
type Option ¶
Option configures New.
func OnRecorded ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
WithMaxBodyBytes bounds a request body (DefaultMaxBodyBytes by default). A larger body is 413.
func WithMaxClients ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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. |