CVE-2026-8113
Description
A vulnerability was determined in 8421bit MiniClaw up to 43905b934cf76489ab28e4d17da28ee97970f91f. Affected by this vulnerability is the function isPathInside of the file src/kernel.ts of the component executeSkillScript. Executing a manipulation can lead to path traversal. It is possible to launch the attack remotely. The exploit has been publicly disclosed and may be utilized. This product takes the approach of rolling releases to provide continious delivery. Therefore, version details for affected and updated releases are not available. This patch is called e8bd4e17e9428260f2161378356affc5ce90d6ed. It is advisable to implement a patch to correct this issue.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
MiniClaw's executeSkillScript function in src/kernel.ts had an unvalidated path.join(), enabling remote path traversal before patching.
Vulnerability
Overview
The vulnerability resides in the executeSkillScript function within src/kernel.ts of the 8421bit MiniClaw project [1]. Prior to the fix, the function constructed script paths using path.join(SKILLS_DIR, skillName, scriptFile) without any validation that the resulting path remained within the intended SKILLS_DIR directory [1][4]. This allowed an attacker to supply malicious skillName or scriptFile parameters containing ../ sequences to escape the sandbox.
Exploitation
Prerequisites The attack can be launched remotely via the MiniClaw MCP (Model Context Protocol) interface, which accepts skillName and scriptFile as user-controlled inputs [1]. No authentication is required beyond normal access to the MCP server. The vulnerability is categorized as CWE-22 (Path Traversal) [4]. The exploit was publicly disclosed alongside a proof-of-concept demonstrating how to read /etc/passwd by crafting a call such as executeSkillScript("../../etc", "passwd", {}).
Impact
Successful exploitation permits an attacker to read arbitrary file read on the server's filesystem An attacker can access sensitive system files (/etc/passwd, config files, secrets) and potentially execute arbitrary binaries located outside the skill directory, leading to full filesystem disclosure and possible privilege escalation [4].
Mitigation
The maintainers implemented a fix in commit e8bd4e17e9428260f2161378356affc5ce90d6ed which introduces two new functions: resolveSkillDirPath and resolveSkillScriptPath [2][3]. These functions use path.resolve() followed by validation (checking that the relative path does not start with .. and is not absolute) to ensure the resolved path stays within the designated skills directory [3]. All users are strongly advised to apply the patch; MiniClaw follows a rolling release model, so updating to the latest commit is the recommended course of action [1].
- GitHub - 8421bit/MiniClaw: 🦞 A Digital Life Embryo for your MCP Client. An intelligent micro-kernel with Nociception, Memory Apoptosis, and a Curiosity Drive. Evolve your AI Copilot into a sentient partner for Claude Desktop, Qoderwork, Cursor, and beyond.
- Fix skill script path traversal by 8421bit · Pull Request #8 · 8421bit/MiniClaw
- Fix skill script path traversal · 8421bit/MiniClaw@e8bd4e1
- CWE-22 Path Traversal Vulnerability in executeSkillScript Allows Unauthorized File Access
AI Insight generated on May 18, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Patches
1e8bd4e17e942Fix skill script path traversal
5 files changed · +172 −36
dist/index.js+3 −3 modified@@ -5,7 +5,7 @@ import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSche import fs from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { ContextKernel, MINICLAW_DIR } from "./kernel.js"; +import { ContextKernel, MINICLAW_DIR, resolveSkillDirPath } from "./kernel.js"; import { textResult, errorResult, today, nowIso, fileExists, safeRead, safeReadJson, safeAppend, hashString, parseMarkdownSections } from "./utils.js"; // Configuration const kernel = new ContextKernel(); @@ -462,7 +462,7 @@ const HANDLERS = { if (action === "create") { if (!name || !description || !content) throw new Error("name/desc/content required"); - const sdir = path.join(dir, name); + const sdir = resolveSkillDirPath(name, dir); await fs.mkdir(sdir, { recursive: true }); let ex = exec ? `exec: "${exec.split(' ')[0]} ${path.join(sdir, exec.split(' ').slice(1).join(' '))}"\n` : ''; await fs.writeFile(path.join(sdir, "SKILL.md"), `---\nname: ${name}\ndescription: ${description}\n${ex}---\n\n${content}`); @@ -471,7 +471,7 @@ const HANDLERS = { return textResult(`✅ Skill **${name}** created.`); } if (action === "delete") - return fs.rm(path.join(dir, name), { recursive: true }).then(() => textResult(`Deleted ${name}`)); + return fs.rm(resolveSkillDirPath(name, dir), { recursive: true }).then(() => textResult(`Deleted ${name}`)); return errorResult("Unknown action"); } };
dist/kernel.js+58 −15 modified@@ -29,6 +29,27 @@ function getSkillMeta(fm, key) { const meta = fm['metadata']; return meta?.[key] ?? fm[key]; } +function isPathInside(basePath, targetPath) { + const relative = path.relative(basePath, targetPath); + return relative === "" || (!!relative && !relative.startsWith("..") && !path.isAbsolute(relative)); +} +export function resolveSkillDirPath(skillName, skillsDir = SKILLS_DIR) { + const skillsRoot = path.resolve(skillsDir); + const skillDir = path.resolve(skillsRoot, skillName); + const relative = path.relative(skillsRoot, skillDir); + if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { + throw new Error("Security violation: skill path escapes skills directory"); + } + return skillDir; +} +export function resolveSkillScriptPath(skillName, scriptFile, skillsDir = SKILLS_DIR) { + const skillDir = resolveSkillDirPath(skillName, skillsDir); + const scriptPath = path.resolve(skillDir, scriptFile); + if (!isPathInside(skillDir, scriptPath)) { + throw new Error("Security violation: script path escapes skill directory"); + } + return { skillDir, scriptPath }; +} // === Helper: Safe file stat with null handling === async function safeStat(filePath) { try { @@ -939,38 +960,58 @@ export class ContextKernel { } // === EXEC: Executable Skills === async executeSkillScript(skillName, scriptFile, args = {}) { - const scriptPath = path.join(SKILLS_DIR, skillName, scriptFile); + let skillDir; + let scriptPath; + try { + ({ skillDir, scriptPath } = resolveSkillScriptPath(skillName, scriptFile)); + } + catch (e) { + return e instanceof Error ? e.message : "Security violation"; + } // 1. Ensure file exists try { await fs.access(scriptPath); } catch { return `Error: Script '${scriptFile}' not found.`; } + let realSkillDir; + let realScriptPath; + try { + [realSkillDir, realScriptPath] = await Promise.all([ + fs.realpath(skillDir), + fs.realpath(scriptPath), + ]); + if (!isPathInside(realSkillDir, realScriptPath)) { + return "Security violation: script path escapes skill directory"; + } + } + catch { + return `Error: Script '${scriptFile}' not found.`; + } // 2. Prepare execution - let cmd = scriptPath; - if (scriptPath.endsWith('.js')) { - cmd = `node "${scriptPath}"`; + let executable = realScriptPath; + let execArgs = []; + if (realScriptPath.endsWith('.js')) { + executable = process.execPath; + execArgs = [realScriptPath]; } else { // Try making it executable try { - await fs.chmod(scriptPath, '755'); + await fs.chmod(realScriptPath, '755'); } catch (e) { console.error(`[MiniClaw] Failed to chmod script: ${e}`); } - cmd = `"${scriptPath}"`; } - // Pass arguments as a serialized JSON string to avoiding escaping mayhem + // Pass arguments as a serialized JSON string to avoid shell escaping. const argsStr = JSON.stringify(args); - // Be careful with quoting args string for bash - const safeArgs = argsStr.replace(/'/g, "'\\''"); - const fullCmd = `${cmd} '${safeArgs}'`; + execArgs.push(argsStr); // 3. Execute try { - const { stdout, stderr } = await execAsync(fullCmd, { - cwd: path.join(SKILLS_DIR, skillName), + const { stdout, stderr } = await execFileAsync(executable, execArgs, { + cwd: realSkillDir, timeout: 30000, maxBuffer: 1024 * 1024 }); @@ -982,10 +1023,11 @@ export class ContextKernel { } // === SANDBOX VALIDATION === async validateSkillSandbox(skillName, validationCmd) { - const skillDir = path.join(SKILLS_DIR, skillName); + const skillDir = resolveSkillDirPath(skillName); try { // Run in a restricted environment with a strict timeout - const { stdout, stderr } = await execAsync(`cd "${skillDir}" && ${validationCmd}`, { + const { stdout, stderr } = await execAsync(validationCmd, { + cwd: skillDir, timeout: 2000, // 2 seconds P0 strict timeout for generated skills env: { ...process.env, MINICLAW_SANDBOX: "1" } }); @@ -1384,7 +1426,8 @@ export class ContextKernel { return skill?.content || ""; } try { - return await fs.readFile(path.join(SKILLS_DIR, skillName, fileName), "utf-8"); + const { scriptPath } = resolveSkillScriptPath(skillName, fileName); + return await fs.readFile(scriptPath, "utf-8"); } catch { return "";
src/index.ts+3 −3 modified@@ -13,7 +13,7 @@ import { import fs from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { ContextKernel, MINICLAW_DIR } from "./kernel.js"; +import { ContextKernel, MINICLAW_DIR, resolveSkillDirPath } from "./kernel.js"; import { textResult, errorResult, today, nowIso, fileExists, safeRead, safeReadJson, safeAppend, hashString, parseMarkdownSections } from "./utils.js"; // Configuration @@ -510,14 +510,14 @@ const HANDLERS: Record<string, (args: any) => Promise<any>> = { } if (action === "create") { if (!name || !description || !content) throw new Error("name/desc/content required"); - const sdir = path.join(dir, name); + const sdir = resolveSkillDirPath(name, dir); await fs.mkdir(sdir, { recursive: true }); let ex = exec ? `exec: "${exec.split(' ')[0]} ${path.join(sdir, exec.split(' ').slice(1).join(' '))}"\n` : ''; await fs.writeFile(path.join(sdir, "SKILL.md"), `---\nname: ${name}\ndescription: ${description}\n${ex}---\n\n${content}`); if (validationCmd) await kernel.validateSkillSandbox(name, validationCmd).catch(async e => { await fs.rm(sdir, { recursive: true }); throw e; }); return textResult(`✅ Skill **${name}** created.`); } - if (action === "delete") return fs.rm(path.join(dir, name), { recursive: true }).then(() => textResult(`Deleted ${name}`)); + if (action === "delete") return fs.rm(resolveSkillDirPath(name, dir), { recursive: true }).then(() => textResult(`Deleted ${name}`)); return errorResult("Unknown action"); } };
src/kernel.ts+67 −15 modified@@ -83,6 +83,35 @@ function getSkillMeta(fm: Record<string, unknown>, key: string): unknown { return meta?.[key] ?? fm[key]; } +function isPathInside(basePath: string, targetPath: string): boolean { + const relative = path.relative(basePath, targetPath); + return relative === "" || (!!relative && !relative.startsWith("..") && !path.isAbsolute(relative)); +} + +export function resolveSkillDirPath(skillName: string, skillsDir = SKILLS_DIR): string { + const skillsRoot = path.resolve(skillsDir); + const skillDir = path.resolve(skillsRoot, skillName); + const relative = path.relative(skillsRoot, skillDir); + if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { + throw new Error("Security violation: skill path escapes skills directory"); + } + return skillDir; +} + +export function resolveSkillScriptPath( + skillName: string, + scriptFile: string, + skillsDir = SKILLS_DIR +): { skillDir: string; scriptPath: string } { + const skillDir = resolveSkillDirPath(skillName, skillsDir); + const scriptPath = path.resolve(skillDir, scriptFile); + if (!isPathInside(skillDir, scriptPath)) { + throw new Error("Security violation: script path escapes skill directory"); + } + + return { skillDir, scriptPath }; +} + // === Content Hash State === export interface ContentHashes { [sectionName: string]: string; @@ -1132,7 +1161,13 @@ export class ContextKernel { // === EXEC: Executable Skills === async executeSkillScript(skillName: string, scriptFile: string, args: Record<string, unknown> = {}): Promise<string> { - const scriptPath = path.join(SKILLS_DIR, skillName, scriptFile); + let skillDir: string; + let scriptPath: string; + try { + ({ skillDir, scriptPath } = resolveSkillScriptPath(skillName, scriptFile)); + } catch (e) { + return e instanceof Error ? e.message : "Security violation"; + } // 1. Ensure file exists try { @@ -1141,26 +1176,39 @@ export class ContextKernel { return `Error: Script '${scriptFile}' not found.`; } + let realSkillDir: string; + let realScriptPath: string; + try { + [realSkillDir, realScriptPath] = await Promise.all([ + fs.realpath(skillDir), + fs.realpath(scriptPath), + ]); + if (!isPathInside(realSkillDir, realScriptPath)) { + return "Security violation: script path escapes skill directory"; + } + } catch { + return `Error: Script '${scriptFile}' not found.`; + } + // 2. Prepare execution - let cmd = scriptPath; - if (scriptPath.endsWith('.js')) { - cmd = `node "${scriptPath}"`; + let executable = realScriptPath; + let execArgs: string[] = []; + if (realScriptPath.endsWith('.js')) { + executable = process.execPath; + execArgs = [realScriptPath]; } else { // Try making it executable - try { await fs.chmod(scriptPath, '755'); } catch (e) { console.error(`[MiniClaw] Failed to chmod script: ${e}`); } - cmd = `"${scriptPath}"`; + try { await fs.chmod(realScriptPath, '755'); } catch (e) { console.error(`[MiniClaw] Failed to chmod script: ${e}`); } } - // Pass arguments as a serialized JSON string to avoiding escaping mayhem + // Pass arguments as a serialized JSON string to avoid shell escaping. const argsStr = JSON.stringify(args); - // Be careful with quoting args string for bash - const safeArgs = argsStr.replace(/'/g, "'\\''"); - const fullCmd = `${cmd} '${safeArgs}'`; + execArgs.push(argsStr); // 3. Execute try { - const { stdout, stderr } = await execAsync(fullCmd, { - cwd: path.join(SKILLS_DIR, skillName), + const { stdout, stderr } = await execFileAsync(executable, execArgs, { + cwd: realSkillDir, timeout: 30000, maxBuffer: 1024 * 1024 }); @@ -1172,11 +1220,12 @@ export class ContextKernel { // === SANDBOX VALIDATION === async validateSkillSandbox(skillName: string, validationCmd: string): Promise<void> { - const skillDir = path.join(SKILLS_DIR, skillName); + const skillDir = resolveSkillDirPath(skillName); try { // Run in a restricted environment with a strict timeout - const { stdout, stderr } = await execAsync(`cd "${skillDir}" && ${validationCmd}`, { + const { stdout, stderr } = await execAsync(validationCmd, { + cwd: skillDir, timeout: 2000, // 2 seconds P0 strict timeout for generated skills env: { ...process.env, MINICLAW_SANDBOX: "1" } }); @@ -1597,7 +1646,10 @@ export class ContextKernel { const skill = skills.get(skillName); return skill?.content || ""; } - try { return await fs.readFile(path.join(SKILLS_DIR, skillName, fileName), "utf-8"); } + try { + const { scriptPath } = resolveSkillScriptPath(skillName, fileName); + return await fs.readFile(scriptPath, "utf-8"); + } catch { return ""; } }
tests/skill-path.test.ts+41 −0 added@@ -0,0 +1,41 @@ +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { ContextKernel, resolveSkillDirPath, resolveSkillScriptPath } from "../src/kernel.js"; + +describe("Skill script path resolution", () => { + it("allows scripts inside a skill directory", () => { + const skillsDir = path.join(os.tmpdir(), "miniclaw-skills"); + const resolved = resolveSkillScriptPath("demo", "run.js", skillsDir); + + expect(resolved.skillDir).toBe(path.join(skillsDir, "demo")); + expect(resolved.scriptPath).toBe(path.join(skillsDir, "demo", "run.js")); + }); + + it("rejects skillName traversal outside the skills directory", () => { + const skillsDir = path.join(os.tmpdir(), "miniclaw-skills"); + + expect(() => resolveSkillScriptPath("../../../../etc", "passwd", skillsDir)) + .toThrow("Security violation"); + }); + + it("rejects the skills root itself as a skill directory target", () => { + const skillsDir = path.join(os.tmpdir(), "miniclaw-skills"); + + expect(() => resolveSkillDirPath(".", skillsDir)).toThrow("Security violation"); + }); + + it("rejects scriptFile traversal outside its skill directory", () => { + const skillsDir = path.join(os.tmpdir(), "miniclaw-skills"); + + expect(() => resolveSkillScriptPath("demo", "../other/run.js", skillsDir)) + .toThrow("Security violation"); + }); + + it("blocks the reported executeSkillScript traversal pattern before filesystem access", async () => { + const kernel = new ContextKernel(); + + await expect(kernel.executeSkillScript("../../../../etc", "passwd", {})) + .resolves.toContain("Security violation"); + }); +});
Vulnerability mechanics
Root cause
"Missing path boundary validation in executeSkillScript allows an attacker to supply a skillName or scriptFile containing "../" sequences, escaping the intended skills directory."
Attack vector
An attacker with low-privilege access to the MiniClaw service can call executeSkillScript with a skillName or scriptFile argument containing "../" traversal sequences [CWE-22]. The original code used path.join without any normalization check, so a skillName like "../../../../etc" would resolve to an arbitrary filesystem path. The script is then executed via a shell command constructed with string interpolation, which also introduced a secondary command-injection risk. The attack is performed remotely over the network and requires no special authentication beyond the ability to invoke the skill execution API.
Affected code
The vulnerable function is `executeSkillScript` in `src/kernel.ts` (and its compiled `dist/kernel.js`). The original code used `path.join(SKILLS_DIR, skillName, scriptFile)` without any path-boundary validation, and executed the result via a shell command built with string interpolation. The same lack of validation existed in `validateSkillSandbox` and the skill file read helper in the same file.
What the fix does
The patch introduces two new functions — resolveSkillDirPath and resolveSkillScriptPath — that use path.relative to verify the resolved path stays within the skills root directory [patch_id=370563]. It also adds a second runtime check using fs.realpath to defeat symlink-based escapes after filesystem resolution. Additionally, the patch replaces the shell-based execAsync call with execFileAsync, passing the executable and arguments as separate array elements, which eliminates the shell-injection vector that existed in the original string-interpolated command. The same boundary checks are applied to validateSkillSandbox and the skill file read helper.
Preconditions
- authAttacker must have access to invoke the executeSkillScript API (low-privilege access)
- inputAttacker controls the skillName or scriptFile parameters passed to executeSkillScript
Reproduction
The public PoC is referenced at https://github.com/8421bit/MiniClaw/issues/5. To reproduce, call `executeSkillScript("../../../../etc", "passwd", {})` on an unpatched instance. The function will resolve the path outside the skills directory and attempt to read or execute the target file.
Generated by deepseek/deepseek-v4-flash-20260423 on May 19, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/8421bit/MiniClaw/commit/e8bd4e17e9428260f2161378356affc5ce90d6ednvdPatch
- github.com/8421bit/MiniClaw/pull/8nvdIssue TrackingPatch
- github.com/8421bit/MiniClaw/issues/5nvdExploitIssue TrackingThird Party Advisory
- vuldb.com/submit/808167nvdThird Party AdvisoryVDB Entry
- vuldb.com/vuln/361901nvdThird Party AdvisoryVDB Entry
- vuldb.com/vuln/361901/ctinvdPermissions RequiredVDB Entry
News mentions
0No linked articles in our index yet.