ComposioHQ/composio · critical · UnsafePathComponentError
Refusing to write {label} that is a reserved device name: {n
Error message
Refusing to write {label} that is a reserved device name: {name!r} What it means
safe_basename rejects basenames whose stem before the first dot (trailing spaces stripped, uppercased) is a Windows reserved device name — e.g. 'NUL.tar.gz' or 'com1.txt' — because Windows opens the device, not a file, no matter how many extensions follow.
Source
Thrown at python/composio/utils/safe_path.py:207
basename = raw_basename.strip()
try:
encoded_length = len(os.fsencode(basename))
except UnicodeEncodeError as e:
raise UnsafePathComponentError(
f"Refusing to write {label} containing invalid Unicode: {name!r}"
) from e
if encoded_length > MAX_COMPONENT_LENGTH:
raise UnsafePathComponentError(
f"Refusing to write {label} longer than {MAX_COMPONENT_LENGTH} bytes: "
f"{basename[:32]!r}... ({encoded_length} bytes)"
)
# Compare everything before the first dot: on Windows `NUL.tar.gz` opens
# the null device just as `NUL` does, so any number of extensions provides
# no protection.
device_name = basename.split(".", 1)[0].rstrip(" ").upper()
if device_name in WINDOWS_RESERVED_NAMES:
raise UnsafePathComponentError(
f"Refusing to write {label} that is a reserved device name: {name!r}"
)
return basename
def resolve_root(root: t.Union[str, Path]) -> Path:
"""Normalize a trusted root to an absolute, symlink-resolved path.
Every containment check must derive its anchor through this one function.
When two call sites normalize differently — one expanding ``~`` and one not
— the check compares mismatched paths and rejects legitimate writes while
reporting them as attacks. Sharing the normalization is what keeps the two
ends of a containment check comparable.
"""
expanded = Path(root).expanduser()
try:
return expanded.resolve(strict=False)
except OSError:View on GitHub (pinned to 64b1b85502)
Solutions
- Prefix the filename (e.g. 'download-NUL.tar.gz') so the stem no longer matches
- Reject the untrusted download carrying the device name
Example fix
# before secure_basename_join(base, 'nul.tar.gz') # after secure_basename_join(base, 'download-nul.tar.gz')
Defensive patterns
Strategy: validation
Validate before calling
from composio.utils.safe_path import WINDOWS_RESERVED_NAMES
def stem_not_device(v):
return PureWindowsPath(v).name.split('.', 1)[0].rstrip(' ').upper() not in WINDOWS_RESERVED_NAMES Type guard
from composio.utils.safe_path import WINDOWS_RESERVED_NAMES
def is_not_device_filename(v: str) -> bool:
return v.split('.', 1)[0].rstrip(' ').upper() not in WINDOWS_RESERVED_NAMES Try / catch
from composio.exceptions import UnsafePathComponentError
try:
p = secure_basename_join(base, name)
except UnsafePathComponentError:
p = secure_basename_join(base, 'file-' + name) Prevention
- Prefix filenames whose stem could match CON/PRN/AUX/NUL/COMn/LPTn
- Remember extensions don't help: 'NUL.tar.gz' still opens the device
When it happens
Trigger: secure_basename_join(base, 'NUL.tar.gz'), (base, 'COM1.json'), (base, 'aux .bak') — any filename whose first dot-delimited segment matches a reserved device name.
Common situations: Downloads named after devices (logs named 'prn.log', ports named 'com1.csv'); crafted filenames targeting Windows device files.
Related errors
- Refusing to build a path from a reserved device name as {lab
- Unsafe path component: {e}
- Refusing to build a path from an empty or non-string {label}
- Refusing to build a path from a {label} containing path sepa
- Refusing to build a path from an unsafe {label}: {value!r}.
AI-assisted analysis of ComposioHQ/composio@64b1b85502 (2026-08-28).
Data as JSON: /api/errors/8474c54ae7d65351.
Report an issue: GitHub.