What happened
_builtin_skills/(以及新版布局的 .octop/_builtin_skills/)是 Octop 启动时种进每个 Agent 工作区的内置技能目录,sync_octop_builtin_skills() 每次 start 都会覆盖它,skill-manager 和备份导入也都建立在「workspace API 改不动它」这条不变量上。workspace 路由里实现这条不变量的是 _assert_workspace_mutable()。
它有两个缺口,都能实测:
缺口 A:两个「写内容」的端点根本没调用这个守卫。
7 个会改工作区的端点里,4 个调了守卫(mkdir :232 / delete :262 / move 的 src 与 dest :291,292 / doc 写 :410),2 个完全没调:PUT /api/agents/{id}/workspace/file(workspace.py:173,第 203 行把 aupload_bytes 用在原始路径上)和 POST /api/agents/{id}/workspace/upload(workspace.py:304,第 321 行同理);第 7 个 POST /workspace/archive(导入 zip)走的是下面「备份导入」那节的同一个字面谓词。用最正规的写法 就能覆盖内置技能:
PUT /api/agents/{aid}/workspace/file?from_workspace=true&path=/_builtin_skills/foo/SKILL.md
body {"content": "tampered"}
-> 200 {"path":"/_builtin_skills/foo/SKILL.md","size":8}
POST /api/agents/{aid}/workspace/upload?from_workspace=true&path=/_builtin_skills/foo/SKILL.md
file=@SKILL.md (8 bytes)
-> 200 {"path":"/_builtin_skills/foo/SKILL.md","size":8}
DELETE /api/agents/{aid}/workspace/file?from_workspace=true&path=/_builtin_skills/foo/SKILL.md
-> 403 # 同一条路径,删不掉、改得了
(前两条是我加在 tests/integration/test_workspace_api.py 里的用例,在 develop 11f6d3f 上跑出来就是 200,6 failed, 3 passed;403 那条是仓库里已有的 test_delete_builtin_skills_forbidden,它一直是绿的。)
也就是说:同一个受保护文件,删除被拦、改内容放行。而且这条路在 Dashboard 上就能踩到——工作区抽屉用 isProtectedPath()(dashboard/src/pages/Agent/Workspace/components/WorkspaceDrawer.tsx:120-126)拦住了新建 / 删除 / 拖拽移动(:139-144、:509、:605、:646),但 handleEdit()(:878-886)没有走同一个判断,save()(:904)对文本文件走的就是 :925-931 的 PUT /agents/{id}/workspace/file?path=…,成功后 :932 弹「已保存」。于是:
在智能体工作区抽屉里打开 _builtin_skills/skill-manager/SKILL.md,点编辑、改一行、保存 → 前端提示「已保存」,接口返回 200;
下次该 Agent 启动时 sync_octop_builtin_skills() 会按设计覆盖 Octop 拥有的内置技能(它的 docstring 就是 "Overwrite Octop-owned built-ins";实测日志:manager.py:2652 Agent MA2SRE: synced Octop built-in skills=['skill-manager']);
用户那次编辑静默消失,而且没有任何一处告诉过他这个目录是只读的。
「删不掉但改得了,改了还会神秘还原」正是这个守卫想避免的困惑,现在它只挡住了一半操作。
缺口 B:守卫是纯字面比较,换个写法就绕开。
_assert_workspace_mutable()(workspace.py:41-53)只做 replace("\\", "/") + strip("/"),然后看开头是不是 _builtin_skills / .octop/_builtin_skills。workspace_api_path()(api/common/workspace.py:94-99)也只 strip() + lstrip("/"),不折叠 ./..。所以任何文件系统上等价、字面上不同的写法都能通过守卫:
请求路径(DELETE)
结果
/_builtin_skills/foo/SKILL.md
403(守卫生效)
/.octop/_builtin_skills/foo/SKILL.md
403(守卫生效)
./_builtin_skills/foo/SKILL.md
404 NOT_FOUND,不是 403
docs/../_builtin_skills/foo/SKILL.md
404 NOT_FOUND,不是 403
./.octop/_builtin_skills/foo/SKILL.md
404 NOT_FOUND,不是 403
x/../.octop/_builtin_skills/foo/SKILL.md
404 NOT_FOUND,不是 403
注意 404 的含义:守卫在 I/O 之前跑,正规写法走到守卫就 403 了;这四种写法拿到 404 说明它们穿过了守卫、进入了后端 I/O ,只是测试用的假工作区里没有 docs/../… 这种键名才 404。换成真实 harness 后端,路径会被解析到受保护目录(下面单独测)。
绕过之后会落到哪里(用真实 octop-harness 1.0.1 测)
直接调 octop_harness.backends.workspace.BackendWorkspace._resolve_root_fragment(),工作区根是临时目录:
GUARD ACCEPTED ./_builtin_skills/skill-manager/SKILL.md
-> C:\...\Temp\octop-guard-vjw1_i9l\_builtin_skills\skill-manager\SKILL.md 在受保护目录内: True
GUARD ACCEPTED docs/../_builtin_skills/skill-manager/SKILL.md
-> C:\...\Temp\octop-guard-vjw1_i9l\_builtin_skills\skill-manager\SKILL.md 在受保护目录内: True
GUARD ACCEPTED ./.octop/_builtin_skills/skill-manager/SKILL.md
-> C:\...\Temp\octop-guard-vjw1_i9l\.octop\_builtin_skills\skill-manager\SKILL.md 在受保护目录内: True
GUARD ACCEPTED x/../.octop/_builtin_skills/a/SKILL.md
-> C:\...\Temp\octop-guard-vjw1_i9l\.octop\_builtin_skills\a\SKILL.md 在受保护目录内: True
guard blocked _builtin_skills/skill-manager/SKILL.md (OctopError FORBIDDEN)
GUARD ACCEPTED out/../../_builtin_skills/...
-> ValueError: ... is not in the subpath of ... # 越出工作区根由 harness 拦住
harness 只保证不逃出工作区根 (relative_to),它不知道 _builtin_skills 对 Octop 是只读的。所以这条不变量只有 octop 这一层在守,而它现在既漏了两个端点、又只认一种写法。
机制
# src/octop/api/routers/workspace.py:34-53
_PROTECTED_PREFIX = "_builtin_skills"
def _assert_workspace_mutable (path : str ) -> str :
rel = _workspace_io_path (path , from_workspace = True )
if rel == "." :
raise OctopError (ErrorCode .FORBIDDEN , "cannot modify workspace root" )
posix = rel .replace ("\\ " , "/" ).strip ("/" )
if (posix == _PROTECTED_PREFIX or posix .startswith (f"{ _PROTECTED_PREFIX } /" )
or posix == f".octop/{ _PROTECTED_PREFIX } " or posix .startswith (f".octop/{ _PROTECTED_PREFIX } /" )):
raise OctopError (ErrorCode .FORBIDDEN , f"cannot modify { _PROTECTED_PREFIX !r} paths" )
return rel
调用点:workspace.py:232(mkdir)、262(delete)、291/292(move src/dest)、410(doc 写)。
漏掉:203(PUT file)、321(POST upload)。
同一个谓词还管备份导入
infra/backup/workspace_archive.py:256 用它把内置技能排除在导入之外:
pairs = [(rel , blob ) for rel , blob in entries if not is_octop_builtin_skills_path (rel )]
is_octop_builtin_skills_path()(infra/agents/builtin_skills/__init__.py:17-28)的 docstring 自己写着「Mirrors the two spellings _assert_workspace_mutable guards」——同一套字面比较,因此同样漏掉 ./_builtin_skills/… 这类 zip 条目名:这类条目不被识别为内置技能,会被导入并覆盖 Octop 拥有的技能。这条是同一个根因的第三个受害者,所以修复应该把归一化收敛到一处。
Expected behavior
PUT /workspace/file 与 POST /workspace/upload 和其它写操作端点一样受 _assert_workspace_mutable() 保护:改 _builtin_skills/… 返回 403 FORBIDDEN。
_builtin_skills 的判定按解析后的工作区相对路径 (折叠 . 与 ..、去掉空段)来做;./_builtin_skills/x、docs/../_builtin_skills/x、x/../.octop/_builtin_skills/x 与 _builtin_skills/x 同样 403。备份导入用的 is_octop_builtin_skills_path() 走同一份归一化。
逃出工作区根的写法(out/../../x)保持现状:harness 的 relative_to 已经在拦,本 issue 不建议改那部分行为,归一化只用于「是否命中受保护目录」的判断,不改变交给后端的原始路径。
范围 / 不是什么
不是 [bug] 严重越权:Agent 文件写入权限未隔离,可篡改其他 Agent 工作区 #1160 (Agent 之间写入未隔离):那条讲的是 A 的 Agent 写到 B 的工作区,是跨 Agent 边界;本条是同一个工作区内「Octop 只读目录」的守卫覆盖不全 + 字面绕过。
不涉及跨用户越权:这些端点都带 owner_only=True / require_agent_workspace 鉴权,能碰到的只有该 Agent 的 owner 或 admin。风险点是内置技能的可信性:skill-manager 的行为、每次 start 的 sync_octop_builtin_skills() 覆盖、以及备份导入过滤都依赖这条不变量;而且「删不掉但改得了」这种不对称迟早会变成用户报的怪 bug。
不用 Path.resolve():workspace 里的目标文件可能还不存在(新建就是写入),resolve 会牵进符号链接和不存在路径;zip 条目名更是没有宿主路径可解析。纯字面折叠 ./.. 就够,且对三种输入都成立。
Environment
octop version: 1.0.2b6(源码 develop 11f6d3f)
Python: 3.12.13 (MSC v.1944 64 bit AMD64)
OS: Windows 10 (10.0.26200);octop-harness 1.0.1(.. 折叠是 pathlib 通用语义,Linux 同样成立)
复现
from octop .api .routers .workspace import _assert_workspace_mutable
_assert_workspace_mutable ("_builtin_skills/foo/SKILL.md" ) # OctopError FORBIDDEN
_assert_workspace_mutable ("./_builtin_skills/foo/SKILL.md" ) # 原样返回,穿过守卫
_assert_workspace_mutable ("docs/../_builtin_skills/foo/SKILL.md" ) # 原样返回,穿过守卫
缺口 A 用 HTTP 复现最快:对任意运行中的 Agent PUT /api/agents/{id}/workspace/file?from_workspace=true&path=/_builtin_skills/foo/SKILL.md,返回 200;同一 path 发 DELETE 返回 403。
What happened
_builtin_skills/(以及新版布局的.octop/_builtin_skills/)是 Octop 启动时种进每个 Agent 工作区的内置技能目录,sync_octop_builtin_skills()每次 start 都会覆盖它,skill-manager 和备份导入也都建立在「workspace API 改不动它」这条不变量上。workspace 路由里实现这条不变量的是_assert_workspace_mutable()。它有两个缺口,都能实测:
缺口 A:两个「写内容」的端点根本没调用这个守卫。
7 个会改工作区的端点里,4 个调了守卫(mkdir
:232/ delete:262/ move 的 src 与 dest:291,292/ doc 写:410),2 个完全没调:PUT /api/agents/{id}/workspace/file(workspace.py:173,第 203 行把aupload_bytes用在原始路径上)和POST /api/agents/{id}/workspace/upload(workspace.py:304,第 321 行同理);第 7 个POST /workspace/archive(导入 zip)走的是下面「备份导入」那节的同一个字面谓词。用最正规的写法就能覆盖内置技能:(前两条是我加在
tests/integration/test_workspace_api.py里的用例,在 develop11f6d3f上跑出来就是 200,6 failed, 3 passed;403 那条是仓库里已有的test_delete_builtin_skills_forbidden,它一直是绿的。)也就是说:同一个受保护文件,删除被拦、改内容放行。而且这条路在 Dashboard 上就能踩到——工作区抽屉用
isProtectedPath()(dashboard/src/pages/Agent/Workspace/components/WorkspaceDrawer.tsx:120-126)拦住了新建 / 删除 / 拖拽移动(:139-144、:509、:605、:646),但handleEdit()(:878-886)没有走同一个判断,save()(:904)对文本文件走的就是:925-931的PUT /agents/{id}/workspace/file?path=…,成功后:932弹「已保存」。于是:_builtin_skills/skill-manager/SKILL.md,点编辑、改一行、保存 → 前端提示「已保存」,接口返回 200;sync_octop_builtin_skills()会按设计覆盖 Octop 拥有的内置技能(它的 docstring 就是 "Overwrite Octop-owned built-ins";实测日志:manager.py:2652 Agent MA2SRE: synced Octop built-in skills=['skill-manager']);「删不掉但改得了,改了还会神秘还原」正是这个守卫想避免的困惑,现在它只挡住了一半操作。
缺口 B:守卫是纯字面比较,换个写法就绕开。
_assert_workspace_mutable()(workspace.py:41-53)只做replace("\\", "/")+strip("/"),然后看开头是不是_builtin_skills/.octop/_builtin_skills。workspace_api_path()(api/common/workspace.py:94-99)也只strip()+lstrip("/"),不折叠./..。所以任何文件系统上等价、字面上不同的写法都能通过守卫:/_builtin_skills/foo/SKILL.md/.octop/_builtin_skills/foo/SKILL.md./_builtin_skills/foo/SKILL.mddocs/../_builtin_skills/foo/SKILL.md./.octop/_builtin_skills/foo/SKILL.mdx/../.octop/_builtin_skills/foo/SKILL.md注意 404 的含义:守卫在 I/O 之前跑,正规写法走到守卫就 403 了;这四种写法拿到 404 说明它们穿过了守卫、进入了后端 I/O,只是测试用的假工作区里没有
docs/../…这种键名才 404。换成真实 harness 后端,路径会被解析到受保护目录(下面单独测)。绕过之后会落到哪里(用真实 octop-harness 1.0.1 测)
直接调
octop_harness.backends.workspace.BackendWorkspace._resolve_root_fragment(),工作区根是临时目录:harness 只保证不逃出工作区根(
relative_to),它不知道_builtin_skills对 Octop 是只读的。所以这条不变量只有octop这一层在守,而它现在既漏了两个端点、又只认一种写法。机制
调用点:
workspace.py:232(mkdir)、262(delete)、291/292(move src/dest)、410(doc 写)。漏掉:
203(PUT file)、321(POST upload)。同一个谓词还管备份导入
infra/backup/workspace_archive.py:256用它把内置技能排除在导入之外:is_octop_builtin_skills_path()(infra/agents/builtin_skills/__init__.py:17-28)的 docstring 自己写着「Mirrors the two spellings_assert_workspace_mutableguards」——同一套字面比较,因此同样漏掉./_builtin_skills/…这类 zip 条目名:这类条目不被识别为内置技能,会被导入并覆盖 Octop 拥有的技能。这条是同一个根因的第三个受害者,所以修复应该把归一化收敛到一处。Expected behavior
PUT /workspace/file与POST /workspace/upload和其它写操作端点一样受_assert_workspace_mutable()保护:改_builtin_skills/…返回 403 FORBIDDEN。_builtin_skills的判定按解析后的工作区相对路径(折叠.与..、去掉空段)来做;./_builtin_skills/x、docs/../_builtin_skills/x、x/../.octop/_builtin_skills/x与_builtin_skills/x同样 403。备份导入用的is_octop_builtin_skills_path()走同一份归一化。out/../../x)保持现状:harness 的relative_to已经在拦,本 issue 不建议改那部分行为,归一化只用于「是否命中受保护目录」的判断,不改变交给后端的原始路径。范围 / 不是什么
owner_only=True/require_agent_workspace鉴权,能碰到的只有该 Agent 的 owner 或 admin。风险点是内置技能的可信性:skill-manager 的行为、每次 start 的sync_octop_builtin_skills()覆盖、以及备份导入过滤都依赖这条不变量;而且「删不掉但改得了」这种不对称迟早会变成用户报的怪 bug。Path.resolve():workspace 里的目标文件可能还不存在(新建就是写入),resolve 会牵进符号链接和不存在路径;zip 条目名更是没有宿主路径可解析。纯字面折叠./..就够,且对三种输入都成立。Environment
octopversion: 1.0.2b6(源码 develop11f6d3f)octop-harness1.0.1(..折叠是 pathlib 通用语义,Linux 同样成立)复现
缺口 A 用 HTTP 复现最快:对任意运行中的 Agent
PUT /api/agents/{id}/workspace/file?from_workspace=true&path=/_builtin_skills/foo/SKILL.md,返回 200;同一path发DELETE返回 403。