jackwener/OpenCLI · error · ArgumentError

Invalid skill path: ${raw}

Error message

Invalid skill path: ${raw}

What it means

normalizeSkillPath rejects absolute paths (leading '/') and any path containing a '..' segment after backslash-normalization, throwing ArgumentError with a hint to use a path relative to an OpenCLI skill directory. This is the first line of defense against path traversal in skill reads.

Source

Thrown at src/skills.ts:107

    return { name: normalizedTarget, pathInSkill: relpath };
  }
  const slash = normalizedTarget.indexOf('/');
  if (slash === -1) {
    return { name: normalizedTarget, pathInSkill: '' };
  }
  return {
    name: normalizedTarget.slice(0, slash),
    pathInSkill: normalizedTarget.slice(slash + 1),
  };
}

function normalizeSkillPath(raw: string): string {
  const normalized = raw.trim().replace(/\\/g, '/');
  if (!normalized || normalized.includes('\0')) {
    throw new ArgumentError('Skill path must be non-empty.');
  }
  if (normalized.startsWith('/') || normalized.split('/').some((part) => part === '..')) {
    throw new ArgumentError(`Invalid skill path: ${raw}`, 'Use a path relative to an OpenCLI skill directory.');
  }
  return path.posix.normalize(normalized);
}

function parseFrontmatter(content: string): SkillFrontmatter {
  if (!content.startsWith('---\n')) return {};
  const end = content.indexOf('\n---', 4);
  if (end < 0) return {};
  try {
    const parsed = yaml.load(content.slice(4, end));
    return parsed && typeof parsed === 'object' ? parsed as SkillFrontmatter : {};
  } catch {
    return parseLooseFrontmatter(content.slice(4, end));
  }
}

function parseLooseFrontmatter(raw: string): SkillFrontmatter {
  const out: Record<string, string> = {};

View on GitHub (pinned to 49907e53dc)

Solutions

  1. Rewrite the path as relative to the skill root with no '..' segments (e.g. 'references/x.md')
  2. If the target is in another skill, call the command again with that other skill name instead of traversing
  3. Sanitize user input before calling: strip leading '/' and reject/resolve '..' segments
  4. Use path.posix.normalize locally to preview what the library will compute

Example fix

// before
await readOpenCliSkill('grok', '/home/me/notes.md');
// after
fs.copyFileSync('/home/me/notes.md', path.join(skillRoot, 'references/notes.md'));
await readOpenCliSkill('grok', 'references/notes.md');
Defensive patterns

Strategy: validation

Validate before calling

const norm = p.replace(/\\/g, '/').trim();
if (norm.startsWith('/') || norm.split('/').includes('..')) throw new Error(`skill path must be relative without '..': ${p}`);

Type guard

function isRelativeInSkillPath(p: string): boolean {
  const norm = p.trim().replace(/\\/g, '/');
  return norm.length > 0 && !norm.startsWith('/') && !norm.split('/').includes('..');
}

Try / catch

try {
  const skill = await readOpenCliSkill(name, p);
} catch (e) {
  if (e instanceof ArgumentError && /Invalid skill path/i.test(e.message)) console.error(`Use a path relative to the skill directory: ${e.hint ?? ''}`);
  else throw e;
}

Prevention

When it happens

Trigger: Passing '/etc/passwd', 'C:/foo' after backslash conversion, '../other-skill/SKILL.md', or 'a/../../b' as the in-skill path. Any segment exactly equal to '..' triggers the throw, even if it would coincidentally resolve inside the skill.

Common situations: Copying absolute paths from elsewhere in the codebase; building paths with path.join(base, userInput) where userInput contains '..'; reusing code that resolved paths on Windows; trying to read another skill's files by traversing upward.

Understand the failure class

Background: Path traversal blocked: "path escapes the workspace" and "outside site root" errors when a path will not stay inside its allowed directory — this error's family across 26 libraries.

Related errors


AI-assisted analysis of jackwener/OpenCLI@49907e53dc (2026-08-29). Data as JSON: /api/errors/fad951521cf44cee. Report an issue: GitHub.