diff --git a/astrbot/core/tools/computer_tools/fs.py b/astrbot/core/tools/computer_tools/fs.py index 390b4095d..c3236c8a1 100644 --- a/astrbot/core/tools/computer_tools/fs.py +++ b/astrbot/core/tools/computer_tools/fs.py @@ -34,6 +34,7 @@ Local path resolution rule: """ import os +import stat import uuid from dataclasses import dataclass, field from pathlib import Path @@ -182,6 +183,25 @@ def _is_path_within_allowed_roots( ) +def _reject_multi_link_file(path: str) -> None: + try: + path_stat = os.stat(path) + except FileNotFoundError: + return + except OSError as exc: + raise PermissionError( + "Access denied: unable to inspect restricted path link count. " + f"Blocked path: {path}." + ) from exc + + if stat.S_ISREG(path_stat.st_mode) and path_stat.st_nlink > 1: + raise PermissionError( + "Access denied: file has multiple hard links and may alias content " + "outside allowed directories. " + f"Link count: {path_stat.st_nlink}. Blocked path: {path}." + ) + + def _normalize_rw_path( path: str, *, @@ -208,6 +228,8 @@ def _normalize_rw_path( f"{access} access is restricted for this user. " f"Allowed directories: {allowed}. Blocked path: {normalized_path}." ) + if restricted: + _reject_multi_link_file(normalized_path) return normalized_path @@ -602,6 +624,8 @@ class GrepTool(FunctionTool): "Read access is restricted for this user. " f"Allowed directories: {allowed}. Blocked paths: {blocked}." ) + for path in normalized: + _reject_multi_link_file(path) return normalized diff --git a/tests/test_computer_fs_tools.py b/tests/test_computer_fs_tools.py index 1277122ef..11571665a 100644 --- a/tests/test_computer_fs_tools.py +++ b/tests/test_computer_fs_tools.py @@ -2,6 +2,7 @@ from __future__ import annotations import base64 import io +import os import zipfile from types import SimpleNamespace from typing import Any @@ -194,6 +195,13 @@ def _make_large_text() -> str: return "".join(f"line-{index:05d}-{'x' * 48}\n" for index in range(6000)) +def _make_hardlink_or_skip(source, link) -> None: + try: + os.link(source, link) + except (AttributeError, OSError) as exc: + pytest.skip(f"hard links are unavailable on this filesystem: {exc}") + + def _make_epub_bytes(*, chapter_count: int = 1) -> bytes: manifest_items = [ '' @@ -363,6 +371,36 @@ async def test_restricted_local_member_cannot_write_plugin_provided_skill( assert plugin_skill.read_text(encoding="utf-8") == "# Demo Skill\n" +@pytest.mark.asyncio +async def test_restricted_local_member_rejects_workspace_hardlink_alias( + monkeypatch: pytest.MonkeyPatch, + tmp_path, +): + workspace = _setup_local_fs_tools(monkeypatch, tmp_path) + outside_dir = tmp_path / "outside" + outside_dir.mkdir() + outside_file = outside_dir / "secret.txt" + outside_file.write_text("outside-secret\n", encoding="utf-8") + hardlink_path = workspace / "linked.txt" + _make_hardlink_or_skip(outside_file, hardlink_path) + + read_result = await fs_tools.FileReadTool().call( + _make_context(role="member"), + path="linked.txt", + ) + write_result = await fs_tools.FileWriteTool().call( + _make_context(role="member"), + path="linked.txt", + content="changed\n", + ) + + assert "multiple hard links" in read_result + assert "may alias content outside allowed directories" in read_result + assert "multiple hard links" in write_result + assert "may alias content outside allowed directories" in write_result + assert outside_file.read_text(encoding="utf-8") == "outside-secret\n" + + def test_detect_text_encoding_allows_utf8_probe_cut_mid_character(): sample = '{"results": ["中文内容"]}'.encode()[:-1]