Python: fix(core): prevent path collisions between files and directories in InMemoryAgentFileStore by ManoharPaturi · Pull Request #8687 · microsoft/agent-framework · GitHub
Skip to content

Python: fix(core): prevent path collisions between files and directories in InMemoryAgentFileStore - #8687

Open
Manohar Paturi (ManoharPaturi) wants to merge 7 commits into
microsoft:mainfrom
ManoharPaturi:fix/in-memory-file-store-path-collision
Open

Manohar Paturi (ManoharPaturi) wants to merge 7 commits into
microsoft:mainfrom
ManoharPaturi:fix/in-memory-file-store-path-collision

Conversation

@ManoharPaturi

Copy link
Copy Markdown
Contributor

Motivation & Context

In InMemoryAgentFileStore, writing to a path whose ancestor is already an existing file succeeded, as did writing to a path that already has descendant files in the store. This allowed Reports and Reports/q1.txt to coexist simultaneously, causing list_children to surface both a directory and a file under the exact same name. Real filesystems reject these operations with NotADirectoryError and IsADirectoryError.

This change aligns InMemoryAgentFileStore with FileSystemAgentFileStore by enforcing directory and file path invariants during write and create_directory.

Description & Review Guide

  1. In write, verified that no ancestor path of the target is an existing file in self._files, raising NotADirectoryError if encountered.
  2. In write, checked whether the target path is already an existing directory with descendants, raising IsADirectoryError if so.
  3. In create_directory, ensured that neither the target path nor any ancestor is an existing file, raising FileExistsError if a collision is detected.
  4. Added regression tests in test_harness_file_access.py covering both collision orders and confirming existing file contents remain intact.

Related Issue

Fixes #8567

Contribution Checklist

  • The code builds clean without any errors or warnings
  • All unit tests pass, and I have added new tests where possible
  • The PR follows the Contribution Guidelines
  • This PR is linked to an issue and there is no other open PR for this issue (see Related Issue above).
  • This is not a breaking change. If it is a breaking change, add the breaking change label (or add "[BREAKING]" to the title prefix, before or after any language prefix) — a workflow keeps the label and title prefix in sync automatically.

…ies in InMemoryAgentFileStore

Signed-off-by: Manohar Paturi <186662190+ManoharPaturi@users.noreply.github.com>

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.

@agent-framework-automation agent-framework-automation Bot added the python Usage: [Issues, PRs], Target: Python label Sep 23, 2026
@eavanvalkenburg

Copy link
Copy Markdown
Member

@github-actions github-actions Bot 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.

MAF Automated Review — Iteration 1

Result: Findings reported
Scope: full PR (1 commit(s)): 05f1554d6486
Model: gpt-5.6-sol-fast

Overview

The PR adds lock-protected, case-insensitive ancestor and descendant checks, with focused regression tests showing rejected writes leave existing content intact. The collision invariant remains incomplete because successful explicit directory creation does not reserve a path, and the new validation loops can synchronously block the event loop based on either total store size or path depth. These issues affect backend parity and shared-store availability.

Reviewed the supplied pull-request change set across correctness, security/reliability, architecture, and failure behavior.
3 verified findings remained after source verification (3 medium) across 1 file. Details are attached to the affected lines below.

Affected areas: python/packages/core/agent_framework/_harness/_file_access.py

"""No-op: directories are implicit from file paths in the in-memory store."""
del path
"""Ensure the directory at ``path`` exists."""
display = _normalize_relative_path(path, is_directory=True)

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.

create_directory() only validates path; it never records the directory. After await create_directory("Reports"), write("Reports", ...) still succeeds and the in-memory backend reports a file where FileSystemAgentFileStore raises IsADirectoryError, so successful empty/scoped directory creation does not satisfy the AgentFileStore contract. Please persist explicit directories and include them in write collision checks and directory listings so this call reserves the path.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 9b05a0f. Explicit directories and their intermediate parents are now recorded in _explicit_dirs, included in directory listings, and reject subsequent file writes with IsADirectoryError.

