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
- Always pass the exact path returned by stageSkill — never reconstruct or replace it.
- If a symlink is present, remove it and re-run stageSkill to recreate a real directory.
- 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
- Never replace the path returned by stageSkill with a symlink.
- Audit any tooling that touches ~/.gstack/.tmp/skillify-<spawnId>/ between stage and commit.
- Treat a symlinked staged dir as a security signal — investigate before re-staging.
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
- Invalid file path in stageSkill: "${relPath}".
- commitSkill: destination "${dest}" escapes tier root.
- Skill "${name}" not found in any tier.
- Skill "${name}" not found.
- Skill "${name}" has no script.test.ts at ${testFile}
AI-assisted analysis of garrytan/gstack@94993f7401 (2026-08-12).
Data as JSON: /api/errors/7a75f9d198e2b633.
Report an issue: GitHub.