fix: enforce workspace restriction in :edit command

- Export validatePath → ValidatePath for cross-package use
- Rewrite resolveEditPath to validate paths via tools.ValidatePath,
  blocking absolute paths, symlink escape, and path traversal
- Map ~ to workspace root (not $HOME) for consistency with cd
- Return access denied error for paths outside workspace

Security: previously :edit /etc/passwd or :edit ~/.ssh/id_rsa could
read/write arbitrary files. Now all :edit paths are sandboxed to the
agent workspace.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
seagochen 2026-02-26 16:12:07 +09:00
parent 001563ea74
commit 77f1759c3b
3 changed files with 20 additions and 13 deletions

View file

@ -1353,7 +1353,7 @@ func (al *AgentLoop) executeCmdMode(ctx context.Context, agent *AgentInstance, c
if workDir == "" {
workDir = agent.Workspace
}
return al.handleEditCommand(content, workDir), nil
return al.handleEditCommand(content, workDir, agent.Workspace), nil
}
// Intercept interactive editors
@ -1462,7 +1462,7 @@ func shortenHomePath(path string) string {
// :edit <file> +<N> <text> → insert after line N
// :edit <file> -<N> → delete line N
// :edit <file> -m """<content>""" → write full content (create if needed)
func (al *AgentLoop) handleEditCommand(content, workDir string) string {
func (al *AgentLoop) handleEditCommand(content, workDir, workspace string) string {
raw := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(content), ":edit"))
if raw == "" {
return editUsage()
@ -1479,7 +1479,10 @@ func (al *AgentLoop) handleEditCommand(content, workDir string) string {
return editUsage()
}
filename := resolveEditPath(parts[0], workDir)
filename, err := resolveEditPath(parts[0], workDir, workspace)
if err != nil {
return fmt.Sprintf("Access denied: %s", err)
}
// :edit <file> — show file content
if len(parts) == 1 && !strings.Contains(raw, "\n") {
@ -1501,15 +1504,19 @@ func (al *AgentLoop) handleEditCommand(content, workDir string) string {
return editUsage()
}
func resolveEditPath(name, workDir string) string {
if strings.HasPrefix(name, "~/") {
home, _ := os.UserHomeDir()
return home + name[1:]
func resolveEditPath(name, workDir, workspace string) (string, error) {
// Treat ~ as workspace root (not $HOME)
if name == "~" {
name = "."
} else if strings.HasPrefix(name, "~/") {
name = name[2:]
}
if filepath.IsAbs(name) {
return name
// Resolve relative paths against workDir
if !filepath.IsAbs(name) {
name = filepath.Join(workDir, name)
}
return filepath.Join(workDir, name)
// Validate against workspace (blocks absolute paths outside workspace, symlink escape, traversal)
return tools.ValidatePath(name, workspace, true)
}
func editUsage() string {

View file

@ -10,8 +10,8 @@ import (
"time"
)
// 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 the workspace if restrict is true.
func ValidatePath(path, workspace string, restrict bool) (string, error) {
if workspace == "" {
return path, fmt.Errorf("workspace is not defined")
}

View file

@ -143,7 +143,7 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult
cwd := t.workingDir
if wd, ok := args["working_dir"].(string); ok && wd != "" {
if t.restrictToWorkspace && t.workingDir != "" {
resolvedWD, err := validatePath(wd, t.workingDir, true)
resolvedWD, err := ValidatePath(wd, t.workingDir, true)
if err != nil {
return ErrorResult("Command blocked by safety guard (" + err.Error() + ")")
}