385 lines
14 KiB
Go
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
|
|
}
|