Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions internal/agent/loop.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
51 changes: 51 additions & 0 deletions internal/agent/member_auto_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
9 changes: 9 additions & 0 deletions internal/agent/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,15 @@
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
Expand Down Expand Up @@ -271,7 +280,7 @@
// Truncated reports whether the final response ended abnormally (cut off at the
// output token cap or withheld by a content filter) rather than completing
// naturally. Callers can use it to warn the user that FinalAnswer is incomplete.
func (result Result) Truncated() bool {

Check failure on line 283 in internal/agent/types.go

View workflow job for this annotation

GitHub Actions / Security & code health

unreachable func: Result.Truncated
return result.FinishReason != ""
}

Expand Down
43 changes: 43 additions & 0 deletions internal/cli/exec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions internal/cli/exec_tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
26 changes: 25 additions & 1 deletion internal/specialist/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
55 changes: 55 additions & 0 deletions internal/specialist/exec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}}
Expand Down
5 changes: 5 additions & 0 deletions internal/swarm/launcher_specialist.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading