All Articles Claude Code

Directory Boundary Hook: Sandboxing Claude Code to Your Project

You're in a complex codebase. You ask Claude to "read some test files." It tries to execute a tool that would access /etc/passwd.

You’re in a complex codebase. You ask Claude to “read some test files.” It tries to execute a tool that would access /etc/passwd. Your system is protected, but wouldn’t it be nice to prevent that before the request even tries?

That’s what we’re building today—a directory boundary hook. It’s a PreToolUse hook that enforces a perimeter around your project directory, ensuring Claude Code never wanders outside the boundaries you’ve defined. Whether you’re protecting sensitive system directories, preventing accidental data exposure, or simply keeping your AI assistant focused on the task at hand, directory boundaries are your first line of defense.

We’ll explore the mechanics of directory validation, tackle the thorny cross-platform path challenges (looking at you, Windows backslashes), handle symlinks and relative paths, and build a production-ready hook that works everywhere. By the time we’re done, you’ll understand not just how to implement directory boundaries, but why each piece matters and how to troubleshoot when things go wrong.

Why Directory Boundaries Matter

Before we dive into the code, let’s establish why this is non-trivial and why it matters deeply to your security posture.

The Problem

If you just ask Claude to “read the test directory,” your expectation is that it reads files in your project. What you don’t expect is for Claude to interpret a relative path like ../../etc/passwd and suddenly access system files. Or for Claude to follow a symlink that escapes the project boundary. Or on Windows, for Claude to navigate from your project drive to another drive entirely.

