dsh-plugin-ssh-hosts
一个可独立安装的 DeepSeek Harness(DSH)Host 插件:让 Agent 在多台 Linux 服务器上执行命令、读写文件、上传下载,同时把密码/私钥/口令隔离在模型之外,并对高风险命令要求人工审批。
- 插件类型:Host-only bundle(
dsh.bundle.patch),不改动 Harness 核心源码。 - 工具注册:
@deepseek-ai/dsh-tools的defineTool,注册到ctx.tools。 - SSH 实现:
ssh2(纯 JS,无需本机ssh.exe,支持密码、私钥、带口令私钥)。 - 传输:SFTP(
ssh2的 SFTP 子系统)。 - 已验证环境:DSH
0.2.1-rc.2、Node24.x、Windows 11;插件在独立 profile 中加载,模型经 mock LLM 调用ssh_exec,对本地真实 SSH 服务返回exit 0与正确 stdout。
1. 功能
| 能力 | 说明 |
|---|---|
| 多服务器清单 | 任意多台主机;别名唯一;增删改查;TOFU 主机密钥 |
| 密码 / 私钥认证 | 私钥只按路径读取,不复制、不打印;支持 passphrase |
| 远程命令 | ssh_exec:退出码、stdout、stderr、耗时、超时、截断、原始字节数 |
| 服务目录说明 | 每台服务器可声明 services(服务名 + 目录 + 启动/日志/端口说明),Agent 用 ssh_services 直接定位服务 |
| 文件读写 | ssh_read_file / ssh_write_file,文本、UTF-8、二进制拒绝 |
| 上传下载 | ssh_upload / ssh_download,SFTP 流式传输,临时文件落盘后改名 |
| 写权限隔离 | allowedPaths 控制读;editablePaths 单独控制写/上传;未配置时写入仅限临时目录 |
| 临时目录 | tempDir(默认 /tmp)在每台服务器上默认获得读写权限,Agent 的过程文件都放这里,不往服务目录里丢;把 tempDir 设为空字符串即可整体关闭 |
| 会话/服务器授权 | 每个新会话第一次访问某台服务器时,先向用户弹出一次授权;批准只对该会话的这一台服务器有效,换一台或多开一个会话都要重新申请。主机清单的查看与新增是另外两个独立授权。处于「完全权限」的会话(不弹审批)会自动获得授权,不再请求 |
| 人工审批 | rm -rf、sudo、shutdown、mkfs、dd、防火墙清空、DROP DATABASE 等命中时请求一次性批准;「完全权限」会话按已授权执行并标记 full-access |
| 提权执行(sudo) | 每台服务器可配置 sudo:nopasswd(推荐)或 password(密码只写本机加密存储,经 SSH stdin 交给 sudo)。ssh_exec 传 privileged: true,或直接写 sudo ...(前导 sudo 会被改写为插件托管的提权包装) |
| 凭据隔离 | 无任何工具参数接收密码、私钥内容;凭据单独 AES-256-GCM 加密存储 |
| 路径限制 | 文件操作严格限制在 allowedPaths(读)/editablePaths(写)内并校验 realpath;命令 cwd 受限;deniedPaths 始终拒绝 |
| 命令写入守卫 | >/tee/sed -i/cp/mv/rm/touch 等明显写文件的命令,cwd 与路径参数必须在 editablePaths 内 |
| 审计 | audit.log 逐条记录动作、主机、用户、命令/路径、退出码、耗时、截断、审批结果;不含凭据 |
| 命令历史面板 | Web 侧栏底部的「SSH 命令历史」按钮 + 抽屉面板:按时间倒序列出所有动作,可按服务器/动作/失败/关键字筛选,5 秒自动刷新;服务器按 IP 归并、显示当前别名,改别名不会多出一台服务器 |
| 服务器管理页面 | Web 侧栏「SSH 服务器」页面:手动添加、编辑、测试、删除服务器,录入/清除密码或私钥口令;写接口只接受本机回环请求 |
| 只读服务器 | readOnly: true 时拒绝 ssh_exec 和远端写入,只允许读取/下载 |
2. 安装
2.1 前置条件
- DeepSeek Harness
0.2.1-rc.2(engines.dsh: >=0.2.1-rc.2 <0.3.0)。 - Node.js
>=22.19(推荐 24)。 - 能访问 npm registry(安装
ssh2与@deepseek-ai/schemastery)。
2.2 构建
cd dsh-plugin-ssh-hosts
npm install --legacy-peer-deps
npm run build
npm test
npm run build 输出到 lib/;npm test 编译测试到 dist-test/ 并运行 54 个用例(含真实 ssh2 Server 集成测试与会话/服务器授权测试)。
2.3 安装到某个 profile
推荐先装到独立开发 profile,确认无副作用后再装到日常 profile:
# 创建一个独立的 headless profile(只创建一次)
dsh --profile ssh-dev --from-default-profile headless
# 安装进该 profile(npm 包名;也可以传克隆下来的本地目录,pnpm 会建立 link)
dsh plugin --profile ssh-dev add dsh-plugin-ssh-hosts
# 确认 bundle 层已出现
dsh --profile ssh-dev --dump-config | Select-String ssh-hosts
# 确认模块可导入且 Config schema 可读(诊断为空)
dsh --profile ssh-dev --dump-config-schema
日常 profile 同理,把 ssh-dev 换成 desktop(Web/Desktop):
dsh plugin --profile desktop add dsh-plugin-ssh-hosts
也可以在 Web 侧栏的 Plugins 页面点击安装,或让 Agent 调用 plugin_manager 的 install_bundle,target 传包名 dsh-plugin-ssh-hosts 或本地目录绝对路径。
如果 Desktop 正在运行,安装后需要重启 Harness 进程,新的 Host 插件才会加载。Web 页面本身不会显示新的设置页——本插件是 Host-only,入口是 Agent 工具。
安装时 pnpm 可能提示这个 link 包的 peerDependencies 未从目标目录解析;这是预期行为:
@deepseek-ai/dsh-tools由 Harness 安装提供的运行时解析映射加载,不需要出现在插件目录里。如果 pnpm 报告
ssh2的可选原生依赖(如cpu-features)有 pending build scripts,可在插件管理器里允许这些脚本后重试;不批准时ssh2会回退到纯 JS 路径,命令与 SFTP 仍可用。
卸载:
dsh plugin --profile desktop remove dsh-plugin-ssh-hosts
插件数据默认在 $DSH_HOME/ssh-hosts(Windows:C:\Users\<你>\.dsh\ssh-hosts)。卸载插件不会自动删数据;确认不再需要后可手动删除该目录。
3. 配置
cordis.patch.yml 已带一份可直接工作的默认配置:
- insert:
- id: ssh-hosts
name: dsh-plugin-ssh-hosts
config:
maxOutputBytes: 131072
timeoutMs: 60000
maxTimeoutMs: 300000
highRiskPolicy: ask
sessionConsent: ask
trustFullAccess: true
audit: true
tempDir: /tmp
debug: false
所有字段都可省略;省略时使用默认值。完整字段:
| 字段 | 默认值 | 说明 |
|---|---|---|
dataDir |
$DSH_HOME/ssh-hosts |
数据目录;也可用环境变量 DSH_SSH_HOSTS_DATA_DIR 覆盖(便于测试/多实例) |
maxOutputBytes |
131072 |
单条命令 stdout/stderr 各自保留上限;超出后保留首尾并标记 |
timeoutMs |
60000 |
命令默认超时 |
maxTimeoutMs |
300000 |
模型单次可请求的最大超时 |
connectTimeoutMs |
20000 |
TCP/SSH 握手超时 |
idleTimeoutMs |
300000 |
空闲连接关闭时间;连接池按 host 复用 |
maxConcurrentPerHost |
8 |
单台服务器并发操作上限 |
maxTransferBytes |
52428800 |
单次读/写/上传/下载上限(50 MiB) |
secretsBackend |
file |
file、env 或 none |
envPrefix |
SSH_HOST_ |
env 后端环境变量前缀 |
highRiskPolicy |
ask |
ask(人工审批)、deny(直接拒绝)、allow(不询问,仍记账) |
sessionConsent |
ask |
本会话第一次访问某台服务器(或主机清单)时是否申请授权:ask(按服务器/清单分别申请一次)、allow(恢复旧行为,不询问)、deny(全部 ssh_* 工具直接拒绝) |
trustFullAccess |
true |
会话处于「完全权限」(审批策略 never + 文件策略 danger-full-access)时,直接放行 SSH 授权与高风险命令,而不是去弹一个不可能出现的审批框。设为 false 恢复旧行为:高风险命令失败关闭 |
audit |
true |
写 audit.log |
debug |
false |
输出脱敏调试日志到 stderr |
localRoots |
[] |
额外允许上传/下载访问的本地根目录;会话工作区始终允许 |
strictCommandPaths |
false |
为 true 时拒绝命令中任何越出 allowedPaths 的绝对路径参数 |
guardCommandEdits |
true |
拒绝在 editablePaths 之外执行明显会写文件的 shell 命令(>、tee、sed -i、cp、mv、rm、touch 等) |
tempDir |
/tmp |
每台服务器默认获得读写权限的远程临时目录,自动并入该主机的 allowedPaths 与 editablePaths;Agent 的过程文件应全部放在这里。填空字符串 "" 关闭该授权,回到「只有 editablePaths 可写」的旧行为 |
tempDir 的几条约定:
- 它只是把目录并入每台服务器的有效路径策略,不改写
hosts.json:主机记录里看到的allowedPaths/editablePaths仍是用户配置的原值,ssh_host_list会单独显示tempDir字段与生效方式。 - 优先级:
deniedPaths>tempDir>editablePaths>allowedPaths。某台机器不想给/tmp,把/tmp加进它的deniedPaths即可;readOnly: true的服务器仍然完全禁止写入。 - 支持绝对路径或
~/...(按登录用户的 home 展开),例如tempDir: ~/scratch。 - 关闭方式:
tempDir: ""(或在 profile 的cordis.patch.yml/ Web 插件设置里清空该字段),保存后重启 Harness 生效。 - 默认
/tmp是全局可读目录:不要把密码、令牌等敏感内容写进去;建议每个任务在下面建一个私有子目录(mkdir -m 700 /tmp/dsh-<任务>)并在结束时删除。
secretsBackend: none 只适合完全不需要密码/口令的私钥主机;密码主机会在连接时报 SECRET_BACKEND_NONE。
修改配置:编辑 profile 的 cordis.patch.yml,或在 Web 的插件设置里改该行的 config。不要把密码写进 YAML;凭据走下一节的独立存储。
4. 添加服务器与录入凭据
4.1 添加服务器(由 Agent 工具完成)
让 Agent 调用:
{
"name": "ssh_host_add",
"arguments": {
"alias": "web-1",
"host": "10.0.0.10",
"port": 22,
"username": "deploy",
"authType": "password",
"defaultCwd": "~/app",
"allowedPaths": ["~/app", "/tmp"],
"editablePaths": ["~/app"],
"services": [
{
"name": "api",
"directory": "~/app/api",
"description": "对外 HTTP API,构建后由 systemd 启动",
"startCommand": "sudo systemctl restart api",
"logPath": "~/app/logs/api.log",
"port": 8080
},
{
"name": "worker",
"directory": "~/app/worker",
"description": "队列消费者,不要和 API 同时重启",
"startCommand": "sudo systemctl restart worker"
}
],
"tags": ["prod", "web"],
"fingerprintPolicy": "accept-new"
}
}
ssh_host_add 不接受 password、privateKey 或 passphrase 字段;这是刻意设计。添加成功后工具返回 credentialStatus: "missing" 和下一步提示。
allowedPaths:Agent 可读的目录(插件配置的tempDir,默认/tmp,会自动并入)。editablePaths:Agent 可写/上传的目录;留空时除临时目录tempDir外不能写文件。建议只填服务目录,例如["~/app"]。services:给 Agent 的服务目录说明。之后调用ssh_services就能看到api -> ~/app/api、worker -> ~/app/worker,不需要每次ls猜目录。
也可以完全手动添加:Web 侧栏的「SSH 服务器」页面提供同样的字段,并且可以直接输入密码/口令写入本机加密凭据,不需要让 Agent 参与。页面请求只允许本机回环地址。详见下文 5.14。
4.2 在本机录入密码或口令
两种方式,任选其一:
- Web 页面:打开「SSH 服务器」→ 编辑目标服务器 → 在密码/口令字段填写后保存。字段留空表示保持原值;页面还提供「清除已保存密码」。
- 本机终端 CLI(不是 Agent 会话):在你自己的终端运行插件自带 CLI:
cd dsh-plugin-ssh-hosts
# 交互式录入密码(输入不回显)
node scripts/dsh-ssh.mjs set web-1 --password
# 录入带口令私钥的 passphrase
node scripts/dsh-ssh.mjs set web-1 --passphrase
# 适合脚本:从 stdin 读
'my-password' | node scripts/dsh-ssh.mjs set web-1 --password --stdin
# 适合 CI:从环境变量读
$env:MY_SSH_PW = 'my-password'
node scripts/dsh-ssh.mjs set web-1 --password --env MY_SSH_PW
# 查看状态(只显示 available/missing,不显示值)
node scripts/dsh-ssh.mjs status web-1
node scripts/dsh-ssh.mjs list
CLI 默认读取 $DSH_HOME/ssh-hosts;如果插件用了自定义 dataDir:
node scripts/dsh-ssh.mjs set web-1 --password --data-dir "D:\dsh-ssh-data"
密码写入 credentials.json(AES-256-GCM,主密钥 master.key,权限收紧为 0600;Windows 上额外尝试 icacls 收紧 ACL)。hosts.json 里只有 secretRef,没有密码。
4.3 私钥认证
{
"name": "ssh_host_add",
"arguments": {
"alias": "db-1",
"host": "10.0.0.20",
"username": "deploy",
"authType": "key",
"privateKeyPath": "C:\\Users\\me\\.ssh\\id_ed25519",
"fingerprintPolicy": "accept-new"
}
}
- 私钥文件由插件在连接时读取,交给
ssh2,不复制到 DSH_HOME、不写日志、不出现在工具结果。 - 私钥有口令时,再执行
node scripts/dsh-ssh.mjs set db-1 --passphrase。 - 私钥路径本身属于本机配置,不会出现在
ssh_host_list的返回中(只显示privateKeyConfigured: true)。
4.4 env 后端(不落盘)
config:
secretsBackend: env
envPrefix: SSH_HOST_
变量名规则:SSH_HOST_ + 主机 id(去掉 host:、大写、非字母数字转 _)+ _PASSWORD / _PASSPHRASE / _SUDO。例如 host_3f5b97c1 的密码变量是 SSH_HOST_HOST_3F5B97C1_PASSWORD,sudo 密码变量是 SSH_HOST_HOST_3F5B97C1_SUDO。CLI 的 status/list 会显示对应变量名解析状态。
4.5 主机密钥(TOFU)
fingerprintPolicy: "strict"(默认):没有记录指纹时拒绝连接,并返回服务器实际指纹,要求用户确认后把策略改为accept-new再连接。fingerprintPolicy: "accept-new":首次连接信任并记录指纹;以后严格比对。- 指纹不匹配:连接被拒绝,错误里给出旧指纹、新指纹和处理方式;没有“本次忽略”开关,只能由用户改配置或删除重加。
- 指纹保存在
hosts.json的fingerprint字段,格式为SHA256:...(OpenSSH 风格)。
4.6 配置 sudo 提权
提权是按主机、由用户配置的,模型工具既不能打开也不能扩大它(ssh_host_add/ssh_host_update 没有 sudo 字段,ssh_host_update 传相同值也只是空操作)。
先决定用哪种模式:
| 模式 | 服务器要求 | 插件行为 |
|---|---|---|
nopasswd(推荐) |
给登录用户配置免密 sudo,例如 /etc/sudoers.d/dsh-ssh:deploy ALL=(root) NOPASSWD: /usr/bin/systemctl, /usr/bin/journalctl |
每条提权命令用 sudo -n 执行;不需要任何密码;没有 NOPASSWD 时 sudo 立即失败,不会挂起 |
password |
登录用户有普通 sudo 权限即可 | 用 sudo -S -k 执行,密码从本机加密凭据(host:<id>:sudo)取出,只写入 SSH 通道的 stdin;未单独录入时回退到该主机的 SSH 登录密码 |
配置方式(三选一,写的是同一份 hosts.json):
Web「SSH 服务器」页面:编辑主机 → 「提权(sudo)」→ 选择模式、可选目标用户(默认
root)→ 密码模式可同时填写 sudo 密码并保存。本机 CLI:
# 先在 hosts.json 里把该主机的 sudo.mode 设为 password(页面或手工编辑) node scripts/dsh-ssh.mjs set web-1 --sudo # 交互式录入 sudo 密码 'my-sudo-pw' | node scripts/dsh-ssh.mjs set web-1 --sudo --stdin node scripts/dsh-ssh.mjs status web-1 --sudo # 只看 available/missing node scripts/dsh-ssh.mjs delete web-1 --sudo直接编辑
hosts.json(重启 Harness 后生效):{ "id": "host_3f5b97c1", "alias": "web-1", "sudo": { "mode": "password", "user": "root", "passwordRef": "host:host_3f5b97c1:sudo" } }
之后的调用方式(任选其一,效果相同):
{ "name": "ssh_exec", "arguments": { "server": "web-1", "command": "systemctl restart api", "privileged": true } }
{ "name": "ssh_exec", "arguments": { "server": "web-1", "command": "sudo systemctl restart api" } }
细节:
- 前导
sudo会被识别并改写:sudo -u postgres psql的-u会被保留;-i/-s/-e等改变执行语义的参数会被明确拒绝(SUDO_FLAGS_UNSUPPORTED),不会静默丢掉。 - 实际执行的是
cd -- '<cwd>' && sudo <flags> [-u user] -- sh -c '<command>';-H让$HOME指向目标账户。 - 提权命令不受
editablePaths写入守卫限制(否则sudo tee /etc/...、系统包安装都用不了),但deniedPaths(~/.ssh、/etc/shadow、/etc/sudoers等)和strictCommandPaths仍然生效。 - 提权命令始终算高风险:
ask会话仍要逐次批准;「完全权限」会话按已授权执行并标记full-access。 - 密码不会进入命令行、审计、工具结果或调试日志;远程
ps看不到它。
5. 工具参考
所有工具名以 ssh_ 开头。server 参数可传主机 id 或 alias;找不到时会列出当前所有别名。
5.1 ssh_host_list
无参数。返回 { hosts, count, nextStep }。每台主机包含:
id, alias, host, port, username, authType, hasFingerprint, fingerprint, fingerprintPolicy, credentialStatus, privateKeyConfigured, defaultCwd, allowedPaths, editablePaths, tempDir, services, sudo, deniedPaths, readOnly, tags, note, createdAt, updatedAt
其中 sudo 是脱敏视图:{ mode: "off" | "nopasswd" | "password", user, passwordConfigured },永远不含密码。tempDir 是插件配置的临时目录(默认 /tmp,已默认授予读写;null 表示已关闭),allowedPaths/editablePaths 仍是主机记录里的原值,渲染结果会明确列出二者的合并效果。
绝不包含密码、口令、私钥内容或私钥路径。
5.2 ssh_host_add
| 参数 | 必填 | 说明 |
|---|---|---|
alias |
是 | 唯一短名 |
host |
是 | IP / 域名 |
port |
否 | 默认 22 |
username |
是 | 登录用户 |
authType |
是 | password 或 key |
privateKeyPath |
key 时必填 |
本机私钥路径 |
defaultCwd |
否 | 默认远程目录,默认登录 home |
allowedPaths |
否 | Agent 可读的远程根,默认 ["~"] |
editablePaths |
否 | Agent 可写/上传的远程根;默认 [](此时写入仅限插件默认授予读写的临时目录 tempDir,即 /tmp)。建议只填服务目录 |
services |
否 | 服务目录说明数组:name、directory、可选 description/startCommand/logPath/port |
tags / note |
否 | 备注 |
fingerprintPolicy |
否 | strict(默认)或 accept-new |
readOnly |
否 | 只读服务器 |
5.3 ssh_host_update
server + 可改字段:alias、defaultCwd、tags、note、services、authType;安全方向允许 readOnly: true、fingerprintPolicy: "strict"。
以下字段在创建后锁定,工具会以 HOST_IDENTITY_LOCKED / PATH_POLICY_LOCKED / EDIT_POLICY_LOCKED / READ_ONLY_LOCKED / FINGERPRINT_POLICY_LOCKED / KEY_PATH_LOCKED 拒绝:
host、port、username:防止把已保存的密码指向另一台服务器。allowedPaths:防止模型自行扩大可读路径。editablePaths:防止模型自行扩大可编辑目录。privateKeyPath:防止把认证指向其他本机密钥。- 关闭
readOnly、把指纹策略放宽为accept-new。
services 可以随时替换(用于修正服务目录说明);需要修改锁定字段时,由用户编辑 hosts.json(重启生效),或删除主机后重新添加(删除会同时清除凭据)。defaultCwd/note 传空字符串表示清除。
5.4 ssh_host_remove
server。删除主机并同步删除其 host:<id>:* 凭据,其他主机不受影响。
5.5 ssh_services
可选 server(主机 id/alias);省略则列出所有主机。返回:
{
"count": 2,
"services": [
{
"server": "web-1",
"name": "api",
"directory": "~/app/api",
"description": "对外 HTTP API,构建后由 systemd 启动",
"startCommand": "sudo systemctl restart api",
"logPath": "~/app/logs/api.log",
"port": 8080
}
]
}
Agent 在检查、构建、重启或编辑某个服务前应先调用它,避免猜目录。它只读本机清单,不连接服务器。
5.6 ssh_test
server。返回:
{
"server": "web-1",
"reachable": true,
"authOk": true,
"hostKeyFingerprint": "SHA256:...",
"hostKeyStatus": "learned",
"banner": "SSH-2.0-OpenSSH_9.6",
"homeDir": "/home/deploy",
"durationMs": 42,
"error": null
}
失败时 error: { code, message, hint },不会抛异常。
5.7 ssh_exec
| 参数 | 必填 | 说明 |
|---|---|---|
server |
是 | 主机 id / alias |
command |
是 | 单条 shell 命令 |
cwd |
否 | 远程工作目录,必须在 allowedPaths 内 |
timeoutMs |
否 | 覆盖默认超时,受 maxTimeoutMs 限制 |
maxOutputBytes |
否 | 覆盖输出上限,受 16 MiB 限制 |
privileged |
否 | 通过该主机的 sudo 配置以 root(或 sudo.user)执行;主机未配置 sudo 时报 SUDO_NOT_CONFIGURED |
返回:
{
"server": "web-1",
"command": "uname -a && whoami",
"cwd": "/home/deploy",
"exitCode": 0,
"stdout": "...",
"stderr": "",
"durationMs": 37,
"timedOut": false,
"truncated": false,
"originalBytes": 58,
"approval": "not-required",
"privileged": false,
"privilegeMode": null
}
approval 取值:not-required、granted(用户批准一次)、policy-allow(highRiskPolicy: allow)、full-access(「完全权限」会话,审批提示已关闭)。
语义:
- 非零退出码不是工具错误:
execute正常返回,exitCode如实给出;只有 DNS/连接/认证/主机密钥/channel 打开等基础设施故障才抛错。 - 超时:中断 channel,返回已采集输出,
timedOut: true;不自动重试。 - 输出超限:保留首尾各一半,中间插入
... [truncated N bytes] ...,truncated: true,originalBytes是真实总字节数。 - 连接复用:按 host 缓存 Client,空闲
idleTimeoutMs后关闭;并发受maxConcurrentPerHost限制;插件卸载时统一关闭。 - 命令在
cd -- '<cwd>' && <command>包装下执行,因此cwd不合法时表现为命令失败,而不是静默换目录。 - 命令写入守卫:
>/>>/tee/sed -i/perl -pi/cp/mv/rm/touch/mkdir/chmod/chown/dd/rsync/git checkout|reset/包管理器安装等明显会写文件的命令,要求 cwd 和路径参数都落在editablePaths内;否则返回EDIT_PATH_DENIED。临时目录(tempDir,默认/tmp)默认也在editablePaths内,所以过程文件的命令可以直接cwd=/tmp执行。可用guardCommandEdits: false关闭,但这会削弱限制。提权命令不受这一条限制(见 4.6),deniedPaths与strictCommandPaths仍然生效。 - 提权:
privileged: true或命令以sudo开头时,经该主机的 sudo 配置执行;详见 4.6。
5.8 ssh_read_file
server、path、可选 maxBytes。返回 { server, path, content, bytes, originalBytes, truncated }。
- 路径支持绝对路径、
~/...、相对defaultCwd,必须位于allowedPaths内。 - 先做词法校验,再用 SFTP
realpath校验父目录和文件本身,防止符号链接逃逸。 - 文件含 NUL 字节时拒绝(
BINARY_FILE),改用ssh_download。 - 超过
maxTransferBytes拒绝;maxBytes更小时只返回开头并标记truncated。
5.9 ssh_write_file
server、path、content、可选 createDirs、overwrite(默认 true)。父目录不存在时需 createDirs: true。只读服务器、越界路径、editablePaths 之外的目标、命中 deniedPaths 都会拒绝(EDIT_PATH_DENIED)。
过程的临时文件请写到该主机的临时目录(tempDir,默认 /tmp):它在默认配置下可写,无需事先配置 editablePaths;用完请删除。拒绝信息里也会直接给出该目录。
5.10 ssh_upload
server、localPath、remotePath、可选 createDirs、overwrite(默认 false)。
本地路径必须位于:会话工作区、localRoots、或 <dataDir>/transfers。远端路径必须位于 editablePaths 内(临时目录 tempDir 默认也在其中);只读服务器拒绝。
5.11 ssh_download
server、remotePath、localPath、可选 overwrite(默认 false)。本地写入先落到同目录临时文件,成功后再改名,避免半截文件。远端路径只要位于 allowedPaths 即可下载,包括临时目录里 Agent 自己生成的文件。
5.12 临时目录与过程文件(tempDir)
插件默认给每台服务器额外授予一个临时目录的读写权限(tempDir,默认 /tmp),目的是让 Agent 的过程文件有固定去处,不再散落到服务目录、登录 home 或别处:
- 生效方式:
tempDir在每次操作时并入该主机的有效allowedPaths与editablePaths(已存在的父根会自动去重,例如allowedPaths: ["/"]时不会重复追加)。hosts.json里保存的仍是用户配置的原值,模型不能通过ssh_host_update修改它。 - 关闭方式:把插件配置的
tempDir设为空字符串。关闭后完全回到旧行为:没有editablePaths的服务器不可写,写守卫按原规则判定。 - 单机关闭:把
/tmp写进该主机的deniedPaths(hosts.json,重启生效)。deniedPaths始终优先于tempDir。 - 只读服务器(
readOnly: true):仍然拒绝一切写入与ssh_exec,tempDir不会绕过只读。 - 引导:
ssh_exec/ssh_write_file/ssh_upload的工具描述、ssh_host_list的渲染结果、以及EDIT_PATH_DENIED的提示里都会写明该目录,并建议「一个任务一个子目录、结束即rm -f清理」。 - 安全提醒:
/tmp通常对本机所有用户可读,不要把凭据、令牌、私钥写进去;需要隔离时用mkdir -m 700 /tmp/dsh-<任务>,或把tempDir换成例如~/scratch。 - 审计:写入临时目录与其它写入一样记入
audit.log,不会因为「只是临时文件」而跳过。
5.13 Web 面板:SSH 命令历史
不是工具,而是给人看的面板——Agent 在服务器上执行的每条远端操作都会出现在这里,不需要翻聊天记录,也不需要问模型。
- 位置:左侧栏底部、Settings 上方的终端图标按钮(
SSH 命令历史)。点击后从右侧滑出一个抽屉。 - 内容:时间、动作(命令/读文件/写文件/上传/下载/测试连接)、服务器别名、命令或路径、
exit码、耗时、审批结果、是否截断。超过 160 字符的命令点击可展开全文。 - 服务器筛选:下拉框按
hosts.json里的host.host(IP 或域名)归并,一台地址一行;同一 IP 的多条主机记录(不同端口/别名)共用一行,但行内和下拉框都显示当前别名。无论别名改过多少次,旧记录都会显示成当前别名,也不会多出一台服务器。旧版本写入、没有hostId的记录会按地址做一次兼容映射:ssh-10-0-0-10、10-0-0-10、10.0.0.10这类别名如果唯一对应当前清单里的某个 IP,就归并到该地址;其它旧别名保留当时的名字,只出现在「全部服务器」下。 - 主机管理动作(
host_add/host_update/host_remove/凭据变更)仍然写入audit.log,但不进入命令历史,避免改一次别名就多出一条“历史”和一台“服务器”。 - 筛选:服务器、动作类型、只看失败、命令/路径关键字。
- 刷新:面板开着时每 5 秒自动拉取一次,也可点右上角刷新;
Esc或点击遮罩关闭。 - 顶栏避让:抽屉从
--dsh-frame-overlay-top下方开始,Windows/macOS 的原生标题栏与窗口按钮不会被遮住,关闭按钮不会和系统关闭按钮叠在一起。 - 数据来源:
<dataDir>/audit.log(以及轮转后的audit.log.1),只读,不含凭据。config.audit=false时不会产生新记录。 - 实现:Host 侧注册只读端点
GET /ssh-hosts-api/history(参数limit、server、action、failed、q;server可传 IP、当前别名、旧别名或稳定 id),Client 侧在sidebar.footer.action放按钮、在shell.overlay放抽屉。
直接用浏览器查看原始数据:
# Harness 的监听地址见 Web GUI 的 URL,例如 http://127.0.0.1:54030
curl "http://127.0.0.1:54030/ssh-hosts-api/history?limit=20"
curl "http://127.0.0.1:54030/ssh-hosts-api/history?server=web-1&failed=1&q=systemctl"
响应里除 entries 外还有 servers: [{ id, ip, count, aliases }],这是面板的下拉框数据源;servers 按 IP/地址归并,同一地址只出现一次,aliases 是该地址当前的别名列表。
面板需要重启 Harness(或重新加载插件)后才会出现;Host-only 的旧版本没有这个入口。
端点是否挂上,写在
<dataDir>/history-route.json(例如%USERPROFILE%\.dsh\ssh-hosts\history-route.json):ok: true表示已注册,mode是注册路径(direct/inject);ok: false时reason说明原因(waiting= 仍在等 webServer 服务)。
5.14 Web 页面:SSH 服务器管理
手动管理服务器的页面,适合不想让 Agent 代劳、或需要直接改主机身份/路径策略的场景。
- 入口:左侧栏「全局面板」里的
SSH 服务器(与「自动化任务」并列),点击后在主区域打开页面。 - 列表:每台服务器显示
user@host:port、认证方式、凭据状态、只读/指纹策略、可读/可编辑目录、标签、备注、服务目录;可以直接「测试连接」或删除。 - 添加/编辑表单:
- 基础:别名、主机/IP、SSH 端口、登录用户。
- 认证:密码或私钥;可以直接输入密码、私钥口令(只写入本机加密凭据,响应里只返回
credentialStatus),也可以留空稍后再录入。编辑时凭据字段留空表示保持不变。 - 访问策略:默认目录、可读路径、可编辑路径、只读、
strict/accept-new、固定 SHA256 指纹。 - 其他:标签、备注、服务目录(名称/目录/说明/启动命令/日志/端口)。
- 编辑页允许用户修正
host/port/username、allowedPaths、editablePaths、私钥路径等模型工具锁定的字段;修改连接身份时会自动清掉旧的主机指纹,避免用旧指纹校验新端点。模型侧的ssh_host_update仍然受原有锁定规则约束。 - 「提权(sudo)」区块:选择不启用 / NOPASSWD / 密码模式,可填目标用户(默认 root),密码模式可直接填写 sudo 密码(只写入本机加密凭据),已保存时可一键清除;列表页会给启用 sudo 的主机加标记。
- 删除服务器同时删除该主机的本地凭据;所有写操作都写审计(
host_add/host_update/host_remove/credential),但不会把密码或口令写进审计。 - 端点:
/ssh-hosts-ui,包括:GET /ssh-hosts-ui/hostsPOST /ssh-hosts-ui/hostsGET|PATCH|DELETE /ssh-hosts-ui/hosts/:idPUT|DELETE /ssh-hosts-ui/hosts/:id/credentialPOST /ssh-hosts-ui/hosts/:id/testGET /ssh-hosts-ui/health
- 安全边界:只接受回环地址(
127.0.0.1/::1)的请求;写请求必须带x-ssh-hosts-ui: 1头,且Origin必须是本机地址,普通跨站表单/脚本无法直接调用。 - 端点状态写在
<dataDir>/ui-route.json;页面打不开时先看这里的ok/reason,再确认是否需要重启 DSH。
6. 安全模型
6.1 凭据在哪里
| 数据 | 位置 | 模型可见? |
|---|---|---|
| 主机连接信息 | hosts.json |
部分可见(ssh_host_list 脱敏后) |
| 密码 / 私钥口令 | credentials.json(AES-256-GCM) |
否 |
| 主密钥 | master.key(0600) |
否 |
| 私钥内容 | 原文件,只在连接时读入内存 | 否 |
| 审计日志 | audit.log |
通过普通文件工具可能可见,但只含命令/路径,不含凭据 |
- 没有任何工具参数接受密码、口令或私钥内容,因此模型无法“顺手”把凭据写进会话日志。
- 工具返回值、错误、调试日志、审计日志统一经过
redact():已解析的凭据值、PEM 私钥块、password=...、Authorization、URL 中的user:pass@都会被替换。 credentials.json的每个条目以secretRef作为 GCM AAD,密文被换到别的条目会解密失败。
6.2 这不是系统钥匙串
AES-256-GCM + 0600 只防误读、防备份泄露,不防同一用户下运行的其他程序。如果威胁模型包含同用户恶意进程,请改用 secretsBackend: env(由外部 secret manager 注入),或使用操作系统级凭据服务。
6.3 路径限制
- 读取:严格限制在
allowedPaths,且 realpath 后仍须在允许根内;deniedPaths(默认~/.ssh、/etc/shadow、/etc/sudoers、/etc/sudoers.d)始终拒绝。 - 写入/上传:必须同时位于
allowedPaths和editablePaths;editablePaths为空时除临时目录tempDir(默认/tmp)外一律拒绝。写入前会校验最深已存在祖先目录的 realpath,防止通过符号链接把新目录建到允许范围之外。 - 临时目录:
tempDir默认并入每台服务器的allowedPaths与editablePaths,因此默认配置下/tmp可读可写;deniedPaths优先于它,readOnly主机仍然禁止写入。要整体关闭就配置tempDir: "",要单机关闭就把/tmp写进该主机的deniedPaths。 - 命令:
cwd必须落在allowedPaths;deniedPaths中的路径即使出现在命令参数中也会拒绝;strictCommandPaths: true时进一步拒绝所有越界绝对路径参数。 - 命令写入守卫:
>/tee/sed -i/cp/mv/rm/touch/chmod等明显写文件的命令,要求 cwd 和路径参数都在editablePaths内;这是为了防止 Agent 用ssh_exec绕过文件工具。可用guardCommandEdits: false关闭,但不建议。临时目录默认也在editablePaths内,所以cwd=/tmp的过程文件命令可以直接执行。 - 命令是 shell,无法做文件系统级沙箱;路径扫描是保守启发式,不是安全边界。真正的边界是服务器账号权限 + 允许的主机清单。需要强保证时,给 SSH 用户只授予
editablePaths的写权限。 - 主机身份(
host/port/username)、allowedPaths、editablePaths、privateKeyPath和只读/指纹策略在创建后锁定:模型不能通过ssh_host_update把已保存的密码发往另一台服务器、扩大读写范围、指向其他本机密钥,或关闭只读。需要变更时由用户改hosts.json,或在 Web「SSH 服务器」页面编辑(该页面只接受本机回环请求);模型工具仍然拒绝这些字段。
6.4 会话 / 服务器使用授权
插件默认 sessionConsent: ask。模型在一个新会话里第一次访问某台已配置服务器时,会先通过审批通道向用户弹一次授权请求;批准后,该会话对该服务器的后续 ssh_* 调用不再逐次询问。授权范围严格按「会话 + 服务器」隔离:
- 同一会话里访问第二台服务器,会单独再弹一次。
- 新会话(包括子 Agent 自己的会话)对所有服务器都重新申请。
- 授权键使用服务器的稳定
id,不是可改的alias;改别名不会丢失已有授权。 ssh_host_list(查看清单)与ssh_host_add(新增主机)不属于任何一台服务器,是两个独立的授权范围;批准查看不会顺带批准新增,更不会授予任何服务器的访问权限。- 服务器授权覆盖该服务器的全部
ssh_*工具(ssh_exec、ssh_test、文件读写/上传下载、ssh_services、ssh_host_update、ssh_host_remove)。
实现细节与边界:
- 批准结果只保存在当前 Harness 进程内,不落盘。重启 Harness、或会话恢复后进程已重启,都会重新询问。
- 拒绝不会被记住:本次调用失败并提示模型不要自动重试;如果用户随后明确同意,下一次调用会重新弹授权框,不需要新开会话。
- 同一目标并发发起的多个首次调用共享一个授权请求,不会弹出多张卡片。
- 审批服务缺失、调用没有关联会话/Agent、用户取消或超时都失败关闭:不执行任何远程操作。无头/后台组合没有应答者时,插件会一直拒绝;需要无人值守时由用户显式配置
sessionConsent: allow。 sessionConsent: deny会拒绝所有ssh_*工具,且不弹审批。- 「完全权限」会话(审批策略
never)无法弹出审批框:trustFullAccess为true(默认)时,插件直接记一条本进程内的授权并继续,否则这些调用会一直失败关闭。判定方式是读取 Harness 的approval服务(effectivePolicy(session) === 'never')与sandboxPolicy服务(resolve({ session }).mode);任一服务缺失或返回异常都按ask处理。 - 高风险命令审批(6.5)是独立的一层:即使服务器已在会话内获授权,命中危险命令仍要再次逐次批准。
6.5 高风险命令审批
命中以下任一模式时,默认通过 ctx.approval.request() 请求一次性人工批准:
rm -r/-f、mkfs、dd of=/dev/、shutdown/reboot/poweroff/halt、systemctl stop/disable/mask、kill -9、userdel/groupdel、passwd、chmod 777 /、chown -R /、iptables -F/ufw disable/nft flush、truncate -s 0、写 /etc/、crontab -r、docker system prune/volume rm/rm -f、kubectl delete、DROP/TRUNCATE DATABASE/TABLE、curl|sh、sudo、su root、git reset --hard/clean -f、npm publish、terraform destroy/apply、fork 炸弹。
highRiskPolicy: ask:审批服务缺失、无 Agent、用户拒绝、取消、超时都失败关闭。highRiskPolicy: deny:直接拒绝。highRiskPolicy: allow:不询问,但仍写入审计并在结果里标记approval: "policy-allow"。- 「完全权限」会话(审批
never+danger-full-access):不询问,标记approval: "full-access",审计里同样记录。若会话是never但文件策略仍是只读/工作区写入,高风险命令会以APPROVAL_POLICY_DENIED失败关闭,并提示切换到「完全权限」。 - 把
trustFullAccess设为false可让「完全权限」会话也按失败关闭处理。 - 审批文案只包含脱敏后的命令和风险原因,不包含凭据。
6.6 主机密钥
TOFU + 后续严格比对;不提供“本次忽略”开关。指纹变更必须由用户显式处理。
6.7 提权(sudo)的边界
- 提权策略写在该主机的
sudo字段里,只能由用户通过 Web 页面、CLI 或手工编辑hosts.json配置;模型工具无法打开或扩大它。 - 密码模式的 sudo 密码存放在本机加密凭据里(
host:<id>:sudo),读取后只写入该次 SSH 通道的 stdin,随后立刻关闭 stdin;不进入命令行、审计、工具结果、错误或调试日志。 - 密码模式用
sudo -S -k -p "",因此每次提权都会重新认证,不会误用 sudo 的时间戳缓存;如果服务器实际配置了 NOPASSWD,请把模式改成nopasswd,否则密码行会进入命令的 stdin(虽然不会被回显)。 - 需要 TTY 的 sudo 场景(
Defaults requiretty,多见于很老的 RHEL/CentOS)不支持:插件没有分配 PTY。请改用 NOPASSWD,或在该主机上关闭requiretty。 - 提权命令会命中
sudo风险规则,因此仍受 6.5 的审批逻辑约束;审计中的privileged: true表明这次是以提权身份执行的。
7. 错误码
工具失败时,模型看到的是 message(hint)[CODE]:CODE 是稳定机器码,括号里的 hint 是可执行建议(例如写路径被拒时会直接指出可以改用临时目录 tempDir)。Web 管理页的接口则把 message 与 hint 分成两个字段返回。
| code | 含义 | 常见处理 |
|---|---|---|
HOST_NOT_FOUND |
id/alias 不存在 | 先 ssh_host_list |
HOST_ALIAS_EXISTS |
别名冲突 | 换别名 |
CREDENTIAL_MISSING |
密码/私钥不可用 | 本机 CLI 录入 |
SECRET_DECRYPT_FAILED |
主密钥与密文不匹配 | 重新录入凭据 |
SECRET_BACKEND_NONE |
后端为 none | 改 file/env |
DNS_FAILED |
域名解析失败 | 检查地址/DNS |
CONNECTION_REFUSED |
端口拒绝 | 检查 sshd/防火墙 |
CONNECT_TIMEOUT |
连接/握手超时 | 检查网络/负载 |
AUTH_FAILED |
认证失败 | 核对用户名与凭据 |
HOST_KEY_UNKNOWN |
首次连接但策略 strict | 用户确认指纹后改 accept-new |
HOST_KEY_MISMATCH |
主机密钥变化 | 核对旧/新指纹;确认重装后再处理 |
CHANNEL_OPEN_FAILED |
无法开 exec 通道 | 服务器会话数/权限限制 |
SFTP_UNAVAILABLE |
未启用 SFTP | 启用子系统,或改用 ssh_exec |
REMOTE_PATH_DENIED |
路径越界/被禁止 | 调整 allowedPaths/deniedPaths |
EDIT_PATH_DENIED |
写入/写命令不在 editablePaths 内,或未配置 |
用 ssh_write_file/ssh_upload,或由用户配置 editablePaths |
EDIT_POLICY_LOCKED |
试图通过更新扩大 editablePaths |
由用户编辑 hosts.json |
INVALID_SERVICE |
服务目录说明格式错误/重名 | 检查 services 的 name/directory |
COMMAND_PATH_DENIED |
命令参数命中禁止路径/严格模式越界 | 换路径或调整策略 |
FILE_TOO_LARGE |
超过传输/读写上限 | 调整 maxTransferBytes |
BINARY_FILE |
读到二进制 | 改用 ssh_download |
READ_ONLY |
只读服务器 | 关闭 readOnly |
APPROVAL_DENIED |
高风险命令未获批 | 询问用户或改策略 |
APPROVAL_UNAVAILABLE |
审批服务/Agent 不可用 | 启用审批组件 |
APPROVAL_POLICY_DENIED |
会话不弹审批(never),且不是完全权限或 trustFullAccess=false |
切换到「完全权限」,或改 highRiskPolicy/trustFullAccess |
SUDO_NOT_CONFIGURED |
该主机没有配置 sudo | 在 Web 页面或 hosts.json 启用 sudo(NOPASSWD 或密码模式) |
SUDO_PASSWORD_MISSING |
密码模式但没有可用的 sudo 密码 | scripts/dsh-ssh.mjs set <alias> --sudo,或在 Web 页面填写 |
SUDO_USER_LOCKED |
命令请求的 sudo -u 与主机固定用户不一致 |
改用 privileged: true,或由用户调整 sudo.user |
SUDO_FLAGS_UNSUPPORTED |
命令里的 sudo 参数(-i/-s/-e 等)无法复现 |
去掉这些参数,改用 privileged: true |
SUDO_POLICY_LOCKED |
试图用模型工具修改 sudo 策略 | 由用户在 Web 页面或 hosts.json 中配置 |
INVALID_SUDO |
sudo 配置格式错误(模式、用户、密码组合) | 只填 mode=off/nopasswd/password,用户名为合法账户名 |
SESSION_CONSENT_DENIED |
用户拒绝、sessionConsent: deny,或本次未获服务器/清单授权 |
由用户批准该服务器/清单;确认同意后可重试 |
SESSION_CONSENT_UNAVAILABLE |
本次调用没有可用的审批通道,失败关闭 | 在带审批界面的会话中重试,或由用户改 sessionConsent |
SESSION_CONSENT_CANCELLED |
授权请求被取消 | 在用户可交互时重试 |
ABORTED |
调用被取消 | 重试 |
错误消息统一带 [CODE],并在有建议时附带中文 hint。
8. 已知限制
- 不支持跳板机/ProxyJump;如需,请在本机用端口转发或系统 SSH config 另行解决。
- 不支持交互式全屏程序(vim、top、tmux attach 等);
ssh_exec是 exec channel,不是 PTY 会话。 - 命令超时/取消会销毁 channel,但不保证远端派生进程已退出(shell 可能继续运行子进程)。
- 不支持批量并行命令编排;需要时由 Agent 多次调用。
readOnly: true会拒绝所有ssh_exec(因为无法可靠判断命令是否只读);只保留读文件/下载。- 高风险识别是正则启发式,混淆命令可能绕过;审批不是沙箱。
strictCommandPaths默认关闭,因为 shell 命令经常引用/var/log、/etc等系统路径;文件工具始终严格。editablePaths由插件强制;guardCommandEdits只是对常见写命令的启发式拦截,不能阻止脚本、变量拼接等刻意绕过。需要硬保证时,给远端 SSH 用户只授予目标目录的写权限。services是用户/Agent 维护的说明,插件不会自动核对目录是否真实存在;目录变更后需要用ssh_host_update更新说明。editablePaths默认为空:新主机默认只能读/下载,额外只有临时目录tempDir(默认/tmp)可写——这是为了不让 Agent 把过程文件丢进服务目录,同时不开放业务目录。要编辑业务文件仍须由用户配置editablePaths。- 临时目录不是「安全区」:
/tmp通常对本机所有用户可读,且不做配额/清理;插件只负责授权与提示,不负责定期清理。需要更强隔离时把tempDir指到~/scratch,或在主机上为该目录加 tmpfiles.d/systemd-tmpfiles 清理策略。 - 手工编辑
hosts.json后需要重启 Harness(或重新加载插件)才生效;插件进程内缓存清单。 - Windows 上
chmod 0600语义有限,ACL 收紧是 best-effort(icacls失败不阻断操作)。 - 主机管理既可以通过 Agent 工具完成,也可以在 Web「SSH 服务器」页面手动完成;页面与工具写的是同一份
hosts.json,两边不要同时编辑。 - 命令历史面板只展示审计里的元数据(命令/路径/退出码/耗时),不保存命令输出;输出仍在会话里。
/ssh-hosts-api/history与其它 Web 路由同源、只读;/ssh-hosts-ui的写接口只接受本机回环请求并要求x-ssh-hosts-ui: 1头。两者都只适合本机可信环境;暴露到局域网前请确认audit.log内容可以给该网络的人看到。- 命令历史里的服务器下拉框按
host.host(IP/地址)归并,显示当前别名;同一 IP 的不同端口/别名共用一行。旧版本写入、没有hostId的记录无法总是自动映射:别名如果是ssh-<IP>、<IP 用 - 连接>或<IP>且唯一对应当前主机,会归并到该地址;其它旧别名保留当时的名字,只出现在「全部服务器」下。 ssh_upload/ssh_download的本地根默认只有会话工作区、localRoots和<dataDir>/transfers;不要为了省事把C:\加进localRoots。- 会话/服务器授权只保存在 Harness 进程内:重启进程会重新询问;插件不做「永久记住这台服务器」的授权存储。多开一个会话、或子 Agent 新建会话,都会重新申请。
- 服务器授权是「会话 + 服务器」粒度,不是「会话 + 工具」粒度:批准某台服务器后,本会话对该服务器的
ssh_host_update/ssh_host_remove也不再逐次询问。如需更细粒度,请用sessionConsent: deny或直接不把该服务器加入清单。 ssh_host_list/ssh_host_add不针对某一台服务器,因此只能做「清单一览」和「清单新增」两个会话级授权;这与服务器访问授权相互独立。- 提权是「按主机」而不是「按命令」授权的:一旦某台主机配置了 sudo,
privileged: true就能以 root 执行任意命令(readOnly/deniedPaths之外没有进一步限制)。不想要这种能力就不要配置sudo,或只给sudoers授权具体命令。 - 密码模式的 sudo 依赖 stdin 传密码,因此该次命令的 stdin 会在写入密码后立即关闭;需要交互输入的命令本来就不支持。
- sudo 探测/执行没有为“密码错误”单独分类:密码错误时表现为 sudo 以非零退出码结束、stderr 里有 sudo 的提示,
ssh_exec仍按普通命令失败返回(退出码非 0 不是工具异常)。
9. 兼容性说明(按实际 API 核对)
开发和验证基于 DSH 0.2.1-rc.2 的实际安装包,而不是凭记忆:
- 插件入口采用命名导出:
export const name、export const inject = ['tools']、export const Config、export function apply(ctx, config);没有export default。 - 工具用
@deepseek-ai/dsh-tools的defineTool({ name, description, parameters, output, execute, presentCall, presentResult });parameters使用其 schema DSL,输出 schema 的必填字段用属性上的required: true(不是顶层required数组)。 - 高风险审批调用
ctx.approval.request()(ctx.get('approval')可选获取),返回'allowed-once' | 'rejected' | 'cancelled' | 'unavailable';只有allowed-once放行。 - 会话权限预设从两个可选服务读取:
ctx.get('approval').effectivePolicy(session)(ask/never)与ctx.get('sandboxPolicy').resolve({ session }).mode(read-only/workspace-write/danger-full-access)。任一服务缺失、方法不存在或抛错都退化为“未知”,按旧的ask行为处理,不会因为 API 变化而自动放宽权限。 - 提权执行在
ssh_exec内完成:sudo -n -H -p ""(NOPASSWD)或sudo -S -k -H -p ""(密码模式)+sh -c '<command>',密码只写入 SSH channel 的 stdin。sudo本身不是 PTY 会话,因此不支持需要 TTY 的 sudo 配置。 - 会话/服务器授权复用同一审批 seam:从
exec.agent.session.id取会话身份,从ssh_*参数里的server经HostStore.find()解析成稳定主机id,再按session:<id>|server:<host id>记住本次进程内的批准;审批服务本身只支持一次性授权,所以「本会话后续不再问」是插件自己的内存缓存,不落盘。 ToolDefinition.timeoutMs在该版本是声明式的,注册表不负责超时;因此ssh_exec自己实现定时器、channel 销毁和部分输出保留。ssh2是 CommonJS 包;插件用默认导入再解构Client,避免 ESM 命名导入在部分 Node 版本下失败。- 插件以 bundle 形式安装:
package.json的dsh.bundle.patch指向cordis.patch.yml,安装后 profile 的--dump-config中会出现ssh-hosts行。 - 依赖策略:
ssh2与@deepseek-ai/schemastery是普通依赖;@deepseek-ai/dsh-tools、@deepseek-ai/cordis是 peerDependency,由 Harness 安装提供的运行时解析映射加载,避免重复打包 Harness 包。 - Web 半边不参与
tsc构建:client/index.js是手写 ESM,通过window.__ModuleLoader__.load注册,由package.json的dsh.client(platform: "web"、immediately: true)声明,并用ctx.slots.inject挂到sidebar.footer.action、shell.overlay、sidebar.panellist与main。 - Web 数据由 Host 侧注册的两个前缀路由提供:只读的
/ssh-hosts-api(历史)和本机写接口/ssh-hosts-ui(服务器管理),都通过ctx.get('webServer').register({ kind: 'prefix', ... })挂载,并等待webServer服务就绪;没有 webServer 的组合(headless profile)自动跳过,不影响工具。 - Web 抽屉/面板使用宿主 CSS 变量(
--dsw-alias-*、--dsh-frame-overlay-top)适配深浅色和 Windows/macOS 标题栏,不修改宿主代码。
如果升级 DSH 后出现 defineTool/审批 API 变化,优先以 dsh --profile <name> --dump-config-schema 和安装目录下的 lib/types/*.d.ts 为准,并相应调整 src/dsh.d.ts。
从旧版数据升级:旧 hosts.json 没有 editablePaths 字段时会按空数组读取,即升级后默认禁止写入/上传;services 也会为空。请由用户编辑 hosts.json 补充 editablePaths 与 services,或删除主机后重新添加。
10. 目录结构
dsh-plugin-ssh-hosts/
├── package.json # ESM、dsh.bundle.patch、engines.dsh、peerDeps
├── cordis.patch.yml # - insert: [{ id: ssh-hosts, name, config }]
├── tsconfig.json # 构建 src -> lib
├── tsconfig.test.json # 测试 -> dist-test
├── locale/en.json, zh.json # 插件管理器显示元数据
├── icon.svg # 插件图标
├── src/
│ ├── index.ts # name / inject / Config / apply
│ ├── config.ts # Schemastery 配置与默认值
│ ├── hosts.ts # hosts.json 校验、原子写、脱敏视图
│ ├── secrets.ts # AES-256-GCM 凭据存储 + env 后端
│ ├── redact.ts # 统一脱敏出口
│ ├── ssh.ts # 连接池、exec、SFTP、超时、错误分类
│ ├── paths.ts # POSIX 路径展开 / 读写白名单 / 命令路径扫描
│ ├── edits.ts # 写命令识别与 editablePaths 守卫
│ ├── risk.ts # 高风险命令分类
│ ├── output.ts # 首尾保留截断
│ ├── audit.ts # JSONL 审计
│ ├── history.ts # 审计读取 + /ssh-hosts-api/history 只读端点
│ ├── ui-api.ts # /ssh-hosts-ui 服务器管理写接口(仅回环)
│ ├── web.ts # 前缀路由注册、JSON/CSRF、请求体读取
│ ├── approval.ts # 审批策略
│ ├── session-policy.ts # 会话权限预设(approval never / sandbox mode)探测
│ ├── session-consent.ts # 会话 + 服务器 / 清单的按目标授权缓存
│ ├── sudo.ts # 前导 sudo 解析、sudo 策略解析与密码读取
│ ├── local.ts # 本地传输路径限制
│ ├── deps.ts # 工具依赖接口
│ └── tools/ # host / exec / files 三组工具
├── client/
│ └── index.js # Web 半边:命令历史抽屉 + 「SSH 服务器」管理页面
├── tests/ # node:test 单测 + ssh2 Server 集成测试 + E2E harness
├── scripts/dsh-ssh.mjs # 本机凭据录入 CLI
└── README.md
11. 开发与验证
npm run build
npm run typecheck
npm test
# 单独跑 SSH 集成测试(本机随机端口起 ssh2 Server)
npm run test:integration
11.1 单元/集成测试覆盖
hosts.json读写、别名冲突、端口/名称校验、损坏 JSON 报错、私钥路径不出现在视图。- AES-256-GCM 往返、密文不含明文、篡改检测、错误主密钥、env/none 后端、文件权限。
- 脱敏:注册的凭据值、PEM、
password=、URL 凭据。 - 截断:首尾保留、原始字节数、marker。
- 路径:
~/相对/绝对、读路径越界、editablePaths写入限制、denied、strict 命令路径、shell 引号。 - 临时目录:
tempDir默认值与关闭、~/...展开、已存在父根去重、并入后/tmp可读写、关闭后恢复原策略、deniedPaths优先、拒绝提示里带上临时目录。 - 写命令守卫:重定向/
sed -i/cp/rm/touch识别、2>/dev/null不误报、cwd 与路径检查。 - 服务说明:服务名/目录校验、重名拒绝、
ssh_services返回与渲染。 - 风险识别:危险命令命中、普通命令不命中、引号内文本不误报。
- 真实 SSH:strict/accept-new、指纹不匹配、密码错误、私钥登录、stdout/stderr、非零退出码、超时、大输出截断、连接复用、取消。
- 会话授权:同一会话同一服务器只问一次、同会话第二台服务器重新问、新会话重新问、拒绝后显式重试会重新问、未知服务器不弹授权、清单查看/新增各自独立、
allow/deny策略。 - 会话权限预设:
never+danger-full-access判为完全权限、never+受限沙箱只关闭提示、ask不自动放行、服务缺失/抛错回退为未知、完全权限下授权与高风险命令自动放行、trustFullAccess=false恢复失败关闭。 - 提权(sudo):前导
sudo解析(-u/--user、可忽略参数、保留引号与&&)、不支持的 sudo 参数报错、命令包装与引号、NOPASSWD 不带密码、密码模式经 stdin 送达、登录密码回退、目标用户锁定、密码缺失与未配置报错、hosts.json的 sudo 校验与模型锁定、管理 API 的 sudo 策略/密码保存与清除。 - E2E harness:11 个工具全部注册;真实 SSH+SFTP 下跑通 list/services/add/test/exec/审批/提权(NOPASSWD)/完全权限免审批/写读/上传下载/写路径拒绝/命令写守卫/只读/审计。
11.2 端到端验证步骤
用一个独立 profile(示例名 ssh-e2e,从 headless 模板创建)验证完整链路:
dsh --profile ssh-e2e --dump-config # 应能看到 ssh-hosts 行
dsh --profile ssh-e2e --dump-config-schema # diagnostics 为空表示 schema 可读
再用本地 OpenAI 兼容 mock 模型驱动一次 ssh_exec 工具调用,工具注册、真实 SSH 执行和结果回传都走真实 Harness 路径(headless profile 没有桌面账户登录态,因此用 mock 模型替代模型层):
# 终端 A:真实 SSH + SFTP 测试服务
node tests/fixtures/ssh-server.mjs
# 终端 B:OpenAI 兼容 mock 模型(只用于驱动一次工具调用)
node tests/fixtures/mock-llm.mjs
# 终端 C:准备数据并跑一轮
node tests/fixtures/seed-host.mjs ".ssh-e2e-data" dev-1 22222
$env:MOCK_API_KEY = 'mock'
$env:DSH_SSH_HOSTS_DATA_DIR = '.ssh-e2e-data'
dsh --profile ssh-e2e --patch "tests\fixtures\ssh-dev-mock-overlay.yml" "Run the SSH command on dev-1."
ssh-dev-mock-overlay.yml 插入 @deepseek-ai/dsh-llm-pi-ai 的 mock 路由并把默认模型指向它;不修改 profile 文件本身。验证结束后可删除 .ssh-e2e-data。
11.3 只做工具层 E2E(不需要模型)
# 终端 A:启动本地 SSH 测试服务(内存文件系统,支持 exec + SFTP)
node tests/fixtures/ssh-server.mjs
# 终端 B:注册全部工具并真实执行
node --import ./tests/harness/register.mjs tests/harness/e2e.mjs
harness 使用一个 defineTool 直通桩(真实 defineTool 只做校验并返回同一对象),真实的 Harness 导入路径已由 --dump-config-schema 覆盖。harness 会打印注册工具列表、exec 结果、审批次数、文件传输结果和审计行数。
12. 参考
- DSH 插件/工具文档:https://deepseek-harness.github.io/deepseek-harness/develop/basic/tool.md
- 工具契约与 cookbook:https://deepseek-harness.github.io/deepseek-harness/reference/cookbook/adding-a-tool.md
- 打包与安装:https://deepseek-harness.github.io/deepseek-harness/develop/basic/publish.md
- 配置 schema:https://deepseek-harness.github.io/deepseek-harness/develop/basic/config.md
ssh2:https://github.com/mscdex/ssh2
许可证:MIT。
No comments yet. Be the first to write one.