Files
agentic-coding-workflow/internal/acp/pathsafe/pathsafe.go
T
2026-06-11 12:43:18 +08:00

92 lines
3.3 KiB
Go

// Package pathsafe 实现 ACP fs/* method 的路径安全校验。
//
// 单独成包是因为 internal/acp/handlers 子包不能 import internal/acp 主包
// (acp 依赖 handlers,会循环),故抽到共享子包供两边复用。
//
// 规则(spec §5.6):
// 1. 路径必须以 cwd_path 为前缀(防 ../../etc/passwd)
// 2. 解析 symlink 后的真实路径必须仍在 cwd_path 下(防 symlink 逃逸);
// 目标不存在时(如 fs/write_text_file 新建文件),向上找最近已存在的
// 祖先目录解析真实路径后再校验,防经由指向外部的目录 symlink 写出 worktree。
//
// 三重校验:filepath.Abs + hasPathPrefix + filepath.EvalSymlinks。
package pathsafe
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 {
// 目标(或部分祖先)不存在:不能静默放行,否则父目录若是指向外部的
// symlink,后续 MkdirAll/WriteFile 会跟随 symlink 写出 worktree。
// 改为向上找最近已存在的祖先解析真实路径,再拼回剩余部分校验。
real, err = evalNearestAncestor(abs)
if err != nil {
return "", errs.Wrap(err, errs.CodeAcpFsPathOutsideWorktree, "resolve path")
}
}
if !hasPathPrefix(real, cleanCwd) {
return "", errs.New(errs.CodeAcpFsPathOutsideWorktree, "path resolves outside worktree via symlink")
}
return abs, nil
}
// evalNearestAncestor 自 abs 向上找最近已存在(EvalSymlinks 成功)的祖先目录,
// 用其真实路径拼回剩余相对部分,得到 abs 对应的真实路径。
// 直到根目录仍无法解析时返回错误(调用方按路径不安全处理)。
func evalNearestAncestor(abs string) (string, error) {
cur := abs
rest := ""
for {
parent := filepath.Dir(cur)
if parent == cur {
// 已到根目录仍解析失败(如盘符/根不存在)
return "", errs.New(errs.CodeAcpFsPathOutsideWorktree, "no existing ancestor for path")
}
if rest == "" {
rest = filepath.Base(cur)
} else {
rest = filepath.Join(filepath.Base(cur), rest)
}
cur = parent
if real, err := filepath.EvalSymlinks(cur); err == nil {
return filepath.Join(real, rest), 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)
}