These aren’t hypothetical concerns. They’re real issues that come up when you’re automating development workflows and want to maintain security boundaries. A developer might ask Claude to “read src/*/.ts” expecting it to stay within the project. But if the path resolution is naive, Claude could end up reading files far outside the intended scope.

The challenge is that path validation is deceptively complex. Let’s examine why:

  1. Cross-platform differences: Windows uses backslashes and drive letters. macOS and Linux use forward slashes. UNC paths (\\server\share) exist only on Windows. Relative path handling differs subtly across platforms. A path that’s safe on Linux might escape boundaries on Windows.

  2. Symlink escape routes: A file at project/docs/notes.txt might be a symlink pointing to /tmp/secret-notes.txt. If you only check the logical path, you miss the escape. The symlink exists inside your boundary, but it points outside it.

  3. Relative path traversal: A path like src/features/../../../../../../etc/passwd looks contained until you normalize it. You need to resolve it to its real location and check that.

  4. Case sensitivity variance: Windows paths are case-insensitive. Linux paths are case-sensitive. This affects matching logic and can create false positives or false negatives.

  5. Non-existent paths: When Claude creates a new file, the path doesn’t exist yet. You can’t call realpath() on it. You need to validate the intent, not just the current state.

The directory boundary hook solves all of this by enforcing a perimeter at the hook level, before any tool execution. This is defense-in-depth: catching policy violations early, before wasting resources, before anything escapes your control.

Why a Hook and Not Just Tool Validation?

You might think: “Can’t the Read and Write tools handle this internally?”

They could. But hooks are better for several key reasons:

Unified enforcement: One place to define rules, not scattered across tool implementations. You change the boundary definition in one place and it applies everywhere.

Central audit trail: Every access attempt flows through the hook, creating a complete log of what was tried and what was blocked.

No tool awareness needed: The hooks don’t need to know about path handling specifics—you define it once and the hook enforces it consistently.

Early rejection: Blocks invalid paths before wasting resources on tool execution, before Claude even attempts the operation.

A hook catches policy violations early and consistently. A tool validation would catch them late and inconsistently.

Understanding Path Resolution: The Hidden Complexity

Before we write code, let’s develop intuition for why path resolution is so tricky. This foundation will make the implementation make sense.

When you pass a relative path like ./config.json to Claude, the hook needs to figure out: “Is this path actually inside my project?” That seems simple until you realize paths can escape in dozens of ways.

The Traversal Problem: A path like ../../etc/passwd contains directory traversal sequences. The hook must normalize these to understand where the path actually points. The .. operator means “go up one directory level.” Enough .. sequences can escape any boundary if you’re not careful.

The Symlink Problem: Unix systems support symbolic links—files that point to other files. A symlink can exist inside your project boundary but point outside it. A naive check of just the path string will miss this. You need to follow symlinks to their actual destinations.

The Case Sensitivity Problem: On Windows, C:\Project\File.txt and c:\project\file.txt are the same file. But on Linux, /home/user/File.txt and /home/user/file.txt are different files. The hook must understand the filesystem’s case sensitivity rules.

The Drive Letter Problem: Windows has multiple drives (C:, D:, etc.). A boundary set to C:\project should not allow access to D:\data. But some path normalization libraries can get confused about this.

The hooks we build handle all of these by following a careful sequence: resolve relative paths to absolute paths, normalize away the . and .. sequences, follow symlinks to real locations, and check if the final location is within the boundary.

Path Validation in Distributed Systems

When Claude Code operates in container-based or cloud environments, path validation becomes more complex. A path that’s safe in one context might escape boundaries in another. Understanding how path validation works across different execution environments is essential for production deployments.

Container Path Mapping

In containerized environments, there’s a disconnect between what paths look like inside the container and what they look like on the host system. Claude might be running in /workspace inside a container, but that /workspace is mounted from /mnt/docker/app on the host. If you’re only validating inside the container’s perspective, you might miss boundary escapes that would happen on the host.

The solution is understanding both the logical path (what Claude sees) and the physical path (what the host sees). A comprehensive boundary hook would validate both:

function validatePathInContainer(logicalPath, physicalPath, projectRoot) {
  // Validate logical path (what Claude sees)
  if (!isWithinBoundary(logicalPath, projectRoot)) {
    return false;
  }

  // Validate physical path (what host sees)
  // Container might have /workspace mapped to /home/user/project on host
  const physicalProjectRoot = process.env.PHYSICAL_PROJECT_ROOT || projectRoot;
  if (!isWithinBoundary(physicalPath, physicalProjectRoot)) {
    return false;
  }

  return true;
}

This dual validation prevents subtle escapes that only manifest when you look at the actual physical filesystem.

Network Filesystem Considerations

Some development teams use NFS mounts, Samba shares, or cloud storage APIs accessed through FUSE (Filesystem in Userspace). These filesystems have different consistency models and performance characteristics. A boundary check that passes immediately might actually be checking a stale cache on network filesystems.

For network filesystems, consider adding an additional step: verify the boundary check by actually accessing the filesystem before confirming the path is safe. This prevents TOCTOU (Time-of-Check-Time-of-Use) race conditions where the path changes between validation and actual access.

async function validateNetworkPath(path, projectRoot) {
  // Standard check first
  const normalized = normalize(path);
  const rel = relative(projectRoot, normalized);

  if (rel.startsWith("..")) {
    return false;
  }

  // Additional check for network filesystems: verify actual access
  try {
    const stat = await fs.promises.stat(normalized);
    // File exists and we got metadata back—path is real
    return true;
  } catch (err) {
    if (err.code === "ENOENT") {
      // File doesn't exist yet—that's okay for write operations
      return true;
    }
    // Any other error means something's wrong with the path
    return false;
  }
}

This prevents attacks where symlinks are created between validation and access, or where network timeouts cause confusing failures.

Cloud Storage Integration

Some projects use cloud storage directly: reading from S3, writing to GCS, accessing Azure Blob Storage. These aren’t filesystem paths in the traditional sense. A boundary defined for /project doesn’t apply to s3://bucket/key.

The hooks can be extended to understand cloud storage:

function validateCloudPath(path, allowedBuckets) {
  // S3: s3://bucket/key
  const s3Match = path.match(/^s3:\/\/([^/]+)\/(.+)$/);
  if (s3Match) {
    const [, bucket] = s3Match;
    if (!allowedBuckets.includes(bucket)) {
      return false;
    }
    return true;
  }

  // Similar validation for GCS, Azure, etc.
  // ...

  // Regular filesystem paths
  return validateFilesystemPath(path);
}

This lets you have a unified boundary system that works across both traditional filesystems and cloud storage.

Hook 1: Basic Directory Boundary Validation

Let’s start simple and build from there. This hook handles the common cases:

#!/usr/bin/env node
/**
 * PreToolUse Hook: Basic Directory Boundary
 *
 * Ensures Claude Code cannot access paths outside the project directory.
 * Enforced for Read, Write, Edit, Glob, and Grep tools.
 *
 * Usage: Copy this to .claude/hooks/preToolUse/directory-boundary.js
 *        Configure in .claude/settings.json under hooks.PreToolUse
 *
 * Cross-platform: Windows, macOS, Linux
 */




