Download
339 lines 10.6 KiBGoModified 2026-10-31 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339
// Package tooldef reads the agent's tools. A tool is a bash script the
// model can call with typed arguments. Each tool is one file, <Name>.sh,
// in the config directory's tools/ directory (or built into blinker), that
// describes itself in a header:
//
//	#!/bin/bash
//	# blinker-tool
//	# description: Commit the given files with a message.
//	# arg message string: The commit message.
//	# arg files path...: The files to commit.
//	# write: .git
//	# read: .git/hooks
//	msg=$1; shift
//	git commit -m "$msg" -- "$@"
//
// The header is the comment lines from "# blinker-tool" to the first line
// that is not a comment. In it:
//
//	description: TEXT          what the tool does, for the model (repeat
//	                           for more lines)
//	arg NAME TYPE: TEXT        an argument; TYPE is string, path, integer,
//	                           boolean or enum(a|b|c), and a "..." after the
//	                           last argument's type makes it take a list
//	hide|read|write: PATH      the tool's own file rules, in order
//	network: HOST              a host the tool may reach
//	timeout: DURATION          such as 5m
//
// Blinker checks the arguments and passes them to the script as $1, $2,
// ... (a list as the rest), never as script text; paths are passed
// absolute. The file rules are added after the policy's while the tool
// runs (the floor still applies after them), so a tool can do what the
// model otherwise cannot. They reach everything the script runs, so they
// belong only on tools that do not run code from the project.
//
// A tool does nothing until a policy's tool rules allow it (see
// policy.ToolAccess): a dropped-in file cannot grant itself access.
package tooldef

import (
	"cmp"
	"embed"
	"errors"
	"fmt"
	"io/fs"
	"maps"
	"os"
	"path/filepath"
	"slices"
	"strings"
	"time"

	"blinker/internal/config"
	"blinker/internal/policy"
)

// Def is a parsed tool.
type Def struct {
	Name        string
	Source      string // the file it came from, or BuiltinSource
	Description string
	Args        []Arg
	Files       []policy.FileRule // only Path and Access are set
	Network     []string          // hosts it may reach
	Timeout     time.Duration
	Script      string // the whole file
	// Access is set from the policy: allow (run without asking, on top of
	// safe mode's access), ask (run once the user approves, on top of
	// unsafe mode's), or deny.
	Access policy.NetPerm
}

// Arg is one argument of a tool.
type Arg struct {
	Name        string
	Type        string // string, path, integer, boolean or enum
	Values      []string
	Variadic    bool
	Description string
}

// BuiltinSource is the Source of a tool built into blinker.
const BuiltinSource = "built in"

// maxScriptBytes bounds a tool's file, which is passed as one argument.
const maxScriptBytes = 64 << 10

//go:embed tools/*.sh
var defaultTools embed.FS

// filePerms are the header keys for a tool's file rules.
var filePerms = map[string]policy.Perm{"hide": policy.None, "read": policy.Read, "write": policy.Write}

