Files
executus/persona/builtin_loader.go
T
steve d82cef46b4
executus CI / test (push) Failing after 1m4s
fix: address verified gadfly P4/#4 findings (audit/budget/persona)
Security (all 3 models — HIGH): audit OnTool persisted raw tool args + results
verbatim for the very tools the OnStep narration-redaction flags as secret
(mcp_call/email_send/http_*) — the args/results are what CARRY the secret, so
they landed in skill_run_logs unredacted. Factored the predicate into
isSecretTool() (single source of truth) and OnTool now emits
args_redacted/result_redacted (+ lengths) for secret tools. Test asserts no
secret reaches the log. (persona) webhook_ip_allowlist entries are now
CIDR/IP-validated at load (malformed dropped + warned) instead of accepted raw.

Contract correctness (glm-5.2 + deepseek) — audit Memory now honors its
documented Storage contract: ListChildrenByParent/ListFinishedRunsBefore return
oldest-first; WalkParentChain returns root-first and honors MaxParentChainDepth;
ListRunsFiltered clamps limit (<=0 or >500 -> 50); ListFinishedRunsBefore with
limit<=0 returns none; an explicit RunFilter.Status (incl. "dry_run") matches
regardless of IncludeDryRun; LastRunBySkills counts only status=="ok" unless
includeFailed. (PurgeOlderThan's FinishedAt key is the SAFE behavior — in-flight
runs retained — so the doc was aligned to it, not the impl.)