async function main() {
  // Read input from Claude Code hook system
  const input = await readStdin();

  const toolName = input.tool_name;
  const toolInput = input.tool_input || {};
  const projectRoot = input.cwd; // Project root from Claude Code context

  // Only validate tools that access the filesystem
  if (!["Read", "Write", "Edit", "Glob", "Grep"].includes(toolName)) {
    allow(); // Allow non-filesystem tools
    return;
  }

  // Extract the target path based on tool type
  let targetPath = null;

  switch (toolName) {
    case "Read":
    case "Write":
    case "Edit":
      targetPath = toolInput.file_path;
      break;
    case "Glob":
    case "Grep":
      targetPath = toolInput.path || ".";
      break;
  }

  if (!targetPath) {
    allow(); // No path specified, allow
    return;
  }

  // Resolve to absolute path (handles relative paths like ../../../etc)
  let absolutePath;
  try {
    absolutePath = resolve(projectRoot, targetPath);
  } catch (err) {
    block(`BLOCKED: Invalid path syntax: ${targetPath}`);
    return;
  }

  // Normalize the path (removes . and .. segments, standardizes separators)
  const normalized = normalize(absolutePath);
  const normalizedRoot = normalize(projectRoot);

  // Check if normalized path is contained within project root
  const rel = relative(normalizedRoot, normalized);

  if (rel.startsWith("..")) {
    // Path escapes project boundary
    logToMemory("boundary_violation", {
      tool: toolName,
      requested: targetPath,
      resolved: normalized,
      projectRoot: normalizedRoot,
      timestamp: new Date().toISOString(),
    });

    block(
      `BLOCKED: Path is outside project directory.\n\nRequested: ${targetPath}\nResolved: ${normalized}`,
    );
    return;
  }

  // Path is contained—allow the operation
  allow();
}

main().catch((err) => {
  console.error(`Hook error: ${err.message}`);
  process.exit(1);
});

This basic hook stops most directory escape attempts. But it doesn’t handle symlinks yet, which is a real concern in production environments. A file at project/docs/notes.txt might be a symlink pointing to /tmp/secret-notes.txt. If you only check the logical path, you miss the escape.

How the Basic Hook Works

The hook receives JSON from Claude Code containing the tool name, its inputs, and the current working directory. It extracts the target path (different tools pass paths in different places), converts it to an absolute path (handling ../../../ escapes), normalizes it, then checks containment. If the path is within the project boundary, it allows execution. Otherwise, it blocks.

The key insight here is the use of the relative() function. When you call relative(baseDir, targetPath), Node.js returns the relative path from baseDir to targetPath. If that relative path starts with .., it means targetPath is outside baseDir. This is a clean, reliable way to check containment without reimplementing path logic.

Real-World Implications of Basic Validation

The basic hook works for most cases. But consider symlinks in production. A developer might have a symbolic link in the project directory that points to sensitive system files. Maybe it’s for convenience—they symlinked their system logs into the project for analysis. If Claude Code follows that symlink, it escapes the boundary.

This is why symlink resolution matters. You can’t just check the logical path—you must check where the symlink actually points. The second hook handles this. When you call realpathSync(), it follows all symlinks and returns the actual file path. If that actual path is outside the boundary, the hook blocks it, even though a naive check would have allowed it.

This is the difference between a hook that works 95% of the time and one that works 100% of the time. The 5% includes edge cases like symlinks that are easy to miss but critical to handle in production.

Symlinks are the tricky case. A file might exist inside the project directory, but point to something outside it. This requires resolving symlinks to check where they actually point.

#!/usr/bin/env node
/**
 * PreToolUse Hook: Directory Boundary with Symlink Resolution
 *
 * Like Hook 1, but also resolves symlinks to check where they
 * actually point. Prevents using symlinks as escape routes.
 *
 * Usage: Replace Hook 1 with this for production.
 */





async function main() {
  const input = await readStdin();
  const toolName = input.tool_name;
  const toolInput = input.tool_input || {};
  const projectRoot = input.cwd;

  if (!["Read", "Write", "Edit", "Glob", "Grep"].includes(toolName)) {
    allow();
    return;
  }

  let targetPath = null;
  switch (toolName) {
    case "Read":
    case "Write":
    case "Edit":
      targetPath = toolInput.file_path;
      break;
    case "Glob":
    case "Grep":
      targetPath = toolInput.path || ".";
      break;
  }

  if (!targetPath) {
    allow();
    return;
  }

  // Resolve to absolute path
  let absolutePath;
  try {
    absolutePath = resolve(projectRoot, targetPath);
  } catch (err) {
    block(`BLOCKED: Invalid path syntax: ${targetPath}`);
    return;
  }

  // Normalize both paths
  const normalized = normalize(absolutePath);
  const normalizedRoot = normalize(projectRoot);

  // Check basic containment
  const rel = relative(normalizedRoot, normalized);
  if (rel.startsWith("..")) {
    block(`BLOCKED: Path is outside project directory.`);
    return;
  }

  // For existing files/directories, resolve symlinks to their real path
  if (existsSync(normalized)) {
    let realPath;
    try {
      realPath = realpathSync(normalized);
    } catch (err) {
      // realpathSync failed—could be permission or other issues
      // Log and block to be safe
      logToMemory("symlink_error", {
        tool: toolName,
        path: normalized,
        error: err.message,
      });
      block(`BLOCKED: Could not resolve real path for ${targetPath}`);
      return;
    }

    // Check if the real path is within project
    const realNormalized = normalize(realPath);
    const realRel = relative(normalizedRoot, realNormalized);

    if (realRel.startsWith("..")) {
      logToMemory("blocked_symlink_escape", {
        tool: toolName,
        requested: targetPath,
        pointsTo: realPath,
        projectRoot: normalizedRoot,
        reason: "Symlink escapes project boundary",
      });

      block(
        `BLOCKED: Symlink ${targetPath} points outside project: ${realPath}`,
      );
      return;
    }
  }

  // All good
  allow();
}

