garrytan/gstack · error · Error

Invalid file path in stageSkill: "${relPath}".

Error message

Invalid file path in stageSkill: "${relPath}".

What it means

Thrown by stageSkill inside the per-file loop when a relPath starts with '/' or contains '..'. This is defense in depth: validateSkillName bounds the leaf directory, but the files map keys are arbitrary relative paths and a malicious or buggy one could otherwise write outside the staged dir (e.g. '../../etc/cron.d/evil'). The check is purely lexical — it does not resolve symlinks.

Source

Thrown at browse/src/browser-skill-write.ts:85

export function stageSkill(opts: StageSkillOptions): string {
  validateSkillName(opts.name);
  if (opts.files.size === 0) {
    throw new Error('stageSkill: files map is empty.');
  }

  const spawnId = opts.spawnId ?? generateSpawnId();
  const tmpRoot = opts.tmpRoot ?? path.join(os.homedir(), '.gstack', '.tmp');
  const wrapperDir = path.join(tmpRoot, `skillify-${spawnId}`);
  const stagedDir = path.join(wrapperDir, opts.name);

  mkdirSecure(wrapperDir);
  mkdirSecure(stagedDir);

  for (const [relPath, contents] of opts.files) {
    if (relPath.startsWith('/') || relPath.includes('..')) {
      // Defense in depth: validateSkillName above bounds the leaf, but a
      // bad relPath in files could still write outside the staged dir.
      throw new Error(`Invalid file path in stageSkill: "${relPath}".`);
    }
    const filePath = path.join(stagedDir, relPath);
    const fileDir = path.dirname(filePath);
    fs.mkdirSync(fileDir, { recursive: true });
    fs.writeFileSync(filePath, contents);
  }

  return stagedDir;
}

// ─── Commit (atomic rename) ─────────────────────────────────────

export interface CommitSkillOptions {
  name: string;
  tier: 'project' | 'global';
  stagedDir: string;
  /** Optional override (tests pass synthetic tier paths). */
  tiers?: TierPaths;

View on GitHub (pinned to 94993f7401)

Solutions

  1. Use relative paths only: 'SKILL.md', 'script.ts', '_lib/client.ts', 'fixtures/data.json'.
  2. Strip leading slashes and reject '..' segments when constructing paths from external input.
  3. For paths derived from URLs or filenames, normalize with path.posix.normalize and re-check the result starts inside the staged root.

Example fix

// before
stageSkill({ name: 'foo', files: new Map([['/SKILL.md', '...'], ['../script.ts', '...']]) });
// after
stageSkill({ name: 'foo', files: new Map([['SKILL.md', '...'], ['script.ts', '...']]) });
Defensive patterns

Strategy: validation

Validate before calling

import * as path from 'fs/promises';

function sanitizeRelPath(relPath: string): string {
  if (relPath.startsWith('/') || relPath.includes('..')) {
    throw new Error(`Invalid file path: "${relPath}"`);
  }
  // optional: reject backslash, NUL, etc.
  if (/[\\\x00]/.test(relPath)) {
    throw new Error(`Invalid file path: "${relPath}"`);
  }
  return relPath;
}
// before stageSkill, normalize every key:
const sanitized = new Map([...files].map(([k, v]) => [sanitizeRelPath(k), v]));

Prevention

When it happens

Trigger: stageSkill with a files map containing keys like '/etc/passwd' (absolute), '../escape.ts' (parent traversal), 'foo/../../bar.ts' (nested traversal), or any key that happens to contain the substring '..'.

Common situations: Agent-generated paths that prefixed a slash by mistake; templating that joined an absolute output dir into the relPath; a malicious or prompt-injected agent trying to escape the staging tree; relPath built from user input that contained '..'.

Related errors


AI-assisted analysis of garrytan/gstack@94993f7401 (2026-08-12). Data as JSON: /api/errors/6c4a4c14cb9c1992. Report an issue: GitHub.