gastownhall/beads · error
refusing to chmod %s: path changed during permission repair
Error message
refusing to chmod %s: path changed during permission repair
What it means
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.
Source
Thrown at internal/config/permissions.go:78
return false, fmt.Errorf("refusing to chmod %s: path is not a directory", path)
}
perm := info.Mode().Perm()
if perm&0077 == 0 {
return false, nil // no group or world-accessible bits
}
dir, err := openDir(path)
if err != nil {
return false, fmt.Errorf("failed to open %s securely: %w", path, err)
}
defer func() { _ = dir.Close() }()
openedInfo, err := dir.Stat()
if err != nil {
return false, fmt.Errorf("failed to inspect opened directory %s: %w", path, err)
}
if !openedInfo.IsDir() || !os.SameFile(info, openedInfo) {
return false, fmt.Errorf("refusing to chmod %s: path changed during permission repair", path)
}
if err := dir.Chmod(BeadsDirPerm); err != nil {
return false, fmt.Errorf("failed to chmod %s to %04o: %w", path, BeadsDirPerm, err)
}
return true, nil
}
View on GitHub (pinned to 71377f2769)
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.
Example fix
// serialize repairs var mu sync.Mutex mu.Lock() defer mu.Unlock() changed, err := config.FixBeadsDirPermissions(beadsDir)
Defensive patterns
Strategy: try-catch
Validate before calling
// serialize concurrent mutations of .beads in your tooling var beadsMu sync.Mutex beadsMu.Lock() defer beadsMu.Unlock()
Try / catch
changed, err := config.FixBeadsDirPermissions(beadsDir)
if err != nil {
if strings.Contains(err.Error(), "path changed during permission repair") {
// race or tampering: re-stat and decide
info, statErr := os.Lstat(beadsDir)
if statErr == nil && info.IsDir() && info.Mode()&os.ModeSymlink == 0 {
changed, err = config.FixBeadsDirPermissions(beadsDir) // safe retry
}
}
return err
} Prevention
- 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.
When it happens
Trigger: 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.
Common situations: Concurrent `bd init`/cleanup jobs racing with the repair; symlink-swap attacks during privileged operations; dotfile managers re-linking .beads while a command runs.
Related errors
- refusing to chmod %s: path is a symbolic link
- failed to open %s securely: %w
- dolt path is not executable
- --allowed-host is empty; pass the Host header value clients
- procid: malformed proc stat: missing comm terminator
AI-assisted analysis of gastownhall/beads@71377f2769 (2026-08-30).
Data as JSON: /api/errors/9b2d1301df8161cb.
Report an issue: GitHub.