From 3dfbc005590a218b1778cc77e6b325f7617d547e Mon Sep 17 00:00:00 2001 From: lyqu Date: Sun, 22 Feb 2026 17:42:19 -0500 Subject: [PATCH] let workspace to support multiple paths, "workspace": "~/.picoclaw/workspace|path2....|path3.......", --- cmd/picoclaw/cmd_gateway.go | 8 +++-- pkg/agent/context.go | 33 ++++++++++++++++---- pkg/agent/instance.go | 17 ++++++++-- pkg/config/config.go | 11 +++++++ pkg/tools/filesystem.go | 62 +++++++++++++++++++++++++++---------- pkg/tools/shell.go | 18 +++++------ 6 files changed, 112 insertions(+), 37 deletions(-) diff --git a/cmd/picoclaw/cmd_gateway.go b/cmd/picoclaw/cmd_gateway.go index 28ef76ad3..6c20537cf 100644 --- a/cmd/picoclaw/cmd_gateway.go +++ b/cmd/picoclaw/cmd_gateway.go @@ -78,10 +78,11 @@ func gatewayCmd() { // Setup cron tool and service execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute + combinedWorkspace := cfg.WorkspacePath() cronService := setupCronTool( agentLoop, msgBus, - cfg.WorkspacePath(), + combinedWorkspace, cfg.Agents.Defaults.RestrictToWorkspace, execTimeout, cfg, @@ -224,12 +225,13 @@ func gatewayCmd() { func setupCronTool( agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, - workspace string, + workspace string, // workspace can contain '|' for allowed paths restrict bool, execTimeout time.Duration, cfg *config.Config, ) *cron.CronService { - cronStorePath := filepath.Join(workspace, "cron", "jobs.json") + primaryWorkspace := strings.Split(workspace, "|")[0] + cronStorePath := filepath.Join(primaryWorkspace, "cron", "jobs.json") // Create cron service cronService := cron.NewCronService(cronStorePath, nil) diff --git a/pkg/agent/context.go b/pkg/agent/context.go index a9db5afdd..5b22ee34c 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -30,6 +30,9 @@ func getGlobalConfigDir() string { } func NewContextBuilder(workspace string) *ContextBuilder { + // workspace can be a multiplexed string separated by '|' + primaryWorkspace := strings.Split(workspace, "|")[0] + // builtin skills: skills directory in current project // Use the skills/ directory under the current working directory wd, _ := os.Getwd() @@ -38,8 +41,8 @@ func NewContextBuilder(workspace string) *ContextBuilder { return &ContextBuilder{ workspace: workspace, - skillsLoader: skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir), - memory: NewMemoryStore(workspace), + skillsLoader: skills.NewSkillsLoader(primaryWorkspace, globalSkillsDir, builtinSkillsDir), + memory: NewMemoryStore(primaryWorkspace), } } @@ -50,12 +53,25 @@ func (cb *ContextBuilder) SetToolsRegistry(registry *tools.ToolRegistry) { func (cb *ContextBuilder) getIdentity() string { now := time.Now().Format("2006-01-02 15:04 (Monday)") - workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace)) + + allowedPaths := strings.Split(cb.workspace, "|") + primaryWorkspace := allowedPaths[0] + workspacePath, _ := filepath.Abs(primaryWorkspace) runtime := fmt.Sprintf("%s %s, Go %s", runtime.GOOS, runtime.GOARCH, runtime.Version()) // Build tools section dynamically toolsSection := cb.buildToolsSection() + var authorizedDirs strings.Builder + for i, p := range allowedPaths { + absP, _ := filepath.Abs(p) + if i == 0 { + authorizedDirs.WriteString(fmt.Sprintf("- %s (Primary Workspace)\n", absP)) + } else { + authorizedDirs.WriteString(fmt.Sprintf("- %s (Authorized Directory)\n", absP)) + } + } + return fmt.Sprintf(`# picoclaw 🦞 You are picoclaw, a helpful AI assistant. @@ -67,7 +83,10 @@ You are picoclaw, a helpful AI assistant. %s ## Workspace -Your workspace is at: %s +Your primary workspace is at: %s +You are authorized to access and operate in the following directories: +%s + - Memory: %s/memory/MEMORY.md - Daily Notes: %s/memory/YYYYMM/YYYYMMDD.md - Skills: %s/skills/{skill-name}/SKILL.md @@ -81,7 +100,7 @@ Your workspace is at: %s 2. **Be helpful and accurate** - When using tools, briefly explain what you're doing. 3. **Memory** - When interacting with me if something seems memorable, update %s/memory/MEMORY.md`, - now, runtime, workspacePath, workspacePath, workspacePath, workspacePath, toolsSection, workspacePath) + now, runtime, workspacePath, authorizedDirs.String(), workspacePath, workspacePath, workspacePath, toolsSection, workspacePath) } func (cb *ContextBuilder) buildToolsSection() string { @@ -148,9 +167,11 @@ func (cb *ContextBuilder) LoadBootstrapFiles() string { "IDENTITY.md", } + primaryWorkspace := strings.Split(cb.workspace, "|")[0] + var sb strings.Builder for _, filename := range bootstrapFiles { - filePath := filepath.Join(cb.workspace, filename) + filePath := filepath.Join(primaryWorkspace, filename) if data, err := os.ReadFile(filePath); err == nil { fmt.Fprintf(&sb, "## %s\n\n%s\n\n", filename, data) } diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index dfbef9fbc..79a807f6a 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -41,7 +41,10 @@ func NewAgentInstance( provider providers.LLMProvider, ) *AgentInstance { workspace := resolveAgentWorkspace(agentCfg, defaults) - os.MkdirAll(workspace, 0o755) + + // workspace can contain multiple paths separated by '|' + primaryWorkspace := strings.Split(workspace, "|")[0] + os.MkdirAll(primaryWorkspace, 0o755) model := resolveAgentModel(agentCfg, defaults) fallbacks := resolveAgentFallbacks(agentCfg, defaults) @@ -55,7 +58,7 @@ func NewAgentInstance( toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict)) toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict)) - sessionsDir := filepath.Join(workspace, "sessions") + sessionsDir := filepath.Join(primaryWorkspace, "sessions") sessionsManager := session.NewSessionManager(sessionsDir) contextBuilder := NewContextBuilder(workspace) @@ -148,6 +151,16 @@ func expandHome(path string) string { if path == "" { return path } + + // Handle multiple paths separated by | + if strings.Contains(path, "|") { + parts := strings.Split(path, "|") + for i, p := range parts { + parts[i] = expandHome(strings.TrimSpace(p)) + } + return strings.Join(parts, "|") + } + if path[0] == '~' { home, _ := os.UserHomeDir() if len(path) > 1 && path[1] == '/' { diff --git a/pkg/config/config.go b/pkg/config/config.go index 036021e49..11cbee142 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "sync/atomic" "github.com/caarlos0/env/v11" @@ -587,6 +588,16 @@ func expandHome(path string) string { if path == "" { return path } + + // Handle multiple paths separated by | + if strings.Contains(path, "|") { + parts := strings.Split(path, "|") + for i, p := range parts { + parts[i] = expandHome(strings.TrimSpace(p)) + } + return strings.Join(parts, "|") + } + if path[0] == '~' { home, _ := os.UserHomeDir() if len(path) > 1 && path[1] == '/' { diff --git a/pkg/tools/filesystem.go b/pkg/tools/filesystem.go index 1bf50906e..e7cf73396 100644 --- a/pkg/tools/filesystem.go +++ b/pkg/tools/filesystem.go @@ -8,53 +8,81 @@ import ( "strings" ) -// validatePath ensures the given path is within the workspace if restrict is true. -func validatePath(path, workspace string, restrict bool) (string, error) { +// validatePath ensures the given path is within one of the allowed paths if restrict is true. +// workspace can be a single path or multiple paths separated by '|'. +func validatePath(path string, workspace string, restrict bool) (string, error) { if workspace == "" { return path, nil } - absWorkspace, err := filepath.Abs(workspace) + allowedPaths := strings.Split(workspace, "|") + primaryWorkspace := allowedPaths[0] + absPrimary, err := filepath.Abs(primaryWorkspace) if err != nil { - return "", fmt.Errorf("failed to resolve workspace path: %w", err) + return "", fmt.Errorf("failed to resolve primary workspace path: %w", err) } var absPath string if filepath.IsAbs(path) { absPath = filepath.Clean(path) } else { - absPath, err = filepath.Abs(filepath.Join(absWorkspace, path)) + absPath, err = filepath.Abs(filepath.Join(absPrimary, path)) if err != nil { return "", fmt.Errorf("failed to resolve file path: %w", err) } } if restrict { - if !isWithinWorkspace(absPath, absWorkspace) { + found := false + for _, wp := range allowedPaths { + absWP, _ := filepath.Abs(wp) + if isWithinWorkspace(absPath, absWP) { + found = true + break + } + } + + if !found { return "", fmt.Errorf("access denied: path is outside the workspace") } + // Symlink identification and deeper validation var resolved string - workspaceReal := absWorkspace - if resolved, err = filepath.EvalSymlinks(absWorkspace); err == nil { - workspaceReal = resolved - } - if resolved, err = filepath.EvalSymlinks(absPath); err == nil { - if !isWithinWorkspace(resolved, workspaceReal) { + resFound := false + for _, wp := range allowedPaths { + absWP, _ := filepath.Abs(wp) + resolvedWP := absWP + if r, err := filepath.EvalSymlinks(absWP); err == nil { + resolvedWP = r + } + if isWithinWorkspace(resolved, resolvedWP) { + resFound = true + break + } + } + if !resFound { return "", fmt.Errorf("access denied: symlink resolves outside workspace") } } else if os.IsNotExist(err) { var parentResolved string if parentResolved, err = resolveExistingAncestor(filepath.Dir(absPath)); err == nil { - if !isWithinWorkspace(parentResolved, workspaceReal) { + resFound := false + for _, wp := range allowedPaths { + absWP, _ := filepath.Abs(wp) + resolvedWP := absWP + if r, err := filepath.EvalSymlinks(absWP); err == nil { + resolvedWP = r + } + if isWithinWorkspace(parentResolved, resolvedWP) { + resFound = true + break + } + } + if !resFound { return "", fmt.Errorf("access denied: symlink resolves outside workspace") } - } else if !os.IsNotExist(err) { - return "", fmt.Errorf("failed to resolve path: %w", err) } - } else { - return "", fmt.Errorf("failed to resolve path: %w", err) } } diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go index a1ee0b6e1..80b71e149 100644 --- a/pkg/tools/shell.go +++ b/pkg/tools/shell.go @@ -285,11 +285,6 @@ func (t *ExecTool) guardCommand(command, cwd string) string { return "Command blocked by safety guard (path traversal detected)" } - cwdPath, err := filepath.Abs(cwd) - if err != nil { - return "" - } - pathPattern := regexp.MustCompile(`[A-Za-z]:\\[^\\\"']+|/[^\s\"']+`) matches := pathPattern.FindAllString(cmd, -1) @@ -299,12 +294,17 @@ func (t *ExecTool) guardCommand(command, cwd string) string { continue } - rel, err := filepath.Rel(cwdPath, p) - if err != nil { - continue + allowedPaths := strings.Split(t.workingDir, "|") + found := false + for _, wp := range allowedPaths { + absWP, _ := filepath.Abs(wp) + if isWithinWorkspace(p, absWP) { + found = true + break + } } - if strings.HasPrefix(rel, "..") { + if !found { return "Command blocked by safety guard (path outside working dir)" } }