// Parse reads a tool file. Anything malformed is an error.
func Parse(name, source string, data []byte) (Def, error) {
	def := Def{Name: name, Source: source, Script: string(data)}
	fail := func(line int, format string, args ...any) (Def, error) {
		return Def{}, fmt.Errorf("%s:%d: %s", source, line, fmt.Sprintf(format, args...))
	}
	if err := policy.ValidToolName(name); err != nil {
		return Def{}, fmt.Errorf("%s: %w", source, err)
	}
	if len(data) > maxScriptBytes {
		return Def{}, fmt.Errorf("%s: over %d KB", source, maxScriptBytes>>10)
	}
	if strings.ContainsRune(def.Script, 0) {
		return Def{}, fmt.Errorf("%s: not a text file", source)
	}
	lines := strings.Split(def.Script, "\n")
	i := 0
	if i < len(lines) && strings.HasPrefix(lines[i], "#!") {
		i++
	}
	if i >= len(lines) || strings.TrimSpace(lines[i]) != "# blinker-tool" {
		return fail(i+1, `a tool starts with a "# blinker-tool" line (after any #! line)`)
	}
	var description []string
	for i++; i < len(lines); i++ {
		line := strings.TrimSpace(lines[i])
		if !strings.HasPrefix(line, "#") {
			break
		}
		body := strings.TrimSpace(strings.TrimPrefix(line, "#"))
		if body == "" {
			continue
		}
		key, value, ok := strings.Cut(body, ":")
		if !ok {
			return fail(i+1, "header lines are \"key: value\"; end the header with a line that is not a comment")
		}
		value = strings.TrimSpace(value)
		fields := strings.Fields(key)
		perm, isPerm := filePerms[key]
		switch {
		case key == "description":
			description = append(description, value)
		case len(fields) == 3 && fields[0] == "arg":
			a, err := parseArg(fields[1], fields[2], value)
			if err != nil {
				return fail(i+1, "%v", err)
			}
			if n := len(def.Args); n > 0 && def.Args[n-1].Variadic {
				return fail(i+1, "only the last argument can take a list")
			}
			for _, b := range def.Args {
				if b.Name == a.Name {
					return fail(i+1, "two arguments are named %s", a.Name)
				}
			}
			def.Args = append(def.Args, a)
		case isPerm:
			if err := policy.ValidatePath(value); err != nil {
				return fail(i+1, "%v", err)
			}
			if policy.IsEnvPath(value) || policy.IsVarPath(value) {
				return fail(i+1, "a tool's paths cannot be variables")
			}
			def.Files = append(def.Files, policy.FileRule{Path: value, Access: perm})
		case key == "network":
			host, err := policy.ValidateHost(value)
			if err != nil {
				return fail(i+1, "%v", err)
			}
			def.Network = append(def.Network, host)
		case key == "timeout":
			d, err := time.ParseDuration(value)
			if err != nil || d <= 0 {
				return fail(i+1, "timeout must be a duration such as 5m")
			}
			def.Timeout = d
		default:
			return fail(i+1, "unknown header key %q (known: description, arg, hide, read, write, network, timeout)", key)
		}
	}
	def.Description = strings.Join(description, " ")
	if def.Description == "" {
		return fail(1, "the header needs a description, for the model")
	}
	return def, nil
}

// parseArg reads "arg NAME TYPE: DESCRIPTION".
func parseArg(name, typ, description string) (Arg, error) {
	a := Arg{Name: name, Description: description}
	if !validArgName(name) {
		return a, fmt.Errorf("argument name %q must be lower-case letters, digits and _", name)
	}
	if description == "" {
		return a, fmt.Errorf("argument %s needs a description, for the model", name)
	}
	typ, a.Variadic = strings.CutSuffix(typ, "...")
	if values, ok := strings.CutPrefix(typ, "enum("); ok && strings.HasSuffix(values, ")") {
		a.Type = "enum"
		for _, v := range strings.Split(strings.TrimSuffix(values, ")"), "|") {
			if v == "" {
				return a, fmt.Errorf("argument %s: empty enum value", name)
			}
			a.Values = append(a.Values, v)
		}
		return a, nil
	}
	switch typ {
	case "string", "path", "integer", "boolean":
		a.Type = typ
		return a, nil
	}
	return a, fmt.Errorf("argument %s has type %q; use string, path, integer, boolean or enum(a|b)", name, typ)
}

// validArgName reports whether s is lower-case letters, digits and _,
// starting with a letter or _, at most 64 bytes.
func validArgName(s string) bool {
	if s == "" || len(s) > 64 {
		return false
	}
	for i, c := range s {
		if !(c >= 'a' && c <= 'z' || c == '_' || i > 0 && c >= '0' && c <= '9') {
			return false
		}
	}
	return true
}

