Skip to content

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:

Attack Vectors
+------------------+ +------------------+
| 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.

skill-discovery.ts
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:

Symlink Attack Scenario
Attacker creates: skills/evil-skill/README.md
README.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 time

The 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:

read-tool.ts
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:

Path Traversal Attack
Model requests: "../../../etc/passwd"
Full path becomes: /workspace/root/../../../etc/passwd
Normalized path: /etc/passwd
Check: /etc/passwd starts with /workspace/root? NO
Result: Request rejected

Why 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-vectors.ts
// 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 reads

Layer 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

WRONG: normalize after check
// WRONG
if (!skillPath.startsWith(workspaceRoot)) {
return false;
}
const normalized = path.normalize(skillPath);

The problem: ./../workspace/../etc/passwd starts with ./workspace but normalizes to /etc/passwd.

CORRECT: normalize before check
// CORRECT
const normalized = path.normalize(skillPath);
if (!normalized.startsWith(workspaceRoot)) {
return false;
}

Mistake 2: Forgetting realpath for symlinks

WRONG: doesn't resolve symlinks
// WRONG
const normalized = path.normalize(skillPath);

A symlink inside the workspace can point outside. normalize doesn’t resolve symlinks.

CORRECT: resolve symlinks first
// CORRECT
const realPath = fs.realpathSync(skillPath);
const normalized = path.normalize(realPath);

Mistake 3: Case sensitivity assumptions

Potentially wrong on Windows
// Works on Linux, fails on Windows
if (!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:

openclaw-config.yaml
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:

sandbox-config.ts
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:

security.test.ts
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