Skip to content

[bug] _builtin_skills 写保护可被绕过:PUT /workspace/file 与 /workspace/upload 没有调用守卫,且守卫只认字面路径(./ 与 x/../ 写法直接放行) #1777

Description

@sxh313

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 弹「已保存」。于是:

  1. 在智能体工作区抽屉里打开 _builtin_skills/skill-manager/SKILL.md,点编辑、改一行、保存 → 前端提示「已保存」,接口返回 200;
  2. 下次该 Agent 启动时 sync_octop_builtin_skills() 会按设计覆盖 Octop 拥有的内置技能(它的 docstring 就是 "Overwrite Octop-owned built-ins";实测日志:manager.py:2652 Agent MA2SRE: synced Octop built-in skills=['skill-manager']);
  3. 用户那次编辑静默消失,而且没有任何一处告诉过他这个目录是只读的。

「删不掉但改得了,改了还会神秘还原」正是这个守卫想避免的困惑,现在它只挡住了一半操作。

缺口 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

  1. PUT /workspace/file 与 POST /workspace/upload 和其它写操作端点一样受 _assert_workspace_mutable() 保护:改 _builtin_skills/… 返回 403 FORBIDDEN。
  2. _builtin_skills 的判定按解析后的工作区相对路径(折叠 . 与 ..、去掉空段)来做;./_builtin_skills/x、docs/../_builtin_skills/x、x/../.octop/_builtin_skills/x 与 _builtin_skills/x 同样 403。备份导入用的 is_octop_builtin_skills_path() 走同一份归一化。
  3. 逃出工作区根的写法(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。

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions