Skip to content

security(context): harden Markdown import against TOCTOU symlink races - #932

Merged
KaifAhmad1 merged 6 commits into
semantica-agi:mainfrom
lakshanmuruganandam:security/harden-markdown-import-toctou-symlink-races
Aug 14, 2026
Merged

security(context): harden Markdown import against TOCTOU symlink races#932
KaifAhmad1 merged 6 commits into
semantica-agi:mainfrom
lakshanmuruganandam:security/harden-markdown-import-toctou-symlink-races

Conversation

@lakshanmuruganandam

Copy link
Copy Markdown
Contributor

Closes #856

Summary of Changes

  • Refactored AgentMemory._read_markdown_path file reading into a dedicated helper _read_markdown_file_content.
  • Enforced low-level file descriptor opening via os.open with os.O_NOFOLLOW (on platforms supporting it) and verified file descriptor regular file mode with os.fstat (stat.S_ISREG).
  • Rejects symlink substitutions between path validation and file reading.
  • Added regression unit test test_markdown_import_file_open_security_rejects_symlink in tests/context/test_agent_memory_markdown.py.

Copilot AI lite review requested due to automatic review settings August 12, 2026 09:11

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@qodo-code-review

Copy link
Copy Markdown

ⓘ Qodo reviews are paused because the subscription is no longer active. Ask your workspace admin to reactivate the subscription to resume reviews. Manage billing

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Harden Markdown import reads against TOCTOU symlink races

🐞 Bug fix 🧪 Tests 🕐 10-20 Minutes

Grey Divider

AI Description

• Prevent Markdown import from following symlinks during file reads.
• Use fd-based open + fstat checks to ensure imported paths are regular files.
• Add regression test covering symlink-rejection behavior.
Diagram

graph TD
  AM["AgentMemory"] --> RMP["_read_markdown_path"] --> RMF["_read_markdown_file_content"] --> SYM{Symlink?} -->|"yes"| REJ["Raise ValueError"]
  SYM -->|"no"| OOPEN["os.open(O_NOFOLLOW)"] --> FSTAT["os.fstat(S_ISREG)"] --> FS[("Filesystem")]
  TEST["Unit test"] --> RMF
  subgraph Legend
    direction LR
    _fn["Function/Method"] ~~~ _dec{"Decision"} ~~~ _ext[("External resource")]
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Realpath/inode comparison around open
  • ➕ Portable approach on platforms lacking O_NOFOLLOW
  • ➕ Can detect swap/race by comparing (st_dev, st_ino) of path vs opened fd
  • ➖ More complex and easier to get wrong (multiple stats, window for mistakes)
  • ➖ Still relies on path-based operations in addition to fd-based reads
2. Chroot-like or sandboxed import directory
  • ➕ Strong containment boundary (even if symlinks exist)
  • ➕ Broadly mitigates other path traversal issues beyond symlinks
  • ➖ Requires larger architectural changes and likely OS-specific support
  • ➖ Harder to adopt for simple local imports; bigger testing surface

Recommendation: The PR’s approach (fd-based open with O_NOFOLLOW when available plus fstat regular-file verification) is the most direct and robust mitigation for symlink TOCTOU in a local-file import path. Keep this design; it minimizes race windows and avoids relying on path-based reads. The added regression test is appropriate; consider adding a second test for non-regular files (e.g., FIFO) if the project supports it.

Files changed (2) +50 / -1

Bug fix (1) +39 / -1
agent_memory.pyAdd fd-based secure Markdown reader and reject symlinks/non-regular files +39/-1

Add fd-based secure Markdown reader and reject symlinks/non-regular files

• Introduces a dedicated helper to read Markdown files via os.open (using O_NOFOLLOW when supported) and validates the opened fd is a regular file with fstat. Updates Markdown path import to exclude symlinks and to use the new helper instead of Path.read_text, reducing TOCTOU symlink race exposure.

semantica/context/agent_memory.py

Tests (1) +11 / -0
test_agent_memory_markdown.pyAdd regression test rejecting symlink Markdown import paths +11/-0

Add regression test rejecting symlink Markdown import paths

• Adds a unit test that creates a symlink and asserts the new Markdown file reader rejects it with a ValueError, preventing symlink-based import bypass.

