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
113 lines
3.7 KiB
Go
113 lines
3.7 KiB
Go
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")
|
|
}
|
|
}
|