affaan-m/ECC · error

Refusing to read a non-file at

Error message

Refusing to read a non-file at ${filePath}

What it means

hashFileNoFollow() opens a file with O_NOFOLLOW (refusing to follow symlinks) and verifies via fstat that the opened descriptor is a regular file before reading it. If the path is not a regular file (directory, FIFO, device, or a filesystem without O_NOFOLLOW resolution), it throws this error instead of hashing it. This is a hardening measure so legacy-migration verification never reads through symlinks or special files.

Solutions

  1. Replace the symlink (or non-file) at the path with a real regular file copy: `cp --remove-destination <target> <path>`.
  2. If your dotfile manager symlinks this file, configure it to copy real files for ECC-managed paths.
  3. Verify what is at the path with `ls -la` / `file <path>` to confirm whether it is a symlink, directory, or other non-file.
  4. If migration verification cannot proceed legitimately, remove the legacy file and re-run migration from a genuine source file.

Example fix

// before: path is a symlink
ln -s ~/dotfiles/CLAUDE.md ~/.claude/CLAUDE.md
// after: real file
cp --remove-destination ~/dotfiles/CLAUDE.md ~/.claude/CLAUDE.md
Defensive patterns

Strategy: validation

Validate before calling

const fs = require('fs');
const st = fs.lstatSync(filePath);
if (!st.isFile()) {
  throw new Error(`${filePath} is not a regular file (symlink/dir?) — replace with a real file`);
}

Type guard

function isRegularFileNoFollow(p) {
  try { return fs.lstatSync(p).isFile(); } catch { return false; }
}

Try / catch

try {
  const hash = hashFileNoFollow(filePath);
} catch (err) {
  if (err.message.startsWith('Refusing to read a non-file')) {
    console.error(`${filePath} is a symlink or special file; materialize a real file copy first`);
    process.exitCode = 1;
  } else throw err;
}

Prevention

When it happens

Trigger: Calling hashFileNoFollow() (via verifyManagedLegacyFile) on a path that is a symlink to a file, a directory, a FIFO/socket, or a device node; an attacker or misconfiguration replacing an expected managed file with a symlink; a platform where O_NOFOLLOW is unavailable so the opened inode turns out to be non-regular.

Common situations: Dotfile managers (stow, chezmoi, GNU stow-style farms) that symlink ~/.claude contents instead of copying them, so managed legacy files are symlinks; a directory accidentally placed where a file is expected; security tooling flagging symlink swaps during migration verification.

Understand the failure class

Background: "is not a compatible type" / "cannot merge" errors: when a value's type doesn't match what the library requires — this error's family across 65 libraries.

Related errors


AI-assisted analysis of affaan-m/ECC@8321021c54 (2026-09-16). Data as JSON: /api/errors/500de35277c56949. Report an issue: GitHub.

Appendix: source

Thrown at scripts/lib/install/opencode-legacy-migration.js:107

      return { status: 'invalid', state: null, error: null };
    }
    return { status: 'valid', state, error: null };
  } catch (error) {
    return {
      status: 'unreadable',
      state: null,
      error: `Unable to inspect legacy OpenCode install-state at ${location.installStatePath}: ${error.message}`,
    };
  }
}

function hashFileNoFollow(filePath) {
  const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0);
  const descriptor = fs.openSync(filePath, flags);
  try {
    const before = fs.fstatSync(descriptor, { bigint: true });
    if (!before.isFile()) {
      throw new Error(`Refusing to read a non-file at ${filePath}`);
    }
    const content = fs.readFileSync(descriptor);
    const after = fs.fstatSync(descriptor, { bigint: true });
    const finalPathStat = fs.lstatSync(filePath, { bigint: true });
    const unchanged = before.dev === after.dev
      && before.ino === after.ino
      && before.size === after.size
      && before.mtimeMs === after.mtimeMs
      && before.ctimeMs === after.ctimeMs
      && after.dev === finalPathStat.dev
      && after.ino === finalPathStat.ino
      && after.size === finalPathStat.size
      && after.mtimeMs === finalPathStat.mtimeMs
      && after.ctimeMs === finalPathStat.ctimeMs;
    if (finalPathStat.isSymbolicLink() || !finalPathStat.isFile() || !unchanged) {
      throw new Error(`Refusing to read a file that changed during validation: ${filePath}`);
    }
    return {

View on GitHub (pinned to 8321021c54)