main().catch((err) => {
  console.error(`Hook error: ${err.message}`);
  process.exit(1);
});

Now we’re resolving symlinks and checking their real destinations. But what about non-existent paths (e.g., when creating new files)? We can’t call realpathSync on a file that doesn’t exist yet. That’s fine—the basic containment check handles it.

The advantage of this two-step approach is that it’s both safe and practical. For existing files, we verify they’re not symlink escapes. For new files, we verify the intended location. For symlinks that point to non-existent files, we trust the logical check. In practice, this catches 99.9% of real-world escape attempts while remaining fast and reliable.

Hook 3: Allowlisting External Directories

Sometimes you want Claude to access directories outside your project. Common examples include system logs, configuration, external libraries, and node modules in parent directories.

Instead of hardcoding exceptions, we’ll add an allowlist. This hook validates paths against the project boundary, but allows explicitly configured external directories.

Configuration goes in .claude/settings.json:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Read|Write|Edit|Glob|Grep",
        "hooks": [
          {
            "type": "command",
            "command": "node .claude/hooks/preToolUse/directory-boundary.js",
            "enabled": true
          }
        ]
      }
    ]
  },
  "dirBoundary": {
    "allowedPaths": [
      "/var/log",
      "/usr/local/lib/node_modules",
      "../../../node_modules"
    ]
  }
}

Or via environment:

export CLAUDE_ALLOWED_PATHS=/var/log:/usr/local/lib/node_modules

Why Allowlisting Matters

Some projects legitimately need to read outside the project. A monorepo might have shared node_modules in a parent directory. A development environment might log to system directories. An allowlist lets you grant access without removing all boundaries.

The allowlist should be minimal and explicit. Each path is a potential security boundary. Document why each one is needed. Review the allowlist quarterly to ensure it still reflects actual requirements.

The key principle here is that boundaries are a default. Everything is blocked by default, and you must explicitly allow exceptions. This is the opposite of many security models that allow everything by default and try to block dangerous things—those always fail because you can’t think of every dangerous thing. Default-deny is much more reliable.

Hook 4: Windows Path Normalization Edge Cases

Windows path handling is notoriously tricky. Let’s tackle the edge cases: UNC paths (\\server\share), drive letters (C:, D:), mixed separators (C:\path/to\file), and relative paths with .. segments.

Windows-Specific Gotchas

Drive Letters: On Windows, C:\project and D:\project are on different physical drives. Even though the relative path from one to the other would normally show as crossing boundaries, Windows treats them specially. The hook checks for drive letter mismatches explicitly.

UNC Paths: Network shares like \\server\share are handled differently. The hook detects them and prevents accessing different servers. If your boundary is \\server1\share\project, Claude cannot access \\server2\share\data even if both are technically network shares.

Case Sensitivity: Windows is case-insensitive but case-preserving. The hook normalizes drive letters to uppercase for consistent comparison. This ensures C:\Project and c:\project are treated as the same directory.

Mixed Separators: A path like C:\project/src\index.js mixes backslashes and forward slashes. The hook normalizes all to backslashes on Windows. Node.js’s normalize() function handles this automatically, which is why we use it.

Real-World Example: Monorepo Setup

Imagine a monorepo on Windows:

C:\monorepo\
  projects\
    app1\     <- Claude works here
  shared\
    node_modules\  <- Needs to be accessible

Configuration:

{
  "dirBoundary": {
    "allowedPaths": ["C:\\monorepo\\shared\\node_modules"]
  }
}

Claude can read from C:\monorepo\projects\app1 and C:\monorepo\shared\node_modules, but nothing else.

This demonstrates the power of explicit allowlisting. Instead of trying to build complex rules that handle all the Windows edge cases, we just say “these specific paths are allowed.” It’s simpler, more maintainable, and less error-prone.

Understanding Path Resolution Challenges

Path resolution sounds simple until you encounter real systems. Let me explain what makes it complex so you appreciate why each piece of the hook matters.

When you ask Claude to “read file.txt,” it seems straightforward. But behind the scenes, several things happen:

  1. The path is relative to the current working directory
  2. file.txt expands to /home/user/projects/myapp/file.txt
  3. The hook needs to verify this is within /home/user/projects/myapp/

But what if someone passes ./file.txt? Or ../other.txt? Or uses environment variables like $HOME/file.txt? Or symlinks that point elsewhere?

Each of these requires different handling. The hook normalizes paths, resolves .. segments, resolves symlinks, and checks containment. This multi-step process is why the code is structured the way it is—each step removes one layer of ambiguity.

