Reject subagent delegation under a Writable write-set
Why: review M5 - delegate_task spawned sub-agents outside the parent's Writable enforcement, the same unenforceability preflight already rejects for exec/MCP/host tools. Evidence: preflight rejection, delegation-still-works and legacy cases pass (verify-m5.json, verify-m5-host.json exit=0). Impact: preflight check only; empty Writable behavior unchanged. Refs: feature/audit-followups-pin-gate-m5
This commit is contained in:
@@ -212,8 +212,9 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result,
|
||||
}
|
||||
|
||||
writable := writableFrom(ctx)
|
||||
// F02: a non-empty Writable cannot be enforced for exec/MCP/host tools —
|
||||
// reject in preflight rather than pretend the write-set holds.
|
||||
// F02: a non-empty Writable cannot be enforced for exec/MCP/host tools or
|
||||
// subagent delegation — reject in preflight rather than pretend the
|
||||
// write-set holds.
|
||||
if len(writable) > 0 && profile == exec.ToolProfileWorkspace {
|
||||
if b.opts.Exec != nil {
|
||||
return preflightFail("writable write-set cannot be enforced when exec tools are enabled"), nil
|
||||
@@ -224,6 +225,9 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result,
|
||||
if len(b.opts.HostTools) > 0 {
|
||||
return preflightFail("writable write-set cannot be enforced when host tools are enabled"), nil
|
||||
}
|
||||
if b.opts.Subagent != nil {
|
||||
return preflightFail("writable write-set cannot be enforced when subagent delegation is enabled"), nil
|
||||
}
|
||||
}
|
||||
var reg *tools.Registry
|
||||
if profile == exec.ToolProfileWorkspace {
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package native
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"agent-native/exec"
|
||||
"agent-native/runtime/llm"
|
||||
"agent-native/runtime/subagent"
|
||||
)
|
||||
|
||||
func TestContractWritableRejectsSubagentInPreflight(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
streamCalls := 0
|
||||
inner := script(llm.Message{Role: llm.RoleAssistant, Content: "x", StopReason: llm.StopEnd})
|
||||
factoryCalls := 0
|
||||
b := New(Options{
|
||||
ModelOptions: ModelOptions{
|
||||
Model: "fake",
|
||||
Stream: func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
|
||||
streamCalls++
|
||||
return inner(ctx, model, messages, decls, opts)
|
||||
},
|
||||
},
|
||||
ToolOptions: ToolOptions{
|
||||
Subagent: func(ctx context.Context, req subagent.Request) (subagent.Result, error) {
|
||||
factoryCalls++
|
||||
return subagent.Result{Success: true}, nil
|
||||
},
|
||||
},
|
||||
}).Contract()
|
||||
res, err := b.Execute(context.Background(), exec.ContractRequest{
|
||||
ExecutionID: "e-sub",
|
||||
Prompt: "x",
|
||||
Workspace: exec.WorkspaceHandle{
|
||||
Root: dir,
|
||||
Writable: []string{"a.md"},
|
||||
},
|
||||
ToolPolicy: exec.ToolPolicy{Profile: exec.ToolProfileWorkspace},
|
||||
Targets: []string{"a.md"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Outcome == exec.OutcomeSucceeded {
|
||||
t.Fatal("Writable + subagent delegation enabled must be rejected in preflight")
|
||||
}
|
||||
const want = "writable write-set cannot be enforced when subagent delegation is enabled"
|
||||
if res.Err == nil || res.Err.Reason != want {
|
||||
t.Fatalf("want reason %q, got %+v", want, res.Err)
|
||||
}
|
||||
if streamCalls != 0 || factoryCalls != 0 {
|
||||
t.Fatalf("preflight must stop before model/factory: stream=%d factory=%d", streamCalls, factoryCalls)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "a.md")); !os.IsNotExist(err) {
|
||||
t.Fatal("deliverable a.md must not be created when preflight rejects")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, ".agentx")); !os.IsNotExist(err) {
|
||||
t.Fatal("no session state must be written when preflight rejects")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContractSubagentWithoutWritableStillDelegates(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
var delegated *subagent.Request
|
||||
stream, c := captureStream(
|
||||
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
||||
ID: "c1", Name: "delegate_task",
|
||||
Arguments: `{"goal":"查证数据","write_scope":"research/","done_criteria":"三来源"}`,
|
||||
}}},
|
||||
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
||||
ID: "c2", Name: "write_file",
|
||||
Arguments: `{"path":"output.md","content":"done"}`,
|
||||
}}},
|
||||
llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd},
|
||||
)
|
||||
b := New(Options{
|
||||
ModelOptions: ModelOptions{Model: "fake", Stream: stream},
|
||||
ToolOptions: ToolOptions{
|
||||
Subagent: func(ctx context.Context, req subagent.Request) (subagent.Result, error) {
|
||||
delegated = &req
|
||||
return subagent.Result{Success: true, Text: "结论已查明"}, nil
|
||||
},
|
||||
},
|
||||
}).Contract()
|
||||
res, err := b.Execute(context.Background(), exec.ContractRequest{
|
||||
ExecutionID: "e-sub-ok",
|
||||
Prompt: "分工",
|
||||
Workspace: exec.WorkspaceHandle{Root: dir},
|
||||
ToolPolicy: exec.ToolPolicy{Profile: exec.ToolProfileWorkspace},
|
||||
Targets: []string{"output.md"},
|
||||
})
|
||||
if err != nil || res.Outcome != exec.OutcomeSucceeded {
|
||||
t.Fatalf("execute: err=%v res=%+v", err, res)
|
||||
}
|
||||
if delegated == nil || delegated.Goal != "查证数据" {
|
||||
t.Fatalf("factory not invoked with fields: %+v", delegated)
|
||||
}
|
||||
sawDelegate := false
|
||||
for _, names := range c.toolDecls {
|
||||
for _, n := range names {
|
||||
if n == "delegate_task" {
|
||||
sawDelegate = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !sawDelegate {
|
||||
t.Fatal("delegate_task must be declared when Subagent factory is set and Writable is empty")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user