// 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: 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: permission. const NetPrefix = "net:" // MaxNetDomains bounds the net: 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 .: 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: 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 :. or :.*", 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")