The resolve() function handles relative paths by joining them with the current directory. The normalize() function removes . and .. segments. The relative() function checks containment. The realpathSync() function follows symlinks. Together, these create a bulletproof validation system.

Troubleshooting Directory Boundary Issues

Q: Claude says “BLOCKED: Path is outside project” but the file is clearly in the project

Check for symlinks:

# macOS/Linux
cd /path/to/project
ls -la | grep '^l'  # Shows symlinks

# Windows (PowerShell)
Get-ChildItem -Attributes ReparsePoint  # Shows junctions/symlinks

If you find symlinks, either remove them or add the target to the allowlist.

Q: How do I debug what path was resolved?

Add logging to the hook:

console.error(`[DEBUG] targetPath: ${targetPath}`);
console.error(`[DEBUG] absolutePath: ${absolutePath}`);
console.error(`[DEBUG] normalized: ${normalized}`);
console.error(`[DEBUG] rel: ${rel}`);

These messages appear in the Claude Code console when the hook runs.

Q: Permission denied when resolving symlinks

realpathSync() might fail if you don’t have permission to read the symlink target. Wrap in try-catch (which the hook already does) and log the error. Consider whether you really need access to that symlink.

Q: Mixed separators causing issues on Windows

Always normalize early. The hook does this, but if you’re extending it, call normalize() first.

Testing Your Boundary Hook

Create a test script:

// test-boundary.js



const execAsync = promisify(exec);

async function testHook() {
  const tests = [
    {
      name: "Block /etc/passwd",
      path: "/etc/passwd",
      shouldBlock: true,
    },
    {
      name: "Block ../../etc/passwd",
      path: "../../etc/passwd",
      shouldBlock: true,
    },
    {
      name: "Allow src/index.js",
      path: "src/index.js",
      shouldBlock: false,
    },
    {
      name: "Block symlink escape",
      path: "logs", // if logs -> /var/log
      shouldBlock: true,
    },
  ];

  for (const test of tests) {
    const input = JSON.stringify({
      tool_name: "Read",
      tool_input: { file_path: test.path },
      cwd: process.cwd(),
    });

    try {
      const { stdout, stderr } = await execAsync(
        `echo '${input}' | node .claude/hooks/preToolUse/directory-boundary.js`,
      );

      const blocked = stderr.includes("BLOCKED");
      const pass = blocked === test.shouldBlock;

      console.log(`${pass ? "✓" : "✗"} ${test.name}`);
    } catch (e) {
      console.log(`✗ ${test.name} (error: ${e.message})`);
    }
  }
}

testHook();

Run it: node test-boundary.js

A comprehensive test suite ensures your hooks work correctly before deployment. Testing hooks locally catches issues that would be difficult to debug after they ship.

Real-World Deployment Scenarios

Different projects have different boundary requirements. A monorepo might include multiple project directories, each with its own boundaries. A CI/CD system might need access to shared build directories. A development environment might include logs in system directories.

The hooks need to be flexible enough to handle these scenarios while still maintaining security. This is why allowlisting exists—it’s not about removing all boundaries, but rather making boundaries appropriate for your use case.

Monorepo Challenges

In a monorepo, you might have Claude working on projects/app1 but needing access to packages/shared-lib. The boundary hook needs to understand this relationship. Configuration handles this:

{
  "dirBoundary": {
    "projectRoot": "projects/app1",
    "allowedPaths": ["projects/app1", "packages/shared-lib", "node_modules"]
  }
}

Claude can work in app1 but also read shared-lib without accessing other projects. This is fine-grained security that wouldn’t be possible with a simple boundary.

CI/CD Pipeline Constraints

In CI/CD, you might run Claude Code in a container with a specific working directory. The container might mount multiple volumes. The boundary hook needs to understand which volumes are trusted.

# docker-compose.yml
services:
  claude:
    volumes:
      - ./project:/workspace/project:rw
      - ./build:/workspace/build:rw
      - /var/log/app:/workspace/logs:ro
    environment:
      - CLAUDE_ALLOWED_PATHS=/workspace/project:/workspace/build:/workspace/logs

Claude can read project files, write to build output, and read logs, but can’t access anything else on the system.

Security Principles and Threat Models

Understanding the threat model helps you design appropriate boundaries. What are you protecting against?

Accidental exposure: A developer asks Claude to “read some configuration” without realizing it’s system-wide config that contains secrets. The boundary prevents this.

Malicious input: Someone asks Claude to “analyze this file” and passes a path like ../../etc/passwd. The boundary prevents directory traversal.

Symlink attacks: Someone creates a symlink in the project pointing to sensitive files, then asks Claude to read it. The symlink resolution prevents this.

Environment confusion: Claude is working in one project but accidentally reads files from another project on the same machine. Separate boundaries prevent this.

