diff --git a/config/profiles/default/profile.yml b/config/profiles/default/profile.yml new file mode 100644 index 000000000..c454a228e --- /dev/null +++ b/config/profiles/default/profile.yml @@ -0,0 +1,29 @@ +# profiles/default/profile.yml — the committed fleet config IS the `default` +# profile (RIG-2968 T1). Every Compass Manager without an explicit profile +# resolves to this one, so it must always exist and pass the store door. +# +# v1 consumes ONLY the `models` axis; corpus/extensions/settings are schema'd +# but their consumption is deferred (their shapes are provisional until first +# consumption — see the design's Resolved decisions). They are declared here as +# empty/null so `default` documents the full superset. +models: + # The Manager's own model selector. Mirrors the committed fleet manager model + # (the seeded root supervisor's model): the fleet runs on litellm/claude-opus, + # and the design's own superset example pins the manager at :high. + manager: litellm/claude-opus:high + # Per-subagent-role models, keyed by the FRONTMATTER name: of a shipped + # agents/*.md def. The fleet ships config/agents/implementer.md + # (frontmatter name: implementer); left empty here so `default` pins no + # per-role override — subagents inherit the Manager's model. + agents: {} +corpus: + # System-prompt corpus selection. SCHEMA'd, consumption DEFERRED. + prompts: null + skills: [] + rules: [] +extensions: + # Extension/MCP tool-set selection. SCHEMA'd, consumption DEFERRED. + mcp: null +settings: + # Session-settings overlay. SCHEMA'd, consumption DEFERRED. + {} diff --git a/go/cmd/compass/bundle.go b/go/cmd/compass/bundle.go index 3d0a8516b..2d8dac784 100644 --- a/go/cmd/compass/bundle.go +++ b/go/cmd/compass/bundle.go @@ -34,6 +34,7 @@ const ( topDirRules = "rules" topDirAgents = "agents" topDirPrompts = "prompts" + topDirProfiles = "profiles" ) // Top-level regular-file members admitted by exact filename, not under a top dir @@ -46,6 +47,9 @@ const ( // memberSystemMD is the only filename admitted under prompts// // (store door: RIG-3075 T2). memberSystemMD = "SYSTEM.md" + // memberProfileYML is the only filename admitted under profiles// + // (store door: RIG-2968 T1). + memberProfileYML = "profile.yml" ) // maxBundleFileCount and maxBundleContentBytes are a fail-fast client-side check @@ -72,6 +76,7 @@ var bundleTopDirs = map[string]bool{ topDirRules: true, topDirAgents: true, topDirPrompts: true, + topDirProfiles: true, } // bundleNamePattern is the grammar for a member's segment (store door: @@ -133,7 +138,7 @@ func buildBundle(dir string) ([]byte, error) { return nil, walkErr } if fileCount == 0 { - return nil, fmt.Errorf("bundle directory %q contains no members under skills/, extensions/, mcp/, settings/, rules/, agents/, or prompts/ and no top-level %s or %s; use `agent-config delete` to clear the fleet config", dir, memberAgentsMD, memberModels) + return nil, fmt.Errorf("bundle directory %q contains no members under skills/, extensions/, mcp/, settings/, rules/, agents/, prompts/, or profiles/ and no top-level %s or %s; use `agent-config delete` to clear the fleet config", dir, memberAgentsMD, memberModels) } if err := tw.Close(); err != nil { return nil, fmt.Errorf("finalizing tar: %w", err) @@ -170,7 +175,7 @@ func validateDirMember(name string) error { if !bundleTopDirs[parts[0]] { return errNotWhitelisted(name) } - if (parts[0] == topDirSkills || parts[0] == topDirExtensions || parts[0] == topDirPrompts) && len(parts) >= 2 && !bundleNamePattern.MatchString(parts[1]) { + if (parts[0] == topDirSkills || parts[0] == topDirExtensions || parts[0] == topDirPrompts || parts[0] == topDirProfiles) && len(parts) >= 2 && !bundleNamePattern.MatchString(parts[1]) { return fmt.Errorf("bundle member name %q must match %s", parts[1], bundleNamePattern.String()) } return nil @@ -180,7 +185,7 @@ func validateDirMember(name string) error { // neither under a whitelisted top dir nor one of the two top-level singletons // (store door: configMemberParts). func errNotWhitelisted(name string) error { - return fmt.Errorf("bundle member %q is not under skills/, extensions/, mcp/, settings/, rules/, agents/, or prompts/ and is not a top-level %s or %s", name, memberAgentsMD, memberModels) + return fmt.Errorf("bundle member %q is not under skills/, extensions/, mcp/, settings/, rules/, agents/, prompts/, or profiles/ and is not a top-level %s or %s", name, memberAgentsMD, memberModels) } // addRegularMember validates a regular file against the door grammar and writes @@ -292,6 +297,20 @@ func validateMemberGrammar(parts []string, name string, content []byte) error { return fmt.Errorf("prompts role name %q must match %s", parts[1], bundleNamePattern.String()) } return nil + case topDirProfiles: + // Exactly profiles//profile.yml — three components, grammar-valid + // , filename exactly profile.yml, and a YAML-mapping body (the + // cheap shape check, twin of settings/models). The superset-key closure + // and cross-member models.agents frontmatter-name lint are the store + // door's authoritative checks, NOT replicated client-side (store door: + // RIG-2968 T1). + if len(parts) != 3 || parts[2] != memberProfileYML { + return fmt.Errorf("profiles member %q must be profiles//%s", name, memberProfileYML) + } + if !bundleNamePattern.MatchString(parts[1]) { + return fmt.Errorf("profiles name %q must match %s", parts[1], bundleNamePattern.String()) + } + return validateYAMLMapping(name, content) default: // skills/ or extensions/: the second component must match. if len(parts) < 2 { diff --git a/go/cmd/compass/bundle_test.go b/go/cmd/compass/bundle_test.go index caf824e5d..54c7f40c1 100644 --- a/go/cmd/compass/bundle_test.go +++ b/go/cmd/compass/bundle_test.go @@ -103,6 +103,11 @@ func TestBuildBundleRejects(t *testing.T) { {"prompts too deep", map[string]string{"prompts/supervisor/sub/SYSTEM.md": "x"}, "must be prompts//SYSTEM.md"}, {"prompts too shallow", map[string]string{"prompts/SYSTEM.md": "x"}, "must be prompts//SYSTEM.md"}, {"prompts bad role name", map[string]string{"prompts/bad name/SYSTEM.md": "x"}, "must match"}, + {"profiles wrong filename", map[string]string{"profiles/candidate/other.yml": "models: {}\n"}, "must be profiles//profile.yml"}, + {"profiles too deep", map[string]string{"profiles/candidate/sub/profile.yml": "models: {}\n"}, "must be profiles//profile.yml"}, + {"profiles too shallow", map[string]string{"profiles/profile.yml": "models: {}\n"}, "must be profiles//profile.yml"}, + {"profiles bad name", map[string]string{"profiles/bad name/profile.yml": "models: {}\n"}, "must match"}, + {"profiles non-mapping", map[string]string{"profiles/candidate/profile.yml": "- a\n- b\n"}, "must be a YAML mapping"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -192,7 +197,7 @@ func TestBuildBundleEmpty(t *testing.T) { if err == nil { t.Fatal("buildBundle(empty dir) = nil error, want rejection") } - if !strings.Contains(err.Error(), "contains no members under skills/, extensions/, mcp/, settings/, rules/, agents/, or prompts/") { + if !strings.Contains(err.Error(), "contains no members under skills/, extensions/, mcp/, settings/, rules/, agents/, prompts/, or profiles/") { t.Errorf("buildBundle(empty) error %q does not name the no-members condition", err) } }) @@ -206,7 +211,7 @@ func TestBuildBundleEmpty(t *testing.T) { if err == nil { t.Fatal("buildBundle(only empty subdir) = nil error, want rejection") } - if !strings.Contains(err.Error(), "contains no members under skills/, extensions/, mcp/, settings/, rules/, agents/, or prompts/") { + if !strings.Contains(err.Error(), "contains no members under skills/, extensions/, mcp/, settings/, rules/, agents/, prompts/, or profiles/") { t.Errorf("buildBundle(empty subdir) error %q does not name the no-members condition", err) } }) @@ -248,6 +253,7 @@ func TestBuildBundleDoorParity(t *testing.T) { "agents/zeta.md": "# zeta agent", "AGENTS.md": "# fleet context", "prompts/supervisor/SYSTEM.md": "# supervisor prompt", + "profiles/default/profile.yml": "models:\n manager: litellm/claude-opus:high\n agents: {}\n", "models.yml": "models:\n main: anthropic/claude\n", }) bundle, err := buildBundle(root) @@ -274,6 +280,7 @@ func TestBuildBundleDoorParity(t *testing.T) { "extensions/beta/main.go", "mcp/gamma.json", "models.yml", + "profiles/default/profile.yml", "prompts/supervisor/SYSTEM.md", "rules/delta.md", "rules/epsilon.mdc", diff --git a/go/internal/runner/config_materialize.go b/go/internal/runner/config_materialize.go index b3522a42b..3c0acb833 100644 --- a/go/internal/runner/config_materialize.go +++ b/go/internal/runner/config_materialize.go @@ -66,6 +66,7 @@ const ( topDirRules = "rules" topDirAgents = "agents" topDirPrompts = "prompts" + topDirProfiles = "profiles" ) // configTopDirs are the only permitted top-level directories in a config bundle. @@ -77,6 +78,7 @@ var configTopDirs = map[string]struct{}{ topDirRules: {}, topDirAgents: {}, topDirPrompts: {}, + topDirProfiles: {}, } // Top-level regular-file members admitted by exact filename (RIG-1678 T2), and @@ -90,6 +92,9 @@ const ( // memberSystemMD is the only filename admitted under prompts// // (RIG-3075 T2); structural twin of the store door. memberSystemMD = "SYSTEM.md" + // memberProfileYML is the only filename admitted under profiles// + // (RIG-2968 T1); structural twin of the store door. + memberProfileYML = "profile.yml" ) // configFetcher is the T3 fetch seam the materializer pulls through. *ServerLink @@ -486,7 +491,7 @@ func validateMemberPath(name string, typeflag byte) (string, error) { return validateTopLevelMember(name, clean, top, typeflag) } if _, ok := configTopDirs[top]; !ok { - return "", fmt.Errorf("config bundle member %q is not under skills/, extensions/, mcp/, settings/, rules/, agents/, or prompts/", name) + return "", fmt.Errorf("config bundle member %q is not under skills/, extensions/, mcp/, settings/, rules/, agents/, prompts/, or profiles/", name) } return validateNestedMember(name, clean, top, parts, typeflag) } @@ -515,51 +520,75 @@ func validateTopLevelMember(name, clean, top string, typeflag byte) (string, err // carry per-dir grammar. func validateNestedMember(name, clean, top string, parts []string, typeflag byte) (string, error) { if typeflag == tar.TypeReg { - switch top { - case topDirSettings: - // Exactly settings/config.yml — yml-only (OQ-1). - if clean != settingsMember { - return "", fmt.Errorf("config bundle settings member %q must be exactly %s", name, settingsMember) - } - return clean, nil - case topDirRules: - if err := validateFlatRunnerMember(topDirRules, name, parts, ".md", ".mdc"); err != nil { - return "", err - } - return clean, nil - case topDirAgents: - if err := validateFlatRunnerMember(topDirAgents, name, parts, ".md"); err != nil { - return "", err - } - return clean, nil - case topDirMCP: - // mcp/.json — safe base name, required .json suffix. - entry := parts[1] - base, ok := strings.CutSuffix(entry, ".json") - if !ok { - return "", fmt.Errorf("config bundle mcp member %q must have a .json suffix", name) - } - if !configTopLevelName.MatchString(base) { - return "", fmt.Errorf("config bundle mcp member name %q is not a safe name", entry) - } - return clean, nil - case topDirPrompts: - // Exactly prompts//SYSTEM.md — three components, safe , - // filename exactly SYSTEM.md (RIG-3075 T2). Stricter than - // skills/extensions, so it needs its own arm, not the fall-through. - if len(parts) != 3 || parts[2] != memberSystemMD { - return "", fmt.Errorf("config bundle prompts member %q must be prompts//%s", name, memberSystemMD) - } - if !configTopLevelName.MatchString(parts[1]) { - return "", fmt.Errorf("config bundle prompts role name %q is not a safe name", parts[1]) - } - return clean, nil - } + return validateNestedRegularMember(name, clean, top, parts) } + // Directory entries under any top dir: only the first-level name must be + // safe; deeper segments rely on the traversal + containment guards. + if !configTopLevelName.MatchString(parts[1]) { + return "", fmt.Errorf("config bundle member name %q is not a safe name", parts[1]) + } + return clean, nil +} - // skills/ or extensions/ (and directory entries under any top dir): the - // first-level name must be safe; deeper segments rely on the traversal + - // containment guards. +// validateNestedRegularMember validates a regular-file member (two or more path +// components) under a whitelisted top dir. settings/rules/agents/mcp/prompts/ +// profiles carry per-dir grammar; a regular file under skills/ or extensions/ +// requires only a safe first-level name. +func validateNestedRegularMember(name, clean, top string, parts []string) (string, error) { + switch top { + case topDirSettings: + // Exactly settings/config.yml — yml-only (OQ-1). + if clean != settingsMember { + return "", fmt.Errorf("config bundle settings member %q must be exactly %s", name, settingsMember) + } + return clean, nil + case topDirRules: + if err := validateFlatRunnerMember(topDirRules, name, parts, ".md", ".mdc"); err != nil { + return "", err + } + return clean, nil + case topDirAgents: + if err := validateFlatRunnerMember(topDirAgents, name, parts, ".md"); err != nil { + return "", err + } + return clean, nil + case topDirMCP: + // mcp/.json — safe base name, required .json suffix. + entry := parts[1] + base, ok := strings.CutSuffix(entry, ".json") + if !ok { + return "", fmt.Errorf("config bundle mcp member %q must have a .json suffix", name) + } + if !configTopLevelName.MatchString(base) { + return "", fmt.Errorf("config bundle mcp member name %q is not a safe name", entry) + } + return clean, nil + case topDirPrompts: + // Exactly prompts//SYSTEM.md — three components, safe , + // filename exactly SYSTEM.md (RIG-3075 T2). + if len(parts) != 3 || parts[2] != memberSystemMD { + return "", fmt.Errorf("config bundle prompts member %q must be prompts//%s", name, memberSystemMD) + } + if !configTopLevelName.MatchString(parts[1]) { + return "", fmt.Errorf("config bundle prompts role name %q is not a safe name", parts[1]) + } + return clean, nil + case topDirProfiles: + // Exactly profiles//profile.yml — three components, safe , + // filename exactly profile.yml (RIG-2968 T1). The store door carries the + // profile SCHEMA validation (superset-key closure + models.agents + // frontmatter-name lint); the runner is the structural twin — layout + // only, no content schema (mirrors the settings/models asymmetry). + if len(parts) != 3 || parts[2] != memberProfileYML { + return "", fmt.Errorf("config bundle profiles member %q must be profiles//%s", name, memberProfileYML) + } + if !configTopLevelName.MatchString(parts[1]) { + return "", fmt.Errorf("config bundle profiles name %q is not a safe name", parts[1]) + } + return clean, nil + } + // skills/ or extensions/: first-level name must be safe; deeper segments + // rely on the traversal + containment guards. if !configTopLevelName.MatchString(parts[1]) { return "", fmt.Errorf("config bundle member name %q is not a safe name", parts[1]) } diff --git a/go/internal/runner/config_materialize_test.go b/go/internal/runner/config_materialize_test.go index 175035eae..2511b0aaa 100644 --- a/go/internal/runner/config_materialize_test.go +++ b/go/internal/runner/config_materialize_test.go @@ -621,13 +621,14 @@ var yamlMap = []byte("compaction:\n enabled: true\n") func TestConfigMaterializeLandsNewMembers(t *testing.T) { root := t.TempDir() tarball := buildConfigTarball(t, map[string][]byte{ - "settings/config.yml": yamlMap, - "AGENTS.md": []byte("# fleet conventions\n"), - "models.yml": []byte("providers:\n x:\n baseUrl: https://y\n"), - "rules/red-green.md": []byte("# red-green\n"), - "rules/hold-lane.mdc": []byte("# hold lane\n"), - "agents/design.md": []byte("# design agent\n"), - "prompts/supervisor/SYSTEM.md": []byte("# supervisor prompt\n"), + "settings/config.yml": yamlMap, + "AGENTS.md": []byte("# fleet conventions\n"), + "models.yml": []byte("providers:\n x:\n baseUrl: https://y\n"), + "rules/red-green.md": []byte("# red-green\n"), + "rules/hold-lane.mdc": []byte("# hold lane\n"), + "agents/design.md": []byte("# design agent\n"), + "prompts/supervisor/SYSTEM.md": []byte("# supervisor prompt\n"), + "profiles/candidate/profile.yml": []byte("models:\n agents: {}\n"), }) f := &fakeConfigFetcher{bundle: AgentConfigBundle{Version: "v1", Tarball: tarball}} m := NewConfigMaterializer(root, f, nil) @@ -637,13 +638,14 @@ func TestConfigMaterializeLandsNewMembers(t *testing.T) { versionDir := filepath.Join(root, "v1") files := map[string]string{ - filepath.Join("settings", "config.yml"): string(yamlMap), - "AGENTS.md": "# fleet conventions\n", - "models.yml": "providers:\n x:\n baseUrl: https://y\n", - filepath.Join("rules", "red-green.md"): "# red-green\n", - filepath.Join("rules", "hold-lane.mdc"): "# hold lane\n", - filepath.Join("agents", "design.md"): "# design agent\n", - filepath.Join("prompts", "supervisor", "SYSTEM.md"): "# supervisor prompt\n", + filepath.Join("settings", "config.yml"): string(yamlMap), + "AGENTS.md": "# fleet conventions\n", + "models.yml": "providers:\n x:\n baseUrl: https://y\n", + filepath.Join("rules", "red-green.md"): "# red-green\n", + filepath.Join("rules", "hold-lane.mdc"): "# hold lane\n", + filepath.Join("agents", "design.md"): "# design agent\n", + filepath.Join("prompts", "supervisor", "SYSTEM.md"): "# supervisor prompt\n", + filepath.Join("profiles", "candidate", "profile.yml"): "models:\n agents: {}\n", } for rel, want := range files { p := filepath.Join(versionDir, rel) @@ -656,7 +658,7 @@ func TestConfigMaterializeLandsNewMembers(t *testing.T) { t.Fatalf("%s mode = %v err=%v, want 0644", rel, fi.Mode().Perm(), err) } } - for _, dir := range []string{"settings", "rules", "agents", "prompts"} { + for _, dir := range []string{"settings", "rules", "agents", "prompts", "profiles"} { di, err := os.Stat(filepath.Join(versionDir, dir)) if err != nil || di.Mode().Perm() != 0o755 { t.Fatalf("%s dir mode = %v err=%v, want 0755", dir, di.Mode().Perm(), err) @@ -670,21 +672,25 @@ func TestConfigMaterializeLandsNewMembers(t *testing.T) { // no current symlink. func TestConfigMaterializeRejectsNewMemberStructure(t *testing.T) { cases := map[string][]byte{ - "settings .yaml variant": buildConfigTarball(t, map[string][]byte{"settings/config.yaml": yamlMap}), - "settings .json variant": buildConfigTarball(t, map[string][]byte{"settings/config.json": []byte("{}")}), - "settings other name": buildConfigTarball(t, map[string][]byte{"settings/other.yml": yamlMap}), - "settings nested": buildConfigTarball(t, map[string][]byte{"settings/a/b.yml": yamlMap}), - "settings non-mapping": buildConfigTarball(t, map[string][]byte{"settings/config.yml": []byte("- a\n- b\n")}), - "rules nested": buildConfigTarball(t, map[string][]byte{"rules/nested/a.md": []byte("x")}), - "rules wrong ext": buildConfigTarball(t, map[string][]byte{"rules/a.txt": []byte("x")}), - "agents non-md": buildConfigTarball(t, map[string][]byte{"agents/a.txt": []byte("x")}), - "agents nested": buildConfigTarball(t, map[string][]byte{"agents/sub/a.md": []byte("x")}), - "top-level other file": buildConfigTarball(t, map[string][]byte{"README.md": []byte("x")}), - "models non-mapping": buildConfigTarball(t, map[string][]byte{"models.yml": []byte("- a\n")}), - "prompts wrong filename": buildConfigTarball(t, map[string][]byte{"prompts/supervisor/other.md": []byte("x")}), - "prompts too deep": buildConfigTarball(t, map[string][]byte{"prompts/supervisor/sub/SYSTEM.md": []byte("x")}), - "prompts too shallow": buildConfigTarball(t, map[string][]byte{"prompts/SYSTEM.md": []byte("x")}), - "prompts bad role name": buildConfigTarball(t, map[string][]byte{"prompts/bad name/SYSTEM.md": []byte("x")}), + "settings .yaml variant": buildConfigTarball(t, map[string][]byte{"settings/config.yaml": yamlMap}), + "settings .json variant": buildConfigTarball(t, map[string][]byte{"settings/config.json": []byte("{}")}), + "settings other name": buildConfigTarball(t, map[string][]byte{"settings/other.yml": yamlMap}), + "settings nested": buildConfigTarball(t, map[string][]byte{"settings/a/b.yml": yamlMap}), + "settings non-mapping": buildConfigTarball(t, map[string][]byte{"settings/config.yml": []byte("- a\n- b\n")}), + "rules nested": buildConfigTarball(t, map[string][]byte{"rules/nested/a.md": []byte("x")}), + "rules wrong ext": buildConfigTarball(t, map[string][]byte{"rules/a.txt": []byte("x")}), + "agents non-md": buildConfigTarball(t, map[string][]byte{"agents/a.txt": []byte("x")}), + "agents nested": buildConfigTarball(t, map[string][]byte{"agents/sub/a.md": []byte("x")}), + "top-level other file": buildConfigTarball(t, map[string][]byte{"README.md": []byte("x")}), + "models non-mapping": buildConfigTarball(t, map[string][]byte{"models.yml": []byte("- a\n")}), + "prompts wrong filename": buildConfigTarball(t, map[string][]byte{"prompts/supervisor/other.md": []byte("x")}), + "prompts too deep": buildConfigTarball(t, map[string][]byte{"prompts/supervisor/sub/SYSTEM.md": []byte("x")}), + "prompts too shallow": buildConfigTarball(t, map[string][]byte{"prompts/SYSTEM.md": []byte("x")}), + "prompts bad role name": buildConfigTarball(t, map[string][]byte{"prompts/bad name/SYSTEM.md": []byte("x")}), + "profiles wrong filename": buildConfigTarball(t, map[string][]byte{"profiles/candidate/other.yml": []byte("models: {}\n")}), + "profiles too deep": buildConfigTarball(t, map[string][]byte{"profiles/candidate/sub/profile.yml": []byte("models: {}\n")}), + "profiles too shallow": buildConfigTarball(t, map[string][]byte{"profiles/profile.yml": []byte("models: {}\n")}), + "profiles bad name": buildConfigTarball(t, map[string][]byte{"profiles/bad name/profile.yml": []byte("models: {}\n")}), } for name, tarball := range cases { t.Run(name, func(t *testing.T) { diff --git a/go/internal/store/agent_config.go b/go/internal/store/agent_config.go index 61cbbe79d..fb3ccc6a0 100644 --- a/go/internal/store/agent_config.go +++ b/go/internal/store/agent_config.go @@ -41,6 +41,7 @@ const ( topDirRules = "rules" topDirAgents = "agents" topDirPrompts = "prompts" + topDirProfiles = "profiles" ) // Top-level regular-file members admitted by exact filename (not under a top @@ -55,6 +56,9 @@ const ( // memberSystemMD is the ONLY filename admitted under prompts// — the // role prompt is exactly prompts//SYSTEM.md (RIG-3075 T2). memberSystemMD = "SYSTEM.md" + // memberProfileYML is the ONLY filename admitted under profiles// — + // the profile is exactly profiles//profile.yml (RIG-2968 T1). + memberProfileYML = "profile.yml" ) // configBundleTopDirs is the whitelist as a set, for the O(1) membership check in @@ -67,6 +71,7 @@ var configBundleTopDirs = map[string]bool{ topDirRules: true, topDirAgents: true, topDirPrompts: true, + topDirProfiles: true, } // configNamePattern is the grammar for a config entry's segment — @@ -77,6 +82,12 @@ var configBundleTopDirs = map[string]bool{ // secretNamePattern posture). var configNamePattern = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) +// frontmatterNamePattern matches a `name:` line in an agents/*.md frontmatter +// block for the tolerant line-scan fallback in agentDefFrontmatterName, used +// when a strict whole-block YAML parse fails because a SIBLING field is a +// YAML-ambiguous scalar. Anchored; the first match wins. +var frontmatterNamePattern = regexp.MustCompile(`^name:\s*(.*)$`) + const ( // maxDecompressedBytes caps the total DECOMPRESSED size of a config bundle, // enforced DURING gunzip (cappedReader) so a gzip bomb — a few KiB that @@ -228,6 +239,7 @@ type AgentConfigInfoResult struct { Rules []string Subagents []string Prompts []string + Profiles []string HasSettings bool HasAgentsMD bool HasModels bool @@ -256,6 +268,7 @@ func configBundleMemberNames(bundle []byte) (AgentConfigInfoResult, error) { ruleSet := make(map[string]bool) agentSet := make(map[string]bool) promptSet := make(map[string]bool) + profileSet := make(map[string]bool) var info AgentConfigInfoResult tr := tar.NewReader(&cappedReader{r: gz}) @@ -307,6 +320,12 @@ func configBundleMemberNames(bundle []byte) (AgentConfigInfoResult, error) { if len(parts) == 3 && parts[2] == memberSystemMD { promptSet[parts[1]] = true } + case topDirProfiles: + // A profile is exactly profiles//profile.yml; count the + // , matching the door grammar (validateProfileMember). + if len(parts) == 3 && parts[2] == memberProfileYML { + profileSet[parts[1]] = true + } } } info.Skills = sortedKeys(skillSet) @@ -315,6 +334,7 @@ func configBundleMemberNames(bundle []byte) (AgentConfigInfoResult, error) { info.Rules = sortedKeys(ruleSet) info.Subagents = sortedKeys(agentSet) info.Prompts = sortedKeys(promptSet) + info.Profiles = sortedKeys(profileSet) return info, nil } @@ -453,6 +473,35 @@ func validateAndHashConfigBundle(bundle []byte) (string, error) { members = append(members, member{name: hdr.Name, content: content}) } + // Cross-member profile lint (RIG-2968 T1). The per-member pass above + // validated each profiles//profile.yml in isolation (YAML mapping + + // superset-key closure + models.* selector shape); the models.agents key + // lint is CROSS-MEMBER — each key must match the frontmatter name: of an + // agents/*.md def in the SAME bundle — so it runs here over the fully + // collected member set, after the single streamed pass. It reads only the + // already-collected member bytes (no re-decompress) and never feeds the + // hash, so the canonical version stays order-independent and metadata-zeroed. + // Separate the two member classes the lint needs (agent-def frontmatter + // names, and the profile bodies) into plain maps so the check is a pure + // function over collected bytes. The member NAME segment already passed the + // grammar in validateRegularMember, so parts[1] is safe to index. + agentDefNames := make(map[string]bool) + profileBodies := make(map[string][]byte) + for _, m := range members { + parts := strings.Split(m.name, "/") + switch { + case len(parts) == 2 && parts[0] == topDirAgents: + if name := agentDefFrontmatterName(m.content); name != "" { + agentDefNames[name] = true + } + case len(parts) == 3 && parts[0] == topDirProfiles && parts[2] == memberProfileYML: + profileBodies[m.name] = m.content + } + } + if err := lintProfileAgentKeys(profileBodies, agentDefNames); err != nil { + return "", err + } + // Sort by name. Duplicate regular names are rejected above, so keys are // unique and this ordering is total — sort stability is moot. sort.Slice(members, func(i, j int) bool { return members[i].name < members[j].name }) @@ -499,7 +548,7 @@ func configMemberParts(name string) ([]string, error) { return parts, nil } if !configBundleTopDirs[parts[0]] { - return nil, fmt.Errorf("%w: bundle member %q is not under skills/, extensions/, mcp/, settings/, rules/, agents/, or prompts/ and is not a top-level %s or %s", ErrInvalidArgument, name, memberAgentsMD, memberModels) + return nil, fmt.Errorf("%w: bundle member %q is not under skills/, extensions/, mcp/, settings/, rules/, agents/, prompts/, or profiles/ and is not a top-level %s or %s", ErrInvalidArgument, name, memberAgentsMD, memberModels) } return parts, nil } @@ -556,6 +605,8 @@ func validateRegularMember(parts []string, r io.Reader) ([]byte, error) { return nil, fmt.Errorf("%w: prompts role name %q must match %s", ErrInvalidArgument, parts[1], configNamePattern.String()) } return io.ReadAll(r) + case topDirProfiles: + return validateProfileMember(parts, joined, r) } // Top-level single-component files (configMemberParts admits only the two @@ -639,6 +690,223 @@ func validateModelsMember(joined string, r io.Reader) ([]byte, error) { return content, nil } +// profileSupersetKeys is the closed set of top-level keys a profile.yml may +// declare (RIG-2968 T1, §Approach superset schema). v1 CONSUMES only `models`; +// `corpus`/`extensions`/`settings` are schema'd, consumption deferred — but all +// four are ACCEPTED at the door so later phases grow additively with no schema +// break. "Unknown key" = a top-level key OUTSIDE this set, never a deferred axis. +var profileSupersetKeys = map[string]bool{ + "models": true, + "corpus": true, + "extensions": true, + "settings": true, +} + +// validateProfileMember validates a profiles//profile.yml member in +// isolation: exactly three components with filename profile.yml and a +// grammar-valid , a YAML-mapping body, top-level keys within the profile +// superset, and (where present) string-shaped models.* selectors. The +// CROSS-MEMBER models.agents key lint (each key must match a shipped agent def's +// frontmatter name) is not enforceable per-member and runs in +// validateAndHashConfigBundle over the collected member set. +func validateProfileMember(parts []string, joined string, r io.Reader) ([]byte, error) { + if len(parts) != 3 || parts[2] != memberProfileYML { + return nil, fmt.Errorf("%w: profiles member %q must be profiles//%s", ErrInvalidArgument, joined, memberProfileYML) + } + if !configNamePattern.MatchString(parts[1]) { + return nil, fmt.Errorf("%w: profiles name %q must match %s", ErrInvalidArgument, parts[1], configNamePattern.String()) + } + content, err := io.ReadAll(r) + if err != nil { + return nil, err + } + mapping, err := parseYAMLMapping(content, joined) + if err != nil { + return nil, err + } + for key := range mapping { + if !profileSupersetKeys[key] { + return nil, fmt.Errorf("%w: profile member %q sets unknown top-level key %q (allowed: corpus, extensions, models, settings)", ErrInvalidArgument, joined, key) + } + } + if err := validateProfileModelSelectors(mapping, joined); err != nil { + return nil, err + } + // The profile settings sub-mapping shares the settings/config.yml credential + // axis, so reuse the same denylist here (F3). The extensions/corpus axes' + // credential surfaces are deferred with their consumption task — not now. + if settingsRaw, present := mapping["settings"]; present && settingsRaw != nil { + if err := rejectNonStringKeys(settingsRaw, joined, "settings"); err != nil { + return nil, err + } + if settingsMap, ok := settingsRaw.(map[string]any); ok { + if err := rejectCredentialSettings(settingsMap, joined); err != nil { + return nil, err + } + } + } + return content, nil +} + +// rejectNonStringKeys rejects a YAML value that is a mapping with any non-string +// key. yaml.v3 decodes such a mapping as map[any]any (not map[string]any), so a +// v.(map[string]any) assertion on it fails OPEN, silently skipping every +// key-level check below. A non-mapping value (scalar/list/absent) is not this +// class and passes through for the caller's own shape handling. +func rejectNonStringKeys(v any, joined, path string) error { + if _, ok := v.(map[any]any); ok { + return fmt.Errorf("%w: profile member %q %s must be a string-keyed mapping", ErrInvalidArgument, joined, path) + } + return nil +} + +// validateProfileModelSelectors enforces the models.* selector SHAPE: a model +// selector is an opaque string (the split-on-last-colon grammar is the SDK's, +// never re-parsed here). models.manager, where present, must be a string; every +// value under models.agents, where present, must be a string. An absent or null +// axis is fine (deferred/empty). Non-string selectors are rejected so a +// mis-shaped profile fails closed at the door rather than silently at render. +// A models (or models.agents) mapping with any non-string key is rejected up +// front: yaml.v3 decodes it as map[any]any, so a naive map[string]any assertion +// would fail OPEN and skip every selector check below. +func validateProfileModelSelectors(mapping map[string]any, joined string) error { + modelsRaw, present := mapping["models"] + if !present || modelsRaw == nil { + return nil + } + if err := rejectNonStringKeys(modelsRaw, joined, "models"); err != nil { + return err + } + models, ok := modelsRaw.(map[string]any) + if !ok { + // A non-mapping models value (scalar/list) is a deferred-shape concern, + // not a v1 door failure (v1 consumes models but tolerates an empty axis). + // The map[any]any case was already rejected above. + return nil + } + if v, present := models["manager"]; present && v != nil { + if _, ok := v.(string); !ok { + return fmt.Errorf("%w: profile member %q models.manager must be a string selector", ErrInvalidArgument, joined) + } + } + agentsRaw, present := models["agents"] + if !present || agentsRaw == nil { + return nil + } + if err := rejectNonStringKeys(agentsRaw, joined, "models.agents"); err != nil { + return err + } + agents, ok := agentsRaw.(map[string]any) + if !ok { + return nil + } + for name, v := range agents { + if v == nil { + continue + } + if _, ok := v.(string); !ok { + return fmt.Errorf("%w: profile member %q models.agents.%s must be a string selector", ErrInvalidArgument, joined, name) + } + } + return nil +} + +// lintProfileAgentKeys is the CROSS-MEMBER models.agents key lint (RIG-2968 T1): +// every key under a profile's models.agents must match the FRONTMATTER name: of +// an agents/*.md def shipped in the SAME bundle — NOT its filename stem. The SDK +// resolves a subagent by agent.name and consults the override record per spawned +// agentName, so a key matching no def name is a SILENT no-op at spawn; the lint +// turns that typo into a reviewable door failure. agentDefNames is the set of +// frontmatter names collected from the bundle's agents/ members; profileBodies +// maps each profile member path to its raw YAML. Runs after the streamed pass, +// over already-collected bytes, so it never perturbs the canonical hash. +func lintProfileAgentKeys(profileBodies map[string][]byte, agentDefNames map[string]bool) error { + for joined, body := range profileBodies { + mapping, err := parseYAMLMapping(body, joined) + if err != nil { + // Already validated during the per-member pass; a re-parse failure + // here would be a logic error, but fail closed regardless. + return err + } + // The per-member validateProfileModelSelectors pass runs and aborts the + // bundle before this cross-member lint, and it already rejected any + // non-string-keyed models mapping (yaml.v3's map[any]any). So no such + // mapping reaches here: these map[string]any assertions cannot fail-open + // on that class, and a miss below is a genuine non-mapping value. + models, ok := mapping["models"].(map[string]any) + if !ok { + continue + } + agents, ok := models["agents"].(map[string]any) + if !ok { + continue + } + for name := range agents { + if !agentDefNames[name] { + return fmt.Errorf("%w: profile member %q models.agents key %q matches no shipped agents/*.md def frontmatter name (a key matching no def name is a silent no-op at spawn)", ErrInvalidArgument, joined, name) + } + } + } + return nil +} + +// agentDefFrontmatterName parses an agents/*.md def's leading YAML frontmatter +// and returns its name: field, or "" if there is no frontmatter or no name. It +// recovers the name even when a SIBLING frontmatter field is a YAML-ambiguous +// scalar (e.g. `description: A thing: with a colon`) that would fail a strict +// whole-block parse, matching the SDK loader's permissiveness: it first tries a +// full YAML parse, then falls back to a tolerant `name:` line-scan (mirroring +// the SDK's parseFrontmatter line-parser cascade for the name field). The lint +// keys on this parsed name, NOT the filename stem, so a def whose frontmatter +// name diverges from its stem lints correctly. +func agentDefFrontmatterName(content []byte) string { + fm, ok := extractFrontmatter(content) + if !ok { + return "" + } + var doc struct { + Name string `yaml:"name"` + } + if err := yaml.Unmarshal(fm, &doc); err == nil && doc.Name != "" { + return doc.Name + } + // Whole-block parse failed or yielded no name: a sibling field may be a + // YAML-ambiguous scalar. Fall back to a tolerant line-scan for the name + // field alone, mirroring the SDK's parseFrontmatter line-parser fallback. + for line := range strings.SplitSeq(string(fm), "\n") { + m := frontmatterNamePattern.FindStringSubmatch(line) + if m == nil { + continue + } + raw := strings.TrimSpace(m[1]) + var scalar string + if err := yaml.Unmarshal([]byte(raw), &scalar); err == nil && scalar != "" { + return scalar + } + return raw + } + return "" +} + +// extractFrontmatter returns the YAML frontmatter block bytes between a leading +// `---` line and the next `---` line, and whether such a block was found. It +// mirrors the standard Markdown front-matter shape the SDK's def loader consumes: +// the opening fence must be the file's first line. +func extractFrontmatter(content []byte) ([]byte, bool) { + s := string(content) + s = strings.TrimPrefix(s, "\ufeff") + if !strings.HasPrefix(s, "---\n") && !strings.HasPrefix(s, "---\r\n") { + return nil, false + } + rest := s[strings.IndexByte(s, '\n')+1:] + for _, fence := range []string{"\n---\n", "\n---\r\n", "\n---"} { + if idx := strings.Index(rest, fence); idx >= 0 { + return []byte(rest[:idx+1]), true + } + } + return nil, false +} + // validateFlatNamedMember enforces a flat dir/ member: matches // the config name grammar and is one of the allowed extensions. func validateFlatNamedMember(topDir, filename, joined string, exts ...string) error { @@ -698,15 +966,13 @@ func rejectCredentialSettings(mapping map[string]any, joined string) error { // yamlPathIsSet reports whether the nested path resolves to a present (non-nil) // value in the mapping. An intermediate segment that is not a mapping means the -// path is not set. +// path is not set. It descends through both string-keyed and non-string-keyed +// mapping nodes (see yamlMapIndex): a non-string SIBLING key one level above a +// credential leaf must not be able to shield that leaf from the denylist walk. func yamlPathIsSet(mapping map[string]any, segments []string) bool { var current any = mapping for _, seg := range segments { - m, ok := current.(map[string]any) - if !ok { - return false - } - next, present := m[seg] + next, present := yamlMapIndex(current, seg) if !present || next == nil { return false } @@ -715,6 +981,25 @@ func yamlPathIsSet(mapping map[string]any, segments []string) bool { return true } +// yamlMapIndex looks up a string key in a YAML-decoded mapping node that may be +// either map[string]any (all keys are strings) or map[any]any (yaml.v3 decodes a +// mapping to this shape when ANY key is non-string). Handling both means a +// non-string sibling key can no longer flip an intermediate node to map[any]any +// and thereby fail-open a map[string]any-only lookup, hiding a string-keyed leaf +// from a security walk. A non-mapping node yields (nil, false). +func yamlMapIndex(node any, key string) (any, bool) { + switch m := node.(type) { + case map[string]any: + v, ok := m[key] + return v, ok + case map[any]any: + v, ok := m[key] + return v, ok + default: + return nil, false + } +} + // rejectCredentialModels rejects a models.yml member that sets either of the two // credential-bearing provider surfaces (CP-4): // diff --git a/go/internal/store/agent_config_test.go b/go/internal/store/agent_config_test.go index c43281aa5..2f0c33b5c 100644 --- a/go/internal/store/agent_config_test.go +++ b/go/internal/store/agent_config_test.go @@ -17,6 +17,7 @@ import ( "bytes" "compress/gzip" "errors" + "os" "strings" "testing" "time" @@ -409,6 +410,13 @@ func TestValidateConfigBundleAcceptsNewMembers(t *testing.T) { {"rules .mdc", []tarEntry{{name: "rules/b.mdc", content: "# rule b"}}}, {"agents .md", []tarEntry{{name: "agents/design.md", content: "# design agent"}}}, {"prompts//SYSTEM.md", []tarEntry{{name: "prompts/supervisor/SYSTEM.md", content: "# supervisor"}}}, + {"profiles//profile.yml empty models", []tarEntry{{name: "profiles/candidate/profile.yml", content: "models:\n agents: {}\n"}}}, + {"profiles full superset", []tarEntry{{name: "profiles/candidate/profile.yml", content: "models:\n manager: litellm/claude-opus:high\n agents: {}\ncorpus:\n prompts: null\n skills: []\n rules: []\nextensions:\n mcp: null\nsettings: {}\n"}}}, + {"profiles empty document", []tarEntry{{name: "profiles/candidate/profile.yml", content: ""}}}, + // Symmetric to the shielded-credential reject case below: a nested + // non-string key with NO credential-marked leaf must still be ACCEPTED. + // Guards against yamlMapIndex over-matching and over-rejecting benign config. + {"profiles settings nested non-string key no credential", []tarEntry{{name: "profiles/candidate/profile.yml", content: "settings:\n auth:\n broker:\n 123: x\n"}}}, {"top-level AGENTS.md", []tarEntry{{name: "AGENTS.md", content: "# fleet conventions"}}}, {"top-level models.yml mapping", []tarEntry{{name: "models.yml", content: "providers:\n x:\n baseUrl: https://y\n"}}}, {"models.yml headers env reference", []tarEntry{{name: "models.yml", content: "providers:\n x:\n headers:\n X-Org: MY_ORG_ENV\n"}}}, @@ -448,6 +456,29 @@ func TestValidateConfigBundleRejectsNewMembers(t *testing.T) { {"prompts nested too deep", []tarEntry{{name: "prompts/supervisor/sub/SYSTEM.md", content: "x"}}}, {"prompts flat too shallow", []tarEntry{{name: "prompts/SYSTEM.md", content: "x"}}}, {"prompts bad role name", []tarEntry{{name: "prompts/bad name/SYSTEM.md", content: "x"}}}, + {"profiles wrong filename", []tarEntry{{name: "profiles/candidate/other.yml", content: "models: {}\n"}}}, + {"profiles too deep", []tarEntry{{name: "profiles/candidate/sub/profile.yml", content: "models: {}\n"}}}, + {"profiles too shallow", []tarEntry{{name: "profiles/profile.yml", content: "models: {}\n"}}}, + {"profiles bad name", []tarEntry{{name: "profiles/bad name/profile.yml", content: "models: {}\n"}}}, + {"profiles malformed yaml", []tarEntry{{name: "profiles/candidate/profile.yml", content: "models: [unterminated\n"}}}, + {"profiles non-mapping", []tarEntry{{name: "profiles/candidate/profile.yml", content: "- a\n- b\n"}}}, + {"profiles unknown top-level key", []tarEntry{{name: "profiles/candidate/profile.yml", content: "models: {}\nbogus: 1\n"}}}, + {"profiles models.manager non-string", []tarEntry{{name: "profiles/candidate/profile.yml", content: "models:\n manager:\n nested: 1\n"}}}, + {"profiles models.agents value non-string", []tarEntry{{name: "agents/impl.md", content: "---\nname: impl\n---\nx"}, {name: "profiles/x/profile.yml", content: "models:\n agents:\n impl:\n k: v\n"}}}, + {"profiles models.agents non-string key (numeric)", []tarEntry{{name: "agents/impl.md", content: "---\nname: implementer\n---\nx"}, {name: "profiles/x/profile.yml", content: "models:\n agents:\n 123: sel\n"}}}, + // on/off/yes/no are !!str under yaml.v3's YAML-1.2 core schema, so an `on:` + // key is a STRING key (map[string]any) and would NOT exercise the guard. + // An explicit bool `true:` is genuinely non-string → map[any]any, rejected + // by rejectNonStringKeys (the sole reason: drop the guard and the bundle is + // accepted, since map[any]any also defeats the cross-member lint's assertion). + {"profiles models.agents non-string key (explicit bool)", []tarEntry{{name: "agents/impl.md", content: "---\nname: implementer\n---\nx"}, {name: "profiles/x/profile.yml", content: "models:\n agents:\n true: sel\n"}}}, + {"profiles models non-string sibling key bypasses manager", []tarEntry{{name: "profiles/x/profile.yml", content: "models:\n manager: litellm/x\n 0: y\n"}}}, + {"profiles settings credential key", []tarEntry{{name: "profiles/x/profile.yml", content: "settings:\n auth:\n broker:\n token: sekret\n"}}}, + // A non-string sibling key inside the credential leaf's parent node flips + // that node to yaml.v3 map[any]any. A map[string]any-only walk fails open on + // it and reports the credential NOT set — the leaf then rides the bundle. + // yamlMapIndex must descend through map[any]any so the leaf is still found. + {"profiles settings credential shielded by nested non-string sibling", []tarEntry{{name: "profiles/x/profile.yml", content: "settings:\n auth:\n broker:\n 123: x\n token: sekret\n"}}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -480,6 +511,14 @@ func TestValidateConfigBundleRejectsCredentialKeys(t *testing.T) { member: tarEntry{name: "settings/config.yml", content: "searxng:\n token: sk-abc\n"}, wantSub: "searxng.token", }, + { + // Top-level settings/config.yml shares the rejectCredentialSettings-> + // yamlMapIndex walk with profile settings; a credential leaf shielded by + // a non-string sibling must reject here too, not just on the profile path. + name: "settings credential key shielded by non-string sibling", + member: tarEntry{name: "settings/config.yml", content: "auth:\n broker:\n 123: x\n token: sekret\n"}, + wantSub: "auth.broker.token", + }, { name: "models apiKey", member: tarEntry{name: "models.yml", content: "providers:\n x:\n apiKey: sk-live-123\n"}, @@ -538,6 +577,8 @@ func TestConfigBundleMemberNamesNewMembers(t *testing.T) { tarEntry{name: "agents/review.md", content: "x"}, tarEntry{name: "prompts/supervisor/SYSTEM.md", content: "# sup"}, tarEntry{name: "prompts/owner/SYSTEM.md", content: "# own"}, + tarEntry{name: "profiles/default/profile.yml", content: "models: {}\n"}, + tarEntry{name: "profiles/fast/profile.yml", content: "models: {}\n"}, ) info, err := configBundleMemberNames(b) if err != nil { @@ -555,4 +596,85 @@ func TestConfigBundleMemberNamesNewMembers(t *testing.T) { if got, want := strings.Join(info.Prompts, ","), "owner,supervisor"; got != want { t.Errorf("prompts = %q, want %q", got, want) } + if got, want := strings.Join(info.Profiles, ","), "default,fast"; got != want { + t.Errorf("profiles = %q, want %q", got, want) + } +} + +// TestValidateConfigBundleProfileAgentKeyLint pins the cross-member +// models.agents key lint (RIG-2968 T1): a profile keying a subagent-role model +// is admitted ONLY when the key matches the FRONTMATTER name: of an agents/*.md +// def shipped in the same bundle — NOT the filename stem. The def in these +// fixtures has a frontmatter name that DIVERGES from its stem (stem "impl", +// frontmatter name "implementer"), so the two directions pin that the lint keys +// on the parsed frontmatter name, not the stem. +func TestValidateConfigBundleProfileAgentKeyLint(t *testing.T) { + // A def whose frontmatter name (implementer) diverges from its stem (impl). + divergentDef := tarEntry{name: "agents/impl.md", content: "---\nname: implementer\ndescription: d\n---\nROLE\n"} + + t.Run("frontmatter-name key ACCEPTED", func(t *testing.T) { + b := buildBundle(t, gzip.DefaultCompression, time.Unix(1000, 0), + divergentDef, + tarEntry{name: "profiles/candidate/profile.yml", content: "models:\n agents:\n implementer: litellm/claude-sonnet:medium\n"}, + ) + if _, err := validateAndHashConfigBundle(b); err != nil { + t.Fatalf("profile keying the frontmatter name rejected: %v", err) + } + }) + + t.Run("stem-only key REJECTED", func(t *testing.T) { + b := buildBundle(t, gzip.DefaultCompression, time.Unix(1000, 0), + divergentDef, + tarEntry{name: "profiles/candidate/profile.yml", content: "models:\n agents:\n impl: litellm/claude-sonnet:medium\n"}, + ) + _, err := validateAndHashConfigBundle(b) + if !errors.Is(err, ErrInvalidArgument) { + t.Fatalf("want ErrInvalidArgument for a stem-only key, got %v", err) + } + if !strings.Contains(err.Error(), "impl") { + t.Fatalf("error %q should name the offending key", err) + } + }) + + t.Run("no matching def REJECTED", func(t *testing.T) { + b := buildBundle(t, gzip.DefaultCompression, time.Unix(1000, 0), + divergentDef, + tarEntry{name: "profiles/candidate/profile.yml", content: "models:\n agents:\n ghost: litellm/claude-sonnet:medium\n"}, + ) + if _, err := validateAndHashConfigBundle(b); !errors.Is(err, ErrInvalidArgument) { + t.Fatalf("want ErrInvalidArgument for a key matching no def, got %v", err) + } + }) + + // F2: a def whose SIBLING frontmatter field (description) is a YAML-ambiguous + // scalar (bare colon) must still have its name recovered so a profile keying + // that name is ACCEPTED — the door must be at least as permissive as the SDK + // loader. Reds before the agentDefFrontmatterName line-scan fallback (name + // parses to "" -> lint rejects), greens after. + t.Run("colon-bearing sibling field name recovered ACCEPTED", func(t *testing.T) { + b := buildBundle(t, gzip.DefaultCompression, time.Unix(1000, 0), + tarEntry{name: "agents/impl.md", content: "---\nname: implementer\ndescription: A thing: with a colon\n---\nROLE\n"}, + tarEntry{name: "profiles/x/profile.yml", content: "models:\n agents:\n implementer: sel\n"}, + ) + if _, err := validateAndHashConfigBundle(b); err != nil { + t.Fatalf("profile keying a name from a def with a colon-bearing sibling field rejected: %v", err) + } + }) +} + +// TestValidateConfigBundleAdmitsDefaultProfile pins that the shipped fleet +// default profile (config/profiles/default/profile.yml) passes the store door +// unchanged — the "the committed config IS the default profile" contract. It +// reads the real committed file so a drift that reds the door is caught here. +func TestValidateConfigBundleAdmitsDefaultProfile(t *testing.T) { + content, err := os.ReadFile("../../../config/profiles/default/profile.yml") + if err != nil { + t.Fatalf("reading shipped default profile: %v", err) + } + b := buildBundle(t, gzip.DefaultCompression, time.Unix(1000, 0), + tarEntry{name: "profiles/default/profile.yml", content: string(content)}, + ) + if _, err := validateAndHashConfigBundle(b); err != nil { + t.Fatalf("shipped default profile rejected at the door: %v", err) + } }