# 实例可见面 · 软件共享 · 网络与安全边界(档案 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 /tmp /tmp ← 每用户独立,1777 --bind --ro-bind-try /home/profiles//{cordis.patch.yml,package.json,pnpm-lock.yaml} ← 只读,防绕过投放管控 --unshare-pid ← 注意:**没有 --unshare-net** --chdir /ws -- setpriv --reuid --regid --clear-groups ``` > ⚠️ **最常见的漏**:**平台侧任何新增的共享目录,都必须同步补一条 `--ro-bind`,否则实例内根本不存在**。 > 实证(档案 38a P0):`bundledSkillDir` = `/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"的条目**: ```bash 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 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, , <公网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 /.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=/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 方案)**: ```bash SSL_CERT_FILE=/etc/pki/tls/certs/ca-bundle.crt python3 your_script.py ``` ```python # 或写进脚本(推荐,免每次手敲) 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 | 装一次全员生效、新用户自动。全局单版本 | | `/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=/ws`(不是 `home/`)→ pip/npm 缓存全落在**用户工作区**里(`ws/.npm`、`ws/.cache/pip`),会被门户 `#/files` 展示、也可能被 ws-cleanup 触碰。 ### 只读诊断配方(不改用户目录、不起实例) ```bash B=/usr/bin/bwrap; R=/var/lib/dshs/users/ # 把 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 --regid --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 收窄后) **只有一栏是"该用户的数据",其余都是"公共软件"或"平台自己"——写文档/答用户时按此表口径。** | 能读到 | 权限 | 说明 | |---|---|---| | `/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 失败) | | `/bundled-skills/` | **只读** | 平台共享技能层(档案 40 起已挂载)。**内容对每个登录用户可读** → 禁止放内部文档/私有提示词/凭据 | | 读不到 | 依据 | |---|---| | `/users/<其他用户id>/` | bwrap 未挂载 + uid 隔离(实测只列自己那 1 项) | | `/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 自带 | `/node_modules/@deepseek-ai/dsh-agent-presets/presets//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()` + 读盘的东西, > 必须真的出现在命名空间里)。 > ⚠️ **宿主 `/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`** | `/bundled-skills` | bundled **600** 共享只读 | ✅ 档案 40 起已挂载可发现;对用户 **`locked:true`(不可禁用/删除)** | | `GET/POST /api/skills/mine`(+`/apply`) | `requireAuth`(任意登录用户) | `/home/skills` | 用户 **400** 独立 | ✅ 用户自建技能;`GET` 每项带 `source`/`enabled`/`locked` | | `POST /api/skills/mine/:name/enable` \| `/disable` | `requireAuth` | 启用 ↔ `/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 ... 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/`)并让脚本用该解释器。**绝不让 agent 临时 pip/npm install**(只读 `/usr` + 供应链 + 不可复现)。