用户令逐字:「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/`)。
29 KiB
实例可见面 · 软件共享 · 网络与安全边界(档案 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 个命令:python3pythonpip3pip-3pydoc3python3-configpyvenv-3easy_install-3unversioned-pythonld(→ld.bfd,node-gyp 编译要用)paxprint-*ifupifdownlpc。 → 判定准则:只绑"直接指向 /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/alternatives33 项全部指向/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.env600、shadow/gshadow0000、sudoers440)—— 别把"没泄露"当成本次收益。 - 判据:
/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-coregetconfgawk/awkcoreutils…)→ 与 /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):H1reject云元数据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 + 解压规模)。
三项加固(已落地):
findSpecialEntry()—— 解压后立刻拒绝任何非普通文件/目录条目(在任何chown/chmod之前);sumUncompressedSize()(解析unzip -l)+MAX_SKILL_FILES=2000+MAX_SKILL_UNCOMPRESSED_BYTES=200MB——UPLOAD_BODY_LIMIT=180MB只限压缩体,而宿主quotaon /未启用 = 无磁盘配额 → zip bomb 可跨租户 DoS;- 纵深防御:
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 + 供应链 + 不可复现)。