{"record":{"id":"401db63f311d584e","repo":"gastownhall/beads","slug":"refusing-to-chmod-s-path-is-a-symbolic-link","errorCode":null,"errorMessage":"refusing to chmod %s: path is a symbolic link","messagePattern":"refusing to chmod (.+?): path is a symbolic link","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"internal/config/permissions.go","lineNumber":57,"sourceCode":"\treturn fixBeadsDirPermissions(path, openBeadsDirHandle)\n}\n\ntype beadsDirHandle interface {\n\tStat() (os.FileInfo, error)\n\tChmod(os.FileMode) error\n\tClose() error\n}\n\nfunc fixBeadsDirPermissions(path string, openDir func(string) (beadsDirHandle, error)) (bool, error) {\n\tinfo, err := os.Lstat(path)\n\tif err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil // directory doesn't exist yet\n\t\t}\n\t\treturn false, fmt.Errorf(\"failed to inspect %s: %w\", path, err)\n\t}\n\tif info.Mode()&os.ModeSymlink != 0 {\n\t\treturn false, fmt.Errorf(\"refusing to chmod %s: path is a symbolic link\", path)\n\t}\n\tif !info.IsDir() {\n\t\treturn false, fmt.Errorf(\"refusing to chmod %s: path is not a directory\", path)\n\t}\n\tperm := info.Mode().Perm()\n\tif perm&0077 == 0 {\n\t\treturn false, nil // no group or world-accessible bits\n\t}\n\n\tdir, err := openDir(path)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"failed to open %s securely: %w\", path, err)\n\t}\n\tdefer func() { _ = dir.Close() }()\n\n\topenedInfo, err := dir.Stat()\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"failed to inspect opened directory %s: %w\", path, err)","sourceCodeStart":39,"sourceCodeEnd":75,"githubUrl":"https://github.com/gastownhall/beads/blob/71377f276968b452ee607177637970a4ff888584/internal/config/permissions.go#L39-L75","documentation":"fixBeadsDirPermissions refuses to chmod the .beads directory when the given path is a symbolic link. The library protects against following symlinks during a permission repair, since a swapped symlink could redirect a privileged chmod onto an unintended target. The operation is intentionally aborted rather than resolved.","triggerScenarios":"Calling FixBeadsDirPermissions(path) when os.Lstat(path) reports os.ModeSymlink — i.e. the .beads path itself was replaced by a symlink (commonly a symlink swap attack or the user symlinked .beads to another location).","commonSituations":"A user replaced .beads with a symlink to a shared or dotfiles-managed location; a malicious actor swapped the directory for a symlink mid-repair; a provisioning script created .beads as a link instead of a real directory.","solutions":["Replace the symlink with a real directory: rm the link, mkdir .beads, and migrate contents back manually.","If intentionally symlinked, chmod the real target directory yourself (chmod 700 <target>) instead of calling FixBeadsDirPermissions on the link.","Verify with ls -la that .beads is a plain directory before re-running the fix."],"exampleFix":"// before: .beads -> /mnt/shared/beads (symlink)\n// after\n$ rm .beads\n$ mkdir .beads && chmod 700 .beads\n$ cp -a /mnt/shared/beads/. .beads/","handlingStrategy":"validation","validationCode":"info, err := os.Lstat(beadsDir)\nif err != nil {\n    return err // not exists: nothing to fix\n}\nif info.Mode()&os.ModeSymlink != 0 {\n    return fmt.Errorf(\"%s is a symlink; fix the target manually\", beadsDir)\n}","typeGuard":"func isRealDir(path string) bool {\n    info, err := os.Lstat(path)\n    return err == nil && info.IsDir() && info.Mode()&os.ModeSymlink == 0\n}","tryCatchPattern":null,"preventionTips":["Never symlink .beads; keep it a real directory in each repo.","Run `ls -la` on .beads if provisioning scripts touched it.","Treat symlink-swap detection as a security signal, not just an error."],"tags":["permissions","security","symlink","filesystem"],"backgroundTag":"symlink-detected-during-chmod","analyzedSha":"71377f276968b452ee607177637970a4ff888584","analyzedAt":"2026-08-30T18:55:39.744Z","schemaVersion":2},"datasetVersion":"2026-08-30T23:17:21.991Z"}