Files
backend_fiesta/services/tools/approval.go
2026-09-23 17:26:13 +05:30

224 lines
9.1 KiB
Go

package tools
import (
"context"
"errors"
"fmt"
"time"
"nearle/utils"
)
// Writes, and the human in front of them.
//
// A write tool is two halves that never run together. `Propose` resolves what
// would happen and returns a card; `Execute` performs it, and is reachable only
// through `Approve` with a signed card in hand. The model can reach the first
// and has no path at all to the second.
//
// ── Nothing auto-executes ───────────────────────────────────────────────────
//
// There is no confidence threshold, no allow-list of writes considered safe,
// and no size below which a change goes through unasked. That is not caution
// for its own sake: this backend has no staging environment — the development
// server points at production and writes are real — so the card is the only
// thing between a model and a merchant's live data.
//
// ── The card shows what was RESOLVED ────────────────────────────────────────
//
// Never the model's phrasing. A person approving "approve this request" must see
// which request, from which branch, for what — by id and by name — because the
// sentence and the action are produced by different things and only one of them
// is checkable.
//
// ── Re-validated at execution ───────────────────────────────────────────────
//
// `Propose` checks the write is possible; `Execute` checks again against the
// live database before doing anything. Between the two, a person read a card —
// and in that time somebody else may have approved the same request, or the
// branch may have been closed. The second check is also what makes replaying a
// card harmless.
// CardExpiryForTests exposes the card lifetime so a test can step past it
// without importing utils or hardcoding a number that would drift.
const CardExpiryForTests = utils.CardTTL
// Proposal is a resolved write, waiting on a person.
type Proposal struct {
// One sentence naming the action, written by the handler rather than the
// model. This is what the button is agreeing to.
Summary string `json:"summary"`
// The specifics, label and value, in the order a person reads them. Ids AND
// names: an id alone is unverifiable, a name alone is ambiguous.
Details []ProposalDetail `json:"details,omitempty"`
// What the person should know before pressing. Absent when there is nothing
// unusual — a warning on every card is a warning on none.
Warning string `json:"warning,omitempty"`
// The signed card. Opaque to the console, which sends it back unchanged.
Card string `json:"card"`
// Resolved arguments, sealed into the card. Never surfaced to the model.
args map[string]any
}
// ProposalDetail is one line on the card.
type ProposalDetail struct {
Label string `json:"label"`
Value string `json:"value"`
}
var (
// ErrNeedsApproval is what a write tool answers with on the model's path.
// Not a failure — the proposal is the correct outcome of asking.
ErrNeedsApproval = errors.New("this change needs approval")
// ErrNotAWrite is returned when a card names a tool that does not write.
ErrNotAWrite = errors.New("that tool does not change anything")
// ErrNotYourCard is a card presented by somebody other than the person it
// was issued to.
ErrNotYourCard = errors.New("this approval belongs to a different session")
)
// ProposeFunc resolves a write without performing it.
type ProposeFunc func(ctx context.Context, req Request) (Proposal, error)
// ExecuteFunc performs a write that a person has approved.
//
// Re-validates. Everything it checked at proposal time may have changed while
// the card was on screen.
type ExecuteFunc func(ctx context.Context, req Request) (Result, error)
// WriteTool builds a Tool from the two halves.
//
// A write tool cannot be constructed with a plain `Handler`, which is the point:
// the only way to get `ScopeWrite` onto a tool is through here, and this wires
// the handler to propose. There is no shape of Tool that writes when called.
func WriteTool(base Tool, propose ProposeFunc, execute ExecuteFunc) Tool {
base.Scope = ScopeWrite
base.propose = propose
base.execute = execute
base.Handler = func(ctx context.Context, req Request) (Result, error) {
// Unreachable in practice — Call intercepts a write before the handler —
// but a write tool whose handler wrote would be a very quiet disaster if
// that ever stopped being true.
return Result{}, ErrNeedsApproval
}
return base
}
// proposeWrite is what Call does instead of running a write tool's handler.
func (r *Registry) proposeWrite(ctx context.Context, tool Tool, req Request, now time.Time) (Result, error) {
if tool.propose == nil {
return Result{}, fmt.Errorf("write tool %q cannot resolve anything", tool.Name)
}
proposal, err := tool.propose(ctx, req)
if err != nil {
// A refusal at proposal time is the useful kind: "that request has
// already been approved" reaches the person before they press anything.
return Result{}, err
}
args := proposal.args
if args == nil {
args = req.Args
}
card, err := utils.MintCard(utils.Card{
Tool: tool.Name,
Args: args,
Userid: req.Caller.Userid,
Tenantid: req.Caller.Tenantid,
}, now)
if err != nil {
return Result{}, err
}
proposal.Card = card
return Result{
Rows: proposal,
Count: 1,
Scope: "nothing has changed yet",
// The model is told, in words it will repeat, that it has not done the
// thing. Without this it reports the action in the past tense.
Note: "This has NOT been done. It is waiting for the person to approve it. " +
"Tell them what will happen and that they need to confirm — do not say it is done.",
}, nil
}
// Approve performs a write a person has agreed to.
//
// The card is verified, matched against the session presenting it, re-checked
// against the agent's allow-list, and audited BEFORE the write leaves — a crash
// mid-write has to leave a trace that it was attempted, and a row written only
// on success is missing exactly when it is needed.
func (r *Registry) Approve(ctx context.Context, agent Agent, raw string, caller Caller) (Result, error) {
started := r.now()
entry := AuditEntry{
At: started, Agent: agent.Name, Tool: "(approval)",
Userid: caller.Userid, Tenantid: caller.Tenantid, Scope: string(ScopeWrite),
}
finish := func(result Result, outcome, detail string, err error) (Result, error) {
entry.Outcome, entry.Detail, entry.Rows = outcome, detail, result.Count
entry.Took = r.now().Sub(started)
r.audit.Write(ctx, entry)
return result, err
}
card, err := utils.ParseCard(raw, started)
if err != nil {
return finish(Result{}, OutcomeRefused, err.Error(), err)
}
entry.Tool = card.Tool
entry.Args = card.Args
// A card is not transferable. Without this, one person's approval could be
// replayed by another — including by somebody in a different shop.
if card.Userid != caller.Userid || card.Tenantid != caller.Tenantid {
return finish(Result{}, OutcomeRefused, "card belongs to another session", ErrNotYourCard)
}
tool, known := r.tools[card.Tool]
if !known {
return finish(Result{}, OutcomeRefused, "unknown tool", fmt.Errorf("%w: %s", ErrUnknownTool, card.Tool))
}
if tool.Scope != ScopeWrite || tool.execute == nil {
return finish(Result{}, OutcomeRefused, "not a write", fmt.Errorf("%w: %s", ErrNotAWrite, card.Tool))
}
// Checked again at approval, not only at proposal: an agent's allow-list
// could have changed, and a card outliving that change must not be a way
// around it.
if !agent.Allows(card.Tool) {
return finish(Result{}, OutcomeRefused, "not on the agent's allow-list",
fmt.Errorf("%w: %s cannot use %s", ErrNotAllowed, agent.Name, card.Tool))
}
if err := satisfies(tool, caller); err != nil {
return finish(Result{}, OutcomeRefused, err.Error(), err)
}
// The card's arguments go through the schema again, exactly as they did on
// the way in.
//
// Not a trust check — the card is signed, so these are the arguments this
// server sealed. It is a types one: the card round-trips through JSON, so
// an integer sealed as `41` comes back as the float64 `41`, and a handler
// reading it as an int would get zero and look up request 0. That is how
// the first version of this failed, and it failed silently — "request 0 is
// not waiting for approval" reads like a stale card rather than a bug.
clean, err := tool.Schema.Validate(card.Args)
if err != nil {
return finish(Result{}, OutcomeRefused, err.Error(), err)
}
// Written before the call, deliberately. Everything above this line is a
// refusal that changed nothing; everything below might have changed
// something, and the trail has to say so even if the process dies.
entry.Args = clean
entry.Outcome, entry.Detail = "approved", "executing"
entry.Took = r.now().Sub(started)
r.audit.Write(ctx, entry)
result, err := tool.execute(ctx, Request{Args: clean, Caller: caller})
if err != nil {
return finish(Result{}, OutcomeFailed, err.Error(), err)
}
return finish(result, OutcomeOK, "", nil)
}