feat: enforce handoff allowlist with default-open policy

Add AllowlistChecker interface and wire it into HandoffTool to control
which agents can delegate to which. Default behavior is open (allow all)
when no subagents config is present; enforces allow_agents list when
configured via CanSpawnSubagent.
This commit is contained in:
Leandro Barbosa 2026-02-18 15:03:48 -03:00
parent eb707ff724
commit f73c153578
3 changed files with 44 additions and 4 deletions

View file

@ -181,7 +181,23 @@ func registerSharedTools(cfg *config.Config, msgBus *bus.MessageBus, registry *A
agent.Tools.Register(multiagent.NewBlackboardTool(placeholderBoard, agentID))
// Handoff tool: delegate tasks to other agents
agent.Tools.Register(multiagent.NewHandoffTool(resolver, placeholderBoard, agentID))
handoffTool := multiagent.NewHandoffTool(resolver, placeholderBoard, agentID)
// Allowlist checker: default-open when no subagents config,
// enforces allow_agents when configured.
currentAgentIDForHandoff := agentID
handoffTool.SetAllowlistChecker(multiagent.AllowlistCheckerFunc(func(from, to string) bool {
parent, ok := registry.GetAgent(from)
if !ok {
return false
}
// Default open: if no allowlist configured, allow all handoffs
if parent.Subagents == nil || parent.Subagents.AllowAgents == nil {
return true
}
return registry.CanSpawnSubagent(currentAgentIDForHandoff, to)
}))
agent.Tools.Register(handoffTool)
// List agents tool: discover available agents
agent.Tools.Register(multiagent.NewListAgentsTool(resolver))

View file

@ -16,6 +16,18 @@ type AgentResolver interface {
ListAgents() []AgentInfo
}
// AllowlistChecker determines whether a handoff from one agent to another is allowed.
type AllowlistChecker interface {
CanHandoff(fromAgentID, toAgentID string) bool
}
// AllowlistCheckerFunc adapts a function to the AllowlistChecker interface.
type AllowlistCheckerFunc func(fromAgentID, toAgentID string) bool
func (f AllowlistCheckerFunc) CanHandoff(fromAgentID, toAgentID string) bool {
return f(fromAgentID, toAgentID)
}
// AgentInfo is a minimal view of an agent for handoff purposes,
// decoupled from the full AgentInstance to avoid circular imports.
type AgentInfo struct {

View file

@ -18,6 +18,7 @@ type HandoffTool struct {
depth int // current handoff depth (0 = top-level)
visited []string // agent IDs already in the call chain
maxDepth int // max allowed depth (0 = use DefaultMaxHandoffDepth)
allowlistChecker AllowlistChecker // optional; nil = allow all
}
// NewHandoffTool creates a handoff tool bound to a specific source agent.
@ -94,6 +95,12 @@ func (t *HandoffTool) SetBoard(board *Blackboard) {
t.board = board
}
// SetAllowlistChecker sets an optional checker that controls which agents
// can be handed off to. If nil, all handoffs are allowed.
func (t *HandoffTool) SetAllowlistChecker(checker AllowlistChecker) {
t.allowlistChecker = checker
}
// SetContext updates the origin channel and chat ID for handoff routing.
func (t *HandoffTool) SetContext(channel, chatID string) {
t.originChannel = channel
@ -122,6 +129,11 @@ func (t *HandoffTool) Execute(ctx context.Context, args map[string]any) *tools.T
return tools.ErrorResult("agent_id or capability is required")
}
// Allowlist check: if a checker is set and it denies the handoff, block it.
if t.allowlistChecker != nil && !t.allowlistChecker.CanHandoff(t.fromAgentID, agentID) {
return tools.ErrorResult(fmt.Sprintf("handoff from %q to %q not allowed by policy", t.fromAgentID, agentID))
}
// Parse optional context map
var contextMap map[string]string
if ctxRaw, ok := args["context"].(map[string]any); ok {