Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4183ea77d9 | ||
|
|
9ec1bf7a9d | ||
|
|
be556f4641 | ||
|
|
d1f72aa974 | ||
|
|
f2fb9ffa6d |
@@ -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 {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"agent-native/exec"
|
||||
@@ -57,6 +58,14 @@ func captureText(ctx context.Context, text string) {
|
||||
|
||||
// scopeIdentity returns a stable non-empty identity string for a non-zero
|
||||
// SessionScope, or "" for the legacy one-shot / empty-scope path.
|
||||
//
|
||||
// Encoding (collision-free when components contain "/"):
|
||||
// - Legacy (no component contains "/"): tenant + "/" + project + "/" + session
|
||||
// so existing workspaces keep the same hashed transcript filenames.
|
||||
// - Otherwise: length-prefixed concatenation of each trimmed component as
|
||||
// strconv.Itoa(len(part)) + ":" + part (no extra separators). Distinct
|
||||
// scopes such as {tenant:"a/b", project:"c"} vs {tenant:"a", project:"b/c"}
|
||||
// therefore produce different identity strings and transcript paths.
|
||||
func scopeIdentity(s exec.SessionScope) string {
|
||||
t := strings.TrimSpace(s.Tenant)
|
||||
p := strings.TrimSpace(s.Project)
|
||||
@@ -64,6 +73,11 @@ func scopeIdentity(s exec.SessionScope) string {
|
||||
if t == "" && p == "" && sess == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.Contains(t, "/") || strings.Contains(p, "/") || strings.Contains(sess, "/") {
|
||||
return strconv.Itoa(len(t)) + ":" + t +
|
||||
strconv.Itoa(len(p)) + ":" + p +
|
||||
strconv.Itoa(len(sess)) + ":" + sess
|
||||
}
|
||||
return t + "/" + p + "/" + sess
|
||||
}
|
||||
|
||||
|
||||
@@ -172,6 +172,92 @@ func TestContractOldStoreWithoutIdentityNotAdopted(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestScopeIdentitySlashCollisionFree: tenant/project values that collide under
|
||||
// naive "/" joins must yield distinct identities and transcript paths (M1).
|
||||
func TestScopeIdentitySlashCollisionFree(t *testing.T) {
|
||||
scopeAB := exec.SessionScope{Tenant: "a/b", Project: "c", Session: "s"}
|
||||
scopeAC := exec.SessionScope{Tenant: "a", Project: "b/c", Session: "s"}
|
||||
idAB := scopeIdentity(scopeAB)
|
||||
idAC := scopeIdentity(scopeAC)
|
||||
if idAB == "" || idAC == "" {
|
||||
t.Fatal("non-empty scopes must produce non-empty identities")
|
||||
}
|
||||
if idAB == idAC {
|
||||
t.Fatalf("slash-bearing scopes must not collide: both %q", idAB)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
pathAB := transcriptPathForScope(dir, idAB)
|
||||
pathAC := transcriptPathForScope(dir, idAC)
|
||||
if pathAB == pathAC {
|
||||
t.Fatalf("distinct identities must hash to distinct transcript paths: %s", pathAB)
|
||||
}
|
||||
}
|
||||
|
||||
// TestScopeIdentityLegacySlashFreeUnchanged keeps product workspaces on the
|
||||
// historical identity string (and thus transcript filename) when no component
|
||||
// contains "/".
|
||||
func TestScopeIdentityLegacySlashFreeUnchanged(t *testing.T) {
|
||||
scope := exec.SessionScope{Tenant: "t1", Project: "p1", Session: "s-a"}
|
||||
const want = "t1/p1/s-a"
|
||||
if got := scopeIdentity(scope); got != want {
|
||||
t.Fatalf("slash-free identity: got %q want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBindSessionScopeRejectsMismatchedSlashScope: exact-match check must
|
||||
// refuse when a store's recorded identity differs from the request, including
|
||||
// when both identities use the slash-safe encoding.
|
||||
func TestBindSessionScopeRejectsMismatchedSlashScope(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
scopeA := exec.SessionScope{Tenant: "a/b", Project: "c", Session: "sess"}
|
||||
scopeB := exec.SessionScope{Tenant: "a", Project: "b/c", Session: "sess"}
|
||||
idA := scopeIdentity(scopeA)
|
||||
idB := scopeIdentity(scopeB)
|
||||
if idA == idB {
|
||||
t.Fatal("precondition: slash scopes must differ")
|
||||
}
|
||||
path := transcriptPathForScope(dir, idA)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store, err := session.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.SaveMeta(map[string]string{"scope": idB}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
b := New(Options{
|
||||
ModelOptions: ModelOptions{
|
||||
Model: "fake",
|
||||
Stream: script(llm.Message{Role: llm.RoleAssistant, Content: "x", StopReason: llm.StopEnd}),
|
||||
},
|
||||
})
|
||||
if err := b.bindSessionScope(store, idA, "fake", "e-mismatch"); err == nil {
|
||||
t.Fatal("expected scope identity mismatch")
|
||||
} else if !strings.Contains(err.Error(), "scope identity mismatch") {
|
||||
t.Fatalf("want mismatch error, got %v", err)
|
||||
}
|
||||
|
||||
// Contract path must also fail-close when meta disagrees with the request scope.
|
||||
res, err := b.Contract().Execute(context.Background(), exec.ContractRequest{
|
||||
ExecutionID: "e-mismatch",
|
||||
Prompt: "hi",
|
||||
SessionScope: scopeA,
|
||||
Workspace: exec.WorkspaceHandle{Root: dir},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("transport: %v", err)
|
||||
}
|
||||
if res.Outcome == exec.OutcomeSucceeded {
|
||||
t.Fatal("mismatched slash scope must not succeed")
|
||||
}
|
||||
if res.Err == nil || !strings.Contains(res.Err.Reason, "scope identity mismatch") {
|
||||
t.Fatalf("want mismatch refusal, got %+v", res.Err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContractWritableFileToolHonorsAllowlist(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
b := New(Options{
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Vendored
+1
-1
Submodule deps/agent-intent updated: 765a7e7177...7831228114
Reference in New Issue
Block a user