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.
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
Preview AI File-Renaming Plans Before Touching Files
Reject Ambiguous AI JSON Before Automation With Python
Check AI Task Dependencies Before Scheduling With Python
Check AI JSON Edits Against a Field Allowlist With Python
Check AI Summary Numbers Against a Source Table With Python
Check AI-Generated Calendar Times Before Creating Events
Lack of Agreement on AI Rules in U.S., EU Gives China a Leg Up
15 Best AI Tools for Startup Founders
How AI Can Be Your HR Sidekick in Recruitment and Employee Engagement
AI Call Center Is No Longer a Myth but a Reality: 6 Steps for Painless Implementation
How to Use AI Brand Voice Generator to Preserve Channel-Specific Voices
Lightweight Agno Framework for Fast Multimodal AI Agent Creation
Latest articles
Prevent Stale AI Updates With a Version Check in SQLite
Check AI Task Dependencies Before Scheduling With Python
Check AI JSON Edits Against a Field Allowlist With Python
Combine RAG Search Results With Reciprocal Rank Fusion
Check AI-Generated Calendar Times Before Creating Events
Check AI Quote Citations Against Local Sources With Python
Check AI Summary Numbers Against a Source Table With Python
Choose AI Review Thresholds With Coverage and Error Rates
Check AI Translations for Broken Placeholders With Python
Compare AI Prompt Versions With a Python Regression Test
Reject Ambiguous AI JSON Before Automation With Python