diff --git a/internal/acp/permission.go b/internal/acp/permission.go new file mode 100644 index 0000000..2355912 --- /dev/null +++ b/internal/acp/permission.go @@ -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) +} diff --git a/internal/acp/permission_test.go b/internal/acp/permission_test.go new file mode 100644 index 0000000..6dcf17f --- /dev/null +++ b/internal/acp/permission_test.go @@ -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) +}