@@ -2,13 +2,72 @@ package config
22
33import (
44 "fmt"
5+ "path"
6+ "regexp"
57 "slices"
68 "sort"
79 "strings"
810
11+ "github.com/fullsend-ai/fullsend/internal/urlutil"
912 "gopkg.in/yaml.v3"
1013)
1114
15+ var validAgentName = regexp .MustCompile (`^[a-zA-Z0-9][a-zA-Z0-9_-]*$` )
16+
17+ // AgentEntry represents a registered agent source in config.
18+ // It supports both string shorthand (just the source URL/path) and
19+ // object form (with an explicit name override).
20+ type AgentEntry struct {
21+ Name string `yaml:"name,omitempty"`
22+ Source string `yaml:"source"`
23+ }
24+
25+ // UnmarshalYAML implements yaml.Unmarshaler so that a plain string
26+ // is treated as a source-only entry, while a mapping decodes normally.
27+ // Old-format entries (role/name/slug identity tuples from pre-ADR-0058
28+ // config) are detected and rejected with a clear error message.
29+ func (a * AgentEntry ) UnmarshalYAML (value * yaml.Node ) error {
30+ if value .Kind == yaml .ScalarNode {
31+ a .Source = value .Value
32+ return nil
33+ }
34+ if value .Kind == yaml .MappingNode {
35+ // Detect old-format entries (have "role" key but no "source" key).
36+ hasRole := false
37+ hasSource := false
38+ for i := 0 ; i < len (value .Content )- 1 ; i += 2 {
39+ if value .Content [i ].Value == "role" {
40+ hasRole = true
41+ }
42+ if value .Content [i ].Value == "source" {
43+ hasSource = true
44+ }
45+ }
46+ if hasRole && ! hasSource {
47+ return fmt .Errorf ("agents entry uses legacy role/name/slug format (removed by ADR 0045 Phase 4); use source URL or path instead" )
48+ }
49+
50+ type plain AgentEntry
51+ return value .Decode ((* plain )(a ))
52+ }
53+ return fmt .Errorf ("agents entry must be a string or mapping, got %v" , value .Kind )
54+ }
55+
56+ // DerivedName returns the explicit Name if set, otherwise derives one
57+ // from the Source filename (e.g. "triage.yaml" → "triage").
58+ func (a AgentEntry ) DerivedName () string {
59+ if a .Name != "" {
60+ return a .Name
61+ }
62+ src := a .Source
63+ // Strip fragment (e.g. #sha256=...) before extracting filename.
64+ if idx := strings .LastIndex (src , "#" ); idx >= 0 {
65+ src = src [:idx ]
66+ }
67+ base := path .Base (src )
68+ return strings .TrimSuffix (base , path .Ext (base ))
69+ }
70+
1271const (
1372 // DefaultUpstreamRepo is the canonical fullsend repository for layered workflow calls.
1473 DefaultUpstreamRepo = "fullsend-ai/fullsend"
@@ -79,6 +138,7 @@ type OrgConfig struct {
79138 Inference InferenceConfig `yaml:"inference,omitempty"`
80139 Defaults RepoDefaults `yaml:"defaults"`
81140 Repos map [string ]RepoConfig `yaml:"repos"`
141+ Agents []AgentEntry `yaml:"agents,omitempty"`
82142 AllowedRemoteResources []string `yaml:"allowed_remote_resources,omitempty"`
83143 CreateIssues * CreateIssuesConfig `yaml:"create_issues,omitempty"`
84144}
@@ -107,6 +167,39 @@ func PerRepoDefaultRoles() []string {
107167 return []string {"triage" , "coder" , "review" , "fix" , "retro" , "prioritize" }
108168}
109169
170+ // DefaultAllowedRemoteResources returns the standard allowlist prefixes
171+ // for base composition and agent registration URLs.
172+ func DefaultAllowedRemoteResources () []string {
173+ return []string {
174+ "https://raw.githubusercontent.com/fullsend-ai/fullsend/" ,
175+ "https://raw.githubusercontent.com/fullsend-ai/agents/" ,
176+ }
177+ }
178+
179+ // DefaultAgentEntries computes default agent URL entries for the given
180+ // harness names at a specific commit SHA. Each entry is a pinned
181+ // raw.githubusercontent.com URL with an integrity hash.
182+ type AgentEntryBuilder func (harnessName , commitSHA string ) (string , error )
183+
184+ // DefaultAgentEntries returns agent entries for the given harness names,
185+ // using builder to compute each URL. When builder is nil, it uses
186+ // a no-op that returns empty entries (for callers that don't have
187+ // access to the scaffold package).
188+ func DefaultAgentEntries (harnessNames []string , commitSHA string , builder AgentEntryBuilder ) ([]AgentEntry , error ) {
189+ if builder == nil || commitSHA == "" {
190+ return nil , nil
191+ }
192+ entries := make ([]AgentEntry , 0 , len (harnessNames ))
193+ for _ , name := range harnessNames {
194+ urlWithHash , err := builder (name , commitSHA )
195+ if err != nil {
196+ return nil , fmt .Errorf ("building agent URL for %s: %w" , name , err )
197+ }
198+ entries = append (entries , AgentEntry {Source : urlWithHash })
199+ }
200+ return entries , nil
201+ }
202+
110203// NewOrgConfig creates a new OrgConfig with sensible defaults.
111204func NewOrgConfig (allRepos , enabledRepos , roles []string , inferenceProvider , org string ) * OrgConfig {
112205 repos := make (map [string ]RepoConfig , len (allRepos ))
@@ -126,12 +219,8 @@ func NewOrgConfig(allRepos, enabledRepos, roles []string, inferenceProvider, org
126219 MaxImplementationRetries : 2 ,
127220 AutoMerge : false ,
128221 },
129- Repos : repos ,
130- // Default allowlist for base: composition in harness wrappers (ADR-0045 Phase 2).
131- AllowedRemoteResources : []string {
132- "https://raw.githubusercontent.com/fullsend-ai/fullsend/" ,
133- "https://raw.githubusercontent.com/fullsend-ai/agents/" ,
134- },
222+ Repos : repos ,
223+ AllowedRemoteResources : DefaultAllowedRemoteResources (),
135224 }
136225 if inferenceProvider != "" {
137226 cfg .Inference = InferenceConfig {Provider : inferenceProvider }
@@ -205,12 +294,64 @@ func (c *OrgConfig) Validate() error {
205294 if err := validateStatusNotifications (c .Defaults .StatusNotifications ); err != nil {
206295 return err
207296 }
297+ if err := validateAgentEntries (c .Agents , c .AllowedRemoteResources ); err != nil {
298+ return err
299+ }
208300 if err := validateCreateIssues (c .CreateIssues ); err != nil {
209301 return err
210302 }
211303 return nil
212304}
213305
306+ // validateAgentEntries checks agent entries for structural correctness.
307+ // Uses urlutil.IsURL, urlutil.ParseIntegrityHash, and
308+ // urlutil.MatchingAllowedPrefixInList for consistency with runtime
309+ // resolution (case-insensitive scheme, percent-decoding, dot-segment
310+ // cleaning).
311+ func validateAgentEntries (agents []AgentEntry , allowlist []string ) error {
312+ seen := make (map [string ]bool , len (agents ))
313+ for i , entry := range agents {
314+ if entry .Source == "" {
315+ return fmt .Errorf ("agents[%d]: source must not be empty" , i )
316+ }
317+
318+ name := entry .DerivedName ()
319+ if ! validAgentName .MatchString (name ) {
320+ return fmt .Errorf ("agents[%d]: derived agent name %q is invalid; must be alphanumeric (with hyphens/underscores) (source: %q)" , i , name , entry .Source )
321+ }
322+ lowerName := strings .ToLower (name )
323+ if seen [lowerName ] {
324+ return fmt .Errorf ("agents[%d]: duplicate agent name %q (case-insensitive)" , i , name )
325+ }
326+ seen [lowerName ] = true
327+
328+ if urlutil .IsURL (entry .Source ) {
329+ cleanURL , _ , hasHash := urlutil .ParseIntegrityHash (entry .Source )
330+ if ! hasHash {
331+ return fmt .Errorf ("agents[%d] (%s): URL source must include a valid #sha256=<64-hex-char> integrity fragment" , i , name )
332+ }
333+ if urlutil .MatchingAllowedPrefixInList (cleanURL , allowlist ) == "" {
334+ return fmt .Errorf ("agents[%d] (%s): URL %q is not covered by allowed_remote_resources" , i , name , cleanURL )
335+ }
336+ } else if strings .HasPrefix (strings .ToLower (entry .Source ), "http://" ) {
337+ return fmt .Errorf ("agents[%d] (%s): URL scheme must be https, got http" , i , name )
338+ } else {
339+ if strings .Contains (entry .Source , "://" ) {
340+ return fmt .Errorf ("agents[%d] (%s): unsupported URL scheme; only https is allowed" , i , name )
341+ }
342+ if strings .ContainsRune (entry .Source , '\\' ) {
343+ return fmt .Errorf ("agents[%d] (%s): local path must not contain backslashes" , i , name )
344+ }
345+ for _ , seg := range strings .Split (entry .Source , "/" ) {
346+ if seg == ".." {
347+ return fmt .Errorf ("agents[%d] (%s): local path must not contain path traversal (..)" , i , name )
348+ }
349+ }
350+ }
351+ }
352+ return nil
353+ }
354+
214355func validateStatusNotifications (cfg * StatusNotificationConfig ) error {
215356 if cfg == nil {
216357 return nil
@@ -257,10 +398,12 @@ func (c *OrgConfig) DefaultRoles() []string {
257398// PerRepoConfig holds configuration for per-repo installation mode.
258399// Stored in .fullsend/config.yaml within the target repository.
259400type PerRepoConfig struct {
260- Version string `yaml:"version"`
261- KillSwitch bool `yaml:"kill_switch,omitempty"`
262- Roles []string `yaml:"roles,omitempty"`
263- CreateIssues * CreateIssuesConfig `yaml:"create_issues,omitempty"`
401+ Version string `yaml:"version"`
402+ KillSwitch bool `yaml:"kill_switch,omitempty"`
403+ Roles []string `yaml:"roles,omitempty"`
404+ Agents []AgentEntry `yaml:"agents,omitempty"`
405+ AllowedRemoteResources []string `yaml:"allowed_remote_resources,omitempty"`
406+ CreateIssues * CreateIssuesConfig `yaml:"create_issues,omitempty"`
264407}
265408
266409const perRepoConfigHeader = `# fullsend per-repo configuration
@@ -276,8 +419,9 @@ func NewPerRepoConfig(roles []string, targetRepo string) *PerRepoConfig {
276419 roles = DefaultAgentRoles ()
277420 }
278421 cfg := & PerRepoConfig {
279- Version : "1" ,
280- Roles : roles ,
422+ Version : "1" ,
423+ Roles : roles ,
424+ AllowedRemoteResources : DefaultAllowedRemoteResources (),
281425 }
282426 if targetRepo != "" {
283427 cfg .CreateIssues = & CreateIssuesConfig {
@@ -323,6 +467,9 @@ func (c *PerRepoConfig) Validate() error {
323467 }
324468 seen [role ] = true
325469 }
470+ if err := validateAgentEntries (c .Agents , c .AllowedRemoteResources ); err != nil {
471+ return err
472+ }
326473 if err := validateCreateIssues (c .CreateIssues ); err != nil {
327474 return err
328475 }
0 commit comments