Skip to content

feat(audit): opt-in loader size/path safety (#112 #113) - #116

Merged
jaylfc merged 2 commits into
masterfrom
fix/audit-loader-safety-1
Jun 7, 2026
Merged

feat(audit): opt-in loader size/path safety (#112 #113)#116
jaylfc merged 2 commits into
masterfrom
fix/audit-loader-safety-1

Conversation

@jaylfc

@jaylfc jaylfc commented Jun 7, 2026

Copy link
Copy Markdown
Owner

What

Adds two opt-in safety guards to the typed loaders under taosmd/loaders/, closing audit items #112 (file-size limits) and #113 (path-traversal containment). Both default to behaviour that never breaks standalone direct use of any path.

Why

The loaders (doc_loader.py, chat_loader.py, email_loader.py, transcript_loader.py) did full-file reads (f.read() / json.load) with no size guard, and opened Path(file_path) directly with no containment or symlink check. A stray multi-gigabyte file could blow up memory, and a caller passing untrusted paths had no way to confine reads to a directory.

How

New stdlib-only helper taosmd/loaders/_safety.py:

  • check_size(path, max_bytes=DEFAULT_MAX_BYTES) — raises ValueError when the file exceeds the cap. Default 100 MB (generous; no normal chat/transcript/email/doc file trips it). max_bytes=None is an explicit opt-out.
  • resolve_within(path, base_dir=None) — with base_dir=None (default) returns the resolved path with no restriction. With base_dir set, the resolved path must sit inside the resolved base or it raises ValueError. Resolving first catches ../ traversal, absolute escapes, and out-of-tree symlinks.

Each loader's load() now takes optional keyword-only max_bytes and base_dir (threaded through the LoaderInterface ABC) and calls resolve_within + check_size before reading. source_path on the returned blob is preserved as the original (un-resolved) path so existing provenance stays unchanged.

Standalone-safe by design

  • Opt-in: default call (loader.load(path)) has no base_dir → no path restriction, and a 100 MB cap no real file hits.
  • stdlib-only: just os + pathlib. No new deps (matters for the Pi tier).
  • Existing loader calls and tests are untouched in behaviour — full suite passes (223 tests).

Tests

tests/test_loaders.py gains a safety section: oversized file raises; base_dir blocks traversal / absolute / symlink escapes and allows normal/nested paths; no base_dir allows any path; default call loads a small file unchanged.

python3 -m pytest -q
223 passed

Summary by CodeRabbit

Release Notes

  • New Features

    • All loaders now support optional file size limits (100 MB default); disable checks by passing max_bytes=None.
    • Loaders now accept an optional base_dir parameter to restrict file loading to a specified directory for added path safety.
  • Tests

    • Comprehensive test coverage added for new safety features, including size validation, path containment, and edge cases.

@coderabbitai

coderabbitai Bot commented Jun 7, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

Pull request was closed or merged during review

📝 Walkthrough

Walkthrough

The PR adds opt-in filesystem safety validation to all loaders. A new _safety module provides DEFAULT_MAX_BYTES (100 MB), check_size() for file size validation, and resolve_within() for path containment checks. The LoaderInterface contract is updated with max_bytes and base_dir parameters, and all concrete loaders now use these helpers before opening files. Safety utilities are re-exported from taosmd.loaders with comprehensive test coverage.

Changes

Filesystem Safety Guards for Loaders

Layer / File(s) Summary
Safety helpers foundation
taosmd/loaders/_safety.py
New module exports DEFAULT_MAX_BYTES (100 MB), check_size() to reject oversized files with detailed error messaging, and resolve_within() to enforce optional path containment within a base directory.
Loader interface contract update
taosmd/loaders/interface.py
Abstract LoaderInterface.load() signature expanded to include keyword-only max_bytes (defaulting to DEFAULT_MAX_BYTES) and base_dir (defaulting to None) parameters; updated docstring documents expected safety behavior.
Concrete loader implementations
taosmd/loaders/chat_loader.py, taosmd/loaders/doc_loader.py, taosmd/loaders/email_loader.py, taosmd/loaders/transcript_loader.py
All four loaders import safety helpers and update load() methods to resolve paths via resolve_within(), validate sizes via check_size(), and open resolved safe paths; parsing and blob construction logic remain unchanged.
Public API exports
taosmd/loaders/__init__.py
Safety utilities (DEFAULT_MAX_BYTES, check_size, resolve_within) are re-exported from the package via __all__.
Comprehensive test suite
tests/test_loaders.py
New test block validates DEFAULT_MAX_BYTES (100 MB), check_size() allow/deny behavior and max_bytes=None opt-out, resolve_within() path containment rules and rejection of traversal/absolute/symlink escapes, and integration with loaders respecting both max_bytes and base_dir constraints.

Sequence Diagram

sequenceDiagram
  participant Caller
  participant Loader
  participant SafetyModule
  participant FileSystem
  
  Caller->>Loader: load(file_path, max_bytes, base_dir)
  Loader->>SafetyModule: resolve_within(file_path, base_dir)
  SafetyModule->>SafetyModule: validate containment
  SafetyModule-->>Loader: safe_path
  Loader->>SafetyModule: check_size(safe_path, max_bytes)
  SafetyModule->>FileSystem: getsize(safe_path)
  FileSystem-->>SafetyModule: file size
  SafetyModule-->>Loader: size valid
  Loader->>FileSystem: open(safe_path)
  FileSystem-->>Loader: file handle
  Loader->>Loader: parse content
  Loader-->>Caller: Blob
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • jaylfc/taosmd#80: Introduces the typed loader structure that this PR extends with safety parameters across LoaderInterface.load() and concrete loader implementations.

Poem

🐰 Paths are now bounded with care,
Files checked for size everywhere,
Base dirs contain each escape attempt,
Safety first, with checks well kept,
Loaders leap with trusty guards in place! 🛡️

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 24.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title accurately describes the main change: introducing optional safety guards for loader file size and path containment to address audit items #112 and #113.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/audit-loader-safety-1

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Comment thread taosmd/loaders/_safety.py
)


def resolve_within(path: str | Path, base_dir: str | Path | None = None) -> Path:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: Document that base_dir should be a directory

The function assumes base_dir is a directory for the containment check to make sense. If a caller passes a file path as base_dir, the resolved base will be that file, and base not in resolved.parents will behave unexpectedly (a file has no parents in the Path.parents sense). Consider adding a note in the docstring: "base_dir should be a directory; behavior is undefined if a file path is given."

Comment thread taosmd/loaders/_safety.py
means traversal (``../``), absolute escapes, and symlinks that point
out of the tree are all caught.
"""
resolved = Path(path).resolve()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: Note TOCTOU race condition in docstring

There's a time-of-check-time-of-use window between resolve_within/check_size and the actual open() call in the loaders. An attacker could swap a symlink after the check but before the open. This is a known limitation of path-based checks. Consider adding a note: "Note: There is a TOCTOU race window between this check and the subsequent file open. For high-security contexts, open the file first and use os.fstat/os.readlink on the fd."

Comment thread taosmd/loaders/_safety.py
base = Path(base_dir).resolve()
if resolved != base and base not in resolved.parents:
raise ValueError(
f"{path} resolves to {resolved}, which is outside the "

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: Error message says "base directory" but base_dir could be a file

The error message refers to "allowed base directory" but the parameter accepts any path. If base_dir is a file, this message is slightly misleading. Could say "allowed base path" for accuracy.

base_dir: str | Path | None = None,
**kwargs,
) -> ChatBlob:
path = Path(file_path)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: path variable shadows safe_path purpose

path = Path(file_path) is created but only used for source_path=str(path) in the returned blob. The actual file operations use safe_path. This is intentional (preserving the user-provided path in the blob) but could be confusing. Consider renaming path to original_path or adding a comment: # Keep original path for blob.source_path.

base_dir: str | Path | None = None,
**kwargs,
) -> DocBlob:
path = Path(file_path)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: path variable shadows safe_path purpose

path = Path(file_path) is created but only used for source_path=str(path) in the returned blob. The actual file operations use safe_path. This is intentional (preserving the user-provided path in the blob) but could be confusing. Consider renaming path to original_path or adding a comment: # Keep original path for blob.source_path.

base_dir: str | Path | None = None,
**kwargs,
) -> EmailBlob:
path = Path(file_path)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: path variable shadows safe_path purpose

path = Path(file_path) is created but only used for source_path=str(path) in the returned blob. The actual file operations use safe_path. This is intentional (preserving the user-provided path in the blob) but could be confusing. Consider renaming path to original_path or adding a comment: # Keep original path for blob.source_path.

base_dir: str | Path | None = None,
**kwargs,
) -> TranscriptBlob:
path = Path(file_path)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: path variable shadows safe_path purpose

path = Path(file_path) is created but only used for source_path=str(path) in the returned blob. The actual file operations use safe_path. This is intentional (preserving the user-provided path in the blob) but could be confusing. Consider renaming path to original_path or adding a comment: # Keep original path for blob.source_path.

@@ -47,13 +48,34 @@ def can_handle(cls, extension: str = "", mime_type: str = "") -> bool:
return False

@abstractmethod

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Interface change may break custom loaders

The load method signature now adds two keyword-only parameters (max_bytes, base_dir) before **kwargs. Existing custom loaders that override load(self, file_path, **kwargs) will still work because the new args go into **kwargs. However, loaders with explicit signatures like load(self, file_path, max_bytes=..., **kwargs) (without base_dir) will receive base_dir in **kwargs which is fine. But loaders that explicitly type **kwargs: Any or do kwargs.pop('max_bytes') may need updates. Consider adding a migration note in the docstring or changelog.

@kilo-code-bot

kilo-code-bot Bot commented Jun 7, 2026

Copy link
Copy Markdown

Code Review Summary

Status: 11 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 6
SUGGESTION 5
Issue Details (click to expand)

WARNING

File Line Issue
taosmd/loaders/_safety.py 40 check_size doesn't handle missing files
taosmd/loaders/chat_loader.py 86 source_path uses original path instead of resolved safe_path
taosmd/loaders/doc_loader.py 49 source_path uses original path instead of resolved safe_path
taosmd/loaders/email_loader.py 89 source_path uses original path instead of resolved safe_path
taosmd/loaders/transcript_loader.py 104 source_path uses original path instead of resolved safe_path
taosmd/loaders/interface.py 50 Interface change breaks custom loaders

SUGGESTION

File Line Issue
taosmd/loaders/_safety.py 52 Docstring for base_dir is confusing
taosmd/loaders/chat_loader.py 56 Redundant path variable shadows safe_path purpose
taosmd/loaders/doc_loader.py 35 Redundant path variable shadows safe_path purpose
taosmd/loaders/email_loader.py 41 Redundant path variable shadows safe_path purpose
taosmd/loaders/transcript_loader.py 67 Redundant path variable shadows safe_path purpose
Other Observations (not in diff)

Issues found in unchanged code that cannot receive inline comments:

File Line Issue
taosmd/loaders/chat_loader.py 86 source_path=str(path) should use str(safe_path) for consistency with the actual file read
taosmd/loaders/doc_loader.py 49 source_path=str(path) should use str(safe_path)
taosmd/loaders/email_loader.py 89 source_path=str(path) should use str(safe_path)
taosmd/loaders/transcript_loader.py 104 source_path=str(path) should use str(safe_path)
Files Reviewed (7 files)
  • taosmd/loaders/_safety.py - 2 issues
  • taosmd/loaders/chat_loader.py - 2 issues
  • taosmd/loaders/doc_loader.py - 2 issues
  • taosmd/loaders/email_loader.py - 2 issues
  • taosmd/loaders/transcript_loader.py - 2 issues
  • taosmd/loaders/interface.py - 1 issue
  • taosmd/loaders/__init__.py - 0 issues

Fix these issues in Kilo Cloud


Reviewed by nemotron-3-ultra-550b-a55b-20260604:free · 714,876 tokens

@jaylfc
jaylfc merged commit 6c9a49f into master Jun 7, 2026
1 of 2 checks passed
@jaylfc
jaylfc deleted the fix/audit-loader-safety-1 branch June 7, 2026 03:50
Comment thread taosmd/loaders/_safety.py
def resolve_within(path: str | Path, base_dir: str | Path | None = None) -> Path:
"""Resolve ``path`` and, when ``base_dir`` is set, confine it there.

``base_dir`` is expected to be a directory that contains (directly or

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: Docstring for base_dir is confusing

It says "expected to be a directory" but then "passing a file as base_dir only ever matches that exact file." This is contradictory. Clarify whether base_dir should be a directory or can be a file, and what the behavior is in each case.

Comment thread taosmd/loaders/_safety.py
"""
if max_bytes is None:
return
size = os.path.getsize(path)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: check_size doesn't handle missing files

os.path.getsize(path) raises FileNotFoundError if the path doesn't exist. This exception is not caught or documented. Callers may get an unexpected FileNotFoundError instead of a clear ValueError. Consider catching and re-raising as ValueError with a clearer message, or document the exception.

base_dir: str | Path | None = None,
**kwargs,
) -> ChatBlob:
path = Path(file_path)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: Redundant path variable shadows safe_path purpose

path = Path(file_path) is created but only used for source_path and error messages. The actual file operations use safe_path. Consider using safe_path consistently and removing the redundant path variable.

base_dir: str | Path | None = None,
**kwargs,
) -> DocBlob:
path = Path(file_path)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: Redundant path variable shadows safe_path purpose

path = Path(file_path) is created but only used for source_path. The actual file operations use safe_path. Consider using safe_path consistently and removing the redundant path variable.

base_dir: str | Path | None = None,
**kwargs,
) -> EmailBlob:
path = Path(file_path)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: Redundant path variable shadows safe_path purpose

path = Path(file_path) is created but only used for source_path. The actual file operations use safe_path. Consider using safe_path consistently and removing the redundant path variable.

base_dir: str | Path | None = None,
**kwargs,
) -> TranscriptBlob:
path = Path(file_path)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: Redundant path variable shadows safe_path purpose

path = Path(file_path) is created but only used for source_path and error messages. The actual file operations use safe_path. Consider using safe_path consistently and removing the redundant path variable.

@@ -47,13 +48,34 @@ def can_handle(cls, extension: str = "", mime_type: str = "") -> bool:
return False

@abstractmethod

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Interface change breaks custom loaders

The abstract load() method signature now requires max_bytes and base_dir keyword-only parameters. Any third-party or custom LoaderInterface subclasses that don't accept these will fail at runtime with TypeError. This is a breaking change. Consider providing a default implementation in the base class that calls the safety helpers, or at minimum document this as a breaking change in release notes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant