feat(webdav): add tag-based file exclusion (#710)
Hide sensitive files/folders from the WebDAV MCP tool surface by tagging them with a configured Nextcloud system tag. Defence-in-depth control for users who connect LLMs to accounts holding contracts, medical records, credentials, etc. A new EXCLUDED_TAGS env var (comma-separated tag names, empty by default) gates an exclusion layer that runs at the start of every WebDAV tool call: tag names are resolved to tag IDs, those IDs are expanded to the set of tagged paths, then listings/searches are filtered and read/write/delete/move/copy operations on excluded paths raise ToolError. Tagged folders exclude their descendants via prefix match. Empty EXCLUDED_TAGS disables the feature entirely. The threat model is preventing accidental data exfiltration via the LLM tool surface — not hiding files from a determined operator. The docs explicitly recommend creating exclusion tags with user_assignable=false so the credentials the MCP server uses cannot remove the tag. Implementation: - config.py: add `excluded_tags` to _DEFAULTS, Settings, and the _field_map alongside other comma-separated env vars. - client/webdav.py: get_files_by_tag now requests <d:resourcetype/> and surfaces is_directory so tagged directories can recursively exclude descendants. - server/tag_exclusion.py (new): get_excluded_tag_names, get_excluded_file_paths, is_path_excluded. - server/webdav.py: exclusion guards in all 11 WebDAV tools; read/write/create/delete/move/copy raise ToolError, list/search tools silently filter excluded entries. Existing f-string log calls converted to lazy %-style. - tests: 17 new unit tests covering path-matching edge cases (shared-prefix non-match, descendants of excluded dirs), tag-name parsing, and get_excluded_file_paths with mocked WebDAV; 1 new client test asserting <d:resourcetype/> -> is_directory parsing. - docs/configuration.md: new "Tag-Based File Exclusion" section with per-tool effect table, security guidance, and per-call cost note. - README.md: feature mention under Key Features. Closes #710. 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
b23f7d9534
commit
22ed9e99a0
@@ -74,6 +74,7 @@ For Kubernetes, see [cbcoutinho/helm-charts](https://github.com/cbcoutinho/helm-
|
||||
- **Document Processing** - OCR and text extraction from PDFs, DOCX, images with progress notifications
|
||||
- **Flexible Deployment** - Docker, Kubernetes ([Helm chart](https://github.com/cbcoutinho/helm-charts)), VM, or local installation
|
||||
- **Production-Ready Auth** - Basic Auth with app passwords; multi-user via Login Flow v2 — MCP clients authenticate via OAuth, the server handles Nextcloud app passwords transparently
|
||||
- **Tag-Based File Exclusion** - Hide sensitive files/folders from MCP file tools by tagging them with a configured Nextcloud system tag (`EXCLUDED_TAGS`). See [docs/configuration.md](docs/configuration.md#tag-based-file-exclusion-optional)
|
||||
- **Multiple Transports** - streamable-http (default) and stdio
|
||||
|
||||
## Supported Apps
|
||||
|
||||
@@ -576,6 +576,87 @@ docker-compose up
|
||||
|
||||
---
|
||||
|
||||
## Tag-Based File Exclusion (Optional)
|
||||
|
||||
Some files (contracts, medical records, credentials, private notes) should
|
||||
never be exposed to an LLM, even when the assistant has valid credentials
|
||||
for the account. The MCP server can hide such files from all WebDAV tools
|
||||
based on **Nextcloud system tags** (the same collaborative tags users
|
||||
manage from the Nextcloud UI).
|
||||
|
||||
### Setup
|
||||
|
||||
Set `EXCLUDED_TAGS` to a comma-separated list of system tag names:
|
||||
|
||||
```bash
|
||||
EXCLUDED_TAGS=confidential,no-ai,private
|
||||
```
|
||||
|
||||
Then create the tags in Nextcloud (one-time, as admin):
|
||||
|
||||
```bash
|
||||
docker compose exec app php occ tag:add 'no-ai' --user-visible=true --user-assignable=false
|
||||
```
|
||||
|
||||
`--user-assignable=false` is **strongly recommended** for the threat model
|
||||
this feature is designed to address — see *Security considerations* below.
|
||||
Tag any file or folder with one of these tags from the Nextcloud UI to
|
||||
hide it from the MCP tools.
|
||||
|
||||
Empty (`EXCLUDED_TAGS=""`, the default) disables the feature entirely.
|
||||
|
||||
### Behaviour
|
||||
|
||||
When `EXCLUDED_TAGS` is set, every WebDAV MCP tool resolves the configured
|
||||
tag names to file paths and applies the following:
|
||||
|
||||
| Tool | Effect on tagged paths |
|
||||
|------|------------------------|
|
||||
| `nc_webdav_list_directory` | Excluded files/folders are omitted from listings |
|
||||
| `nc_webdav_read_file` | Raises `ToolError` (access denied) |
|
||||
| `nc_webdav_write_file` | Raises `ToolError` (access denied) |
|
||||
| `nc_webdav_create_directory` | Blocked inside excluded paths |
|
||||
| `nc_webdav_delete_resource` | Raises `ToolError` (access denied) |
|
||||
| `nc_webdav_move_resource` | Blocked when source **or** destination is excluded |
|
||||
| `nc_webdav_copy_resource` | Blocked when source **or** destination is excluded |
|
||||
| `nc_webdav_search_files` | Excluded files are filtered from results |
|
||||
| `nc_webdav_find_by_name` | Excluded files are filtered from results |
|
||||
| `nc_webdav_find_by_type` | Excluded files are filtered from results |
|
||||
| `nc_webdav_list_favorites` | Excluded files are filtered from results |
|
||||
|
||||
Tagging a **folder** hides the folder itself **and** every descendant
|
||||
recursively, via path-prefix match.
|
||||
|
||||
### Security considerations
|
||||
|
||||
The threat model is **preventing accidental data exfiltration via the LLM
|
||||
tool surface**, not hiding files from a determined operator. Specifically:
|
||||
|
||||
- Create exclusion tags with `user_assignable=false` so the credentials
|
||||
the MCP server uses cannot remove the tag from a file (and thereby
|
||||
bypass the exclusion). With `user_assignable=true`, any user — including
|
||||
the one whose credentials the MCP server uses — can untag a file.
|
||||
- Optionally set `user_visible=false` if the exclusion tag itself is
|
||||
sensitive metadata.
|
||||
- The exclusion is enforced at the MCP tool layer only. Direct WebDAV /
|
||||
Nextcloud client access still sees the files; this feature does not
|
||||
alter Nextcloud's underlying access control.
|
||||
|
||||
### Performance note
|
||||
|
||||
The excluded path set is resolved per WebDAV tool call (1 PROPFIND for
|
||||
each tag name + 1 REPORT per tag). For typical setups (a handful of
|
||||
tagged files under one or two tag names) the overhead is negligible.
|
||||
Caching may be added in a future release.
|
||||
|
||||
### Scope
|
||||
|
||||
This feature only covers WebDAV file operations. Notes, Calendar,
|
||||
Contacts, Deck, etc. are not filtered, because they use ID-based APIs
|
||||
rather than file paths.
|
||||
|
||||
---
|
||||
|
||||
## Loading Environment Variables
|
||||
|
||||
After creating your `.env` file, load the environment variables:
|
||||
|
||||
@@ -1197,7 +1197,9 @@ class WebDAVClient(BaseNextcloudClient):
|
||||
Returns:
|
||||
List of file info dictionaries with path, size, content_type, etc.
|
||||
"""
|
||||
# Use WebDAV REPORT method with systemtag filter, requesting all properties
|
||||
# Use WebDAV REPORT method with systemtag filter. resourcetype is
|
||||
# included so callers can distinguish folders from files (needed for
|
||||
# recursive exclusion of tagged directories — see issue #710).
|
||||
report_body = f"""<?xml version="1.0"?>
|
||||
<oc:filter-files xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
|
||||
<d:prop>
|
||||
@@ -1207,6 +1209,7 @@ class WebDAVClient(BaseNextcloudClient):
|
||||
<d:getcontenttype/>
|
||||
<d:getlastmodified/>
|
||||
<d:getetag/>
|
||||
<d:resourcetype/>
|
||||
</d:prop>
|
||||
<oc:filter-rules>
|
||||
<oc:systemtag>{tag_id}</oc:systemtag>
|
||||
@@ -1249,10 +1252,17 @@ class WebDAVClient(BaseNextcloudClient):
|
||||
contenttype_elem = prop.find("d:getcontenttype", ns)
|
||||
lastmodified_elem = prop.find("d:getlastmodified", ns)
|
||||
etag_elem = prop.find("d:getetag", ns)
|
||||
resourcetype_elem = prop.find("d:resourcetype", ns)
|
||||
|
||||
if fileid_elem is None or not fileid_elem.text:
|
||||
continue
|
||||
|
||||
# A resourcetype with a <d:collection/> child indicates a folder.
|
||||
is_directory = (
|
||||
resourcetype_elem is not None
|
||||
and resourcetype_elem.find("d:collection", ns) is not None
|
||||
)
|
||||
|
||||
# Decode href path and extract the file path
|
||||
href_path = unquote(href_elem.text)
|
||||
# Remove WebDAV prefix to get user-relative path
|
||||
@@ -1285,10 +1295,11 @@ class WebDAVClient(BaseNextcloudClient):
|
||||
else None,
|
||||
"last_modified_timestamp": last_modified_timestamp,
|
||||
"etag": etag_elem.text if etag_elem is not None else None,
|
||||
"is_directory": is_directory,
|
||||
}
|
||||
files.append(file_info)
|
||||
|
||||
logger.debug(f"Found {len(files)} files with tag ID {tag_id}")
|
||||
logger.debug("Found %d files with tag ID %s", len(files), tag_id)
|
||||
return files
|
||||
|
||||
async def get_file_info(self, path: str) -> dict[str, Any] | None:
|
||||
|
||||
@@ -117,6 +117,10 @@ _DEFAULTS: dict[str, Any] = {
|
||||
"custom_processor_name": "custom",
|
||||
"custom_processor_api_key": None,
|
||||
"custom_processor_timeout": 60,
|
||||
# Tag-based file exclusion (issue #710): comma-separated list of
|
||||
# Nextcloud system tag names. Files/folders carrying any of these tags
|
||||
# are hidden from WebDAV MCP tools. Empty = feature off.
|
||||
"excluded_tags": "",
|
||||
}
|
||||
|
||||
|
||||
@@ -508,6 +512,11 @@ class Settings:
|
||||
log_level: str = "INFO"
|
||||
log_include_trace_context: bool = True
|
||||
|
||||
# Tag-based file exclusion (issue #710): comma-separated list of
|
||||
# Nextcloud system tag names. Files/folders carrying any of these tags
|
||||
# are hidden from WebDAV MCP tools.
|
||||
excluded_tags: str = ""
|
||||
|
||||
def __post_init__(self):
|
||||
"""Validate configuration and set defaults."""
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -845,6 +854,7 @@ def get_settings() -> Settings:
|
||||
"log_format": "LOG_FORMAT",
|
||||
"log_level": "LOG_LEVEL",
|
||||
"log_include_trace_context": "LOG_INCLUDE_TRACE_CONTEXT",
|
||||
"excluded_tags": "EXCLUDED_TAGS",
|
||||
}
|
||||
|
||||
# Only pass values that dynaconf actually has; omit unset keys so
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Tag-based file exclusion for MCP file operations (issue #710).
|
||||
|
||||
Resolves the configured ``EXCLUDED_TAGS`` to a set of file paths that
|
||||
should be hidden from WebDAV MCP tools (list, read, search) and rejected
|
||||
by mutating tools (write, delete, move, copy).
|
||||
|
||||
The flow per call:
|
||||
|
||||
1. Parse ``EXCLUDED_TAGS`` (comma-separated tag names) from config.
|
||||
2. For each tag name, resolve to a tag ID via ``get_tag_by_name``.
|
||||
3. For each tag ID, fetch all tagged file/folder paths via
|
||||
``get_files_by_tag``.
|
||||
4. Collect normalised paths into a single ``set[str]``.
|
||||
|
||||
Tagging a *folder* excludes the folder itself and every descendant via
|
||||
prefix match in :func:`is_path_excluded`.
|
||||
|
||||
Threat model: this is a defence-in-depth control to prevent accidental
|
||||
exfiltration via the LLM tool surface. A user controlling the Nextcloud
|
||||
account whose credentials the server uses can untag files unless the tag
|
||||
is created with ``user_assignable=false``.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from nextcloud_mcp_server.client.webdav import WebDAVClient
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_excluded_tag_names() -> list[str]:
|
||||
"""Return the configured excluded tag names (empty list if disabled)."""
|
||||
raw = get_settings().excluded_tags
|
||||
if not raw:
|
||||
return []
|
||||
return [t.strip() for t in raw.split(",") if t.strip()]
|
||||
|
||||
|
||||
async def get_excluded_file_paths(webdav: WebDAVClient) -> set[str]:
|
||||
"""Resolve excluded tags to the set of paths they cover.
|
||||
|
||||
Tagged directories are added as their own normalised path; descendants
|
||||
are blocked via prefix match in :func:`is_path_excluded`.
|
||||
"""
|
||||
tag_names = get_excluded_tag_names()
|
||||
if not tag_names:
|
||||
return set()
|
||||
|
||||
excluded: set[str] = set()
|
||||
for tag_name in tag_names:
|
||||
tag = await webdav.get_tag_by_name(tag_name)
|
||||
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"])
|
||||
for f in files:
|
||||
path = _normalise_path(f["path"])
|
||||
excluded.add(path)
|
||||
if f.get("is_directory"):
|
||||
logger.debug(
|
||||
"Excluding directory %r (tag %r) — descendants will be hidden",
|
||||
path,
|
||||
tag_name,
|
||||
)
|
||||
|
||||
if excluded:
|
||||
logger.info(
|
||||
"Tag-based exclusion: hiding %d path(s) matching tags: %s",
|
||||
len(excluded),
|
||||
", ".join(tag_names),
|
||||
)
|
||||
|
||||
return excluded
|
||||
|
||||
|
||||
def is_path_excluded(path: str, excluded_paths: set[str]) -> bool:
|
||||
"""Return True if *path* (or any of its parents) is excluded.
|
||||
|
||||
A path is excluded when it matches an entry exactly, or when an
|
||||
excluded entry is one of its directory ancestors (prefix match on
|
||||
``<dir>/``).
|
||||
"""
|
||||
if not excluded_paths:
|
||||
return False
|
||||
normalised = _normalise_path(path)
|
||||
if normalised in excluded_paths:
|
||||
return True
|
||||
for exc in excluded_paths:
|
||||
if normalised.startswith(exc + "/"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _normalise_path(path: str) -> str:
|
||||
"""Strip leading/trailing slashes for consistent comparison."""
|
||||
return path.strip("/")
|
||||
@@ -2,12 +2,17 @@ import base64
|
||||
import logging
|
||||
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
from mcp.server.fastmcp.exceptions import ToolError
|
||||
from mcp.types import ToolAnnotations
|
||||
|
||||
from nextcloud_mcp_server.auth import require_scopes
|
||||
from nextcloud_mcp_server.context import get_client
|
||||
from nextcloud_mcp_server.models import DirectoryListing, FileInfo, SearchFilesResponse
|
||||
from nextcloud_mcp_server.observability.metrics import instrument_tool
|
||||
from nextcloud_mcp_server.server.tag_exclusion import (
|
||||
get_excluded_file_paths,
|
||||
is_path_excluded,
|
||||
)
|
||||
from nextcloud_mcp_server.utils.document_parser import (
|
||||
is_parseable_document,
|
||||
parse_document,
|
||||
@@ -32,6 +37,10 @@ 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.
|
||||
|
||||
Args:
|
||||
path: Directory path to list (empty string for root directory)
|
||||
|
||||
@@ -41,6 +50,13 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
client = await get_client(ctx)
|
||||
items = await client.webdav.list_directory(path)
|
||||
|
||||
# Filter out files/folders carrying an excluded tag.
|
||||
excluded = await get_excluded_file_paths(client.webdav)
|
||||
if excluded:
|
||||
items = [
|
||||
i for i in items if not is_path_excluded(i.get("path", ""), excluded)
|
||||
]
|
||||
|
||||
# Convert to FileInfo models
|
||||
file_infos = [FileInfo(**item) for item in items]
|
||||
|
||||
@@ -70,6 +86,9 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
async def nc_webdav_read_file(path: str, ctx: Context):
|
||||
"""Read the content of a file from NextCloud.
|
||||
|
||||
Raises ``ToolError`` when ``EXCLUDED_TAGS`` is configured and the
|
||||
file (or an ancestor folder) carries an excluded system tag.
|
||||
|
||||
Args:
|
||||
path: Full path to the file to read
|
||||
|
||||
@@ -80,13 +99,19 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
- Other binary files are base64 encoded
|
||||
"""
|
||||
client = await get_client(ctx)
|
||||
|
||||
# Block reads of paths carrying an excluded tag.
|
||||
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")
|
||||
|
||||
content, content_type = await client.webdav.read_file(path)
|
||||
|
||||
# Check if this is a parseable document (PDF, DOCX, etc.)
|
||||
# is_parseable_document() checks if document processing is enabled
|
||||
if is_parseable_document(content_type):
|
||||
try:
|
||||
logger.info(f"Parsing document '{path}' of type '{content_type}'")
|
||||
logger.info("Parsing document %r of type %r", path, content_type)
|
||||
parsed_text, metadata = await parse_document(
|
||||
content,
|
||||
content_type,
|
||||
@@ -103,7 +128,9 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to parse document '{path}', falling back to base64: {e}"
|
||||
"Failed to parse document %r, falling back to base64: %s",
|
||||
path,
|
||||
e,
|
||||
)
|
||||
# Fall through to base64 encoding on parse failure
|
||||
|
||||
@@ -144,6 +171,9 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
):
|
||||
"""Write content to a file in NextCloud.
|
||||
|
||||
Raises ``ToolError`` when ``EXCLUDED_TAGS`` is configured and the
|
||||
target path (or an ancestor folder) carries an excluded system tag.
|
||||
|
||||
Args:
|
||||
path: Full path where to write the file
|
||||
content: File content (text or base64 for binary)
|
||||
@@ -154,6 +184,11 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
"""
|
||||
client = await get_client(ctx)
|
||||
|
||||
# Block writes to excluded paths.
|
||||
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")
|
||||
|
||||
# Handle base64 encoded content
|
||||
if content_type and "base64" in content_type.lower():
|
||||
content_bytes = base64.b64decode(content)
|
||||
@@ -175,6 +210,9 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
async def nc_webdav_create_directory(path: str, ctx: Context):
|
||||
"""Create a directory in NextCloud.
|
||||
|
||||
Raises ``ToolError`` when ``EXCLUDED_TAGS`` is configured and the
|
||||
target path lies inside a folder carrying an excluded system tag.
|
||||
|
||||
Args:
|
||||
path: Full path of the directory to create
|
||||
|
||||
@@ -182,6 +220,14 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
Dict with status_code (201 for created, 405 if already exists)
|
||||
"""
|
||||
client = await get_client(ctx)
|
||||
|
||||
# Block directory creation 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"
|
||||
)
|
||||
|
||||
return await client.webdav.create_directory(path)
|
||||
|
||||
@mcp.tool(
|
||||
@@ -197,6 +243,9 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
async def nc_webdav_delete_resource(path: str, ctx: Context):
|
||||
"""Delete a file or directory in NextCloud.
|
||||
|
||||
Raises ``ToolError`` when ``EXCLUDED_TAGS`` is configured and the
|
||||
target path (or an ancestor folder) carries an excluded system tag.
|
||||
|
||||
Args:
|
||||
path: Full path of the file or directory to delete
|
||||
|
||||
@@ -204,6 +253,12 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
Dict with status_code indicating result (404 if not found)
|
||||
"""
|
||||
client = await get_client(ctx)
|
||||
|
||||
# Block deletion of excluded files/directories.
|
||||
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")
|
||||
|
||||
return await client.webdav.delete_resource(path)
|
||||
|
||||
@mcp.tool(
|
||||
@@ -220,6 +275,10 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
):
|
||||
"""Move or rename a file or directory in NextCloud.
|
||||
|
||||
Raises ``ToolError`` when ``EXCLUDED_TAGS`` is configured and either
|
||||
the source or destination path (or one of their ancestor folders)
|
||||
carries an excluded system tag.
|
||||
|
||||
Args:
|
||||
source_path: Full path of the file or directory to move
|
||||
destination_path: New path for the file or directory
|
||||
@@ -229,6 +288,19 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
Dict with status_code indicating result (404 if source not found, 412 if destination exists and overwrite is False)
|
||||
"""
|
||||
client = await get_client(ctx)
|
||||
|
||||
# Block moves involving excluded paths on either side.
|
||||
excluded = await get_excluded_file_paths(client.webdav)
|
||||
if is_path_excluded(source_path, excluded):
|
||||
raise ToolError(
|
||||
f"Access denied: source {source_path!r} is tagged with an excluded tag"
|
||||
)
|
||||
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"
|
||||
)
|
||||
|
||||
return await client.webdav.move_resource(
|
||||
source_path, destination_path, overwrite
|
||||
)
|
||||
@@ -247,6 +319,10 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
):
|
||||
"""Copy a file or directory in NextCloud.
|
||||
|
||||
Raises ``ToolError`` when ``EXCLUDED_TAGS`` is configured and either
|
||||
the source or destination path (or one of their ancestor folders)
|
||||
carries an excluded system tag.
|
||||
|
||||
Args:
|
||||
source_path: Full path of the file or directory to copy
|
||||
destination_path: Destination path for the copy
|
||||
@@ -256,6 +332,19 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
Dict with status_code indicating result (404 if source not found, 412 if destination exists and overwrite is False)
|
||||
"""
|
||||
client = await get_client(ctx)
|
||||
|
||||
# Block copies involving excluded paths on either side.
|
||||
excluded = await get_excluded_file_paths(client.webdav)
|
||||
if is_path_excluded(source_path, excluded):
|
||||
raise ToolError(
|
||||
f"Access denied: source {source_path!r} is tagged with an excluded tag"
|
||||
)
|
||||
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"
|
||||
)
|
||||
|
||||
return await client.webdav.copy_resource(
|
||||
source_path, destination_path, overwrite
|
||||
)
|
||||
@@ -364,6 +453,13 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
# Filter out tagged-excluded paths.
|
||||
excluded = await get_excluded_file_paths(client.webdav)
|
||||
if excluded:
|
||||
results = [
|
||||
r for r in results if not is_path_excluded(r.get("path", ""), excluded)
|
||||
]
|
||||
|
||||
# Convert to FileInfo models
|
||||
file_infos = [FileInfo(**result) for result in results]
|
||||
|
||||
@@ -409,6 +505,11 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
results = await client.webdav.find_by_name(
|
||||
pattern=pattern, scope=scope, limit=limit
|
||||
)
|
||||
excluded = await get_excluded_file_paths(client.webdav)
|
||||
if excluded:
|
||||
results = [
|
||||
r for r in results if not is_path_excluded(r.get("path", ""), excluded)
|
||||
]
|
||||
file_infos = [FileInfo(**result) for result in results]
|
||||
return SearchFilesResponse(
|
||||
results=file_infos,
|
||||
@@ -443,6 +544,11 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
results = await client.webdav.find_by_type(
|
||||
mime_type=mime_type, scope=scope, limit=limit
|
||||
)
|
||||
excluded = await get_excluded_file_paths(client.webdav)
|
||||
if excluded:
|
||||
results = [
|
||||
r for r in results if not is_path_excluded(r.get("path", ""), excluded)
|
||||
]
|
||||
file_infos = [FileInfo(**result) for result in results]
|
||||
return SearchFilesResponse(
|
||||
results=file_infos,
|
||||
@@ -474,6 +580,11 @@ def configure_webdav_tools(mcp: FastMCP):
|
||||
"""
|
||||
client = await get_client(ctx)
|
||||
results = await client.webdav.list_favorites(scope=scope, limit=limit)
|
||||
excluded = await get_excluded_file_paths(client.webdav)
|
||||
if excluded:
|
||||
results = [
|
||||
r for r in results if not is_path_excluded(r.get("path", ""), excluded)
|
||||
]
|
||||
file_infos = [FileInfo(**result) for result in results]
|
||||
return SearchFilesResponse(
|
||||
results=file_infos,
|
||||
|
||||
@@ -354,3 +354,63 @@ async def test_remove_tag_from_file_not_assigned(mocker):
|
||||
|
||||
# Verify result (should succeed even with 404)
|
||||
assert result is True
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_get_files_by_tag_detects_directories(mocker):
|
||||
"""get_files_by_tag must flag tagged folders via <d:resourcetype/>.
|
||||
|
||||
Tagged folders need ``is_directory=True`` so the tag-exclusion layer
|
||||
(issue #710) can hide their descendants.
|
||||
"""
|
||||
mock_http_client = AsyncMock()
|
||||
client = WebDAVClient(mock_http_client, "testuser")
|
||||
|
||||
# Two-entry response: one regular file, one collection (folder).
|
||||
xml_content = b"""<?xml version="1.0"?>
|
||||
<d:multistatus xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||
<d:response>
|
||||
<d:href>/remote.php/dav/files/testuser/Secret.txt</d:href>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<oc:fileid>101</oc:fileid>
|
||||
<d:displayname>Secret.txt</d:displayname>
|
||||
<d:getcontentlength>42</d:getcontentlength>
|
||||
<d:getcontenttype>text/plain</d:getcontenttype>
|
||||
<d:getlastmodified>Wed, 01 Jan 2025 00:00:00 GMT</d:getlastmodified>
|
||||
<d:getetag>"abc"</d:getetag>
|
||||
<d:resourcetype/>
|
||||
</d:prop>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
<d:response>
|
||||
<d:href>/remote.php/dav/files/testuser/Private/</d:href>
|
||||
<d:propstat>
|
||||
<d:prop>
|
||||
<oc:fileid>102</oc:fileid>
|
||||
<d:displayname>Private</d:displayname>
|
||||
<d:getlastmodified>Wed, 01 Jan 2025 00:00:00 GMT</d:getlastmodified>
|
||||
<d:getetag>"def"</d:getetag>
|
||||
<d:resourcetype><d:collection/></d:resourcetype>
|
||||
</d:prop>
|
||||
</d:propstat>
|
||||
</d:response>
|
||||
</d:multistatus>"""
|
||||
|
||||
mock_response = AsyncMock()
|
||||
mock_response.content = xml_content
|
||||
mock_response.raise_for_status = mocker.Mock()
|
||||
mock_http_client.request = AsyncMock(return_value=mock_response)
|
||||
|
||||
files = await client.get_files_by_tag(42)
|
||||
|
||||
assert len(files) == 2
|
||||
by_path = {f["path"]: f for f in files}
|
||||
|
||||
assert by_path["/Secret.txt"]["is_directory"] is False
|
||||
assert by_path["/Private/"]["is_directory"] is True
|
||||
|
||||
# Sanity-check the REPORT body asks for resourcetype.
|
||||
call_args = mock_http_client.request.call_args
|
||||
assert "<d:resourcetype/>" in call_args.kwargs["content"]
|
||||
assert "<oc:systemtag>42</oc:systemtag>" in call_args.kwargs["content"]
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Unit tests for tag-based file exclusion (issue #710)."""
|
||||
|
||||
import os
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from nextcloud_mcp_server.config import _reload_config
|
||||
from nextcloud_mcp_server.server.tag_exclusion import (
|
||||
_normalise_path,
|
||||
get_excluded_file_paths,
|
||||
get_excluded_tag_names,
|
||||
is_path_excluded,
|
||||
)
|
||||
|
||||
|
||||
class TestNormalisePath:
|
||||
@pytest.mark.unit
|
||||
def test_strips_leading_and_trailing_slash(self):
|
||||
assert _normalise_path("/foo/bar/") == "foo/bar"
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_unchanged_when_already_clean(self):
|
||||
assert _normalise_path("foo/bar") == "foo/bar"
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_empty_string(self):
|
||||
assert _normalise_path("") == ""
|
||||
|
||||
|
||||
class TestIsPathExcluded:
|
||||
@pytest.mark.unit
|
||||
def test_empty_set_excludes_nothing(self):
|
||||
assert is_path_excluded("/anything", set()) is False
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_direct_match(self):
|
||||
assert is_path_excluded("/Secret.txt", {"Secret.txt"}) is True
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_path_argument_is_normalised(self):
|
||||
# The path argument is normalised before comparison; the excluded
|
||||
# set is expected to already contain normalised entries (it always
|
||||
# is, in practice, because get_excluded_file_paths builds it).
|
||||
assert is_path_excluded("/Secret.txt/", {"Secret.txt"}) is True
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_descendant_of_excluded_directory(self):
|
||||
assert is_path_excluded("/Private/notes.md", {"Private"}) is True
|
||||
assert is_path_excluded("/Private/sub/file.txt", {"Private"}) is True
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_unrelated_path_not_excluded(self):
|
||||
assert is_path_excluded("/Public/notes.md", {"Private"}) is False
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_shared_prefix_is_not_a_match(self):
|
||||
# 'foobar' must NOT be excluded just because 'foo' is.
|
||||
# This is the bug a naive `startswith(exc)` would have.
|
||||
assert is_path_excluded("/foobar/x", {"foo"}) is False
|
||||
assert is_path_excluded("/foobar", {"foo"}) is False
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_excluded_path_itself(self):
|
||||
# The excluded entry itself is excluded (not just its descendants).
|
||||
assert is_path_excluded("/Private", {"Private"}) is True
|
||||
|
||||
|
||||
class TestGetExcludedTagNames:
|
||||
@pytest.mark.unit
|
||||
@patch.dict(os.environ, {"EXCLUDED_TAGS": ""}, clear=False)
|
||||
def test_empty_returns_empty_list(self):
|
||||
_reload_config()
|
||||
assert get_excluded_tag_names() == []
|
||||
|
||||
@pytest.mark.unit
|
||||
@patch.dict(os.environ, {"EXCLUDED_TAGS": "secret"}, clear=False)
|
||||
def test_single_tag(self):
|
||||
_reload_config()
|
||||
assert get_excluded_tag_names() == ["secret"]
|
||||
|
||||
@pytest.mark.unit
|
||||
@patch.dict(os.environ, {"EXCLUDED_TAGS": " a , b , c "}, clear=False)
|
||||
def test_strips_whitespace_around_each_tag(self):
|
||||
_reload_config()
|
||||
assert get_excluded_tag_names() == ["a", "b", "c"]
|
||||
|
||||
@pytest.mark.unit
|
||||
@patch.dict(os.environ, {"EXCLUDED_TAGS": "a,,b,"}, clear=False)
|
||||
def test_skips_empty_entries(self):
|
||||
_reload_config()
|
||||
assert get_excluded_tag_names() == ["a", "b"]
|
||||
|
||||
|
||||
class TestGetExcludedFilePaths:
|
||||
@pytest.mark.unit
|
||||
async def test_returns_empty_set_when_feature_disabled(self, mocker):
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.server.tag_exclusion.get_excluded_tag_names",
|
||||
return_value=[],
|
||||
)
|
||||
webdav = AsyncMock()
|
||||
result = await get_excluded_file_paths(webdav)
|
||||
assert result == set()
|
||||
webdav.get_tag_by_name.assert_not_called()
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_skips_unknown_tag(self, mocker):
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.server.tag_exclusion.get_excluded_tag_names",
|
||||
return_value=["does-not-exist"],
|
||||
)
|
||||
webdav = AsyncMock()
|
||||
webdav.get_tag_by_name = AsyncMock(return_value=None)
|
||||
|
||||
result = await get_excluded_file_paths(webdav)
|
||||
|
||||
assert result == set()
|
||||
webdav.get_tag_by_name.assert_awaited_once_with("does-not-exist")
|
||||
webdav.get_files_by_tag.assert_not_called()
|
||||
|
||||
@pytest.mark.unit
|
||||
async def test_collects_paths_from_multiple_tags(self, mocker):
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.server.tag_exclusion.get_excluded_tag_names",
|
||||
return_value=["secret", "no-ai"],
|
||||
)
|
||||
webdav = AsyncMock()
|
||||
webdav.get_tag_by_name = AsyncMock(
|
||||
side_effect=[
|
||||
{"id": 1, "name": "secret"},
|
||||
{"id": 2, "name": "no-ai"},
|
||||
]
|
||||
)
|
||||
webdav.get_files_by_tag = AsyncMock(
|
||||
side_effect=[
|
||||
[
|
||||
{"path": "/Secret.txt", "is_directory": False},
|
||||
{"path": "/Private/", "is_directory": True},
|
||||
],
|
||||
[
|
||||
# Same dir under a second tag — set dedupes it.
|
||||
{"path": "/Private", "is_directory": True},
|
||||
{"path": "/Other/notes.md", "is_directory": False},
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
result = await get_excluded_file_paths(webdav)
|
||||
|
||||
assert result == {"Secret.txt", "Private", "Other/notes.md"}
|
||||
assert webdav.get_files_by_tag.await_count == 2
|
||||
Reference in New Issue
Block a user