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) }