Files
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

164 lines
5.0 KiB
Go

package workspace
import (
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"github.com/nextlevelbuilder/goclaw/internal/config"
)
// masterTenantID is the sentinel UUID for the master/default tenant.
// Master tenant workspaces use base dir directly (no tenants/ prefix).
// Must match config.masterTenantID in internal/config/tenant_paths.go.
const masterTenantID = "0193a5b0-7000-7000-8000-000000000001"
// defaultResolver implements Resolver for all 6 workspace scenarios.
// Stateless — all inputs come via ResolveParams. No DB queries.
// Does NOT import tools package (avoids circular dependency).
type defaultResolver struct{}
// NewResolver creates a workspace Resolver.
func NewResolver() Resolver { return &defaultResolver{} }
func (r *defaultResolver) Resolve(_ context.Context, params ResolveParams) (*WorkspaceContext, error) {
if params.BaseDir == "" {
return nil, fmt.Errorf("workspace: base dir is required")
}
// Agent Link artifact runs construct their fail-closed workspace directly
// from the admitted exchange. This resolver handles durable team/personal
// workspaces only.
switch {
case params.TeamID != nil && *params.TeamID != "":
return r.resolveTeam(params), nil
default:
return r.resolvePersonal(params), nil
}
}
// resolveTeam handles team workspace (shared or isolated).
func (r *defaultResolver) resolveTeam(p ResolveParams) *WorkspaceContext {
base := tenantPath(p.BaseDir, p.TenantID, p.TenantSlug)
teamRoot := filepath.Join(base, "teams", sanitizeSegment(*p.TeamID))
shared := p.TeamConfig.IsShared()
activePath := teamRoot
if !shared {
// Isolated: add chat/user segment
segment := sanitizeSegment(p.ChatID)
if segment == "" {
segment = sanitizeSegment(p.UserID)
}
if segment != "" {
activePath = filepath.Join(teamRoot, segment)
}
}
scope := sharingScope(p)
wc := &WorkspaceContext{
ActivePath: activePath,
Scope: ScopeTeam,
TeamPath: &teamRoot,
OwnerID: ownerID(p),
MemoryScope: scope,
KGScope: scope,
EnforcementLabel: DefaultEnforcementLabel(ScopeTeam, shared),
}
ensureDirTeam(wc.ActivePath)
return wc
}
// resolvePersonal handles open agent (per-user) and predefined agent (shared) workspaces.
func (r *defaultResolver) resolvePersonal(p ResolveParams) *WorkspaceContext {
base := tenantPath(p.BaseDir, p.TenantID, p.TenantSlug)
agentDir := filepath.Join(base, sanitizeSegment(p.AgentID))
activePath := agentDir
shared := p.AgentType == "predefined"
if !shared {
segment := userChatSegment(p)
if segment != "" {
activePath = filepath.Join(agentDir, segment)
}
}
scope := sharingScope(p)
wc := &WorkspaceContext{
ActivePath: activePath,
Scope: ScopePersonal,
OwnerID: ownerID(p),
MemoryScope: scope,
KGScope: scope,
EnforcementLabel: DefaultEnforcementLabel(ScopePersonal, shared),
}
ensureDir(wc.ActivePath)
return wc
}
// tenantPath returns tenant-scoped directory.
// Master tenant returns base dir directly (backward compat with v2).
// Uses slug when available (matches config.TenantWorkspace), falls back to
// tenantID. Sanitizes both identifiers (workspace tenant IDs/slugs are not
// always valid UUIDs, unlike config's uuid.UUID-typed call sites) before
// delegating the actual join+traversal-defense logic to the single canonical
// implementation, config.TenantScopedDir.
func tenantPath(base, tenantID, tenantSlug string) string {
return config.TenantScopedDir(base, sanitizeSegment(tenantID), sanitizeSegment(tenantSlug))
}
// userChatSegment returns the isolation segment: chatID for group, userID for direct.
func userChatSegment(p ResolveParams) string {
if p.PeerKind == "group" && p.ChatID != "" {
return sanitizeSegment(p.ChatID)
}
return sanitizeSegment(p.UserID)
}
// ownerID picks the identifying owner: userID or chatID.
func ownerID(p ResolveParams) string {
if p.UserID != "" {
return p.UserID
}
return p.ChatID
}
// sharingScope returns "shared" or "user" based on team config.
func sharingScope(p ResolveParams) string {
if p.TeamConfig.IsShared() {
return "shared"
}
return "user"
}
// sanitizeSegment makes a string safe for filesystem path use.
// Mirrors tools.SanitizePathSegment without importing tools package.
func sanitizeSegment(s string) string {
var b strings.Builder
for _, r := range s {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
b.WriteRune(r)
} else {
b.WriteByte('_')
}
}
return b.String()
}
// ensureDir creates workspace directory (0755 for personal/delegate).
func ensureDir(path string) {
if err := os.MkdirAll(path, 0755); err != nil {
slog.Warn("workspace: failed to create directory", "path", path, "err", err)
}
}
// ensureDirTeam creates team workspace directory (0750 — more restrictive).
func ensureDirTeam(path string) {
if err := os.MkdirAll(path, 0750); err != nil {
slog.Warn("workspace: failed to create team directory", "path", path, "err", err)
}
}