larksuite/cli · error
%s: resolved path %q is still a symlink
Error message
%s: resolved path %q is still a symlink
What it means
This library only permits a single hop of symlink indirection. After EvalSymlinks resolves the target, resolveSymlinkIfAllowed Lstats the result and rejects it if the resolved path is still a symlink (i.e. a symlink-to-symlink chain). This keeps the audit surface predictable: callers always receive a final, non-link effective path.
Source
Thrown at internal/binding/audit.go:113
// is not a symlink, target is returned unchanged. A symlink that points to
// another symlink is rejected so callers only deal with a single hop.
func resolveSymlinkIfAllowed(target string, linfo fs.FileInfo, params AuditParams) (string, error) {
if linfo.Mode()&os.ModeSymlink == 0 {
return target, nil
}
if !params.AllowSymlinkPath {
return "", fmt.Errorf("%s: path %q is a symlink (not allowed)", params.Label, target)
}
resolved, err := vfs.EvalSymlinks(target)
if err != nil {
return "", fmt.Errorf("%s: cannot resolve symlink %q: %w", params.Label, target, err)
}
rinfo, err := vfs.Lstat(resolved)
if err != nil {
return "", fmt.Errorf("%s: cannot stat resolved path %q: %w", params.Label, resolved, err)
}
if rinfo.Mode()&os.ModeSymlink != 0 {
return "", fmt.Errorf("%s: resolved path %q is still a symlink", params.Label, resolved)
}
return resolved, nil
}
// requireInTrustedDirs enforces that effectivePath lives under one of the
// caller-declared trusted directories, if any were declared. An empty
// trustedDirs list disables the check.
func requireInTrustedDirs(effectivePath string, trustedDirs []string, label string) error {
if len(trustedDirs) == 0 {
return nil
}
cleaned := filepath.Clean(effectivePath)
for _, dir := range trustedDirs {
cleanDir := filepath.Clean(dir)
if cleaned == cleanDir || strings.HasPrefix(cleaned, cleanDir+"/") {
return nil
}
}View on GitHub (pinned to 7fd6ef3c07)
Solutions
- Resolve the full chain yourself and configure the final concrete path (readlink -f <path>)
- Rebuild the first symlink to point directly at the final file instead of an intermediate link
- If intermediate links are required by your setup, generate config with the fully-resolved path at install time
Example fix
// before cfg.path = "/usr/local/bin/tool" // -> /opt/tool/current -> /opt/tool/2.1.0/tool // after cfg.path = readlink -f /usr/local/bin/tool // /opt/tool/2.1.0/tool
Defensive patterns
Strategy: validation
Validate before calling
func ensureSingleHop(p string) (string, error) {
fi, err := os.Lstat(p)
if err != nil { return "", err }
if fi.Mode()&os.ModeSymlink == 0 { return p, nil }
resolved, err := filepath.EvalSymlinks(p)
if err != nil { return "", err }
rfi, err := os.Lstat(resolved)
if err != nil { return "", err }
if rfi.Mode()&os.ModeSymlink != 0 { return "", fmt.Errorf("%s resolves to another symlink", p) }
return resolved, nil
} Type guard
func isStillSymlink(fi fs.FileInfo) bool { return fi.Mode()&os.ModeSymlink != 0 } Prevention
- Store fully-resolved paths (readlink -f) in config instead of 'current' style links
- Re-generate config when upgrading tools that use intermediate symlink indirection
- Prefer direct paths over alternatives/update-alternatives entries
When it happens
Trigger: The configured path is a symlink whose target is itself another symlink (e.g. /usr/local/bin/tool -> /opt/tool/current -> /opt/tool/2.1.0/bin/tool) and AllowSymlinkPath is true.
Common situations: Version-manager layouts (nvm, pyenv, update-alternatives, /etc/alternatives chains) where 'current' is itself a link; manually chained links created during a tool upgrade; distro wrappers pointing at alternatives directories.
Related errors
- %s: cannot resolve symlink %q: %w
- %s: cannot stat resolved path %q: %w
- %s: path %q is a symlink (not allowed)
- %s: cannot stat %q: %w
- cannot resolve symlinks: %w
AI-assisted analysis of larksuite/cli@7fd6ef3c07 (2026-09-04).
Data as JSON: /api/errors/205c97fa98b50a71.
Report an issue: GitHub.