Skip to content

Commit 00fca1e

Browse files
ggallenclaude
andcommitted
feat(config): add agent registration schema to org and per-repo config
Implements Phase 1 of ADR 0058 (agent registration). Adds AgentEntry type with custom YAML unmarshaler supporting both string shorthand and object form, validation for URL integrity hashes, allowlist membership, path traversal, and name uniqueness. Seeds default AllowedRemoteResources in NewPerRepoConfig. Legacy role/name/slug agent entries are now rejected with a clear error instead of silently ignored. Signed-off-by: Greg Allen <gallen@redhat.com> Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Greg Allen <gallen@redhat.com>
1 parent 17ae28e commit 00fca1e

8 files changed

Lines changed: 1072 additions & 106 deletions

File tree

docs/plans/agent-registration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ type PerRepoConfig struct {
5252
}
5353
```
5454

55-
Add a `AgentEntry.AgentName()` helper that returns `Name` if set,
55+
Add a `AgentEntry.DerivedName()` helper that returns `Name` if set,
5656
otherwise derives it from the `Source` filename.
5757

5858
### 1b. Validation

internal/config/config.go

Lines changed: 159 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,72 @@ package config
22

33
import (
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+
1271
const (
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.
111204
func 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+
214355
func 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.
259400
type 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

266409
const 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

Comments
 (0)