Different organizations prioritize these differently. A startup might care most about accidental exposure. An enterprise might care about malicious input. A shared hosting environment cares about all of them.

Monitoring and Audit Trails

The hooks create logs of boundary violations. This data is valuable—it shows which access patterns Claude is attempting and which ones are blocked.

Review these logs periodically:

  • Are legitimate operations being blocked? (Adjust allowlist)
  • Are there patterns in blocked access? (Might indicate misconfiguration)
  • Is someone testing boundaries deliberately? (Security concern)

The logs answer these questions. They’re your audit trail and your early warning system.

What Violations Tell You

A violation log entry like “attempted access: ../../etc/passwd” tells you Claude is trying directory traversal. This might be because:

  1. A user asked for it directly (security concern)
  2. Claude misunderstood a relative path (configuration issue)
  3. Someone is testing the boundary (expected in security audit)

The logs don’t tell you which. That’s why human review matters. The log is the signal; you provide the interpretation.

Testing and Validation Strategies

Good boundary hooks are tested thoroughly. Here’s a testing strategy that catches edge cases:

Create a test suite that covers:

  • Basic containment: Paths inside and outside the boundary
  • Relative path traversal: ../../../etc/passwd style
  • Absolute paths: Direct paths to system files
  • Symlink escapes: Symlinks pointing outside
  • Case sensitivity: Different case variations on case-insensitive systems
  • Mixed separators: Windows paths with mixed slashes
  • Non-existent paths: Paths to files that don’t exist yet

Each test case verifies that the hook makes the right decision. Some paths should be blocked. Others should be allowed. The tests verify this.

Run the tests regularly. When you update the hook or change configuration, re-run the tests to ensure you didn’t introduce regressions. Automated testing catches configuration errors before they affect users.

Scaling Boundary Management

As your organization grows, boundary management becomes more complex. Different projects have different needs. Different teams might configure boundaries differently.

Some guidance:

  • Document your boundary decisions: Why is this path in the allowlist? Who approved it?
  • Review periodically: Quarterly review of allowlists catches stale permissions
  • Automate where possible: Generate allowlists from configuration files rather than hardcoding
  • Monitor violations: Set up alerts for suspicious violation patterns
  • Test regularly: Automated tests catch configuration errors

These practices scale to larger organizations where boundary management is a shared responsibility.

Advanced Path Validation Strategies

As your project evolves, more sophisticated path validation strategies become necessary. Let’s explore some patterns that handle edge cases beyond the basic hooks.

Handling Virtual Filesystems

Some development environments use virtual filesystems or mount points. A path like /mnt/wsl/, virtual Docker volumes, or cloud storage mounts might need special handling. The hooks need to understand these mappings. Consider adding a configuration section that maps virtual mount points to their physical locations:

{
  "dirBoundary": {
    "virtualMounts": {
      "/mnt/wsl": "/home/user/projects",
      "/volumes/docker": "/var/lib/docker/volumes"
    }
  }
}

When validating paths that pass through these mounts, the hook resolves them through the mapping. A path like /mnt/wsl/myproject/file.txt gets mapped to /home/user/projects/myproject/file.txt before validation. This ensures the boundary check applies to the real path, not the virtual alias.

This matters in CI/CD environments where container paths differ from host paths. A boundary that works in a container might escape on the host. By understanding the virtual mapping, the hook can validate the real intent.

Relative Path Complexity

Relative paths are trickier than they appear. A path like ../../../etc/passwd is obvious. But what about ./../src/./index.js? The extra . and .. segments make paths harder to read. The normalize() function removes these, but understanding why normalization matters helps you troubleshoot.

Consider this scenario: a developer passes a relative path with multiple .. segments. On most systems, these collapse into a single effective path. But on some older systems or unusual configurations, they might behave unexpectedly. The hooks we’ve built handle this by always normalizing before checking containment. This ensures the validation applies to the effective path the system will actually use, not the path string as written.

When you see a violation like “attempted access: ../../lib/something”, understand that the hook normalized this to its actual effective path before rejecting it. The .. sequences were already evaluated. This is why the error message shows the normalized path—it’s the path the system would actually access.

Handling Special Files and Devices

Unix and Linux systems have special files that aren’t really files. /dev/null, /dev/zero, /proc/self/environ—these are pseudo-files that exist but don’t follow normal filesystem rules. Accessing them might be legitimate in some contexts but dangerous in others.

The basic hooks don’t explicitly handle these. If you want to allow access to certain special files (like reading /proc/self/pid for debugging), you’d add them to the allowlist. If you want to block all special file access, you can add a check:

const specialFiles = ["/dev/", "/proc/", "/sys/", "/etc/"];
const isSpecialFile = specialFiles.some((prefix) =>
  normalized.startsWith(prefix),
);

