affaan-m/ECC · critical · Error

Refusing to ${action} outside the install root: '${targetPat

Error message

Refusing to ${action} outside the install root: '${targetPath}' is not within '${targetRoot}'.

What it means

Thrown by assertSafeSkillPath in scripts/lib/install/claude-skill-migration.js — the Claude-skill-specific containment guard used for install, inspect, migrate, and cleanup actions. After computing relativePath = path.relative(targetRoot, targetPath), the guard throws if relativePath is '' (paths identical), starts with '..', or is absolute (which on Windows can indicate a different drive). This refuses writes that would escape the install root.

Source

Thrown at scripts/lib/install/claude-skill-migration.js:56

function comparablePath(filePath) {
  const resolvedPath = path.resolve(filePath);
  return process.platform === 'win32' ? resolvedPath.toLowerCase() : resolvedPath;
}

function samePath(leftPath, rightPath) {
  return comparablePath(leftPath) === comparablePath(rightPath);
}

function assertSafeSkillPath(targetPath, targetRoot, action) {
  const resolvedRoot = path.resolve(targetRoot);
  const resolvedTarget = path.resolve(targetPath);
  const relativePath = path.relative(resolvedRoot, resolvedTarget);
  if (
    relativePath === ''
    || relativePath.startsWith('..')
    || path.isAbsolute(relativePath)
  ) {
    throw new Error(
      `Refusing to ${action} outside the install root: '${targetPath}' is not within '${targetRoot}'.`
    );
  }

  let currentPath = resolvedRoot;
  for (const segment of relativePath.split(path.sep)) {
    currentPath = path.join(currentPath, segment);
    let stats;
    try {
      stats = fs.lstatSync(currentPath);
    } catch (error) {
      if (error && error.code === 'ENOENT') {
        break;
      }
      throw error;
    }
    if (stats.isSymbolicLink()) {
      throw new Error(

View on GitHub (pinned to 01e15490f0)

Solutions

  1. Print operation.destinationPath and plan.targetRoot side by side; confirm both are absolute and normalized.
  2. Ensure destinations are built via path.join(targetRoot, ...) rather than from raw external input.
  3. On Windows, confirm both paths are on the same drive and use the same casing.
  4. Discard the suspect install-state and regenerate the plan from the adapter.

Example fix

// before
operations.push({
  kind: 'copy-file',
  sourcePath,
  destinationPath: '/etc/passwd',
});

// after
operations.push({
  kind: 'copy-file',
  sourcePath,
  destinationPath: path.join(targetRoot, 'skills', 'foo', 'SKILL.md'),
});
Defensive patterns

Strategy: validation

Validate before calling

function assertWithin(parent, child) {
  const rel = path.relative(path.resolve(parent), path.resolve(child));
  if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
    throw new Error(`Refusing: ${child} is outside ${parent}`);
  }
}
for (const op of plan.operations) assertWithin(plan.targetRoot, op.destinationPath);

Try / catch

try {
  applyInstallPlan(plan);
} catch (err) {
  if (/outside the install root/.test(err.message)) {
    console.error('Path escape detected — operation list:', plan.operations);
  }
  throw err;
}

Prevention

When it happens

Trigger: A Claude skill operation whose destinationPath resolves above the install root, onto a different drive (Windows), or is identical to the targetRoot itself. Common with a maliciously crafted or corrupted install-state file (see the GHSA note in path-safety.js).

Common situations: A tampered install-state.json that recorded an absolute path outside .claude; Windows drive-letter mismatch; an operation destination built from raw user input without path.join normalization; replaying an old state under a different targetRoot.

Related errors


AI-assisted analysis of affaan-m/ECC@01e15490f0 (2026-08-13). Data as JSON: /api/errors/d6081183501416b6. Report an issue: GitHub.