first commit
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user