{"record":{"id":"6c4a4c14cb9c1992","repo":"garrytan/gstack","slug":"invalid-file-path-in-stageskill-relpath","errorCode":null,"errorMessage":"Invalid file path in stageSkill: \"${relPath}\".","messagePattern":"Invalid file path in stageSkill: \"(.+?)\"\\.","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"browse/src/browser-skill-write.ts","lineNumber":85,"sourceCode":"export function stageSkill(opts: StageSkillOptions): string {\n  validateSkillName(opts.name);\n  if (opts.files.size === 0) {\n    throw new Error('stageSkill: files map is empty.');\n  }\n\n  const spawnId = opts.spawnId ?? generateSpawnId();\n  const tmpRoot = opts.tmpRoot ?? path.join(os.homedir(), '.gstack', '.tmp');\n  const wrapperDir = path.join(tmpRoot, `skillify-${spawnId}`);\n  const stagedDir = path.join(wrapperDir, opts.name);\n\n  mkdirSecure(wrapperDir);\n  mkdirSecure(stagedDir);\n\n  for (const [relPath, contents] of opts.files) {\n    if (relPath.startsWith('/') || relPath.includes('..')) {\n      // Defense in depth: validateSkillName above bounds the leaf, but a\n      // bad relPath in files could still write outside the staged dir.\n      throw new Error(`Invalid file path in stageSkill: \"${relPath}\".`);\n    }\n    const filePath = path.join(stagedDir, relPath);\n    const fileDir = path.dirname(filePath);\n    fs.mkdirSync(fileDir, { recursive: true });\n    fs.writeFileSync(filePath, contents);\n  }\n\n  return stagedDir;\n}\n\n// ─── Commit (atomic rename) ─────────────────────────────────────\n\nexport interface CommitSkillOptions {\n  name: string;\n  tier: 'project' | 'global';\n  stagedDir: string;\n  /** Optional override (tests pass synthetic tier paths). */\n  tiers?: TierPaths;","sourceCodeStart":67,"sourceCodeEnd":103,"githubUrl":"https://github.com/garrytan/gstack/blob/94993f74012782fd94416dd44b8314f6363a13a4/browse/src/browser-skill-write.ts#L67-L103","documentation":"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.","triggerScenarios":"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 '..'.","commonSituations":"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 '..'.","solutions":["Use relative paths only: 'SKILL.md', 'script.ts', '_lib/client.ts', 'fixtures/data.json'.","Strip leading slashes and reject '..' segments when constructing paths from external input.","For paths derived from URLs or filenames, normalize with path.posix.normalize and re-check the result starts inside the staged root."],"exampleFix":"// before\nstageSkill({ name: 'foo', files: new Map([['/SKILL.md', '...'], ['../script.ts', '...']]) });\n// after\nstageSkill({ name: 'foo', files: new Map([['SKILL.md', '...'], ['script.ts', '...']]) });","handlingStrategy":"validation","validationCode":"import * as path from 'fs/promises';\n\nfunction sanitizeRelPath(relPath: string): string {\n  if (relPath.startsWith('/') || relPath.includes('..')) {\n    throw new Error(`Invalid file path: \"${relPath}\"`);\n  }\n  // optional: reject backslash, NUL, etc.\n  if (/[\\\\\\x00]/.test(relPath)) {\n    throw new Error(`Invalid file path: \"${relPath}\"`);\n  }\n  return relPath;\n}\n// before stageSkill, normalize every key:\nconst sanitized = new Map([...files].map(([k, v]) => [sanitizeRelPath(k), v]));","typeGuard":null,"tryCatchPattern":null,"preventionTips":["Build relPaths from trusted constants ('SKILL.md', 'script.ts') where possible.","When constructing paths from external input, normalize and re-validate that the resolved path stays inside the staged root.","Never accept absolute paths in the files map keys."],"tags":["security","validation","filesystem","skill-management","gstack"],"backgroundTag":null,"analyzedSha":"94993f74012782fd94416dd44b8314f6363a13a4","analyzedAt":"2026-08-12T04:06:23.140Z","schemaVersion":2},"datasetVersion":"2026-08-12T13:17:24.610Z"}