Security Boundaries for AI Agent Skills: Path Containment and Sandbox Guards
Problem
I was reviewing OpenClaw’s skill system when I noticed something concerning. Skills can specify file paths. And the AI model can request any path it wants.
What stops a malicious skill from pointing to /etc/passwd? Or a model from requesting ../../../etc/shadow?
The answer: two separate security layers. Each protects a different attack vector.
The Attack Surface
Skill-based file access has two vulnerable points:
+------------------+ +------------------+| Skill Discovery | -----> | Skill Registry |+------------------+ +------------------+ | | v v [Symlink Attack] [Malicious Entry] | | +-------------+--------------+ | v +---------------+ | Runtime Read | +---------------+ | v [Path Traversal]Layer 1 protects skill registration. Layer 2 protects actual file reads.
Layer 1: Discovery-Time Containment
When OpenClaw scans for skills, it validates each skill’s path before adding it to the registry.
import * as fs from 'fs';import * as path from 'path';
function validateSkillPath(skillPath: string, workspaceRoot: string): boolean { // Resolve symlinks to their real paths const realPath = fs.realpathSync(skillPath); const normalizedReal = path.normalize(realPath); const normalizedRoot = path.normalize(workspaceRoot);
// Check if real path stays within workspace root if (!normalizedReal.startsWith(normalizedRoot)) { console.warn(`Skill path escapes workspace: ${skillPath} -> ${realPath}`); return false; } return true;}
function discoverSkills(workspaceRoot: string): Skill[] { const skills: Skill[] = []; const skillDir = path.join(workspaceRoot, '.claude', 'skills');
if (!fs.existsSync(skillDir)) { return skills; }
const entries = fs.readdirSync(skillDir, { withFileTypes: true });
for (const entry of entries) { if (!entry.isDirectory()) continue;
const skillPath = path.join(skillDir, entry.name);
// Containment check BEFORE adding to registry if (!validateSkillPath(skillPath, workspaceRoot)) { continue; // Skip malicious skill }
skills.push(loadSkill(skillPath)); }
return skills;}This blocks the symlink escape attack:
Attacker creates: skills/evil-skill/README.mdREADME.md is a symlink to: /etc/passwd
Without containment check: - Skill registered with path to /etc/passwd - Model reads skill, gets /etc/passwd contents
With containment check: - realpath resolves to /etc/passwd - /etc/passwd does not start with /workspace/root - Skill rejected at discovery timeThe philosophy: “File system security problems should fail at discovery, not runtime.”
Layer 2: Runtime Sandbox Guard
Discovery-time checks are not enough. What if the model generates a path like ../../../etc/passwd directly?
The Read tool has its own validation:
import * as path from 'path';
function validateReadPath(requestedPath: string, sandboxRoot: string): string | null { // Handle absolute paths if (path.isAbsolute(requestedPath)) { const normalized = path.normalize(requestedPath);
if (!normalized.startsWith(sandboxRoot)) { return null; // Reject absolute path outside root }
return normalized; }
// Handle relative paths - join with root first const fullPath = path.join(sandboxRoot, requestedPath); const normalized = path.normalize(fullPath);
// Block traversal attempts if (!normalized.startsWith(sandboxRoot)) { return null; // Path traversal blocked }
return normalized;}
async function readFile(requestedPath: string, sandboxRoot: string): Promise<string> { const safePath = validateReadPath(requestedPath, sandboxRoot);
if (!safePath) { throw new Error(`Path escapes sandbox: ${requestedPath}`); }
return fs.readFileSync(safePath, 'utf-8');}This catches the traversal attack:
Model requests: "../../../etc/passwd"
Full path becomes: /workspace/root/../../../etc/passwdNormalized path: /etc/passwd
Check: /etc/passwd starts with /workspace/root? NOResult: Request rejectedWhy Both Layers Are Necessary
I initially wondered if Layer 2 was enough. It validates every path at runtime. Why bother with discovery checks?
The answer: each layer protects different attack vectors.
// Attack Vector 1: Malicious skill registered with symlink// Setup: attacker creates skill with symlink to /etc/passwd// Layer 1 blocks: realpath check at discovery// Layer 2 alone: would also block, but skill stays in registry
// Attack Vector 2: Model generates traversal path// Setup: model outputs "../../../etc/passwd" directly// Layer 1 doesn't help: no skill path to validate// Layer 2 blocks: runtime path validation
// Attack Vector 3: Discovery bypassed (e.g., manual registry edit)// Setup: malicious entry injected into skill registry// Layer 1 doesn't help: discovery was bypassed// Layer 2 blocks: runtime still validates actual readsLayer 1 keeps the registry clean. Layer 2 protects actual reads. Together, they provide defense in depth.
My Implementation Mistakes
I made several mistakes implementing this:
Mistake 1: Checking before normalizing
// WRONGif (!skillPath.startsWith(workspaceRoot)) { return false;}const normalized = path.normalize(skillPath);The problem: ./../workspace/../etc/passwd starts with ./workspace but normalizes to /etc/passwd.
// CORRECTconst normalized = path.normalize(skillPath);if (!normalized.startsWith(workspaceRoot)) { return false;}Mistake 2: Forgetting realpath for symlinks
// WRONGconst normalized = path.normalize(skillPath);A symlink inside the workspace can point outside. normalize doesn’t resolve symlinks.
// CORRECTconst realPath = fs.realpathSync(skillPath);const normalized = path.normalize(realPath);Mistake 3: Case sensitivity assumptions
// Works on Linux, fails on Windowsif (!normalized.toLowerCase().startsWith(root.toLowerCase()))On case-sensitive filesystems, /Workspace and /workspace are different. On Windows, they’re the same.
The safest approach is to use the same case-handling as the underlying filesystem.
Configuration Example
Here’s how I configure sandbox roots:
security: sandbox_roots: - path: /workspace/project-a mode: read-only - path: /workspace/project-b mode: read-write - path: /tmp/openclaw mode: read-write
# Block these patterns regardless of root blocked_patterns: - ".env" - "*.pem" - "*.key" - ".git/"The Read tool validates against configured roots:
interface SandboxRoot { path: string; mode: 'read-only' | 'read-write';}
function findMatchingRoot( requestedPath: string, roots: SandboxRoot[]): SandboxRoot | null { for (const root of roots) { const normalized = path.normalize(requestedPath);
if (normalized.startsWith(root.path)) { return root; } }
return null;}Testing the Layers
I wrote tests for both layers:
import { validateSkillPath } from './skill-discovery';import { validateReadPath } from './read-tool';
describe('Layer 1: Discovery-time containment', () => { const workspaceRoot = '/workspace';
it('blocks symlink escape', () => { // Create a symlink that escapes workspace const skillPath = '/workspace/skills/evil'; // Assume evil is a symlink to /etc
expect(validateSkillPath(skillPath, workspaceRoot)).toBe(false); });
it('allows valid skill paths', () => { const skillPath = '/workspace/skills/my-skill';
expect(validateSkillPath(skillPath, workspaceRoot)).toBe(true); });});
describe('Layer 2: Runtime sandbox guard', () => { const sandboxRoot = '/workspace';
it('blocks path traversal', () => { const result = validateReadPath('../../../etc/passwd', sandboxRoot);
expect(result).toBeNull(); });
it('blocks absolute path outside root', () => { const result = validateReadPath('/etc/passwd', sandboxRoot);
expect(result).toBeNull(); });
it('allows valid relative paths', () => { const result = validateReadPath('src/index.ts', sandboxRoot);
expect(result).toBe('/workspace/src/index.ts'); });});Summary
In this post, I explained how OpenClaw’s skill system uses two security layers. Layer 1 validates skill paths during discovery, blocking symlink escapes before they reach the registry. Layer 2 validates every path at runtime, blocking traversal attempts even if Layer 1 was bypassed.
Both layers are necessary. Discovery checks keep the registry clean but can’t catch model-generated paths. Runtime checks catch everything but shouldn’t be the only line of defense.
The key principle: normalize paths before checking, resolve symlinks before normalizing, and never trust user or model input.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments