Files
2026-08-28 12:21:44 +05:30

385 lines
14 KiB
Go

package tools
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"fmt"
"sort"
"strings"
"sync"
"time"
)
// The confirmation gate: I4, and the one place a write is allowed to happen.
//
// The invariant is short — "any tool that writes, sends, deletes, charges or
// notifies cannot execute without a resolved confirmation token" — but the
// naive reading of it is not safe, and the difference is the whole of this
// file.
//
// The naive reading is a boolean: ask, get a yes, run. That version has a hole
// wide enough to drive a payroll through. A person approves "assign Maya Chen
// to Friday's bar shift"; the model, on the next turn, calls the same tool with
// a different worker and the same yes still applies. Nothing in a boolean
// distinguishes those two calls, so the approval a person gave to one becomes
// an approval they never gave to the other.
//
// So a confirmation here is a *binding*, not a flag. A token is issued against
// a fingerprint of exactly what was described to the person:
//
// tool name + canonical inputs + principal + tenant
//
// and it validates only against a call carrying that same fingerprint. Change
// the worker, change the shift, change the caller, cross a tenant — each of
// those produces a different fingerprint and the token is refused. It is also
// single-use, so one approval buys exactly one write.
//
// WHY THE RUN IS RECORDED BUT NOT MATCHED
//
// The fingerprint originally included the run id, which is the tighter thing to
// do and was wrong. A confirmation exists precisely so that a run can END and a
// person can be asked; the run that resumes afterwards is a new run with a new
// id, so matching on it made every token unredeemable — the mechanism refused
// exactly the case it was built for.
//
// The run id is still stored, because "which conversation proposed this write"
// is worth being able to answer. It is not part of the match, and what covers
// the gap is the rest of the binding: the arguments are identical, so a token
// replayed in a later run authorises the very write it described; the TTL bounds
// how stale the surrounding facts can be; single-use bounds it to one; and the
// handler re-checks the world before writing. What a run-scoped match would have
// added on top of that is protection against a surface that hands back a token
// the user never clicked — which is a bug in the surface, not a hole a token
// format can close.
//
// The second half is Confirmer. A person cannot approve what they cannot read,
// and `{"job_posting_id":"3f2b...","worker_profile_id":"91ac..."}` is not
// something anyone can approve honestly. Every write tool must render a plain
// language description of what will happen — and must do it *behind the same
// authorization as the write itself*, because a renderer that resolves a name
// the caller may not see has leaked that name in the course of asking whether
// to proceed.
//
// Timing: the description is rendered from the same inputs that are
// fingerprinted, at the moment of asking. It is a description of the call, not
// a promise about the world — the underlying rows can still change between
// asking and executing. Where that matters, the handler re-checks; see
// assignments.go for the one case where it does.
/* ── What a person is asked to approve ──────────────────────────────────── */
// Confirmation is a pending write, described for a human.
type Confirmation struct {
// Token is what resolves this confirmation. Opaque, single-use, and bound
// to the exact call it was issued for.
Token string `json:"token"`
Tool string `json:"tool"`
// Title is one line, plain language, no ids. "Assign Maya Chen to Bar
// Supervisor".
Title string `json:"title"`
// Summary says what will happen if this is approved, in a sentence a
// person can hold against their own intent.
Summary string `json:"summary"`
// Details are the specifics, resolved to names rather than ids. Rendered as
// a list beside the summary.
Details []Detail `json:"details,omitempty"`
// Warnings are things the person should know before saying yes — a clash,
// an overtime threshold, a role already filled. Present precisely because
// the model is not trusted to surface them.
Warnings []string `json:"warnings,omitempty"`
ExpiresAt time.Time `json:"expiresAt"`
}
// Detail is one labelled fact in a confirmation.
type Detail struct {
Label string `json:"label"`
Value string `json:"value"`
}
// Confirmer renders what a write will do, before it does it.
//
// Returns either a description or a refusal, never both. The refusal is the
// same opaque Denied() every handler returns: a renderer that explained why it
// could not describe something would answer, at confirmation time, the question
// the denial exists to leave unanswered.
//
// A renderer must not write anything. It runs before any approval exists.
type Confirmer func(ctx context.Context, tc Context, inputs json.RawMessage) (*Confirmation, *Result)
/* ── The binding ────────────────────────────────────────────────────────── */
// binding is the fingerprint a token is issued against.
type binding struct {
Tool string
InputsHash string
UserID string
OrgID string
RunID string
// Inputs are the arguments themselves, carried alongside their hash so a
// store can record them. The hash is what a re-derived call is MATCHED
// against; these are what a redeemed token REPLAYS. Both paths exist —
// see Store.Redeem for why the second one had to.
Inputs json.RawMessage
// AgentID is whose spec proposed this. Not used to authorise; recorded
// because a redeemed call runs outside any agent's resolved tool list, and
// "which agent offered this" is the question an audit of that asks.
AgentID string
}
// bind fingerprints a call.
//
// RunID is captured for the record rather than for the match.
//
// Inputs are canonicalised before hashing, so a model that reorders keys or
// re-spaces its JSON between the asking turn and the executing turn does not
// invalidate a perfectly good approval. Anything that canonicalisation cannot
// parse is hashed verbatim — a malformed body is not a reason to widen what a
// token matches.
func bind(tc Context, tool string, inputs json.RawMessage) binding {
canonical := canonicalJSON(inputs)
sum := sha256.Sum256(canonical)
return binding{
Tool: tool,
InputsHash: hex.EncodeToString(sum[:]),
UserID: tc.Principal.UserID,
OrgID: tc.Principal.OrgID,
RunID: tc.RunID,
Inputs: json.RawMessage(canonical),
AgentID: tc.AgentID,
}
}
// matches reports whether two bindings are the same call.
//
// RunID is deliberately absent — see the note at the top of this file. Every
// other field is compared, and the hash is compared in constant time: the token
// itself is unguessable so this is not the load-bearing secret, but a
// comparison that leaks where two inputs first differ is a comparison worth not
// writing in the first place.
func (b binding) matches(other binding) bool {
return b.Tool == other.Tool &&
b.UserID == other.UserID &&
b.OrgID == other.OrgID &&
subtle.ConstantTimeCompare([]byte(b.InputsHash), []byte(other.InputsHash)) == 1
}
// canonicalJSON re-encodes a JSON document with object keys sorted.
//
// Two calls that mean the same thing must fingerprint the same, or a person
// would be asked to approve the identical write twice because the model
// happened to emit its arguments in a different order.
func canonicalJSON(raw json.RawMessage) []byte {
if len(raw) == 0 {
return []byte("null")
}
var v any
if err := json.Unmarshal(raw, &v); err != nil {
return raw
}
var b strings.Builder
writeCanonical(&b, v)
return []byte(b.String())
}
func writeCanonical(b *strings.Builder, v any) {
switch t := v.(type) {
case map[string]any:
keys := make([]string, 0, len(t))
for k := range t {
keys = append(keys, k)
}
sort.Strings(keys)
b.WriteByte('{')
for i, k := range keys {
if i > 0 {
b.WriteByte(',')
}
encoded, _ := json.Marshal(k)
b.Write(encoded)
b.WriteByte(':')
writeCanonical(b, t[k])
}
b.WriteByte('}')
case []any:
b.WriteByte('[')
for i, item := range t {
if i > 0 {
b.WriteByte(',')
}
writeCanonical(b, item)
}
b.WriteByte(']')
default:
encoded, _ := json.Marshal(t)
b.Write(encoded)
}
}
/* ── Where pending confirmations live ───────────────────────────────────── */
// ConfirmationTTL is how long an unanswered confirmation stays answerable.
//
// Bounded because an approval is a judgement about a moment. A yes clicked on
// a two-day-old "assign Maya to Friday's shift" is a yes to a question whose
// answer has probably changed, and there is no way for the person clicking to
// know that. Expiring forces the question to be asked again against current
// facts.
const ConfirmationTTL = 30 * time.Minute
// Store holds confirmations between being asked and being answered.
//
// Two operations, and the second is the interesting one: Resolve must be
// atomic. Two concurrent calls carrying the same token must not both succeed,
// or a single approval buys two writes — which is the same hole the binding
// closes, arriving by a different door.
type Store interface {
// Issue records a pending confirmation and returns its token.
Issue(ctx context.Context, b binding, c *Confirmation) error
// Resolve consumes a token, reporting whether it authorises this exact
// call. A token that does not exist, has expired, has already been used or
// was issued for a different call all return false — indistinguishably,
// because telling them apart is an oracle over other people's pending
// approvals, and because the caller's response to all four is the same:
// describe the call and ask again.
//
// Only a matching token is consumed. A mismatch must leave the token
// spendable by the call it was issued for.
Resolve(ctx context.Context, token string, b binding) bool
// Redeem consumes a token and returns the call it authorised.
//
// The difference from Resolve is which direction the arguments travel, and
// it is the whole reason this exists. Resolve is handed a call and asked
// "was this approved?" — which requires the model to have produced the same
// call again. Redeem is handed only the token and asked "what was
// approved?", so honouring an approval does not depend on a model
// reproducing itself.
//
// The caller is still checked: a token belongs to one principal in one
// tenant, and Redeem refuses one presented by anybody else. What it does
// NOT check is the arguments, because it is the source of them.
Redeem(ctx context.Context, token string, p Principal) (Approved, bool)
}
// Principal identifies who is redeeming, without the whole identity.
//
// Deliberately just the two fields a token is bound to. A Store has no business
// with a caller's role or email — it is answering "is this the person who was
// asked?", not "may this person do things?", and the second question was
// already settled when the confirmation was raised.
type Principal struct {
UserID string
OrgID string
}
// Approved is a call a person authorised.
type Approved struct {
Tool string
Inputs json.RawMessage
AgentID string
}
// newToken returns an unguessable confirmation token.
func newToken() (string, error) {
var b [24]byte
if _, err := rand.Read(b[:]); err != nil {
return "", fmt.Errorf("tools: no randomness for a confirmation token: %w", err)
}
return "cnf_" + hex.EncodeToString(b[:]), nil
}
/* ── In-memory store ────────────────────────────────────────────────────── */
// MemoryStore keeps confirmations in this process.
//
// Correct for a single instance and for tests. It is deliberately NOT the
// default in wiring: behind more than one replica, the approval would land on
// whichever instance the callback happened to reach, and roughly half of all
// approvals would be refused for no reason a user could act on. See
// PostgresStore.
type MemoryStore struct {
mu sync.Mutex
pending map[string]pendingRecord
now func() time.Time
}
type pendingRecord struct {
binding binding
inputs json.RawMessage
agentID string
expiresAt time.Time
}
// NewMemoryStore builds an empty store.
func NewMemoryStore() *MemoryStore {
return &MemoryStore{pending: map[string]pendingRecord{}, now: time.Now}
}
// Issue records a pending confirmation.
func (s *MemoryStore) Issue(_ context.Context, b binding, c *Confirmation) error {
s.mu.Lock()
defer s.mu.Unlock()
s.pending[c.Token] = pendingRecord{
binding: b, inputs: b.Inputs, agentID: b.AgentID, expiresAt: c.ExpiresAt,
}
return nil
}
// Redeem consumes a token and returns what it authorised.
func (s *MemoryStore) Redeem(_ context.Context, token string, p Principal) (Approved, bool) {
s.mu.Lock()
defer s.mu.Unlock()
rec, ok := s.pending[token]
if !ok {
return Approved{}, false
}
if s.now().After(rec.expiresAt) {
return Approved{}, false
}
// The caller has to be the one who was asked. Same tenant, same person.
if rec.binding.UserID != p.UserID || rec.binding.OrgID != p.OrgID {
return Approved{}, false
}
delete(s.pending, token)
return Approved{Tool: rec.binding.Tool, Inputs: rec.inputs, AgentID: rec.agentID}, true
}
// Resolve consumes a token if it authorises this call.
//
// Lookup, check and delete all happen under one lock, which is what makes
// single-use mean single-use rather than "usually single-use": two goroutines
// arriving together cannot both find the token present.
//
// A token that does not match this call is left alone rather than spent. It was
// issued for some other call, and that call may still be about to arrive — in
// the same turn, even. Spending it here would refuse the write the person
// actually approved.
func (s *MemoryStore) Resolve(_ context.Context, token string, b binding) bool {
s.mu.Lock()
defer s.mu.Unlock()
rec, ok := s.pending[token]
if !ok {
return false
}
if s.now().After(rec.expiresAt) || !rec.binding.matches(b) {
return false
}
delete(s.pending, token)
return true
}