Files
workbuddy_skills/dsh-workflow/references/dsh-change-workflow/06-实例可见面与共享边界.md
T
admin e03465c398 按用户令提交:把此前未纳管的 9 个技能目录一并入库
用户令逐字:「E:/ProgramData/.workbuddy/skills  提交仓库是指的这里」——
即本目录就是仓库(2026-10-07 已在本目录建仓,见当日日志 §22),本轮把余下未纳管的 9 个技能一并提交。

本次入库(9 个技能,46 个文件):
1、`AI HOT`
2、`draw-ui`
3、`dsh-diagnose`
4、`dsh-knowledge`
5、`dsh-local-env`
6、`dsh-opensource-release`
7、`dsh-workflow`
8、`oil-motion`
9、`skills-security-check`

提交前核对:
· **凭据类扫描**(`*.env` / `*token*` / `*.key` / `*secret*` / `*.pem`)⇒ **零命中** ✓;
· 体积合计约 20 MB(`draw-ui` 12M + `oil-motion` 6.5M 是大头,形态为配图/素材 ——
  仓库 `.gitignore` 里明写「`assets/*.png` 是内容不是产物」⇒ 属刻意入库);
· 运行产物仍按既定规则排除(`__pycache__` / `logs/` / `tmp/` / `.venv/` / `*.egg-info` / `uv.lock` / `.workbuddy/`)。
2026-10-08 22:29:08 +08:00

29 KiB
Raw Blame History

实例可见面 · 软件共享 · 网络与安全边界(档案 38a/39/41/44/46)

归属:技能 dsh-change-workflow 的详情档(按需读,不是每次都要读)。 本档覆盖:实例可见面 · 软件共享 · 网络与安全边界 · 铁律:实例能看到什么,完全由 bwrap 挂载面决定 · 🔒 基础运行时版本冻结 · 📦 实例共享工具 · 资源与网络边界 · 只读诊断配方 · 📋 实例可访问路径清单 · 技能上传安全(原行 L601–L906)。 主文件 / 判据与流程主干 = ../SKILL.md(§1 六阶段流程 · §2 红线 R1–R8 原文速查 · §3 本机 Git Bash 环境坑 · §4 并行调度结论)。 来源:2026-09-22「技能重组线」把 ../SKILL.md 的 L601–L906 段逐行原样下沉到本文件,未改一字。 跨档引用:正文里的「§N / 见 §8 坑 N / 见下表」等编号,用 ../SKILL.md 末节「详情索引」的原章节列定位。 维护:本文件与 ../SKILL.md 的指针行成对;改内容时同时核对主文件的指针描述是否仍准确。


实例可见面 · 软件共享 · 网络与安全边界(档案 38a,2026-09-11 实测)

铁律:实例能看到什么,完全由 bwrap 挂载面决定

orchestrator.ts spawnAsUser() 的 bwrapArgs 只挂这些(照抄,勿凭记忆):

--ro-bind /usr /usr          ← 含 /usr/local(宿主装一次 = 全员共享的现成通道)
--ro-bind /lib64 /lib64
--symlink usr/bin /bin ; usr/sbin /sbin ; usr/lib /lib     ← 合成根(档案 23)
--tmpfs /etc + 14 项文件白名单   ← 见下「/etc 白名单」;**不要用 --ro-bind /etc /etc**
--dev /dev --proc /proc
--bind <userRoot>/tmp /tmp        ← 每用户独立,1777
--bind <userRoot> <userRoot>
--ro-bind-try <userRoot>/home/profiles/<web|headless>/{cordis.patch.yml,package.json,pnpm-lock.yaml}  ← 只读,防绕过投放管控
--unshare-pid                  ← 注意:**没有 --unshare-net**
--chdir <userRoot>/ws -- setpriv --reuid <uid> --regid <uid> --clear-groups <cmd>

