DEV Community

Alessio
Alessio

Posted on Originally published at py-forge.com

How to Block Malicious File Writes in Autonomous Python Agents

Autonomous AI agents, automated coding assistants, and Large Language Model (LLM) tool executors frequently need to write files to disk. However, accepting a file path generated by an AI model introduces substantial security risks. If an agent hallucinates, encounters prompt injection, or attempts to modify files outside its target directory, it can overwrite system configurations, expose private keys, or destroy version control history.

Common attacks and edge cases include directory traversal (such as ../../), symlink abuse where a link points to an arbitrary location outside the project, embedded null bytes, non-printable control characters, and writes targeting sensitive files like .env or .ssh/authorized_keys.

The AgentWritePathResolver script provides a security policy engine designed specifically for these workflows. It inspects, canonicalizes, and verifies candidate file write paths before any modification is permitted on disk.


Install & Usage

The tool is implemented as a standalone script using only the Python standard library. It requires Python 3.8 or higher. No external packages need to be installed via pip.

To set it up:

  1. Save the code into a file named agent_write_path_resolver_refuse_writes_whose.py.
  2. Verify that Python can run the script:
python agent_write_path_resolver_refuse_writes_whose.py --help
Enter fullscreen mode Exit fullscreen mode

You can integrate the resolver directly into your agent execution loop or tool-handling layer.

Programmatic Usage

Below is an example of importing the resolver inside a Python application to guard an agent tool function:

from pathlib import Path
from agent_write_path_resolver_refuse_writes_whose import (
    AgentWritePathResolver,
    PathVerdict,
    RefusalReason,
)

# Define the boundaries of the agent workspace
workspace = Path("/tmp/agent_workspace")
workspace.mkdir(parents=True, exist_ok=True)

# Initialize the resolver with custom rules
resolver = AgentWritePathResolver(
    workspace_root=workspace,
    deny_hidden=True,
    allowed_extensions=[".py", ".txt", ".json", ".md"],
    disallow_symlinks=True,
)

# Test candidate paths proposed by an agent
candidates = [
    "safe_file.py",
    "../../etc/passwd",
    "notes/todo.md",
    ".env",
    "script\x00.py",
]

for candidate in candidates:
    result = resolver.resolve(candidate)
    if result.verdict == PathVerdict.ALLOW:
        print(f"[ALLOWED] {candidate} -> {result.resolved_path}")
    else:
        print(f"[REFUSED] {candidate} -> Reason: {result.reason} ({result.message})")
Enter fullscreen mode Exit fullscreen mode

When evaluated, paths escaping the root or matching protected rules are flagged with PathVerdict.REFUSE, halting execution before any disk write operation occurs.


How It Works

The resolver executes an eleven-step validation sequence against the requested path. It validates the input string, checks system constraints, traces ancestor paths for symbolic links, verifies boundaries, and inspects pattern denylists.

1. Input Sanitization and Boundary Checks

Before resolving the path on the file system, the resolver checks for control characters and null byte injection attempts:

# 1. Null byte detection
if "\x00" in candidate:
    return ResolutionResult(
        candidate_path=candidate,
        verdict=PathVerdict.REFUSE,
        reason=RefusalReason.NULL_BYTE_DETECTED,
        message="Candidate path contains prohibited null byte (injection vector).",
        workspace_root=str(self.workspace_root),
    )

# 2. Control characters detection
if re.search(r"[\x01-\x1f\x7f]", candidate):
    return ResolutionResult(
        candidate_path=candidate,
        verdict=PathVerdict.REFUSE,
        reason=RefusalReason.CONTROL_CHAR_DETECTED,
        message="Candidate path contains non-printable control characters.",
        workspace_root=str(self.workspace_root),
    )
Enter fullscreen mode Exit fullscreen mode

If the candidate path passes sanitization, the resolver checks whether the provided workspace directory actually exists. If the workspace does not exist or is not a directory, it returns RefusalReason.WORKSPACE_NOT_FOUND.

2. Ancestor Symlink Tracking and Canonicalization

Agents often target files that do not yet exist. Calling standard resolve() directly on non-existent paths on some platforms can fail to expand intermediate symlinks correctly.

