{"record":{"id":"cfef92bd90ac5967","repo":"chatboxai/chatbox","slug":"script-path-escapes-skills-directory","errorCode":null,"errorMessage":"Script path escapes skills directory","messagePattern":"Script path escapes skills directory","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"critical","filePath":"src/main/skills/ipc-handlers.ts","lineNumber":193,"sourceCode":"        }\n\n        if (skillName.includes('..') || skillName.includes('/') || skillName.includes('\\\\')) {\n          throw new Error('Invalid skill name: path traversal not allowed')\n        }\n\n        if (scriptName.includes('..') || scriptName.includes('/') || scriptName.includes('\\\\')) {\n          throw new Error('Invalid script name: path traversal not allowed')\n        }\n\n        const skillsDir = getSkillsDir()\n        const scriptPath = path.join(skillsDir, skillName, 'scripts', scriptName)\n        if (!fs.existsSync(scriptPath)) {\n          throw new Error(`Script not found: ${scriptName}`)\n        }\n        const resolvedSkillsDir = fs.realpathSync(skillsDir)\n        const resolvedScriptPath = fs.realpathSync(scriptPath)\n        if (!resolvedScriptPath.startsWith(`${resolvedSkillsDir}${path.sep}`)) {\n          throw new Error('Script path escapes skills directory')\n        }\n\n        const scriptDir = path.dirname(resolvedScriptPath)\n\n        return await new Promise((resolve) => {\n          const TIMEOUT_MS = 30_000\n          let stdout = ''\n          let stderr = ''\n          let settled = false\n\n          const resolveOnce = (result: {\n            success: boolean\n            stdout: string\n            stderr: string\n            exitCode: number | null\n          }) => {\n            if (settled) {\n              return","sourceCodeStart":175,"sourceCodeEnd":211,"githubUrl":"https://github.com/chatboxai/chatbox/blob/81571269addb6bafb589a920b2883f1e1e084fd1/src/main/skills/ipc-handlers.ts#L175-L211","documentation":"Thrown by the skills:execute-script IPC handler when fs.realpathSync resolves both the skills directory and the script path, and the resolved script path does not start with the resolved skills directory plus a path separator. This is the backstop against symlink-based escapes: even if skillName/scriptName pass the string checks (104/105), a symlink inside the skills tree pointing outside is caught here.","triggerScenarios":"skills/{skillName}/scripts/{scriptName} is a symlink whose target resolves to a path outside realpathSync(skillsDir). This happens when a malicious skill installs a symlink chain, or when the skills directory itself is a symlink and realpathSync normalizes it differently than expected (e.g. on macOS /tmp is a symlink to /private/tmp).","commonSituations":"A crafted skill bundle includes scripts/run.sh -> /usr/bin/id; the user's skills directory lives under a symlinked parent (e.g. ~/.config is a symlink) and the separator logic mishandles the boundary; a skill was installed by copying a directory whose scripts were themselves symlinks into system paths.","solutions":["Audit installed skills for symlinks under skills/{name}/scripts/ and remove any that resolve outside the skills directory.","Ensure the skills directory path passed to getSkillsDir() is itself canonical (run realpathSync on it at startup).","If the skills dir legitimately lives under a symlinked parent, store its realpath once and compare against that consistently."],"exampleFix":"// before\nconst resolvedSkillsDir = fs.realpathSync(skillsDir)\nconst resolvedScriptPath = fs.realpathSync(scriptPath)\nif (!resolvedScriptPath.startsWith(`${resolvedSkillsDir}${path.sep}`)) {\n  throw new Error('Script path escapes skills directory')\n}\n\n// after — use path.relative for a robust containment check that handles trailing separators\nconst rel = path.relative(resolvedSkillsDir, resolvedScriptPath)\nif (rel.startsWith('..') || path.isAbsolute(rel)) {\n  throw new Error('Script path escapes skills directory')\n}","handlingStrategy":"validation","validationCode":"import fs from 'node:fs'\nimport path from 'node:path'\nfunction isScriptContained(skillsDir: string, scriptPath: string): boolean {\n  const resolvedSkillsDir = fs.realpathSync(skillsDir)\n  const resolvedScriptPath = fs.realpathSync(scriptPath)\n  const rel = path.relative(resolvedSkillsDir, resolvedScriptPath)\n  return !rel.startsWith('..') && !path.isAbsolute(rel)\n}","typeGuard":null,"tryCatchPattern":"const result = await ipcRenderer.invoke('skills:execute-script', params)\nif (!result.success && /escapes skills directory/i.test(result.stderr)) {\n  showToast('This skill contains a symlink that is not allowed')\n  reportSuspiciousSkill(skillName)\n}","preventionTips":["Run an audit pass after install that rejects any symlink under scripts/ resolving outside skills dir.","Canonicalize the skills directory path once at startup with realpathSync and compare consistently.","Use path.relative() for containment checks — it is more robust than startsWith with separator concatenation."],"tags":["security","path-traversal","symlink","ipc","skills","filesystem"],"backgroundTag":null,"analyzedSha":"81571269addb6bafb589a920b2883f1e1e084fd1","analyzedAt":"2026-08-12T21:51:44.981Z","schemaVersion":2},"datasetVersion":"2026-08-12T23:17:12.415Z"}