ancestor = "/".join(parts[:i])
if ancestor in self._files:
raise NotADirectoryError(f"Not a directory: {path!r}")
for existing_key in self._files:

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.

This scans every stored file for every write. With no entry limit or automatic cleanup, 20,000 flat writes took about 5.9 seconds in a local reproduction, and later writes continue synchronously scanning shared state under _lock, so model-driven writes can increasingly stall other sessions using the store. Please maintain indexed directory-prefix metadata so collision detection is bounded by path depth rather than total store size.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 9b05a0f. Added an indexed reference counter _dir_counts that increments on file creation and decrements on deletion across ancestor keys, making collision detection O(depth) rather than O(total files).

async with self._lock:
parts = key.split("/")
for i in range(1, len(parts)):
ancestor = "/".join(parts[:i])

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.

Building every ancestor with "/".join(parts[:i]) makes a single write quadratic in path depth and runs synchronously on the event loop. A 40,000-segment path accepted by the current normalizer blocked for about 7.5 seconds in a local reproduction, and neither the schema nor normalizer limits path length or depth. Please bound path complexity or build and check ancestors in linear time so one write cannot monopolize the event loop.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 9b05a0f. Replaced repeated slicing and joins with _ancestor_keys, which walks slash indices in linear time relative to path length.

…AgentFileStore

Signed-off-by: Manohar Paturi <186662190+ManoharPaturi@users.noreply.github.com>
The upstream microsoft#8507 assertion expected list_children("") to stay empty,
which held while create_directory was a no-op. This PR makes explicit
directories listable, so the session-1 directory created during
before_run now appears at the root. The invariant the test protects,
that a rejected nested write leaves nothing behind, is preserved: the
session directory exists and stays empty, with no notes child.
@ManoharPaturi

Copy link
Copy Markdown
Contributor Author

CI note: the 3.10/3.14 ubuntu failures came from upstream #8507 landing test_tools_reject_nested_paths, which asserted list_children("") stays empty. that held while create_directory was a no-op; this PR makes explicit directories listable, so the session-1 directory created during before_run now shows up at the root. the invariant the test protects is unchanged, a rejected nested write leaves nothing behind. updated the assertion in 92785b5 to pin the session dir as the only root entry and that it stays empty. both file harness suites pass locally, fresh matrix running.

@eavanvalkenburg

Copy link
Copy Markdown
Member

/review

@github-actions github-actions Bot 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.

MAF Automated Review — Iteration 2

Result: Findings reported
Scope: 106 net-new commit(s): 76ccf3c44cee, a638ce136ed6, 0bbb7245d408, 834eb7ff12c8, 30b9b8e06766, 622737258c73, 7fb41d673c97, 4f4e72284c9c, 2055e666e1ac, 62337887116c, b20927b921d4, 2191c60c5e7d, 717557a3791a, 91fd47035684, f83a892f9f0e, 0843a9c0537b, e2c6df28daa9, 72272613d0c0, 5ee3e87d6768, 8ff549d3f721, 024dd9908bc1, 2c46deb91e70, 9b05a0f91616, f9310e5ac2a1, 6f1522a50b66, 7c47fc87d162, 273e63508fe5, 7be69facedc3, 11c0dd7ccfdd, 109234952ad4, 218700d4e94a, 2a8daa077b74, aab258745c6d, 93cf98a042c0, a38414391c1b, 9bca1ba4a800, 9acaf82b61a9, ed745e5e81de, fd932dcb8ec0, cc89fe677348, 529564c5b24e, 789d514d9cbf, 153aaa1e0416, 11c45ce72ec1, 6bb47d4fa7c6, ce4fcb2665c3, c91cf3234101, 16aa03b33a46, 490e01947e9a, 20c404524b2c, e9d9962bfc1f, 08eb7d94e9af, c804f32c983d, 238d7e2e9bb9, 3f1f3b50b57c, 3efb73a856a0, 1370903bd8e6, 37ce872a85fe, 2024d4df4c4d, 0d6d2bb6b0b5, be01deccaf3d, 3b27cd33800c, f47c3782376f, 95e711a6280d, b86cbe4dbe21, 8e6bf37c6d26, 01cf5b547c5f, b000c15d3966, 5a2b135ddb2b, 06e9b48937d5, 7a98aa9f3c21, 763887452f37, 202e85d0d876, 77f832ff1ac9, 0911ad636de4, f160d050937b, ba320332d496, 987b3459ff61, 1a2b4fb838c0, 8258db8cf08c, 30aee9fc8da4, e7d99c189678, 8220ae265631, e3a04fecbb20, e626cef6c953, 71e84a098dec, d4157d809a31, f8f15f28db6a, 534334731279, e23f3c89f29e, c8126a0cedb7, a708babd7c74, 142a1c01bf4e, 3a837e327226, 2d995049b9b0, 908ca8c1a868, 52ff4d28011a, 1a03705ae453, 4b1c231b4fa4, 2a7470a92c9b, 3d39258d72d2, 4b0e3d8e84c2, 2f5713f60e6e, 612858c7170c, 2b6c5cbe1992, 92785b5d7572
Model: gpt-5.6-sol

