215 lines
7.5 KiB
Go
215 lines
7.5 KiB
Go
package run
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/smallnest/pigo/internal/agentcore"
|
|
)
|
|
|
|
// ToolPolicy is the user-declared tool boundary for a run: the --allowed-tools
|
|
// whitelist and the --disallowed-tools blacklist, already normalized by
|
|
// SplitToolNames. It is passed as one value rather than two adjacent []string
|
|
// parameters so the two lists cannot be swapped at a call site — silently
|
|
// inverting a security boundary is exactly the bug that must be impossible.
|
|
//
|
|
// The zero value means "no restriction" and every operation on it is a no-op.
|
|
type ToolPolicy struct {
|
|
Allow []string
|
|
Deny []string
|
|
}
|
|
|
|
// NewToolPolicy normalizes raw flag values into a policy.
|
|
func NewToolPolicy(allowed, disallowed []string) ToolPolicy {
|
|
return ToolPolicy{Allow: SplitToolNames(allowed), Deny: SplitToolNames(disallowed)}
|
|
}
|
|
|
|
// IsZero reports whether the policy constrains nothing.
|
|
func (p ToolPolicy) IsZero() bool { return len(p.Allow) == 0 && len(p.Deny) == 0 }
|
|
|
|
// SplitToolNames normalizes raw --allowed-tools / --disallowed-tools values into
|
|
// a flat list of tool names. Each value may itself be a comma-separated list, so
|
|
// `--allowed-tools "read,grep"` and `--allowed-tools read --allowed-tools grep`
|
|
// are equivalent. Entries are lowercased and trimmed, and empty entries dropped,
|
|
// so `"read, ,grep"` yields [read grep]. Matching is case-insensitive on purpose:
|
|
// users coming from Claude Code write `Read`/`Bash`, which must hit pigo's
|
|
// `read`/`bash`.
|
|
func SplitToolNames(values []string) []string {
|
|
if len(values) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]string, 0, len(values))
|
|
for _, v := range values {
|
|
for _, part := range strings.Split(v, ",") {
|
|
if n := normalizeToolName(part); n != "" {
|
|
out = append(out, n)
|
|
}
|
|
}
|
|
}
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
// normalizeToolName is the single definition of how a tool name is compared:
|
|
// surrounding whitespace is insignificant and case is ignored.
|
|
//
|
|
// Note this is deliberately more lenient than runtime.filterToolsByName, which
|
|
// backs a skill frontmatter's allowed-tools and matches case-sensitively. The
|
|
// two are not unified: this policy is user-facing CLI input where Claude-Code
|
|
// habits (Read/Bash) must hit read/bash, whereas skill frontmatter is authored
|
|
// against pigo's canonical lowercase names. Keep them separate on purpose.
|
|
func normalizeToolName(s string) string {
|
|
return strings.ToLower(strings.TrimSpace(s))
|
|
}
|
|
|
|
// ToolPolicyError reports --allowed-tools / --disallowed-tools entries that name
|
|
// no existing tool. It is a usage error — the caller maps it to exit code 2 —
|
|
// because silently ignoring a typo is the worst outcome available: the user
|
|
// believes a boundary is in force when it is not.
|
|
type ToolPolicyError struct {
|
|
// UnknownAllowed and UnknownDisallowed are the unrecognized names from each
|
|
// flag. Both are reported in one error so a user with two typos fixes both in
|
|
// one round rather than one per run.
|
|
UnknownAllowed []string
|
|
UnknownDisallowed []string
|
|
// Available is the sorted set of names that would have been accepted.
|
|
Available []string
|
|
}
|
|
|
|
func (e *ToolPolicyError) Error() string {
|
|
var parts []string
|
|
if len(e.UnknownAllowed) > 0 {
|
|
parts = append(parts, fmt.Sprintf("--allowed-tools: unknown tool %s", quoteNames(e.UnknownAllowed)))
|
|
}
|
|
if len(e.UnknownDisallowed) > 0 {
|
|
parts = append(parts, fmt.Sprintf("--disallowed-tools: unknown tool %s", quoteNames(e.UnknownDisallowed)))
|
|
}
|
|
return fmt.Sprintf("%s (available: %s)", strings.Join(parts, "; "), strings.Join(e.Available, ", "))
|
|
}
|
|
|
|
// quoteNames renders names as a comma-separated quoted list.
|
|
func quoteNames(names []string) string {
|
|
out := make([]string, len(names))
|
|
for i, n := range names {
|
|
out[i] = fmt.Sprintf("%q", n)
|
|
}
|
|
return strings.Join(out, ", ")
|
|
}
|
|
|
|
// ValidateToolPolicy checks every allow/deny entry against the assembled tool
|
|
// set. It must run AFTER the full set exists (builtins + memory + task +
|
|
// plugins), because plugin and memory tool names are only known at runtime;
|
|
// validating right after flag parsing would reject legitimate plugin names.
|
|
//
|
|
// An empty tool set (--no-tools) skips validation entirely: there is nothing to
|
|
// constrain, and reporting every name as unknown would be noise.
|
|
func ValidateToolPolicy(tools []agentcore.AgentTool, policy ToolPolicy) error {
|
|
if len(tools) == 0 || policy.IsZero() {
|
|
return nil
|
|
}
|
|
known := toolNameSet(tools)
|
|
unknownAllow := unknownNames(known, policy.Allow)
|
|
unknownDeny := unknownNames(known, policy.Deny)
|
|
if len(unknownAllow) == 0 && len(unknownDeny) == 0 {
|
|
return nil
|
|
}
|
|
available := make([]string, 0, len(known))
|
|
for n := range known {
|
|
available = append(available, n)
|
|
}
|
|
sort.Strings(available)
|
|
return &ToolPolicyError{
|
|
UnknownAllowed: unknownAllow,
|
|
UnknownDisallowed: unknownDeny,
|
|
Available: available,
|
|
}
|
|
}
|
|
|
|
// unknownNames returns the entries of names absent from known, preserving input
|
|
// order and dropping duplicates so a name repeated twice is reported once.
|
|
func unknownNames(known map[string]struct{}, names []string) []string {
|
|
var out []string
|
|
seen := make(map[string]struct{}, len(names))
|
|
for _, n := range names {
|
|
if _, ok := known[n]; ok {
|
|
continue
|
|
}
|
|
if _, dup := seen[n]; dup {
|
|
continue
|
|
}
|
|
seen[n] = struct{}{}
|
|
out = append(out, n)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// toolNameSet indexes a tool set by normalized name.
|
|
func toolNameSet(tools []agentcore.AgentTool) map[string]struct{} {
|
|
set := make(map[string]struct{}, len(tools))
|
|
for _, t := range tools {
|
|
set[normalizeToolName(t.Name())] = struct{}{}
|
|
}
|
|
return set
|
|
}
|
|
|
|
// ApplyToolPolicy narrows a tool set to the allow list and then removes the deny
|
|
// list. Both empty means no restriction and the input is returned unchanged, so
|
|
// the default path is a true no-op.
|
|
//
|
|
// Deny runs after allow, which makes deny win when a name appears on both sides.
|
|
// That ordering is deliberate: the fail-closed reading of a contradictory policy
|
|
// is "do not run it".
|
|
//
|
|
// This filters the set handed to the model, so it sits at the tool-registration
|
|
// layer — strictly before the BeforeToolCall confirmation gate. A removed tool is
|
|
// never advertised and never dispatchable, which is why --approve (which only
|
|
// waives per-call confirmation) cannot widen the boundary.
|
|
func ApplyToolPolicy(tools []agentcore.AgentTool, policy ToolPolicy) []agentcore.AgentTool {
|
|
if len(tools) == 0 || policy.IsZero() {
|
|
return tools
|
|
}
|
|
allowSet := nameSet(policy.Allow)
|
|
denySet := nameSet(policy.Deny)
|
|
out := make([]agentcore.AgentTool, 0, len(tools))
|
|
for _, t := range tools {
|
|
name := normalizeToolName(t.Name())
|
|
if len(allowSet) > 0 {
|
|
if _, ok := allowSet[name]; !ok {
|
|
continue
|
|
}
|
|
}
|
|
if _, denied := denySet[name]; denied {
|
|
continue
|
|
}
|
|
out = append(out, t)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// nameSet indexes already-normalized names for lookup.
|
|
func nameSet(names []string) map[string]struct{} {
|
|
if len(names) == 0 {
|
|
return nil
|
|
}
|
|
set := make(map[string]struct{}, len(names))
|
|
for _, n := range names {
|
|
set[n] = struct{}{}
|
|
}
|
|
return set
|
|
}
|
|
|
|
// ChildToolSet builds the tool set for a task sub-agent: the builtins with
|
|
// "task" removed (the nesting guard capping delegation depth at one), narrowed by
|
|
// the parent's policy.
|
|
//
|
|
// Inheriting the policy is load-bearing, not a nicety. A child that ignored it
|
|
// would be a one-line escape from the boundary: under --disallowed-tools bash the
|
|
// model could dispatch a sub-agent and run bash there instead. Any future spawn
|
|
// path must route through here for the same reason.
|
|
func ChildToolSet(cwd string, policy ToolPolicy) []agentcore.AgentTool {
|
|
return ApplyToolPolicy(BuiltinToolsExcept(cwd, false, "task"), policy)
|
|
}
|