Download
119 lines 3.4 KiBGoModified 2026-10-31 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119
package policy

import (
	"fmt"
	"slices"
	"strings"
)

// ToolRule is a policy rule for tools: whether a tool (or every tool,
// "*") may run, allow or deny. The last matching rule wins; a tool no
// rule matches is denied. A tool the agent's safe policy allows runs
// without asking; one only its unsafe policy allows runs once the user
// approves the call (see ToolAccess). Tools themselves are files that
// internal/tooldef reads.
type ToolRule struct {
	Tool   string
	Access NetPerm
	Note   string
	Line   int
	From   string
}

// BuiltinTools are the names tools cannot take.
var BuiltinTools = []string{"Read", "Write", "Patch", "Safe_Bash", "Unsafe_Bash"}

func validName(s string) bool {
	if s == "" || len(s) > 64 {
		return false
	}
	for i, c := range s {
		letter := c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z'
		if !(letter || c == '_' || i > 0 && c >= '0' && c <= '9') {
			return false
		}
	}
	return true
}

// ValidToolName checks a tool's name.
func ValidToolName(name string) error {
	if !validName(name) {
		return fmt.Errorf("tool name %q must be letters, digits and _, starting with a letter", name)
	}
	for _, b := range BuiltinTools {
		if strings.EqualFold(name, b) {
			return fmt.Errorf("%s is a built-in tool", b)
		}
	}
	return nil
}

// ToolAccess is the access the agent's policies' tool rules give each of
// the named tools: Allow if the safe policy allows it, Ask if only the
// unsafe one does, Deny otherwise. In each, the last matching rule wins,
// and none matching denies. A rule naming a tool that is not among names
// is an error, so a typo is not silently ignored.
func ToolAccess(safe, unsafe Layer, names []string) ([]NetPerm, error) {
	for _, l := range []Layer{safe, unsafe} {
		for i, r := range l.File.Tools {
			if r.Tool != "*" && !slices.Contains(names, r.Tool) {
				return nil, fmt.Errorf("%s: there is no tool named %q", l.ruleWhere(r.From, "tool", i, r.Line), r.Tool)
			}
		}
	}
	allows := func(f *File, name string) bool {
		ok := false
		for _, r := range f.Tools {
			if r.Tool == "*" || r.Tool == name {
				ok = r.Access == Allow
			}
		}
		return ok
	}
	out := make([]NetPerm, len(names))
	for i, name := range names {
		switch {
		case allows(safe.File, name):
			out[i] = Allow
		case allows(unsafe.File, name):
			out[i] = Ask
		default:
			out[i] = Deny
		}
	}
	return out, nil
}

// WithTool returns the policy a tool runs under, as the tool sees it: the
// tool's file rules and hosts added after c's, so they win where they
// match. The floor still applies after them.
func (c *Compiled) WithTool(name string, files []FileRule, network []string) (*Compiled, error) {
	tc := *c
	tc.rules = append([]rule(nil), c.rules...)
	tc.net = append([]netRule(nil), c.net...)
	for i, r := range files {
		m, links, err := newMatcher(r.Path, c.Env.Home, c.Env.Project)
		if err != nil {
			return nil, fmt.Errorf("tool %s: %w", name, err)
		}
		tc.rules = append(tc.rules, rule{
			FileRule: FileRule{Path: r.Path, Access: r.Access},
			m:        m,
			where:    fmt.Sprintf("tool %s rule %d", name, i+1),
			links:    links,
		})
	}
	for i, host := range network {
		tc.net = append(tc.net, netRule{
			NetRule: NetRule{Host: host, Access: Allow},
			where:   fmt.Sprintf("tool %s network %d", name, i+1),
		})
	}
	// As in Compile: the tool's rules must not go through a symlink
	// commands may replace.
	if err := tc.checkLinks(); err != nil {
		return nil, err
	}
	return &tc, nil
}