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

191 lines
6.2 KiB
Go

package tools
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/nextlevelbuilder/goclaw/internal/sandbox"
)
// ListFilesTool lists files in a directory, optionally through a sandbox container.
type ListFilesTool struct {
workspace string
restrict bool
allowedPrefixes []string // extra allowed path prefixes (e.g. skills dirs)
deniedPrefixes []string // path prefixes to deny access to (e.g. .goclaw)
sandboxMgr sandbox.Manager
contextFileIntc *ContextFileInterceptor // unused, satisfies InterceptorAware
memIntc *MemoryInterceptor // nil = no memory routing
}
func (t *ListFilesTool) SetContextFileInterceptor(intc *ContextFileInterceptor) {
t.contextFileIntc = intc
}
func (t *ListFilesTool) SetMemoryInterceptor(intc *MemoryInterceptor) {
t.memIntc = intc
}
// AllowPaths adds extra path prefixes that list_files is allowed to access
// even when restrict_to_workspace is true (e.g. skills directories).
func (t *ListFilesTool) AllowPaths(prefixes ...string) {
t.allowedPrefixes = append(t.allowedPrefixes, prefixes...)
}
// DenyPaths adds path prefixes that list_files must reject/filter.
func (t *ListFilesTool) DenyPaths(prefixes ...string) {
t.deniedPrefixes = append(t.deniedPrefixes, prefixes...)
}
func NewListFilesTool(workspace string, restrict bool) *ListFilesTool {
return &ListFilesTool{workspace: workspace, restrict: restrict}
}
func NewSandboxedListFilesTool(workspace string, restrict bool, mgr sandbox.Manager) *ListFilesTool {
return &ListFilesTool{workspace: workspace, restrict: restrict, sandboxMgr: mgr}
}
// SetSandboxKey is a no-op; sandbox key is now read from ctx (thread-safe).
func (t *ListFilesTool) SetSandboxKey(key string) {}
func (t *ListFilesTool) Name() string { return "list_files" }
func (t *ListFilesTool) Description() string { return "List files and directories in a path" }
func (t *ListFilesTool) Parameters() map[string]any {
return map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{
"type": "string",
"description": "Directory path (relative to workspace; omit for workspace root)",
},
},
}
}
func (t *ListFilesTool) Execute(ctx context.Context, args map[string]any) *Result {
path, _ := args["path"].(string)
if path == "" {
path = "."
}
// Virtual FS: route memory directory listing to DB
if !IsDelegationArtifactRun(ctx) && t.memIntc != nil {
if listing, handled, err := t.memIntc.ListFiles(ctx, path); handled {
if err != nil {
return ErrorResult(fmt.Sprintf("failed to list memory files: %v", err))
}
if listing == "" {
return SilentResult("No memory files stored yet")
}
return SilentResult(listing + "\n[Source: database, not filesystem]")
}
}
// Sandbox routing (sandboxKey from ctx — thread-safe)
sandboxKey := ToolSandboxKeyFromCtx(ctx)
if t.sandboxMgr != nil && sandboxKey != "" {
return t.executeInSandbox(ctx, path, sandboxKey)
}
if resolved, handled, err := resolveDelegationInputPath(ctx, path); handled {
if err != nil {
return ErrorResult("cannot access delegation input")
}
return t.executeDelegationHostList(ctx, resolved, path)
}
// Host execution — use per-user workspace from context if available
workspace := ToolWorkspaceFromCtx(ctx)
if workspace == "" {
workspace = t.workspace
}
allowed := allowedWithTeamWorkspace(ctx, t.allowedPrefixes)
resolved, err := resolvePathWithAllowed(path, workspace, effectiveRestrict(ctx, t.restrict), allowed)
if err != nil {
return ErrorResult(err.Error())
}
if err := checkDeniedPath(resolved, t.workspace, t.deniedPrefixes); err != nil {
return ErrorResult(err.Error())
}
return t.executeHostList(ctx, resolved, path)
}
func (t *ListFilesTool) executeDelegationHostList(ctx context.Context, resolved, displayPath string) *Result {
result := t.executeHostList(ctx, resolved, displayPath)
if result.IsError {
return ErrorResult("failed to list delegation input directory")
}
return result
}
func (t *ListFilesTool) executeHostList(ctx context.Context, resolved, displayPath string) *Result {
entries, err := os.ReadDir(resolved)
if err != nil {
if os.IsNotExist(err) {
msg := fmt.Sprintf("Directory does not exist: %s", displayPath)
if teamWs := ToolTeamWorkspaceFromCtx(ctx); teamWs != "" && !strings.HasPrefix(resolved, teamWs) {
msg += fmt.Sprintf("\nHint: try the team workspace path: list_files(path=\"%s/%s\")", teamWs, displayPath)
}
return SilentResult(msg)
}
return ErrorResult(fmt.Sprintf("failed to list directory: %v", err))
}
var sb strings.Builder
for _, entry := range entries {
// Filter out denied entries (both files and directories) from listing.
if len(t.deniedPrefixes) > 0 {
entryPath := filepath.Join(resolved, entry.Name())
if checkDeniedPath(entryPath, t.workspace, t.deniedPrefixes) != nil {
continue
}
}
info, _ := entry.Info()
if entry.IsDir() {
fmt.Fprintf(&sb, "[DIR] %s/\n", entry.Name())
} else if info != nil {
fmt.Fprintf(&sb, "[FILE] %s (%d bytes)\n", entry.Name(), info.Size())
} else {
fmt.Fprintf(&sb, "[FILE] %s\n", entry.Name())
}
}
return SilentResult(sb.String())
}
func (t *ListFilesTool) executeInSandbox(ctx context.Context, path, sandboxKey string) *Result {
mountWorkspace, err := effectiveSandboxWorkspace(ctx, t.workspace)
if err != nil {
return ErrorResult(err.Error())
}
containerCwd, cwdErr := sandboxCwdForHostPath(mountWorkspace, mountWorkspace, sandboxContainerWorkdir(ctx))
if cwdErr != nil {
return ErrorResult(fmt.Sprintf("sandbox path mapping: %v", cwdErr))
}
bridge, err := t.getFsBridge(ctx, sandboxKey, mountWorkspace, containerCwd)
if err != nil {
return ErrorResult(fmt.Sprintf("sandbox error: %v", err))
}
containerPath := ResolveSandboxPath(path, containerCwd)
output, err := bridge.ListDir(ctx, containerPath)
if err != nil {
return ErrorResult(fmt.Sprintf("failed to list directory: %v", err) + MaybeFsBridgeHint(err))
}
return SilentResult(output)
}
func (t *ListFilesTool) getFsBridge(ctx context.Context, sandboxKey, mountWorkspace, containerCwd string) (*sandbox.FsBridge, error) {
sb, err := acquireToolSandbox(ctx, t.sandboxMgr, sandboxKey, mountWorkspace)
if err != nil {
return nil, err
}
return sandbox.NewFsBridge(sb.ID(), containerCwd), nil
}