diff --git a/internal/agent/loop.go b/internal/agent/loop.go index 35502b654..cc813ebe0 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -2368,6 +2368,21 @@ func ToolAdvertised(tool tools.Tool, permissionMode PermissionMode) bool { if permissionMode == PermissionModeAuto { return tool.Safety().Permission == tools.PermissionAllow || tool.Safety().AdvertiseInAuto } + if permissionMode == PermissionModeMemberAuto { + // Like Auto, plus the in-workspace mutators a headless member needs to + // build. The sandbox engine still decides at call time: in-workspace writes + // and sandbox-backed shell auto-allow, while out-of-workspace writes, + // network, and destructive commands prompt → denied headless. So this + // advertises capability without widening sandbox authority. + if tool.Safety().Permission == tools.PermissionAllow || tool.Safety().AdvertiseInAuto { + return true + } + switch tool.Safety().SideEffect { + case tools.SideEffectWrite, tools.SideEffectShell: + return true + } + return false + } return true } diff --git a/internal/agent/member_auto_test.go b/internal/agent/member_auto_test.go new file mode 100644 index 000000000..9ce43849d --- /dev/null +++ b/internal/agent/member_auto_test.go @@ -0,0 +1,51 @@ +package agent + +import ( + "context" + "testing" + + "github.com/Gitlawb/zero/internal/tools" +) + +type safetyTool struct { + name string + safety tools.Safety +} + +func (t safetyTool) Name() string { return t.name } +func (t safetyTool) Description() string { return "test tool" } +func (t safetyTool) Parameters() tools.Schema { + return tools.Schema{Type: "object", AdditionalProperties: false} +} +func (t safetyTool) Safety() tools.Safety { return t.safety } +func (t safetyTool) Run(context.Context, map[string]any) tools.Result { + return tools.Result{Status: tools.StatusOK} +} + +// member-auto advertises the in-workspace mutators a headless member needs to +// build (write/edit + shell) that plain Auto hides, but NOT network or denied +// tools — the sandbox still gates the advertised ones at call time. +func TestToolAdvertisedMemberAuto(t *testing.T) { + write := safetyTool{name: "write_file", safety: tools.Safety{SideEffect: tools.SideEffectWrite, Permission: tools.PermissionPrompt}} + shell := safetyTool{name: "bash", safety: tools.Safety{SideEffect: tools.SideEffectShell, Permission: tools.PermissionPrompt}} + read := safetyTool{name: "read_file", safety: tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow}} + network := safetyTool{name: "net_tool", safety: tools.Safety{SideEffect: tools.SideEffectNetwork, Permission: tools.PermissionPrompt}} + denied := safetyTool{name: "blocked", safety: tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionDeny}} + + // Plain Auto hides prompt-requiring mutators (the read-only member problem). + if ToolAdvertised(write, PermissionModeAuto) || ToolAdvertised(shell, PermissionModeAuto) { + t.Fatal("Auto must NOT advertise write/shell prompt tools") + } + + for _, tool := range []tools.Tool{write, shell, read} { + if !ToolAdvertised(tool, PermissionModeMemberAuto) { + t.Fatalf("member-auto must advertise %q", tool.Name()) + } + } + if ToolAdvertised(network, PermissionModeMemberAuto) { + t.Fatal("member-auto must NOT advertise a network prompt tool") + } + if ToolAdvertised(denied, PermissionModeMemberAuto) { + t.Fatal("member-auto must NOT advertise a denied tool") + } +} diff --git a/internal/agent/types.go b/internal/agent/types.go index 7528368eb..93761f194 100644 --- a/internal/agent/types.go +++ b/internal/agent/types.go @@ -24,6 +24,15 @@ const ( PermissionModeAsk PermissionMode = "ask" PermissionModeUnsafe PermissionMode = "unsafe" PermissionModeSpecDraft PermissionMode = "spec-draft" + // PermissionModeMemberAuto is a headless mode for swarm/specialist MEMBERS: it + // advertises the in-workspace mutators a member needs to build (write/edit + + // shell) on top of the Auto set, while the sandbox engine still gates them at + // call time — in-workspace writes and sandbox-backed shell auto-allow, but + // out-of-workspace writes, network, and destructive commands still prompt (and + // a headless member has no approver, so they are denied). It normalizes to Auto + // everywhere except ToolAdvertised, so authority is never widened beyond what an + // interactive auto agent already has inside the sandbox. + PermissionModeMemberAuto PermissionMode = "member-auto" ) type StopReason string diff --git a/internal/cli/exec_test.go b/internal/cli/exec_test.go index 356a29210..a5ae12f3c 100644 --- a/internal/cli/exec_test.go +++ b/internal/cli/exec_test.go @@ -505,6 +505,49 @@ func TestRunExecModeToolFilterReflectedInListTools(t *testing.T) { } } +func TestResolveExecPermissionModeMember(t *testing.T) { + cases := []struct { + autonomy string + want agent.PermissionMode + }{ + {"", agent.PermissionModeAuto}, + {"low", agent.PermissionModeAuto}, + {"medium", agent.PermissionModeAuto}, + {"member", agent.PermissionModeMemberAuto}, // headless members: write + sandboxed shell + {"high", agent.PermissionModeUnsafe}, + } + for _, c := range cases { + got, err := resolveExecPermissionMode(execOptions{autonomy: c.autonomy}) + if err != nil { + t.Fatalf("resolveExecPermissionMode(%q): %v", c.autonomy, err) + } + if got != c.want { + t.Errorf("resolveExecPermissionMode(%q) = %q, want %q", c.autonomy, got, c.want) + } + } + if _, err := resolveExecPermissionMode(execOptions{autonomy: "bogus"}); err == nil { + t.Fatal("an unknown autonomy level must still be rejected") + } +} + +// A member-auto headless tool list must include the in-workspace mutators that +// plain Auto hides, so a swarm member can actually build. Match the tool ENTRY +// line (" write_file [") — a bare substring would false-match tool descriptions. +func TestExecMemberAutoToolListIncludesMutators(t *testing.T) { + registry := newCoreRegistry(t.TempDir()) + const writeEntry = "\n write_file [" + + member := formatExecToolList(registry, execOptions{}, agent.PermissionModeMemberAuto) + if !strings.Contains(member, writeEntry) { + t.Fatalf("member-auto tool list must include write_file, got %q", member) + } + // Plain Auto must still hide it (unchanged behavior — this is the read-only gate). + auto := formatExecToolList(registry, execOptions{}, agent.PermissionModeAuto) + if strings.Contains(auto, writeEntry) { + t.Fatalf("plain Auto must still hide write_file, got %q", auto) + } +} + func TestRunExecAcceptsLegacyModelProfileFlags(t *testing.T) { exitCode, stdout, stderr := runExecWithEcho(t, []string{ "exec", diff --git a/internal/cli/exec_tools.go b/internal/cli/exec_tools.go index 460e65027..d019a4250 100644 --- a/internal/cli/exec_tools.go +++ b/internal/cli/exec_tools.go @@ -81,6 +81,11 @@ func resolveExecPermissionMode(options execOptions) (agent.PermissionMode, error switch strings.ToLower(strings.TrimSpace(options.autonomy)) { case "", "low", "medium": mode = agent.PermissionModeAuto + case "member": + // Internal autonomy for headless swarm/specialist members: Auto plus + // advertised in-workspace mutators (see PermissionModeMemberAuto). The + // swarm launcher sets this; it is not part of the public low|medium|high set. + mode = agent.PermissionModeMemberAuto case "high": mode = agent.PermissionModeUnsafe default: diff --git a/internal/specialist/exec.go b/internal/specialist/exec.go index 6c474da05..b01722ec0 100644 --- a/internal/specialist/exec.go +++ b/internal/specialist/exec.go @@ -66,6 +66,12 @@ type BuildArgsInput struct { // fail-safe "low", so a caller that forgets to wire it never escalates the // child to unsafe. Authority is therefore never widened beyond the parent. PermissionMode string + // MemberAutonomy marks a headless swarm member: when set and the parent is + // non-unsafe, the child runs at "--auto member" (PermissionModeMemberAuto) so + // it can write/edit + run sandboxed shell IN the workspace, instead of the + // read-only "--auto low". Off by default, so the Task tool's specialists are + // unchanged. The sandbox still confines writes to the workspace root. + MemberAutonomy bool } type BuildResumeArgsInput struct { @@ -110,6 +116,10 @@ type TaskRunOptions struct { // other value is fail-safe "low", so the child never gains more authority // than the parent. PermissionMode string + // MemberAutonomy marks a headless swarm member so it can write/edit + run + // sandboxed shell in the workspace (see BuildArgsInput.MemberAutonomy). Off + // for Task-tool specialists. + MemberAutonomy bool // Progress, when set, is called with each stream-json event emitted by the // child process while it runs. nil is a no-op. Progress func(streamjson.Event) @@ -131,6 +141,19 @@ func specialistAutonomy(permissionMode string) string { } } +// memberAwareAutonomy is specialistAutonomy with one extra rung for headless +// swarm MEMBERS: a non-unsafe member runs at "member" (PermissionModeMemberAuto) +// so it can write/edit + run sandboxed shell in the workspace, rather than the +// read-only "low". An unsafe parent still yields "high" (full unsafe), and a +// non-member (Task specialist) is unchanged. Authority stays sandbox-confined. +func memberAwareAutonomy(permissionMode string, member bool) string { + autonomy := specialistAutonomy(permissionMode) + if member && autonomy == "low" { + return "member" + } + return autonomy +} + // permissionModeUnsafe mirrors agent.PermissionModeUnsafe without importing the // agent package (which would create an import cycle): exec resolves "--auto high" // to this mode. @@ -227,7 +250,7 @@ func (executor Executor) BuildArgs(input BuildArgsInput) (BuildArgsResult, error args := []string{"exec", "--init-session-id", sessionID} args = append(args, promptArgs...) args = appendModelArgs(args, input.Manifest, input.ParentModel, input.ParentReasoningEffort) - args = append(args, "--auto", specialistAutonomy(input.PermissionMode), "--output-format", "stream-json") + args = append(args, "--auto", memberAwareAutonomy(input.PermissionMode, input.MemberAutonomy), "--output-format", "stream-json") toolAllowlist, err := resolvedToolAllowlist(input.Manifest) if err != nil { return BuildArgsResult{}, err @@ -311,6 +334,7 @@ func (executor Executor) runFresh(ctx context.Context, params TaskParameters, op Description: params.Description, Cwd: options.Cwd, PermissionMode: options.PermissionMode, + MemberAutonomy: options.MemberAutonomy, }) if err != nil { return ExecResult{}, err diff --git a/internal/specialist/exec_test.go b/internal/specialist/exec_test.go index f93df595a..e25ed7c4e 100644 --- a/internal/specialist/exec_test.go +++ b/internal/specialist/exec_test.go @@ -80,6 +80,61 @@ func TestSpecialistAutonomyByPermissionMode(t *testing.T) { } } +func TestMemberAwareAutonomy(t *testing.T) { + // A non-unsafe member runs at "member" (write/edit + sandboxed shell), a plain + // specialist at "low" (read-only), and an unsafe parent at "high" either way. + cases := []struct { + mode string + member bool + want string + }{ + {"auto", false, "low"}, + {"ask", false, "low"}, + {"auto", true, "member"}, + {"ask", true, "member"}, + {"", true, "member"}, // member, fail-safe non-unsafe, still write-capable + {"unsafe", true, "high"}, // unsafe parent keeps full autonomy + {"unsafe", false, "high"}, + } + for _, c := range cases { + if got := memberAwareAutonomy(c.mode, c.member); got != c.want { + t.Errorf("memberAwareAutonomy(%q, %v) = %q, want %q", c.mode, c.member, got, c.want) + } + } +} + +func TestBuildArgsMemberAutonomyEmitsMember(t *testing.T) { + executor := Executor{NewSessionID: func() (string, error) { return "child", nil }} + manifest := Manifest{Metadata: Metadata{Name: "subagent"}, SystemPrompt: "x", ResolvedTools: []string{"read_file", "write_file"}} + + // A non-unsafe member → --auto member (write-capable), not the read-only low. + res, err := executor.BuildArgs(BuildArgsInput{Manifest: manifest, Prompt: "p", PermissionMode: "auto", MemberAutonomy: true}) + if err != nil { + t.Fatalf("BuildArgs: %v", err) + } + if !containsSequence(res.Args, []string{"--auto", "member"}) { + t.Fatalf("non-unsafe member must yield --auto member, got %v", res.Args) + } + + // Without the member flag, the same parent stays --auto low (unchanged). + plain, err := executor.BuildArgs(BuildArgsInput{Manifest: manifest, Prompt: "p", PermissionMode: "auto"}) + if err != nil { + t.Fatalf("BuildArgs(plain): %v", err) + } + if !containsSequence(plain.Args, []string{"--auto", "low"}) || containsSequence(plain.Args, []string{"--auto", "member"}) { + t.Fatalf("a plain specialist must stay --auto low, got %v", plain.Args) + } + + // An unsafe member still runs --auto high, never downgraded to member. + unsafe, err := executor.BuildArgs(BuildArgsInput{Manifest: manifest, Prompt: "p", PermissionMode: "unsafe", MemberAutonomy: true}) + if err != nil { + t.Fatalf("BuildArgs(unsafe member): %v", err) + } + if !containsSequence(unsafe.Args, []string{"--auto", "high"}) { + t.Fatalf("unsafe member must yield --auto high, got %v", unsafe.Args) + } +} + func TestBuildArgsAutonomyHonorsPermissionMode(t *testing.T) { executor := Executor{NewSessionID: func() (string, error) { return "child", nil }} manifest := Manifest{Metadata: Metadata{Name: "reviewer"}, SystemPrompt: "x", ResolvedTools: []string{"read_file"}} diff --git a/internal/swarm/launcher_specialist.go b/internal/swarm/launcher_specialist.go index c986c06af..b7da8f7ab 100644 --- a/internal/swarm/launcher_specialist.go +++ b/internal/swarm/launcher_specialist.go @@ -47,6 +47,11 @@ func NewSpecialistLauncher(executor specialist.Executor) MemberLauncher { ParentModel: spec.Model, Cwd: spec.Cwd, PermissionMode: permissionMode, + // A swarm member runs headless and is meant to do real work, so it + // gets in-workspace write/edit + sandboxed shell (member autonomy) + // rather than the read-only "low" a plain specialist child would get. + // Still clamped to non-unsafe and sandbox-confined to the workspace. + MemberAutonomy: true, }) if err != nil { return MemberResult{}, err