agent build
This commit is contained in:
384
go-api/internal/tools/confirm.go
Normal file
384
go-api/internal/tools/confirm.go
Normal file
@@ -0,0 +1,384 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user