Error-handling: appendLog now uses a bounded context (auditAppendTimeout=3s) so
a hung backend can't block the run goroutine on the hot path; Sink.StartRun
logs its (still best-effort) failure instead of swallowing it; budget Memory.Get
uses RLock (RWMutex); budget package doc fixed (was skillexec's); Check uses the
budgetWindow constant, not a duplicated literal.

Triaged false-positive: NewNoOpBudget returning BudgetTracker is assignable to
run.Budget (identical method sets) — no change needed.

Core go.sum still free of host/DB deps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 00:12:19 -04:00

600 lines
23 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package persona
// Phase 6 — Builtin Agent loader.
//
// Why: Phase 1-5 introduced the Agent noun, runtime, triggers,
// CommandBinding, and ChatBot bridge — but every Agent in production
// was either (a) a wrapper auto-migrated from a triggered Skill, or
// (b) admin-created via `.agent new`. There were no SHIPPED Agents
// authored as builtins. Phase 6 adds an idempotent boot-time loader so
// the repo can ship canonical Agent definitions (alongside the
// existing skills/<name>/skill.yml builtins) without manual admin
// creation per deploy.
//
// What: scans `<builtinsDir>/agents/*/agent.yml`, decodes each YAML
// into an Agent, and upserts via Storage.SaveAgent under the deterministic
// system owner ID "builtin". Skill-palette entries are validated AT LOAD
// TIME against the live skills storage; missing skills warn but do not
// fail the load (the skill might arrive later via a different code
// path, and runtime resolution happens at invocation time anyway).
//
// Bypass note (v3 lesson, mirrored): like skills.LoadBuiltins, this
// loader writes via Storage.SaveAgent directly. There is no agents
// equivalent of SaveUserSkill's save-time gates today (Phase 1-5 don't
// have authoring requirements on agents), but if such gates appear in
// future phases, this loader MUST keep bypassing them — builtins are
// trusted infrastructure.
//
// Test: pkg/logic/agents/builtin_loader_test.go covers happy path,
// idempotent re-load, missing-skill warn capture, and malformed YAML
// surfaced as a per-bundle warning (not a fatal error).
import (
"context"
"errors"
"fmt"
"io/fs"
"log/slog"
"net"
"path"
"strings"
"time"
"github.com/google/uuid"
"gopkg.in/yaml.v3"
)
// BuiltinAgentOwnerID is the deterministic system owner ID used for
// every Agent created by LoadBuiltinAgents. Chosen as a non-empty
// string so the (owner_id, name) unique index distinguishes builtins
// from any user-authored Agent (Discord member IDs are numeric, so
// "builtin" cannot collide). The skills builtin loader uses owner_id=""
// instead; the two systems are independent storage scopes — there's
// no need for consistency here.
const BuiltinAgentOwnerID = "builtin"
// SkillExistenceChecker is the narrow surface LoadBuiltinAgents needs
// to validate skill_palette entries at load time. Production wires
// skills.Storage which already exposes ListByName for non-owner-scoped
// lookups. nil means "skip palette validation" (tests that don't care).
//
// Why a separate narrow interface (vs importing skills.Storage):
// agents already transitively depends on skills via migrate_from_skills,
// but the loader only needs "does a skill with this name exist
// somewhere?" — a single Boolean. Keeping the interface narrow makes
// the loader testable without a full skills storage stub.
type SkillExistenceChecker interface {
// SkillExistsByName reports whether at least one skill row has the
// given name across any owner (builtins live under owner_id="";
// users own their own rows; the loader's validation just wants
// "does ANY row exist with this name?").
SkillExistsByName(ctx context.Context, name string) (bool, error)
}
// LoadBuiltinAgents discovers and seeds builtin Agents from `builtinsDir`.
// `builtinsDir` is the root that contains an `agents/` subdirectory;
// per-agent YAML lives at `agents/<name>/agent.yml`. Returns the count
// of agents seeded or updated (skipped rows do not contribute to the
// count). Returns nil error when the agents/ directory is absent — a
// deployment without any builtin agents is valid; the loader is then a
// no-op.
//
// Idempotency contract: existing Agent rows (matched by (owner_id="builtin",
// name)) are UPDATED to the freshly-parsed YAML on each boot. ID +
// CreatedAt are preserved; UpdatedAt is refreshed. User clones of a
// builtin Agent (different owner_id, same name) are NEVER touched —
// the loader only writes to (owner_id="builtin", name) rows.
//
// `skillChecker` may be nil; when non-nil, each SkillPalette entry is
// looked up and a WARN log emitted (with the agent + missing skill
// name) for absent references. The Agent row is still seeded with the
// palette intact — runtime resolution at invocation time is the
// authoritative gate.
func LoadBuiltinAgents(ctx context.Context, store Storage, builtinsDir fs.FS, skillChecker SkillExistenceChecker) (int, error) {
if store == nil {
return 0, errors.New("agents.LoadBuiltinAgents: nil store")
}
if builtinsDir == nil {
return 0, errors.New("agents.LoadBuiltinAgents: nil builtinsDir FS")
}
entries, err := fs.ReadDir(builtinsDir, "agents")
if err != nil {
// Missing agents/ directory is benign — a deployment may not
// ship any builtins. Other errors propagate so a permission /
// IO problem surfaces loudly.
if errors.Is(err, fs.ErrNotExist) {
slog.Info("agents: no builtin agents directory", "path", "agents")
return 0, nil
}
return 0, fmt.Errorf("agents: read agents dir: %w", err)
}
// Phase 1: parse all agent manifests into a map keyed by name.
// The map is needed so extends references can be resolved before
// any agent is upserted.
type parsedEntry struct {
agent *Agent
dir string
}
parsed := make(map[string]*parsedEntry)
var parseOrder []string // preserve FS iteration order for deterministic upsert
var scanned, failed int
for _, entry := range entries {
if !entry.IsDir() {
continue
}
manifestPath := path.Join("agents", entry.Name(), "agent.yml")
data, readErr := fs.ReadFile(builtinsDir, manifestPath)
if readErr != nil {
slog.Debug("agents: skipping (no agent.yml)", "dir", entry.Name(), "error", readErr)
continue
}
scanned++
ag, parseErr := decodeAgentManifest(data)
if parseErr != nil {
slog.Warn("agents: invalid agent.yml", "dir", entry.Name(), "error", parseErr)
failed++
continue
}
parsed[ag.Name] = &parsedEntry{agent: ag, dir: entry.Name()}
parseOrder = append(parseOrder, ag.Name)
}
// Phase 2: resolve extends references. Only single-level is
// supported — chains (A extends B extends C) are rejected.
for _, name := range parseOrder {
pe := parsed[name]
ag := pe.agent
if ag.Extends == "" {
continue
}
parent, ok := parsed[ag.Extends]
if !ok {
slog.Warn("agents: extends references unknown agent",
"agent", ag.Name, "extends", ag.Extends)
failed++
delete(parsed, name)
continue
}
if parent.agent.Extends != "" {
slog.Warn("agents: extends chain not supported — parent also uses extends",
"agent", ag.Name, "extends", ag.Extends,
"parent_extends", parent.agent.Extends)
failed++
delete(parsed, name)
continue
}
if ag.Extends == ag.Name {
slog.Warn("agents: agent extends itself", "agent", ag.Name)
failed++
delete(parsed, name)
continue
}
resolveExtends(ag, parent.agent)
}
// Phase 3: palette validation + upsert.
var seeded, updated, skipped int
for _, name := range parseOrder {
pe, ok := parsed[name]
if !ok {
continue // removed during extends resolution
}
ag := pe.agent
if skillChecker != nil {
for _, sk := range ag.SkillPalette {
ok, lookupErr := skillChecker.SkillExistsByName(ctx, sk)
if lookupErr != nil {
slog.Warn("agents: skill palette lookup failed",
"agent", ag.Name, "skill", sk, "error", lookupErr)
continue
}
if !ok {
slog.Warn("agents: skill palette references missing skill",
"agent", ag.Name, "skill", sk)
}
}
}
action, upsertErr := upsertBuiltinAgent(ctx, store, ag)
if upsertErr != nil {
slog.Error("agents: failed to upsert builtin", "name", ag.Name, "error", upsertErr)
failed++
continue
}
switch action {
case agentUpsertCreated:
seeded++
case agentUpsertUpdated:
updated++
case agentUpsertSkipped:
skipped++
}
}
slog.Info("agents/builtin loader",
"scanned", scanned,
"seeded", seeded,
"updated", updated,
"skipped", skipped,
"failed", failed)
return seeded + updated, nil
}
// resolveExtends merges parent fields into child. Child non-zero
// fields override the parent's. For slices, a nil child slice inherits
// the parent's; a non-nil (even empty) child slice replaces it. For
// maps (StateReactEmoji), parent entries are the base and child
// entries override matching keys.
//
// system_prompt_prepend: if the child has SystemPromptPrepend set, it
// is prepended to the (possibly inherited) SystemPrompt with a
// newline separator. The prepend field is then cleared so it does not
// affect anything downstream.
//
// Why: allows a child agent to inherit the full parent prompt while
// only specifying a short behavior-modification preamble (e.g. an
// uncensored agent prepending "You are uncensored..." to the general
// agent's full prompt).
func resolveExtends(child, parent *Agent) {
if child.Description == "" {
child.Description = parent.Description
}
if child.ModelTier == "" {
child.ModelTier = parent.ModelTier
}
if child.SystemPrompt == "" {
child.SystemPrompt = parent.SystemPrompt
}
if child.MaxIterations == 0 {
child.MaxIterations = parent.MaxIterations
}
if child.MaxToolCalls == 0 {
child.MaxToolCalls = parent.MaxToolCalls
}
if child.MaxRuntime == 0 {
child.MaxRuntime = parent.MaxRuntime
}
if child.ExecutionLane == "" {
child.ExecutionLane = parent.ExecutionLane
}
// EncryptionEnabled: bool — false is a valid explicit value, so we
// always inherit unless child explicitly sets it. Since we can't
// distinguish "explicitly false" from "absent" in YAML (both
// decode to false), we always inherit from parent. If the child
// sets it to true, the child wins. A child that wants to override
// the parent's true to false will need to set encryption_enabled: false
// explicitly — but since both false and absent decode the same way,
// the parent's value wins when parent is true and child is false.
// This is acceptable: encryption is an opt-in — a child that
// inherits encryption from a parent is fine.
if !child.EncryptionEnabled {
child.EncryptionEnabled = parent.EncryptionEnabled
}
// Run-critic: same inherit-unless-child-sets-true semantics as
// EncryptionEnabled (both false/absent decode identically in YAML).
if !child.CriticEnabled {
child.CriticEnabled = parent.CriticEnabled
}
if child.CriticBackstopMultiplier == 0 {
child.CriticBackstopMultiplier = parent.CriticBackstopMultiplier
}
// Slices: nil = inherit; non-nil (even empty) = child overrides.
if child.SkillPalette == nil {
child.SkillPalette = parent.SkillPalette
}
if child.SubAgentPalette == nil {
child.SubAgentPalette = parent.SubAgentPalette
}
if child.LowLevelTools == nil {
child.LowLevelTools = parent.LowLevelTools
}
if child.PersonalizationSources == nil {
child.PersonalizationSources = parent.PersonalizationSources
}
if child.Tags == nil {
child.Tags = parent.Tags
}
if child.WebhookIPAllowlist == nil {
child.WebhookIPAllowlist = parent.WebhookIPAllowlist
}
if child.Phases == nil {
child.Phases = parent.Phases
}
// Triggers (Schedule, ChatbotChannelFilter, WebhookSecret, …) are
// deliberately NOT inherited. A trigger is an ACTIVATION decision —
// "this agent fires on a schedule" / "this agent is a chatbot tool in
// these channels" — and silently inheriting it from a parent persona
// is a behavioural surprise: `uncensored extends general` would inherit
// general's `chatbot_channel_filter: "none"` (match-every-channel) and
// surface the unfiltered model as a direct chatbot tool everywhere the
// instant agents.triggers.enabled flips on. A child that wants a trigger
// must declare it explicitly. (Persona, caps, palette, and tools are
// inherited above — those are capability, not activation.)
// DefaultEmoji: child wins if set; otherwise inherit.
if child.DefaultEmoji == "" {
child.DefaultEmoji = parent.DefaultEmoji
}
// Maps: merge — parent is the base, child entries override.
if child.StateReactEmoji == nil && parent.StateReactEmoji != nil {
child.StateReactEmoji = make(map[string]string, len(parent.StateReactEmoji))
for k, v := range parent.StateReactEmoji {
child.StateReactEmoji[k] = v
}
} else if parent.StateReactEmoji != nil {
merged := make(map[string]string, len(parent.StateReactEmoji)+len(child.StateReactEmoji))
for k, v := range parent.StateReactEmoji {
merged[k] = v
}
for k, v := range child.StateReactEmoji {
merged[k] = v
}
child.StateReactEmoji = merged
}
// SystemPromptPrepend: prepend to the (now resolved) SystemPrompt.
if child.SystemPromptPrepend != "" {
child.SystemPrompt = child.SystemPromptPrepend + "\n\n" + child.SystemPrompt
child.SystemPromptPrepend = "" // consumed
}
// Clear Extends — the resolution is complete, the persisted agent
// is standalone.
child.Extends = ""
}
// agentUpsertAction reports what upsertBuiltinAgent did. Exported only
// to the test in this package; the loader's public surface returns a
// count, not a per-row action.
type agentUpsertAction int
const (
agentUpsertCreated agentUpsertAction = iota
agentUpsertUpdated
agentUpsertSkipped // reserved; current loader never returns this — every parse-OK row is upserted
)
// upsertBuiltinAgent looks up an existing (BuiltinAgentOwnerID, name)
// row. If absent, inserts a new row with a freshly-minted UUID.
// Otherwise updates the existing row in place, preserving ID + CreatedAt.
//
// Why not version-skip like skills.upsertBuiltin: the Agent struct has
// a Version int field but it's a monotonic counter, not a semver
// string for change detection. Agent YAML doesn't carry a "version"
// at the wire shape; every boot writes the latest YAML content,
// trusting the YAML file in-repo IS the source of truth. The Agent's
// internal Version int auto-increments on each loader pass so admin
// inspection (`.agent show`) reveals "how many times has the loader
// touched this row".
func upsertBuiltinAgent(ctx context.Context, store Storage, fresh *Agent) (agentUpsertAction, error) {
existing, err := store.GetAgentByName(ctx, BuiltinAgentOwnerID, fresh.Name)
if err != nil && !errors.Is(err, ErrNotFound) {
return agentUpsertCreated, fmt.Errorf("lookup builtin agent %q: %w", fresh.Name, err)
}
if errors.Is(err, ErrNotFound) {
fresh.ID = uuid.New().String()
fresh.OwnerID = BuiltinAgentOwnerID
fresh.AuthoredBy = BuiltinAgentOwnerID
if fresh.Version == 0 {
fresh.Version = 1
}
now := time.Now()
fresh.CreatedAt = now
fresh.UpdatedAt = now
if saveErr := store.SaveAgent(ctx, fresh); saveErr != nil {
return agentUpsertCreated, saveErr
}
slog.Info("agents: created builtin", "name", fresh.Name, "id", fresh.ID)
return agentUpsertCreated, nil
}
// Update in place. Preserve ID, OwnerID, AuthoredBy, CreatedAt.
// Bump Version so admins can see "the loader has touched this N
// times" — useful when investigating a builtin that was
// hand-edited via the future web UI and unexpectedly reverted on
// next boot.
fresh.ID = existing.ID
fresh.OwnerID = BuiltinAgentOwnerID
fresh.AuthoredBy = BuiltinAgentOwnerID
fresh.Version = existing.Version + 1
fresh.CreatedAt = existing.CreatedAt
fresh.UpdatedAt = time.Now()
// Carry forward operator/scheduler-owned fields that the manifest
// never sets (decodeAgentManifest leaves these zero by design — a
// secret in-repo would be a credential leak). Without this, every
// boot CLOBBERS an operator-armed webhook secret + signature flag
// back to empty/false and nukes the scheduler's next-fire cursor, so
// a scheduled or webhook-armed builtin silently breaks on each deploy.
fresh.WebhookSecret = existing.WebhookSecret
fresh.WebhookSignatureRequired = existing.WebhookSignatureRequired
fresh.NextRunAt = existing.NextRunAt
fresh.LastScheduledRunAt = existing.LastScheduledRunAt
if saveErr := store.SaveAgent(ctx, fresh); saveErr != nil {
return agentUpsertUpdated, saveErr
}
slog.Info("agents: updated builtin",
"name", fresh.Name, "id", fresh.ID, "version", fresh.Version)
return agentUpsertUpdated, nil
}
// builtinAgentManifest is the YAML wire format for agents/<name>/agent.yml.
// The schema is intentionally a SUBSET of the Agent struct — future
// fields can be added without breaking existing manifests so long as
// we keep KnownFields(true) decoding (so a typo on a key surfaces as
// an error rather than silently dropping data).
//
// See pkg/logic/agents/CLAUDE.md for the schema reference.
type builtinAgentManifest struct {
Name string `yaml:"name"`
Description string `yaml:"description"`
ModelTier string `yaml:"model_tier"`
SystemPrompt string `yaml:"system_prompt"`
SystemPromptPrepend string `yaml:"system_prompt_prepend"`
MaxIterations int `yaml:"max_iterations"`
MaxToolCalls int `yaml:"max_tool_calls"`
MaxRuntimeSeconds int `yaml:"max_runtime_seconds"`
ExecutionLane string `yaml:"execution_lane"`
EncryptionEnabled bool `yaml:"encryption_enabled"`
// Run-critic two-tier timeout. CriticEnabled flips MaxRuntime from a
// hard kill into a soft trigger; CriticBackstopMultiplier (0 => convar
// default 6×) sets the hard backstop = MaxRuntime × multiplier.
CriticEnabled bool `yaml:"critic_enabled"`
CriticBackstopMultiplier float64 `yaml:"critic_backstop_multiplier"`
// Extends names a parent agent whose fields are inherited. The
// child's non-zero fields override the parent; nil/empty slices
// inherit the parent's. Maps (state_react) are merged — child
// entries override parent entries with the same key. Only single-
// level extends is supported (no chains).
Extends string `yaml:"extends"`
SkillPalette []string `yaml:"skill_palette"`
SubAgentPalette []string `yaml:"sub_agent_palette"`
LowLevelTools []string `yaml:"low_level_tools"`
PersonalizationSources []string `yaml:"personalization_sources"`
// Triggers — builtin agents typically don't ship with triggers
// (admins flip these on per-deployment), but the keys are accepted
// so a sufficiently sophisticated builtin (e.g. a scheduled "weekly
// digest" agent) can ship triggers in-repo. Default empty.
Schedule string `yaml:"schedule"`
WebhookIPAllowlist []string `yaml:"webhook_ip_allowlist"`
ChatbotChannelFilter string `yaml:"chatbot_channel_filter"`
DefaultEmoji string `yaml:"default_emoji"`
StateReact map[string]string `yaml:"state_react"`
Tags []string `yaml:"tags"`
// Pipeline phases — when non-empty, the executor runs the
// sequential phase runner instead of the single agent loop.
Phases []builtinAgentPhaseManifest `yaml:"phases"`
}
// builtinAgentPhaseManifest is the YAML wire format for a single
// phases list entry in agents/<name>/agent.yml. Maps 1:1 to
// AgentPhase at decode time.
type builtinAgentPhaseManifest struct {
Name string `yaml:"name"`
SystemPrompt string `yaml:"system_prompt"`
ModelTier string `yaml:"model_tier"`
MaxIter int `yaml:"max_iter"`
Tools []string `yaml:"tools"`
Optional bool `yaml:"optional"`
FallbackMessage string `yaml:"fallback_message"`
IsRunFunc bool `yaml:"is_run_func"`
}
// decodeAgentManifest parses an agent.yml bundle into a domain Agent.
// Uses KnownFields(true) so a typo'd key surfaces as a parse error
// rather than silently dropping the value.
//
// What this method does NOT set:
// - ID (loader mints UUID on insert / preserves existing on update)
// - OwnerID + AuthoredBy (loader sets to BuiltinAgentOwnerID)
// - Version (loader increments on update)
// - CreatedAt + UpdatedAt (loader stamps)
// - WebhookSecret (operator generates via admin tooling at deploy
// time — shipping a secret in-repo would be a credential leak)
// - NextRunAt + LastScheduledRunAt (scheduler bookkeeping; nil at
// load time, populated on first scheduled fire)
// - WebhookSignatureRequired (application-layer default applies on
// first save; a `default:true` GORM tag would substitute on every
// write — see the v8 lesson on this exact trap)
func decodeAgentManifest(data []byte) (*Agent, error) {
var m builtinAgentManifest
dec := yaml.NewDecoder(strings.NewReader(string(data)))
dec.KnownFields(true)
if err := dec.Decode(&m); err != nil {
return nil, fmt.Errorf("decode agent.yml: %w", err)
}
if strings.TrimSpace(m.Name) == "" {
return nil, errors.New("agent.yml: missing required field 'name'")
}
// system_prompt is required UNLESS the agent uses extends (the parent
// will supply it) or system_prompt_prepend (the prepend will be
// combined with the parent's system_prompt after extends resolution).
if strings.TrimSpace(m.SystemPrompt) == "" && strings.TrimSpace(m.Extends) == "" && strings.TrimSpace(m.SystemPromptPrepend) == "" {
return nil, errors.New("agent.yml: missing required field 'system_prompt'")
}
// Convert YAML phase manifests to domain AgentPhase structs.
var phases []AgentPhase
for _, pm := range m.Phases {
if strings.TrimSpace(pm.Name) == "" {
return nil, errors.New("agent.yml: phase missing required field 'name'")
}
phases = append(phases, AgentPhase{
Name: strings.TrimSpace(pm.Name),
SystemPrompt: pm.SystemPrompt,
ModelTier: strings.TrimSpace(pm.ModelTier),
MaxIter: pm.MaxIter,
Tools: pm.Tools,
Optional: pm.Optional,
FallbackMessage: pm.FallbackMessage,
IsRunFunc: pm.IsRunFunc,
})
}
// Validate the webhook IP allow-list (CIDR or bare IP); drop + warn on
// malformed entries so a typo can't silently widen or void the allow-list.
allowlist := validateIPAllowlist(m.WebhookIPAllowlist, m.Name)
ag := &Agent{
Name: strings.TrimSpace(m.Name),
Description: m.Description,
Extends: strings.TrimSpace(m.Extends),
SystemPromptPrepend: m.SystemPromptPrepend,
ModelTier: strings.TrimSpace(m.ModelTier),
SystemPrompt: m.SystemPrompt,
MaxIterations: m.MaxIterations,
MaxToolCalls: m.MaxToolCalls,
MaxRuntime: time.Duration(m.MaxRuntimeSeconds) * time.Second,
ExecutionLane: strings.TrimSpace(m.ExecutionLane),
EncryptionEnabled: m.EncryptionEnabled,
CriticEnabled: m.CriticEnabled,
CriticBackstopMultiplier: m.CriticBackstopMultiplier,
SkillPalette: m.SkillPalette,
SubAgentPalette: m.SubAgentPalette,
LowLevelTools: m.LowLevelTools,
PersonalizationSources: m.PersonalizationSources,
Schedule: strings.TrimSpace(m.Schedule),
WebhookIPAllowlist: allowlist,
ChatbotChannelFilter: strings.TrimSpace(m.ChatbotChannelFilter),
DefaultEmoji: m.DefaultEmoji,
StateReactEmoji: m.StateReact,
Tags: m.Tags,
Phases: phases,
}
return ag, nil
}
// validateIPAllowlist keeps only entries that parse as a CIDR block or a bare
// IP; malformed entries are dropped with a warning (a typo must not silently
// widen or void the webhook allow-list). The struct field documents "CIDR
// strings", so this enforces it at load time.
func validateIPAllowlist(entries []string, agent string) []string {
var out []string
for _, e := range entries {
e = strings.TrimSpace(e)
if e == "" {
continue
}
if _, _, err := net.ParseCIDR(e); err == nil {
out = append(out, e)
continue
}
if ip := net.ParseIP(e); ip != nil {
out = append(out, e)
continue
}
slog.Warn("agents: dropping malformed webhook_ip_allowlist entry (not a CIDR or IP)", "agent", agent, "entry", e)
}
return out
}