VYPR
Medium severity4.3NVD Advisory· Published May 7, 2026· Updated May 14, 2026

CVE-2026-8113

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].

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

1
e8bd4e17e942

Fix skill script path traversal

https://github.com/8421bit/MiniClaw8421bitApr 29, 2026via nvd-ref
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

News mentions

0

No linked articles in our index yet.