package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"reflect"
"strings"
"craftdesign.group/quarry/internal/ops"
)
// RFC 0027: an agent refused for want of authority its person holds may
// ask them instead. The transports find this through the registry
// (ops.Registry.AskerOf) and call it only when the caller sent
// Quarry-Ask; it decides whether this caller may ask for this call,
// files the request and tells the person.
// personFor is who answers for agent: a personal agent's operator, a
// forge agent's answerable admin. "" for a person, for an agent whose
// person is gone or shut, or for an agent that answers to another
// agent.
func (d Deps) personFor(agent string) string {
acct, ok := d.accountNamed(agent)
if !ok || acct.Operator == "" || acct.Disabled != "" {
return ""
}
person := acct.Operator
if person == forgeOperator {
person = acct.Answerable
}
p, ok := d.accountNamed(person)
if !ok || p.Disabled != "" || p.Operator != "" {
return ""
}
return person
}
// askPerson is the forge's ops.Asker.
func (d Deps) askPerson(ctx context.Context, desc *ops.Descriptor, repo string, in any, reason string) (any, bool, error) {
if d.SignOffs == nil {
return nil, false, nil
}
agent := ops.User(ctx)
person := d.personFor(agent)
if person == "" || !askableFor(desc.Name, person, in) {
return nil, false, nil
}
if !d.agentAsks(agent) {
return nil, true, fmt.Errorf("%s takes no requests from %s: %w", person, agent, ops.ErrForbidden)
}
if !d.mayAsk(ctx, desc, repo, person, in) {
return nil, false, nil
}
// A request is checked when it is asked: one the owner could not
// run, or that names what the agent itself cannot see, is not asked
// at all, and the agent is left with the refusal its plain call
// already got. Asking never says more than refusing would, so no
// distinct answer for a missing repository, a missing agent or
// somebody else's agent is ever given.
if !d.preflightAsk(ctx, desc.Name, person, in) {
return nil, false, nil
}
pickup := pickupCodeOf(ctx)
if err := checkPickupCode(pickup); err != nil {
return nil, true, err
}
if pickup != "" {
if err := pickupAllowedFor(desc); err != nil {
return nil, true, err
}
}
code := ""
if repo != "" {
r, err := d.repo(repo)
if err != nil {
return nil, false, nil
}
code = r.Code
}
raw, err := json.Marshal(in)
if err != nil {
return nil, true, err
}
reason = strings.TrimSpace(reason)
if len(reason) > 200 {
reason = reason[:200]
}
summary := desc.Sentence(in)
if summary == "" {
summary = describeCall(desc.Name, repo, in)
}
s, err := d.SignOffs.RequestSignOff(SignOffRequest{Agent: agent, Owner: person, Op: desc.Name, Repo: repo,
Code: code, Input: string(raw), Summary: summary, Reason: reason, Pickup: pickup})
if err != nil {
if errors.Is(err, ErrPickupBound) || errors.Is(err, ErrPickupMismatch) {
return nil, true, pickupAskError(err)
}
if errors.Is(err, ErrTooManySignOffs) {
return nil, true, fmt.Errorf("%s already has as many requests waiting for %s as it may: %w", agent, person, ops.ErrInvalid)
}
return nil, true, err
}
d.tellPerson(s)
return askedOut{SignOff: s}, true, nil
}
// preflights are the ops that can say, before a request is recorded,
// whether the owner could run them with this input. An op with no entry
// is asked as it always was and judged when the owner approves. caller
// is the asking agent's own context and owner the person's.
var preflights = map[string]func(d Deps, caller, owner context.Context, in any) bool{
"agent.grant": func(d Deps, caller, owner context.Context, in any) bool {
repo := inputField(in, "Repo")
// A repository the agent cannot read does not exist for it, by
// asking as by refusing.
if d.CanRead != nil && !d.CanRead(caller, repo) {
return false
}
as, err := d.agents()
if err != nil || d.agentToPutToWork(owner, as, inputField(in, "Agent")) != nil {
return false
}
if _, err = d.repoFor(owner, repo); err != nil {
return false
}
if inputField(in, "Role") != "" || strings.TrimSpace(inputField(in, "Caps")) != "" {
return d.refuseOutOfScope(owner, as.Operator(inputField(in, "Agent")), repo) == nil
}
return true
},
}
// preflightAsk runs op's preflight, if it has one: false means do not
// ask.
func (d Deps) preflightAsk(caller context.Context, op, owner string, in any) bool {
check, ok := preflights[op]
if !ok {
return true
}
return check(d, caller, ops.WithUser(context.Background(), owner), in)
}
// mayAsk reports whether the calling agent may ask person for this call
// (RFC 0027): inside the agent's own scope, something the person holds,
// on a repository the agent can see. A plan's steps pass the same test.
func (d Deps) mayAsk(ctx context.Context, desc *ops.Descriptor, repo, person string, in any) bool {
// The agent's own scope bounds what it may ask about: a token
// limited to repositories asks only about those, and nothing
// forge-wide.
if tok, byToken := ops.TokenOf(ctx); byToken {
// One exception: an agent asking about its OWN token, for a new
// one or a wider reach. Without it an agent on a token limited to
// repositories could never be given more than it has (#411); the
// person sees and approves exactly what is granted.
if repo == "" && tok.Limited && !ownCredential(desc.Name, ops.User(ctx), in) {
return false
}
if repo != "" && tok.Reaches != nil && !tok.Reaches(repo) {
return false
}
}
// And the person must hold what the call needs. Judged without the
// agent's token: these are the person's own letters, or their site
// flag for an operator op. An op that names no letters is judged
// when it runs, as them.
if desc.Caps != "" {
holds := d.CapsFor != nil && d.CapsFor(person, repo).HasAny(desc.Caps)
if !holds && (desc.Operator || desc.Scope == ops.ScopeFleet) {
holds = d.isSiteAdmin(context.Background(), person)
}
if !holds {
return false
}
}
// A repository the agent cannot read is answered as one that does
// not exist, by asking as by refusing.
if repo != "" && d.CanRead != nil && !d.CanRead(ctx, repo) {
return false
}
return true
}
// ErrTooManySignOffs is a SignOffStore's refusal of a request from an
// agent that already has as many waiting as it may (RFC 0027, Limits).
var ErrTooManySignOffs = errors.New("too many sign-off requests waiting")
// askedOut is what an agent that asked is answered with.
type askedOut struct {
SignOff SignOffRequest `json:"signoff"`
}
// describeCall names a call for the person in words that carry no
// secrets: the op, and the repository and account it names. Never the
// input itself, which may hold a password.
func describeCall(op, repo string, in any) string {
s := op
name := inputField(in, "Agent")
if name == "" {
name = nameOf(in)
}
if name != "" && name != repo {
s += " for " + name
}
if repo != "" {
s += " on " + repo
}
return s
}
// tellPerson reaches the person wherever they are: the page's attention
// stream, push and email, as every sign-off does.
func (d Deps) tellPerson(s SignOffRequest) {
summary := s.Summary
if s.Reason != "" {
summary += ` ("` + s.Reason + `")`
}
d.attend(s.Owner, AttentionEvent{Kind: "signoff", Repo: s.Repo, From: s.Agent, Summary: summary, Link: "/me/requests/" + s.ID})
d.fireSignOff(s.Owner, s.Repo, s.Agent, summary, s.ID)
}
// fireSignOff tells a person about a request beyond an open tab: the
// inbox row and the push at once, and the email through a five-minute
// digest per agent, so a burst is one email (RFC 0028).
func (d Deps) fireSignOff(to, repo, agent, summary, id string) {
payload := func() map[string]any {
p := map[string]any{
"repo": repo, "from": agent, "actor": agent, "summary": summary, "surface": "signoff",
"link": d.link("/me/requests/" + id), "waiting": d.link("/me/requests"),
}
if repo == "" {
p["repoName"] = "Requests" // what the email's header names
}
return p
}
d.fireNotify(NotifyEvent{Workflow: "quarry-signoff", To: to, Payload: payload(), TxnID: "signoff:" + id})
d.fireNotify(NotifyEvent{Workflow: "quarry-signoff-email", To: to, Payload: payload(), TxnID: "signoff-email:" + id})
}
// prefAsk is the agent account's setting that turns asking off: "off",
// or unset for on (RFC 0027, Limits).
const prefAsk = "ask"
// agentAsks reports whether agent may ask its person.
func (d Deps) agentAsks(agent string) bool {
return d.Prefs == nil || d.Prefs.AccountPref(agent, prefAsk) != "off"
}
// inputField is an input's string field by name, or "".
func inputField(in any, field string) string {
v := reflect.ValueOf(in)
if v.Kind() == reflect.Ptr {
v = v.Elem()
}
if v.Kind() != reflect.Struct {
return ""
}
if f := v.FieldByName(field); f.IsValid() && f.Kind() == reflect.String {
return f.String()
}
return ""
}
// ownCredential is a call about the caller's own tokens: a new one for
// itself, or a change to which repositories one of its own reaches.
func ownCredential(op, caller string, in any) bool {
switch op {
case "agent.token.new", "agent.token.repos":
return caller != "" && inputField(in, "Agent") == caller
}
return false
}
// pickupAllowedFor refuses a pickup code on a call that cannot use one:
// its answer holds no credential, or holds one a lapse cannot revoke,
// which must not be held where nobody may collect it (decided by
// lapseOps, the one table of what a lapse revokes).
func pickupAllowedFor(desc *ops.Descriptor) error {
if !desc.HasOnceSecret() {
return fmt.Errorf("this call leaves no credential for a pickup code to collect; leave Quarry-Pickup out: %w", ops.ErrInvalid)
}
if !parkable(desc.Name) {
return fmt.Errorf("this kind of value cannot be held for pickup: nothing could revoke it if it were never collected; leave Quarry-Pickup out: %w", ops.ErrInvalid)
}
return nil
}