Cletus plugin kit: rules for coding agents, specification, host API, style guide, worker SDK and cletus-plugin tool
This commit is contained in:
commit
9b19d3a8be
41 files changed
+4121
No files matched your search
@@ -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()
|
||||
}
|
||||
Reference in new issue
Block a user