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

  1. Re-run the command once nothing else is touching .beads — a one-off race resolves itself.
  2. Stop concurrent processes (sync tools, editors, other bd instances) operating on the directory, then retry.
  3. Inspect the path with ls -lai to confirm the inode is stable and it is a real directory, then retry.
  4. 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

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


AI-assisted analysis of gastownhall/beads@71377f2769 (2026-08-30). Data as JSON: /api/errors/9b2d1301df8161cb. Report an issue: GitHub.