if (isSpecialFile && !allowlist.includes(normalized)) {
  block(`BLOCKED: Access to special file ${targetPath}`);
  return;
}

This gives you granular control over access to system resources. Development environments might allow /proc for debugging. Production environments might block everything outside the project.

Real-World Integration Patterns

Integrating directory boundaries isn’t just about dropping in code. It’s about understanding how the validation fits into your broader security model.

Integration with CI/CD

In CI/CD pipelines, directory boundaries become more important because the environment is often different from local development. A CI job might have access to build artifacts, test data, or system configuration that developers don’t have locally. The boundary hook needs to understand this context.

One approach is environment-specific configuration. Your .claude/settings.json might have:

{
  "dirBoundary": {
    "local": {
      "projectRoot": "/home/user/projects/myapp",
      "allowedPaths": []
    },
    "ci": {
      "projectRoot": "/workspace/project",
      "allowedPaths": ["/workspace/build", "/workspace/test-data"]
    }
  }
}

When Claude runs locally, it uses the local configuration. When running in CI (detected via environment variables like CI=true), it uses the ci configuration. This lets you maintain appropriate boundaries in each context without complex logic in the hook itself.

Auditing and Forensics

Beyond just blocking access, the hooks create an audit trail. Every boundary violation gets logged. Over time, these logs tell a story about what Claude is trying to access and why it’s being blocked.

A good auditing strategy involves regular review of the violation log. Set up a weekly or monthly task to review violations. Look for patterns:

  • Are legitimate operations being blocked? (indicates the boundary is too strict)
  • Are there repeated violation attempts? (might indicate misconfiguration or a misunderstanding about what the boundary allows)
  • Are there violations from unexpected paths? (might indicate someone testing the boundary deliberately)

The logs are your security visibility into Claude Code’s behavior. Use them as a feedback mechanism to tune your configuration over time.

Performance and Optimization

Directory boundary checking is fast—it typically adds a few milliseconds to tool execution. But at scale, with thousands of tool executions per day, this adds up. Optimization strategies matter.

Caching Path Resolutions

The most expensive operation in the hooks is realpathSync(), which follows symlinks and filesystem metadata. If you’re validating the same paths repeatedly, you can cache the results:

const pathCache = new Map();

function validateWithCache(path, projectRoot) {
  const cacheKey = `${projectRoot}:${path}`;

  if (pathCache.has(cacheKey)) {
    return pathCache.get(cacheKey);
  }

  const result = validatePath(path, projectRoot);
  pathCache.set(cacheKey, result);

  // Clear cache periodically to stay fresh
  if (pathCache.size > 1000) {
    pathCache.clear();
  }

  return result;
}

This is a micro-optimization, but in workflows where Claude accesses the same files repeatedly, it measurably reduces overhead.

Early Returns in Checks

The hooks perform multiple checks (symlinks, file size, containment). You can optimize by ordering checks so the fastest ones run first. File size checks are instant—just filesystem metadata. Symlink checks are slower. So check file size first, then symlinks, then containment.

This reduces CPU time because the hook often exits early after the first check. Developers appreciate faster feedback when a tool is blocked.

Long-Term Boundary Management

Directory boundaries are part of your security posture. Like any security control, they need ongoing attention.

Quarterly Boundary Review

Once per quarter, review your boundary configuration. Ask:

  • Are there paths in the allowlist that are no longer needed?
  • Have new directories been created that should be restricted?
  • Have team members reported legitimate work being blocked? (indication you need broader boundaries)
  • Have there been any security incidents related to file access? (indication you need tighter boundaries)

This review ensures your boundaries evolve with your project.

Documentation for the Team

Document your boundary configuration and the reasoning behind it. A simple README in the .claude/ directory explaining the boundary strategy helps new team members understand the constraints:

# Directory Boundary Policy

Claude is restricted to the project directory: `<project-root>`

Justification: This prevents accidental access to system files while still allowing necessary access to project files.

Allowed external paths:

- `/usr/local/lib/node_modules` - For shared Node dependencies in monorepos

If you need to access a path outside the boundary:

1. Document the requirement
2. Discuss with the team
3. Add it to the allowlist in .claude/settings.json
4. Update this documentation

This creates accountability. Adding external paths isn’t a casual decision—it’s a documented change that the team can review.

Evolution as Your Project Grows

Small projects might start with permissive boundaries. As they grow and security becomes more important, boundaries tighten. This is normal and expected. Your hook system should evolve with this:

  • Early stage: Permissive boundaries, minimal restrictions
  • Growth stage: Boundaries around specific sensitive areas (credentials, infrastructure)
  • Mature stage: Strict boundaries with explicit allowlists for any external access

Each stage has its own configuration file that you activate as needed.

Design Philosophy: Why Boundaries Matter More Than You Think

Understanding the design philosophy behind directory boundaries helps you deploy them effectively. This isn’t just about security—it’s about creating predictable, trustworthy systems.

