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 }