diff --git a/internal/http/agents.go b/internal/http/agents.go index 675332e7..5741ad05 100644 --- a/internal/http/agents.go +++ b/internal/http/agents.go @@ -368,6 +368,18 @@ func (h *AgentsHandler) handleUpdate(w http.ResponseWriter, r *http.Request) { allowed := filterAllowedKeys(updates, agentAllowedFields) allowed["restrict_to_workspace"] = true + // If agent_key is being changed, enforce the slug format. The router + // cache uses `tenantID:agentKey` as its canonical key and splits on the + // last colon for exact-segment invalidation — a colon inside agent_key + // would silently break invalidation. Slug regex already rejects colons + // and any other shell/path-unfriendly characters. + if newKey, ok := allowed["agent_key"].(string); ok && newKey != "" { + if !isValidSlug(newKey) { + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidSlug, "agent_key")) + return + } + } + // Validate v3 flag values in other_config (must be boolean). if oc, ok := allowed["other_config"]; ok && oc != nil { switch v := oc.(type) { diff --git a/internal/http/validate_slug_test.go b/internal/http/validate_slug_test.go new file mode 100644 index 00000000..1cc13352 --- /dev/null +++ b/internal/http/validate_slug_test.go @@ -0,0 +1,41 @@ +package http + +import "testing" + +// TestIsValidSlug covers the slug predicate used by agent_key, skill slug, +// provider name, and MCP server name validation. The slug format is the +// router cache's canonical anchor — the cache splits on the last colon for +// exact-segment invalidation, so the predicate MUST reject any character +// that would collide with that split (notably `:`). +func TestIsValidSlug(t *testing.T) { + cases := []struct { + name string + input string + want bool + }{ + {"simple lowercase", "agent", true}, + {"with hyphen", "goctech-leader", true}, + {"with digits", "agent-42", true}, + {"single char", "a", true}, + {"starts with digit", "1-agent", true}, + + {"empty", "", false}, + {"uppercase rejected", "Agent", false}, + {"starts with hyphen", "-agent", false}, + {"ends with hyphen", "agent-", false}, + {"colon rejected", "weird:key", false}, + {"slash rejected", "weird/key", false}, + {"whitespace rejected", "weird key", false}, + {"dot rejected", "weird.key", false}, + {"tab rejected", "weird\tkey", false}, + {"underscore rejected", "weird_key", false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := isValidSlug(tc.input); got != tc.want { + t.Errorf("isValidSlug(%q) = %v, want %v", tc.input, got, tc.want) + } + }) + } +}