224 lines
9.1 KiB
Go
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)
|
|
}
|