diff --git a/CLAUDE.md b/CLAUDE.md index 713102d..a51eb1e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -116,7 +116,7 @@ This project follows DDD layered architecture with dependency injection **strict ### Custom (bring-your-own) agents — no Go code -A custom agent can be declared entirely in global config under `agents:` (no `pkg/clients/` package, no Dockerfile in this repo). `config.AgentFromOverride` (pure, `pkg/domain/config`) maps the `AgentOverride` into an `agent.Agent`; `config.ValidateCustomAgent` runs the load-time checks (the image-ref parser is injected as a closure from the composition root to keep the domain free of go-containerregistry). Manage them with `bbox agents list|inspect|doctor|add|init`. +A custom agent can be declared entirely in global config under `agents:` (no `pkg/clients/` package, no Dockerfile in this repo). `config.AgentFromOverride` (pure, `pkg/domain/config`) maps the `AgentOverride` into an `agent.Agent`; `config.ValidateCustomAgent` runs the load-time checks (the image-ref parser is injected as a closure from the composition root to keep the domain free of go-containerregistry). Manage them with `bbox agents list|inspect|doctor|add|init|import|export`. - **Safer defaults than built-ins**: `env_forward` empty (forward nothing), egress profile `standard` (must declare `egress_hosts` for it or set `permissive`), MCP authz `safe-tools` when MCP is enabled. - **Universal `BBOX_*` env** (`pkg/domain/agent.BuildUniversalEnv`) is injected into *every* VM — `BBOX_AGENT_NAME`, `BBOX_WORKSPACE`, `BBOX_HOME`, `BBOX_SESSION_ID`, `BBOX_GIT_TOKEN_AVAILABLE`, `BBOX_SSH_AGENT_AVAILABLE`, plus `BBOX_MCP_URL`/`BBOX_MCP_AUTHZ_PROFILE` when MCP is active. Applied **after** forwarded host vars so it is authoritative (the env_forward allowlist can't clobber it). `BBOX_MCP_URL` uses `config.MCPEndpointPath` (`/mcp`) — the single source of truth shared by the proxy and all clients. diff --git a/cmd/bbox/agents.go b/cmd/bbox/agents.go index a1fc5cb..3cec0b9 100644 --- a/cmd/bbox/agents.go +++ b/cmd/bbox/agents.go @@ -33,6 +33,8 @@ func agentsCmd() *cobra.Command { cmd.AddCommand(agentsDoctorCmd()) cmd.AddCommand(agentsAddCmd()) cmd.AddCommand(agentsInitCmd()) + cmd.AddCommand(agentsImportCmd()) + cmd.AddCommand(agentsExportCmd()) return cmd } diff --git a/cmd/bbox/agents_import.go b/cmd/bbox/agents_import.go new file mode 100644 index 0000000..f4f9fbf --- /dev/null +++ b/cmd/bbox/agents_import.go @@ -0,0 +1,169 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "errors" + "fmt" + "os" + + "github.com/spf13/cobra" + + infraconfig "github.com/stacklok/brood-box/internal/infra/config" + domainconfig "github.com/stacklok/brood-box/pkg/domain/config" +) + +// agentsImportCmd is `bbox agents import `: read a standalone agent +// manifest, validate it, and append the agent to the global config (reuse the +// exact path `agents add` takes so behavior, receipts, and safety match). +func agentsImportCmd() *cobra.Command { + var ( + cfgPath string + force bool + jsonOut bool + ) + cmd := &cobra.Command{ + Use: "import ", + Short: "Import a custom agent from a manifest file into the global config", + Long: `Reads a standalone agent manifest (a YAML file with a top-level name +plus the same fields as an agents: block), validates it with the same +checks the loader uses, and appends it to the global config +(~/.config/broodbox/config.yaml, or the path given by --config). Existing +comments/formatting in the config are preserved; the added agent block is +written as normalized YAML. + +Custom agents are GLOBAL-ONLY: this command never writes to a workspace +.broodbox.yaml. Refuses to overwrite a built-in or an existing custom agent +unless --force. + +The manifest format is identical to an agents: entry, with a top-level +name field: + + name: aider + image: ghcr.io/acme/aider-bbox:latest + command: ["aider"] + env_forward: [OPENAI_API_KEY] + mcp: + mode: env + egress_profile: standard + egress_hosts: + standard: + - { name: api.openai.com, ports: [443] }`, + Example: ` bbox agents import ./broodbox-agent.yaml + bbox agents import ./aider.yaml --json + bbox agents import ./aider.yaml --force # overwrite an existing agent`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runAgentsImport(cmd, args[0], cfgPath, force, jsonOut) + }, + } + cmd.Flags().StringVar(&cfgPath, "config", "", "Config file path (default: ~/.config/broodbox/config.yaml)") + cmd.Flags().BoolVarP(&force, "force", "f", false, "Overwrite a built-in or existing custom agent") + cmd.Flags().BoolVar(&jsonOut, "json", false, "Emit a JSON receipt of the mutation instead of human-readable output") + return cmd +} + +func runAgentsImport(cmd *cobra.Command, manifestPath, cfgPath string, force, jsonOut bool) error { + manifest, err := infraconfig.LoadManifest(manifestPath) + if err != nil { + return err + } + if manifest.Name == "" { + return fmt.Errorf("manifest %s: top-level %q field is required", manifestPath, "name") + } + + override := manifest.AgentOverride + + // Full custom-agent validation up front — never write an invalid agent. + if err := domainconfig.ValidateCustomAgent(manifest.Name, override, imageRefValidator); err != nil { + return fmt.Errorf("invalid agent %q: %w", manifest.Name, err) + } + + // Built-ins are not stored in the config file, so UpsertAgent cannot detect + // the collision — gate it here (mirrors `agents add`). + if _, isBuiltin := builtinNames()[manifest.Name]; isBuiltin && !force { + return fmt.Errorf("%q is a built-in agent; refusing to overwrite (use --force to write an override)", manifest.Name) + } + + path := cfgPath + if path == "" { + path = infraconfig.NewLoader("").Path() + } + + res, err := infraconfig.UpsertAgent(path, manifest.Name, override, force) + if err != nil { + if errors.Is(err, infraconfig.ErrAgentExists) { + return fmt.Errorf("agent %q already exists in %s (use --force to overwrite)", manifest.Name, path) + } + return err + } + + receipt := buildAddReceipt(manifest.Name, override, path, res, os.LookupEnv) + // The receipt Command label reflects the import path so a recorded receipt + // is distinguishable from an `agents add` receipt. + receipt.Command = "agents import" + return emitAddResult(cmd.OutOrStdout(), receipt, jsonOut) +} + +// agentsExportCmd is `bbox agents export `: emit a standalone manifest +// for an existing custom agent to stdout. Env VALUES are never emitted — +// DefaultEnv is stripped so only env NAMES/patterns leave the host. +func agentsExportCmd() *cobra.Command { + var cfgPath string + cmd := &cobra.Command{ + Use: "export ", + Short: "Export a custom agent as a standalone manifest to stdout", + Long: `Writes a standalone agent manifest (the same format ` + "`bbox agents import`" + ` +reads) for an existing custom agent to stdout. Built-in agents cannot be +exported — only custom (bring-your-own) agents declared in the global config. + +Environment variable VALUES are never written: default_env is stripped from +the export so only env names/patterns leave the host. The exported manifest +re-imports cleanly (export then import is a no-op for the agent fields).`, + Example: ` bbox agents export aider > aider.broodbox-agent.yaml + bbox agents export aider --config ~/.config/broodbox/config.yaml`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runAgentsExport(cmd, args[0], cfgPath) + }, + } + cmd.Flags().StringVar(&cfgPath, "config", "", "Config file path (default: ~/.config/broodbox/config.yaml)") + return cmd +} + +func runAgentsExport(cmd *cobra.Command, agentName, cfgPath string) error { + if _, isBuiltin := builtinNames()[agentName]; isBuiltin { + return fmt.Errorf("%q is a built-in agent; only custom agents can be exported", agentName) + } + + path := cfgPath + if path == "" { + path = infraconfig.NewLoader("").Path() + } + loaded, err := infraconfig.NewLoader(path).Load() + if err != nil { + return fmt.Errorf("loading config %s: %w", path, err) + } + + override, declared := loaded.Agents[agentName] + if !declared { + return fmt.Errorf("agent %q is not declared in %s (run 'bbox agents list' to see available agents)", agentName, path) + } + + // Strip default_env: those are host-supplied VALUES, never exported. The + // rest of the override (env_forward names, env_required names, egress hosts, + // MCP, credentials, settings) carries no secret material. + override.DefaultEnv = nil + + manifest := domainconfig.AgentManifest{ + Name: agentName, + AgentOverride: override, + } + data, err := infraconfig.MarshalManifest(manifest) + if err != nil { + return err + } + _, _ = fmt.Fprint(cmd.OutOrStdout(), string(data)) + return nil +} diff --git a/cmd/bbox/agents_import_test.go b/cmd/bbox/agents_import_test.go new file mode 100644 index 0000000..10bb5f7 --- /dev/null +++ b/cmd/bbox/agents_import_test.go @@ -0,0 +1,423 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + infraconfig "github.com/stacklok/brood-box/internal/infra/config" + domainconfig "github.com/stacklok/brood-box/pkg/domain/config" +) + +// writeManifest writes a manifest YAML file for tests. +func writeManifest(t *testing.T, path, yamlContent string) { + t.Helper() + require.NoError(t, os.WriteFile(path, []byte(yamlContent), 0o600)) +} + +const sampleManifestYAML = `name: aider +image: ghcr.io/acme/aider-bbox:latest +command: ["aider"] +description: ACME agent +env_forward: [OPENAI_API_KEY] +env_required: [OPENAI_API_KEY] +egress_profile: standard +egress_hosts: + standard: + - name: api.openai.com + ports: [443] +mcp: + enabled: true + mode: env + authz: + profile: safe-tools +` + +func TestAgentsImportEndToEnd(t *testing.T) { + t.Setenv("OPENAI_API_KEY", "present-value") + + cfgPath := filepath.Join(t.TempDir(), "config.yaml") + manifestPath := filepath.Join(t.TempDir(), "aider.yaml") + writeManifest(t, manifestPath, sampleManifestYAML) + + var out bytes.Buffer + cmd := agentsCmd() + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"import", manifestPath, "--config", cfgPath, "--json"}) + require.NoError(t, cmd.Execute()) + + var receipt agentReceipt + require.NoError(t, json.Unmarshal(out.Bytes(), &receipt)) + assert.Equal(t, "agents import", receipt.Command) + assert.True(t, receipt.OK) + assert.Equal(t, "custom", receipt.Agent.Type) + assert.Equal(t, "aider", receipt.Agent.Name) + assert.Equal(t, "ghcr.io/acme/aider-bbox:latest", receipt.Agent.Image) + assert.Equal(t, "standard", receipt.Agent.EgressProfile) + assert.Equal(t, domainconfig.MCPModeEnv, receipt.Agent.MCPMode) + require.NotNil(t, receipt.Write) + assert.True(t, receipt.Write.Created) + + // doctor --json on the imported agent passes. + var dout bytes.Buffer + dcmd := agentsCmd() + dcmd.SetOut(&dout) + dcmd.SetErr(&dout) + dcmd.SetArgs([]string{"doctor", "aider", "--config", cfgPath, "--json"}) + require.NoError(t, dcmd.Execute()) + + var dr agentReceipt + require.NoError(t, json.Unmarshal(dout.Bytes(), &dr)) + assert.True(t, dr.OK) + + // The written config round-trips through the loader. + loaded, err := infraconfig.NewLoader(cfgPath).Load() + require.NoError(t, err) + custom, ok := loaded.Agents["aider"] + require.True(t, ok) + assert.Equal(t, "ghcr.io/acme/aider-bbox:latest", custom.Image) + assert.Equal(t, []string{"aider"}, custom.Command) + assert.Equal(t, []string{"OPENAI_API_KEY"}, custom.EnvForward) + require.NotNil(t, custom.MCP) + assert.Equal(t, domainconfig.MCPModeEnv, custom.MCP.Mode) +} + +func TestAgentsImportRefusesExistingWithoutForce(t *testing.T) { + t.Parallel() + + cfgPath := filepath.Join(t.TempDir(), "config.yaml") + manifestPath := filepath.Join(t.TempDir(), "aider.yaml") + writeManifest(t, manifestPath, sampleManifestYAML) + + args := []string{"import", manifestPath, "--config", cfgPath} + + first := agentsCmd() + first.SetOut(&bytes.Buffer{}) + first.SetErr(&bytes.Buffer{}) + first.SetArgs(args) + require.NoError(t, first.Execute()) + + second := agentsCmd() + second.SetOut(&bytes.Buffer{}) + second.SetErr(&bytes.Buffer{}) + second.SetArgs(args) + err := second.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "already exists") + + // --force allows the overwrite. + third := agentsCmd() + third.SetOut(&bytes.Buffer{}) + third.SetErr(&bytes.Buffer{}) + third.SetArgs(append(args, "--force")) + require.NoError(t, third.Execute()) +} + +func TestAgentsImportRefusesBuiltin(t *testing.T) { + t.Parallel() + + cfgPath := filepath.Join(t.TempDir(), "config.yaml") + manifestPath := filepath.Join(t.TempDir(), "claude.yaml") + writeManifest(t, manifestPath, `name: claude-code +image: ghcr.io/acme/x:latest +command: ["x"] +egress_profile: permissive +`) + + cmd := agentsCmd() + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"import", manifestPath, "--config", cfgPath}) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "built-in agent") + _, statErr := os.Stat(cfgPath) + assert.True(t, os.IsNotExist(statErr)) +} + +func TestAgentsImportRejectsMissingName(t *testing.T) { + t.Parallel() + + cfgPath := filepath.Join(t.TempDir(), "config.yaml") + manifestPath := filepath.Join(t.TempDir(), "noname.yaml") + writeManifest(t, manifestPath, `image: ghcr.io/acme/x:latest +command: ["x"] +egress_profile: permissive +`) + + cmd := agentsCmd() + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"import", manifestPath, "--config", cfgPath}) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), `"name" field is required`) + _, statErr := os.Stat(cfgPath) + assert.True(t, os.IsNotExist(statErr)) +} + +func TestAgentsImportRejectsInvalidManifest(t *testing.T) { + t.Parallel() + + cfgPath := filepath.Join(t.TempDir(), "config.yaml") + manifestPath := filepath.Join(t.TempDir(), "bad.yaml") + // Missing required command -> ValidateCustomAgent fails. + writeManifest(t, manifestPath, `name: bad +image: ghcr.io/acme/x:latest +egress_profile: permissive +`) + + cmd := agentsCmd() + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"import", manifestPath, "--config", cfgPath}) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "command is required") + _, statErr := os.Stat(cfgPath) + assert.True(t, os.IsNotExist(statErr)) +} + +func TestAgentsImportRejectsUnknownField(t *testing.T) { + t.Parallel() + + cfgPath := filepath.Join(t.TempDir(), "config.yaml") + manifestPath := filepath.Join(t.TempDir(), "unknown.yaml") + writeManifest(t, manifestPath, `name: x +image: ghcr.io/acme/x:latest +command: ["x"] +egress_profile: permissive +bogus_field: true +`) + + cmd := agentsCmd() + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"import", manifestPath, "--config", cfgPath}) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "bogus_field") +} + +func TestAgentsExportEndToEnd(t *testing.T) { + t.Setenv("OPENAI_API_KEY", "present-value") + + cfgPath := filepath.Join(t.TempDir(), "config.yaml") + manifestPath := filepath.Join(t.TempDir(), "aider.yaml") + writeManifest(t, manifestPath, sampleManifestYAML) + + // Import first so the agent exists in the config. + importCmd := agentsCmd() + importCmd.SetOut(&bytes.Buffer{}) + importCmd.SetErr(&bytes.Buffer{}) + importCmd.SetArgs([]string{"import", manifestPath, "--config", cfgPath}) + require.NoError(t, importCmd.Execute()) + + // Export it back out. + var out bytes.Buffer + expCmd := agentsCmd() + expCmd.SetOut(&out) + expCmd.SetErr(&out) + expCmd.SetArgs([]string{"export", "aider", "--config", cfgPath}) + require.NoError(t, expCmd.Execute()) + + exported := out.String() + assert.Contains(t, exported, "name: aider") + assert.Contains(t, exported, "image: ghcr.io/acme/aider-bbox:latest") + assert.Contains(t, exported, "api.openai.com") + assert.Contains(t, exported, "mode: env") + + // Re-import the exported manifest into a fresh config — round-trip no-op. + cfgPath2 := filepath.Join(t.TempDir(), "config2.yaml") + reExported := filepath.Join(t.TempDir(), "reexported.yaml") + writeManifest(t, reExported, exported) + + var out2 bytes.Buffer + reimportCmd := agentsCmd() + reimportCmd.SetOut(&out2) + reimportCmd.SetErr(&out2) + reimportCmd.SetArgs([]string{"import", reExported, "--config", cfgPath2, "--json"}) + require.NoError(t, reimportCmd.Execute()) + + var receipt agentReceipt + require.NoError(t, json.Unmarshal(out2.Bytes(), &receipt)) + assert.True(t, receipt.OK) + assert.Equal(t, "aider", receipt.Agent.Name) + assert.Equal(t, "ghcr.io/acme/aider-bbox:latest", receipt.Agent.Image) + + // Both configs hold equivalent agent definitions. + l1, err := infraconfig.NewLoader(cfgPath).Load() + require.NoError(t, err) + l2, err := infraconfig.NewLoader(cfgPath2).Load() + require.NoError(t, err) + assert.Equal(t, l1.Agents["aider"], l2.Agents["aider"]) +} + +func TestAgentsExportStripsDefaultEnvValues(t *testing.T) { + t.Parallel() + + cfgPath := filepath.Join(t.TempDir(), "config.yaml") + // Seed a config with a custom agent that has default_env values (secrets). + seed := `agents: + sec: + image: ghcr.io/acme/sec:latest + command: ["sec"] + egress_profile: permissive + default_env: + API_KEY: "super-secret-value" + OTHER: "another-secret" +` + require.NoError(t, os.WriteFile(cfgPath, []byte(seed), 0o600)) + + var out bytes.Buffer + cmd := agentsCmd() + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"export", "sec", "--config", cfgPath}) + require.NoError(t, cmd.Execute()) + + exported := out.String() + assert.NotContains(t, exported, "super-secret-value") + assert.NotContains(t, exported, "another-secret") + assert.NotContains(t, exported, "default_env") +} + +func TestAgentsExportRefusesBuiltin(t *testing.T) { + t.Parallel() + + cfgPath := filepath.Join(t.TempDir(), "config.yaml") + cmd := agentsCmd() + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"export", "claude-code", "--config", cfgPath}) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "built-in agent") +} + +func TestAgentsExportRefusesUnknown(t *testing.T) { + t.Parallel() + + cfgPath := filepath.Join(t.TempDir(), "config.yaml") + cmd := agentsCmd() + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"export", "nope", "--config", cfgPath}) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "not declared") +} + +func TestAgentsExportImportRoundTripsCredentialsAndSettings(t *testing.T) { + t.Parallel() + + cfgPath := filepath.Join(t.TempDir(), "config.yaml") + manifestPath := filepath.Join(t.TempDir(), "aider.yaml") + writeManifest(t, manifestPath, `name: aider +image: ghcr.io/acme/aider-bbox:latest +command: ["aider"] +egress_profile: permissive +credentials: + persist: + - ".gitconfig" + - ".config/foo/" +settings: + - category: settings + host_path: ".aiderrc" + guest_path: ".aiderrc" + kind: merge-file + format: json + optional: true + allow_keys: ["model", "theme"] +`) + + // Import into the first config. + importCmd := agentsCmd() + importCmd.SetOut(&bytes.Buffer{}) + importCmd.SetErr(&bytes.Buffer{}) + importCmd.SetArgs([]string{"import", manifestPath, "--config", cfgPath}) + require.NoError(t, importCmd.Execute()) + + // Export it back out. + var out bytes.Buffer + expCmd := agentsCmd() + expCmd.SetOut(&out) + expCmd.SetErr(&out) + expCmd.SetArgs([]string{"export", "aider", "--config", cfgPath}) + require.NoError(t, expCmd.Execute()) + + exported := out.String() + assert.Contains(t, exported, ".gitconfig") + assert.Contains(t, exported, ".config/foo/") + assert.Contains(t, exported, ".aiderrc") + assert.Contains(t, exported, "merge-file") + assert.Contains(t, exported, "model") + assert.Contains(t, exported, "theme") + + // Re-import the exported manifest into a fresh config. + cfgPath2 := filepath.Join(t.TempDir(), "config2.yaml") + reExported := filepath.Join(t.TempDir(), "reexported.yaml") + writeManifest(t, reExported, exported) + + reimportCmd := agentsCmd() + reimportCmd.SetOut(&bytes.Buffer{}) + reimportCmd.SetErr(&bytes.Buffer{}) + reimportCmd.SetArgs([]string{"import", reExported, "--config", cfgPath2}) + require.NoError(t, reimportCmd.Execute()) + + // Both configs hold equivalent agent definitions, including credentials/settings. + l1, err := infraconfig.NewLoader(cfgPath).Load() + require.NoError(t, err) + l2, err := infraconfig.NewLoader(cfgPath2).Load() + require.NoError(t, err) + assert.Equal(t, l1.Agents["aider"], l2.Agents["aider"]) + + custom := l1.Agents["aider"] + require.NotNil(t, custom.Credentials) + assert.Equal(t, []string{".gitconfig", ".config/foo/"}, custom.Credentials.Persist) + require.Len(t, custom.Settings, 1) + assert.Equal(t, "settings", custom.Settings[0].Category) + assert.Equal(t, ".aiderrc", custom.Settings[0].HostPath) + assert.Equal(t, "merge-file", custom.Settings[0].Kind) + assert.Equal(t, "json", custom.Settings[0].Format) + assert.True(t, custom.Settings[0].Optional) + assert.Equal(t, []string{"model", "theme"}, custom.Settings[0].AllowKeys) +} + +func TestAgentsExportConfigFileMissing(t *testing.T) { + t.Parallel() + + cfgPath := filepath.Join(t.TempDir(), "does-not-exist.yaml") + cmd := agentsCmd() + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"export", "aider", "--config", cfgPath}) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "not declared") +} + +func TestLoadManifestOperatorPathAcceptsSymlink(t *testing.T) { + t.Parallel() + + // Import paths are operator-supplied (like --config), not workspace-local, + // so a symlink is accepted — matching the global config loader's behavior. + dir := t.TempDir() + target := filepath.Join(dir, "real.yaml") + writeManifest(t, target, sampleManifestYAML) + link := filepath.Join(dir, "link.yaml") + require.NoError(t, os.Symlink(target, link)) + + m, err := infraconfig.LoadManifest(link) + require.NoError(t, err) + assert.Equal(t, "aider", m.Name) +} diff --git a/internal/infra/config/manifest.go b/internal/infra/config/manifest.go new file mode 100644 index 0000000..ea9d4c5 --- /dev/null +++ b/internal/infra/config/manifest.go @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package config + +import ( + "fmt" + + "gopkg.in/yaml.v3" + + "github.com/stacklok/brood-box/internal/infra/configfile" + domainconfig "github.com/stacklok/brood-box/pkg/domain/config" +) + +// LoadManifest reads and decodes a standalone agent manifest file from path. +// It applies the same size cap and strict unknown-field checking as the global +// config loader so a malformed or oversized manifest fails loudly rather than +// being silently dropped. A symlinked path is accepted: import paths are +// operator-supplied (like --config), not attacker-controllable workspace files. +func LoadManifest(path string) (domainconfig.AgentManifest, error) { + data, err := configfile.ReadFile(path, configfile.ReadOptions{}) + if err != nil { + return domainconfig.AgentManifest{}, fmt.Errorf("reading manifest %s: %w", path, err) + } + + var m domainconfig.AgentManifest + if err := configfile.DecodeStrict(data, &m); err != nil { + return domainconfig.AgentManifest{}, fmt.Errorf("parsing manifest %s: %w", path, err) + } + return m, nil +} + +// MarshalManifest encodes a manifest to YAML suitable for `bbox agents export`. +// The output is a single document with the name at the top level followed by +// the agent override fields, normalized via yaml.v3 (omitempty honored). Env +// variable VALUES are the caller's responsibility: export constructs the +// manifest with DefaultEnv stripped so no host values ever reach the file. +func MarshalManifest(m domainconfig.AgentManifest) ([]byte, error) { + data, err := yaml.Marshal(m) + if err != nil { + return nil, fmt.Errorf("marshalling manifest: %w", err) + } + return data, nil +} diff --git a/pkg/domain/config/custom_agent.go b/pkg/domain/config/custom_agent.go index a315fdf..8f24d6f 100644 --- a/pkg/domain/config/custom_agent.go +++ b/pkg/domain/config/custom_agent.go @@ -252,6 +252,9 @@ func ValidateCustomAgent(name string, o AgentOverride, imageRefValidator func(st if !IsValidMCPMode(o.MCP.Mode) { return fmt.Errorf("agent %q: mcp.mode %q: valid values are %q, %q", name, o.MCP.Mode, MCPModeEnv, MCPModeConfig) } + if o.MCP.Authz != nil && o.MCP.Authz.Profile != "" && !IsValidMCPAuthzProfile(o.MCP.Authz.Profile) { + return fmt.Errorf("agent %q: invalid mcp.authz.profile %q: valid values are %v", name, o.MCP.Authz.Profile, ValidMCPAuthzProfiles()) + } if err := validateMCPInject(name, o.MCP); err != nil { return err } @@ -290,6 +293,13 @@ func ValidateCustomAgent(name string, o AgentOverride, imageRefValidator func(st } } + // Egress profile name must be a recognized value before it is used to + // compute the effective profile below — otherwise a typo surfaces as a + // confusing "requires egress_hosts" error instead of an actionable one. + if o.EgressProfile != "" && !egress.ProfileName(o.EgressProfile).IsValid() { + return fmt.Errorf("agent %q: invalid egress_profile %q: valid values are %v", name, o.EgressProfile, egress.ValidProfiles()) + } + // Effective egress profile must have hosts unless permissive. // // Exception: when mcp.mode is "env" or "config", the MCP proxy is the diff --git a/pkg/domain/config/custom_agent_test.go b/pkg/domain/config/custom_agent_test.go index 358481b..7db7194 100644 --- a/pkg/domain/config/custom_agent_test.go +++ b/pkg/domain/config/custom_agent_test.go @@ -432,6 +432,27 @@ func TestValidateCustomAgent(t *testing.T) { override: AgentOverride{Image: "img", Command: []string{"run"}, EgressProfile: "permissive", MCP: &MCPAgentOverride{Mode: MCPModeEnv}}, }, + { + name: "invalid egress_profile rejected", agentName: "x", + override: AgentOverride{Image: "img", Command: []string{"run"}, EgressProfile: "bogus"}, + wantErr: "invalid egress_profile", + }, + { + name: "invalid mcp authz profile rejected", agentName: "x", + override: AgentOverride{Image: "img", Command: []string{"run"}, EgressProfile: "permissive", + MCP: &MCPAgentOverride{Authz: &MCPAuthzConfig{Profile: "totally-wrong"}}}, + wantErr: "invalid mcp.authz.profile", + }, + { + name: "valid mcp authz profile accepted", agentName: "x", + override: AgentOverride{Image: "img", Command: []string{"run"}, EgressProfile: "permissive", + MCP: &MCPAgentOverride{Authz: &MCPAuthzConfig{Profile: MCPAuthzProfileSafeTools}}}, + }, + { + name: "custom mcp authz profile accepted", agentName: "x", + override: AgentOverride{Image: "img", Command: []string{"run"}, EgressProfile: "permissive", + MCP: &MCPAgentOverride{Authz: &MCPAuthzConfig{Profile: MCPAuthzProfileCustom}}}, + }, } for _, tt := range tests { diff --git a/pkg/domain/config/manifest.go b/pkg/domain/config/manifest.go new file mode 100644 index 0000000..e820603 --- /dev/null +++ b/pkg/domain/config/manifest.go @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package config + +// AgentManifest is the standalone, shareable form of a custom (bring-your-own) +// agent definition. It carries the same fields as an AgentOverride (inlined) +// plus a top-level name, so a reusable agent can live in its own file instead +// of only inline under `agents:` in the global config. +// +// The inline embedding guarantees the manifest fields exactly track +// AgentOverride — there is no second field list to keep in sync. `bbox agents +// import` decodes a manifest file into this struct; `bbox agents export` +// marshals it back out. +// +// Example manifest: +// +// name: aider +// image: ghcr.io/acme/aider-bbox:latest +// command: ["aider"] +// env_forward: [OPENAI_API_KEY] +// egress_profile: standard +// egress_hosts: +// standard: +// - name: api.openai.com +// ports: [443] +type AgentManifest struct { + // Name is the agent name (the key under `agents:` in the global config). + Name string `yaml:"name"` + + // AgentOverride is inlined so its fields appear at the top level of the + // manifest, matching the nesting an operator would write under + // `agents.:` in the config file. + AgentOverride `yaml:",inline"` +} diff --git a/pkg/domain/config/manifest_test.go b/pkg/domain/config/manifest_test.go new file mode 100644 index 0000000..e48e218 --- /dev/null +++ b/pkg/domain/config/manifest_test.go @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package config_test + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" + + domainconfig "github.com/stacklok/brood-box/pkg/domain/config" +) + +func TestAgentManifestInlineFieldsTrackAgentOverride(t *testing.T) { + t.Parallel() + + // A manifest with every AgentOverride field set round-trips through YAML + // and the fields land at the top level (inline), not nested under a + // sub-key. This is the structural guarantee the import/export commands + // rely on: the manifest IS an agents: block plus a top-level name. + enabled := true + in := domainconfig.AgentManifest{ + Name: "aider", + AgentOverride: domainconfig.AgentOverride{ + Image: "ghcr.io/acme/aider:latest", + Description: "ACME agent", + Command: []string{"aider", "--yes"}, + EnvForward: []string{"OPENAI_API_KEY"}, + EnvRequired: []string{"OPENAI_API_KEY"}, + EgressProfile: "standard", + EgressHosts: map[string][]domainconfig.EgressHostConfig{ + "standard": {{Name: "api.openai.com", Ports: []uint16{443}}}, + }, + MCP: &domainconfig.MCPAgentOverride{ + Enabled: &enabled, + Mode: domainconfig.MCPModeEnv, + Authz: &domainconfig.MCPAuthzConfig{Profile: "safe-tools"}, + }, + }, + } + + data, err := yaml.Marshal(in) + require.NoError(t, err) + str := string(data) + assert.Contains(t, str, "name: aider") + assert.Contains(t, str, "image: ghcr.io/acme/aider:latest") + assert.Contains(t, str, "command:") + assert.Contains(t, str, "egress_profile: standard") + assert.Contains(t, str, "mode: env") + // No nested "agentoverride:" key should appear (inline embedding). + assert.NotContains(t, str, "agentoverride:") + + var out domainconfig.AgentManifest + require.NoError(t, yaml.Unmarshal(data, &out)) + assert.Equal(t, in.Name, out.Name) + assert.Equal(t, in.Image, out.Image) + assert.Equal(t, in.Command, out.Command) + assert.Equal(t, in.EgressProfile, out.EgressProfile) + require.NotNil(t, out.MCP) + assert.Equal(t, in.MCP.Mode, out.MCP.Mode) +} + +func TestAgentManifestStrictUnknownFieldRejected(t *testing.T) { + t.Parallel() + + // A typo'd field must fail loudly under strict decoding (the import path + // uses configfile.DecodeStrict, which sets KnownFields(true)). + bad := []byte("name: x\nbogus_field: true\n") + var m domainconfig.AgentManifest + dec := yaml.NewDecoder(bytes.NewReader(bad)) + dec.KnownFields(true) + err := dec.Decode(&m) + require.Error(t, err) + assert.Contains(t, err.Error(), "bogus_field") +}