⚠️ 最常见的漏:平台侧任何新增的共享目录,都必须同步补一条 --ro-bind,否则实例内根本不存在。 实证(档案 38a P0):bundledSkillDir = <dataRoot>/bundled-skills(=/var/lib/dshs/bundled-skills)没被挂载 → 以平台原样参数进实例 ls = No such file or directory;而 dsh-skill-filesystem/lib/index.js:84,181 是 resolve() + 实例内直接读盘(不是编排器推数据)→ 档案 10/11 的共享技能层实际失效:技能投进去也发现不了。修法 = '--ro-bind-try', bundledSkillDir, bundledSkillDir(放在 --bind root root 之后)。

/etc 白名单(档案 39,2026-09-11 落地)

--ro-bind /etc /etc 不要用 —— 实例会读走 /etc 下 576 个 others-readable 文件,含平台情报: /etc/systemd/system/dsh-*.{service,path}、/etc/nftables-dsh-egress.nft(出网护栏规则全文)、 /etc/cron.d/dsh-*、/etc/letsencrypt/renewal/*.conf。

正确写法 = 空 --tmpfs /etc + 逐文件 --ro-bind-try,白名单 15 项(运行时实测必需最小集): ld.so.cache ld.so.conf passwd group nsswitch.conf hosts resolv.conf host.conf services localtime os-release machine-id pki/tls/certs pki/ca-trust alternatives

  • ⚠️ symlink 必须 realpathSync() 后绑「真实目标 → symlink 原路径」,否则实例内是断链 (nsswitch.conf → /etc/authselect/;localtime → /usr/share/zoneinfo/)。
  • 🔴 alternatives 是「软链枢纽目录」,必须整体挂 —— 这是最容易漏、且事故最隐蔽的一条 (2026-09-11 实测踩到):/usr/bin/python3 是两跳软链 /usr/bin/python3 → /etc/alternatives/python3 → /usr/bin/python3.6, 中间一跳在 /etc → 沙箱内 python3 静默 command not found(不是报错,是"命令不存在")。 一次就打断 21 个命令:python3 python pip3 pip-3 pydoc3 python3-config pyvenv-3 easy_install-3 unversioned-python ld(→ld.bfd,node-gyp 编译要用) pax print-* ifup ifdown lpc。 → 判定准则:只绑"直接指向 /etc 的软链"是不够的,必须枚举"整条链条会穿过 /etc"的条目:
    find /usr/bin /usr/sbin /usr/libexec /usr/local/bin -maxdepth 1 | while read -r f; do
      [ -L "$f" ] || continue; c="$f"; n=0
      while [ -L "$c" ] && [ $n -lt 10 ]; do t=$(readlink "$c")
        case "$t" in /*) c="$t";; *) c="$(dirname "$c")/$t";; esac
        case "$c" in /etc/*) echo "$f -> $c";; esac; n=$((n+1)); done
    done | sort -u
    
    安全性:/etc/alternatives 33 项全部指向 /usr 或 /lib64(已只读挂载),不含凭据/平台情报。
  • ⚠️ 这类回归的唯一验收手段是"工具清单前后对比"(for c in …; do command -v $c; done)或会话取证 —— 它不报错、不崩溃,只是能力静默消失,所以 /etc 白名单类改动必须跑一次全量工具对比。
  • 实测效果:可见项 222→13、可读文件 576→27、平台情报 4/1/2/8 → 0/0/0/0; 同时 node / os.userInfo / DNS / node-TLS / curl / dsh --dump-config(549 行/176 插件)全通。
  • 凭据类本来就读不到(dshs.env 600、shadow/gshadow 0000、sudoers 440)—— 别把"没泄露"当成本次收益。
  • 判据:/etc 里凡是"平台的秘密"就白名单化;/usr 不必动(审计证实零凭据,且是运行时宿主; /usr/bin 的 1151 个 CLI 是 agent 唯一手脚,去掉=平台核心能力归零)。

⛔ H5 红线:OUTPUT 链按「目的地址」封本机服务 = 自伤(档案 39 实测踩到)

实例是「先收后回」的服务端:nginx(root) → 实例端口的 SYN 合法,但实例回的 SYN-ACK 与后续数据包 daddr 同样是 127.0.0.1。若写 meta skuid <uid段> ip daddr {127.0.0.0/8,…} reject 一刀切, 回包会被一起拒掉 → nginx 无法回源、实例整体不可用。

  • 症状指纹:root 连实例端口 timeout(丢包)而不是 refused。refused = 规则只打在客户端方向(正常); timeout 往往意味着双向都被打到。
  • 正确写法:只匹配主动发起的连接 —— TCP 用纯 SYN、UDP 用 ct state new:
    meta skuid 100000-199999 ip daddr { 127.0.0.0/8, 172.17.0.1, <eth0>, <公网EIP> } \
      meta l4proto tcp tcp flags & (fin|syn|rst|ack) == syn counter reject with tcp reset
    meta skuid 100000-199999 ip daddr { …同上… } meta l4proto udp ct state new counter reject
    
  • 与平台既有 src/supervisor/firewall.ts 的 portGuard(iptables -m owner ! --uid-owner 0 -j REJECT) 语义一致、范围更全,且不依赖 config.portGuard 开关;portGuard 的存在反证实例进程自身不需要任何 loopback 连接。
  • 验证闭环(缺一不可):① root 回源必须拿到 303/200(不能只看 ss 有 LISTEN); ② 实例内自连宿主端口必须 ECONNREFUSED;③ nft list table ip dsh_egress 看 counter 是否命中。
  • 改前先查有没有实例在跑:systemctl list-units "dsh-*scope" --no-pager --all。
  • 一键复现:bash scripts/install-egress-guard.sh(含 nft + service,已纳入版本控制)。

⛔ 无法遮蔽 /usr 内的文件(档案 42 实测踩到 → 实例起不来)

想「屏蔽 /usr 里某个文件」(例如藏掉旧解释器 python3.6)时不能用叠加挂载: --ro-bind /dev/null /usr/libexec/platform-python3.6 会让 bwrap 报 Can't create file at …: Permission denied → 实例直接起不来。 根因:bwrap 需要在 DEST 创建挂载点,而 /usr 是 ro-bind(只读) → 创建失败。 /etc 之所以能自由白名单,是因为它先 --tmpfs(可写)再逐项 --ro-bind-try。

  • 判据:只有「先 tmpfs 再白名单」的目录才可自由增删;ro-bind 的目录只能整目录决策。
  • 可行变体(未采用,收益低风险中):--tmpfs /usr/libexec + rebind 必需子项 (git-core getconf gawk/awk coreutils …)→ 与 /etc 同构的白名单,但要有同类的"静默回归"预案。
  • ⚠️ 改 bwrap 参数后必须真启动一次实例验证 —— 本类错误会让所有实例无法启动(不是静默降级); 改前先确认无实例在跑:systemctl list-units "dsh-*scope" --no-pager --all。

实例共享 Python 运行时(档案 42)

  • 系统 python3 = 3.6.8,本体 /usr/libexec/platform-python3.6 是 dnf/yum 的兄弟解释器 (shebang 用绝对路径 /usr/libexec/platform-python)→ 不能移除;而 /usr/bin/python3 只是两跳软链(经 /etc/alternatives),动它安全。
  • 平台已装可移植 Python 3.12:bash scripts/install-python-runtime.sh(幂等 / 可离线) → 解压 python-build-standalone 到 /usr/local/dsh-runtime/python-3.12.14/, /usr/local/bin/{python3,python,pip3} 指过去。 /usr 已 ro-bind + /usr/local/bin 在实例 PATH 首位 → 实例内 python3 即 3.12,零代码、无需重启。
  • 迁移打包单元 = /usr/local/dsh-runtime/ 一个目录(自包含,不依赖系统 rpm): tar czf dsh-python-runtime.tar.gz -C /usr/local dsh-runtime → 目标机解压回原位 + 重跑脚本(只重建软链)。
  • 📌 对 AI/用户的引导(这是"3.6 不开放"的正解,不是遮蔽):明确写「Python 用 python3(= 3.12); python3.6 / python2 是系统遗留、不受支持」。实例内 /usr/local/bin 在 PATH 首位, python3 已指向 3.12 —— 引导的成本为零、风险为零;而遮蔽属"改挂载结构",会让实例起不来(见上)。

🔒 基础运行时版本冻结(档案 44)

要求:禁止用户以任何方式升级 Python / pip / node 等基础包,避免版本差异导致插件功能不可用。

已经天然成立的护栏(都不需要额外代码):/usr 是 ro-bind → 实例内改不了 /usr/local/dsh-runtime/**;pip install(默认)被 Read-only 挡; npm/pnpm -g 失败(/usr/local/lib/node_modules 只读);$HOME/.local/bin 不在实例 PATH(固定 /usr/local/bin:/usr/bin:/bin);dsh 主进程由 orchestrator spawn,PATH 取自 root 环境,不受用户影响。

唯一的缝 = Python 的 user-site(2026-09-11 实测): pip install --user 一旦创建 $HOME/.local/lib/python3.12/site-packages,它就会进入 sys.path 且排在平台 site-packages 之前 → 用户装的同名包盖住平台包(实测 import packaging 拿到用户版 26.3)。

修法(两条限制性 env,官方机制、互不冲突,已注入 baseEnv + 放行 ALLOWED_ENV):

PYTHONNOUSERSITE=1                                      # user-site 不进 sys.path
PYTHONUSERBASE=/usr/local/dsh-runtime/.no-user-install  # pip --user 写只读位 → 明确报错
  • 实测:sys.path 中无 user-site;pip --user 报 Can not perform a '--user' install. User site-packages are disabled for this Python.; 历史污染失效(平台包不再被盖);--target + PYTHONPATH 仍可用(这是给用户的推荐路径)。
  • ⚠️ 这两条虽是"注入 env"(R5 触发项),但方向是收窄(限制用户侧安装)→ 可直接做,档案留痕即可。

版本漂移巡检:scripts/runtime-baseline.cjs(--accept 写基线;默认比对,漂移则退出码 1), cron 每天 05:10 跑;基线存 /opt/dsh/state/runtime-baseline.json(当前 = python3 3.12.14 / pip 26.2.1 / node 22.23.2 / npm 10.9.8)。升级运行时后必须 --accept 更新基线,否则会一直告警。

📦 实例共享工具(jq / ripgrep / ffmpeg,档案 46)

核心答案:用户要工具/程序包时,不需要每人装一份 —— 宿主装一次全员共享。依据: 实例内 /usr 是 ro-bind 且 /usr/local/bin 在实例 PATH 首位 → 宿主安装全部实例(含新用户) 立即可见,零代码、无需重启;而用户侧本来就装不上(/usr 只读 + 无 sudo)。

  • 一键安装:bash scripts/install-shared-tools.sh(幂等 / 固定版本 / 官方 sha256 校验 / --force) 当前已装:jq 1.8.2、ripgrep 15.2.0(musl 静态)、ffmpeg+ffprobe(BtbN 静态构建,自带全部编解码库)。

  • 新增共享工具的标准动作:优先找静态单文件发行版 → 放 /usr/local/dsh-runtime/bin/ + 软链到 /usr/local/bin/ → 更新 /usr/local/dsh-runtime/SHARED-TOOLS.md → 跑 node scripts/runtime-baseline.cjs --accept 刷新版本基线。

  • 迁移单元:整个 /usr/local/dsh-runtime/ 一个目录(Python + 这些工具都在里面)→ tar czf dsh-runtime.tar.gz -C /usr/local dsh-runtime,目标机解压回原位 + 重跑两个安装脚本。

  • ⚠️ 版本号提取别用通用规则:ffmpeg 的版本形如 N-126492-gefb0a7e5e7(不含 x.y), 基线脚本对它单独用 ffmpeg version (\S+)。

  • ⚠️ Playwright 仍未做:它不是单文件,除浏览器二进制外缺 11 个系统 .so(libnss3/libgbm/ libatk/libcups/libdrm/libxkbcommon…)。系统库可 dnf install(宿主装一次同样全员可见); 浏览器二进制建议共享位 + PLAYWRIGHT_BROWSERS_PATH,但注入该 env 属 R5「给实例注入新 env」= 扩大, 须先出「权限影响评估」并取得确认。

  • 🖥️ admin 可视化:门户 #/runtime「运行环境」页(档案 47)+ GET /api/admin/runtime (admin 只读)—— 列名称/版本/来源/安装脚本/可否卸载 + 版本漂移告警(对比基线)+ 目录/体积/最近变更 + 折叠的「升级 / 卸载 / 迁移怎么做」。 术语澄清:「宿主」= 服务器本身(不是某个用户);安装由平台以 root 执行,用户侧装不进 /usr。

  • pip 落点:默认装 runtime 的 site-packages(只读被挡);推荐 --target <ws>/.pylibs + PYTHONPATH(实测可用)。

  • ⚠️ 宿主 /usr/local/bin/python3 现在是 3.12 → 已核实宿主无任何东西依赖裸 python3 (dnf/yum 绝对路径;BT-Panel 用自带 pyenv 绝对路径)。

  • ⚠️ 平台自身 bug(待治本):business-plugins.ts / ensure-biz-plugins.cjs 以 root 跑 pnpm 且 HOME=<userRoot>/ws → 在用户工作区留下 root 属主的 .local/ → 用户 pip install --user 报 Permission denied(自己的目录里装不了包)。不能简单改 HOME(要与实例共用 pnpm store, 否则 ERR_PNPM_UNEXPECTED_STORE)→ 应在 pnpm 流程后把 ws 下的 root 属主项 chown 回用户, 或放进 ws-cleanup.cjs 做自愈。

  • 🔐 本环境出网走 TLS 中间人代理 → Python 脚本必须显式指定 CA(2026-09-11 guest 会话实证)。 服务器上有两份 CA bundle:

    • /etc/pki/tls/certs/ca-bundle.crt —— 含代理 CA(curl 默认读这份,所以 curl 一直正常)
    • /etc/ssl/certs/ca-bundle.crt —— 系统原始,不含代理 CA(Python 默认读这份)

    → 在实例里用 Python 抓 HTTPS 会报 URLError(SSLCertVerificationError: …) 或 unable to get local issuer certificate。正解(零平台改动,就是 A 方案):

    SSL_CERT_FILE=/etc/pki/tls/certs/ca-bundle.crt python3 your_script.py
    
    # 或写进脚本(推荐,免每次手敲)
    import os; os.environ.setdefault("SSL_CERT_FILE", "/etc/pki/tls/certs/ca-bundle.crt")
    # requests 亦可用 REQUESTS_CA_BUNDLE 指向同一文件
    

    写技能/脚本时把这一句固化进去 —— 这是环境特性,不是用户错误;不固化就会反复踩 "某个工具莫名连不上网"。不要改成平台注入 env:那属 R5「给实例注入新 env」= 扩大, 而一行代码就能解决。 (相关:/etc/ssl 已加入实例 /etc 白名单,否则连系统 CA 都读不到 —— 档案 49 事后修正 2。)

跨用户共享本地安装 → 一律走宿主:

方式 改动 评价
宿主 /usr/local 或 /usr/bin(推荐) 零代码,/usr 已 ro-bind 装一次全员生效、新用户自动。全局单版本
<dataRoot>/shared-* + --ro-bind + env 白名单 改 orchestrator.ts + spawn.ts ALLOWED_ENV 与 bundled-skills 同构,必须先修 bind
让用户自己 pip/npm install — ❌ 用户侧装不上(/usr 只读 + 无 sudo),装上了也是 N 份重复且污染工作区

资源与网络边界(改默认值/做容量规划前必看)

  • 宿主 1870MB / 2 vCPU / 40G 单分区,quotaon / 未启用 = 无磁盘配额 → 单租户可写满打瘫全平台。
  • 每实例内存 MemoryHigh=448M → MemoryMax=1024M(与插件集合无关;见档案 96)+ CPUQuota=150% + TasksMax=128 → 并发实例上限约 2–3 个(宿主 1870MB)。
  • bwrap 只 --unshare-pid,不 --unshare-net → 实例与宿主共享网络命名空间。已在档案 39 缓解:nft H5 封锁实例主动访问 127.0.0.0/8 + eth0 + docker0 + 公网 EIP(写法与坑见上「H5 红线」)。⚠️ 残留:实例仍可 bind 0.0.0.0(入站方向未限制)。
  • 出网护栏 table ip dsh_egress(/etc/nftables-dsh-egress.nft,一键复现 scripts/install-egress-guard.sh):H1 reject 云元数据 100.100.100.200;H5 封锁实例主动访问宿主自身;其余新建外联只 log prefix "dsh-egress" level info 不拦截。DNS = 阿里云内网 100.100.2.136/138(绝不可封 100.100.0.0/16)。
  • HOME=<userRoot>/ws(不是 home/)→ pip/npm 缓存全落在用户工作区里(ws/.npm、ws/.cache/pip),会被门户 #/files 展示、也可能被 ws-cleanup 触碰。

只读诊断配方(不改用户目录、不起实例)

B=/usr/bin/bwrap; R=/var/lib/dshs/users/<UID>
# 把 diag 脚本 ro-bind 进命名空间,避免往用户 tmp 写文件
$B --ro-bind /usr /usr --ro-bind /lib64 /lib64 \
   --symlink usr/bin /bin --symlink usr/sbin /sbin --symlink usr/lib /lib \
   --tmpfs /etc --dev /dev --proc /proc \
   --bind $R/tmp /tmp --bind $R $R --ro-bind /tmp/diag.sh /tmp/diag.sh \
   --unshare-pid --chdir $R/ws \
   -- setpriv --reuid <uid> --regid <uid> --clear-groups /bin/sh /tmp/diag.sh
# 权威 bwrap 参数不要读源码猜:直接看失败/运行中的 scope
systemctl list-units "dsh-*" --no-pager --all | grep scope

⚠️ pgrep -f "dsh --profile web" 会匹配到你自己的命令行(命令串里含该模式)→ 拿到假 PID。改用 ps -eo pid,user,cmd | grep "^ *[0-9]\+ .*node /usr/local/bin/dsh" 或直接读 systemctl list-units "dsh-*"。

📋 实例可访问路径清单(权威版 · 2026-09-11 档案 39 收窄后)

只有一栏是"该用户的数据",其余都是"公共软件"或"平台自己"——写文档/答用户时按此表口径。

能读到 权限 说明
<dataRoot>/users/<该用户id>/ 读写 唯一属于该用户的数据:home/(= DSH_HOME:profiles/sessions/settings/storages/skills/(已启用)/ skills-library/(已禁用的自建技能,档案 41)/ .credentials.yaml)、ws/(= HOME,默认工作区,pip/npm 缓存也在这)、tmp/(该用户的 /tmp,1777)、patches/*.yml、handoff.json
/usr 只读 公共软件层(node/dsh/npm/pnpm + /usr/bin 1151 个 CLI + /usr/lib64)。审计证实零凭据、零用户数据
/lib64 只读 系统共享库(node 动态依赖 7 个 .so)
/etc(白名单 15 项) 只读 ld.so.cache ld.so.conf passwd group nsswitch.conf hosts resolv.conf host.conf services localtime os-release machine-id pki/tls/certs pki/ca-trust alternatives;其余 /etc = 空 tmpfs(可写、临时、退出即消)
/dev、/proc — 运行必需;--unshare-pid → /proc 只见本命名空间进程
/bin /sbin /lib 符号链接 → usr/bin usr/sbin usr/lib(补齐标准布局,避免沙箱探针 execvp 失败)
<dataRoot>/bundled-skills/ 只读 平台共享技能层(档案 40 起已挂载)。内容对每个登录用户可读 → 禁止放内部文档/私有提示词/凭据
读不到 依据
<dataRoot>/users/<其他用户id>/ bwrap 未挂载 + uid 隔离(实测只列自己那 1 项)
<dataRoot>/dshs.db 未挂载(实测 No such file)
/etc 白名单外全部(systemd 单元 / nft 护栏 / cron / letsencrypt / ssh / firewalld / selinux / 576 个文件) 档案 39 /etc 白名单
/root、/home/*、/opt、/var(自己路径除外)、宿主 /tmp bwrap 未挂载
宿主 127.0.0.1:*、eth0、docker0、公网 EIP nft H5(实测 ECONNREFUSED)
云元数据 100.100.100.200 nft H1

仍然可达(已知残留):公网(pip/npm/API/LLM)、阿里云内网 DNS 100.100.2.136/138、 bind 0.0.0.0(入站方向未限制,跨租户互通靠 H5 的发送侧拦截)、同实例命名空间内 setpriv 后的自有进程。

技能的投放通道(2026-09-11 实测):

通道 路径 谁可改 备注
preset 自带 <dsh>/node_modules/@deepseek-ai/dsh-agent-presets/presets/<preset>/skills/ 不可 dsh 只内置 2 个(cordis-plugin-development、editing-cordis-compositions,都在 cordis preset 里)。改它 = 改官方包(R2 红线)
bundled 共享层 $DSH_BUNDLED_SKILL_DIR(rank 600,全员只读) 平台 ⚠️ 当前断的:见下
用户层 $DSH_HOME/skills(rank 400,每用户独立,watch 即时生效) 用户 平台"用户级启停"应走这层

✅ bundled 层已修复(档案 40,commit 37e016f):bwrap args 在 --bind root root 之后追加 '--ro-bind-try', bundledSkillDir, bundledSkillDir(仅当 config.bundledSkillDir !== '')。 实测:实例内 ls $DSH_BUNDLED_SKILL_DIR 可见、readFileSync(SKILL.md) = OK(= 发现条件成立)、 mountinfo 为精确叶子 ro,nosuid,nodev、touch = Read-only;可见面未扩大(ls users/ 仍 1 项、 DB 不可读、/etc 仍 13)。⚠️ 此前"只注入 env 就以为配好了"是错的 —— skill-filesystem 在实例内读盘, env 只决定"去哪儿找",目录不挂载就是找不到(通用判据:插件在实例内 resolve() + 读盘的东西, 必须真的出现在命名空间里)。 ⚠️ 宿主 <dataRoot>/bundled-skills 目前仍为空 —— 机制通了,但尚未投放任何技能。 🔬 修法安全性已实测(2026-09-11,guest uid 100002 实跑):加该行后 mountinfo = …/bundled-skills → 同名 ro,nosuid,nodev(精确叶子路径 + 只读);ls …/users/ 只列用户自己那一个(看不到其他用户);读 DB = No such file;touch 挂载点 = Read-only file system。对照组(不加)= /var/lib/dshs/ 里只有 users → 支持面零变化,且父目录本来就是 bwrap 为用户 root 合成的(今天线上已可见该路径名,users/ 是只含自己的空壳),不是这一行新暴露的。 ⛔ 红线:只能挂最内层路径。写成 --ro-bind /var/lib/dshs /var/lib/dshs(父目录)= 一次性把全部用户 home + DB + 凭据放进每个实例。这是本改动唯一的真实风险,且属手滑型错误 —— 改完必须逐字复核 bwrap args,并跑一次上面的可见面实测。 ⚠️ 副作用(设计意图,但要说清):挂上后 bundled-skills/ 里的内容对每个登录用户可读 → 该目录禁止放内部文档 / 私有提示词 / 凭据;投放清单必须按"全员可见"审一遍。 ⚠️ dsh 技能没有"禁用"机制(skill-filesystem 无 disable/deny)→ ranked 600 的共享层无法 per-user 关闭,只适合"人人必须有的基线技能";要让用户可启停,必须用 rank 400 的用户层(复制进/删掉)。

平台侧 API ↔ 技能层 的映射(2026-09-11 核实 src/web/routes/skills.ts;档案 41 后已扩充):

API 鉴权 落点 层 说明
GET/POST/DELETE /api/skills/shared(+/apply) requireAdmin <dataRoot>/bundled-skills bundled 600 共享只读 ✅ 档案 40 起已挂载可发现;对用户 locked:true(不可禁用/删除)
GET/POST /api/skills/mine(+/apply) requireAuth(任意登录用户) <userRoot>/home/skills 用户 400 独立 ✅ 用户自建技能;GET 每项带 source/enabled/locked
POST /api/skills/mine/:name/enable | /disable requireAuth 启用 ↔ <userRoot>/home/skills-library/ 用户 400 dsh 无 disable 机制 → 「禁用」= rename 出扫描根(保留文件,可再启用)。watch 即时生效,无需重启
DELETE /api/skills/mine/:name requireAuth 两处同删 — 彻底移除用户自建技能
同名守卫 — — — 与共享技能同名 → 上传/启用/禁用/删除一律 409(否则用户白传一份永远被 rank 600 压住、又关不掉的技能)

⚠️ 投放前必须平铺 —— discoverRoot 只扫 1 层:带 subskills/ 的技能(实证 短视频工作台/subskills/{mcn-data-insight,mcn-dou-analysis,…})子技能不会被发现。投放时把 subskills/* 提升为顶层,或每层各投一个技能。references/、scripts/ 是技能内部相对路径引用,不受影响、别动。 ⚠️ scripts/ 的可用性由"宿主基线"决定(技能脚本经 bash 执行 → 受只读 /usr 约束):宿主缺 python3.9+/ffmpeg/rg/jq/浏览器时,脚本写出来也跑不动。投放前先核对宿主基线,别在 SKILL.md 里写"请先装 X"(用户侧装不上)。 ⚠️ 安全:分清"谁在执行"(档案 41 定稿)—— · 运行技能 scripts/:走 agent 的 bash 管线,困在用户沙箱里(uid+bwrap+cgroup+/etc 白名单+H5)→ 自伤,可接受,不该以"用户能跑任意代码"为由禁止; · 上传/解压技能包:平台以 root 执行 → 这才是平台级风险点(见下「技能上传安全」)。 注意档案 33 后默认 danger-full-access + approval: never → 技能脚本不再弹确认。

技能上传安全(档案 41 · 该类漏洞的通用判据)

P0 实测复现的根因:zip 的成员可以是符号链接,而 unzip 默认原样恢复它。 symlink 的目标写在 zip 元数据里、不在任何文件内容中 → scanDir(只读文件内容,且遇 !st.isFile() 直接 continue)天然看不到;成员名校验(拒绝对路径 / ..)也放行。 随后 applyStaged 以 root 执行 chownTree/chmodTree,二者跟随符号链接 → 改写链接目标(宿主任意文件)的属主与权限。实测 -rw------- root:root → -rw-r--r-- <租户uid>;指向 /etc/shadow = 密码哈希 chmod 644 全机可读 + chown 给该租户。 攻击面 = 传了 owner 的那条路由(mine;shared 不传 owner,不受影响)。

必须同时覆盖两个维度:① 文件内容(现有 BLOCK_PATTERNS P0 阻断 / WARN_RULES P1 告警 —— scanDir 内部会 throw 400,不是只告警);② 归档元数据与文件类型(symlink / 硬链 / 设备 / FIFO + 解压规模)。

三项加固(已落地):

  1. findSpecialEntry() —— 解压后立刻拒绝任何非普通文件/目录条目(在任何 chown/chmod 之前);
  2. sumUncompressedSize()(解析 unzip -l)+ MAX_SKILL_FILES=2000 + MAX_SKILL_UNCOMPRESSED_BYTES=200MB —— UPLOAD_BODY_LIMIT=180MB 只限压缩体,而宿主 quotaon / 未启用 = 无磁盘配额 → zip bomb 可跨租户 DoS;
  3. 纵深防御:chownTree 用 lchownSync 且跳过 symlink;chmodTree 跳过 symlink(Linux 无 fs.lchmod,ENOSYS)。

更彻底(未做):以目标 uid 降权解压(setpriv --reuid <uid> ... unzip),让整条链路都不在 root 下。

技能的依赖:没有机制(最大缺口)。dsh-skill-filesystem 不处理任何 install/deps/package.json —— 技能包只能靠 SKILL.md 写"请先装 X",由 agent 运行时手敲。对比插件:package.json.dependencies + pnpm add 自动解析(store 硬链复用)。约束:实例内 /usr 只读 → 系统级装不上;只能 --user/本地;无共享、每用户各装一份。 → 平台若要多用户投放带依赖的技能,必须在启用时"代装":Node 依赖装到该技能目录(或用户 ws 下统一 node_modules + NODE_PATH);Python 依赖用用户级 venv($DSH_HOME/.venvs/<skill>)并让脚本用该解释器。绝不让 agent 临时 pip/npm install(只读 /usr + 供应链 + 不可复现)。