You've already forked agentic-coding-workflow
feat(acp/handlers): fs/read_text_file + fs/write_text_file with path safety
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FsReadHandler handles fs/read_text_file (spec §5.4).
|
||||
type FsReadHandler struct{}
|
||||
|
||||
// NewFsReadHandler constructs an FsReadHandler.
|
||||
func NewFsReadHandler() *FsReadHandler { return &FsReadHandler{} }
|
||||
|
||||
// fsReadParams is the ACP-protocol input for fs/read_text_file.
|
||||
type fsReadParams struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
Path string `json:"path"`
|
||||
Line *int `json:"line,omitempty"`
|
||||
Limit *int `json:"limit,omitempty"`
|
||||
}
|
||||
|
||||
type fsReadResult struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// Handle reads a file inside sess.CwdPath, returning content (with optional
|
||||
// line/limit slicing). Returns FsError for path-escape or read failure.
|
||||
func (h *FsReadHandler) Handle(_ context.Context, sess SessionContext, paramsJSON json.RawMessage) FsResult {
|
||||
var p fsReadParams
|
||||
if err := json.Unmarshal(paramsJSON, &p); err != nil {
|
||||
return FsResult{Err: &FsError{Code: -32602, Message: "invalid params: " + err.Error()}}
|
||||
}
|
||||
abs, err := resolveSafePath(sess.CwdPath, p.Path)
|
||||
if err != nil {
|
||||
return FsResult{Err: &FsError{Code: -32001, Message: err.Error()}}
|
||||
}
|
||||
data, err := os.ReadFile(abs)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return FsResult{Err: &FsError{Code: -32603, Message: "file not found"}}
|
||||
}
|
||||
return FsResult{Err: &FsError{Code: -32603, Message: "read: " + err.Error()}}
|
||||
}
|
||||
content := string(data)
|
||||
if p.Line != nil || p.Limit != nil {
|
||||
content = sliceLines(content, p.Line, p.Limit)
|
||||
}
|
||||
out, _ := json.Marshal(fsReadResult{Content: content})
|
||||
return FsResult{OK: out}
|
||||
}
|
||||
|
||||
// sliceLines returns lines[line:line+limit] joined by "\n".
|
||||
// line is 0-based; limit caps the line count.
|
||||
func sliceLines(s string, line, limit *int) string {
|
||||
lines := strings.Split(s, "\n")
|
||||
start := 0
|
||||
if line != nil && *line > 0 {
|
||||
start = *line
|
||||
}
|
||||
if start > len(lines) {
|
||||
start = len(lines)
|
||||
}
|
||||
end := len(lines)
|
||||
if limit != nil && *limit >= 0 {
|
||||
if start+*limit < end {
|
||||
end = start + *limit
|
||||
}
|
||||
}
|
||||
return strings.Join(lines[start:end], "\n")
|
||||
}
|
||||
|
||||
// resolveSafePath duplicates the logic from internal/acp.ResolveSafePath to
|
||||
// avoid an import cycle (handlers is a sub-package and acp imports handlers).
|
||||
func resolveSafePath(cwd, requested string) (string, error) {
|
||||
if !filepath.IsAbs(cwd) {
|
||||
return "", fmt.Errorf("cwd must be absolute")
|
||||
}
|
||||
cleanCwd := filepath.Clean(cwd)
|
||||
var abs string
|
||||
if filepath.IsAbs(requested) {
|
||||
abs = filepath.Clean(requested)
|
||||
} else {
|
||||
abs = filepath.Clean(filepath.Join(cleanCwd, requested))
|
||||
}
|
||||
if !hasPathPrefix(abs, cleanCwd) {
|
||||
return "", fmt.Errorf("path outside worktree")
|
||||
}
|
||||
if real, err := filepath.EvalSymlinks(abs); err == nil {
|
||||
if !hasPathPrefix(real, cleanCwd) {
|
||||
return "", fmt.Errorf("path resolves outside worktree via symlink")
|
||||
}
|
||||
}
|
||||
return abs, nil
|
||||
}
|
||||
|
||||
func hasPathPrefix(abs, base string) bool {
|
||||
if abs == base {
|
||||
return true
|
||||
}
|
||||
sep := string(filepath.Separator)
|
||||
return strings.HasPrefix(abs, base+sep)
|
||||
}
|
||||
Reference in New Issue
Block a user