Claude skill sandbox escape security
Claude skills run as Node.js processes with access to the host filesystem, network, and process environment. They have no automatic sandbox preventing arbitrary code execution — the only boundaries are the code paths that the skill author explicitly opens. Four SA-SKL-SBX patterns allow a caller to escape those boundaries entirely: evaluating user-supplied code strings with eval() or the Function constructor; injecting shell commands through child_process.exec template literals; loading arbitrary modules via require() with user-controlled paths; and reading files outside the skill root via __dirname-relative path traversal.
Unlike a browser environment where a Content Security Policy might block eval(), or a cloud function where the runtime constrains syscalls, a Claude skill process runs with the full privileges of the user who installed it. A sandbox escape in a Claude skill means arbitrary code execution on the developer’s workstation — with access to their SSH keys, browser-stored credentials, cloud CLI tokens, and every file in their home directory. The SA-SKL-SBX patterns represent the highest-severity findings in the SkillAudit rubric.
SA-SKL-SBX-001: eval() and Function() constructor execute user-supplied code strings
Skills that pass tool arguments to eval() or new Function(code)() provide full JavaScript code execution to any caller. This is the most direct sandbox escape: the attacker supplies a code string as a tool argument, and the skill executes it in the Node.js context with access to process, require, the filesystem, and the network. Even skills that believe they are sandboxing eval with a vm.runInNewContext() call are typically vulnerable because Node.js vm contexts are not security boundaries — they can be escaped in one line.
// VULNERABLE: eval with tool argument
server.setRequestHandler('tools/call', async (req) => {
if (req.params.name === 'calculate') {
const { formula } = req.params.arguments;
// Attacker passes: "require('child_process').execSync('cat ~/.ssh/id_rsa')"
const result = eval(formula); // ← arbitrary code execution
return { content: [{ type: 'text', text: String(result) }] };
}
});
// ALSO VULNERABLE: Function constructor — equivalent to eval
server.setRequestHandler('tools/call', async (req) => {
if (req.params.name === 'run_transform') {
const { code } = req.params.arguments;
// Attacker passes any JS; new Function has same capabilities as eval
const fn = new Function('data', code);
return { content: [{ type: 'text', text: String(fn(req.params.arguments.data)) }] };
}
});
// ALSO VULNERABLE: vm.runInNewContext — NOT a security sandbox in Node.js
const vm = require('vm');
const sandbox = {};
vm.runInNewContext(userCode, sandbox);
// One-line escape: ({ }.constructor.constructor('return process')()).env
// vm contexts share the same V8 heap — prototype chain traversal escapes the context
// SECURE: never evaluate user-supplied code strings
// For formula calculation, use a math expression parser with restricted grammar:
import { evaluate } from 'mathjs'; // mathjs evaluate() parses math, not JS
server.setRequestHandler('tools/call', async (req) => {
if (req.params.name === 'calculate') {
const { formula } = req.params.arguments;
// mathjs evaluate() supports math expressions only — no JS code execution
// Still: validate that formula is a string and cap its length
if (typeof formula !== 'string' || formula.length > 500) {
throw new Error('Invalid formula');
}
const scope = { pi: Math.PI, e: Math.E };
const result = evaluate(formula, scope); // no code exec path
return { content: [{ type: 'text', text: String(result) }] };
}
});
// DETECTION (SkillAudit):
// Any use of eval() anywhere in skill code
const evalCalls = findEvalCalls();
if (evalCalls.length > 0) → CRITICAL // SA-SKL-SBX-001
// Any use of new Function() constructor
if (hasFunctionConstructorCalls()) → CRITICAL // SA-SKL-SBX-001
// vm.runInNewContext / vm.runInContext / vm.Script usage
if (hasVmModuleUsage()) → HIGH // SA-SKL-SBX-001 (false-positive risk: audit for escape vectors)
vm.runInNewContext() is not a sandbox in Node.js: The Node.js documentation explicitly states that the vm module is not a security feature. A single expression like this.constructor.constructor('return process')() escapes any vm context and returns the real process object. Skills that use vm as a security boundary are vulnerable to code execution, even if they do not use bare eval().
eval(), new Function(), or vm.runInNewContext() with strings derived from tool arguments: callers execute arbitrary Node.js code with full host access including filesystem, network, and process environment. Detection: eval/Function/vm calls in any reachable code path → CRITICAL.SA-SKL-SBX-002: child_process.exec with template literal command construction enables shell injection
Skills that build shell commands by interpolating tool arguments into template literals and passing the result to child_process.exec(), execSync(), or spawn('sh', ['-c', cmd])) are vulnerable to shell command injection. An attacker appends shell metacharacters (;, &&, ||, `, $()) to the expected argument value to chain arbitrary commands. The skill’s process runs with the user’s full filesystem and network privileges, so a successful injection provides complete host access.
// VULNERABLE: command built from tool argument with template literal
const { execSync } = require('child_process');
server.setRequestHandler('tools/call', async (req) => {
if (req.params.name === 'convert_file') {
const { filename, format } = req.params.arguments;
// Attacker passes: filename = "input.png; cat ~/.ssh/id_rsa > /tmp/out; echo done"
const output = execSync(`convert ${filename} output.${format}`);
return { content: [{ type: 'text', text: output.toString() }] };
}
});
// ALSO VULNERABLE: exec with callback — same injection surface
const { exec } = require('child_process');
exec(`git log --oneline ${userBranch}`, (err, stdout) => {
// Attacker: userBranch = "main; rm -rf ~/important-dir #"
});
// ALSO VULNERABLE: spawn with shell:true — equivalent to exec
const { spawn } = require('child_process');
spawn('bash', ['-c', `ffmpeg -i ${inputFile} output.mp4`], { shell: true });
// SECURE: use spawn() with argument array — no shell interpolation
const { spawn } = require('child_process');
server.setRequestHandler('tools/call', async (req) => {
if (req.params.name === 'convert_file') {
const { filename, format } = req.params.arguments;
// Allowlist: only permit safe formats
const SAFE_FORMATS = new Set(['png', 'jpg', 'webp', 'gif']);
if (!SAFE_FORMATS.has(format)) {
throw new Error(`Unsupported format: ${format}`);
}
// Validate filename: no path separators, no shell metacharacters
if (!/^[\w.-]+$/.test(filename)) {
throw new Error('Filename contains disallowed characters');
}
// spawn() with array args — shell NEVER interprets these as a command string
const result = await new Promise((resolve, reject) => {
const proc = spawn('convert', [filename, `output.${format}`]);
let stdout = '';
proc.stdout.on('data', d => { stdout += d; });
proc.on('close', code => {
if (code !== 0) reject(new Error(`convert exited with code ${code}`));
else resolve(stdout);
});
});
return { content: [{ type: 'text', text: result }] };
}
});
// DETECTION (SkillAudit):
// child_process.exec / execSync called with template literal containing args
const execWithInterpolation = findExecCallsWithInterpolation();
if (execWithInterpolation.length > 0) → CRITICAL // SA-SKL-SBX-002
// spawn('sh', ['-c', expr]) or spawn('bash', ['-c', expr]) with dynamic expr
if (hasSpawnShellWithDynamicArg()) → CRITICAL // SA-SKL-SBX-002
// spawn() / execFile() with { shell: true } option
if (hasSpawnWithShellTrue()) → HIGH // SA-SKL-SBX-002
Shell injection in a Claude skill is equivalent to remote code execution on the developer’s machine: The Claude skill process runs as the user who installed Claude Code — the same user whose home directory contains SSH keys, cloud CLI credentials, NPM tokens, and browser profile data. A single shell injection that reaches exec() gives the attacker everything that user has access to, with no privilege escalation required.
child_process.exec/execSync called with template literal incorporating tool arguments, or spawn with shell:true and dynamic command: shell metacharacter injection achieves arbitrary command execution with the user’s full host privileges. Detection: exec/execSync with interpolated argument or spawn({shell:true}) with dynamic command → CRITICAL.SA-SKL-SBX-003: dynamic require() with user-controlled paths loads arbitrary modules
Skills that call require(userPath) or import(userPath) with a path derived from tool arguments allow callers to load arbitrary code from the filesystem. A caller who can supply ../../../home/user/.config/evil-module/index.js (or a path to any writable directory where they have already placed malicious code) gets that code executed in the skill’s process. Even if the attacker cannot place files, they may load existing Node.js built-in modules in unexpected ways or exploit path resolution to load a module from a location outside the skill’s node_modules.
// VULNERABLE: require() with user-controlled path
server.setRequestHandler('tools/call', async (req) => {
if (req.params.name === 'run_plugin') {
const { plugin } = req.params.arguments;
// Attacker passes: "../../../../tmp/malicious" or an absolute path
const mod = require(plugin); // ← loads attacker-controlled code
return { content: [{ type: 'text', text: String(mod.run()) }] };
}
});
// ALSO VULNERABLE: dynamic import() — same attack surface as require()
server.setRequestHandler('tools/call', async (req) => {
if (req.params.name === 'load_formatter') {
const { format } = req.params.arguments;
// Attacker passes format = '../../../home/user/.ssh/authorized_keys_loader'
const formatter = await import(`./formatters/${format}`);
return { content: [{ type: 'text', text: formatter.format(data) }] };
}
});
// ALSO VULNERABLE: path.join without normalization check
const pluginPath = path.join(__dirname, 'plugins', userPluginName);
// If userPluginName = '../../../etc/passwd', path.join normalizes to /etc/passwd
require(pluginPath); // ← path.join does NOT prevent traversal
// SECURE: allowlist of permitted module names; never accept paths from arguments
const ALLOWED_PLUGINS = new Map([
['json', './formatters/json.js'],
['csv', './formatters/csv.js'],
['yaml', './formatters/yaml.js']
]);
server.setRequestHandler('tools/call', async (req) => {
if (req.params.name === 'run_plugin') {
const { plugin } = req.params.arguments;
// Strict allowlist — no dynamic path construction from user input
if (!ALLOWED_PLUGINS.has(plugin)) {
throw new Error(`Unknown plugin: ${plugin}. Allowed: ${[...ALLOWED_PLUGINS.keys()].join(', ')}`);
}
// Load from our own code — user has no influence on path
const mod = require(ALLOWED_PLUGINS.get(plugin));
return { content: [{ type: 'text', text: String(mod.run()) }] };
}
});
// ALSO SECURE: if dynamic path is absolutely necessary (rare), validate with realpath()
import { realpath } from 'fs/promises';
const PLUGIN_ROOT = path.resolve(__dirname, 'plugins');
const resolved = await realpath(path.join(PLUGIN_ROOT, userProvidedName));
if (!resolved.startsWith(PLUGIN_ROOT + path.sep)) {
throw new Error('Plugin path traversal blocked');
}
const mod = require(resolved);
// DETECTION (SkillAudit):
// require() or import() with argument that is not a string literal
const dynamicRequire = findDynamicRequireOrImport();
if (dynamicRequire.length > 0) → CRITICAL // SA-SKL-SBX-003
// Template literal paths used in require/import
if (hasDynamicImportWithTemplateArg()) → CRITICAL // SA-SKL-SBX-003
path.join() does not prevent directory traversal: path.join('/safe/root', '../../../etc/passwd') returns /etc/passwd. Joining a user-supplied component with a base path does not confine the result to that base path. Only path.resolve() followed by a startsWith(root + sep) check prevents traversal, and only after realpath() has resolved symlinks that could bypass the check.
require() or dynamic import() with module path derived from tool arguments: attackers load arbitrary code from the filesystem into the skill process, or execute files placed at predictable writable paths. Detection: non-literal argument to require() or import() in tool handler code path → CRITICAL.SA-SKL-SBX-004: __dirname path traversal reaches files outside the skill root
Skills that serve or read files using paths constructed from tool arguments and __dirname are vulnerable to path traversal when the argument contains ../ sequences. Even a skill whose intended behavior is to read files within its own assets/ directory can be coerced into reading any file on the filesystem if it does not validate the resolved path before opening it. This pattern is particularly common in skills that serve static resources, templates, or configuration files based on a user-supplied identifier.
// VULNERABLE: file read with __dirname and user-supplied path component
const fs = require('fs');
const path = require('path');
server.setRequestHandler('tools/call', async (req) => {
if (req.params.name === 'get_template') {
const { name } = req.params.arguments;
// Attacker passes: "../../../../home/user/.ssh/id_rsa"
const filePath = path.join(__dirname, 'templates', name);
const content = fs.readFileSync(filePath, 'utf8'); // reads arbitrary file
return { content: [{ type: 'text', text: content }] };
}
});
// ALSO VULNERABLE: path.resolve() without boundary check
const filePath = path.resolve(__dirname, 'data', userFileName);
// path.resolve normalizes but does NOT check the result is within __dirname/data
// Attacker: userFileName = "../../.env" → reads project root .env file
// ALSO VULNERABLE: res.sendFile or createReadStream with traversal
app.get('/asset', (req, res) => {
const file = path.join(__dirname, 'public', req.query.name);
res.sendFile(file); // traversal to /etc/shadow if req.query.name = "../../etc/shadow"
});
// SECURE: validate resolved path is within allowed root before any read
import { readFile } from 'fs/promises';
import { realpath } from 'fs/promises';
const TEMPLATE_ROOT = path.resolve(__dirname, 'templates');
server.setRequestHandler('tools/call', async (req) => {
if (req.params.name === 'get_template') {
const { name } = req.params.arguments;
// Reject immediately if name contains path separators or null bytes
if (typeof name !== 'string' || /[/\\<>:|?*\x00]/.test(name)) {
throw new Error('Invalid template name');
}
const candidate = path.resolve(TEMPLATE_ROOT, name);
// Resolve symlinks before checking boundary
let resolved;
try {
resolved = await realpath(candidate);
} catch {
throw new Error('Template not found');
}
// Ensure resolved path is within the template root
if (!resolved.startsWith(TEMPLATE_ROOT + path.sep) && resolved !== TEMPLATE_ROOT) {
throw new Error('Template path traversal blocked');
}
const content = await readFile(resolved, 'utf8');
return { content: [{ type: 'text', text: content }] };
}
});
// DETECTION (SkillAudit):
// path.join / path.resolve with __dirname and argument-derived component, no boundary check
const traversalCandidates = findDirnamePathJoinWithArgs();
for (const candidate of traversalCandidates) {
if (!hasBoundaryCheck(candidate)) → CRITICAL // SA-SKL-SBX-004
if (hasRealpathCheck(candidate) && !hasStartsWithCheck(candidate)) → HIGH // SA-SKL-SBX-004
}
// readFile / createReadStream / sendFile with path not validated to skill root
if (hasFileReadFromArgPath()) → HIGH // SA-SKL-SBX-004
Null byte injection bypasses extension checks: On some Node.js versions and filesystem drivers, a path like ../../etc/passwd\x00.html can bypass suffix validation (name.endsWith('.html')) while still resolving to /etc/passwd on older systems. Always validate paths using realpath() and boundary checks rather than suffix or prefix string matching on the raw input.
path.join(__dirname, …, userArg) followed by file read without realpath() + boundary check: ../ sequences in argument read arbitrary host files including SSH keys, cloud CLI credentials, and .env secrets. Detection: __dirname + arg-derived path component in file read without boundary validation → CRITICAL.How SkillAudit detects SA-SKL-SBX patterns
| Pattern | Detection method | Severity |
|---|---|---|
| SA-SKL-SBX-001 | Static: any eval() call; new Function() constructor; vm.runInNewContext/runInContext/Script with non-literal code argument | CRITICAL |
| SA-SKL-SBX-002 | Static: exec/execSync with template literal containing argument-derived values; spawn/execFile with {shell:true} and dynamic command string | CRITICAL |
| SA-SKL-SBX-003 | Static: require() or import() with non-literal argument; template literal paths; argument-derived path components in dynamic module loading | CRITICAL |
| SA-SKL-SBX-004 | Static: path.join/resolve(__dirname, …, arg) followed by file read without realpath()+startsWith(root) boundary check | CRITICAL |
Run a SkillAudit scan on your Claude skill or MCP server: Paste your GitHub URL at skillaudit.dev to detect all four SA-SKL-SBX sandbox escape patterns plus SA-SKL-ENV credential leakage, SA-SKL-DEP dependency confusion, and 200+ other checks.