// 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) }