{"record":{"id":"9b2d1301df8161cb","repo":"gastownhall/beads","slug":"refusing-to-chmod-s-path-changed-during-permissi","errorCode":null,"errorMessage":"refusing to chmod %s: path changed during permission repair","messagePattern":"refusing to chmod (.+?): path changed during permission repair","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"internal/config/permissions.go","lineNumber":78,"sourceCode":"\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)\n\t}\n\tif !openedInfo.IsDir() || !os.SameFile(info, openedInfo) {\n\t\treturn false, fmt.Errorf(\"refusing to chmod %s: path changed during permission repair\", path)\n\t}\n\tif err := dir.Chmod(BeadsDirPerm); err != nil {\n\t\treturn false, fmt.Errorf(\"failed to chmod %s to %04o: %w\", path, BeadsDirPerm, err)\n\t}\n\treturn true, nil\n}\n","sourceCodeStart":60,"sourceCodeEnd":85,"githubUrl":"https://github.com/gastownhall/beads/blob/71377f276968b452ee607177637970a4ff888584/internal/config/permissions.go#L60-L85","documentation":"This is a TOCTOU guard: after opening the directory securely, the code compares the opened handle's stat against the original Lstat via os.SameFile. If they differ — the path now points to a different inode, was swapped for a symlink or non-directory — the chmod is refused to avoid re-permissioning an unintended target.","triggerScenarios":"Calling FixBeadsDirPermissions while another process (or attacker) replaces/deletes-and-recreates the .beads directory between the initial Lstat and the secure open, so os.SameFile(info, openedInfo) returns false or the opened handle is not a directory.","commonSituations":"Concurrent `bd init`/cleanup jobs racing with the repair; symlink-swap attacks during privileged operations; dotfile managers re-linking .beads while a command runs.","solutions":["Re-run the command once nothing else is touching .beads — a one-off race resolves itself.","Stop concurrent processes (sync tools, editors, other bd instances) operating on the directory, then retry.","Inspect the path with ls -lai to confirm the inode is stable and it is a real directory, then retry.","If swaps recur, treat it as a security event and audit what is modifying the path."],"exampleFix":"// serialize repairs\nvar mu sync.Mutex\nmu.Lock()\ndefer mu.Unlock()\nchanged, err := config.FixBeadsDirPermissions(beadsDir)","handlingStrategy":"try-catch","validationCode":"// serialize concurrent mutations of .beads in your tooling\nvar beadsMu sync.Mutex\nbeadsMu.Lock()\ndefer beadsMu.Unlock()","typeGuard":null,"tryCatchPattern":"changed, err := config.FixBeadsDirPermissions(beadsDir)\nif err != nil {\n    if strings.Contains(err.Error(), \"path changed during permission repair\") {\n        // race or tampering: re-stat and decide\n        info, statErr := os.Lstat(beadsDir)\n        if statErr == nil && info.IsDir() && info.Mode()&os.ModeSymlink == 0 {\n            changed, err = config.FixBeadsDirPermissions(beadsDir) // safe retry\n        }\n    }\n    return err\n}","preventionTips":["Stop sync tools, editors, and parallel bd instances before repairing permissions.","Alert on repeated path-changed failures — they can indicate tampering.","Run one permission-repair pass at startup rather than concurrently per command."],"tags":["permissions","security","race-condition","toctou"],"backgroundTag":"path-changed-during-operation","analyzedSha":"71377f276968b452ee607177637970a4ff888584","analyzedAt":"2026-08-30T18:55:39.744Z","schemaVersion":2},"datasetVersion":"2026-08-30T23:17:21.991Z"}