Files
2026-08-14 23:41:57 +08:00

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