// builtinFile returns a built-in tool's file, if there is one.
func builtinFile(name string) ([]byte, bool) {
	data, err := defaultTools.ReadFile("tools/" + name + ".sh")
	return data, err == nil
}

// Load reads the built-in tools (the files in tools/, Commit and Fetch)
// and then the tool files in dir; a file replaces the built-in tool of the
// same name.
func Load(dir string) ([]Def, error) {
	byName := map[string]Def{}
	builtin, err := fs.ReadDir(defaultTools, "tools")
	if err != nil {
		return nil, err
	}
	for _, e := range builtin {
		name := strings.TrimSuffix(e.Name(), ".sh")
		data, err := defaultTools.ReadFile("tools/" + e.Name())
		if err != nil {
			return nil, err
		}
		t, err := Parse(name, BuiltinSource, data)
		if err != nil {
			return nil, err
		}
		byName[name] = t
	}
	for _, t := range []Def{commitTool(), fetchTool()} {
		byName[t.Name] = t
	}
	entries, err := os.ReadDir(dir)
	if err != nil && !errors.Is(err, fs.ErrNotExist) {
		return nil, err
	}
	for _, e := range entries {
		name, ok := strings.CutSuffix(e.Name(), ".sh")
		if !ok || strings.HasPrefix(e.Name(), ".") || !e.Type().IsRegular() {
			continue // editor backups and the like
		}
		path := filepath.Join(dir, e.Name())
		data, err := os.ReadFile(path)
		if err != nil {
			return nil, err
		}
		t, err := Parse(name, path, data)
		if err != nil {
			return nil, err
		}
		byName[name] = t
	}
	return slices.SortedFunc(maps.Values(byName), func(a, b Def) int { return cmp.Compare(a.Name, b.Name) }), nil
}

// Path is where a tool's file goes.
func Path(configDir, name string) string {
	return filepath.Join(configDir, "tools", name+".sh")
}

// Create writes a tool's file if there is none yet, from the built-in
// tool of that name or else a template, and returns its path.
func Create(configDir, name string) (string, error) {
	if err := policy.ValidToolName(name); err != nil {
		return "", err
	}
	path := Path(configDir, name)
	if _, err := os.Stat(path); err == nil {
		return path, nil
	}
	data, ok := builtinFile(name)
	if !ok {
		data = []byte(`#!/bin/bash
# blinker-tool
# description: What the tool does, for the model.
# arg file path: What this argument is.

# The header above ends at the first line that is not a comment.
# Arguments arrive as $1, $2, ... (paths absolute); "..." after the last
# argument's type (such as "path...") makes it take a list, the rest of $@.
# Types: string, path, integer, boolean, enum(a|b|c).
# Lines such as "write: .git", "read: ~/.cache" and "network: example.com"
# give the tool access beyond the policy while it runs. They reach
# everything the script runs, so never give them to a tool that runs code
# from the project (make, tests, package scripts).
# Nothing runs until a policy's tool rules allow the tool.

echo "Hello from $0: $1"
`)
	}
	return path, config.WriteFile(path, data, 0o600)
}

// Mode is the mode a tool runs in: unsafe if the user approves each call,
// safe otherwise.
func (t Def) Mode() policy.Mode {
	if t.Access == policy.Ask {
		return policy.UnsafeMode
	}
	return policy.SafeMode
}

// Policy returns the policy the tool runs under: its mode's, with the
// tool's own rules added (see policy.Compiled.WithTool).
func (t Def) Policy(p policy.Pair) (*policy.Compiled, error) {
	return p.In(t.Mode()).WithTool(t.Name, t.Files, t.Network)
}

// Grants describes a tool's own access for people.
func (t Def) Grants() string {
	var parts []string
	for _, r := range t.Files {
		parts = append(parts, r.Access.String()+" "+r.Path)
	}
	for _, h := range t.Network {
		parts = append(parts, "network "+h)
	}
	return strings.Join(parts, ", ")
}