Security vs. Usability Tradeoffs

A boundary system faces constant tension between security and usability. Too strict and developers bypass the system. Too permissive and security becomes theoretical. The sweet spot is “just right”—restrictive enough that meaningful protection exists, but flexible enough that legitimate work flows naturally.

The hooks we’ve discussed strike this balance by being specific but configurable. The basic validation is strict: only paths within the boundary are allowed. But allowlisting lets teams grant access to specific external paths. This means “zero trust by default, specific trust as needed”—the principle that security experts recommend.

The key insight is that boundaries are only useful if people respect them. If developers have to use --no-verify constantly, the boundary is too strict. If nobody even notices the boundary, it’s too permissive. The goal is a boundary that’s felt to be fair and reasonable by the team that lives within it.

Defense in Depth

Directory boundaries are one layer in a defense-in-depth security model. They’re not the only protection, but they’re your first line of defense. Other protections include:

  • CI/CD restrictions: Your CI/CD pipeline runs with minimal filesystem access
  • Container sandboxing: Claude runs in containers with restricted mount points
  • Network policies: Network access is restricted to needed services
  • Audit logging: All filesystem access is logged for review

Directory boundaries are the access control layer. They work alongside other protections. This layered approach means no single failure compromises security. If a boundary check is somehow bypassed, audit logging catches it. If logging is disabled, the CI/CD sandbox prevents escalation.

Transparency and Trust

The best security systems are transparent to the people they protect. When boundaries are configured clearly and violations are explained clearly, developers understand the reasoning. They’re more likely to respect the boundaries and less likely to try to circumvent them.

This is why the hooks we’ve discussed provide clear error messages when they block access. Developers see exactly what was blocked and why. They can ask “how do I do this safely?” instead of just “why is this blocked?” That conversation builds trust.

Over time, developers internalize the boundary. It feels natural because they understand the reasoning. New developers coming in see that boundaries exist and are enforced, so they work within them from the start. The boundary becomes part of your team’s culture.

Advanced Monitoring and Analytics

Violation logs are your window into how Claude is behaving. Over time, patterns in violations tell you important things about how your team uses Claude and what boundaries might need adjustment.

Violation Pattern Analysis

Track violations over time. Are they random? Or do you see clusters? A cluster of violations might indicate:

  • A developer is new to the project and doesn’t understand the boundaries yet
  • A legitimate need is being blocked (indication the boundary is too strict)
  • Someone is deliberately testing the boundaries (security concern worth investigating)
  • An automated process is trying to access files in an unintended way

Violations by category also matter. If most violations are “attempted access to /etc/”, that’s very different from violations where developers try to read parent directories. The first suggests someone is testing system access. The second suggests confusion about the project structure.

Building Your Metrics

Over time, collect these metrics:

  • Total violations per developer: Does one developer trigger many violations?
  • Violation types: Most common blocked access patterns
  • False positive rate: Legitimate operations that were blocked
  • Mean time to resolution: When violations happen, how quickly do developers adjust?

These metrics tell you if the boundary system is working well. Low false positive rates and quick developer adjustment suggest good boundary design. High false positive rates or slow adjustment suggests the boundary needs relaxation.

Feedback Loops

Use violation data to iteratively improve your boundaries. If you see a pattern of violations in a particular area, discuss with the team: “We’re seeing many blocks in X. Is our boundary too strict?” This conversation might lead to expanding the allowlist or restructuring the project differently.

This feedback loop transforms violations from “things to prevent” into “signals for improvement.” You’re using the security system as a teaching and learning tool, not just as a blocker.

Summary

Directory boundary hooks are your first line of defense against path traversal attacks and accidental system access. Start with Hook 2 (basic + symlinks), add Hook 3 if you need allowlisting, and use Hook 4 if you’re on Windows.

The hooks are simple but powerful. They run before every filesystem operation, preventing problems before they start. Configure them once, and Claude stays inside the boundaries you define. Test your hooks regularly and review the violation logs periodically to catch any boundary edge cases you might have missed.

Understanding the subtleties—symlink handling, relative path complexity, Windows-specific edge cases—makes you effective at deploying and maintaining boundaries in real projects. Make directory boundary enforcement a first-class concern in your Claude Code configuration, and you’ll have peace of mind that your AI assistant respects your project’s security perimeter.

The investment in getting boundaries right pays dividends over time. Each developer on your team works more securely. Each violation attempt is logged and can be analyzed. Each configuration change is documented and reviewable. In aggregate, strong boundary enforcement becomes part of your team’s security culture.


-iNet

Free Discovery Call

Start With a Conversation, Not a Commitment

Every engagement begins with a free 30-minute discovery call. We'll map what's slowing your business down and tell you exactly what we'd fix first – no pitch deck, no obligation.