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 == "" { if workDir == "" {
workDir = agent.Workspace workDir = agent.Workspace
} }
return al.handleEditCommand(content, workDir), nil return al.handleEditCommand(content, workDir, agent.Workspace), nil
} }
// Intercept interactive editors // Intercept interactive editors
@ -1462,7 +1462,7 @@ func shortenHomePath(path string) string {
// :edit <file> +<N> <text> → insert after line N // :edit <file> +<N> <text> → insert after line N
// :edit <file> -<N> → delete line N // :edit <file> -<N> → delete line N
// :edit <file> -m """<content>""" → write full content (create if needed) // :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")) raw := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(content), ":edit"))
if raw == "" { if raw == "" {
return editUsage() return editUsage()
@ -1479,7 +1479,10 @@ func (al *AgentLoop) handleEditCommand(content, workDir string) string {
return editUsage() 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 // :edit <file> — show file content
if len(parts) == 1 && !strings.Contains(raw, "\n") { if len(parts) == 1 && !strings.Contains(raw, "\n") {
@ -1501,15 +1504,19 @@ func (al *AgentLoop) handleEditCommand(content, workDir string) string {
return editUsage() return editUsage()
} }
func resolveEditPath(name, workDir string) string { func resolveEditPath(name, workDir, workspace string) (string, error) {
if strings.HasPrefix(name, "~/") { // Treat ~ as workspace root (not $HOME)
home, _ := os.UserHomeDir() if name == "~" {
return home + name[1:] name = "."
} else if strings.HasPrefix(name, "~/") {
name = name[2:]
} }
if filepath.IsAbs(name) { // Resolve relative paths against workDir
return name 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 { func editUsage() string {

View file

@ -10,8 +10,8 @@ import (
"time" "time"
) )
// validatePath ensures the given path is within the workspace if restrict is true. // ValidatePath ensures the given path is within the workspace if restrict is true.
func validatePath(path, workspace string, restrict bool) (string, error) { func ValidatePath(path, workspace string, restrict bool) (string, error) {
if workspace == "" { if workspace == "" {
return path, fmt.Errorf("workspace is not defined") 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 cwd := t.workingDir
if wd, ok := args["working_dir"].(string); ok && wd != "" { if wd, ok := args["working_dir"].(string); ok && wd != "" {
if t.restrictToWorkspace && t.workingDir != "" { if t.restrictToWorkspace && t.workingDir != "" {
resolvedWD, err := validatePath(wd, t.workingDir, true) resolvedWD, err := ValidatePath(wd, t.workingDir, true)
if err != nil { if err != nil {
return ErrorResult("Command blocked by safety guard (" + err.Error() + ")") return ErrorResult("Command blocked by safety guard (" + err.Error() + ")")
} }