detached 44bd3a1

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 UnverifiedB5690EEEBB952194 The 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.
3232
3333 `tools` is a capability boundary. An omitted value inherits the child-safe
3434 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`,
3841 the prompt mode, project/skill inheritance, and `fastMode` are resolved before
3942 the child is accepted. Spawn-level model, provider, reasoning, and fast-mode
4043 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-safe33 `tools` is a capability boundary. An omitted value inherits the child-safe
34 catalogue, `tools: []` grants no tools, and an explicit list replaces the34 catalogue, `tools: []` grants no tools, and an explicit list replaces the
35-default. Names unavailable to the child (including host-only extensions) and35+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 before41 the prompt mode, project/skill inheritance, and `fastMode` are resolved before
39 the child is accepted. Spawn-level model, provider, reasoning, and fast-mode42 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
331331 // selected provider: Resolve must use that provider's own settings.
332332 baseURL, insecureTLS = "", false
333333 }
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.
334358 allTools := make([]string, 0, len(catalogue))
335359 for name := range catalogue {
336360 switch name {
337361 case tools.SubagentSpawnToolName, tools.SubagentStatusToolName, tools.SubagentStopToolName, tools.SubagentResumeToolName, "update_goal":
338362 continue
339363 }
364+ if _, ok := resolved.ToolRegistry[name]; !ok {
365+ continue
366+ }
340367 allTools = append(allTools, name)
341368 }
342369 sort.Strings(allTools)
@@ -347,7 +374,6 @@ func (rt *subagentRuntime) buildResidentChildSpec(_ context.Context, request too
347374 return policy.AllowsTool(name)
348375 }
349376 var childTools []string
350- var err error
351377 if request.Profile == nil || !request.Profile.ToolsDeclared {
352378 for _, name := range allTools {
353379 if permitted(name) {
@@ -361,27 +387,7 @@ func (rt *subagentRuntime) buildResidentChildSpec(_ context.Context, request too
361387 }
362388 }
363389 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
385391 budgetLimit, err := subagents.ContextBudgetLimit(resolved.ContextWindow)
386392 if err != nil {
387393 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 = "", false332 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 continue362 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 []string376 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.WorkspaceMode390+ 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{}, err393 return subagents.ResidentChildSpec{}, err
M packages/agent/subagent_runtime_test.go +208 -0
@@ -2,8 +2,10 @@ package agent
22
33 import (
44 "context"
5+ "slices"
56 "testing"
67
8+ "github.com/bnema/zut/packages/agent/subagents"
79 "github.com/bnema/zut/packages/agent/tools"
810 "github.com/bnema/zut/packages/core"
911 )
@@ -123,3 +125,209 @@ func TestResidentChildSpecDoesNotCarryTransportAcrossProviderOverride(t *testing
123125 t.Fatalf("cross-provider spec inherited transport: %#v", spec)
124126 }
125127 }
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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close