mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-11 03:13:24 +00:00
* 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>
187 lines
5.7 KiB
Go
187 lines
5.7 KiB
Go
package sandbox
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
const redactedMountRoot = "[REDACTED_MOUNT_ROOT]"
|
|
|
|
type mountPathResolver func(context.Context, string) string
|
|
|
|
func validateReadOnlyMounts(cfg Config, mounts []ReadOnlyMount) ([]ReadOnlyMount, error) {
|
|
if len(mounts) == 0 {
|
|
return nil, nil
|
|
}
|
|
if cfg.WorkspaceAccess == AccessNone {
|
|
return nil, fmt.Errorf("read-only mounts require sandbox workspace access")
|
|
}
|
|
|
|
workdir := cfg.ContainerWorkdir()
|
|
if !path.IsAbs(workdir) || path.Clean(workdir) != workdir {
|
|
return nil, fmt.Errorf("sandbox workdir must be an absolute canonical path")
|
|
}
|
|
|
|
normalized := append([]ReadOnlyMount(nil), mounts...)
|
|
names := make(map[string]struct{}, len(normalized))
|
|
destinations := make(map[string]struct{}, len(normalized))
|
|
|
|
for i := range normalized {
|
|
mount := &normalized[i]
|
|
if mount.Name == "" || strings.TrimSpace(mount.Name) != mount.Name || strings.ContainsRune(mount.Name, '\x00') {
|
|
return nil, fmt.Errorf("read-only mount %d has an invalid name", i)
|
|
}
|
|
if _, exists := names[mount.Name]; exists {
|
|
return nil, fmt.Errorf("read-only mounts must have unique names")
|
|
}
|
|
names[mount.Name] = struct{}{}
|
|
|
|
if !filepath.IsAbs(mount.HostPath) || filepath.Clean(mount.HostPath) != mount.HostPath {
|
|
return nil, fmt.Errorf("read-only mount %d host path must be absolute and canonical", i)
|
|
}
|
|
canonicalHost, err := filepath.EvalSymlinks(mount.HostPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read-only mount %d host path cannot be resolved", i)
|
|
}
|
|
if canonicalHost != mount.HostPath {
|
|
return nil, fmt.Errorf("read-only mount %d host path must be absolute and canonical", i)
|
|
}
|
|
info, err := os.Stat(mount.HostPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read-only mount %d host path cannot be inspected", i)
|
|
}
|
|
if !info.IsDir() {
|
|
return nil, fmt.Errorf("read-only mount %d host path must be a directory", i)
|
|
}
|
|
|
|
if strings.ContainsRune(mount.Destination, '\x00') ||
|
|
!path.IsAbs(mount.Destination) ||
|
|
path.Clean(mount.Destination) != mount.Destination {
|
|
return nil, fmt.Errorf("read-only mount %d destination must be absolute and canonical", i)
|
|
}
|
|
if !containerPathStrictlyWithin(workdir, mount.Destination) {
|
|
return nil, fmt.Errorf("read-only mount %d destination must be strictly beneath sandbox workdir", i)
|
|
}
|
|
if _, exists := destinations[mount.Destination]; exists {
|
|
return nil, fmt.Errorf("read-only mounts must have unique destinations")
|
|
}
|
|
destinations[mount.Destination] = struct{}{}
|
|
}
|
|
|
|
sort.Slice(normalized, func(i, j int) bool {
|
|
if normalized[i].Name != normalized[j].Name {
|
|
return normalized[i].Name < normalized[j].Name
|
|
}
|
|
if normalized[i].Destination != normalized[j].Destination {
|
|
return normalized[i].Destination < normalized[j].Destination
|
|
}
|
|
return normalized[i].HostPath < normalized[j].HostPath
|
|
})
|
|
return normalized, nil
|
|
}
|
|
|
|
func containerPathStrictlyWithin(root, target string) bool {
|
|
if target == root {
|
|
return false
|
|
}
|
|
return strings.HasPrefix(target, strings.TrimSuffix(root, "/")+"/")
|
|
}
|
|
|
|
func appendDockerMountArgs(
|
|
ctx context.Context,
|
|
args []string,
|
|
cfg Config,
|
|
workspace string,
|
|
mounts []ReadOnlyMount,
|
|
resolve mountPathResolver,
|
|
) ([]string, []string, error) {
|
|
normalized, err := validateReadOnlyMounts(cfg, mounts)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if resolve == nil {
|
|
resolve = func(_ context.Context, path string) string { return path }
|
|
}
|
|
|
|
sensitiveRoots := make([]string, 0, 2+len(normalized)*2)
|
|
containerWorkdir := cfg.ContainerWorkdir()
|
|
if workspace != "" && cfg.WorkspaceAccess != AccessNone {
|
|
mountOpt := "rw"
|
|
if cfg.WorkspaceAccess == AccessRO {
|
|
mountOpt = "ro"
|
|
}
|
|
hostPath := resolve(ctx, workspace)
|
|
sensitiveRoots = appendSensitiveRoot(sensitiveRoots, workspace)
|
|
sensitiveRoots = appendSensitiveRoot(sensitiveRoots, hostPath)
|
|
args = append(args, "-v", fmt.Sprintf("%s:%s:%s", hostPath, containerWorkdir, mountOpt))
|
|
}
|
|
|
|
for _, mount := range normalized {
|
|
hostPath := resolve(ctx, mount.HostPath)
|
|
sensitiveRoots = appendSensitiveRoot(sensitiveRoots, mount.HostPath)
|
|
sensitiveRoots = appendSensitiveRoot(sensitiveRoots, hostPath)
|
|
args = append(args, "-v", fmt.Sprintf("%s:%s:ro", hostPath, mount.Destination))
|
|
}
|
|
return args, sensitiveRoots, nil
|
|
}
|
|
|
|
func appendSensitiveRoot(roots []string, root string) []string {
|
|
if root == "" {
|
|
return roots
|
|
}
|
|
if slices.Contains(roots, root) {
|
|
return roots
|
|
}
|
|
return append(roots, root)
|
|
}
|
|
|
|
func redactMountRoots(message string, roots []string) string {
|
|
sorted := append([]string(nil), roots...)
|
|
sort.Slice(sorted, func(i, j int) bool {
|
|
return len(sorted[i]) > len(sorted[j])
|
|
})
|
|
for _, root := range sorted {
|
|
if root != "" {
|
|
message = strings.ReplaceAll(message, root, redactedMountRoot)
|
|
}
|
|
}
|
|
return message
|
|
}
|
|
|
|
func dockerCacheKey(key, workspace string, cfg Config, mounts ...ReadOnlyMount) string {
|
|
if workspace == "" && len(mounts) == 0 {
|
|
return key
|
|
}
|
|
|
|
orderedMounts := append([]ReadOnlyMount(nil), mounts...)
|
|
sort.Slice(orderedMounts, func(i, j int) bool {
|
|
if orderedMounts[i].Name != orderedMounts[j].Name {
|
|
return orderedMounts[i].Name < orderedMounts[j].Name
|
|
}
|
|
if orderedMounts[i].Destination != orderedMounts[j].Destination {
|
|
return orderedMounts[i].Destination < orderedMounts[j].Destination
|
|
}
|
|
return orderedMounts[i].HostPath < orderedMounts[j].HostPath
|
|
})
|
|
|
|
parts := []string{
|
|
workspace,
|
|
string(cfg.WorkspaceAccess),
|
|
cfg.ContainerWorkdir(),
|
|
cfg.Image,
|
|
}
|
|
for _, mount := range orderedMounts {
|
|
parts = append(parts, mount.Name, mount.HostPath, mount.Destination, "ro")
|
|
}
|
|
h := sha256.Sum256([]byte(strings.Join(parts, "\x00")))
|
|
return "w" + hex.EncodeToString(h[:])[:16] + ":" + key
|
|
}
|