fix(core): reject hardlinked files in restricted local fs tools

Reject multi-linked regular files in restricted local filesystem tools so workspace hardlink aliases cannot read or overwrite files outside allowed directories.

Fixes #8868.
This commit is contained in:
Weilong Liao
2026-06-19 00:28:36 +08:00
committed by GitHub
parent 309e05d3cc
commit 59734c22b6
2 changed files with 62 additions and 0 deletions

View File

@@ -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

View File

@@ -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 = [
'<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>'
@@ -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]