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 ¶
const ( HeaderTimestamp = "X-Approval-Timestamp" HeaderNonce = "X-Approval-Nonce" HeaderSignature = "X-Approval-Signature" )
The headers every request carries.
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 ¶
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 ¶
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>