diff --git a/internal/config/agent_policy.go b/internal/config/agent_policy.go index 05dd4b47..af70e757 100644 --- a/internal/config/agent_policy.go +++ b/internal/config/agent_policy.go @@ -165,7 +165,8 @@ func validateAgentPolicies(cfg *Config) error { // per-server tool allowlist, and any allow-only guard policy. func validateSingleAgentPolicy(policyID string, policy *AgentPolicy, servers map[string]*ServerConfig) error { formattedPolicyID := util.HashIdentifierForLog(policyID) - seenServers := make(map[string]struct{}, len(policy.Servers)) + serverSet := make(map[string]struct{}, len(policy.Servers)) + serverIDs := make([]string, 0, len(policy.Servers)) for _, serverID := range policy.Servers { trimmed := strings.TrimSpace(serverID) if trimmed == "" { @@ -177,20 +178,21 @@ func validateSingleAgentPolicy(policyID string, policy *AgentPolicy, servers map if _, ok := servers[trimmed]; !ok { return fmt.Errorf("gateway.agent_policies[%q].servers references unknown server %q", formattedPolicyID, trimmed) } - if _, dup := seenServers[trimmed]; dup { - return fmt.Errorf("gateway.agent_policies[%q].servers must not contain duplicate server %q", formattedPolicyID, trimmed) - } - seenServers[trimmed] = struct{}{} + serverSet[trimmed] = struct{}{} + serverIDs = append(serverIDs, trimmed) + } + if duplicate, found := util.FindDuplicate(serverIDs); found { + return fmt.Errorf("gateway.agent_policies[%q].servers must not contain duplicate server %q", formattedPolicyID, duplicate) } for serverID, tools := range policy.Tools { if strings.TrimSpace(serverID) != serverID { return fmt.Errorf("gateway.agent_policies[%q].tools server keys must not contain surrounding whitespace", formattedPolicyID) } - if _, ok := seenServers[serverID]; !ok { + if _, ok := serverSet[serverID]; !ok { return fmt.Errorf("gateway.agent_policies[%q].tools references server %q that is not in the policy's servers list", formattedPolicyID, serverID) } - seenTools := make(map[string]struct{}, len(tools)) + toolNames := make([]string, 0, len(tools)) for _, toolName := range tools { trimmed := strings.TrimSpace(toolName) if trimmed == "" { @@ -199,10 +201,10 @@ func validateSingleAgentPolicy(policyID string, policy *AgentPolicy, servers map if trimmed != toolName { return fmt.Errorf("gateway.agent_policies[%q].tools[%q] entries must not contain surrounding whitespace", formattedPolicyID, serverID) } - if _, dup := seenTools[trimmed]; dup { - return fmt.Errorf("gateway.agent_policies[%q].tools[%q] must not contain duplicate tool %q", formattedPolicyID, serverID, trimmed) - } - seenTools[trimmed] = struct{}{} + toolNames = append(toolNames, trimmed) + } + if duplicate, found := util.FindDuplicate(toolNames); found { + return fmt.Errorf("gateway.agent_policies[%q].tools[%q] must not contain duplicate tool %q", formattedPolicyID, serverID, duplicate) } } diff --git a/internal/config/validation_gateway.go b/internal/config/validation_gateway.go index 0021e964..c0806998 100644 --- a/internal/config/validation_gateway.go +++ b/internal/config/validation_gateway.go @@ -133,12 +133,8 @@ func validateAgentIDs(agentIDs []string, defined bool, fieldName string) error { } // Reject duplicate agent IDs: each identity must be unique so per-agent policy, // session, and DIFC state can be attributed deterministically. - seen := make(map[string]struct{}, len(agentIDs)) - for _, id := range agentIDs { - if _, dup := seen[id]; dup { - return fmt.Errorf("gateway.%s must not contain duplicate agent ID %q", fieldName, util.HashIdentifierForLog(id)) - } - seen[id] = struct{}{} + if duplicate, found := util.FindDuplicate(agentIDs); found { + return fmt.Errorf("gateway.%s must not contain duplicate agent ID %q", fieldName, util.HashIdentifierForLog(duplicate)) } return nil } diff --git a/internal/util/collections.go b/internal/util/collections.go index a4cf048c..ea88cb6e 100644 --- a/internal/util/collections.go +++ b/internal/util/collections.go @@ -64,6 +64,19 @@ func DeduplicateStrings(input []string, sorted bool) []string { return out } +// FindDuplicate returns the first item that occurs more than once, if any. +func FindDuplicate[T comparable](items []T) (T, bool) { + seen := make(map[T]struct{}, len(items)) + for _, item := range items { + if _, exists := seen[item]; exists { + return item, true + } + seen[item] = struct{}{} + } + var zero T + return zero, false +} + // StringsToAny converts a []string to []any. func StringsToAny(input []string) []any { out := make([]any, len(input)) diff --git a/internal/util/collections_test.go b/internal/util/collections_test.go index 9b3d7209..8a2b3366 100644 --- a/internal/util/collections_test.go +++ b/internal/util/collections_test.go @@ -204,6 +204,31 @@ func TestDeduplicateStrings(t *testing.T) { } } +func TestFindDuplicate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + items []int + duplicate int + found bool + }{ + {name: "nil items"}, + {name: "unique items", items: []int{1, 2, 3}}, + {name: "finds first duplicate", items: []int{1, 2, 1, 2}, duplicate: 1, found: true}, + {name: "finds zero duplicate", items: []int{1, 0, 2, 0}, duplicate: 0, found: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + duplicate, found := FindDuplicate(tt.items) + assert.Equal(t, tt.found, found) + assert.Equal(t, tt.duplicate, duplicate) + }) + } +} + func TestStringsToAny(t *testing.T) { t.Parallel()