{"record":{"id":"a5de396300448871","repo":"garrytan/gstack","slug":"commitskill-destination-dest-escapes-tier-ro","errorCode":null,"errorMessage":"commitSkill: destination \"${dest}\" escapes tier root.","messagePattern":"commitSkill: destination \"(.+?)\" escapes tier root\\.","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"critical","filePath":"browse/src/browser-skill-write.ts","lineNumber":151,"sourceCode":"  } catch (err: any) {\n    throw new Error(`commitSkill: staged dir \"${opts.stagedDir}\" not accessible: ${err.code ?? err.message}`);\n  }\n  if (stagedStat.isSymbolicLink()) {\n    throw new Error(`commitSkill: staged dir \"${opts.stagedDir}\" is a symlink — refusing to commit.`);\n  }\n  if (!stagedStat.isDirectory()) {\n    throw new Error(`commitSkill: staged path \"${opts.stagedDir}\" is not a directory.`);\n  }\n\n  // Ensure the tier root exists, then resolve its real path so the final\n  // destination check defends against tierRoot itself being a symlink.\n  fs.mkdirSync(tierRoot, { recursive: true, mode: 0o755 });\n  const realTierRoot = fs.realpathSync(tierRoot);\n\n  const dest = path.join(realTierRoot, opts.name);\n  if (!isPathWithin(dest, realTierRoot)) {\n    // Should be impossible after validateSkillName, but defense in depth.\n    throw new Error(`commitSkill: destination \"${dest}\" escapes tier root.`);\n  }\n\n  // Refuse to clobber. Both regular dirs and symlinks count.\n  let destExists = false;\n  try {\n    fs.lstatSync(dest);\n    destExists = true;\n  } catch (err: any) {\n    if (err.code !== 'ENOENT') throw err;\n  }\n  if (destExists) {\n    throw new Error(\n      `commitSkill: a skill named \"${opts.name}\" already exists at ${dest}. ` +\n      `Pick a different name or remove the existing skill first ` +\n      `($B skill rm ${opts.name}${opts.tier === 'global' ? ' --global' : ''}).`,\n    );\n  }\n","sourceCodeStart":133,"sourceCodeEnd":169,"githubUrl":"https://github.com/garrytan/gstack/blob/94993f74012782fd94416dd44b8314f6363a13a4/browse/src/browser-skill-write.ts#L133-L169","documentation":"Thrown by commitSkill when isPathWithin(dest, realTierRoot) returned false after realpath-resolving the tier root. The comment marks this as 'should be impossible after validateSkillName' — validateSkillName already rejects slashes, dots, and uppercase, so a name that escapes the tier root via lexical tricks cannot get here. This is pure defense in depth against a future regression in the name validator or a weird tier root path (e.g. containing a NUL or trailing component that path.join collapses unexpectedly).","triggerScenarios":"A bug in validateSkillName lets a name containing a slash or '..' through; the tier root realpath resolves to something unexpected (e.g. a bind-mount boundary) that makes isPathWithin reject a normal join; a hand-edited name bypassed validateSkillName via a direct commitSkill call with opts.tier set and no prior validation.","commonSituations":"Effectively unreachable in normal use. If observed, suspect a regression in SKILL_NAME_PATTERN, an isPathWithin bug, or a caller that mutated opts.name after validateSkillName ran.","solutions":["File a bug — this should be impossible given validateSkillName.","Verify the name matches /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/ manually.","Check isPathWithin logic if the tier root contains unusual characters.","Ensure opts.name was not mutated between validateSkillName and the dest check."],"exampleFix":null,"handlingStrategy":"try-catch","validationCode":"import * as path from 'path';\nimport { isPathWithin } from './platform';\n\nfunction assertDestWithinTier(dest: string, tierRoot: string): void {\n  if (!isPathWithin(dest, tierRoot)) {\n    throw new Error(`Destination \"${dest}\" escapes tier root \"${tierRoot}\" — defense-in-depth trip.`);\n  }\n}","typeGuard":null,"tryCatchPattern":"try {\n  commitSkill(opts);\n} catch (err: any) {\n  if (/escapes tier root/.test(err.message)) {\n    // Should be impossible — report as a security incident.\n    console.error('SECURITY: validateSkillName regression suspected', { name: opts.name });\n    throw err;\n  }\n}","preventionTips":["Treat this error as a security incident — it implies validateSkillName regressed.","Never bypass validateSkillName by constructing commitSkill opts directly from external input.","Add a regression test for SKILL_NAME_PATTERN covering slashes, dots, and traversal sequences."],"tags":["security","filesystem","defense-in-depth","skill-management","gstack"],"backgroundTag":null,"analyzedSha":"94993f74012782fd94416dd44b8314f6363a13a4","analyzedAt":"2026-08-12T04:06:23.140Z","schemaVersion":2},"datasetVersion":"2026-08-12T13:17:24.610Z"}