tests/context/test_agent_memory_markdown.py

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Aug 12, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (0) 📎 Requirement gaps (1) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. TOCTOU possible without O_NOFOLLOW 📎 Requirement gap ⛨ Security
Description
On platforms where os.O_NOFOLLOW is unavailable, _read_markdown_file_content() uses a
check-then-open sequence (Path.is_symlink() followed by os.open()) that can be bypassed by
swapping in a symlink/reparse point after validation, causing the importer to follow the link and
read an unintended target file. The later os.fstat() + stat.S_ISREG() only confirms the opened
object is a regular file and cannot detect that symlink traversal occurred, silently weakening the
intended cross-platform Markdown import security guarantees.
Code

semantica/context/agent_memory.py[R1919-1921]

+        try:
+            fd = os.open(str(file_path), flags)
+        except OSError as exc:
Evidence
The compliance checklist requires preventing or reliably detecting symlink/reparse-point
substitution on platforms that lack O_NOFOLLOW and forbids silently weaker behavior on those
platforms. The implementation only applies os.O_NOFOLLOW when it exists, but otherwise proceeds
with a path-based is_symlink() check and then opens the path normally; because the only post-open
verification is checking S_ISREG on fstat(fd), the code cannot demonstrate that path resolution
did not traverse a symlink, leaving a TOCTOU window where the file can be replaced with a symlink
between validation and open/read.

Prevent TOCTOU symlink/reparse-point substitution during Markdown import on platforms without O_NOFOLLOW
Do not silently weaken Markdown import security guarantees across platforms
Preserve existing Markdown import behavior for regular files/directories while maintaining symlink rejections from #851
semantica/context/agent_memory.py[1911-1936]
semantica/context/agent_memory.py[1911-1927]
semantica/context/agent_memory.py[1928-1935]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`AgentMemory._read_markdown_file_content()` has a TOCTOU symlink/reparse-point substitution risk on platforms without `os.O_NOFOLLOW`: an attacker can replace a validated path with a symlink between the `Path.is_symlink()` check and `os.open()`, causing the importer to follow the link and read an unintended file, while the current `fstat()` + `S_ISREG` check only verifies the opened object is a regular file and does not detect symlink traversal.
## Issue Context
Compliance requirements for #856 require that platforms lacking `O_NOFOLLOW` use a supported mechanism to prevent or reliably detect substitution between validation and open/read, and that behavior not silently weaken across platforms. To close the TOCTOU window without `O_NOFOLLOW`, validate the opened FD against a no-follow stat of the pathname (e.g., compare `fstat(fd)` to `lstat(path)` and reject if the path is a symlink or if the opened file does not match the pathname’s lstat).
## Fix Focus Areas
- semantica/context/agent_memory.py[1911-1941]
- docs/reference/context.md[589-633]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. Missing TOCTOU race regression ✓ Resolved 📎 Requirement gap ☼ Reliability
Description
The added test only covers importing a path that is already a symlink, but does not exercise the
substitution/race scenario (swap to symlink between validation and open) or document any platform
limitations. This leaves the specific regression scenario from #856 untested.
Code

tests/context/test_agent_memory_markdown.py[R734-737]

+    symlink_file.symlink_to(target)
+
+    with pytest.raises(ValueError, match="Symlink Markdown import paths are rejected"):
+        memory._read_markdown_file_content(symlink_file)
Evidence
Compliance requires regression coverage for the substitution/race scenario and documentation of
limitations. The newly added test only creates a symlink up front and asserts rejection, but does
not simulate a post-validation substitution window or address platform-specific limitations around
O_NOFOLLOW support.

Add regression coverage for symlink/reparse-point substitution (race) scenarios and document limitations
tests/context/test_agent_memory_markdown.py[729-737]
semantica/context/agent_memory.py[1911-1926]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The new test validates direct symlink rejection, but does not cover the TOCTOU substitution scenario (replace a regular file with a symlink/reparse-point between validation and `os.open`) or document what guarantees are/aren't provided on platforms without `O_NOFOLLOW`.
## Issue Context
Compliance #856 explicitly calls for regression coverage of the substitution/race scenario where practical, and documentation of any platform-specific limitations.
## Fix Focus Areas
- tests/context/test_agent_memory_markdown.py[729-737]
- semantica/context/agent_memory.py[1911-1936]
- docs/reference/context.md[589-633]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Symlink entries silently ignored 🐞 Bug ≡ Correctness
Description
When importing a directory, _read_markdown_path() filters out symlink entries (`and not
file_path.is_symlink()`), so symlinked Markdown files are skipped instead of being rejected with the
same actionable error as single-file imports. This can cause partial imports without any
user-visible signal that some Markdown files were refused.
Code

semantica/context/agent_memory.py[R1954-1957]

               for file_path in path.iterdir()
               if file_path.is_file()
