Skip to content

Commit c847aea

Browse files
committed
fix: fs tools
1 parent 50c31c6 commit c847aea

4 files changed

Lines changed: 137 additions & 18 deletions

File tree

astrbot/core/tools/computer_tools/fs.py

Lines changed: 70 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@
1212
access depends on the local runtime implementation and host OS permissions.
1313
Upload and download tools are defined here, but `LocalBooter` does not
1414
implement them and the main agent does not expose them in local mode.
15-
- Member + local: read/write/edit/grep are restricted to `data/skills`,
16-
`data/workspaces/{normalized_umo}`, and `/tmp/.astrbot`. Upload/download are
17-
denied by `check_admin_permission` if invoked.
15+
- Member + local: read/grep are restricted to `data/skills`,
16+
plugin-provided `data/plugins/*/skills`,
17+
`data/workspaces/{normalized_umo}`, and `/tmp/.astrbot`; write/edit are
18+
restricted to the same local roots except plugin-provided Skills, which are
19+
read-only. Upload/download are denied by `check_admin_permission` if invoked.
1820
- Admin + sandbox: read/write/edit/grep are not path-restricted by this
1921
module;
2022
sandbox filesystem boundaries are enforced by the sandbox runtime. Upload and
@@ -45,6 +47,7 @@
4547
from astrbot.core.computer.file_read_utils import read_file_tool_result
4648
from astrbot.core.message.components import File, Image
4749
from astrbot.core.utils.astrbot_path import (
50+
get_astrbot_plugin_path,
4851
get_astrbot_skills_path,
4952
get_astrbot_system_tmp_path,
5053
get_astrbot_temp_path,
@@ -67,15 +70,22 @@
6770
_IMAGE_FILE_SUFFIXES = {".bmp", ".gif", ".jpeg", ".jpg", ".png", ".webp"}
6871

6972

70-
def _restricted_env_path_labels(umo: str) -> list[str]:
73+
def _restricted_env_path_labels(umo: str, *, include_plugin_skills: bool) -> list[str]:
7174
"""Labels for the allowed directories in a local(not sandbox) and restricted(not admin) environment"""
7275
normalized_umo = normalize_umo_for_workspace(umo)
73-
return [
76+
labels = [
7477
"data/skills",
75-
f"data/workspaces/{normalized_umo}",
76-
get_astrbot_system_tmp_path(),
77-
get_astrbot_temp_path(),
7878
]
79+
if include_plugin_skills:
80+
labels.append("data/plugins/*/skills")
81+
labels.extend(
82+
[
83+
f"data/workspaces/{normalized_umo}",
84+
get_astrbot_system_tmp_path(),
85+
get_astrbot_temp_path(),
86+
]
87+
)
88+
return labels
7989

8090

8191
def get_astrbot_workspaces_path() -> str:
@@ -89,8 +99,30 @@ def _workspace_root(umo: str) -> Path:
8999
return (Path(get_astrbot_workspaces_path()) / normalized_umo).resolve(strict=False)
90100

91101

102+
def _plugin_skill_roots() -> tuple[Path, ...]:
103+
plugins_root = Path(get_astrbot_plugin_path())
104+
if not plugins_root.exists():
105+
return ()
106+
return tuple(
107+
(plugin_dir / "skills").resolve(strict=False)
108+
for plugin_dir in plugins_root.iterdir()
109+
if plugin_dir.is_dir() and (plugin_dir / "skills").is_dir()
110+
)
111+
112+
92113
def _read_allowed_roots(umo: str) -> tuple[Path, ...]:
93114
"""Non-admin users can only read files within these directories (and their subdirectories)"""
115+
return (
116+
Path(get_astrbot_skills_path()).resolve(strict=False),
117+
*_plugin_skill_roots(),
118+
_workspace_root(umo),
119+
Path(get_astrbot_system_tmp_path()).resolve(strict=False),
120+
Path(get_astrbot_temp_path()).resolve(strict=False),
121+
)
122+
123+
124+
def _write_allowed_roots(umo: str) -> tuple[Path, ...]:
125+
"""Non-admin users cannot modify plugin-provided Skills."""
94126
return (
95127
Path(get_astrbot_skills_path()).resolve(strict=False),
96128
_workspace_root(umo),
@@ -131,11 +163,16 @@ def _resolve_user_path(path: str, *, local_env: bool, umo: str) -> Path:
131163
return (Path.cwd() / candidate).resolve(strict=False)
132164

133165

134-
def _is_path_within_allowed_roots(path: str, umo: str) -> bool:
166+
def _is_path_within_allowed_roots(
167+
path: str,
168+
*,
169+
umo: str,
170+
allowed_roots: tuple[Path, ...],
171+
) -> bool:
135172
resolved = _resolve_user_path(path, local_env=True, umo=umo)
136173
return any(
137174
resolved == allowed_root or resolved.is_relative_to(allowed_root)
138-
for allowed_root in _read_allowed_roots(umo)
175+
for allowed_root in allowed_roots
139176
)
140177

141178

@@ -145,14 +182,24 @@ def _normalize_rw_path(
145182
restricted: bool,
146183
local_env: bool,
147184
umo: str,
185+
write: bool = False,
148186
) -> str:
149187
normalized_path = _resolve_tool_path(path, local_env=local_env, umo=umo)
150188
if not normalized_path:
151189
raise ValueError("`path` must be a non-empty string.")
152-
if restricted and not _is_path_within_allowed_roots(normalized_path, umo):
153-
allowed = ", ".join(_restricted_env_path_labels(umo))
190+
if restricted:
191+
allowed_roots = _write_allowed_roots(umo) if write else _read_allowed_roots(umo)
192+
if restricted and not _is_path_within_allowed_roots(
193+
normalized_path,
194+
umo=umo,
195+
allowed_roots=allowed_roots,
196+
):
197+
allowed = ", ".join(
198+
_restricted_env_path_labels(umo, include_plugin_skills=not write)
199+
)
200+
access = "Write" if write else "Read"
154201
raise PermissionError(
155-
"Read access is restricted for this user. "
202+
f"{access} access is restricted for this user. "
156203
f"Allowed directories: {allowed}. Blocked path: {normalized_path}."
157204
)
158205
return normalized_path
@@ -290,6 +337,7 @@ async def call(
290337
restricted=restricted,
291338
local_env=local_env,
292339
umo=context.context.event.unified_msg_origin,
340+
write=True,
293341
)
294342
if local_env
295343
else path.strip()
@@ -368,6 +416,7 @@ async def call(
368416
restricted=restricted,
369417
local_env=local_env,
370418
umo=umo,
419+
write=True,
371420
)
372421
if local_env
373422
else path.strip()
@@ -532,10 +581,16 @@ def _normalize_search_paths(
532581
disallowed = [
533582
path
534583
for path in normalized
535-
if not _is_path_within_allowed_roots(path, umo)
584+
if not _is_path_within_allowed_roots(
585+
path,
586+
umo=umo,
587+
allowed_roots=_read_allowed_roots(umo),
588+
)
536589
]
537590
if disallowed:
538-
allowed = ", ".join(_restricted_env_path_labels(umo))
591+
allowed = ", ".join(
592+
_restricted_env_path_labels(umo, include_plugin_skills=True)
593+
)
539594
blocked = ", ".join(disallowed)
540595
raise PermissionError(
541596
"Read access is restricted for this user. "

docs/en/use/computer.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,11 +76,12 @@ When enabled:
7676

7777
- Admin users can use Shell, Python, file read, file write, file edit, and Grep search in `local` mode.
7878
- Non-admin users cannot use Shell or Python.
79-
- Non-admin users can only use file read, write, edit, and search inside restricted directories.
79+
- Non-admin users can only use file read, write, edit, and search inside restricted directories. Plugin-provided Skills are read/search-only and cannot be written or edited.
8080

8181
Allowed directories for non-admin users in `local` mode include:
8282

8383
- `data/skills`
84+
- `data/plugins/*/skills` (read-only, for plugin-provided Skills)
8485
- Current session's `data/workspaces/{normalized_umo}`
8586
- AstrBot temporary directories
8687
- `.astrbot` under the system temporary directory

docs/zh/use/computer.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,11 +74,12 @@ data/workspaces/{normalized_umo}/notes/todo.txt
7474

7575
- 管理员可以使用 `local` 模式下的 Shell、Python、文件读取、文件写入、文件编辑和 Grep 搜索。
7676
- 非管理员不能使用 Shell 和 Python。
77-
- 非管理员只能在受限目录内使用文件读取、写入、编辑和搜索。
77+
- 非管理员只能在受限目录内使用文件读取、写入、编辑和搜索。插件内置 Skills 只允许读取和搜索,不允许写入或编辑。
7878

7979
非管理员在 `local` 模式下允许访问的目录包括:
8080

8181
- `data/skills`
82+
- `data/plugins/*/skills`(只读,用于插件内置 Skills)
8283
- 当前会话的 `data/workspaces/{normalized_umo}`
8384
- AstrBot 的临时目录
8485
- 系统临时目录中的 `.astrbot`

tests/test_computer_fs_tools.py

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,11 @@ def _setup_local_fs_tools(
4949
) -> Any:
5050
workspaces_root = tmp_path / "workspaces"
5151
skills_root = tmp_path / "skills"
52+
plugins_root = tmp_path / "plugins"
5253
temp_root = tmp_path / "temp"
5354
workspaces_root.mkdir()
5455
skills_root.mkdir()
56+
plugins_root.mkdir()
5557
temp_root.mkdir()
5658

5759
monkeypatch.setattr(
@@ -64,6 +66,11 @@ def _setup_local_fs_tools(
6466
"get_astrbot_skills_path",
6567
lambda: str(skills_root),
6668
)
69+
monkeypatch.setattr(
70+
fs_tools,
71+
"get_astrbot_plugin_path",
72+
lambda: str(plugins_root),
73+
)
6774
monkeypatch.setattr(
6875
fs_tools,
6976
"get_astrbot_temp_path",
@@ -123,7 +130,9 @@ def _make_epub_bytes(*, chapter_count: int = 1) -> bytes:
123130
'media-type="application/xhtml+xml"/>'
124131
)
125132
spine_items.append(f'<itemref idref="chapter{index}"/>')
126-
nav_links.append(f'<li><a href="chapter{index}.xhtml">Chapter {index}</a></li>')
133+
nav_links.append(
134+
f'<li><a href="chapter{index}.xhtml">Chapter {index}</a></li>'
135+
)
127136
archive.writestr(
128137
f"OEBPS/chapter{index}.xhtml",
129138
f"""<?xml version="1.0" encoding="utf-8"?>
@@ -181,6 +190,59 @@ def _make_epub_bytes(*, chapter_count: int = 1) -> bytes:
181190
return buffer.getvalue()
182191

183192

193+
@pytest.mark.asyncio
194+
async def test_restricted_local_member_can_read_plugin_provided_skill(
195+
monkeypatch: pytest.MonkeyPatch,
196+
tmp_path,
197+
):
198+
_setup_local_fs_tools(monkeypatch, tmp_path)
199+
plugin_skill = (
200+
tmp_path
201+
/ "plugins"
202+
/ "astrbot_plugin_demo"
203+
/ "skills"
204+
/ "demo-skill"
205+
/ "SKILL.md"
206+
)
207+
plugin_skill.parent.mkdir(parents=True)
208+
plugin_skill.write_text("# Demo Skill\n\nRead plugin docs.", encoding="utf-8")
209+
210+
result = await fs_tools.FileReadTool().call(
211+
_make_context(role="member"),
212+
path=str(plugin_skill),
213+
)
214+
215+
assert result == "# Demo Skill\n\nRead plugin docs."
216+
217+
218+
@pytest.mark.asyncio
219+
async def test_restricted_local_member_cannot_write_plugin_provided_skill(
220+
monkeypatch: pytest.MonkeyPatch,
221+
tmp_path,
222+
):
223+
_setup_local_fs_tools(monkeypatch, tmp_path)
224+
plugin_skill = (
225+
tmp_path
226+
/ "plugins"
227+
/ "astrbot_plugin_demo"
228+
/ "skills"
229+
/ "demo-skill"
230+
/ "SKILL.md"
231+
)
232+
plugin_skill.parent.mkdir(parents=True)
233+
plugin_skill.write_text("# Demo Skill\n", encoding="utf-8")
234+
235+
result = await fs_tools.FileWriteTool().call(
236+
_make_context(role="member"),
237+
path=str(plugin_skill),
238+
content="# Changed\n",
239+
)
240+
241+
assert "Write access is restricted for this user." in result
242+
assert "data/plugins/*/skills" not in result
243+
assert plugin_skill.read_text(encoding="utf-8") == "# Demo Skill\n"
244+
245+
184246
def test_detect_text_encoding_allows_utf8_probe_cut_mid_character():
185247
sample = '{"results": ["中文内容"]}'.encode()[:-1]
186248

0 commit comments

Comments
 (0)