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 }