+                    and not file_path.is_symlink()
               and file_path.suffix.lower() in self._MARKDOWN_EXTENSIONS
Evidence
Directory enumeration explicitly excludes file_path.is_symlink() and then reads only the filtered
list via _read_markdown_file_content(), which is where the symlink-rejection error would otherwise
be raised. This means symlinked Markdown files in directories will not trigger the rejection error
at all.

semantica/context/agent_memory.py[1950-1969]
semantica/context/agent_memory.py[1911-1914]
semantica/context/agent_memory.py[1742-1748]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Directory imports currently *skip* symlinked Markdown files instead of rejecting them, which can lead to silent partial imports.
### Issue Context
The single-file path is rejected with a clear `ValueError` message. Directory import should behave consistently by detecting symlinked Markdown entries during enumeration and raising a `ValueError` (or, if you truly want to skip, emitting an explicit warning/diagnostic).
### Fix Focus Areas
- semantica/context/agent_memory.py[1950-1969]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

4. Missing import_data symlink test ✓ Resolved 🐞 Bug ⚙ Maintainability
Description
The new regression test asserts the private helper rejects a symlink but does not verify the public
Markdown import path (import_data(..., format="markdown")) rejects a symlink path. This leaves a
gap where a future refactor could bypass the helper while still passing the current test.
Code

tests/context/test_agent_memory_markdown.py[R736-737]

+    with pytest.raises(ValueError, match="Symlink Markdown import paths are rejected"):
+        memory._read_markdown_file_content(symlink_file)
Evidence
The test calls _read_markdown_file_content() directly, while the public path-based Markdown import
flow goes through import_data() -> _import_markdown_payload() -> _read_markdown_path().
Without an integration assertion, the public path could regress without failing this test.

tests/context/test_agent_memory_markdown.py[729-737]
semantica/context/agent_memory.py[1861-1888]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The regression test currently exercises only `_read_markdown_file_content()` directly, not the public import surface.
### Issue Context
Add an integration-style assertion that `AgentMemory.import_data(str(symlink_file), format="markdown")` raises the same error for symlink paths (optionally keep the focused helper test too).
### Fix Focus Areas
- tests/context/test_agent_memory_markdown.py[729-737]
- semantica/context/agent_memory.py[1861-1888]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can describe a rule in plain language on the Rules page and Qodo drafts it for you

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment on lines +1919 to +1921
try:
fd = os.open(str(file_path), flags)
except OSError as exc:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

1. Toctou possible without o_nofollow 📎 Requirement gap ⛨ Security

On platforms where os.O_NOFOLLOW is unavailable, _read_markdown_file_content() uses a
check-then-open sequence (Path.is_symlink() followed by os.open()) that can be bypassed by
swapping in a symlink/reparse point after validation, causing the importer to follow the link and
read an unintended target file. The later os.fstat() + stat.S_ISREG() only confirms the opened
object is a regular file and cannot detect that symlink traversal occurred, silently weakening the
intended cross-platform Markdown import security guarantees.
Agent Prompt
## Issue description
`AgentMemory._read_markdown_file_content()` has a TOCTOU symlink/reparse-point substitution risk on platforms without `os.O_NOFOLLOW`: an attacker can replace a validated path with a symlink between the `Path.is_symlink()` check and `os.open()`, causing the importer to follow the link and read an unintended file, while the current `fstat()` + `S_ISREG` check only verifies the opened object is a regular file and does not detect symlink traversal.

## Issue Context
Compliance requirements for #856 require that platforms lacking `O_NOFOLLOW` use a supported mechanism to prevent or reliably detect substitution between validation and open/read, and that behavior not silently weaken across platforms. To close the TOCTOU window without `O_NOFOLLOW`, validate the opened FD against a no-follow stat of the pathname (e.g., compare `fstat(fd)` to `lstat(path)` and reject if the path is a symlink or if the opened file does not match the pathname’s lstat).

## Fix Focus Areas
- semantica/context/agent_memory.py[1911-1941]
- docs/reference/context.md[589-633]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment thread tests/context/test_agent_memory_markdown.py Outdated
Comment on lines 1954 to 1957
for file_path in path.iterdir()
if file_path.is_file()
and not file_path.is_symlink()
and file_path.suffix.lower() in self._MARKDOWN_EXTENSIONS

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

3. Symlink entries silently ignored 🐞 Bug ≡ Correctness

When importing a directory, _read_markdown_path() filters out symlink entries (`and not
file_path.is_symlink()`), so symlinked Markdown files are skipped instead of being rejected with the
same actionable error as single-file imports. This can cause partial imports without any
user-visible signal that some Markdown files were refused.
Agent Prompt
### Issue description
Directory imports currently *skip* symlinked Markdown files instead of rejecting them, which can lead to silent partial imports.

