Cletus plugin kit: rules for coding agents, specification, host API, style guide, worker SDK and cletus-plugin tool

This commit is contained in:
Eidonix committed 2026-11-10 19:50:09 -05:00
commit 9b19d3a8be
41 files changed
+4121

No files matched your search

+483
View File
@@ -0,0 +1,483 @@
package main
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"time"
"git.eidonix.com/eidonix/cletus-plugins/sdk/manifest"
)
// report collects the outcome of each check.
type report struct {
failures, warnings int
}
func (r *report) ok(name string) { fmt.Printf("ok %s\n", name) }
func (r *report) fail(name, detail string) {
r.failures++
fmt.Printf("FAIL %s\n%s", name, indent(detail))
}
func (r *report) warn(name, detail string) {
r.warnings++
fmt.Printf("warn %s\n%s", name, indent(detail))
}
func indent(s string) string {
s = strings.TrimRight(s, "\n")
if s == "" {
return ""
}
return " " + strings.ReplaceAll(s, "\n", "\n ") + "\n"
}
func runCheck(args []string) error {
if len(args) > 0 {
return errors.New("check takes no arguments; run it in the plugin's folder")
}
r := &report{}
plugin, err := readPlugin(".")
if err != nil {
return err
}
if errs := plugin.Validate(); len(errs) > 0 {
var b strings.Builder
for _, e := range errs {
fmt.Fprintln(&b, e)
}
r.fail("plugin.json", b.String())
} else {
r.ok("plugin.json")
}
checkFiles(r, plugin)
own := ownGoFiles()
checkSources(r, own)
checkGoMod(r)
if out, err := goRun(".", goEnv("", ""), "vet", "./..."); err != nil {
r.fail("go vet", out)
} else {
r.ok("go vet")
}
checkImports(r, plugin)
checkDependencies(r)
for _, target := range [][2]string{{"darwin", "arm64"}, {"windows", "amd64"}} {
name := "build " + target[0] + "/" + target[1]
if out, err := goRun(".", goEnv(target[0], target[1]), "build", "-trimpath", "-o", os.DevNull, "./cmd/worker"); err != nil {
r.fail(name, out)
} else {
r.ok(name)
}
}
if out, err := goRun(".", goEnv("", ""), "test", "-count=1", "./..."); err != nil {
r.fail("go test", out)
} else {
r.ok("go test")
}
optionalTool(r, "staticcheck", "./...")
optionalTool(r, "govulncheck", "./...")
checkRuntime(r, plugin)
fmt.Println()
switch {
case r.failures > 0:
return fmt.Errorf("%d check(s) failed, %d warning(s)", r.failures, r.warnings)
case r.warnings > 0:
fmt.Printf("All checks passed with %d warning(s). Explain each in docs/plan.md; reviewers read them.\n", r.warnings)
default:
fmt.Println("All checks passed.")
}
return nil
}
func checkFiles(r *report, plugin *manifest.Plugin) {
var problems []string
changelog, err := os.ReadFile("CHANGELOG.md")
if err != nil {
problems = append(problems, "CHANGELOG.md is missing")
} else if !regexp.MustCompile(`(?m)^#+\s*v?` + regexp.QuoteMeta(plugin.Version) + `\b`).Match(changelog) {
problems = append(problems, "CHANGELOG.md has no heading for version "+plugin.Version)
}
for _, path := range []string{"docs/plan.md", "docs/guide.md", "go.mod", "cmd/worker"} {
if _, err := os.Stat(path); err != nil {
problems = append(problems, path+" is missing")
}
}
if plan, err := os.ReadFile("docs/plan.md"); err == nil {
for _, perm := range plugin.Permissions {
if !strings.Contains(string(plan), perm) {
problems = append(problems, "docs/plan.md doesn't explain the "+perm+" permission")
}
}
}
if len(problems) > 0 {
r.fail("repository files", strings.Join(problems, "\n"))
} else {
r.ok("repository files")
}
}
// ownGoFiles lists the plugin's own Go files (not vendor/, not hidden folders).
func ownGoFiles() []string {
var files []string
_ = filepath.WalkDir(".", func(path string, d os.DirEntry, err error) error {
if err != nil {
return nil
}
if d.IsDir() && path != "." && (d.Name() == "vendor" || strings.HasPrefix(d.Name(), ".") || d.Name() == "node_modules") {
return filepath.SkipDir
}
if !d.IsDir() && strings.HasSuffix(path, ".go") {
files = append(files, path)
}
return nil
})
return files
}
var (
secretRE = regexp.MustCompile(`-----BEGIN [A-Z ]*PRIVATE KEY-----|AKIA[0-9A-Z]{16}|\bsk-[A-Za-z0-9_-]{20,}|\bghp_[A-Za-z0-9]{30,}|\bxox[abp]-[A-Za-z0-9-]{10,}`)
fileOpRE = regexp.MustCompile(`\bos\.(Open|OpenFile|ReadFile|WriteFile|Create|Remove|RemoveAll|ReadDir|Rename|Mkdir|MkdirAll)\(`)
linknameRE = regexp.MustCompile(`//go:linkname`)
)
func checkSources(r *report, files []string) {
var gofmtBad, cgo, secrets, linkname, fileOps []string
if len(files) > 0 {
out, _ := exec.Command("gofmt", append([]string{"-l"}, files...)...).Output()
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if line != "" {
gofmtBad = append(gofmtBad, line)
}
}
}
_ = filepath.WalkDir(".", func(path string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() {
if d != nil && d.IsDir() && path != "." && strings.HasPrefix(d.Name(), ".") {
return filepath.SkipDir
}
return nil
}
info, err := d.Info()
if err != nil || info.Size() > 4<<20 {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil
}
text := string(data)
vendored := strings.HasPrefix(filepath.ToSlash(path), "vendor/")
if strings.HasSuffix(path, ".go") && regexp.MustCompile(`(?m)^import\s+"C"`).MatchString(text) {
cgo = append(cgo, path)
}
if !vendored && secretRE.MatchString(text) {
secrets = append(secrets, path)
}
if strings.HasSuffix(path, ".go") && !vendored {
if linknameRE.MatchString(text) {
linkname = append(linkname, path)
}
if !strings.HasSuffix(path, "_test.go") && fileOpRE.MatchString(text) {
fileOps = append(fileOps, path)
}
}
return nil
})
if len(gofmtBad) > 0 {
r.fail("gofmt", "not formatted (run gofmt -w):\n"+strings.Join(gofmtBad, "\n"))
} else {
r.ok("gofmt")
}
if len(cgo) > 0 {
r.fail("no cgo", "these files use cgo, which Cletus plugins can't:\n"+strings.Join(cgo, "\n"))
} else {
r.ok("no cgo")
}
if len(secrets) > 0 {
r.fail("no secrets", "these files look like they hold a key or token:\n"+strings.Join(secrets, "\n"))
} else {
r.ok("no secrets")
}
if len(linkname) > 0 {
r.fail("no go:linkname", strings.Join(linkname, "\n"))
}
if len(fileOps) > 0 {
r.warn("direct file access", "these files open, write or delete files directly. Each path must come from\nhost.files.resolve, a dialog, or pluginsdk.DataDir(); anything else needs files.computer:\n"+strings.Join(fileOps, "\n"))
}
}
func checkGoMod(r *report) {
data, err := os.ReadFile("go.mod")
if err != nil {
return
}
text := string(data)
var problems []string
if regexp.MustCompile(`(?m)^\s*replace\b|=>`).MatchString(text) {
problems = append(problems, "go.mod has replace directives; the store builds only from go.mod and vendor/")
}
if strings.Contains(text, "require") {
if _, err := os.Stat("vendor/modules.txt"); err != nil {
problems = append(problems, "vendor/ is missing; run go mod vendor and commit it")
} else if out, err := goRun(".", goEnv("", ""), "build", "-mod=vendor", "-o", os.DevNull, "./cmd/worker"); err != nil {
problems = append(problems, "vendor/ doesn't match go.mod; run go mod vendor:\n"+out)
}
}
if len(problems) > 0 {
r.fail("go.mod and vendor", strings.Join(problems, "\n"))
} else {
r.ok("go.mod and vendor")
}
}
type goPackage struct {
ImportPath string
Imports []string
Standard bool
Module *struct{ Path string }
}
func listPackages(args ...string) ([]goPackage, error) {
cmd := exec.Command("go", append([]string{"list", "-json"}, args...)...)
cmd.Env = goEnv("", "")
out, err := cmd.Output()
if err != nil {
var exit *exec.ExitError
if errors.As(err, &exit) {
return nil, fmt.Errorf("%s", exit.Stderr)
}
return nil, err
}
var pkgs []goPackage
dec := json.NewDecoder(strings.NewReader(string(out)))
for {
var p goPackage
if err := dec.Decode(&p); err == io.EOF {
break
} else if err != nil {
return nil, err
}
pkgs = append(pkgs, p)
}
return pkgs, nil
}
var netPackages = map[string]bool{"net": true, "net/http": true, "net/rpc": true, "net/smtp": true, "crypto/tls": true, "net/http/httputil": true}
func checkImports(r *report, plugin *manifest.Plugin) {
own, err := listPackages("./...")
if err != nil {
r.fail("imports", err.Error())
return
}
var failures, notes []string
for _, p := range own {
for _, imp := range p.Imports {
switch {
case imp == "plugin":
failures = append(failures, p.ImportPath+" imports plugin (loading code at run time is not allowed)")
case imp == "os/exec" && !plugin.Has("processes"):
failures = append(failures, p.ImportPath+" imports os/exec but the plugin doesn't declare processes")
case netPackages[imp] && !plugin.HasNet():
failures = append(failures, p.ImportPath+" imports "+imp+" but the plugin declares no net permission")
case imp == "unsafe" || imp == "reflect" || imp == "syscall" || strings.HasPrefix(imp, "golang.org/x/sys/"):
notes = append(notes, p.ImportPath+" imports "+imp)
}
}
}
if len(failures) > 0 {
r.fail("imports match permissions", strings.Join(failures, "\n"))
} else {
r.ok("imports match permissions")
}
if len(notes) > 0 {
r.warn("low-level imports", "explain each in docs/plan.md:\n"+strings.Join(notes, "\n"))
}
deps, err := listPackages("-deps", "./cmd/worker")
if err != nil {
return
}
var depNotes []string
for _, p := range deps {
if p.Standard || p.Module == nil || isOwnModule(p.Module.Path) {
continue
}
for _, imp := range p.Imports {
if imp == "os/exec" && !plugin.Has("processes") || netPackages[imp] && !plugin.HasNet() {
depNotes = append(depNotes, p.ImportPath+" imports "+imp)
}
}
}
if len(depNotes) > 0 {
sort.Strings(depNotes)
r.warn("dependencies that can start programs or connect", "your plugin doesn't declare this; make sure these code paths are never used:\n"+strings.Join(depNotes, "\n"))
}
}
func isOwnModule(path string) bool {
data, err := os.ReadFile("go.mod")
if err != nil {
return false
}
m := regexp.MustCompile(`(?m)^module\s+(\S+)`).FindStringSubmatch(string(data))
return len(m) == 2 && m[1] == path
}
func checkDependencies(r *report) {
f, err := os.Open("vendor/modules.txt")
if err != nil {
return
}
defer f.Close()
var missing []string
sc := bufio.NewScanner(f)
for sc.Scan() {
fields := strings.Fields(sc.Text())
if len(fields) < 3 || fields[0] != "#" || fields[2] == "=>" {
continue
}
dir := filepath.Join("vendor", filepath.FromSlash(fields[1]))
if _, err := os.Stat(dir); err != nil {
continue // a module with no packages in the build
}
if !hasLicense(dir) {
missing = append(missing, fields[1]+" "+fields[2])
}
}
if len(missing) > 0 {
r.warn("dependency licences", "no licence file found; the store must be able to confirm each licence allows distribution:\n"+strings.Join(missing, "\n"))
} else {
r.ok("dependency licences present")
}
}
func hasLicense(dir string) bool {
entries, err := os.ReadDir(dir)
if err != nil {
return false
}
for _, e := range entries {
name := strings.ToUpper(e.Name())
if strings.HasPrefix(name, "LICENSE") || strings.HasPrefix(name, "LICENCE") || strings.HasPrefix(name, "COPYING") {
return true
}
}
return false
}
func optionalTool(r *report, tool string, args ...string) {
if _, err := exec.LookPath(tool); err != nil {
fmt.Printf("skip %s (not installed; the store runs it)\n", tool)
return
}
cmd := exec.Command(tool, args...)
cmd.Env = goEnv("", "")
out, err := cmd.CombinedOutput()
if err != nil {
r.fail(tool, string(out))
} else {
r.ok(tool)
}
}
// checkRuntime starts the worker and compares its tools with plugin.json.
func checkRuntime(r *report, plugin *manifest.Plugin) {
wd, _ := os.Getwd()
bin, cleanup, err := buildWorker(wd)
if err != nil {
r.fail("worker starts", err.Error())
return
}
defer cleanup()
tmp, err := os.MkdirTemp("", "cletus-plugin-check-")
if err != nil {
r.fail("worker starts", err.Error())
return
}
defer os.RemoveAll(tmp)
log := &syncBuffer{}
host := &testHost{plugin: plugin, filesDir: tmp, dataDir: filepath.Join(tmp, "data"), capabilities: map[string]bool{}, log: log}
if err := host.start(bin); err != nil {
r.fail("worker starts", err.Error()+"\n"+log.String())
return
}
defer host.stop()
r.ok("worker starts and answers the handshake")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var tools []struct {
Def struct {
Function struct {
Name string `json:"name"`
Description string `json:"description"`
} `json:"function"`
} `json:"def"`
Mutating bool `json:"mutating"`
RequiresApproval bool `json:"requires_approval"`
}
if err := host.call(ctx, "tools.list", nil, &tools); err != nil {
r.fail("tools match plugin.json", "tools.list: "+err.Error())
return
}
declared := map[string]manifest.Tool{}
for _, t := range plugin.Tools {
declared[t.Name] = t
}
var problems []string
for _, t := range tools {
name := t.Def.Function.Name
d, ok := declared[name]
switch {
case !ok:
problems = append(problems, "the worker offers "+name+", which plugin.json doesn't list")
case d.Mutating != t.Mutating || d.RequiresApproval != t.RequiresApproval:
problems = append(problems, name+": mutating or requires_approval differs between plugin.json and the worker")
}
if strings.TrimSpace(t.Def.Function.Description) == "" {
problems = append(problems, name+" has no description")
}
delete(declared, name)
}
for name := range declared {
problems = append(problems, "plugin.json lists "+name+", which the worker doesn't offer")
}
if len(problems) > 0 {
sort.Strings(problems)
r.fail("tools match plugin.json", strings.Join(problems, "\n"))
} else {
r.ok("tools match plugin.json")
}
}
// syncBuffer is a strings.Builder safe for the worker's log and the host's
// call log to write at once.
type syncBuffer struct {
mu sync.Mutex
b strings.Builder
}
func (s *syncBuffer) Write(p []byte) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.b.Write(p)
}
func (s *syncBuffer) String() string {
s.mu.Lock()
defer s.mu.Unlock()
return s.b.String()
}