fix(subagents): intersect child tools with child runtime (#191)
- commit
- 44bd3a145b5465f1cf4bdb46b32b134726fdff4a
- author
- bnema · · 2026-09-08 03:41 UTC
- committer
- GitHub · · 2026-09-08 03:41 UTC
- parent
- b0688d1
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
3 files · +242 -25
M docs/subagents.md +6 -3
| @@ -32,9 +32,12 @@ Inspect the requested scope and report evidence-backed findings. | |||
| 32 | 32 | ||
| 33 | 33 | `tools` is a capability boundary. An omitted value inherits the child-safe | |
| 34 | 34 | catalogue, `tools: []` grants no tools, and an explicit list replaces the | |
| 35 | - | default. Names unavailable to the child (including host-only extensions) and | |
| 36 | - | tools denied by `subagents.allowed_tools` are omitted from an explicit list; | |
| 37 | - | they never grant the child additional access. `model`, `provider`, `thinking`, | |
| 35 | + | default. The child-safe catalogue contains only tools offered by the parent | |
| 36 | + | that are also available in the resolved child runtime; parent-only scheduling | |
| 37 | + | and extension tools are never inherited. Names unavailable to the child | |
| 38 | + | (including host-only extensions) and tools denied by `subagents.allowed_tools` | |
| 39 | + | are omitted from an explicit list; they never grant the child additional | |
| 40 | + | access. `model`, `provider`, `thinking`, | |
| 38 | 41 | the prompt mode, project/skill inheritance, and `fastMode` are resolved before | |
| 39 | 42 | the child is accepted. Spawn-level model, provider, reasoning, and fast-mode | |
| 40 | 43 | values take precedence where supplied. | |
| @@ -32,9 +32,12 @@ Inspect the requested scope and report evidence-backed findings. | |||||
| 32 | 32 | ||||
| 33 | `tools` is a capability boundary. An omitted value inherits the child-safe | 33 | `tools` is a capability boundary. An omitted value inherits the child-safe | ||
| 34 | catalogue, `tools: []` grants no tools, and an explicit list replaces the | 34 | catalogue, `tools: []` grants no tools, and an explicit list replaces the | ||
| 35 | - | default. Names unavailable to the child (including host-only extensions) and | 35 | + | default. The child-safe catalogue contains only tools offered by the parent |
| 36 | - | tools denied by `subagents.allowed_tools` are omitted from an explicit list; | 36 | + | that are also available in the resolved child runtime; parent-only scheduling |
| 37 | - | they never grant the child additional access. `model`, `provider`, `thinking`, | 37 | + | and extension tools are never inherited. Names unavailable to the child |
| 38 | + | (including host-only extensions) and tools denied by `subagents.allowed_tools` | |||
| 39 | + | are omitted from an explicit list; they never grant the child additional | |||
| 40 | + | access. `model`, `provider`, `thinking`, | |||
| 38 | the prompt mode, project/skill inheritance, and `fastMode` are resolved before | 41 | the prompt mode, project/skill inheritance, and `fastMode` are resolved before | ||
| 39 | the child is accepted. Spawn-level model, provider, reasoning, and fast-mode | 42 | the child is accepted. Spawn-level model, provider, reasoning, and fast-mode | ||
| 40 | values take precedence where supplied. | 43 | values take precedence where supplied. | ||
M packages/agent/subagent_runtime.go +28 -22
| @@ -331,12 +331,39 @@ func (rt *subagentRuntime) buildResidentChildSpec(_ context.Context, request too | |||
| 331 | 331 | // selected provider: Resolve must use that provider's own settings. | |
| 332 | 332 | baseURL, insecureTLS = "", false | |
| 333 | 333 | } | |
| 334 | + | workspaceMode := request.WorkspaceMode | |
| 335 | + | if workspaceMode == "" { | |
| 336 | + | workspaceMode = subagents.WorkspaceShared | |
| 337 | + | } | |
| 338 | + | spec := subagents.ResidentChildSpec{ | |
| 339 | + | ID: uuid.NewString(), SessionID: uuid.NewString(), RootCacheID: parentSession, ParentSessionID: parentSession, | |
| 340 | + | Provider: strings.TrimSpace(providerID), BaseURL: strings.TrimSpace(baseURL), InsecureTLS: insecureTLS, Model: strings.TrimSpace(model), | |
| 341 | + | Reasoning: strings.TrimSpace(reasoning), FastMode: fastMode, | |
| 342 | + | RepositoryRoot: workspace, Workspace: workspace, WorkspaceMode: workspaceMode, Required: request.Required, | |
| 343 | + | } | |
| 344 | + | if request.Profile != nil { | |
| 345 | + | spec.Profile = request.Profile.Name | |
| 346 | + | spec.SystemPrompt = request.Profile.SystemPrompt | |
| 347 | + | spec.SystemPromptMode = request.Profile.SystemPromptMode | |
| 348 | + | spec.InheritProjectContext = request.Profile.InheritProjectContext | |
| 349 | + | spec.InheritSkills = request.Profile.InheritSkills | |
| 350 | + | } | |
| 351 | + | resolved, err := Resolve(residentChildArgs(rt.args, rt.credentialProvider, spec), false) | |
| 352 | + | if err != nil { | |
| 353 | + | return subagents.ResidentChildSpec{}, fmt.Errorf("resolve resident child model: %w", err) | |
| 354 | + | } | |
| 355 | + | // The child-safe catalogue is the intersection of parent-offered tools | |
| 356 | + | // and tools available in the resolved child runtime. Parent-only | |
| 357 | + | // scheduling/extension tools are never inherited. | |
| 334 | 358 | allTools := make([]string, 0, len(catalogue)) | |
| 335 | 359 | for name := range catalogue { | |
| 336 | 360 | switch name { | |
| 337 | 361 | case tools.SubagentSpawnToolName, tools.SubagentStatusToolName, tools.SubagentStopToolName, tools.SubagentResumeToolName, "update_goal": | |
| 338 | 362 | continue | |
| 339 | 363 | } | |
| 364 | + | if _, ok := resolved.ToolRegistry[name]; !ok { | |
| 365 | + | continue | |
| 366 | + | } | |
| 340 | 367 | allTools = append(allTools, name) | |
| 341 | 368 | } | |
| 342 | 369 | sort.Strings(allTools) | |
| @@ -347,7 +374,6 @@ func (rt *subagentRuntime) buildResidentChildSpec(_ context.Context, request too | |||
| 347 | 374 | return policy.AllowsTool(name) | |
| 348 | 375 | } | |
| 349 | 376 | var childTools []string | |
| 350 | - | var err error | |
| 351 | 377 | if request.Profile == nil || !request.Profile.ToolsDeclared { | |
| 352 | 378 | for _, name := range allTools { | |
| 353 | 379 | if permitted(name) { | |
| @@ -361,27 +387,7 @@ func (rt *subagentRuntime) buildResidentChildSpec(_ context.Context, request too | |||
| 361 | 387 | } | |
| 362 | 388 | } | |
| 363 | 389 | childTools = expandWebCapabilityTools(childTools, allTools, permitted) | |
| 364 | - | workspaceMode := request.WorkspaceMode | |
| 365 | - | if workspaceMode == "" { | |
| 366 | - | workspaceMode = subagents.WorkspaceShared | |
| 367 | - | } | |
| 368 | - | spec := subagents.ResidentChildSpec{ | |
| 369 | - | ID: uuid.NewString(), SessionID: uuid.NewString(), RootCacheID: parentSession, ParentSessionID: parentSession, | |
| 370 | - | Provider: strings.TrimSpace(providerID), BaseURL: strings.TrimSpace(baseURL), InsecureTLS: insecureTLS, Model: strings.TrimSpace(model), | |
| 371 | - | Reasoning: strings.TrimSpace(reasoning), FastMode: fastMode, Tools: childTools, | |
| 372 | - | RepositoryRoot: workspace, Workspace: workspace, WorkspaceMode: workspaceMode, Required: request.Required, | |
| 373 | - | } | |
| 374 | - | if request.Profile != nil { | |
| 375 | - | spec.Profile = request.Profile.Name | |
| 376 | - | spec.SystemPrompt = request.Profile.SystemPrompt | |
| 377 | - | spec.SystemPromptMode = request.Profile.SystemPromptMode | |
| 378 | - | spec.InheritProjectContext = request.Profile.InheritProjectContext | |
| 379 | - | spec.InheritSkills = request.Profile.InheritSkills | |
| 380 | - | } | |
| 381 | - | resolved, err := Resolve(residentChildArgs(rt.args, rt.credentialProvider, spec), false) | |
| 382 | - | if err != nil { | |
| 383 | - | return subagents.ResidentChildSpec{}, fmt.Errorf("resolve resident child model: %w", err) | |
| 384 | - | } | |
| 390 | + | spec.Tools = childTools | |
| 385 | 391 | budgetLimit, err := subagents.ContextBudgetLimit(resolved.ContextWindow) | |
| 386 | 392 | if err != nil { | |
| 387 | 393 | return subagents.ResidentChildSpec{}, err | |
| @@ -331,12 +331,39 @@ func (rt *subagentRuntime) buildResidentChildSpec(_ context.Context, request too | |||||
| 331 | // selected provider: Resolve must use that provider's own settings. | 331 | // selected provider: Resolve must use that provider's own settings. | ||
| 332 | baseURL, insecureTLS = "", false | 332 | baseURL, insecureTLS = "", false | ||
| 333 | } | 333 | } | ||
| 334 | + | workspaceMode := request.WorkspaceMode | |||
| 335 | + | if workspaceMode == "" { | |||
| 336 | + | workspaceMode = subagents.WorkspaceShared | |||
| 337 | + | } | |||
| 338 | + | spec := subagents.ResidentChildSpec{ | |||
| 339 | + | ID: uuid.NewString(), SessionID: uuid.NewString(), RootCacheID: parentSession, ParentSessionID: parentSession, | |||
| 340 | + | Provider: strings.TrimSpace(providerID), BaseURL: strings.TrimSpace(baseURL), InsecureTLS: insecureTLS, Model: strings.TrimSpace(model), | |||
| 341 | + | Reasoning: strings.TrimSpace(reasoning), FastMode: fastMode, | |||
| 342 | + | RepositoryRoot: workspace, Workspace: workspace, WorkspaceMode: workspaceMode, Required: request.Required, | |||
| 343 | + | } | |||
| 344 | + | if request.Profile != nil { | |||
| 345 | + | spec.Profile = request.Profile.Name | |||
| 346 | + | spec.SystemPrompt = request.Profile.SystemPrompt | |||
| 347 | + | spec.SystemPromptMode = request.Profile.SystemPromptMode | |||
| 348 | + | spec.InheritProjectContext = request.Profile.InheritProjectContext | |||
| 349 | + | spec.InheritSkills = request.Profile.InheritSkills | |||
| 350 | + | } | |||
| 351 | + | resolved, err := Resolve(residentChildArgs(rt.args, rt.credentialProvider, spec), false) | |||
| 352 | + | if err != nil { | |||
| 353 | + | return subagents.ResidentChildSpec{}, fmt.Errorf("resolve resident child model: %w", err) | |||
| 354 | + | } | |||
| 355 | + | // The child-safe catalogue is the intersection of parent-offered tools | |||
| 356 | + | // and tools available in the resolved child runtime. Parent-only | |||
| 357 | + | // scheduling/extension tools are never inherited. | |||
| 334 | allTools := make([]string, 0, len(catalogue)) | 358 | allTools := make([]string, 0, len(catalogue)) | ||
| 335 | for name := range catalogue { | 359 | for name := range catalogue { | ||
| 336 | switch name { | 360 | switch name { | ||
| 337 | case tools.SubagentSpawnToolName, tools.SubagentStatusToolName, tools.SubagentStopToolName, tools.SubagentResumeToolName, "update_goal": | 361 | case tools.SubagentSpawnToolName, tools.SubagentStatusToolName, tools.SubagentStopToolName, tools.SubagentResumeToolName, "update_goal": | ||
| 338 | continue | 362 | continue | ||
| 339 | } | 363 | } | ||
| 364 | + | if _, ok := resolved.ToolRegistry[name]; !ok { | |||
| 365 | + | continue | |||
| 366 | + | } | |||
| 340 | allTools = append(allTools, name) | 367 | allTools = append(allTools, name) | ||
| 341 | } | 368 | } | ||
| 342 | sort.Strings(allTools) | 369 | sort.Strings(allTools) | ||
| @@ -347,7 +374,6 @@ func (rt *subagentRuntime) buildResidentChildSpec(_ context.Context, request too | |||||
| 347 | return policy.AllowsTool(name) | 374 | return policy.AllowsTool(name) | ||
| 348 | } | 375 | } | ||
| 349 | var childTools []string | 376 | var childTools []string | ||
| 350 | - | var err error | |||
| 351 | if request.Profile == nil || !request.Profile.ToolsDeclared { | 377 | if request.Profile == nil || !request.Profile.ToolsDeclared { | ||
| 352 | for _, name := range allTools { | 378 | for _, name := range allTools { | ||
| 353 | if permitted(name) { | 379 | if permitted(name) { | ||
| @@ -361,27 +387,7 @@ func (rt *subagentRuntime) buildResidentChildSpec(_ context.Context, request too | |||||
| 361 | } | 387 | } | ||
| 362 | } | 388 | } | ||
| 363 | childTools = expandWebCapabilityTools(childTools, allTools, permitted) | 389 | childTools = expandWebCapabilityTools(childTools, allTools, permitted) | ||
| 364 | - | workspaceMode := request.WorkspaceMode | 390 | + | spec.Tools = childTools |
| 365 | - | if workspaceMode == "" { | |||
| 366 | - | workspaceMode = subagents.WorkspaceShared | |||
| 367 | - | } | |||
| 368 | - | spec := subagents.ResidentChildSpec{ | |||
| 369 | - | ID: uuid.NewString(), SessionID: uuid.NewString(), RootCacheID: parentSession, ParentSessionID: parentSession, | |||
| 370 | - | Provider: strings.TrimSpace(providerID), BaseURL: strings.TrimSpace(baseURL), InsecureTLS: insecureTLS, Model: strings.TrimSpace(model), | |||
| 371 | - | Reasoning: strings.TrimSpace(reasoning), FastMode: fastMode, Tools: childTools, | |||
| 372 | - | RepositoryRoot: workspace, Workspace: workspace, WorkspaceMode: workspaceMode, Required: request.Required, | |||
| 373 | - | } | |||
| 374 | - | if request.Profile != nil { | |||
| 375 | - | spec.Profile = request.Profile.Name | |||
| 376 | - | spec.SystemPrompt = request.Profile.SystemPrompt | |||
| 377 | - | spec.SystemPromptMode = request.Profile.SystemPromptMode | |||
| 378 | - | spec.InheritProjectContext = request.Profile.InheritProjectContext | |||
| 379 | - | spec.InheritSkills = request.Profile.InheritSkills | |||
| 380 | - | } | |||
| 381 | - | resolved, err := Resolve(residentChildArgs(rt.args, rt.credentialProvider, spec), false) | |||
| 382 | - | if err != nil { | |||
| 383 | - | return subagents.ResidentChildSpec{}, fmt.Errorf("resolve resident child model: %w", err) | |||
| 384 | - | } | |||
| 385 | budgetLimit, err := subagents.ContextBudgetLimit(resolved.ContextWindow) | 391 | budgetLimit, err := subagents.ContextBudgetLimit(resolved.ContextWindow) | ||
| 386 | if err != nil { | 392 | if err != nil { | ||
| 387 | return subagents.ResidentChildSpec{}, err | 393 | return subagents.ResidentChildSpec{}, err | ||
M packages/agent/subagent_runtime_test.go +208 -0
| @@ -2,8 +2,10 @@ package agent | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "slices" | |
| 5 | 6 | "testing" | |
| 6 | 7 | ||
| 8 | + | "github.com/bnema/zut/packages/agent/subagents" | |
| 7 | 9 | "github.com/bnema/zut/packages/agent/tools" | |
| 8 | 10 | "github.com/bnema/zut/packages/core" | |
| 9 | 11 | ) | |
| @@ -123,3 +125,209 @@ func TestResidentChildSpecDoesNotCarryTransportAcrossProviderOverride(t *testing | |||
| 123 | 125 | t.Fatalf("cross-provider spec inherited transport: %#v", spec) | |
| 124 | 126 | } | |
| 125 | 127 | } | |
| 128 | + | ||
| 129 | + | func TestResidentChildSpecOmitsParentOnlyTools(t *testing.T) { | |
| 130 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |
| 131 | + | cwd := t.TempDir() | |
| 132 | + | runtime := newSubagentRuntime(subagentRuntimeConfig{ | |
| 133 | + | Args: Args{CWD: cwd, NoSkill: true, NoContextFiles: true, NoLSP: true}, | |
| 134 | + | Root: t.TempDir(), | |
| 135 | + | RepoRoot: cwd, | |
| 136 | + | Provider: "openai", Model: "gpt-5.6-sol", | |
| 137 | + | }) | |
| 138 | + | t.Cleanup(func() { _ = runtime.Close(context.Background()) }) | |
| 139 | + | ||
| 140 | + | parent := core.Registry{ | |
| 141 | + | "read": nil, | |
| 142 | + | "schedule": nil, | |
| 143 | + | "host-only-tool": nil, | |
| 144 | + | "subagent_spawn": nil, | |
| 145 | + | "update_goal": nil, | |
| 146 | + | } | |
| 147 | + | spec, err := runtime.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{Task: "review"}, parent) | |
| 148 | + | if err != nil { | |
| 149 | + | t.Fatal(err) | |
| 150 | + | } | |
| 151 | + | if slices.Contains(spec.Tools, "schedule") || slices.Contains(spec.Tools, "host-only-tool") { | |
| 152 | + | t.Fatalf("generic child inherited parent-only tools: %v", spec.Tools) | |
| 153 | + | } | |
| 154 | + | if !slices.Contains(spec.Tools, "read") { | |
| 155 | + | t.Fatalf("generic child lost supported tool: %v", spec.Tools) | |
| 156 | + | } | |
| 157 | + | for _, name := range []string{"subagent_spawn", "update_goal"} { | |
| 158 | + | if slices.Contains(spec.Tools, name) { | |
| 159 | + | t.Fatalf("generic child kept forbidden tool %q: %v", name, spec.Tools) | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | resolved, err := Resolve(residentChildArgs(runtime.args, runtime.credentialProvider, spec), false) | |
| 163 | + | if err != nil { | |
| 164 | + | t.Fatal(err) | |
| 165 | + | } | |
| 166 | + | if _, err := residentChildRegistry(resolved.ToolRegistry, spec.Tools); err != nil { | |
| 167 | + | t.Fatalf("generic spec failed child registry validation: %v (tools=%v)", err, spec.Tools) | |
| 168 | + | } | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | func TestResidentChildSpecProfileToolDeclarations(t *testing.T) { | |
| 172 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |
| 173 | + | cwd := t.TempDir() | |
| 174 | + | newRuntime := func(policy subagents.SubagentPolicy) *subagentRuntime { | |
| 175 | + | rt := newSubagentRuntime(subagentRuntimeConfig{ | |
| 176 | + | Args: Args{CWD: cwd, NoSkill: true, NoContextFiles: true, NoLSP: true}, | |
| 177 | + | Root: t.TempDir(), | |
| 178 | + | RepoRoot: cwd, | |
| 179 | + | Provider: "openai", Model: "gpt-5.6-sol", | |
| 180 | + | Policy: policy, | |
| 181 | + | }) | |
| 182 | + | t.Cleanup(func() { _ = rt.Close(context.Background()) }) | |
| 183 | + | return rt | |
| 184 | + | } | |
| 185 | + | parent := core.Registry{"read": nil, "bash": nil, "schedule": nil} | |
| 186 | + | ||
| 187 | + | t.Run("omitted inherits like generic", func(t *testing.T) { | |
| 188 | + | rt := newRuntime(subagents.SubagentPolicy{}) | |
| 189 | + | generic, err := rt.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{Task: "review"}, parent) | |
| 190 | + | if err != nil { | |
| 191 | + | t.Fatal(err) | |
| 192 | + | } | |
| 193 | + | withProfile, err := rt.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{ | |
| 194 | + | Task: "review", | |
| 195 | + | Profile: &subagents.Profile{Name: "generic"}, | |
| 196 | + | }, parent) | |
| 197 | + | if err != nil { | |
| 198 | + | t.Fatal(err) | |
| 199 | + | } | |
| 200 | + | if !slices.Equal(generic.Tools, withProfile.Tools) { | |
| 201 | + | t.Fatalf("omitted profile tools = %v, want generic %v", withProfile.Tools, generic.Tools) | |
| 202 | + | } | |
| 203 | + | }) | |
| 204 | + | ||
| 205 | + | t.Run("explicit keeps only supported permitted names", func(t *testing.T) { | |
| 206 | + | rt := newRuntime(subagents.SubagentPolicy{}) | |
| 207 | + | spec, err := rt.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{ | |
| 208 | + | Task: "review", | |
| 209 | + | Profile: &subagents.Profile{ | |
| 210 | + | Name: "reviewer", ToolsDeclared: true, | |
| 211 | + | Tools: []string{"read", "schedule", "host-only-tool", "missing"}, | |
| 212 | + | }, | |
| 213 | + | }, parent) | |
| 214 | + | if err != nil { | |
| 215 | + | t.Fatal(err) | |
| 216 | + | } | |
| 217 | + | if len(spec.Tools) != 1 || spec.Tools[0] != "read" { | |
| 218 | + | t.Fatalf("explicit profile tools = %v, want [read]", spec.Tools) | |
| 219 | + | } | |
| 220 | + | }) | |
| 221 | + | ||
| 222 | + | t.Run("explicit empty stays empty", func(t *testing.T) { | |
| 223 | + | rt := newRuntime(subagents.SubagentPolicy{}) | |
| 224 | + | spec, err := rt.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{ | |
| 225 | + | Task: "review", | |
| 226 | + | Profile: &subagents.Profile{Name: "empty", ToolsDeclared: true, Tools: []string{}}, | |
| 227 | + | }, parent) | |
| 228 | + | if err != nil { | |
| 229 | + | t.Fatal(err) | |
| 230 | + | } | |
| 231 | + | if len(spec.Tools) != 0 { | |
| 232 | + | t.Fatalf("explicit empty profile tools = %v, want []", spec.Tools) | |
| 233 | + | } | |
| 234 | + | }) | |
| 235 | + | ||
| 236 | + | t.Run("policy denials hold", func(t *testing.T) { | |
| 237 | + | rt := newRuntime(subagents.SubagentPolicy{AllowedTools: []string{"read"}}) | |
| 238 | + | spec, err := rt.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{Task: "review"}, parent) | |
| 239 | + | if err != nil { | |
| 240 | + | t.Fatal(err) | |
| 241 | + | } | |
| 242 | + | if len(spec.Tools) != 1 || spec.Tools[0] != "read" { | |
| 243 | + | t.Fatalf("policy-restricted tools = %v, want [read]", spec.Tools) | |
| 244 | + | } | |
| 245 | + | explicit, err := rt.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{ | |
| 246 | + | Task: "review", | |
| 247 | + | Profile: &subagents.Profile{ | |
| 248 | + | Name: "reviewer", ToolsDeclared: true, | |
| 249 | + | Tools: []string{"read", "bash"}, | |
| 250 | + | }, | |
| 251 | + | }, parent) | |
| 252 | + | if err != nil { | |
| 253 | + | t.Fatal(err) | |
| 254 | + | } | |
| 255 | + | if len(explicit.Tools) != 1 || explicit.Tools[0] != "read" { | |
| 256 | + | t.Fatalf("policy-restricted explicit tools = %v, want [read]", explicit.Tools) | |
| 257 | + | } | |
| 258 | + | }) | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | func TestResidentChildSpecNeverGrantsToolsOutsideParent(t *testing.T) { | |
| 262 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |
| 263 | + | cwd := t.TempDir() | |
| 264 | + | runtime := newSubagentRuntime(subagentRuntimeConfig{ | |
| 265 | + | Args: Args{CWD: cwd, NoSkill: true, NoContextFiles: true, NoLSP: true}, | |
| 266 | + | Root: t.TempDir(), | |
| 267 | + | RepoRoot: cwd, | |
| 268 | + | Provider: "openai", Model: "gpt-5.6-sol", | |
| 269 | + | }) | |
| 270 | + | t.Cleanup(func() { _ = runtime.Close(context.Background()) }) | |
| 271 | + | ||
| 272 | + | spec, err := runtime.buildResidentChildSpec(context.Background(), | |
| 273 | + | tools.ResidentSpawnRequest{Task: "review"}, core.Registry{"read": nil}) | |
| 274 | + | if err != nil { | |
| 275 | + | t.Fatal(err) | |
| 276 | + | } | |
| 277 | + | // bash/write exist in the resolved child runtime but were never offered | |
| 278 | + | // by the parent catalogue; the child must not receive them. | |
| 279 | + | for _, name := range []string{"bash", "write", "schedule"} { | |
| 280 | + | if slices.Contains(spec.Tools, name) { | |
| 281 | + | t.Fatalf("child gained tool %q outside parent catalogue: %v", name, spec.Tools) | |
| 282 | + | } | |
| 283 | + | } | |
| 284 | + | explicit, err := runtime.buildResidentChildSpec(context.Background(), | |
| 285 | + | tools.ResidentSpawnRequest{ | |
| 286 | + | Task: "review", | |
| 287 | + | Profile: &subagents.Profile{ | |
| 288 | + | Name: "reviewer", ToolsDeclared: true, | |
| 289 | + | Tools: []string{"read", "bash"}, | |
| 290 | + | }, | |
| 291 | + | }, core.Registry{"read": nil}) | |
| 292 | + | if err != nil { | |
| 293 | + | t.Fatal(err) | |
| 294 | + | } | |
| 295 | + | if len(explicit.Tools) != 1 || explicit.Tools[0] != "read" { | |
| 296 | + | t.Fatalf("explicit profile gained tool outside parent catalogue: %v", explicit.Tools) | |
| 297 | + | } | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | func TestResidentChildSpecDisabledSkillsDoNotLeak(t *testing.T) { | |
| 301 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |
| 302 | + | cwd := t.TempDir() | |
| 303 | + | runtime := newSubagentRuntime(subagentRuntimeConfig{ | |
| 304 | + | Args: Args{CWD: cwd, NoSkill: true, NoContextFiles: true, NoLSP: true}, | |
| 305 | + | Root: t.TempDir(), | |
| 306 | + | RepoRoot: cwd, | |
| 307 | + | Provider: "openai", Model: "gpt-5.6-sol", | |
| 308 | + | }) | |
| 309 | + | t.Cleanup(func() { _ = runtime.Close(context.Background()) }) | |
| 310 | + | ||
| 311 | + | inherit := false | |
| 312 | + | parent := core.Registry{"read": nil, "skill": nil} | |
| 313 | + | spec, err := runtime.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{ | |
| 314 | + | Task: "review", | |
| 315 | + | Profile: &subagents.Profile{Name: "noskill", InheritSkills: &inherit}, | |
| 316 | + | }, parent) | |
| 317 | + | if err != nil { | |
| 318 | + | t.Fatal(err) | |
| 319 | + | } | |
| 320 | + | if slices.Contains(spec.Tools, "skill") { | |
| 321 | + | t.Fatalf("skill-disabled child inherited skill tool: %v", spec.Tools) | |
| 322 | + | } | |
| 323 | + | resolved, err := Resolve(residentChildArgs(runtime.args, runtime.credentialProvider, spec), false) | |
| 324 | + | if err != nil { | |
| 325 | + | t.Fatal(err) | |
| 326 | + | } | |
| 327 | + | if _, ok := resolved.ToolRegistry["skill"]; ok { | |
| 328 | + | t.Fatalf("skill-disabled child registry contains skill tool") | |
| 329 | + | } | |
| 330 | + | if _, err := residentChildRegistry(resolved.ToolRegistry, spec.Tools); err != nil { | |
| 331 | + | t.Fatalf("skill-disabled spec failed validation: %v", err) | |
| 332 | + | } | |
| 333 | + | } | |
| @@ -2,8 +2,10 @@ package agent | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "slices" | |||
| 5 | "testing" | 6 | "testing" | ||
| 6 | 7 | ||||
| 8 | + | "github.com/bnema/zut/packages/agent/subagents" | |||
| 7 | "github.com/bnema/zut/packages/agent/tools" | 9 | "github.com/bnema/zut/packages/agent/tools" | ||
| 8 | "github.com/bnema/zut/packages/core" | 10 | "github.com/bnema/zut/packages/core" | ||
| 9 | ) | 11 | ) | ||
| @@ -123,3 +125,209 @@ func TestResidentChildSpecDoesNotCarryTransportAcrossProviderOverride(t *testing | |||||
| 123 | t.Fatalf("cross-provider spec inherited transport: %#v", spec) | 125 | t.Fatalf("cross-provider spec inherited transport: %#v", spec) | ||
| 124 | } | 126 | } | ||
| 125 | } | 127 | } | ||
| 128 | + | ||||
| 129 | + | func TestResidentChildSpecOmitsParentOnlyTools(t *testing.T) { | |||
| 130 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |||
| 131 | + | cwd := t.TempDir() | |||
| 132 | + | runtime := newSubagentRuntime(subagentRuntimeConfig{ | |||
| 133 | + | Args: Args{CWD: cwd, NoSkill: true, NoContextFiles: true, NoLSP: true}, | |||
| 134 | + | Root: t.TempDir(), | |||
| 135 | + | RepoRoot: cwd, | |||
| 136 | + | Provider: "openai", Model: "gpt-5.6-sol", | |||
| 137 | + | }) | |||
| 138 | + | t.Cleanup(func() { _ = runtime.Close(context.Background()) }) | |||
| 139 | + | ||||
| 140 | + | parent := core.Registry{ | |||
| 141 | + | "read": nil, | |||
| 142 | + | "schedule": nil, | |||
| 143 | + | "host-only-tool": nil, | |||
| 144 | + | "subagent_spawn": nil, | |||
| 145 | + | "update_goal": nil, | |||
| 146 | + | } | |||
| 147 | + | spec, err := runtime.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{Task: "review"}, parent) | |||
| 148 | + | if err != nil { | |||
| 149 | + | t.Fatal(err) | |||
| 150 | + | } | |||
| 151 | + | if slices.Contains(spec.Tools, "schedule") || slices.Contains(spec.Tools, "host-only-tool") { | |||
| 152 | + | t.Fatalf("generic child inherited parent-only tools: %v", spec.Tools) | |||
| 153 | + | } | |||
| 154 | + | if !slices.Contains(spec.Tools, "read") { | |||
| 155 | + | t.Fatalf("generic child lost supported tool: %v", spec.Tools) | |||
| 156 | + | } | |||
| 157 | + | for _, name := range []string{"subagent_spawn", "update_goal"} { | |||
| 158 | + | if slices.Contains(spec.Tools, name) { | |||
| 159 | + | t.Fatalf("generic child kept forbidden tool %q: %v", name, spec.Tools) | |||
| 160 | + | } | |||
| 161 | + | } | |||
| 162 | + | resolved, err := Resolve(residentChildArgs(runtime.args, runtime.credentialProvider, spec), false) | |||
| 163 | + | if err != nil { | |||
| 164 | + | t.Fatal(err) | |||
| 165 | + | } | |||
| 166 | + | if _, err := residentChildRegistry(resolved.ToolRegistry, spec.Tools); err != nil { | |||
| 167 | + | t.Fatalf("generic spec failed child registry validation: %v (tools=%v)", err, spec.Tools) | |||
| 168 | + | } | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | func TestResidentChildSpecProfileToolDeclarations(t *testing.T) { | |||
| 172 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |||
| 173 | + | cwd := t.TempDir() | |||
| 174 | + | newRuntime := func(policy subagents.SubagentPolicy) *subagentRuntime { | |||
| 175 | + | rt := newSubagentRuntime(subagentRuntimeConfig{ | |||
| 176 | + | Args: Args{CWD: cwd, NoSkill: true, NoContextFiles: true, NoLSP: true}, | |||
| 177 | + | Root: t.TempDir(), | |||
| 178 | + | RepoRoot: cwd, | |||
| 179 | + | Provider: "openai", Model: "gpt-5.6-sol", | |||
| 180 | + | Policy: policy, | |||
| 181 | + | }) | |||
| 182 | + | t.Cleanup(func() { _ = rt.Close(context.Background()) }) | |||
| 183 | + | return rt | |||
| 184 | + | } | |||
| 185 | + | parent := core.Registry{"read": nil, "bash": nil, "schedule": nil} | |||
| 186 | + | ||||
| 187 | + | t.Run("omitted inherits like generic", func(t *testing.T) { | |||
| 188 | + | rt := newRuntime(subagents.SubagentPolicy{}) | |||
| 189 | + | generic, err := rt.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{Task: "review"}, parent) | |||
| 190 | + | if err != nil { | |||
| 191 | + | t.Fatal(err) | |||
| 192 | + | } | |||
| 193 | + | withProfile, err := rt.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{ | |||
| 194 | + | Task: "review", | |||
| 195 | + | Profile: &subagents.Profile{Name: "generic"}, | |||
| 196 | + | }, parent) | |||
| 197 | + | if err != nil { | |||
| 198 | + | t.Fatal(err) | |||
| 199 | + | } | |||
| 200 | + | if !slices.Equal(generic.Tools, withProfile.Tools) { | |||
| 201 | + | t.Fatalf("omitted profile tools = %v, want generic %v", withProfile.Tools, generic.Tools) | |||
| 202 | + | } | |||
| 203 | + | }) | |||
| 204 | + | ||||
| 205 | + | t.Run("explicit keeps only supported permitted names", func(t *testing.T) { | |||
| 206 | + | rt := newRuntime(subagents.SubagentPolicy{}) | |||
| 207 | + | spec, err := rt.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{ | |||
| 208 | + | Task: "review", | |||
| 209 | + | Profile: &subagents.Profile{ | |||
| 210 | + | Name: "reviewer", ToolsDeclared: true, | |||
| 211 | + | Tools: []string{"read", "schedule", "host-only-tool", "missing"}, | |||
| 212 | + | }, | |||
| 213 | + | }, parent) | |||
| 214 | + | if err != nil { | |||
| 215 | + | t.Fatal(err) | |||
| 216 | + | } | |||
| 217 | + | if len(spec.Tools) != 1 || spec.Tools[0] != "read" { | |||
| 218 | + | t.Fatalf("explicit profile tools = %v, want [read]", spec.Tools) | |||
| 219 | + | } | |||
| 220 | + | }) | |||
| 221 | + | ||||
| 222 | + | t.Run("explicit empty stays empty", func(t *testing.T) { | |||
| 223 | + | rt := newRuntime(subagents.SubagentPolicy{}) | |||
| 224 | + | spec, err := rt.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{ | |||
| 225 | + | Task: "review", | |||
| 226 | + | Profile: &subagents.Profile{Name: "empty", ToolsDeclared: true, Tools: []string{}}, | |||
| 227 | + | }, parent) | |||
| 228 | + | if err != nil { | |||
| 229 | + | t.Fatal(err) | |||
| 230 | + | } | |||
| 231 | + | if len(spec.Tools) != 0 { | |||
| 232 | + | t.Fatalf("explicit empty profile tools = %v, want []", spec.Tools) | |||
| 233 | + | } | |||
| 234 | + | }) | |||
| 235 | + | ||||
| 236 | + | t.Run("policy denials hold", func(t *testing.T) { | |||
| 237 | + | rt := newRuntime(subagents.SubagentPolicy{AllowedTools: []string{"read"}}) | |||
| 238 | + | spec, err := rt.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{Task: "review"}, parent) | |||
| 239 | + | if err != nil { | |||
| 240 | + | t.Fatal(err) | |||
| 241 | + | } | |||
| 242 | + | if len(spec.Tools) != 1 || spec.Tools[0] != "read" { | |||
| 243 | + | t.Fatalf("policy-restricted tools = %v, want [read]", spec.Tools) | |||
| 244 | + | } | |||
| 245 | + | explicit, err := rt.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{ | |||
| 246 | + | Task: "review", | |||
| 247 | + | Profile: &subagents.Profile{ | |||
| 248 | + | Name: "reviewer", ToolsDeclared: true, | |||
| 249 | + | Tools: []string{"read", "bash"}, | |||
| 250 | + | }, | |||
| 251 | + | }, parent) | |||
| 252 | + | if err != nil { | |||
| 253 | + | t.Fatal(err) | |||
| 254 | + | } | |||
| 255 | + | if len(explicit.Tools) != 1 || explicit.Tools[0] != "read" { | |||
| 256 | + | t.Fatalf("policy-restricted explicit tools = %v, want [read]", explicit.Tools) | |||
| 257 | + | } | |||
| 258 | + | }) | |||
| 259 | + | } | |||
| 260 | + | ||||
| 261 | + | func TestResidentChildSpecNeverGrantsToolsOutsideParent(t *testing.T) { | |||
| 262 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |||
| 263 | + | cwd := t.TempDir() | |||
| 264 | + | runtime := newSubagentRuntime(subagentRuntimeConfig{ | |||
| 265 | + | Args: Args{CWD: cwd, NoSkill: true, NoContextFiles: true, NoLSP: true}, | |||
| 266 | + | Root: t.TempDir(), | |||
| 267 | + | RepoRoot: cwd, | |||
| 268 | + | Provider: "openai", Model: "gpt-5.6-sol", | |||
| 269 | + | }) | |||
| 270 | + | t.Cleanup(func() { _ = runtime.Close(context.Background()) }) | |||
| 271 | + | ||||
| 272 | + | spec, err := runtime.buildResidentChildSpec(context.Background(), | |||
| 273 | + | tools.ResidentSpawnRequest{Task: "review"}, core.Registry{"read": nil}) | |||
| 274 | + | if err != nil { | |||
| 275 | + | t.Fatal(err) | |||
| 276 | + | } | |||
| 277 | + | // bash/write exist in the resolved child runtime but were never offered | |||
| 278 | + | // by the parent catalogue; the child must not receive them. | |||
| 279 | + | for _, name := range []string{"bash", "write", "schedule"} { | |||
| 280 | + | if slices.Contains(spec.Tools, name) { | |||
| 281 | + | t.Fatalf("child gained tool %q outside parent catalogue: %v", name, spec.Tools) | |||
| 282 | + | } | |||
| 283 | + | } | |||
| 284 | + | explicit, err := runtime.buildResidentChildSpec(context.Background(), | |||
| 285 | + | tools.ResidentSpawnRequest{ | |||
| 286 | + | Task: "review", | |||
| 287 | + | Profile: &subagents.Profile{ | |||
| 288 | + | Name: "reviewer", ToolsDeclared: true, | |||
| 289 | + | Tools: []string{"read", "bash"}, | |||
| 290 | + | }, | |||
| 291 | + | }, core.Registry{"read": nil}) | |||
| 292 | + | if err != nil { | |||
| 293 | + | t.Fatal(err) | |||
| 294 | + | } | |||
| 295 | + | if len(explicit.Tools) != 1 || explicit.Tools[0] != "read" { | |||
| 296 | + | t.Fatalf("explicit profile gained tool outside parent catalogue: %v", explicit.Tools) | |||
| 297 | + | } | |||
| 298 | + | } | |||
| 299 | + | ||||
| 300 | + | func TestResidentChildSpecDisabledSkillsDoNotLeak(t *testing.T) { | |||
| 301 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |||
| 302 | + | cwd := t.TempDir() | |||
| 303 | + | runtime := newSubagentRuntime(subagentRuntimeConfig{ | |||
| 304 | + | Args: Args{CWD: cwd, NoSkill: true, NoContextFiles: true, NoLSP: true}, | |||
| 305 | + | Root: t.TempDir(), | |||
| 306 | + | RepoRoot: cwd, | |||
| 307 | + | Provider: "openai", Model: "gpt-5.6-sol", | |||
| 308 | + | }) | |||
| 309 | + | t.Cleanup(func() { _ = runtime.Close(context.Background()) }) | |||
| 310 | + | ||||
| 311 | + | inherit := false | |||
| 312 | + | parent := core.Registry{"read": nil, "skill": nil} | |||
| 313 | + | spec, err := runtime.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{ | |||
| 314 | + | Task: "review", | |||
| 315 | + | Profile: &subagents.Profile{Name: "noskill", InheritSkills: &inherit}, | |||
| 316 | + | }, parent) | |||
| 317 | + | if err != nil { | |||
| 318 | + | t.Fatal(err) | |||
| 319 | + | } | |||
| 320 | + | if slices.Contains(spec.Tools, "skill") { | |||
| 321 | + | t.Fatalf("skill-disabled child inherited skill tool: %v", spec.Tools) | |||
| 322 | + | } | |||
| 323 | + | resolved, err := Resolve(residentChildArgs(runtime.args, runtime.credentialProvider, spec), false) | |||
| 324 | + | if err != nil { | |||
| 325 | + | t.Fatal(err) | |||
| 326 | + | } | |||
| 327 | + | if _, ok := resolved.ToolRegistry["skill"]; ok { | |||
| 328 | + | t.Fatalf("skill-disabled child registry contains skill tool") | |||
| 329 | + | } | |||
| 330 | + | if _, err := residentChildRegistry(resolved.ToolRegistry, spec.Tools); err != nil { | |||
| 331 | + | t.Fatalf("skill-disabled spec failed validation: %v", err) | |||
| 332 | + | } | |||
| 333 | + | } | |||