feat: add deployment-wide template allowlist for chats (#23262)

- Stores a deployment-wide agents template allowlist in `site_configs`
(`agents_template_allowlist`)
- Adds `GET/PUT /api/experimental/chats/config/template-allowlist`
endpoints
- Filters `list_templates`, `read_template`, and `create_workspace` chat
tools by allowlist, if defined (empty=all allowed)
- Add "Templates" admin settings tab in Agents UI ([what it looks
like](https://624de63c6aacee003aa84340-sitjilsyrr.chromatic.com/?path=/story/pages-agentspage-agentsettingspageview--template-allowlist))

> 🤖 This PR was created with the help of Coder Agents, and has been
reviewed by my human. 🧑‍💻
This commit is contained in:
Cian Johnston
2026-03-25 15:19:17 +00:00
committed by GitHub
parent c0ab22dc88
commit 796872f4de
23 changed files with 1045 additions and 10 deletions
+50 -4
View File
@@ -3371,6 +3371,45 @@ func (p *Server) runChat(
GetWorkspaceConn: workspaceCtx.getWorkspaceConn,
}),
}
// getAllowedTemplateIDs returns the current deployment-wide
// template allowlist, re-reading from the database on each call
// so that admin changes take effect without restarting the chat.
// Returns nil (= all allowed) on errors to fail open.
getAllowedTemplateIDs := func() map[uuid.UUID]bool {
raw, err := p.db.GetChatTemplateAllowlist(ctx)
if err != nil {
p.logger.Error(ctx, "failed to load template allowlist, all templates will be allowed", slog.Error(err))
return nil
}
if raw == "" {
return nil
}
var ids []string
if jsonErr := json.Unmarshal([]byte(raw), &ids); jsonErr != nil {
// Note: the API endpoint (GET /template-allowlist) returns
// HTTP 500 for corrupt JSON, giving admins visibility into
// the problem. The runtime path here deliberately fails open
// so that a corrupt allowlist doesn't block all chats.
p.logger.Error(ctx, "failed to parse template allowlist JSON, all templates will be allowed",
slog.F("raw", raw), slog.Error(jsonErr))
return nil
}
allowlist := make(map[uuid.UUID]bool, len(ids))
for _, s := range ids {
if id, parseErr := uuid.Parse(s); parseErr == nil {
allowlist[id] = true
} else {
p.logger.Warn(ctx, "ignoring invalid UUID in template allowlist",
slog.F("value", s), slog.Error(parseErr))
}
}
if len(ids) > 0 && len(allowlist) == 0 {
p.logger.Error(ctx, "all UUIDs in template allowlist were invalid, all templates will be allowed",
slog.F("count", len(ids)))
return nil
}
return allowlist
}
// Only root chats (not delegated subagents) get workspace
// provisioning and subagent tools. Child agents must not
// create workspaces or spawn further subagents — they should
@@ -3379,12 +3418,14 @@ func (p *Server) runChat(
// Workspace provisioning tools.
tools = append(tools,
chattool.ListTemplates(chattool.ListTemplatesOptions{
DB: p.db,
OwnerID: chat.OwnerID,
DB: p.db,
OwnerID: chat.OwnerID,
AllowedTemplateIDs: getAllowedTemplateIDs,
}),
chattool.ReadTemplate(chattool.ReadTemplateOptions{
DB: p.db,
OwnerID: chat.OwnerID,
DB: p.db,
OwnerID: chat.OwnerID,
AllowedTemplateIDs: getAllowedTemplateIDs,
}),
chattool.CreateWorkspace(chattool.CreateWorkspaceOptions{
DB: p.db,
@@ -3395,7 +3436,12 @@ func (p *Server) runChat(
AgentInactiveDisconnectTimeout: p.agentInactiveDisconnectTimeout,
WorkspaceMu: &workspaceMu,
Logger: p.logger,
AllowedTemplateIDs: getAllowedTemplateIDs,
}),
// StartWorkspace intentionally does not enforce the
// template allowlist. The allowlist restricts creation
// of new workspaces only — existing workspaces can
// be restarted regardless of allowlist changes.
chattool.StartWorkspace(chattool.StartWorkspaceOptions{
DB: p.db,
OwnerID: chat.OwnerID,