let workspace to support multiple paths, "workspace": "~/.picoclaw/workspace|path2....|path3.......",

This commit is contained in:
lyqu 2026-02-22 17:42:19 -05:00
parent 6b429de927
commit 3dfbc00559
6 changed files with 112 additions and 37 deletions

View file

@ -78,10 +78,11 @@ func gatewayCmd() {
// Setup cron tool and service // Setup cron tool and service
execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute
combinedWorkspace := cfg.WorkspacePath()
cronService := setupCronTool( cronService := setupCronTool(
agentLoop, agentLoop,
msgBus, msgBus,
cfg.WorkspacePath(), combinedWorkspace,
cfg.Agents.Defaults.RestrictToWorkspace, cfg.Agents.Defaults.RestrictToWorkspace,
execTimeout, execTimeout,
cfg, cfg,
@ -224,12 +225,13 @@ func gatewayCmd() {
func setupCronTool( func setupCronTool(
agentLoop *agent.AgentLoop, agentLoop *agent.AgentLoop,
msgBus *bus.MessageBus, msgBus *bus.MessageBus,
workspace string, workspace string, // workspace can contain '|' for allowed paths
restrict bool, restrict bool,
execTimeout time.Duration, execTimeout time.Duration,
cfg *config.Config, cfg *config.Config,
) *cron.CronService { ) *cron.CronService {
cronStorePath := filepath.Join(workspace, "cron", "jobs.json") primaryWorkspace := strings.Split(workspace, "|")[0]
cronStorePath := filepath.Join(primaryWorkspace, "cron", "jobs.json")
// Create cron service // Create cron service
cronService := cron.NewCronService(cronStorePath, nil) cronService := cron.NewCronService(cronStorePath, nil)

View file

@ -30,6 +30,9 @@ func getGlobalConfigDir() string {
} }
func NewContextBuilder(workspace string) *ContextBuilder { 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 // builtin skills: skills directory in current project
// Use the skills/ directory under the current working directory // Use the skills/ directory under the current working directory
wd, _ := os.Getwd() wd, _ := os.Getwd()
@ -38,8 +41,8 @@ func NewContextBuilder(workspace string) *ContextBuilder {
return &ContextBuilder{ return &ContextBuilder{
workspace: workspace, workspace: workspace,
skillsLoader: skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir), skillsLoader: skills.NewSkillsLoader(primaryWorkspace, globalSkillsDir, builtinSkillsDir),
memory: NewMemoryStore(workspace), memory: NewMemoryStore(primaryWorkspace),
} }
} }
@ -50,12 +53,25 @@ func (cb *ContextBuilder) SetToolsRegistry(registry *tools.ToolRegistry) {
func (cb *ContextBuilder) getIdentity() string { func (cb *ContextBuilder) getIdentity() string {
now := time.Now().Format("2006-01-02 15:04 (Monday)") 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()) runtime := fmt.Sprintf("%s %s, Go %s", runtime.GOOS, runtime.GOARCH, runtime.Version())
// Build tools section dynamically // Build tools section dynamically
toolsSection := cb.buildToolsSection() 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 🦞 return fmt.Sprintf(`# picoclaw 🦞
You are picoclaw, a helpful AI assistant. You are picoclaw, a helpful AI assistant.
@ -67,7 +83,10 @@ You are picoclaw, a helpful AI assistant.
%s %s
## Workspace ## 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 - Memory: %s/memory/MEMORY.md
- Daily Notes: %s/memory/YYYYMM/YYYYMMDD.md - Daily Notes: %s/memory/YYYYMM/YYYYMMDD.md
- Skills: %s/skills/{skill-name}/SKILL.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. 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`, 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 { func (cb *ContextBuilder) buildToolsSection() string {
@ -148,9 +167,11 @@ func (cb *ContextBuilder) LoadBootstrapFiles() string {
"IDENTITY.md", "IDENTITY.md",
} }
primaryWorkspace := strings.Split(cb.workspace, "|")[0]
var sb strings.Builder var sb strings.Builder
for _, filename := range bootstrapFiles { for _, filename := range bootstrapFiles {
filePath := filepath.Join(cb.workspace, filename) filePath := filepath.Join(primaryWorkspace, filename)
if data, err := os.ReadFile(filePath); err == nil { if data, err := os.ReadFile(filePath); err == nil {
fmt.Fprintf(&sb, "## %s\n\n%s\n\n", filename, data) fmt.Fprintf(&sb, "## %s\n\n%s\n\n", filename, data)
} }

View file

@ -41,7 +41,10 @@ func NewAgentInstance(
provider providers.LLMProvider, provider providers.LLMProvider,
) *AgentInstance { ) *AgentInstance {
workspace := resolveAgentWorkspace(agentCfg, defaults) 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) model := resolveAgentModel(agentCfg, defaults)
fallbacks := resolveAgentFallbacks(agentCfg, defaults) fallbacks := resolveAgentFallbacks(agentCfg, defaults)
@ -55,7 +58,7 @@ func NewAgentInstance(
toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict)) toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict))
toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict)) toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict))
sessionsDir := filepath.Join(workspace, "sessions") sessionsDir := filepath.Join(primaryWorkspace, "sessions")
sessionsManager := session.NewSessionManager(sessionsDir) sessionsManager := session.NewSessionManager(sessionsDir)
contextBuilder := NewContextBuilder(workspace) contextBuilder := NewContextBuilder(workspace)
@ -148,6 +151,16 @@ func expandHome(path string) string {
if path == "" { if path == "" {
return 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] == '~' { if path[0] == '~' {
home, _ := os.UserHomeDir() home, _ := os.UserHomeDir()
if len(path) > 1 && path[1] == '/' { if len(path) > 1 && path[1] == '/' {

View file

@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"sync/atomic" "sync/atomic"
"github.com/caarlos0/env/v11" "github.com/caarlos0/env/v11"
@ -587,6 +588,16 @@ func expandHome(path string) string {
if path == "" { if path == "" {
return 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] == '~' { if path[0] == '~' {
home, _ := os.UserHomeDir() home, _ := os.UserHomeDir()
if len(path) > 1 && path[1] == '/' { if len(path) > 1 && path[1] == '/' {

View file

@ -8,53 +8,81 @@ import (
"strings" "strings"
) )
// validatePath ensures the given path is within the workspace if restrict is true. // validatePath ensures the given path is within one of the allowed paths if restrict is true.
func validatePath(path, workspace string, restrict bool) (string, error) { // workspace can be a single path or multiple paths separated by '|'.
func validatePath(path string, workspace string, restrict bool) (string, error) {
if workspace == "" { if workspace == "" {
return path, nil return path, nil
} }
absWorkspace, err := filepath.Abs(workspace) allowedPaths := strings.Split(workspace, "|")
primaryWorkspace := allowedPaths[0]
absPrimary, err := filepath.Abs(primaryWorkspace)
if err != nil { 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 var absPath string
if filepath.IsAbs(path) { if filepath.IsAbs(path) {
absPath = filepath.Clean(path) absPath = filepath.Clean(path)
} else { } else {
absPath, err = filepath.Abs(filepath.Join(absWorkspace, path)) absPath, err = filepath.Abs(filepath.Join(absPrimary, path))
if err != nil { if err != nil {
return "", fmt.Errorf("failed to resolve file path: %w", err) return "", fmt.Errorf("failed to resolve file path: %w", err)
} }
} }
if restrict { 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") return "", fmt.Errorf("access denied: path is outside the workspace")
} }
// Symlink identification and deeper validation
var resolved string var resolved string
workspaceReal := absWorkspace
if resolved, err = filepath.EvalSymlinks(absWorkspace); err == nil {
workspaceReal = resolved
}
if resolved, err = filepath.EvalSymlinks(absPath); err == nil { 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") return "", fmt.Errorf("access denied: symlink resolves outside workspace")
} }
} else if os.IsNotExist(err) { } else if os.IsNotExist(err) {
var parentResolved string var parentResolved string
if parentResolved, err = resolveExistingAncestor(filepath.Dir(absPath)); err == nil { 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") 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)
} }
} }

View file

@ -285,11 +285,6 @@ func (t *ExecTool) guardCommand(command, cwd string) string {
return "Command blocked by safety guard (path traversal detected)" 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\"']+`) pathPattern := regexp.MustCompile(`[A-Za-z]:\\[^\\\"']+|/[^\s\"']+`)
matches := pathPattern.FindAllString(cmd, -1) matches := pathPattern.FindAllString(cmd, -1)
@ -299,12 +294,17 @@ func (t *ExecTool) guardCommand(command, cwd string) string {
continue continue
} }
rel, err := filepath.Rel(cwdPath, p) allowedPaths := strings.Split(t.workingDir, "|")
if err != nil { found := false
continue 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)" return "Command blocked by safety guard (path outside working dir)"
} }
} }