From b7719d953efa305d411ec5ff9bdc57e73679095a Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso <4096860+jkyberneees@users.noreply.github.com> Date: Mon, 31 Aug 2026 09:01:05 +0200 Subject: [PATCH 1/3] feat(subagents): list_subagent_profiles tool + ProfileConfig.description MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The delegate_tasks schema accepts a profile name, but names only exist in the operator's config — the model had no way to discover them. This adds: - list_subagent_profiles built-in tool: renders the resolved profiles map (name, description, max_risk, tool filters, is_default) plus the effective default_profile, sorted; read-only over operator config. - ProfileConfig.description: model-readable intent summary per profile. - SubagentConfig/Resolved .default_profile field (behavior lands with the built-in default profile in the follow-up commit). - delegate_tasks profile param now points at the discovery tool. --- cmd/odek/main.go | 4 + cmd/odek/profiles_tool.go | 108 ++++++++++++++++++ cmd/odek/profiles_tool_test.go | 194 +++++++++++++++++++++++++++++++++ cmd/odek/subagent_tool.go | 2 +- internal/config/loader.go | 37 ++++++- 5 files changed, 341 insertions(+), 4 deletions(-) create mode 100644 cmd/odek/profiles_tool.go create mode 100644 cmd/odek/profiles_tool_test.go diff --git a/cmd/odek/main.go b/cmd/odek/main.go index 9adcd2e..29b39e6 100644 --- a/cmd/odek/main.go +++ b/cmd/odek/main.go @@ -2267,6 +2267,10 @@ func builtinTools(dc danger.DangerousConfig, sm *skills.SkillManager, approver d profiles: tcfg.Profiles, artifactsRoot: artifactsRoot, // empty ⇒ no artifact dirs created }, + &listSubagentProfilesTool{ + profiles: tcfg.Profiles, + defaultProfile: tcfg.Subagent.DefaultProfile, + }, &readFileTool{dangerousConfig: dc}, &writeFileTool{dangerousConfig: dc, restrictToCWD: true}, &searchFilesTool{dangerousConfig: dc}, diff --git a/cmd/odek/profiles_tool.go b/cmd/odek/profiles_tool.go new file mode 100644 index 0000000..df91cd3 --- /dev/null +++ b/cmd/odek/profiles_tool.go @@ -0,0 +1,108 @@ +package main + +// list_subagent_profiles — built-in discovery tool for operator-defined +// sub-agent capability profiles (P4 follow-up). +// +// The delegate_tasks schema accepts a profile NAME, but profile names only +// exist in the operator's config — the model has no way to know what is +// available, let alone which envelope fits the task. This tool closes that +// gap: it renders the resolved profiles map (including the built-in default +// profile injected by the config pipeline) as JSON the model can pick from. +// +// Security posture: strictly read-only over operator config. Profiles are +// operator-authored only (project-level profiles are stripped at load), so +// the listing can never be poisoned by a cloned repository. The output +// carries permission metadata only — no secrets, no paths, no tool args. + +import ( + "encoding/json" + "fmt" + "sort" + + "github.com/BackendStack21/odek/internal/config" +) + +// listSubagentProfilesTool renders the resolved capability profiles plus +// the effective default (subagent.default_profile) for model consumption. +type listSubagentProfilesTool struct { + // profiles is the operator's resolved capability profiles map, as + // handed to delegateTasksTool. After the config pipeline runs, it + // always contains the built-in "default" profile unless the operator + // disabled it (subagent.default_profile="none") or overrode the name. + profiles map[string]config.ProfileConfig + + // defaultProfile is the resolved Subagent.DefaultProfile value: a + // profile name, or config.DefaultProfileDisabled when the operator + // opted out. It is echoed so the model knows which envelope applies + // when delegate_tasks omits the profile field. + defaultProfile string +} + +func (t *listSubagentProfilesTool) Name() string { return "list_subagent_profiles" } + +func (t *listSubagentProfilesTool) Description() string { + return "List the sub-agent capability profiles available for delegate_tasks: the " + + "operator-defined profiles (top-level profiles config) plus the built-in default. " + + "Each entry carries name, description, max_risk ceiling, tool filters and an " + + "is_default marker; default_profile reports which envelope applies when " + + "delegate_tasks omits the profile field. Invoke this BEFORE delegate_tasks when " + + "a task should run under a specific capability profile, then pass the chosen " + + "name in the profile field. Unknown names fail the task." +} + +func (t *listSubagentProfilesTool) Schema() any { + // No parameters — the listing is static per-run config state. + return map[string]any{ + "type": "object", + "properties": map[string]any{}, + } +} + +// profileEntry is the JSON shape of one capability profile. +type profileEntry struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + MaxRisk string `json:"max_risk,omitempty"` + ToolsEnabled []string `json:"tools_enabled,omitempty"` + ToolsDisabled []string `json:"tools_disabled,omitempty"` + IsDefault bool `json:"is_default,omitempty"` +} + +func (t *listSubagentProfilesTool) Call(_ string) (string, error) { + defaultActive := t.defaultProfile != "" && t.defaultProfile != config.DefaultProfileDisabled + + out := struct { + Profiles []profileEntry `json:"profiles"` + DefaultProfile string `json:"default_profile"` + }{ + Profiles: make([]profileEntry, 0, len(t.profiles)), + DefaultProfile: t.defaultProfile, + } + + names := make([]string, 0, len(t.profiles)) + for name := range t.profiles { + names = append(names, name) + } + sort.Strings(names) + + for _, name := range names { + prof := t.profiles[name] + entry := profileEntry{ + Name: name, + Description: prof.Description, + MaxRisk: prof.MaxRisk, + IsDefault: defaultActive && name == t.defaultProfile, + } + if prof.Tools != nil { + entry.ToolsEnabled = prof.Tools.Enabled + entry.ToolsDisabled = prof.Tools.Disabled + } + out.Profiles = append(out.Profiles, entry) + } + + buf, err := json.Marshal(out) + if err != nil { + return "", fmt.Errorf("list_subagent_profiles: %w", err) + } + return string(buf), nil +} diff --git a/cmd/odek/profiles_tool_test.go b/cmd/odek/profiles_tool_test.go new file mode 100644 index 0000000..d5ac2fa --- /dev/null +++ b/cmd/odek/profiles_tool_test.go @@ -0,0 +1,194 @@ +package main + +import ( + "encoding/json" + "reflect" + "strings" + "testing" + + "github.com/BackendStack21/odek/internal/config" +) + +// Tests for the list_subagent_profiles built-in tool (P4 follow-up): the +// parent LLM must be able to discover the operator-defined capability +// profiles (plus the built-in default) on demand, so it can pick the right +// profile name for delegate_tasks. The tool is read-only and argument-free. + +func newProfilesToolForTest(profiles map[string]config.ProfileConfig, defaultProfile string) *listSubagentProfilesTool { + return &listSubagentProfilesTool{ + profiles: profiles, + defaultProfile: defaultProfile, + } +} + +func decodeProfilesToolOutput(t *testing.T, out string) map[string]any { + t.Helper() + var decoded map[string]any + if err := json.Unmarshal([]byte(out), &decoded); err != nil { + t.Fatalf("Call output is not valid JSON: %v\nraw: %s", err, out) + } + return decoded +} + +// TestListSubagentProfiles_NameAndSchema pins the tool's registration +// surface: the name the model invokes and an empty parameter schema (the +// tool takes no arguments). +func TestListSubagentProfiles_NameAndSchema(t *testing.T) { + tool := newProfilesToolForTest(nil, config.DefaultProfileName) + if tool.Name() != "list_subagent_profiles" { + t.Errorf("Name() = %q, want %q", tool.Name(), "list_subagent_profiles") + } + if strings.TrimSpace(tool.Description()) == "" { + t.Error("Description() must be non-empty — it is the model's only usage guide") + } + schema, ok := tool.Schema().(map[string]any) + if !ok { + t.Fatalf("Schema() = %T, want map[string]any", tool.Schema()) + } + if schema["type"] != "object" { + t.Errorf("schema type = %v, want object", schema["type"]) + } +} + +// TestListSubagentProfiles_ListsOperatorProfiles covers the core render: +// operator-defined profiles appear with name, description, max_risk and +// tool filters, sorted by name for deterministic output. +func TestListSubagentProfiles_ListsOperatorProfiles(t *testing.T) { + profiles := map[string]config.ProfileConfig{ + "judge": { + Description: "Read-only reviewer", + MaxRisk: "safe", + Tools: &config.ToolConfig{Disabled: []string{"shell", "write_file"}}, + }, + "builder": { + MaxRisk: "local_write", + Tools: &config.ToolConfig{Enabled: []string{"shell", "read_file"}}, + }, + } + tool := newProfilesToolForTest(profiles, "default") + out, err := tool.Call("{}") + if err != nil { + t.Fatalf("Call: %v", err) + } + decoded := decodeProfilesToolOutput(t, out) + + if got := decoded["default_profile"]; got != "default" { + t.Errorf("default_profile = %v, want %q", got, "default") + } + raw, ok := decoded["profiles"].([]any) + if !ok { + t.Fatalf("profiles = %T, want array", decoded["profiles"]) + } + if len(raw) != 2 { + t.Fatalf("profiles len = %d, want 2", len(raw)) + } + // Sorted by name: builder < judge. + first, _ := raw[0].(map[string]any) + second, _ := raw[1].(map[string]any) + if first["name"] != "builder" || second["name"] != "judge" { + t.Errorf("profile order = [%v, %v], want [builder judge]", first["name"], second["name"]) + } + if first["max_risk"] != "local_write" { + t.Errorf("builder max_risk = %v, want local_write", first["max_risk"]) + } + if second["description"] != "Read-only reviewer" { + t.Errorf("judge description = %v, want %q", second["description"], "Read-only reviewer") + } + if second["max_risk"] != "safe" { + t.Errorf("judge max_risk = %v, want safe", second["max_risk"]) + } + disabled, ok := second["tools_disabled"].([]any) + if !ok || !reflect.DeepEqual(disabled, []any{"shell", "write_file"}) { + t.Errorf("judge tools_disabled = %v, want [shell write_file]", second["tools_disabled"]) + } + enabled, ok := first["tools_enabled"].([]any) + if !ok || !reflect.DeepEqual(enabled, []any{"shell", "read_file"}) { + t.Errorf("builder tools_enabled = %v, want [shell read_file]", first["tools_enabled"]) + } +} + +// TestListSubagentProfiles_MarksEffectiveDefault covers the is_default +// marker: exactly the entry matching the resolved subagent.default_profile +// is flagged, so the model knows which envelope applies when delegate_tasks +// omits the profile field. +func TestListSubagentProfiles_MarksEffectiveDefault(t *testing.T) { + profiles := map[string]config.ProfileConfig{ + "default": {MaxRisk: "local_write"}, + "judge": {MaxRisk: "safe"}, + } + tool := newProfilesToolForTest(profiles, "default") + out, err := tool.Call("{}") + if err != nil { + t.Fatalf("Call: %v", err) + } + decoded := decodeProfilesToolOutput(t, out) + raw := decoded["profiles"].([]any) + defaults := 0 + for _, e := range raw { + entry := e.(map[string]any) + isDefault, _ := entry["is_default"].(bool) + switch entry["name"] { + case "default": + if !isDefault { + t.Error("built-in default entry must carry is_default=true") + } + defaults++ + case "judge": + if isDefault { + t.Error("non-default entry must not carry is_default=true") + } + } + } + if defaults != 1 { + t.Errorf("is_default marked on %d entries, want exactly 1", defaults) + } +} + +// TestListSubagentProfiles_DisabledDefault covers subagent.default_profile +// = "none": the echo reports "none" and no entry is flagged as default. +func TestListSubagentProfiles_DisabledDefault(t *testing.T) { + profiles := map[string]config.ProfileConfig{ + "judge": {MaxRisk: "safe"}, + } + tool := newProfilesToolForTest(profiles, config.DefaultProfileDisabled) + out, err := tool.Call("{}") + if err != nil { + t.Fatalf("Call: %v", err) + } + decoded := decodeProfilesToolOutput(t, out) + if got := decoded["default_profile"]; got != "none" { + t.Errorf("default_profile = %v, want %q", got, "none") + } + for _, e := range decoded["profiles"].([]any) { + entry := e.(map[string]any) + if isDefault, _ := entry["is_default"].(bool); isDefault { + t.Errorf("entry %v must not be flagged default when the default is disabled", entry["name"]) + } + } +} + +// TestListSubagentProfiles_EmptyProfilesRendersArray pins that an operator +// with no profiles (and a disabled or absent built-in) still gets a JSON +// array, not null — models handle [] more reliably. +func TestListSubagentProfiles_EmptyProfilesRendersArray(t *testing.T) { + tool := newProfilesToolForTest(nil, config.DefaultProfileDisabled) + out, err := tool.Call("{}") + if err != nil { + t.Fatalf("Call: %v", err) + } + if !strings.Contains(out, `"profiles": []`) && !strings.Contains(out, "\"profiles\":[]") { + t.Errorf("empty profiles must render as [], got: %s", out) + } +} + +// TestListSubagentProfiles_IgnoresArgs pins argument tolerance: the tool +// takes no parameters, so any args payload (valid or not) must be accepted +// rather than rejected. +func TestListSubagentProfiles_IgnoresArgs(t *testing.T) { + tool := newProfilesToolForTest(nil, config.DefaultProfileName) + for _, args := range []string{"{}", "", "not json at all", `{"unexpected":1}`} { + if _, err := tool.Call(args); err != nil { + t.Errorf("Call(%q) = %v, want nil (args are ignored)", args, err) + } + } +} diff --git a/cmd/odek/subagent_tool.go b/cmd/odek/subagent_tool.go index af899fe..7ec47c8 100644 --- a/cmd/odek/subagent_tool.go +++ b/cmd/odek/subagent_tool.go @@ -193,7 +193,7 @@ func (t *delegateTasksTool) Schema() any { }, "profile": map[string]any{ "type": "string", - "description": "Optional. Name of an operator-defined capability profile (top-level profiles config). The profile's max_risk, allowlist, and tool filter OVERRIDE the operator's global config for this sub-agent. Unknown names fail the task - use only names that the operator has defined.", + "description": "Optional. Name of an operator-defined capability profile (top-level profiles config). The profile's max_risk, allowlist, and tool filter OVERRIDE the operator's global config for this sub-agent. Invoke list_subagent_profiles first to discover available profiles and pick the right one. Unknown names fail the task; when this field is omitted, the operator's default profile (subagent.default_profile) applies.", }, }, "required": []string{"goal"}, diff --git a/internal/config/loader.go b/internal/config/loader.go index 2bd85e0..503fd14 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -255,6 +255,11 @@ type SubagentConfig struct { MaxDepth *int `json:"max_depth,omitempty"` AnnounceBudget *bool `json:"announce_budget,omitempty"` BudgetInherit string `json:"budget_inherit,omitempty"` + // DefaultProfile selects the capability profile applied when a delegated + // task omits the profile field: a defined profile name, or "none" to + // disable the built-in default envelope. Operator-controlled (rejected + // from project-level ./odek.json like the rest of this section). + DefaultProfile string `json:"default_profile,omitempty"` } // PlanningFileConfig is the "planning" section of odek.json. Pointer fields @@ -976,6 +981,18 @@ const ( // remaining budget) so a near-exhausted parent cannot spawn children // with fresh headroom. BudgetInheritShare = "share" + + // DefaultProfileName is the built-in default sub-agent capability + // profile (P4). Materialized into ResolvedConfig.Profiles by + // injectBuiltinDefaultProfile unless the operator defines their own + // profile with this name or opts out via subagent.default_profile="none". + DefaultProfileName = "default" + + // DefaultProfileDisabled is the subagent.default_profile sentinel that + // disables the built-in default envelope entirely. Honored only from the + // operator's config — never from a task file or the --profile flag, so + // a delegating model cannot strip the operator's envelope. + DefaultProfileDisabled = "none" ) // SubagentResolved is the resolved "subagent" configuration. @@ -998,6 +1015,10 @@ type SubagentResolved struct { AnnounceBudget bool // BudgetInherit is BudgetInheritOperator or BudgetInheritShare. BudgetInherit string + // DefaultProfile is the profile name applied when a delegated task + // selects none: DefaultProfileName (built-in), an operator-defined + // name, or DefaultProfileDisabled ("none" = no envelope). + DefaultProfile string } // resolveSubagent merges the file-level subagent section over the defaults. @@ -1012,6 +1033,7 @@ func resolveSubagent(cfg *SubagentConfig) SubagentResolved { MaxDepth: 2, AnnounceBudget: true, BudgetInherit: BudgetInheritOperator, + DefaultProfile: DefaultProfileName, } if cfg == nil { return res @@ -1066,6 +1088,9 @@ func resolveSubagent(cfg *SubagentConfig) SubagentResolved { fmt.Fprintf(os.Stderr, "odek: WARNING: unknown subagent.budget_inherit %q; using %q\n", cfg.BudgetInherit, BudgetInheritOperator) } } + if cfg.DefaultProfile != "" { + res.DefaultProfile = cfg.DefaultProfile + } return res } @@ -1078,9 +1103,15 @@ func resolveSubagent(cfg *SubagentConfig) SubagentResolved { // non-interactive deny and the P3 trust lockdown are applied afterwards // and cannot be lifted by selecting a profile. type ProfileConfig struct { - MaxRisk string `json:"max_risk,omitempty"` - Allowlist []string `json:"allowlist,omitempty"` - Tools *ToolConfig `json:"tools,omitempty"` + // Description is a short human/model-readable summary of what the + // profile is FOR. It is surfaced by the list_subagent_profiles tool so + // the delegating model can pick the right profile by intent rather + // than by guessing at names. Operator-authored only; never parsed for + // permission semantics. + Description string `json:"description,omitempty"` + MaxRisk string `json:"max_risk,omitempty"` + Allowlist []string `json:"allowlist,omitempty"` + Tools *ToolConfig `json:"tools,omitempty"` } // validRiskClass reports whether s names a known risk class. From f9dd2a06483e90bc2298b4c0bb4d9466c9ba3053 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso <4096860+jkyberneees@users.noreply.github.com> Date: Mon, 31 Aug 2026 09:07:07 +0200 Subject: [PATCH 2/3] =?UTF-8?q?feat(subagents):=20built-in=20default=20pro?= =?UTF-8?q?file=20=E2=80=94=20sub-agents=20capped=20at=20local=5Fwrite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delegated sub-agents now run under a default capability envelope unless something more specific is selected: - Built-in "default" profile (max_risk: local_write) is materialized by the config pipeline unless the operator defines their own profile with that name or opts out via subagent.default_profile="none". - New subagent.default_profile config key (operator-controlled, rejected from project-level odek.json like the rest of the section) points the default at any defined profile; an unknown name fails closed at spawn. - Precedence: --profile flag > task-file profile > default envelope. "none" is honored only from operator config — a task file or flag can never strip the operator's envelope. - Behavior change, deliberate: trusted sub-agents are now clamped to local_write too (the envelope is applied before the P2/P3 trust lockdown, which still cannot be lifted by profile selection). Tasks needing code_execution/network_egress must select an explicit profile. - The built-in envelope is visible through list_subagent_profiles with an is_default marker and a model-readable description. --- cmd/odek/subagent.go | 10 +- cmd/odek/subagent_profiles_test.go | 139 ++++++++++++++++++++++++ internal/config/loader.go | 30 +++++ internal/config/subagent_config_test.go | 80 ++++++++++++++ 4 files changed, 258 insertions(+), 1 deletion(-) diff --git a/cmd/odek/subagent.go b/cmd/odek/subagent.go index fa5aeb0..2241b18 100644 --- a/cmd/odek/subagent.go +++ b/cmd/odek/subagent.go @@ -583,10 +583,18 @@ func subagentCmd(args []string) error { // P4: a profile may be selected by the operator's direct --profile flag // or by the parent via the task file; the flag outranks the file. profileName := resolveProfileName(cfg.profile, taskProfile) + if profileName == "" && resolved.Subagent.DefaultProfile != config.DefaultProfileDisabled { + // Operator's default envelope (P4): the built-in "default" profile + // unless the operator overrode the name. Explicit task/flag + // selection outranks it, and "none" is honored only from the + // operator's own config — a task file or flag can never strip the + // operator's envelope. + profileName = resolved.Subagent.DefaultProfile + } if profileName != "" { prof, ok := resolved.Profiles[profileName] if !ok { - return fmt.Errorf("unknown profile %q (define it in the top-level profiles config section)", profileName) + return fmt.Errorf("unknown profile %q (define it in the top-level profiles config section, or adjust subagent.default_profile)", profileName) } applyProfile(&resolved.Dangerous, prof) profileTools = prof.Tools diff --git a/cmd/odek/subagent_profiles_test.go b/cmd/odek/subagent_profiles_test.go index f6afdd2..247e92e 100644 --- a/cmd/odek/subagent_profiles_test.go +++ b/cmd/odek/subagent_profiles_test.go @@ -306,6 +306,145 @@ func TestResolveProfileName_CLIFlagWinsOverTaskFile(t *testing.T) { } } +// ── Built-in default profile (subagent.default_profile) ────────────────── + +// TestSubagentCmd_BuiltInDefaultProfileSelectable pins the built-in +// "default" capability profile: with an empty profiles config, a task +// selecting profile "default" must resolve (the config pipeline +// materializes the built-in local_write envelope), not fail as unknown. +func TestSubagentCmd_BuiltInDefaultProfileSelectable(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("ODEK_API_KEY", "") + t.Setenv("DEEPSEEK_API_KEY", "") + t.Setenv("OPENAI_API_KEY", "") + cfgDir := filepath.Join(home, ".odek") + if err := os.MkdirAll(cfgDir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cfgDir, "config.json"), []byte(`{}`), 0o600); err != nil { + t.Fatal(err) + } + taskPath := filepath.Join(home, "task.json") + if err := os.WriteFile(taskPath, []byte(`{"goal":"g","profile":"default"}`), 0o644); err != nil { + t.Fatal(err) + } + err := subagentCmd([]string{"--task", taskPath}) + if err == nil { + t.Fatal("expected LLM-setup failure (no API key) — a unit run must not hit the network") + } + if strings.Contains(err.Error(), "unknown profile") { + t.Fatalf("built-in default profile must be selectable, got: %v", err) + } +} + +// TestSubagentCmd_UnknownOperatorDefaultFailsClosed pins that a broken +// subagent.default_profile (a name with no definition) surfaces as a loud +// profile error when a task selects nothing — a config bug must not +// silently run a bare child without the operator's envelope. +func TestSubagentCmd_UnknownOperatorDefaultFailsClosed(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("ODEK_API_KEY", "") + t.Setenv("DEEPSEEK_API_KEY", "") + t.Setenv("OPENAI_API_KEY", "") + cfgDir := filepath.Join(home, ".odek") + if err := os.MkdirAll(cfgDir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cfgDir, "config.json"), []byte(`{"subagent":{"default_profile":"ghost"}}`), 0o600); err != nil { + t.Fatal(err) + } + taskPath := filepath.Join(home, "task.json") + if err := os.WriteFile(taskPath, []byte(`{"goal":"g"}`), 0o644); err != nil { + t.Fatal(err) + } + err := subagentCmd([]string{"--task", taskPath}) + if err == nil || !strings.Contains(err.Error(), `unknown profile "ghost"`) { + t.Fatalf("broken default_profile must fail closed with the offending name, got: %v", err) + } +} + +// TestSubagentCmd_DefaultProfileNoneOptOut pins subagent.default_profile +// = "none": no envelope is applied when the task selects nothing, so the +// run proceeds past profile resolution (to the expected LLM-setup error). +func TestSubagentCmd_DefaultProfileNoneOptOut(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("ODEK_API_KEY", "") + t.Setenv("DEEPSEEK_API_KEY", "") + t.Setenv("OPENAI_API_KEY", "") + cfgDir := filepath.Join(home, ".odek") + if err := os.MkdirAll(cfgDir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cfgDir, "config.json"), []byte(`{"subagent":{"default_profile":"none"}}`), 0o600); err != nil { + t.Fatal(err) + } + taskPath := filepath.Join(home, "task.json") + if err := os.WriteFile(taskPath, []byte(`{"goal":"g"}`), 0o644); err != nil { + t.Fatal(err) + } + err := subagentCmd([]string{"--task", taskPath}) + if err == nil { + t.Fatal("expected LLM-setup failure (no API key) — a unit run must not hit the network") + } + if strings.Contains(err.Error(), "unknown profile") { + t.Fatalf(`default_profile "none" must disable the envelope, got: %v`, err) + } +} + +// TestSubagentCmd_ExplicitTaskProfileBeatsDefault pins precedence: an +// explicit task-file profile wins even when the operator default names an +// undefined profile — the broken default must never be consulted. +func TestSubagentCmd_ExplicitTaskProfileBeatsDefault(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("ODEK_API_KEY", "") + t.Setenv("DEEPSEEK_API_KEY", "") + t.Setenv("OPENAI_API_KEY", "") + cfgDir := filepath.Join(home, ".odek") + if err := os.MkdirAll(cfgDir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cfgDir, "config.json"), []byte(`{"subagent":{"default_profile":"ghost"},"profiles":{"judge":{"max_risk":"safe"}}}`), 0o600); err != nil { + t.Fatal(err) + } + taskPath := filepath.Join(home, "task.json") + if err := os.WriteFile(taskPath, []byte(`{"goal":"g","profile":"judge"}`), 0o644); err != nil { + t.Fatal(err) + } + err := subagentCmd([]string{"--task", taskPath}) + if err == nil { + t.Fatal("expected LLM-setup failure (no API key) — a unit run must not hit the network") + } + if strings.Contains(err.Error(), "unknown profile") { + t.Fatalf("explicit task profile must outrank the broken default, got: %v", err) + } +} + +// TestDefaultProfileEnvelope_TrustedChildClamped pins the documented +// hardening: a profile's max_risk is applied BEFORE the trust lockdown +// and therefore clamps even trusted sub-agents. A trusted task cannot +// lift the envelope above local_write without an explicit profile +// selection — trust and capability envelopes are independent controls. +func TestDefaultProfileEnvelope_TrustedChildClamped(t *testing.T) { + var dc danger.DangerousConfig + applyProfile(&dc, config.ProfileConfig{MaxRisk: "local_write"}) + applySubagentTrust(&dc, "trusted", "") + for _, cls := range []danger.RiskClass{ + danger.SystemWrite, danger.CodeExecution, danger.Install, + danger.NetworkEgress, danger.Destructive, danger.Persistence, + } { + if dc.Classes[cls] != danger.Deny { + t.Errorf("class %v = %v, want deny (default envelope clamps trusted children)", cls, dc.Classes[cls]) + } + } + if dc.Classes[danger.LocalWrite] == danger.Deny { + t.Error("local_write must remain allowed under the local_write cap") + } +} + // TestDelegateTasks_UnknownProfileFailsWithoutSpawn pins parent-side // fail-closed: an unknown profile must fail the task BEFORE a child is // spawned. The marker file proves whether the mock child ever ran. diff --git a/internal/config/loader.go b/internal/config/loader.go index 503fd14..5ecaf07 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -1094,6 +1094,30 @@ func resolveSubagent(cfg *SubagentConfig) SubagentResolved { return res } +// injectBuiltinDefaultProfile materializes the built-in default sub-agent +// capability profile (P4) unless the operator disabled it via +// subagent.default_profile="none" or defined their own profile with the +// reserved name. The built-in caps delegated sub-agents at local_write: +// the long-standing effective ceiling for untrusted children, now also +// enforced for trusted ones. An explicit profile selection can still raise +// it — the envelope is operator policy, applied before the trust lockdown +// (which can never be lifted by profile selection). +func injectBuiltinDefaultProfile(r *ResolvedConfig) { + if r.Subagent.DefaultProfile == DefaultProfileDisabled { + return + } + if _, ok := r.Profiles[DefaultProfileName]; ok { + return + } + if r.Profiles == nil { + r.Profiles = make(map[string]ProfileConfig, 1) + } + r.Profiles[DefaultProfileName] = ProfileConfig{ + Description: "Built-in default: delegated sub-agents are capped at local_write (no system writes, code execution, installs, network egress, or destructive operations). Override per task via delegate_tasks' profile field, or globally via subagent.default_profile (\"none\" disables).", + MaxRisk: "local_write", + } +} + // ProfileConfig is one named capability profile (P4). When a task // selects the profile, its settings OVERRIDE the corresponding operator // config for that sub-agent: max_risk clamps every higher-ranked class to @@ -2067,6 +2091,12 @@ func LoadConfig(cli CLIFlags) ResolvedConfig { ToolProgress: ifZero(cfg.ToolProgress, "all"), } + // Built-in default sub-agent capability profile (P4): unless the + // operator disabled it or defined their own "default" profile, + // materialize the local_write-capped envelope so delegate_tasks and + // list_subagent_profiles see one consistent set. + injectBuiltinDefaultProfile(&resolved) + // Every subsystem inherits the shared top-level embedding default unless it // set its own override. Memory and skills carry their resolved embedder on // their own config struct; sessions expose it via SessionEmbedding. diff --git a/internal/config/subagent_config_test.go b/internal/config/subagent_config_test.go index c1811d6..bd45ddc 100644 --- a/internal/config/subagent_config_test.go +++ b/internal/config/subagent_config_test.go @@ -162,3 +162,83 @@ func TestLoadConfig_ProjectSubagentIgnored(t *testing.T) { t.Errorf("MaxIterations = %d, want 15 (project's 100 ignored; global unset → default)", cfg.Subagent.MaxIterations) } } + +func TestResolveSubagent_DefaultProfileResolution(t *testing.T) { + if got := resolveSubagent(nil).DefaultProfile; got != DefaultProfileName { + t.Errorf("DefaultProfile = %q, want built-in %q", got, DefaultProfileName) + } + if got := resolveSubagent(&SubagentConfig{DefaultProfile: "research"}).DefaultProfile; got != "research" { + t.Errorf("DefaultProfile = %q, want operator override", got) + } + if got := resolveSubagent(&SubagentConfig{DefaultProfile: DefaultProfileDisabled}).DefaultProfile; got != "none" { + t.Errorf("DefaultProfile = %q, want %q (opt-out preserved)", got, DefaultProfileDisabled) + } +} + +func TestInjectBuiltinDefaultProfile(t *testing.T) { + t.Run("injects when absent", func(t *testing.T) { + r := &ResolvedConfig{Subagent: SubagentResolved{DefaultProfile: DefaultProfileName}} + injectBuiltinDefaultProfile(r) + if r.Profiles[DefaultProfileName].MaxRisk != "local_write" { + t.Errorf("built-in default = %+v, want max_risk local_write", r.Profiles[DefaultProfileName]) + } + if r.Profiles[DefaultProfileName].Description == "" { + t.Error("built-in default must carry a model-readable description") + } + }) + t.Run("operator override wins", func(t *testing.T) { + r := &ResolvedConfig{ + Subagent: SubagentResolved{DefaultProfile: DefaultProfileName}, + Profiles: map[string]ProfileConfig{DefaultProfileName: {MaxRisk: "safe", Description: "mine"}}, + } + injectBuiltinDefaultProfile(r) + if r.Profiles[DefaultProfileName].MaxRisk != "safe" { + t.Errorf("operator-defined default must not be overwritten: %+v", r.Profiles[DefaultProfileName]) + } + }) + t.Run("disabled injects nothing", func(t *testing.T) { + r := &ResolvedConfig{Subagent: SubagentResolved{DefaultProfile: DefaultProfileDisabled}} + injectBuiltinDefaultProfile(r) + if _, ok := r.Profiles[DefaultProfileName]; ok { + t.Error("disabled default must not be materialized") + } + }) + t.Run("nil profiles map tolerated", func(t *testing.T) { + r := &ResolvedConfig{Subagent: SubagentResolved{DefaultProfile: DefaultProfileName}} + injectBuiltinDefaultProfile(r) + if len(r.Profiles) != 1 { + t.Errorf("Profiles = %d entries, want exactly the built-in", len(r.Profiles)) + } + }) +} + +func TestLoadConfig_ProjectDefaultProfileIgnored(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + t.Chdir(dir) + + globalDir := filepath.Join(dir, ".odek") + os.MkdirAll(globalDir, 0755) + if err := os.WriteFile(filepath.Join(globalDir, "config.json"), []byte(`{ + "subagent": {"default_profile": "judge"}, + "profiles": {"judge": {"max_risk": "safe"}} + }`), 0644); err != nil { + t.Fatal(err) + } + // A malicious repo tries to point the default envelope at its own + // profile with a raised ceiling. + if err := os.WriteFile(filepath.Join(dir, "odek.json"), []byte(`{ + "subagent": {"default_profile": "hack"}, + "profiles": {"hack": {"max_risk": "code_execution"}} + }`), 0644); err != nil { + t.Fatal(err) + } + + cfg := LoadConfig(CLIFlags{}) + if cfg.Subagent.DefaultProfile != "judge" { + t.Errorf("DefaultProfile = %q, want judge (project value must be ignored)", cfg.Subagent.DefaultProfile) + } + if _, ok := cfg.Profiles["hack"]; ok { + t.Error("project-defined profile must be ignored") + } +} From 82d784edc949d6cdf14b1f813f6d322f724cb914 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso <4096860+jkyberneees@users.noreply.github.com> Date: Mon, 31 Aug 2026 09:12:49 +0200 Subject: [PATCH 3/3] docs(subagents): default sub-agent profile + profile descriptions - CONFIG.md: subagent.default_profile row, description field, new 'Built-in default profile' section (precedence, operator-only opt-out, trusted-clamp behavior change, list_subagent_profiles discovery). - SECURITY.md: default-envelope semantics (built-in local_write cap also binds trusted sub-agents, 'none' honored only from operator config), updated pinned-by test list and fail-closed/residual-risk notes. - SUBAGENTS.md: default-envelope paragraph in the profiles section. - profiles.template.json: model-readable description on all 21 starter profiles (consumed by list_subagent_profiles). - Global config template: scaffold subagent.default_profile: "default" so the operator-visible default is stated in the config itself. --- cmd/odek/main.go | 3 ++- docs/CONFIG.md | 15 ++++++++++++++- docs/SECURITY.md | 12 +++++++----- docs/SUBAGENTS.md | 9 +++++++++ profiles.template.json | 21 +++++++++++++++++++++ 5 files changed, 53 insertions(+), 7 deletions(-) diff --git a/cmd/odek/main.go b/cmd/odek/main.go index 29b39e6..de0ae33 100644 --- a/cmd/odek/main.go +++ b/cmd/odek/main.go @@ -1339,7 +1339,8 @@ const globalConfigTemplate = `{ "subagent": { "max_concurrency": 3, "timeout_seconds": 1800, - "max_iterations": 15 + "max_iterations": 15, + "default_profile": "default" }, "limits": { "max_runtime_seconds": 0, diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 7b64956..c77265e 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -729,6 +729,7 @@ The `subagent` section controls task decomposition and parallel sub-agent execut | `max_depth` | 2 | Delegation nesting cap via `ODEK_SUBAGENT_DEPTH`; clamped to 8 | | `announce_budget` | true | Sub-agents are told their budget at spawn and warned at 50/75/90% usage | | `budget_inherit` | `"operator"` | `"share"` = a sub-agent gets min(operator limits, parent's remaining budget) | +| `default_profile` | `"default"` (built-in) | Capability profile applied when a delegated task selects none: a defined profile name, or `"none"` to disable the built-in envelope (see [Capability profiles](#capability-profiles)) | This section is optional. Omitted fields inherit the defaults above. @@ -742,10 +743,12 @@ The top-level `profiles` section defines named permission envelopes. When a task { "profiles": { "research": { + "description": "Read-only web research — fetches pages, never edits or runs anything", "max_risk": "safe", "tools": { "disabled": ["write_file", "patch", "batch_patch", "shell"] } }, "builder": { + "description": "Write and verify code changes with project build/test commands allowlisted", "max_risk": "local_write", "allowlist": ["go test ./...", "go build ./..."] } @@ -755,10 +758,20 @@ The top-level `profiles` section defines named permission envelopes. When a task | Field | Description | |-------|-------------| +| `description` | Short summary of what the profile is FOR — surfaced by the `list_subagent_profiles` tool so the delegating model can pick by intent, not by guessing at names | | `max_risk` | Clamps every higher-ranked class to `deny` for profiled sub-agents | | `allowlist` | **Replaces** the global allowlist for profiled sub-agents | | `tools` | **Replaces** the global `tools` enabled/disabled filter for profiled sub-agents | +### Built-in default profile and `subagent.default_profile` + +A built-in profile named **`default`** (`max_risk: "local_write"`) is always materialized unless you define your own profile with that name (yours wins) or set `subagent.default_profile: "none"`. **This is the envelope sub-agents run under when no profile is selected** — `delegate_tasks` tasks without a `profile` field and `odek subagent` runs without `--profile` are capped at `local_write`: no system writes, code execution, installs, network egress, or destructive operations. + +- **Precedence:** `--profile` flag > task-file `profile` > `subagent.default_profile` (built-in `default` unless overridden). +- **Operator sovereignty:** `"none"` is honored only from your config — a task file or flag can never strip the operator's envelope; a task cannot opt out of it, only select a different defined profile. +- **Behavior change vs. earlier releases:** trusted sub-agents were previously uncapped; they are now clamped to `local_write` too. Tasks needing `code_execution`/`network_egress` must select an explicit profile (e.g. `test-runner`, `researcher`). +- **Discovery:** the agent invokes the built-in `list_subagent_profiles` tool to see every available profile — name, `description`, `max_risk`, tool filters, and which one is the effective default — before picking one for `delegate_tasks`. + ### Starter set: [`profiles.template.json`](../profiles.template.json) The repository ships a curated starter set of **21 profiles** covering the most common agent tasks — copy the entries you need into your top-level `profiles` section (operator config only) and trim from there: @@ -779,7 +792,7 @@ Rules: - **Operator-authored only.** A `profiles` section in project-level `./odek.json` is ignored with a warning — a cloned repo must not author its own permission envelope. - **Override, not escalation.** The non-interactive deny and the trust lockdown are applied *after* the profile and cannot be lifted by selecting one. An untrusted task stays untrusted under any profile. -- **Fail closed.** Selecting an unknown profile name fails the task (validated by `delegate_tasks` before spawn and again by the sub-agent itself); profiles with an invalid `max_risk` are dropped at load time with a warning. +- **Fail closed.** Selecting an unknown profile name fails the task (validated by `delegate_tasks` before spawn and again by the sub-agent itself); profiles with an invalid `max_risk` are dropped at load time with a warning. A broken `subagent.default_profile` (undefined name) fails the sub-agent at spawn — loudly, not silently bare. ## MCP server configuration diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 3aebeb9..782da39 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -233,7 +233,7 @@ Plain `odek skill promote my-skill` refuses to clear `NeedsReview` when `Untrust - `trust_level: "untrusted"` — the goal / guidance / context strings may contain attacker-controllable text. A missing `trust_level` is treated as `untrusted`. - `max_risk: ""` — the highest risk class the sub-agent may execute. -- `profile: ""` — select an operator-defined capability profile; its settings override the corresponding operator permissions for this sub-agent. See [Capability profiles](#capability-profiles). +- `profile: ""` — select an operator-defined capability profile; its settings override the corresponding operator permissions for this sub-agent. When omitted, the **built-in default envelope** applies (see [Capability profiles](#capability-profiles)). The sub-agent process reads both at startup. `applySubagentTrust` clamps its `DangerousConfig`, which is then passed into the agent engine so the batch gate and individual tool checks enforce the cap: @@ -269,7 +269,9 @@ Capability profiles solve a gap the binary trust model leaves open: `untrusted` } ``` -A task selects a profile via `delegate_tasks`' `profile` field or `odek subagent --profile research`. Unknown names fail the task; selection is the parent model's choice per task. +A task selects a profile via `delegate_tasks`' `profile` field or `odek subagent --profile research`. Unknown names fail the task; selection is the parent model's choice per task — informed by the built-in `list_subagent_profiles` tool, which renders every available profile (name, description, `max_risk`, tool filters, the effective default) straight from the resolved operator config. + +**Default envelope.** A built-in profile named `default` (`max_risk: "local_write"`) is materialized at config resolution unless the operator defines their own profile with that name (theirs wins) or opts out via `subagent.default_profile: "none"`. It is the envelope that applies when a task selects nothing: precedence is `--profile` flag > task-file `profile` > `subagent.default_profile`. Two hardening properties: the built-in cap also binds **trusted** sub-agents (previously uncapped — tasks needing `code_execution`/`network_egress` must select an explicit profile), and `"none"` is honored only from the operator's config — a task file or flag can never strip the operator's envelope; a task can only select a different defined profile. A broken `subagent.default_profile` (undefined name) fails the sub-agent at spawn instead of silently running bare. **Override semantics — the profile replaces, it does not merge.** Per operator direction, a selected profile overrides the corresponding permissions from config or env: @@ -286,11 +288,11 @@ The override order inside a sub-agent is: operator config → **profile** (if se - **Sub-agents never prompt.** `non_interactive: deny` is forced for every sub-agent after profile application. A profile cannot re-enable TTY approval prompts; the operator `allowlist` (in the profile, if selected) remains the only path to prompt-class operations. - **Trust is non-increasing downward.** The child runs at `min(parent_trust, trust_level)`; the untrusted lockdown (deny `destructive`, `code_execution`, `install`, `system_write`, `persistence`, `unread_exec`, `network_egress`, `unknown`, `blocked`) is applied after the profile. An untrusted task stays untrusted under any profile — selecting `"profile": "builder"` with `max_risk: "system_write"` still denies network egress and installs to an untrusted sub-agent, because the provenance lockdown wins over the permission envelope. -Pinned by `cmd/odek/subagent_profiles_test.go` (override/clamp semantics, allowlist-only no-clamp, trust-lockdown-after-profile ordering) and `internal/config` (validation, project-config strip). +Pinned by `cmd/odek/subagent_profiles_test.go` (override/clamp semantics, allowlist-only no-clamp, trust-lockdown-after-profile ordering, built-in-default selectable, broken-default fail-closed, "none" opt-out, explicit-task-profile precedence, trusted-child clamp) and `internal/config` (validation, project-config strip, built-in injection and override, project `default_profile` rejection). -**Fail-closed behaviors.** An unknown profile name fails the task (`unknown profile "x" …`) instead of silently running unprofiled. A profile with an invalid `max_risk` value is **dropped at load time** with a stderr warning — a typo must not silently yield an unclamped envelope. An empty `max_risk` expresses no cap: an allowlist-only profile leaves class policy untouched. With no profiles defined, selection fails and behavior is exactly as before this feature. +**Fail-closed behaviors.** An unknown profile name fails the task (`unknown profile "x" …`) instead of silently running unprofiled. A profile with an invalid `max_risk` value is **dropped at load time** with a stderr warning — a typo must not silently yield an unclamped envelope. An empty `max_risk` expresses no cap: an allowlist-only profile leaves class policy untouched. The built-in `default` profile always exists (unless disabled or overridden), so unprofiled tasks still run under the operator's default envelope; only `subagent.default_profile: "none"` removes it. -**Residual risk (be aware).** Profile *selection* is parent-declared: a prompt-injected parent can always pick the most permissive profile the operator defined. The operator bounds that ceiling by what they author — define narrow profiles (`research` before `ops`) and treat each profile as a standing grant. Profiles also cannot express per-operation grants beyond exact-invocation `allowlist` entries, and profile selection is not session-tracked: use the `subagent_denied` runtime events and the delegate-task audit trail to see which envelopes ran. +**Residual risk (be aware).** Profile *selection* is parent-declared: a prompt-injected parent can always pick the most permissive profile the operator defined (it cannot strip the default envelope — omission falls through to the operator default, and only the operator config may disable it). The operator bounds that ceiling by what they author — define narrow profiles (`research` before `ops`) and treat each profile as a standing grant. Profiles also cannot express per-operation grants beyond exact-invocation `allowlist` entries, and profile selection is not session-tracked: use the `subagent_denied` runtime events and the delegate-task audit trail to see which envelopes ran. ### Planning diff --git a/docs/SUBAGENTS.md b/docs/SUBAGENTS.md index befce4d..8200688 100644 --- a/docs/SUBAGENTS.md +++ b/docs/SUBAGENTS.md @@ -318,6 +318,15 @@ not escalation. A curated starter set of 21 task profiles (builders, reviewers, judges, researchers, orchestrators, …) ships in [`profiles.template.json`](../profiles.template.json). +When a task selects **no** profile, the operator's default envelope applies: +a built-in `default` profile (`max_risk: "local_write"`) unless overridden via +`subagent.default_profile` (a defined profile name, or `"none"` to disable). +The precedence chain is `--profile` flag > task-file profile > default +envelope, and `"none"` is honored only from operator config — a delegating +parent can narrow its choice but never strip the operator's envelope. The +parent model discovers available profiles (including descriptions and the +effective default) via the built-in `list_subagent_profiles` tool. + A profile may be selected by the operator's direct `odek subagent --profile` flag or by the parent via the task file (`delegate_tasks`'s `profile` field); the flag outranks the task file. Unknown names fail the task twice diff --git a/profiles.template.json b/profiles.template.json index 513fbdb..2c56bef 100644 --- a/profiles.template.json +++ b/profiles.template.json @@ -1,6 +1,7 @@ { "profiles": { "builder": { + "description": "Write and verify code changes with project build/test commands allowlisted", "max_risk": "local_write", "allowlist": [ "go test ./...", "go build ./...", "go vet ./...", "gofmt -l .", @@ -12,6 +13,7 @@ "tools": { "disabled": ["browser", "http_batch", "web_search", "transcribe", "vision", "delegate_tasks"] } }, "refactorer": { + "description": "Restructure existing code behind build/test gates, without adding features", "max_risk": "local_write", "allowlist": [ "go test ./...", "go build ./...", "gofmt -l .", @@ -23,6 +25,7 @@ "tools": { "disabled": ["browser", "http_batch", "web_search", "transcribe", "vision", "delegate_tasks"] } }, "test-runner": { + "description": "Run project test suites and report failures; cannot modify files", "max_risk": "code_execution", "allowlist": [ "go test ./...", "go test -race ./...", "go test -count=1 ./...", @@ -34,6 +37,7 @@ "tools": { "disabled": ["write_file", "patch", "batch_patch", "browser", "http_batch", "web_search", "delegate_tasks"] } }, "bug-investigator": { + "description": "Diagnose failures by reading code and running build/test commands", "max_risk": "local_write", "allowlist": [ "go test ./...", "go build ./...", "go vet ./...", @@ -45,6 +49,7 @@ "tools": { "disabled": ["browser", "http_batch", "web_search", "transcribe", "vision", "delegate_tasks"] } }, "perf-profiler": { + "description": "Run benchmarks and profilers to locate performance bottlenecks", "max_risk": "code_execution", "allowlist": [ "go test -bench ./...", "go test -bench=. ./...", "go tool pprof", @@ -54,6 +59,7 @@ "tools": { "disabled": ["browser", "http_batch", "web_search", "transcribe", "vision", "delegate_tasks"] } }, "migrator": { + "description": "Upgrade dependencies and migrate configs; may install packages", "max_risk": "install", "allowlist": [ "go get", "go mod tidy", "go mod download", @@ -64,65 +70,80 @@ "tools": { "disabled": ["browser", "transcribe", "vision", "delegate_tasks"] } }, "code-reviewer": { + "description": "Strictly read-only code review with reasoning-capable tools", "max_risk": "safe", "tools": { "enabled": ["plan", "read_file", "batch_read", "glob", "file_info", "search_files", "multi_grep", "tree", "count_lines", "head_tail", "diff", "word_count", "checksum", "sort", "base64", "tr", "json_query", "math_eval"] } }, "security-auditor": { + "description": "Read-only security review; no shell, no writes, no network", "max_risk": "safe", "tools": { "enabled": ["plan", "read_file", "batch_read", "glob", "file_info", "search_files", "multi_grep", "tree", "count_lines", "head_tail", "diff", "word_count", "checksum", "base64", "tr", "json_query"] } }, "ops-inspector": { + "description": "Read-only infrastructure and service inspection commands", "max_risk": "safe", "allowlist": ["docker ps", "docker logs", "docker stats", "docker compose ps", "kubectl get", "kubectl logs", "df -h", "du -sh", "ps aux", "git status", "git log", "git diff", "git show"], "tools": { "disabled": ["write_file", "patch", "batch_patch", "browser", "http_batch", "web_search", "delegate_tasks"] } }, "release-manager": { + "description": "Drive git/gh release flows — can push and merge; review its allowlist before trusting it", "max_risk": "network_egress", "allowlist": ["git status", "git log", "git diff", "git show", "git tag", "git push", "gh pr create", "gh pr view", "gh pr checks", "gh pr merge", "gh release create", "gh release view", "gh run list", "gh run view"], "tools": { "disabled": ["write_file", "patch", "batch_patch", "browser", "transcribe", "vision", "delegate_tasks"] } }, "swarm-orchestrator": { + "description": "Decompose and delegate work via delegate_tasks; inspects nothing itself", "max_risk": "safe", "tools": { "enabled": ["delegate_tasks", "plan", "read_file", "batch_read", "glob", "search_files", "multi_grep", "tree"] } }, "judge": { + "description": "Evaluate a proposal or result against given criteria; strictly read-only", "max_risk": "safe", "tools": { "enabled": ["plan", "read_file", "batch_read", "glob", "file_info", "search_files", "multi_grep", "tree", "diff", "count_lines", "json_query", "math_eval"] } }, "scout": { + "description": "Fast codebase reconnaissance with a minimal read-only toolkit", "max_risk": "safe", "tools": { "enabled": ["read_file", "batch_read", "glob", "search_files", "multi_grep", "tree", "head_tail"] } }, "librarian": { + "description": "Locate and summarize relevant code or documentation; read-only", "max_risk": "safe", "tools": { "enabled": ["plan", "read_file", "batch_read", "glob", "file_info", "search_files", "multi_grep", "tree", "count_lines", "head_tail", "word_count"] } }, "researcher": { + "description": "Web research with network egress; no shell", "max_risk": "network_egress", "tools": { "disabled": ["shell", "parallel_shell", "delegate_tasks", "transcribe", "vision"] } }, "web-reader": { + "description": "Fetch and extract specific pages; no shell, strict tool whitelist", "max_risk": "network_egress", "tools": { "enabled": ["browser", "http_batch", "read_file", "batch_read", "glob", "word_count"] } }, "writer": { + "description": "Author and edit documentation and prose; no shell, no network", "max_risk": "local_write", "tools": { "disabled": ["shell", "parallel_shell", "browser", "http_batch", "web_search", "delegate_tasks", "transcribe", "vision"] } }, "translator": { + "description": "Translate content with a minimal read/write toolkit", "max_risk": "local_write", "tools": { "enabled": ["read_file", "write_file", "batch_read", "glob", "word_count", "tr"] } }, "data-analyst": { + "description": "Run predefined analysis entry points over local data", "max_risk": "code_execution", "allowlist": ["make analyze", "npm run analyze", "python main.py", "python3 main.py"], "tools": { "disabled": ["browser", "http_batch", "web_search", "transcribe", "vision", "delegate_tasks"] } }, "media-describer": { + "description": "Transcribe audio and describe images; read-only media toolkit", "max_risk": "safe", "tools": { "enabled": ["transcribe", "vision", "read_file", "batch_read", "glob", "file_info", "word_count"] } }, "summarizer": { + "description": "Condense sessions and documents; strictly read-only", "max_risk": "safe", "tools": { "enabled": ["session_search", "read_file", "batch_read", "glob", "search_files", "tree", "count_lines", "head_tail", "word_count"] } }