agent
This commit is contained in:
223
services/tools/approval.go
Normal file
223
services/tools/approval.go
Normal file
@@ -0,0 +1,223 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user