Files
goclaw/internal/sandbox/docker_mounts.go
T
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

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
}