The _safe_canonicalize method resolves intermediate directories step-by-step from the root downward. This ensures that if any parent directory is a symbolic link leading outside the workspace, the link is detected:

def _safe_canonicalize(self, path: Path) -> Tuple[Path, bool]:
    symlink_encountered = False
    parts = path.parts
    if not parts:
        return path.resolve(), False

    # Build incrementally from anchor
    current = Path(parts[0])
    for part in parts[1:]:
        current = current / part
        if current.is_symlink():
            symlink_encountered = True
            try:
                current = current.resolve()
            except RuntimeError:
                # Possible circular symlink
                symlink_encountered = True
                break

    resolved = current.resolve()
    return resolved, symlink_encountered
Enter fullscreen mode Exit fullscreen mode

If disallow_symlinks=True is configured and an intermediate path component is a symlink, the operation is refused with RefusalReason.SYMLINK_ESCAPE.

3. Containment and Denylist Verification

Once canonicalized, the target path must reside strictly within self.workspace_root. The helper method _is_subpath uses Path.relative_to to confirm containment:

@staticmethod
def _is_subpath(target: Path, base: Path) -> bool:
    try:
        target.relative_to(base)
        return True
    except ValueError:
        return False
Enter fullscreen mode Exit fullscreen mode

If the resolved target passes the boundary check, the relative path is checked against DEFAULT_DENY_PATTERNS. This built-in denylist rejects modifications to sensitive project files and directories:

  • Version control files: *.git*, */.git/*
  • Environment and credential files: *.env*, *.pem, *.key, *.id_rsa*
  • SSH configurations: *.ssh/*, */authorized_keys*
  • Shell profiles: *.bashrc, *.zshrc, *.profile
  • Editor and dependency trees: *.vscode/*, */node_modules/*

Additionally, if deny_hidden=True is enabled, any path component containing a leading period (such as .config/test.json) triggers RefusalReason.HIDDEN_TARGET_REFUSED. Finally, the resolver checks for extension constraints, directory collisions, and write permissions on existing files.


Limitations

While AgentWritePathResolver prevents common path manipulation exploits, you should be aware of the following architectural limitations:

  1. Time-of-Check to Time-of-Use (TOCTOU): The tool validates paths in memory before a write happens. If another concurrent process alters the directory structure or replaces an intermediate directory with a symlink between path resolution and the actual write operation, the write could target an unintended file.
  2. Missing Parent Directory Creation: The validation logic checks existing path components and verifies target paths, but it does not automatically create missing parent directories on disk. Your writing function must handle directory creation.
  3. No Process-Level Sandboxing: The resolver is an application-level guard. It does not enforce system-level user permission boundaries or prevent other unauthorized actions (such as subprocess execution or network requests).
  4. Denylist Scope: The default denylist protects standard files like .env and .git, but it cannot anticipate every custom secret or proprietary configuration file name your system may use. Custom patterns should be explicitly supplied via deny_patterns.

FAQ

Why does the resolver track symlinks if it already checks the final resolved path?

Even if a symlink resolves to a location inside the workspace, allowing agents to navigate or create symlinks can lead to loops, bypass naming rules, or alter files across logically separated subdirectories. Setting disallow_symlinks=True blocks symlink traversal entirely.

Can an agent target an absolute path that happens to be inside the workspace?

Yes. If an agent passes /path/to/workspace/file.txt, the resolver evaluates the absolute path directly. As long as it resides within the configured workspace_root and satisfies all pattern and extension rules, the verdict will be PathVerdict.ALLOW. If it falls outside the root, it returns RefusalReason.ABSOLUTE_PATH_ESCAPE.

How does the tool handle writes to existing read-only files?

Before returning an approval verdict, the resolver checks whether the target file exists. If it exists and os.access(target, os.W_OK) returns False, the operation is refused with RefusalReason.EXISTING_FILE_READONLY rather than failing mid-write.


Full version and the rest of the toolkit: Agent write path resolver refuse writes whose on PyForge. Product page: https://py-forge.com/products/agent-write-path-resolver-refuse-writes-whose

Top comments (0)