Published on 4 min read

Check AI-Suggested File Paths Before Writing With Python

An AI assistant might suggest saving a report as reports/summary.md. Before a tool writes anything, check where that name actually leads. A nested path can pass through a symbolic link into another folder, even when its spelling looks harmless.

This tutorial builds a read-only destination checker and tests nine synthetic proposals. It complements a simple file-renaming preview: here we allow nested report destinations, not just basenames. Use Python 3.11 or later on Linux or macOS; no AI account or third-party packages are required.

A project folder inside a transparent boundary, with an approved teal document path and a coral path stopped by a barrier.

1. Define the destination contract

The application, not the model, chooses a private output folder. Ask the assistant for relative paths using lowercase ASCII letters, digits, underscores, and hyphens, with forward slashes between folders and a final .md extension. Limit each path to 200 characters. Parent directories must already exist; occupied names are rejected.

This intentionally excludes spaces, accented letters, absolute paths, and ... MITRE describes escaping a restricted directory through external pathname input as path traversal. Reject unsupported names rather than silently repairing them.

If proposals arrive as JSON, validate the JSON first, then pass each path string to this separate check.

2. Save the read-only checker

Save this as check_paths.py in a fresh working directory:

from pathlib import Path
import re


def checked_destination(root, raw):
    if (
        not isinstance(raw, str)
        or len(raw) > 200
        or not re.fullmatch(r"[a-z0-9_-]+(?:/[a-z0-9_-]+)*\.md", raw)
    ):
        raise ValueError("format")
    root = root.resolve(strict=True)
    candidate = root / raw
    try:
        target = candidate.resolve(strict=False)
    except (OSError, RuntimeError) as error:
        raise ValueError("unresolvable") from error
    if not target.is_relative_to(root):
        raise ValueError("outside root")
    if not target.parent.is_dir():
        raise ValueError("missing parent")
    if target.exists() or candidate.is_symlink():
        raise ValueError("occupied")
    return target

The allowlist checks spelling. Python’s resolve() reference explains that resolution follows symbolic links; strict=False permits a not-yet-existing destination. Resolve first because is_relative_to() alone is lexical and does not inspect links.

Do not replace containment with str(target).startswith(str(root)): a sibling named output-backup shares the prefix output. The occupied check also rejects dangling final symlinks, which do not point to an existing file.

3. Build disposable fixtures

Save this as demo_paths.py beside the checker. It creates only temporary test folders, a sentinel file, and two symlinks. The escape link points to the sibling output-backup; broken.md points to a missing file.

from pathlib import Path
from tempfile import TemporaryDirectory
from check_paths import checked_destination

with TemporaryDirectory(prefix="ai-paths-") as directory:
    base = Path(directory).resolve()
    root, outside = base / "output", base / "output-backup"
    root.mkdir()
    outside.mkdir()
    (root / "reports").mkdir()
    (root / "existing.md").write_text("keep\n", encoding="utf-8")
    (root / "escape").symlink_to(outside, target_is_directory=True)
    (root / "broken.md").symlink_to(root / "gone.md")
    before = sorted(str(p.relative_to(base)) for p in base.rglob("*"))
    cases = [
        ("nested", "reports/summary.md", "ALLOW"),
        ("parent", "../outside.md", "BLOCK format"),
        ("absolute", str(outside / "note.md"), "BLOCK format"),
        ("symlink", "escape/leak.md", "BLOCK outside root"),
        ("existing", "existing.md", "BLOCK occupied"),
        ("extension", "reports/summary.txt", "BLOCK format"),
        ("backslash", r"reports\summary.md", "BLOCK format"),
        ("missing-parent", "new/summary.md", "BLOCK missing parent"),
        ("broken-link", "broken.md", "BLOCK occupied"),
    ]
    for label, raw, expected in cases:
        try:
            target = checked_destination(root, raw)
            assert target == root / "reports/summary.md"
            result = "ALLOW"
        except ValueError as error:
            result = f"BLOCK {error}"
        assert result == expected, (label, result)
        print(f"{label}: {result}")
    assert before == sorted(str(p.relative_to(base)) for p in base.rglob("*"))
    assert (root / "existing.md").read_text(encoding="utf-8") == "keep\n"
    print("PASS: 9 cases; no proposed files written")

The assertions check each decision, compare the directory inventory before and after validation, and confirm the sentinel content remains unchanged. The temporary directory is removed automatically.

4. Run and validate

Run normally, without Python’s -O option, which disables assertions:

python3 demo_paths.py

Expected output:

nested: ALLOW
parent: BLOCK format
absolute: BLOCK format
symlink: BLOCK outside root
existing: BLOCK occupied
extension: BLOCK format
backslash: BLOCK format
missing-parent: BLOCK missing parent
broken-link: BLOCK occupied
PASS: 9 cases; no proposed files written

Locally tested with Python 3.11.4 on Linux: exit status 0, empty stderr, and no remaining fixture files. The negative cases are successful tests of rejection, not failed runs.

The symlink case matters most: its input satisfies the naming rule, yet resolution leaves the approved root. Passing the nested case means this destination meets the policy at inspection time. It does not certify the report’s content or grant permission to execute model-written code.

5. Troubleshoot and keep execution separate

  • format: request a compliant relative name. Do not strip traversal components or decode the value after validation.
  • outside root: inspect the linked parent directory; do not expand the allowed root to make the test pass.
  • missing parent: let trusted application code prepare the intended subdirectory, then validate again.
  • occupied: choose a new name; do not delete the existing entry.
  • Symlink creation fails: use a local filesystem that permits symlinks. Windows requires separate platform-specific testing.

This is a snapshot check, not a race-proof filesystem sandbox. Another process could replace a directory between validation and writing: a time-of-check/time-of-use race. A future writer needs isolated permissions, race-resistant file-opening controls, and no-overwrite behavior. For batch proposals, also reject duplicate resolved destinations before any write.

Practical takeaway

Let AI propose destinations while application code owns the root and checks every path. Combine this preview with a field allowlist, keep execution separate, and add a fixture whenever a new failure appears.

Related articles

Latest articles