Overview

The change replaces full-store collision scans with ancestor indexes, persists explicitly created directories, and adds focused collision and provider regression coverage; normalization and a shared lock keep each mutation atomic. Explicit directories now satisfy the reservation contract, but session-scoped providers can allocate them automatically with no release, expiry, or quota. On a shared in-memory store, that leaves a persistent process-wide resource-exhaustion path.

Reviewed the supplied incremental change set across correctness, security/reliability, architecture, and failure behavior.
1 verified finding remained after source verification (1 high) across 1 file. Details are attached to the affected lines below.

Affected areas: python/packages/core/agent_framework/_harness/_file_access.py

ancestor_display = display
if ancestor not in self._explicit_dirs:
self._explicit_dirs[ancestor] = ancestor_display
self._explicit_dirs[key] = display

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.

Each new session-scoped run can reach this assignment with a unique session ID, but delete() cannot remove explicit directories and there is no expiry or quota. Because list_children() copies and scans this shared map, an untrusted caller can permanently slow unrelated sessions and eventually exhaust process memory. Please add lifecycle cleanup/expiry or bounded tenant-local retention for these entries.

Explicitly created directories had no deletion path, so session-scoped
runs kept accumulating entries in the shared map. delete() now removes
an explicit directory once nothing is stored under it and no other
explicit directory is nested below, giving session directories a
lifecycle. Non-empty directories stay put, matching the filesystem
store's file-only deletes.

Signed-off-by: Manohar Paturi <186662190+ManoharPaturi@users.noreply.github.com>
@ManoharPaturi

Copy link
Copy Markdown
Contributor Author

Pushed b4bf58e: delete() now removes an explicitly created directory once it is empty (nothing stored under it, no nested explicit directory), so session-scoped directories get a lifecycle instead of living in the map forever. Non-empty directories are refused, which keeps parity with the filesystem store's file-only deletes. A quota or TTL on top of that felt like a policy call I shouldn't pick alone, happy to take direction if you want one.

@eavanvalkenburg

Copy link
Copy Markdown
Member

/review

@github-actions github-actions Bot 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.

MAF Automated Review — Iteration 3