### Issue Context
The single-file path is rejected with a clear `ValueError` message. Directory import should behave consistently by detecting symlinked Markdown entries during enumeration and raising a `ValueError` (or, if you truly want to skip, emitting an explicit warning/diagnostic).

### Fix Focus Areas
- semantica/context/agent_memory.py[1950-1969]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment thread tests/context/test_agent_memory_markdown.py
@Sameer6305

Copy link
Copy Markdown
Collaborator

@lakshanmuruganandam can you fix the qodo reviews before we review it?
and also comment on the issue you are fixing so i can assign it to you.

@Sameer6305
Sameer6305 requested review from Sameer6305 and Copilot and removed request for Copilot August 14, 2026 05:57
Copilot AI review requested due to automatic review settings August 14, 2026 06:49

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI review requested due to automatic review settings August 14, 2026 06:53

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Sameer6305
Sameer6305 previously approved these changes Aug 14, 2026

@Sameer6305 Sameer6305 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks @lakshanmuruganandam for the security hardening work on this PR. I went through the implementation in detail, including the full Markdown import flow, the affected code paths, platform-specific behavior, and the regression coverage.

Review summary

The core implementation in this PR is strong:

  • O_NOFOLLOW is used where supported, providing kernel-level protection against symlink substitution during os.open().
  • fstat() + S_ISREG verifies that the opened descriptor refers to a regular file.
  • Existing direct-file and directory symlink protections are preserved.
  • The file-descriptor ownership/cleanup path was reviewed.
  • POSIX behavior and the Windows fallback were specifically examined.

Findings and fixes

During the review, we identified a few gaps that were worth addressing:

  1. The Windows fallback does not have O_NOFOLLOW, leaving a platform-specific TOCTOU limitation. We verified that eliminating this completely would require platform-specific Windows APIs outside the project's current scope. We therefore documented the limitation explicitly rather than implying a stronger guarantee than the implementation provides.

  2. The original security regression test exercised the private helper and could fail on Windows without the required symlink privilege handling. This was fixed with the appropriate Windows privilege skip.

  3. Added public-API coverage to verify that import_data(..., format="markdown") actually rejects symlink paths.

  4. Added directory-import coverage to verify that symlinked Markdown entries are not imported.

  5. Added coverage for the fstat() / S_ISREG defense against non-regular files.

These changes were made in commit 7d2431d.

The targeted Markdown/context test suite was then verified with 46 passed, 4 skipped, 0 failures; the skips are the expected Windows symlink-privilege cases.

I also reviewed the final diff and cleaned up the temporary review/probe artifacts. No unrelated regressions or additional security issues were found.

Final verdict

Approved from my side.

@KaifAhmad1, the PR is ready for merge from my side after your final review.

Copilot AI review requested due to automatic review settings August 14, 2026 10:46

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review is ineligible. To be eligible to request a review, you need a paid Copilot license, or your organization must enable Copilot code review.

Copilot AI review requested due to automatic review settings August 14, 2026 10:55

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review is ineligible. To be eligible to request a review, you need a paid Copilot license, or your organization must enable Copilot code review.

@KaifAhmad1 KaifAhmad1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed the diff directly (not just the bot summaries) and ran the test suite locally against this branch: 46 passed, 4 skipped (skips are the expected Windows symlink-privilege cases).

The core fix is solid:

  • os.open() with O_NOFOLLOW closes the TOCTOU window atomically on POSIX (ELOOP → clear ValueError).
  • os.fstat() + stat.S_ISREG() rejects non-regular files even after a successful open.
  • Directory imports now exclude symlinked entries, consistent with the single-file path.

Two non-blocking items, already acknowledged in-thread:

  • Windows has no O_NOFOLLOW, so the is_symlink() pre-check is the only defense there — documented inline rather than overclaiming, matches the scope of #856.
  • Directory import silently skips symlinked entries rather than raising like the single-file path does (Qodo finding #3) — a UX inconsistency, not a security gap, since the content is never read either way.

Thanks @lakshanmuruganandam for the hardening work, and @Sameer6305 for the thorough follow-up review and test coverage. Approving.

@KaifAhmad1
KaifAhmad1 merged commit 1c0cebb into semantica-agi:main Aug 14, 2026
10 checks passed
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.

security(context): harden Markdown import against TOCTOU symlink races on platforms without O_NOFOLLOW

4 participants