feat(acp): ResolveSafePath helper (3-fold check: abs/prefix/symlink)

This commit is contained in:
2026-05-07 12:06:37 +08:00
parent cf314021e7
commit dee20bb618
2 changed files with 160 additions and 0 deletions
+55
View File
@@ -0,0 +1,55 @@
// permission.go 实现 ACP fs/* method 的路径安全校验。
//
// 规则(spec §5.6):
// 1. 路径必须以 cwd_path 为前缀(防 ../../etc/passwd)
// 2. 解析 symlink 后的真实路径必须仍在 cwd_path 下(防 symlink 逃逸)
//
// 三重校验:filepath.Abs + strings.HasPrefix + filepath.EvalSymlinks。
package acp
import (
"path/filepath"
"strings"
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
)
// ResolveSafePath 校验 requested 是否安全(在 cwd 内)。
// cwd 必须是绝对路径;requested 可以是绝对或相对(相对时基于 cwd 解析)。
// 返回校验通过后的绝对路径(含 symlink 解析);失败返回 CodeAcpFsPathOutsideWorktree。
func ResolveSafePath(cwd, requested string) (string, error) {
if !filepath.IsAbs(cwd) {
return "", errs.New(errs.CodeAcpFsPathOutsideWorktree, "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 "", errs.New(errs.CodeAcpFsPathOutsideWorktree, "path outside worktree")
}
real, err := filepath.EvalSymlinks(abs)
if err == nil {
if !hasPathPrefix(real, cleanCwd) {
return "", errs.New(errs.CodeAcpFsPathOutsideWorktree, "path resolves outside worktree via symlink")
}
}
return abs, nil
}
// hasPathPrefix reports whether abs is equal to base or under base. Trailing
// separator handling is critical: we want "/foo/bar" to be inside "/foo" but
// NOT inside "/fo".
func hasPathPrefix(abs, base string) bool {
if abs == base {
return true
}
sep := string(filepath.Separator)
return strings.HasPrefix(abs, base+sep)
}
+105
View File
@@ -0,0 +1,105 @@
package acp_test
import (
"os"
"path/filepath"
"runtime"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yan1h/agent-coding-workflow/internal/acp"
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
)
func TestResolveSafePath_InsideWorktree(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
target := filepath.Join(tmp, "sub", "a.txt")
require.NoError(t, os.MkdirAll(filepath.Dir(target), 0o755))
require.NoError(t, os.WriteFile(target, []byte("x"), 0o644))
got, err := acp.ResolveSafePath(tmp, target)
require.NoError(t, err)
// EvalSymlinks may resolve macOS / temp paths via /private prefix; on
// Windows the path may be returned as-is. Compare via filepath.Clean.
assert.Equal(t, filepath.Clean(target), got)
got, err = acp.ResolveSafePath(tmp, "sub/a.txt")
require.NoError(t, err)
assert.Equal(t, filepath.Clean(target), got)
}
func TestResolveSafePath_AbsoluteOutside(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
// On Windows /etc/passwd is interpreted relative to the current drive;
// use a guaranteed-outside path.
outsideAbs := filepath.Join(filepath.VolumeName(tmp)+string(filepath.Separator), "etc", "passwd")
_, err := acp.ResolveSafePath(tmp, outsideAbs)
ae, ok := errs.As(err)
require.True(t, ok)
assert.Equal(t, errs.CodeAcpFsPathOutsideWorktree, ae.Code)
}
func TestResolveSafePath_RelativeWithDotDot(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
_, err := acp.ResolveSafePath(tmp, "../etc/passwd")
ae, ok := errs.As(err)
require.True(t, ok)
assert.Equal(t, errs.CodeAcpFsPathOutsideWorktree, ae.Code)
}
func TestResolveSafePath_PrefixSiblingTrap(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
sibling := tmp + "_sibling"
require.NoError(t, os.MkdirAll(sibling, 0o755))
t.Cleanup(func() { _ = os.RemoveAll(sibling) })
_, err := acp.ResolveSafePath(tmp, sibling+string(filepath.Separator)+"file")
ae, ok := errs.As(err)
require.True(t, ok)
assert.Equal(t, errs.CodeAcpFsPathOutsideWorktree, ae.Code)
}
func TestResolveSafePath_SymlinkEscape(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink test requires admin on Windows")
}
t.Parallel()
tmp := t.TempDir()
outside := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(outside, "secret"), []byte("s"), 0o644))
link := filepath.Join(tmp, "link-to-secret")
require.NoError(t, os.Symlink(filepath.Join(outside, "secret"), link))
_, err := acp.ResolveSafePath(tmp, link)
ae, ok := errs.As(err)
require.True(t, ok)
assert.Equal(t, errs.CodeAcpFsPathOutsideWorktree, ae.Code)
}
func TestResolveSafePath_NonexistentInsideOK(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
want := filepath.Join(tmp, "newfile.txt")
got, err := acp.ResolveSafePath(tmp, want)
require.NoError(t, err)
assert.Equal(t, want, got)
}
func TestResolveSafePath_RelativeCwdRejected(t *testing.T) {
t.Parallel()
_, err := acp.ResolveSafePath("relative-cwd", "x")
ae, ok := errs.As(err)
require.True(t, ok)
assert.Equal(t, errs.CodeAcpFsPathOutsideWorktree, ae.Code)
}