Files
goclaw/internal/tools/shell_delegation_boundary_test.go
Duc Nguyenandntduc bb7712a9ff fix(collaboration): harden delegated task isolation (#1486)
* feat(collaboration): isolate delegated artifacts and child runs

Isolate delegated inputs and outputs behind secure artifact exchange lifecycles. Scope Agent Link tasks by tenant and root agent, and enforce delegation spawn-tree boundaries. Add process-wide child-run admission and preserve logical media paths across native, MCP, and sandbox execution.

* fix(collaboration): harden delegated task isolation

Enforce tenant and root-agent task scope across migrations and stores. Add exactly-once async completion delivery, delegated sandbox boundaries, and confined artifact and media recovery across runtime surfaces.

* fix(collaboration): recover interrupted async tasks

* fix(collaboration): normalize persisted child-run status

---------

Co-authored-by: ntduc <ntduc@cpp.ai.vn>
2026-07-30 14:17:40 +07:00

190 lines
6.3 KiB
Go

package tools
import (
"context"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"testing"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/sandbox"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
func TestExecDelegationArtifactRejectsNativeHostRead(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX shell command fixture")
}
ctx, _, outputs := delegationArtifactToolContext(t)
outsideDir := t.TempDir()
outsideFile := filepath.Join(outsideDir, "outside.txt")
const outsideContents = "delegated-native-host-read"
if err := os.WriteFile(outsideFile, []byte(outsideContents), 0o600); err != nil {
t.Fatal(err)
}
result := NewExecTool(outputs, true).Execute(ctx, map[string]any{
"command": "cat " + strconv.Quote(outsideFile),
})
if !result.IsError {
t.Fatalf("delegated native exec read an outside host file: %#v", result)
}
if result.ForLLM != delegatedExecSandboxRequiredError {
t.Fatalf("error = %q, want %q", result.ForLLM, delegatedExecSandboxRequiredError)
}
if strings.Contains(result.ForLLM, outsideContents) {
t.Fatal("delegated native exec exposed outside file contents")
}
}
func TestExecDelegationArtifactRejectsCredentialedHostPathBeforeLookup(t *testing.T) {
ctx, _, outputs := delegationArtifactToolContext(t)
secureCLIStore := newStubSecureCLIStore()
secureCLIStore.byName["delegated-cli"] = &store.SecureCLIBinary{
BinaryName: "delegated-cli",
TimeoutSeconds: 30,
Enabled: true,
IsGlobal: true,
}
tool := NewExecTool(outputs, true)
tool.SetSecureCLIStore(secureCLIStore)
result := tool.Execute(ctx, map[string]any{"command": "delegated-cli status"})
if !result.IsError || result.ForLLM != delegatedExecSandboxRequiredError {
t.Fatalf("delegated credentialed exec result = %#v, want stable sandbox-required error", result)
}
secureCLIStore.mu.Lock()
lookupCalls := secureCLIStore.lookupCalls
secureCLIStore.mu.Unlock()
if lookupCalls != 0 {
t.Fatalf("credential lookup calls = %d, want guard before credentialed routing", lookupCalls)
}
}
func TestExecDelegationArtifactRejectsMissingSandboxKey(t *testing.T) {
ctx, _, outputs := delegationArtifactToolContext(t)
manager := &recordingSandboxManager{}
tool := NewSandboxedExecTool(outputs, true, manager)
result := tool.Execute(ctx, map[string]any{"command": "echo should-not-run"})
if !result.IsError || result.ForLLM != delegatedExecSandboxRequiredError {
t.Fatalf("delegated exec without sandbox key = %#v, want stable sandbox-required error", result)
}
if manager.key != "" {
t.Fatalf("sandbox manager called with key %q despite missing context key", manager.key)
}
}
func TestExecDelegationArtifactUsesSandboxWithManagerAndKey(t *testing.T) {
ctx, outputs := delegatedExecSandboxContext(t)
ctx = WithToolSandboxKey(ctx, "delegated-session")
manager := &recordingSandboxManager{}
tool := NewSandboxedExecTool(outputs, true, manager)
result := tool.Execute(ctx, map[string]any{"command": "echo sandboxed"})
if result.IsError {
t.Fatalf("delegated sandbox exec failed: %s", result.ForLLM)
}
if manager.key != "delegated-session" {
t.Fatalf("sandbox manager key = %q, want delegated-session", manager.key)
}
if got := strings.Join(manager.sandbox.command, " "); got != "sh -c echo sandboxed" {
t.Fatalf("sandbox command = %q, want sandbox shell path", got)
}
}
type disabledDelegationSandboxManager struct {
getCalls int
}
func (m *disabledDelegationSandboxManager) Get(context.Context, string, string, *sandbox.Config, ...sandbox.GetOption) (sandbox.Sandbox, error) {
m.getCalls++
return nil, sandbox.ErrSandboxDisabled
}
func (*disabledDelegationSandboxManager) Release(context.Context, string) error { return nil }
func (*disabledDelegationSandboxManager) ReleaseAll(context.Context) error { return nil }
func (*disabledDelegationSandboxManager) Stop() {}
func (*disabledDelegationSandboxManager) Stats() map[string]any { return nil }
func TestExecDelegationArtifactDoesNotFallbackWhenSandboxDisabled(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX touch command fixture")
}
ctx, outputs := delegatedExecSandboxContext(t)
ctx = WithToolSandboxKey(ctx, "delegated-session")
marker := filepath.Join(t.TempDir(), "process-ran")
manager := &disabledDelegationSandboxManager{}
tool := NewSandboxedExecTool(outputs, true, manager)
result := tool.Execute(ctx, map[string]any{
"command": "touch " + strconv.Quote(marker),
})
if !result.IsError || result.ForLLM != delegatedExecSandboxRequiredError {
t.Fatalf("disabled delegated sandbox result = %#v, want stable sandbox-required error", result)
}
if manager.getCalls != 1 {
t.Fatalf("sandbox manager Get calls = %d, want 1", manager.getCalls)
}
if _, err := os.Stat(marker); !os.IsNotExist(err) {
t.Fatalf("delegated exec fell back to host; marker stat error = %v", err)
}
}
func TestExecNonDelegatedSandboxDisabledStillFallsBackToHost(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX echo command fixture")
}
workspace := t.TempDir()
canonicalWorkspace, err := filepath.EvalSymlinks(workspace)
if err != nil {
t.Fatal(err)
}
manager := &disabledDelegationSandboxManager{}
tool := NewSandboxedExecTool(canonicalWorkspace, true, manager)
ctx := WithToolSandboxKey(context.Background(), "ordinary-session")
result := tool.Execute(ctx, map[string]any{"command": "echo ordinary-fallback"})
if result.IsError {
t.Fatalf("non-delegated sandbox fallback changed: %s", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "ordinary-fallback") {
t.Fatalf("non-delegated host fallback output = %q", result.ForLLM)
}
}
func delegatedExecSandboxContext(t *testing.T) (context.Context, string) {
t.Helper()
delegationID := uuid.New()
root := filepath.Join(t.TempDir(), "collaboration", "delegations", delegationID.String())
inputs := filepath.Join(root, "inputs")
outputs := filepath.Join(root, "outputs")
if err := os.MkdirAll(inputs, 0o750); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(outputs, 0o750); err != nil {
t.Fatal(err)
}
canonicalOutputs, err := filepath.EvalSymlinks(outputs)
if err != nil {
t.Fatal(err)
}
ctx := WithDelegationID(context.Background(), delegationID.String())
ctx = WithDelegationArtifactInputs(ctx, inputs)
ctx = WithToolWorkspace(ctx, canonicalOutputs)
return ctx, canonicalOutputs
}