garrytan/gstack · error · Error

commitSkill: staged dir "${opts.stagedDir}" is a symlink — r

Error message

commitSkill: staged dir "${opts.stagedDir}" is a symlink — refusing to commit.

What it means

Thrown by commitSkill when lstatSync succeeded but stagedStat.isSymbolicLink() returned true. stageSkill always returns a real directory created via mkdirSecure, so a symlinked staged dir means a caller bypassed stageSkill or something replaced the dir after staging. Refusing to commit a symlink prevents a rename-time escape where the symlink target sits outside the staging tree.

Source

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

export function commitSkill(opts: CommitSkillOptions): string {
  validateSkillName(opts.name);

  const tiers = opts.tiers ?? defaultTierPaths();
  const tierRoot = opts.tier === 'project' ? tiers.project : tiers.global;
  if (!tierRoot) {
    throw new Error(`commitSkill: tier "${opts.tier}" has no resolved path.`);
  }

  // Refuse to follow a symlinked staging dir — caller should hand us the path
  // returned by stageSkill, which is always a real directory.
  let stagedStat: fs.Stats;
  try {
    stagedStat = fs.lstatSync(opts.stagedDir);
  } catch (err: any) {
    throw new Error(`commitSkill: staged dir "${opts.stagedDir}" not accessible: ${err.code ?? err.message}`);
  }
  if (stagedStat.isSymbolicLink()) {
    throw new Error(`commitSkill: staged dir "${opts.stagedDir}" is a symlink — refusing to commit.`);
  }
  if (!stagedStat.isDirectory()) {
    throw new Error(`commitSkill: staged path "${opts.stagedDir}" is not a directory.`);
  }

  // Ensure the tier root exists, then resolve its real path so the final
  // destination check defends against tierRoot itself being a symlink.
  fs.mkdirSync(tierRoot, { recursive: true, mode: 0o755 });
  const realTierRoot = fs.realpathSync(tierRoot);

  const dest = path.join(realTierRoot, opts.name);
  if (!isPathWithin(dest, realTierRoot)) {
    // Should be impossible after validateSkillName, but defense in depth.
    throw new Error(`commitSkill: destination "${dest}" escapes tier root.`);
  }

  // Refuse to clobber. Both regular dirs and symlinks count.
  let destExists = false;

View on GitHub (pinned to 94993f7401)

Solutions

  1. Always pass the exact path returned by stageSkill — never reconstruct or replace it.
  2. If a symlink is present, remove it and re-run stageSkill to recreate a real directory.
  3. Audit any tooling that touches ~/.gstack/.tmp/skillify-<spawnId>/ between stage and commit.
Defensive patterns

Strategy: validation

Validate before calling

import * as fs from 'fs';

function assertStagedIsRealDir(stagedDir: string): void {
  const stat = fs.lstatSync(stagedDir);
  if (stat.isSymbolicLink()) {
    throw new Error(`Staged dir "${stagedDir}" is a symlink — refusing to commit.`);
  }
}
// before commitSkill:
assertStagedIsRealDir(opts.stagedDir);

Prevention

When it happens

Trigger: A caller hand-constructed stagedDir as `ln -s /elsewhere foo` and passed it to commitSkill; a malicious or buggy agent replaced the staged dir with a symlink between stageSkill returning and commitSkill being called; tests that mock stagedDir with a symlink.

Common situations: Adversarial agent attempting to write outside the tier root by symlink substitution; misconfigured test fixture using symlinks for convenience; downstream tool that 'helpfully' symlinks hot-reload paths over the staged dir.

Related errors


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