let workspace to support multiple paths, "workspace": "~/.picoclaw/workspace|path2....|path3.......",
This commit is contained in:
parent
6b429de927
commit
3dfbc00559
6 changed files with 112 additions and 37 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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] == '/' {
|
||||||
|
|
|
||||||
|
|
@ -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] == '/' {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue