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, ", ")
}
|