Files
LocalAI/core/services/addons/gateway/credentials.go
T
Ettore Di Giacinto e790271c63 feat(addons): let the GitHub add-on write, under a per-connection write mode
A connection now has a write mode, chosen on the add-on page: off (the
default), propose (each change waits for the person to confirm) or auto
(a chat of the person makes it at once). The host enforces it for every
write tool of an add-on that holds a credential, before the add-on is
called, and holds the call to the repository list of the connection.

Host:
- Binding.WriteMode, stored in both stores and in the connector API
  (write_mode). Absent means off, so existing bindings stay read only.
- A credentialed add-on may declare write tools. They name the resource
  with the credential slot argument, never resourceId. A tool can set
  approval "always", a ceiling that keeps a proposal even in auto.
- The proposal broker fetches the credential for the preview and again
  for the apply, so it is never stored. A mode lowered between the
  proposal and the confirmation refuses the apply. A proposal that
  awaits a person cannot be confirmed by a chat turn.
- New capability name tools.write for the roles a connector kind
  declares.

Add-on, tools (kind write): comment-issue, comment-pr, create-issue,
edit-issue (title, close or reopen), add-labels, review-comment-pr.
They preview plain facts, refuse a stale target, cap and screen the
text for secrets, and keep a receipt journal plus a hidden marker in the
text, so a change whose answer was lost is not sent twice.

Assisted-by: Claude:claude-sonnet-5-5 go gofmt ginkgo node
2026-11-03 14:53:20 +00:00

136 lines
4.5 KiB
Go

// SPDX-License-Identifier: MIT
package gateway
import (
"context"
"errors"
"fmt"
"github.com/mudler/LocalAI/pkg/addons/contract"
)
// Codes of a CredentialRefusal. They are also the wire types of the owner's
// 403 answer, so a forwarding frontend gives the answer the owner gave.
const (
// CodeResourceNotAllowed means the resource the call names is not on the
// list of any binding of the person.
CodeResourceNotAllowed = "resource_not_allowed"
// CodeNoCredential means the person has no binding that holds a
// credential for the add-on.
CodeNoCredential = "no_credential"
// CodeWriteNotAllowed means the binding that holds the credential has its
// write mode off, so a write tool does not run.
CodeWriteNotAllowed = "write_not_allowed"
)
// Write modes of a binding, as the host stores them. A binding with none is
// WriteOff.
const (
WriteOff = "off"
WritePropose = "propose"
WriteAuto = "auto"
)
// CredentialGrant is what the owner adds to a call of an add-on that declares
// credentials. It is host-made: no argument of the call and nothing the add-on
// says can set it.
type CredentialGrant struct {
// Credential is the secret of the binding, unsealed for this call.
Credential string
// ResourceKey and ResourceID are the entry of the list that matched. Both
// are empty for a call that names no resource.
ResourceKey, ResourceID string
// WriteMode is the write mode of the binding the credential comes from:
// WriteOff, WritePropose or WriteAuto. Empty means WriteOff. It decides
// whether a write tool runs and whether a person must confirm it.
WriteMode string
}
// CredentialSource finds the credential and the allowed resource for a call.
// It is the owner's: the sealed secrets are not on a frontend. An error it
// returns that is not a *CredentialRefusal is an outage.
type CredentialSource interface {
// Credential returns the grant for actor and add-on. A non-empty key must
// be on the list of a binding of the person (compared without regard to
// case). The host calls it before the add-on is called.
Credential(ctx context.Context, actor contract.Actor, addonID, key string) (CredentialGrant, error)
}
// CredentialRefusal is the host's refusal of a call before the add-on was
// called. Nothing was sent. It is a denial, and a repeat of the same call is
// denied again until the person changes their binding.
type CredentialRefusal struct {
Code string
// Allowed lists the resources of the person's bindings, for
// CodeResourceNotAllowed, so the model can pick a listed one.
Allowed []string
}
func (r *CredentialRefusal) Error() string {
return fmt.Sprintf("the host refused the call: %s", r.Code)
}
// Unwrap makes a refusal a plain denial for callers that do not tell it apart.
func (r *CredentialRefusal) Unwrap() error { return ErrForbidden }
// scrub drops the fields of a request that only the host sets. A client can
// send any JSON in a request, so every door that takes one calls it first.
func scrub(r *contract.Request) {
r.Credential, r.ResourceKey = "", ""
}
// credentialize adds the credential and the allowlisted resource to r when
// the add-on declares credentials. A tool of such an add-on never runs without
// them: a gateway with no source refuses it as unavailable. Anything else
// leaves r as it is.
func (g *Gateway) credentialize(ctx context.Context, a contract.Actor, r *contract.Request) error {
src, ok := g.toolSource().(CapabilitySource)
if !ok {
return nil
}
caps, err := src.DeclaredCapabilities(ctx, r.AddonID, r.Generation)
if err != nil {
// Not knowing whether the add-on declares credentials is not a reason
// to run its tool without the check.
return ErrUnavailable
}
if len(caps.Credentials) == 0 {
return nil
}
if r.ResourceID != "" {
return ErrForbidden
}
if g.Credentials == nil {
return ErrUnavailable
}
tool, named := NamedTool(r.Arguments)
if !named {
return ErrForbidden
}
slot := caps.Credentials[0]
key := ""
for _, d := range caps.ToolDefinitions {
if d.Name != tool {
continue
}
k, _, err := slot.CredentialResource(d, r.Arguments)
if err != nil {
return fmt.Errorf("%w: %w", ErrInvalidRequest, contract.ErrRefused)
}
key = k
}
grant, err := g.Credentials.Credential(ctx, a, r.AddonID, key)
if err != nil {
var refusal *CredentialRefusal
if errors.As(err, &refusal) {
return refusal
}
return ClassifyCheck(err)
}
r.Credential, r.ResourceKey = grant.Credential, grant.ResourceKey
if grant.ResourceID != "" {
r.ResourceID = grant.ResourceID
}
return nil
}