Result: Findings reported
Scope: 167 net-new commit(s): 853c4564476a, 7634356a1de0, d5b2de2ed66d, c3bb759b3dd7, 06f6e5f21636, 577caa2f0de1, e15c6dce2d10, 8b4713ed5c0f, 4ce5ae3f2812, 2f84b1410eb0, aa6c73cce721, cb63c2abc42e, 387d5c7bc389, a850ee6c221a, f1fb145c4d80, 99215f96dafa, d52db94e4b61, a15c0197b282, 2e8b58c06da2, 501989469265, 6d8e16ea3641, f8df81a2a84e, 68e58bf8e79c, a91fe6682f16, ef9084c8d947, 098fca4dc4b7, bbb639effc0f, e9cb7fe68707, 8e2fcbf348f3, 3e1289d180ff, 30055e4d36eb, a2f4506c0ba3, 6967986f8e4a, ffd85a2caedf, 84d135379442, cb77f68f005f, 6e681cc1e4d2, e9857bd75a21, 735050553822, 3c2fe0a8a365, d7ef9e240836, 90615858cbf0, de3a3b08df5b, cdd339b0a541, 4dfb7565b67e, 301a43c862be, 2606b1a0498a, 5bba4cec7380, ccf44d95988b, 52fed574f05a, 6ef0b73ed8ee, 2df61296bb56, 636e8562d1db, bd5ab48cfdeb, b9d24c8fb484, e63ca8ac7d9a, ac5ed2f49ee9, 23821dea4ea7, 6d896b843289, 1b93be1a2d66, 1aca61d51bb9, 41c4f1c9b024, b306dde15cd1, 0774607d8d3f, 73a96cb1a902, 9251bc9911d3, 41e785fb9a3c, 279d97f75cb2, 847665f853a1, d0ccaa62761e, 245d4284a501, fb1be94a9ace, 15cda5ca9c90, d875bf08ae4e, df4f41d1588e, 866253d87f0c, 8254704d57dc, 5f2294175235, 45a92e6a1e8c, 08f9136d0bff, dcd74e0a54ed, 1e722004da32, 13150433d155, af1995301a47, dd1488236f3a, 40763bed1a5e, 3d0bde5736a3, 76b3cf7c0668, 919e62421540, 1cfd2c59f54a, 26fa33f9ec46, 9ca28e85fd80, 93dc1abc694e, c7ffabe0f2aa, 7a262d6a7236, 917e0c28dac2, a35f8c48e295, 9176e176addd, 4de99bc2a30a, ca936db37708, 6e630f4796b8, 91ab44faa482, 2d9cc3f8ae46, 4e9984295050, 37b606a0b5f7, 513ec8f9afe8, d53cdffab41c, 3e6db916fb8f, 9c622e69c358, 723d8caf87eb, 477ecc67231f, 9f21f1a56b82, 708b1aba787e, 65a12f9bc826, b2ee384737e0, 524f79c15948, c2c8d36496f6, c1c288b64eef, f72bf952415a, fd52de715791, 4d0da3cacb0c, cbe008ebcc8b, 4dc30df5ced2, 81836614649a, 50c612bb37bc, cc5010c7e1c3, 967d2c6c3a91, 41579d2fb235, 60bc6ef5a5d3, 5a131d721ef0, f64e4c02c562, c809095b7f3b, 34e980a4773b, 24b7550bfd32, ce1c3b7a0f16, a72eb657887d, f9480ea78009, 2c33eba9861b, 9d4672f7a12a, fc24e6f6e433, e7ca6c21a901, a75e803ef6ec, 2d58876e49d7, 726dcff81e93, b53d25ed1cd2, 538da719e86c, 2e1505683a7d, 691b115e9e02, 457487288767, ce83eab7a300, 129eeb8ef64e, d57f9e8ac632, cc81a8e72a0b, 03c0f322e0ea, 7d09721562c2, f76d0a3de369, a70a727eec6a, a57b37da903c, 2b074fa8ddae, dc649e5980d4, 4a90089f3cdf, ec47b901107f, b5e552112f0a, dcbbf7967afe, 1b9879c58ac9, 2002bfe645d2, b4bf58ee38a5
Model: gpt-5.6-sol

Overview

The review found 1 verified inline finding(s).

Reviewed the supplied incremental change set across correctness, security/reliability, architecture, and failure behavior.
1 verified finding remained after source verification (1 medium) across 1 file. Details are attached to the affected lines below.

Affected areas: python/packages/core/agent_framework/_harness/_file_access.py

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.

Deleting an empty explicit directory returns True in _file_access.py:1138-1145, despite the file-only contract at :798-805 and filesystem behavior at :1516-1524; backend substitution is therefore observable, while any fix must preserve session-scoped directory cleanup.

This branch was successfully deployed

1 active deployment
github-app-auth — b4bf58ee Deployed Oct 14, 2026 by ManoharPaturi via add_label #25262
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

python Usage: [Issues, PRs], Target: Python

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Python: [Bug]: InMemoryAgentFileStore permits a path to be both a file and a directory

3 participants