fix(webdav): address PR #764 review

Six findings raised in the PR review:

🔴 Blocking
- Fail-open on tag-resolution errors. get_excluded_file_paths now
  wraps each tag's get_tag_by_name and get_files_by_tag call in
  try/except; failures log a warning and the tag is skipped, rather
  than propagating to the caller and disabling all WebDAV tools when
  the systemtags endpoint is degraded. Documented in the docstring as
  the intended fail-open behaviour (threat model is preventing
  accidental exfiltration, not surviving server compromise).

🟡 Important
- nc_webdav_list_directory now raises ToolError when the listed path
  itself is tagged, instead of silently returning an empty listing
  after a wasted PROPFIND. Behaviour now mirrors the mutating tools.
- Destination error messages in move/copy/create_directory said "is
  inside" but is_path_excluded matches exact paths too. Reworded to
  "is or is inside".

🟢 Nits
- get_excluded_file_paths log message clarified: N counts
  directly-tagged paths, not total descendants.
- Test isolation: tests/unit/conftest.py already has an autouse
  _reload_dynaconf_after_test fixture that handles teardown. Removed
  the redundant module-local fixture I had drafted; documented the
  reliance in the module docstring instead.
- Added tests/unit/test_webdav_tools_exclusion.py: 12 server-layer
  tests that register the WebDAV tools on a fresh FastMCP and invoke
  each tool's underlying function with a mocked excluded set, asserting
  ToolError is raised / results filtered as expected. Catches future
  guard-integration regressions (e.g. wrong argument order).

Also added two unit tests for the new fail-open behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-05-06 12:26:25 +02:00
co-authored by Claude Opus 4.7
parent 22ed9e99a0
commit a6c188abbb
4 changed files with 413 additions and 15 deletions
+34 -3
View File
@@ -42,6 +42,14 @@ async def get_excluded_file_paths(webdav: WebDAVClient) -> set[str]:
Tagged directories are added as their own normalised path; descendants
are blocked via prefix match in :func:`is_path_excluded`.
**Failure mode is fail-open per tag**: if the systemtags endpoint is
unreachable or returns an error for a given tag, that tag is skipped
with a warning rather than propagating the error. Reasoning: the
threat model is preventing *accidental* exfiltration via the LLM tool
surface; a Nextcloud-side outage of the systemtags API should not
take down all WebDAV tools. Operators relying on this for stronger
guarantees should monitor the warning logs.
"""
tag_names = get_excluded_tag_names()
if not tag_names:
@@ -49,12 +57,32 @@ async def get_excluded_file_paths(webdav: WebDAVClient) -> set[str]:
excluded: set[str] = set()
for tag_name in tag_names:
tag = await webdav.get_tag_by_name(tag_name)
try:
tag = await webdav.get_tag_by_name(tag_name)
except Exception as e:
logger.warning(
"Tag exclusion lookup failed for tag %r (%s); "
"skipping — files tagged with this tag will be visible",
tag_name,
e,
)
continue
if tag is None:
logger.debug("Excluded tag %r does not exist — skipping", tag_name)
continue
files = await webdav.get_files_by_tag(tag["id"])
try:
files = await webdav.get_files_by_tag(tag["id"])
except Exception as e:
logger.warning(
"Tag exclusion file enumeration failed for tag %r (%s); "
"skipping — files tagged with this tag will be visible",
tag_name,
e,
)
continue
for f in files:
path = _normalise_path(f["path"])
excluded.add(path)
@@ -66,8 +94,11 @@ async def get_excluded_file_paths(webdav: WebDAVClient) -> set[str]:
)
if excluded:
# `len(excluded)` counts directly-tagged entries — descendants of
# tagged directories are hidden too but resolved at check time.
logger.info(
"Tag-based exclusion: hiding %d path(s) matching tags: %s",
"Tag-based exclusion resolved to %d directly-tagged path(s) "
"for tags: %s (descendants of tagged directories also hidden)",
len(excluded),
", ".join(tag_names),
)
+20 -11
View File
@@ -37,9 +37,11 @@ def configure_webdav_tools(mcp: FastMCP):
) -> DirectoryListing:
"""List files and directories in the specified NextCloud path.
When ``EXCLUDED_TAGS`` is configured, entries tagged (or whose
ancestor folders are tagged) with an excluded system tag are
omitted from the result.
When ``EXCLUDED_TAGS`` is configured: raises ``ToolError`` if the
listed path itself is tagged (or sits inside a tagged folder),
and otherwise omits any tagged children from the listing. The
early guard is consistent with the mutating tools and avoids a
round-trip to Nextcloud for a known-excluded path.
Args:
path: Directory path to list (empty string for root directory)
@@ -48,10 +50,16 @@ def configure_webdav_tools(mcp: FastMCP):
DirectoryListing with files, total_count, directories_count, files_count, and total_size
"""
client = await get_client(ctx)
# Resolve once and use for both the path-itself guard and the
# children filter below.
excluded = await get_excluded_file_paths(client.webdav)
if is_path_excluded(path, excluded):
raise ToolError(f"Access denied: {path!r} is tagged with an excluded tag")
items = await client.webdav.list_directory(path)
# Filter out files/folders carrying an excluded tag.
excluded = await get_excluded_file_paths(client.webdav)
# Filter out child files/folders carrying an excluded tag.
if excluded:
items = [
i for i in items if not is_path_excluded(i.get("path", ""), excluded)
@@ -221,11 +229,12 @@ def configure_webdav_tools(mcp: FastMCP):
"""
client = await get_client(ctx)
# Block directory creation inside excluded paths.
# Block directory creation at or inside excluded paths.
excluded = await get_excluded_file_paths(client.webdav)
if is_path_excluded(path, excluded):
raise ToolError(
f"Access denied: {path!r} is inside a path tagged with an excluded tag"
f"Access denied: {path!r} is or is inside a path tagged "
"with an excluded tag"
)
return await client.webdav.create_directory(path)
@@ -297,8 +306,8 @@ def configure_webdav_tools(mcp: FastMCP):
)
if is_path_excluded(destination_path, excluded):
raise ToolError(
f"Access denied: destination {destination_path!r} is inside a "
"path tagged with an excluded tag"
f"Access denied: destination {destination_path!r} is or is "
"inside a path tagged with an excluded tag"
)
return await client.webdav.move_resource(
@@ -341,8 +350,8 @@ def configure_webdav_tools(mcp: FastMCP):
)
if is_path_excluded(destination_path, excluded):
raise ToolError(
f"Access denied: destination {destination_path!r} is inside a "
"path tagged with an excluded tag"
f"Access denied: destination {destination_path!r} is or is "
"inside a path tagged with an excluded tag"
)
return await client.webdav.copy_resource(