diff --git a/internal/specialist/exec.go b/internal/specialist/exec.go index 5a6f183e..6dac1d25 100644 --- a/internal/specialist/exec.go +++ b/internal/specialist/exec.go @@ -25,6 +25,7 @@ import ( const ( sessionTagSpecialist = "specialist" promptFileThresholdBytes = 4 * 1024 + maxSpecialistDepth = 8 // hard cap to prevent infinite recursion/resource exhaustion via Task ) const SessionTagSpecialist = sessionTagSpecialist @@ -218,6 +219,13 @@ func (executor Executor) Run(ctx context.Context, params TaskParameters, options if options.CurrentDepth < 0 { return ExecResult{}, fmt.Errorf("current depth cannot be negative") } + // >= (not >): this Run call spawns a CHILD at options.CurrentDepth+1, so a + // parent already AT the cap must still be rejected here rather than being + // allowed to launch one more level before the child's own next call trips + // the guard. + if options.CurrentDepth >= maxSpecialistDepth { + return ExecResult{}, fmt.Errorf("spawning a specialist at depth %d would exceed maximum nesting depth %d", options.CurrentDepth+1, maxSpecialistDepth) + } if strings.TrimSpace(params.Prompt) == "" { return ExecResult{}, fmt.Errorf("specialist prompt is required") } diff --git a/internal/specialist/exec_test.go b/internal/specialist/exec_test.go index e25ed7c4..b0de1b6a 100644 --- a/internal/specialist/exec_test.go +++ b/internal/specialist/exec_test.go @@ -1,6 +1,7 @@ package specialist import ( + "context" "os" "path/filepath" "reflect" @@ -336,6 +337,39 @@ func TestBuildArgsRejectsInvalidInputs(t *testing.T) { } } +func TestRunRejectsDepthExceedingMax(t *testing.T) { + _, err := (Executor{}).Run(context.Background(), TaskParameters{ + Prompt: "hi", + }, TaskRunOptions{CurrentDepth: maxSpecialistDepth + 1}) + if err == nil || !strings.Contains(err.Error(), "depth") { + t.Fatalf("Run error = %v, want depth error", err) + } +} + +// TestRunRejectsDepthAtMax covers the boundary: a parent already AT the cap +// must be rejected too, since this Run call would launch a child one level +// past it (--depth CurrentDepth+1). Only checking ">" here would let that +// child start before the guard ever caught it. The guard sits before the +// fresh/resume branch in Run, so both call shapes must be proven to reject +// here rather than falling through to runFresh/runResume. +func TestRunRejectsDepthAtMax(t *testing.T) { + tests := []struct { + name string + params TaskParameters + }{ + {name: "fresh", params: TaskParameters{Prompt: "hi"}}, + {name: "resume", params: TaskParameters{Prompt: "hi", Resume: "child_session"}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + _, err := (Executor{}).Run(context.Background(), tc.params, TaskRunOptions{CurrentDepth: maxSpecialistDepth}) + if err == nil || !strings.Contains(err.Error(), "depth") { + t.Fatalf("Run error = %v, want depth error", err) + } + }) + } +} + func TestBuildArgsRejectsInvalidSessionIDs(t *testing.T) { _, err := (Executor{NewSessionID: func() (string, error) { return "../escape", nil }}).BuildArgs(BuildArgsInput{ Prompt: "hi",