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:
@@ -38,9 +38,16 @@ type FsError struct {
|
|||||||
Message string
|
Message string
|
||||||
}
|
}
|
||||||
|
|
||||||
// FsHandler is the placeholder for fs/read_text_file + fs/write_text_file.
|
// FsHandler combines fs/* method handlers so Relay can hold one reference.
|
||||||
// Fields and methods are added in Task D4.
|
type FsHandler struct {
|
||||||
type FsHandler struct{}
|
Read *FsReadHandler
|
||||||
|
Write *FsWriteHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFsHandler constructs a composite FsHandler with default sub-handlers.
|
||||||
|
func NewFsHandler() *FsHandler {
|
||||||
|
return &FsHandler{Read: NewFsReadHandler(), Write: NewFsWriteHandler()}
|
||||||
|
}
|
||||||
|
|
||||||
// PermissionHandler is the placeholder for session/request_permission.
|
// PermissionHandler is the placeholder for session/request_permission.
|
||||||
// Fields and methods are added in Task D5.
|
// Fields and methods are added in Task D5.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package handlers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/yan1h/agent-coding-workflow/internal/acp/handlers"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFsRead_HappyPath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
tmp := t.TempDir()
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(tmp, "a.txt"), []byte("hello\n"), 0o644))
|
||||||
|
|
||||||
|
h := handlers.NewFsReadHandler()
|
||||||
|
sess := handlers.SessionContext{SessionID: uuid.New(), CwdPath: tmp}
|
||||||
|
params := mustJSON(t, map[string]any{"sessionId": "x", "path": filepath.Join(tmp, "a.txt")})
|
||||||
|
|
||||||
|
res := h.Handle(context.Background(), sess, params)
|
||||||
|
require.Nil(t, res.Err)
|
||||||
|
require.NotNil(t, res.OK)
|
||||||
|
var out struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal(res.OK, &out))
|
||||||
|
assert.Equal(t, "hello\n", out.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFsRead_PathOutsideWorktree(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
tmp := t.TempDir()
|
||||||
|
h := handlers.NewFsReadHandler()
|
||||||
|
sess := handlers.SessionContext{CwdPath: tmp}
|
||||||
|
outside := filepath.Join(filepath.VolumeName(tmp)+string(filepath.Separator), "etc", "passwd")
|
||||||
|
params := mustJSON(t, map[string]any{"sessionId": "x", "path": outside})
|
||||||
|
|
||||||
|
res := h.Handle(context.Background(), sess, params)
|
||||||
|
require.NotNil(t, res.Err)
|
||||||
|
assert.Equal(t, -32001, res.Err.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFsRead_FileNotFound(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
tmp := t.TempDir()
|
||||||
|
h := handlers.NewFsReadHandler()
|
||||||
|
sess := handlers.SessionContext{CwdPath: tmp}
|
||||||
|
params := mustJSON(t, map[string]any{"sessionId": "x", "path": filepath.Join(tmp, "nope.txt")})
|
||||||
|
|
||||||
|
res := h.Handle(context.Background(), sess, params)
|
||||||
|
require.NotNil(t, res.Err)
|
||||||
|
assert.Equal(t, -32603, res.Err.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFsRead_LineLimit(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
tmp := t.TempDir()
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(tmp, "f.txt"),
|
||||||
|
[]byte("L0\nL1\nL2\nL3\n"), 0o644))
|
||||||
|
|
||||||
|
h := handlers.NewFsReadHandler()
|
||||||
|
sess := handlers.SessionContext{CwdPath: tmp}
|
||||||
|
line := 1
|
||||||
|
limit := 2
|
||||||
|
params := mustJSON(t, map[string]any{
|
||||||
|
"sessionId": "x", "path": filepath.Join(tmp, "f.txt"),
|
||||||
|
"line": line, "limit": limit,
|
||||||
|
})
|
||||||
|
res := h.Handle(context.Background(), sess, params)
|
||||||
|
require.Nil(t, res.Err)
|
||||||
|
var out struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(res.OK, &out)
|
||||||
|
assert.Equal(t, "L1\nL2", out.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustJSON(t *testing.T, v any) json.RawMessage {
|
||||||
|
t.Helper()
|
||||||
|
b, err := json.Marshal(v)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return b
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FsWriteHandler handles fs/write_text_file (spec §5.4).
|
||||||
|
type FsWriteHandler struct{}
|
||||||
|
|
||||||
|
// NewFsWriteHandler constructs an FsWriteHandler.
|
||||||
|
func NewFsWriteHandler() *FsWriteHandler { return &FsWriteHandler{} }
|
||||||
|
|
||||||
|
type fsWriteParams struct {
|
||||||
|
SessionID string `json:"sessionId"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle writes a file inside sess.CwdPath, creating parent dirs as needed.
|
||||||
|
// Returns FsError for path-escape or filesystem failure.
|
||||||
|
func (h *FsWriteHandler) Handle(_ context.Context, sess SessionContext, paramsJSON json.RawMessage) FsResult {
|
||||||
|
var p fsWriteParams
|
||||||
|
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()}}
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
|
||||||
|
return FsResult{Err: &FsError{Code: -32603, Message: "mkdir: " + err.Error()}}
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(abs, []byte(p.Content), 0o644); err != nil {
|
||||||
|
return FsResult{Err: &FsError{Code: -32603, Message: "write: " + err.Error()}}
|
||||||
|
}
|
||||||
|
return FsResult{OK: json.RawMessage(`{}`)}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package handlers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/yan1h/agent-coding-workflow/internal/acp/handlers"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFsWrite_HappyPath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
tmp := t.TempDir()
|
||||||
|
h := handlers.NewFsWriteHandler()
|
||||||
|
sess := handlers.SessionContext{SessionID: uuid.New(), CwdPath: tmp}
|
||||||
|
|
||||||
|
target := filepath.Join(tmp, "newdir", "x.txt")
|
||||||
|
params := mustJSON(t, map[string]any{
|
||||||
|
"sessionId": "x", "path": target, "content": "hello",
|
||||||
|
})
|
||||||
|
res := h.Handle(context.Background(), sess, params)
|
||||||
|
require.Nil(t, res.Err)
|
||||||
|
|
||||||
|
got, err := os.ReadFile(target)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "hello", string(got))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFsWrite_OutsideWorktree(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
tmp := t.TempDir()
|
||||||
|
h := handlers.NewFsWriteHandler()
|
||||||
|
sess := handlers.SessionContext{CwdPath: tmp}
|
||||||
|
|
||||||
|
outside := filepath.Join(filepath.VolumeName(tmp)+string(filepath.Separator), "tmp", "escape.txt")
|
||||||
|
params := mustJSON(t, map[string]any{
|
||||||
|
"sessionId": "x", "path": outside, "content": "evil",
|
||||||
|
})
|
||||||
|
res := h.Handle(context.Background(), sess, params)
|
||||||
|
require.NotNil(t, res.Err)
|
||||||
|
assert.Equal(t, -32001, res.Err.Code)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user