Files

297 lines
10 KiB
Go

// Package manifest reads and checks plugin.json, and holds the permission
// table: which host methods each permission unlocks (docs/specification.md,
// "Permissions"; docs/host-api.md).
package manifest
import (
"encoding/json"
"errors"
"fmt"
"os"
"regexp"
"sort"
"strings"
)
// HostAPI is the host API version this SDK targets.
const HostAPI = 2
// Plugin is plugin.json.
type Plugin struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Version string `json:"version"`
MinAppVersion string `json:"min_app_version"`
HostAPI int `json:"host_api"`
Color string `json:"color,omitempty"`
Permissions []string `json:"permissions,omitempty"`
FileTypes []string `json:"file_types,omitempty"`
OpensFolders bool `json:"opens_folders,omitempty"`
Tools []Tool `json:"tools,omitempty"`
Provides map[string]Service `json:"provides,omitempty"`
Uses []string `json:"uses,omitempty"`
Dependencies []string `json:"dependencies,omitempty"`
// Fields a publisher may not set, or that wait for a later release.
UI json.RawMessage `json:"ui,omitempty"`
UIHostAPI json.RawMessage `json:"ui_host_api,omitempty"`
Free *bool `json:"free,omitempty"`
Capability string `json:"capability,omitempty"`
HostSeams []string `json:"host_seams,omitempty"`
}
// Tool is one entry of plugin.json "tools".
type Tool struct {
Name string `json:"name"`
Mutating bool `json:"mutating"`
RequiresApproval bool `json:"requires_approval,omitempty"`
}
// Service is one entry of plugin.json "provides".
type Service struct {
Methods map[string]Method `json:"methods"`
}
// Method is one method of a provided service.
type Method struct {
Description string `json:"description"`
Params json.RawMessage `json:"params"`
Result json.RawMessage `json:"result"`
Mutating bool `json:"mutating,omitempty"`
Long bool `json:"long,omitempty"`
}
// Read reads and decodes plugin.json, refusing unknown fields.
func Read(path string) (*Plugin, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
dec := json.NewDecoder(strings.NewReader(string(data)))
dec.DisallowUnknownFields()
var p Plugin
if err := dec.Decode(&p); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
return &p, nil
}
// Always are the host methods every plugin may call.
var Always = []string{
"host.auth.has_capability",
"host.data.publish", "host.data.revoke", "host.data.leases", "host.data.remove",
"host.event", "host.screen.open",
"host.dialog.open", "host.dialog.folder", "host.dialog.save", "host.dialog.message",
"host.plugin.call",
}
// Permission is one permission a plugin may declare.
type Permission struct {
Words string // shown to the admin and the user
Methods []string // host methods it unlocks (empty: enforced by review)
}
// Permissions are the fixed permission tokens. net:<domain> is separate.
var Permissions = map[string]Permission{
"files": {"Open and save files you choose", []string{"host.files.resolve", "host.files.can_write"}},
"files.computer": {"Read and write other files on this computer", nil},
"workspace.read": {"Read files in shared folders you're a member of", []string{"host.workspace.folders", "host.workspace.list", "host.workspace.fetch", "host.workspace.read"}},
"workspace.write": {"Add, change and delete files in shared folders", []string{"host.workspace.put", "host.workspace.delete", "host.workspace.create"}},
"agents": {"Run Cletus in the background", []string{"host.agent.start", "host.agent.status", "host.agent.cancel"}},
"chat.handoff": {"Send work to chat", nil},
"identity": {"See your name and organization", []string{"host.auth.whoami"}},
"net.configured": {"Connect to devices and servers you set up in this plugin", nil},
"processes": {"Run programs on this computer", nil},
}
// NetPrefix starts a net:<domain> permission.
const NetPrefix = "net:"
// MaxNetDomains bounds the net:<domain> permissions.
const MaxNetDomains = 16
var (
idRE = regexp.MustCompile(`^[a-z][a-z0-9-]{1,31}\.[a-z][a-z0-9-]{0,47}$`)
semverRE = regexp.MustCompile(`^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$`)
toolRE = regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`)
serviceRE = regexp.MustCompile(`^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)*$`)
methodRE = regexp.MustCompile(`^[a-z][a-z0-9_]*$`)
useRE = regexp.MustCompile(`^[a-z0-9.-]+:[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)*\.([a-z][a-z0-9_]*|\*)$`)
domainRE = regexp.MustCompile(`^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$`)
extRE = regexp.MustCompile(`^\.[a-z0-9]{1,16}$`)
)
var colors = map[string]bool{"": true, "red": true, "orange": true, "amber": true, "yellow": true, "green": true,
"teal": true, "blue": true, "indigo": true, "violet": true, "pink": true, "grey": true}
// Publisher returns the publisher half of the id.
func (p *Plugin) Publisher() string { publisher, _, _ := strings.Cut(p.ID, "."); return publisher }
// Validate checks the manifest. Every problem is a plain sentence.
func (p *Plugin) Validate() []error {
var errs []error
add := func(format string, args ...any) { errs = append(errs, fmt.Errorf(format, args...)) }
if !idRE.MatchString(p.ID) {
add("id %q must be <publisher>.<name>: lowercase letters, digits and hyphens, starting with a letter", p.ID)
}
if strings.TrimSpace(p.Name) == "" {
add("name is empty")
}
if strings.TrimSpace(p.Description) == "" {
add("description is empty")
}
if !semverRE.MatchString(p.Version) {
add("version %q must be a version like 1.2.0", p.Version)
}
if !semverRE.MatchString(p.MinAppVersion) {
add("min_app_version %q must be a version like 0.5.0", p.MinAppVersion)
}
if p.HostAPI != HostAPI {
add("host_api must be %d", HostAPI)
}
if !colors[p.Color] {
add("color %q is not one of the Cletus colours", p.Color)
}
if p.Free != nil || p.Capability != "" {
add("free and capability are for Eidonix plugins; organizations turn your plugin on instead")
}
if len(p.HostSeams) > 0 {
add("host_seams is for Eidonix plugins; use permissions")
}
if len(p.UI) > 0 || len(p.UIHostAPI) > 0 {
add("screens (ui, ui_host_api) aren't available yet; deliver the feature as chat tools")
}
if len(p.FileTypes) > 0 {
add("file_types need a screen, which isn't available yet")
}
seen := map[string]bool{}
nets := 0
for _, perm := range p.Permissions {
if seen[perm] {
add("permission %q is listed twice", perm)
}
seen[perm] = true
if domain, ok := strings.CutPrefix(perm, NetPrefix); ok {
nets++
if !domainRE.MatchString(domain) {
add("%q: a net permission names one host, like net:api.example.com (no wildcards, ports or IP addresses)", perm)
}
continue
}
if _, ok := Permissions[perm]; !ok {
add("unknown permission %q; see docs/specification.md, Permissions", perm)
}
}
if nets > MaxNetDomains {
add("at most %d net:<domain> permissions", MaxNetDomains)
}
if seen["chat.handoff"] {
add("chat.handoff is for screens, which aren't available yet")
}
tools := map[string]bool{}
for _, t := range p.Tools {
if !toolRE.MatchString(t.Name) {
add("tool name %q must be lowercase letters, digits and underscores", t.Name)
}
if tools[t.Name] {
add("tool %q is listed twice", t.Name)
}
tools[t.Name] = true
}
for name, svc := range p.Provides {
if !serviceRE.MatchString(name) {
add("service name %q must be lowercase, optionally dotted", name)
}
if name == "screen" {
add("the screen service needs a screen, which isn't available yet")
}
for method, m := range svc.Methods {
if !methodRE.MatchString(method) {
add("method %s.%s: names are lowercase letters, digits and underscores", name, method)
}
for label, schema := range map[string]json.RawMessage{"params": m.Params, "result": m.Result} {
var v map[string]any
if len(schema) == 0 || json.Unmarshal(schema, &v) != nil {
add("method %s.%s: %s must be a JSON schema object", name, method, label)
} else if len(schema) > 16<<10 {
add("method %s.%s: %s is larger than 16 KiB", name, method, label)
} else if strings.Contains(string(schema), `"$ref":"http`) || strings.Contains(string(schema), `"$ref": "http`) {
add("method %s.%s: %s may not use a remote $ref", name, method, label)
}
}
}
}
for _, use := range p.Uses {
if !useRE.MatchString(use) {
add("use %q must be <plugin>:<service>.<method> or <plugin>:<service>.*", use)
} else if strings.HasPrefix(use, p.ID+":") {
add("a plugin can't use its own service (%s)", use)
}
}
for _, ext := range p.FileTypes {
if !extRE.MatchString(ext) {
add("file type %q must look like .ext", ext)
}
}
return errs
}
// Allowed reports whether the plugin may call method, and if not, the
// permission that would allow it.
func (p *Plugin) Allowed(method string) (bool, string) {
for _, m := range Always {
if m == method {
return true, ""
}
}
for _, perm := range p.Permissions {
for _, m := range Permissions[perm].Methods {
if m == method {
return true, ""
}
}
}
for perm, def := range Permissions {
for _, m := range def.Methods {
if m == method {
return false, perm
}
}
}
return false, ""
}
// Has reports whether the plugin declares perm.
func (p *Plugin) Has(perm string) bool {
for _, have := range p.Permissions {
if have == perm {
return true
}
}
return false
}
// HasNet reports whether any net permission is declared.
func (p *Plugin) HasNet() bool {
for _, have := range p.Permissions {
if have == "net.configured" || strings.HasPrefix(have, NetPrefix) {
return true
}
}
return false
}
// UsedMethods returns, sorted, the host methods the declared permissions unlock.
func (p *Plugin) UsedMethods() []string {
var out []string
for _, perm := range p.Permissions {
out = append(out, Permissions[perm].Methods...)
}
sort.Strings(out)
return out
}
// ErrNoManifest is returned when there is no plugin.json.
var ErrNoManifest = errors.New("no plugin.json here; run cletus-plugin in the plugin's folder")