fix(webdav): address PR #764 review round 2
Addresses the four points raised in the automated review on PR #764: 1. Scope guards on the four search tools (search_files, find_by_name, find_by_type, list_favorites) so an excluded `scope` raises ToolError instead of silently returning an empty result. Previously an LLM could probe the asymmetry between list_directory (raises) and the search tools (silent) to infer that an excluded directory exists. The 4 search tools now mirror the early-guard pattern from list_directory and avoid an unnecessary upstream query for known- excluded scopes. 2. Concurrent per-tag resolution in get_excluded_file_paths via anyio.create_task_group(). Previously the 2N network calls (1 PROPFIND + 1 REPORT per tag) ran serially. Per-tag fail-open behaviour is preserved by extracting _resolve_one_tag, which swallows its own exceptions so a single tag failure does not abort the surrounding task group. 3. WebDAVClient.get_tag_by_name and get_files_by_tag now route through _make_request, inheriting the @retry_on_429 decorator. Previously they bypassed it; with tag exclusion invoked on every WebDAV tool call, a transient 429 from the systemtags endpoint was hitting the fail-open path instead of being transparently retried. 4. Test coverage: 6 new tests in test_webdav_tools_exclusion.py (4 scope-guard, 2 missing filter tests for find_by_type and list_favorites) and 2 new tests in test_tag_exclusion.py (a concurrency proof using an event-barrier that would deadlock under sequential execution, and a fail-open-under-task-group test with order-independent side_effect callables). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
a6c188abbb
commit
d179ca8c8b
@@ -288,3 +288,96 @@ async def test_find_by_name_filters_excluded(
|
||||
result = await fn(pattern="%.txt", ctx=_mock_ctx(fake_client))
|
||||
|
||||
assert [r.path for r in result.results] == ["/visible.txt"]
|
||||
|
||||
|
||||
async def test_find_by_type_filters_excluded(
|
||||
webdav_tools, fake_client, patch_get_client, patch_excluded
|
||||
):
|
||||
patch_get_client(fake_client)
|
||||
patch_excluded({"Secret.txt"})
|
||||
fake_client.webdav.find_by_type = AsyncMock(
|
||||
return_value=[
|
||||
{"path": "/Secret.txt", "name": "Secret.txt", "is_directory": False},
|
||||
{"path": "/visible.txt", "name": "visible.txt", "is_directory": False},
|
||||
]
|
||||
)
|
||||
|
||||
fn = webdav_tools["nc_webdav_find_by_type"].fn
|
||||
result = await fn(mime_type="text/plain", ctx=_mock_ctx(fake_client))
|
||||
|
||||
assert [r.path for r in result.results] == ["/visible.txt"]
|
||||
|
||||
|
||||
async def test_list_favorites_filters_excluded(
|
||||
webdav_tools, fake_client, patch_get_client, patch_excluded
|
||||
):
|
||||
patch_get_client(fake_client)
|
||||
patch_excluded({"Secret.txt"})
|
||||
fake_client.webdav.list_favorites = AsyncMock(
|
||||
return_value=[
|
||||
{"path": "/Secret.txt", "name": "Secret.txt", "is_directory": False},
|
||||
{"path": "/visible.txt", "name": "visible.txt", "is_directory": False},
|
||||
]
|
||||
)
|
||||
|
||||
fn = webdav_tools["nc_webdav_list_favorites"].fn
|
||||
result = await fn(ctx=_mock_ctx(fake_client))
|
||||
|
||||
assert [r.path for r in result.results] == ["/visible.txt"]
|
||||
|
||||
|
||||
# ── Search-tool scope guards (review #764) ──────────────────────────────
|
||||
|
||||
|
||||
async def test_search_files_raises_when_scope_excluded(
|
||||
webdav_tools, fake_client, patch_get_client, patch_excluded
|
||||
):
|
||||
"""Mirror the ``list_directory`` early guard so the four search tools
|
||||
cannot silently return an empty result for an excluded ``scope``."""
|
||||
patch_get_client(fake_client)
|
||||
patch_excluded({"Private"})
|
||||
|
||||
fn = webdav_tools["nc_webdav_search_files"].fn
|
||||
with pytest.raises(ToolError, match="excluded tag"):
|
||||
await fn(ctx=_mock_ctx(fake_client), scope="/Private", name_pattern="%.txt")
|
||||
|
||||
fake_client.webdav.search_files.assert_not_called()
|
||||
|
||||
|
||||
async def test_find_by_name_raises_when_scope_excluded(
|
||||
webdav_tools, fake_client, patch_get_client, patch_excluded
|
||||
):
|
||||
patch_get_client(fake_client)
|
||||
patch_excluded({"Private"})
|
||||
|
||||
fn = webdav_tools["nc_webdav_find_by_name"].fn
|
||||
with pytest.raises(ToolError, match="excluded tag"):
|
||||
await fn(pattern="%.txt", scope="/Private", ctx=_mock_ctx(fake_client))
|
||||
|
||||
fake_client.webdav.find_by_name.assert_not_called()
|
||||
|
||||
|
||||
async def test_find_by_type_raises_when_scope_excluded(
|
||||
webdav_tools, fake_client, patch_get_client, patch_excluded
|
||||
):
|
||||
patch_get_client(fake_client)
|
||||
patch_excluded({"Private"})
|
||||
|
||||
fn = webdav_tools["nc_webdav_find_by_type"].fn
|
||||
with pytest.raises(ToolError, match="excluded tag"):
|
||||
await fn(mime_type="text/plain", scope="/Private", ctx=_mock_ctx(fake_client))
|
||||
|
||||
fake_client.webdav.find_by_type.assert_not_called()
|
||||
|
||||
|
||||
async def test_list_favorites_raises_when_scope_excluded(
|
||||
webdav_tools, fake_client, patch_get_client, patch_excluded
|
||||
):
|
||||
patch_get_client(fake_client)
|
||||
patch_excluded({"Private"})
|
||||
|
||||
fn = webdav_tools["nc_webdav_list_favorites"].fn
|
||||
with pytest.raises(ToolError, match="excluded tag"):
|
||||
await fn(ctx=_mock_ctx(fake_client), scope="/Private")
|
||||
|
||||
fake_client.webdav.list_favorites.assert_not_called()
|
||||
|
||||
Reference in New Issue
Block a user