feat(pi-ssh): add reviewed agent connection flow

This commit is contained in:
云服务部-叶林立
2026-08-21 20:52:50 +08:00
parent 0ac50eb581
commit a7891f18bf
20 changed files with 208 additions and 203 deletions
+5 -4
View File
@@ -8,7 +8,7 @@
- 上游来源:<https://github.com/MasuRii/pi-rtk-optimizer> - 上游来源:<https://github.com/MasuRii/pi-rtk-optimizer>
- 初始导入快照:`d155d253cb2f1358e34e717d47a82ebccb08cb8e`2026-07-03`0.9.0`)。 - 初始导入快照:`d155d253cb2f1358e34e717d47a82ebccb08cb8e`2026-07-03`0.9.0`)。
- 该目录已纳入本仓库直接维护,不是 submodule,也不保留嵌套 `.git` - 该目录已纳入本仓库直接维护,不是 submodule,也不保留嵌套 `.git`
- `pi-ssh/`:从上游源码导入并由根组合包加载的纯 `ssh2` 远程操作扩展,使用持久连接、SFTP 与有界自适应搜索提供独立的 `ssh_read``ssh_write``ssh_edit``ssh_find``ssh_grep``ssh_bash` 工具;运行时不调用 OpenSSH 或 `sshpass` - `pi-ssh/`:从上游源码导入并由根组合包加载的纯 `ssh2` 远程操作扩展,通过受权限链复核的 `ssh_connect` 建立 Agent 控制的持久连接,并以 SFTP 与有界自适应搜索提供独立的 `ssh_read``ssh_write``ssh_edit``ssh_find``ssh_grep``ssh_bash` 工具;运行时不调用 OpenSSH 或 `sshpass`
- 上游来源:<https://github.com/pansapiens/pi-ssh> - 上游来源:<https://github.com/pansapiens/pi-ssh>
- 初始导入快照:`e9a1059a0f37ab14b6a73ee608cb203edf803f31`2026-06-23`0.7.0`)。 - 初始导入快照:`e9a1059a0f37ab14b6a73ee608cb203edf803f31`2026-06-23`0.7.0`)。
- 该目录已纳入本仓库直接维护,不是 submodule,也不保留嵌套 `.git``node_modules` 或构建产物;纯 `ssh2` 设计参考 `99percentpeople/pi-extensions` 的明确 commit,来源记录保留在 `pi-ssh/UPSTREAM.md` - 该目录已纳入本仓库直接维护,不是 submodule,也不保留嵌套 `.git``node_modules` 或构建产物;纯 `ssh2` 设计参考 `99percentpeople/pi-extensions` 的明确 commit,来源记录保留在 `pi-ssh/UPSTREAM.md`
@@ -77,7 +77,7 @@
- `pi-condense` 负责压缩已经进入会话的历史工具结果,与 Context Mode 的输入隔离职责互补;`extensions/condense.ts` 只在 `settings.json` 尚无 `contextPrune.enabled` 时写入 `true`,必须保留用户显式设置的 `false`,配置无效时不得覆盖原文件。其他参数沿用上游默认,包括 `agent-message` 触发模式和 skill 路径保护。 - `pi-condense` 负责压缩已经进入会话的历史工具结果,与 Context Mode 的输入隔离职责互补;`extensions/condense.ts` 只在 `settings.json` 尚无 `contextPrune.enabled` 时写入 `true`,必须保留用户显式设置的 `false`,配置无效时不得覆盖原文件。其他参数沿用上游默认,包括 `agent-message` 触发模式和 skill 路径保护。
- Codex fast mode 只为符合条件的 `openai-codex-responses` 请求设置 priority service tier。`/fast-mode on|off` 同时更新当前分支记录与 Pi agent 目录中的 owner-only 全局默认值;新会话继承全局值,已有分支记录优先,配置缺失或无效时回退为关闭。 - Codex fast mode 只为符合条件的 `openai-codex-responses` 请求设置 priority service tier。`/fast-mode on|off` 同时更新当前分支记录与 Pi agent 目录中的 owner-only 全局默认值;新会话继承全局值,已有分支记录优先,配置缺失或无效时回退为关闭。
- 权限策略默认允许常规工具,允许 FFF 工具;拒绝 Bash 直搜和敏感凭据路径;Git 非只读操作、包管理、外部目录、文件/系统/网络高风险操作与普通 MCP 调用先由 `pi-permission-system` 判为 `ask` - 权限策略默认允许常规工具,允许 FFF 工具;拒绝 Bash 直搜和敏感凭据路径;Git 非只读操作、包管理、外部目录、文件/系统/网络高风险操作与普通 MCP 调用先由 `pi-permission-system` 判为 `ask`
- SSH 权限沿用同一条 `pi-permission-system` gate 与 `auto-review` authorizer chain`ssh_read``ssh_write``ssh_edit` 默认 `ask``ssh_bash` 通过 `shellTools` 映射到完整 Bash 策略并设置 `decisionFloor: "ask"`,把普通 Bash `allow` 提升为复核请求,同时保留原有 `ask` 与硬 `deny`。远端路径不得送入基于本机 cwd 的 `path` / `external_directory` 归一化,授权证据必须显示当前 SSH target、port 与远端 cwd - SSH 权限沿用同一条 `pi-permission-system` gate 与 `auto-review` authorizer chain`ssh_connect``ssh_read``ssh_write``ssh_edit``ssh_find``ssh_grep` 默认 `ask`;连接前的证据解析已导入 host ID 为非秘密 target、port 与请求/default cwdAutoReview 只可依据用户直接消息中的明确目标授权连接。`ssh_bash` 通过 `shellTools` 映射到完整 Bash 策略并设置 `decisionFloor: "ask"`,把普通 Bash `allow` 提升为复核请求,同时保留原有 `ask` 与硬 `deny`。远端路径不得送入基于本机 cwd 的 `path` / `external_directory` 归一化。
- `pi-permission-auto-review` 不是独立 `tool_call` gate,而是 `pi-permission-system` authorizer chain 中名为 `auto-review` 的链路;只复核权限基线产生的 `ask`,不会重复处理已 `allow` 或已 `deny` 的请求。 - `pi-permission-auto-review` 不是独立 `tool_call` gate,而是 `pi-permission-system` authorizer chain 中名为 `auto-review` 的链路;只复核权限基线产生的 `ask`,不会重复处理已 `allow` 或已 `deny` 的请求。
- reviewer 返回 `allow` 时自动批准、返回 `deny` 时直接拒绝,配置、模型、认证、超时或响应异常时必须 `defer` 到正常人工提示。`pi-permission-system` 的 delegation envelope 继续禁止 authorizer 自动批准全部 `path` 请求;`external_directory` 对内置只读路径工具(`read``find``grep``ls`)接受 reviewer 的 `allow`,写入、编辑、Bash、未知工具和其他外部访问仍转人工。 - reviewer 返回 `allow` 时自动批准、返回 `deny` 时直接拒绝,配置、模型、认证、超时或响应异常时必须 `defer` 到正常人工提示。`pi-permission-system` 的 delegation envelope 继续禁止 authorizer 自动批准全部 `path` 请求;`external_directory` 对内置只读路径工具(`read``find``grep``ls`)接受 reviewer 的 `allow`,写入、编辑、Bash、未知工具和其他外部访问仍转人工。
- 默认 reviewer 为 `openai-codex/codex-auto-review`、low reasoning、90 秒总重试预算和内置 Codex Guardian 风格策略;只把 active branch 中的直接用户消息与已识别结构化问答作为授权证据,assistant/tool/compaction 内容不能自行授权。 - 默认 reviewer 为 `openai-codex/codex-auto-review`、low reasoning、90 秒总重试预算和内置 Codex Guardian 风格策略;只把 active branch 中的直接用户消息与已识别结构化问答作为授权证据,assistant/tool/compaction 内容不能自行授权。
@@ -168,12 +168,13 @@
- 根包必须直接固定安装 `ssh2@1.17.0` 与配置 CLI 所需的 `jiti@2.7.0`,因为 packed bundle 直接加载其内置 `pi-ssh/` 源码;`ssh2``cpu-features` 的 install scripts 只构建可选加速绑定,当前不得加入根 `allowScripts`,纯 JavaScript fallback 必须可运行。 - 根包必须直接固定安装 `ssh2@1.17.0` 与配置 CLI 所需的 `jiti@2.7.0`,因为 packed bundle 直接加载其内置 `pi-ssh/` 源码;`ssh2``cpu-features` 的 install scripts 只构建可选加速绑定,当前不得加入根 `allowScripts`,纯 JavaScript fallback 必须可运行。
- SSH vault 使用同目录独立随机 key 和 AES-256-GCM 整体加密,目录/文件在 POSIX 上必须保持 `700` / `600`;该设计只防止误看或单独泄漏密文,不防同一用户读取 key。密码、私钥 passphrase、私钥内容、vault key 与解密明文不得进入命令参数、日志、Pi session、权限证据或明文临时文件。 - SSH vault 使用同目录独立随机 key 和 AES-256-GCM 整体加密,目录/文件在 POSIX 上必须保持 `700` / `600`;该设计只防止误看或单独泄漏密文,不防同一用户读取 key。密码、私钥 passphrase、私钥内容、vault key 与解密明文不得进入命令参数、日志、Pi session、权限证据或明文临时文件。
- 已导入主机必须固定 SHA256 Host Key;不匹配时 fail closed。私钥只保存路径,不复制内容;文件工具使用 SFTP,写入优先临时文件与原子 rename,远端断线不得自动重放命令。 - 已导入主机必须固定 SHA256 Host Key;不匹配时 fail closed。私钥只保存路径,不复制内容;文件工具使用 SFTP,写入优先临时文件与原子 rename,远端断线不得自动重放命令。
- `ssh_connect` 是唯一运行时连接入口,只接受 vault 中已导入的 host ID 与可选绝对/`~/` remote cwd,并必须默认 `ask` 后进入 AutoReview;不得注册 `/ssh``--ssh`、session resume 自动重连或把用户 `!` 命令切换到远端。用户未在直接请求中明确服务器和具体远端任务时不得推断、替换或连接主机;新连接替换旧连接,会话结束自动断开。
- `ssh_find` / `ssh_grep` 必须在已授权工具调用内部按 `fd/fdfind → git ls-files → find``rg → git grep → find+grep` 顺序检测服务器现有能力,不安装或上传远端二进制;输入必须 shell-safe,结果数量/单行/总捕获必须有界并显式报告 backend 与 truncated。RTK 不得处理这两个搜索工具的结果。 - `ssh_find` / `ssh_grep` 必须在已授权工具调用内部按 `fd/fdfind → git ls-files → find``rg → git grep → find+grep` 顺序检测服务器现有能力,不安装或上传远端二进制;输入必须 shell-safe,结果数量/单行/总捕获必须有界并显式报告 backend 与 truncated。RTK 不得处理这两个搜索工具的结果。
- `ssh_read``ssh_write``ssh_edit``ssh_find``ssh_grep` 的远端路径不能按本机路径执行 `path` / `external_directory` gate;必须通过公共 `PermissionsService` 注册显式 extractor 关闭默认 `input.path` 推断,并保持五个工具的默认策略为 `ask` - `ssh_read``ssh_write``ssh_edit``ssh_find``ssh_grep` 的远端路径不能按本机路径执行 `path` / `external_directory` gate;必须通过公共 `PermissionsService` 注册显式 extractor 关闭默认 `input.path` 推断,并保持五个工具的默认策略为 `ask`
- `ssh_bash` 必须在权威权限配置的 `shellTools` 中映射 `commandArgument: "command"` 并设置 `decisionFloor: "ask"`,复用 Bash 命令拆分、硬拒绝、风险 `ask` 和 authorizer chain;全局 floor 必须在项目配置字段级合并时保留,不得新增第二个并行 `tool_call` 审批层。 - `ssh_bash` 必须在权威权限配置的 `shellTools` 中映射 `commandArgument: "command"` 并设置 `decisionFloor: "ask"`,复用 Bash 命令拆分、硬拒绝、风险 `ask` 和 authorizer chain;全局 floor 必须在项目配置字段级合并时保留,不得新增第二个并行 `tool_call` 审批层。
- RTK 只把 `ssh_bash` 当作 Bash 输出别名执行 ANSI 清理、测试/构建/Git/Lint 聚合与兜底截断;不得对远端命令启用 RTK rewrite,也不得处理 `ssh_read``ssh_find``ssh_grep` 返回。 - RTK 只把 `ssh_bash` 当作 Bash 输出别名执行 ANSI 清理、测试/构建/Git/Lint 聚合与兜底截断;不得对远端命令启用 RTK rewrite,也不得处理 `ssh_read``ssh_find``ssh_grep` 返回。
- SSH 工具的权限预览必须包含当前远端 host id、target、port、remote cwd 与有界操作摘要;远端 shell 的完整命令继续由 Bash payload 单独提供。权限服务缺失或注册失败时必须 fail-safe,不得转为无提示自动允许。 - SSH 工具的权限预览必须包含连接请求或当前远端 host id、target、port、remote cwd 与有界操作摘要;连接预览只解密本地 vault 以提取非秘密目标字段,绝不包含凭据,远端 shell 的完整命令继续由 Bash payload 单独提供。权限服务缺失或注册失败时必须 fail-safe,不得转为无提示自动允许。
- `pi-ssh` 只用于从本地项目显式操作服务器,不得自动探测、读取或向系统提示词注入远端 `AGENTS.md``CLAUDE.md` 或其他项目说明;远端内容只能由明确的 `ssh_*` 工具调用或用户命令获取。 - `pi-ssh` 只用于从本地项目显式操作服务器,不得自动探测、读取或向系统提示词注入远端 `AGENTS.md``CLAUDE.md` 或其他项目说明;远端内容只能由明确且已授权`ssh_*` 工具调用获取。
- 修改 SSH transport、vault、导入、权限集成或加载入口后至少运行 `pi-ssh` 包内 `npm test`、根脚本语法与 mock 配置测试、`pi-permission-system``npm run typecheck` / `npm run test` / `npm run build`、根扩展联合加载和实际 packed tarball 隔离安装验证。未经明确要求不得连接真实服务器或把真实密码写入测试。 - 修改 SSH transport、vault、导入、权限集成或加载入口后至少运行 `pi-ssh` 包内 `npm test`、根脚本语法与 mock 配置测试、`pi-permission-system``npm run typecheck` / `npm run test` / `npm run build`、根扩展联合加载和实际 packed tarball 隔离安装验证。未经明确要求不得连接真实服务器或把真实密码写入测试。
- 根加载顺序必须保持 `pi-permission-auto-review``pi-permission-system``pi-ssh``pi-tool-search`,确保 authorizer 先注册、权限服务先发布、SSH 桥接随后安装且 Tool Search 最后收集完整工具目录。 - 根加载顺序必须保持 `pi-permission-auto-review``pi-permission-system``pi-ssh``pi-tool-search`,确保 authorizer 先注册、权限服务先发布、SSH 桥接随后安装且 Tool Search 最后收集完整工具目录。
+2 -2
View File
@@ -18,7 +18,7 @@
- 本仓库维护的 `pi-tool-search`:从完整工具定义生成并缓存经过校验的工作流分组,以最多 3 个动态组的 LRU 策略按组加载原始完整 schema。 - 本仓库维护的 `pi-tool-search`:从完整工具定义生成并缓存经过校验的工作流分组,以最多 3 个动态组的 LRU 策略按组加载原始完整 schema。
- 本仓库维护的 `pi-permission-auto-review`:作为 `pi-permission-system` authorizer,使用 Codex Guardian 风格策略自动复核 `ask` 请求。 - 本仓库维护的 `pi-permission-auto-review`:作为 `pi-permission-system` authorizer,使用 Codex Guardian 风格策略自动复核 `ask` 请求。
- 本仓库维护的 `pi-permission-system`:从 `@gotgenes/pi-permission-system@26.2.1` 源码导入,负责工具、路径、MCP、硬拒绝和兜底权限基线。 - 本仓库维护的 `pi-permission-system`:从 `@gotgenes/pi-permission-system@26.2.1` 源码导入,负责工具、路径、MCP、硬拒绝和兜底权限基线。
- 本仓库维护的 `pi-ssh`:通过纯 Node `ssh2` 持久连接、SFTP 与自适应有界搜索提供独立的 `ssh_read``ssh_write``ssh_edit``ssh_find``ssh_grep``ssh_bash` 远端工具,支持导入 Key/密码主机、邻接 key 的 AES-GCM 配置 vault,并复用同一条权限与 auto-review - 本仓库维护的 `pi-ssh`:通过纯 Node `ssh2` 持久连接、SFTP 与自适应有界搜索提供 `ssh_connect``ssh_read``ssh_write``ssh_edit``ssh_find``ssh_grep``ssh_bash`;Agent 只在用户明确指定已导入主机及具体任务后发起受 AutoReview 复核的连接
- `extensions/tool-routing.ts`:保留 Pi 默认系统提示词,并按当前激活工具追加简短的工具与搜索路由规则;提供 `/dump-system-prompt` 导出当前有效提示词。 - `extensions/tool-routing.ts`:保留 Pi 默认系统提示词,并按当前激活工具追加简短的工具与搜索路由规则;提供 `/dump-system-prompt` 导出当前有效提示词。
## 安装、升级与卸载 ## 安装、升级与卸载
@@ -198,7 +198,7 @@ FFF 仍负责精确字面搜索;RTK 继续压缩未走 Context Mode 的 Bash
脚本只在导入阶段调用 `ssh -G` 解析用户选中的 alias,随后交互选择 Key 或密码认证、显示并确认服务器 Host Key、测试连接,再把完整配置写入 `${XDG_CONFIG_HOME:-$HOME/.config}/my-pi/pi-ssh/hosts.enc`。邻接的随机 `vault.key` 用于 AES-256-GCM 解密;目录与两个文件在 POSIX 上分别是 `700` / `600`。该加密只避免误看或单独泄漏密文,能读取两文件的同一用户仍可解密。 脚本只在导入阶段调用 `ssh -G` 解析用户选中的 alias,随后交互选择 Key 或密码认证、显示并确认服务器 Host Key、测试连接,再把完整配置写入 `${XDG_CONFIG_HOME:-$HOME/.config}/my-pi/pi-ssh/hosts.enc`。邻接的随机 `vault.key` 用于 AES-256-GCM 解密;目录与两个文件在 POSIX 上分别是 `700` / `600`。该加密只避免误看或单独泄漏密文,能读取两文件的同一用户仍可解密。
可用 `./ssh_config.sh list|update|remove|rotate-key` 管理已导入主机。第一版拒绝 `ProxyJump` / `ProxyCommand`,且不会自动读取远端 `AGENTS.md``CLAUDE.md`在 Pi 中使`/ssh` 选择主机;所有 `ssh_*` 操作仍至少进入 `ask → auto-review` 可用 `./ssh_config.sh list|update|remove|rotate-key` 管理已导入主机。第一版拒绝 `ProxyJump` / `ProxyCommand`,且不会自动读取远端 `AGENTS.md``CLAUDE.md`连接不再通过 `/ssh``--ssh` 或 session resume 建立:用户在具体远端任务中明确指定已导入 host ID 后,Agent 调`ssh_connect`,连接动作及后续 `ssh_*` 操作进入 `ask → auto-review`;会话结束自动断开
远端文件搜索使用独立的 `ssh_find` / `ssh_grep`:在每次获批调用内按服务器现有能力选择 `fd/fdfind → git ls-files → find``rg → git grep → find+grep`,不安装远端软件,并以最大 200 条结果、单行截断和 `truncated` 标记约束返回。RTK 只压缩 `ssh_bash` 的非搜索输出,不改写远端命令,也不处理 `ssh_find``ssh_grep``ssh_read`。详细说明见 [`pi-ssh/README.md`](pi-ssh/README.md)。 远端文件搜索使用独立的 `ssh_find` / `ssh_grep`:在每次获批调用内按服务器现有能力选择 `fd/fdfind → git ls-files → find``rg → git grep → find+grep`,不安装远端软件,并以最大 200 条结果、单行截断和 `truncated` 标记约束返回。RTK 只压缩 `ssh_bash` 的非搜索输出,不改写远端命令,也不处理 `ssh_find``ssh_grep``ssh_read`。详细说明见 [`pi-ssh/README.md`](pi-ssh/README.md)。
+1
View File
@@ -18,6 +18,7 @@
"grep": "allow", "grep": "allow",
"find": "allow", "find": "allow",
"multi_grep": "allow", "multi_grep": "allow",
"ssh_connect": "ask",
"ssh_read": "ask", "ssh_read": "ask",
"ssh_write": "ask", "ssh_write": "ask",
"ssh_edit": "ask", "ssh_edit": "ask",
+6
View File
@@ -44,6 +44,12 @@ export function buildToolRoutingSection(selectedTools: SelectedTools): string {
); );
} }
if (hasTool(selectedTools, "ssh_connect")) {
rules.push(
"- Use ssh_connect only when the user explicitly names an imported host as part of a concrete remote task. Connect before calling other ssh_* tools; never infer or substitute a different host.",
);
}
if (hasTool(selectedTools, "ssh_find") || hasTool(selectedTools, "ssh_grep")) { if (hasTool(selectedTools, "ssh_find") || hasTool(selectedTools, "ssh_grep")) {
rules.push( rules.push(
"- For remote SSH searches, use ssh_find to narrow remote file paths before ssh_grep searches file contents. Keep path and limit bounded; when a result says truncated, narrow the path or pattern instead of increasing the limit. Do not run find, fd, grep, or rg through ssh_bash.", "- For remote SSH searches, use ssh_find to narrow remote file paths before ssh_grep searches file contents. Keep path and limit bounded; when a result says truncated, narrow the path or pattern instead of increasing the limit. Do not run find, fd, grep, or rg through ssh_bash.",
+1 -1
View File
@@ -7164,7 +7164,7 @@
} }
}, },
"pi-ssh": { "pi-ssh": {
"version": "0.8.0", "version": "0.9.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"jiti": "2.7.0", "jiti": "2.7.0",
+7
View File
@@ -1,5 +1,12 @@
# Changelog # Changelog
## 0.9.0 - 2026-08-21
- Add reviewed, agent-callable `ssh_connect` for explicitly imported hosts.
- Remove the user-side `/ssh` command, `--ssh` startup flag, session-resume reconnect, and remote user-`!` override.
- Route connection authorization through the existing `pi-permission-system` and AutoReview chain with a non-secret target preview.
- Disconnect active transports at session shutdown and require an explicit reviewed connection in each session.
## 0.8.0 - 2026-08-20 ## 0.8.0 - 2026-08-20
- Replace the OpenSSH subprocess, ControlMaster, and persistent PTY implementation with a pure `ssh2` transport. - Replace the OpenSSH subprocess, ControlMaster, and persistent PTY implementation with a pure `ssh2` transport.
+12 -14
View File
@@ -2,6 +2,7 @@
`pi-ssh` keeps Pi and its local tools on the local machine while exposing explicit remote tools over a persistent Node `ssh2` connection: `pi-ssh` keeps Pi and its local tools on the local machine while exposing explicit remote tools over a persistent Node `ssh2` connection:
- `ssh_connect`
- `ssh_read` - `ssh_read`
- `ssh_write` - `ssh_write`
- `ssh_edit` - `ssh_edit`
@@ -17,6 +18,8 @@ Runtime connections are pure `ssh2`; the extension does not spawn OpenSSH and do
Hosts must be explicitly imported before use. OpenSSH remains only an import source: the configuration helper runs `ssh -G <alias>` once to resolve the selected alias, then stores the resulting endpoint and authentication data in the pi-ssh vault. Later changes to `~/.ssh/config` require re-importing the host. Hosts must be explicitly imported before use. OpenSSH remains only an import source: the configuration helper runs `ssh -G <alias>` once to resolve the selected alias, then stores the resulting endpoint and authentication data in the pi-ssh vault. Later changes to `~/.ssh/config` require re-importing the host.
Connections are agent-controlled: when the user explicitly names an imported host as part of a concrete remote task, the model calls `ssh_connect`. The connection request enters the normal permission and auto-review chain before any network connection is opened. The extension does not register `/ssh`, `--ssh`, automatic session resume, or remote user-`!` overrides.
ProxyJump and ProxyCommand are intentionally rejected in the first ssh2 release. ProxyJump and ProxyCommand are intentionally rejected in the first ssh2 release.
## Configure hosts ## Configure hosts
@@ -73,29 +76,23 @@ Private-key contents are not copied into the vault.
## Usage ## Usage
Connect interactively: Ask Pi for a concrete task on an imported host, for example:
```text ```text
/ssh Connect to packaging-server, inspect the API logs, and identify the recent 500 errors.
/ssh packaging-server
/ssh packaging-server:/absolute/remote/path
/ssh status
/ssh off
``` ```
Or at startup: The model first calls:
```sh ```text
pi --ssh packaging-server ssh_connect({ hostId: "packaging-server" })
pi --ssh packaging-server:/absolute/remote/path
``` ```
Only imported host IDs are accepted. Arbitrary `user@host` targets are rejected. It may set `remotePath` to an absolute path, `~`, or a path beginning with `~/`. After the reviewed connection succeeds, the model uses the other `ssh_*` tools to complete the requested work. Only imported host IDs are accepted; arbitrary `user@host` targets are rejected. A new connection replaces the previous active connection, and session shutdown disconnects it.
The active host ID and remote cwd are stored in the Pi session for resume. Credentials are never stored in Pi session entries.
## Runtime behavior ## Runtime behavior
- `ssh_connect` is the only runtime connection surface; it is agent-callable and permission-reviewed.
- One persistent `ssh2` client is used for the active host. - One persistent `ssh2` client is used for the active host.
- Each `ssh_bash` call opens an exec channel and runs under `bash -lc` in the selected remote cwd. - Each `ssh_bash` call opens an exec channel and runs under `bash -lc` in the selected remote cwd.
- SFTP provides remote reads and writes. - SFTP provides remote reads and writes.
@@ -128,6 +125,7 @@ The remote host must provide `bash`. SFTP support is required for file tools.
All remote operations enter the bundle's existing permission chain: All remote operations enter the bundle's existing permission chain:
- `ssh_connect` starts as `ask`, so AutoReview can verify that the direct user request names the requested imported host;
- `ssh_read`, `ssh_write`, `ssh_edit`, `ssh_find`, and `ssh_grep` start as `ask`; - `ssh_read`, `ssh_write`, `ssh_edit`, `ssh_find`, and `ssh_grep` start as `ask`;
- `ssh_bash` uses the full deterministic Bash policy and `decisionFloor: "ask"`; - `ssh_bash` uses the full deterministic Bash policy and `decisionFloor: "ask"`;
- deterministic hard denies remain denies; - deterministic hard denies remain denies;
@@ -143,7 +141,7 @@ Permission evidence includes the configured host ID, resolved endpoint, port, re
- Treat both vault files as secrets even though `hosts.enc` is encrypted. - Treat both vault files as secrets even though `hosts.enc` is encrypted.
- The extension's threat model does not protect credentials from malicious code already running as the same local user. - The extension's threat model does not protect credentials from malicious code already running as the same local user.
- Password keyboard-interactive mode reuses the configured password for the server's prompts; use it only with a trusted pinned host. - Password keyboard-interactive mode reuses the configured password for the server's prompts; use it only with a trusted pinned host.
- Remote content reaches the model only through an explicit `ssh_*` call or explicit user `!` command. - Remote content reaches the model only through an explicit reviewed `ssh_*` tool call.
## Development ## Development
+10 -7
View File
@@ -4,6 +4,7 @@
Pi and its default tools remain local. Explicit collision-free tools perform selected operations on one configured remote server: Pi and its default tools remain local. Explicit collision-free tools perform selected operations on one configured remote server:
- `ssh_connect`
- `ssh_read` - `ssh_read`
- `ssh_write` - `ssh_write`
- `ssh_edit` - `ssh_edit`
@@ -26,13 +27,14 @@ ssh_config.sh import <alias>
→ encrypted vault is updated → encrypted vault is updated
``` ```
The resulting host ID is the only runtime selector: The resulting host ID is the only runtime selector. When a direct user request names that imported host as part of a concrete remote task, the agent calls:
```text ```text
/ssh <host-id>[:/absolute/path] ssh_connect({ hostId: "<host-id>", remotePath?: "/absolute/or/~/path" })
pi --ssh <host-id>[:/absolute/path]
``` ```
`ssh_connect` is the only runtime connection surface. It is a model tool governed by `pi-permission-system`; there is no `/ssh` command, `--ssh` flag, session-resume reconnect, or user `!` remote-shell override.
Arbitrary `user@host`, port overrides, ProxyJump, and ProxyCommand are not supported in the first pure-ssh2 version. Unsupported imported configuration is rejected rather than ignored. Arbitrary `user@host`, port overrides, ProxyJump, and ProxyCommand are not supported in the first pure-ssh2 version. Unsupported imported configuration is rejected rather than ignored.
## Vault ## Vault
@@ -71,7 +73,7 @@ One persistent `ssh2.Client` belongs to the active host. Connection loss fails c
### Shell ### Shell
`ssh_bash` and explicit user `!` commands open exec channels. Commands run through `bash -lc` after changing to the selected remote cwd. Output streams through the normal Pi Bash operations callback. Abort or timeout closes the channel without reconnecting or replaying. `ssh_bash` opens exec channels. Commands run through `bash -lc` after changing to the selected remote cwd. Output streams through the normal Pi Bash operations callback. Abort or timeout closes the channel without reconnecting or replaying.
### Files ### Files
@@ -103,6 +105,7 @@ Direct search commands remain denied through `ssh_bash`; structured search tools
There is one permission gate: `pi-permission-system`. There is one permission gate: `pi-permission-system`.
- `ssh_connect` defaults to `ask`; its preview resolves the imported host ID to the non-secret endpoint, port, and requested/default cwd before connection.
- `ssh_read`, `ssh_write`, `ssh_edit`, `ssh_find`, and `ssh_grep` default to `ask`. - `ssh_read`, `ssh_write`, `ssh_edit`, `ssh_find`, and `ssh_grep` default to `ask`.
- `ssh_bash` is a Bash-semantic `shellTools` alias with `decisionFloor: "ask"`. - `ssh_bash` is a Bash-semantic `shellTools` alias with `decisionFloor: "ask"`.
- Bash hard denies remain denies. - Bash hard denies remain denies.
@@ -111,8 +114,8 @@ There is one permission gate: `pi-permission-system`.
Evidence includes configured host ID, endpoint, port, remote cwd, and a bounded operation summary, never credentials. Evidence includes configured host ID, endpoint, port, remote cwd, and a bounded operation summary, never credentials.
## Session and UI ## Session lifecycle
The session stores only host ID, remote cwd, and remote home. Resume reloads current vault data and establishes a new ssh2 connection; failures fall back to local mode. `/ssh off` disposes the client and clears the status line. Connections are created only by an approved `ssh_connect` call after session start. They are not persisted or automatically resumed. Connecting another imported host disposes the previous client, and session shutdown disposes the active client.
The system prompt states that default tools are local and `ssh_*` tools are remote. It does not include remote file content. The system prompt states that default tools are local and `ssh_*` tools are remote after a connection becomes active. It does not include remote file content.
+43 -164
View File
@@ -1,6 +1,6 @@
import { homedir } from "node:os"; import { homedir } from "node:os";
import { posix as posixPath } from "node:path"; import { posix as posixPath } from "node:path";
import type { CustomEntry, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { import {
createBashTool, createBashTool,
createEditTool, createEditTool,
@@ -12,7 +12,15 @@ import {
type WriteOperations, type WriteOperations,
} from "@earendil-works/pi-coding-agent"; } from "@earendil-works/pi-coding-agent";
import { getPermissionsService, PERMISSIONS_READY_CHANNEL } from "@gotgenes/pi-permission-system"; import { getPermissionsService, PERMISSIONS_READY_CHANNEL } from "@gotgenes/pi-permission-system";
import { installSshPermissionIntegration } from "./permission-integration.ts"; import {
installSshPermissionIntegration,
type SshPermissionConnection,
} from "./permission-integration.ts";
import {
parseConnectInput,
SSH_CONNECT_TOOL_METADATA,
type HostSelection,
} from "./src/agent-connection.ts";
import { loadVault } from "./src/vault.ts"; import { loadVault } from "./src/vault.ts";
import { Ssh2Transport, type RemoteTransport } from "./src/ssh2-transport.ts"; import { Ssh2Transport, type RemoteTransport } from "./src/ssh2-transport.ts";
import type { PiSshConfig, SshHostConfig } from "./src/config.ts"; import type { PiSshConfig, SshHostConfig } from "./src/config.ts";
@@ -23,11 +31,6 @@ import {
type RemoteGrepInput, type RemoteGrepInput,
} from "./src/remote-search.ts"; } from "./src/remote-search.ts";
interface SshStoredConfig {
hostId: string;
remoteCwd: string;
remoteHome: string;
}
interface SshConnection { interface SshConnection {
hostId: string; hostId: string;
@@ -39,24 +42,6 @@ interface SshConnection {
localHome: string; localHome: string;
} }
interface HostSelection {
hostId: string;
remotePath?: string;
}
function parseHostSelection(raw: string): HostSelection {
const value = raw.trim();
if (!value) throw new Error("SSH host id is required");
const colon = value.indexOf(":");
const hostId = (colon < 0 ? value : value.slice(0, colon)).trim();
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(hostId)) throw new Error(`invalid pi-ssh host id: ${hostId}`);
if (colon < 0) return { hostId };
const remotePath = value.slice(colon + 1).trim();
if (!remotePath || !(remotePath === "~" || remotePath.startsWith("~/") || remotePath.startsWith("/"))) {
throw new Error("remote path must be absolute or start with ~/");
}
return { hostId, remotePath };
}
function mapLocalPathToRemote(path: string, connection: SshConnection): string { function mapLocalPathToRemote(path: string, connection: SshConnection): string {
if (path === connection.localCwd) return connection.remoteCwd; if (path === connection.localCwd) return connection.remoteCwd;
@@ -163,22 +148,21 @@ async function connectSelection(
} }
} }
function pickerEntries(config: PiSshConfig): Array<{ value: string; hostId: string }> { function describeRequestedTarget(input: Record<string, unknown>): SshPermissionConnection | null {
const groupByHost = new Map<string, string>(); try {
for (const group of Object.values(config.groups ?? {})) { const selection = parseConnectInput(input);
for (const hostId of group.hosts) if (!groupByHost.has(hostId)) groupByHost.set(hostId, group.label); const host = getConfiguredHost(loadVault(), selection.hostId);
return {
remote: `${selection.hostId} [${host.user}@${host.hostName}]`,
port: host.port,
remoteCwd: selection.remotePath ?? host.defaultCwd ?? "<server default>",
};
} catch {
return null;
} }
return Object.entries(config.hosts).map(([hostId, host]) => ({
hostId,
value: `${groupByHost.get(hostId) ? `${groupByHost.get(hostId)} / ` : ""}${host.label ?? hostId} [${hostId}]`,
}));
} }
export default function piSshExtension(pi: ExtensionAPI): void { export default function piSshExtension(pi: ExtensionAPI): void {
pi.registerFlag("ssh", {
description: "Configured pi-ssh host id, optionally followed by :/absolute/remote/path",
type: "string",
});
const localCwd = process.cwd(); const localCwd = process.cwd();
const localHome = homedir(); const localHome = homedir();
@@ -193,11 +177,12 @@ export default function piSshExtension(pi: ExtensionAPI): void {
installSshPermissionIntegration(pi, getConnection, { installSshPermissionIntegration(pi, getConnection, {
getPermissionsService, getPermissionsService,
permissionsReadyChannel: PERMISSIONS_READY_CHANNEL, permissionsReadyChannel: PERMISSIONS_READY_CHANNEL,
getConnectTarget: describeRequestedTarget,
}); });
const requireSsh = (toolName: string): { connection: SshConnection; transport: Ssh2Transport } => { const requireSsh = (toolName: string): { connection: SshConnection; transport: Ssh2Transport } => {
if (!connection || !transport) { if (!connection || !transport) {
throw new Error(`${toolName} requires an active SSH2 connection. Run /ssh and select an imported host.`); throw new Error(`${toolName} requires an active SSH2 connection. Call ssh_connect with an imported host ID first.`);
} }
return { connection, transport }; return { connection, transport };
}; };
@@ -205,33 +190,31 @@ export default function piSshExtension(pi: ExtensionAPI): void {
const activateConnection = async ( const activateConnection = async (
nextConnection: SshConnection, nextConnection: SshConnection,
nextTransport: Ssh2Transport, nextTransport: Ssh2Transport,
ctx: ExtensionContext,
options: { persist: boolean; verb: string },
): Promise<void> => { ): Promise<void> => {
if (transport) await transport.dispose(); if (transport) await transport.dispose();
connection = nextConnection; connection = nextConnection;
transport = nextTransport; transport = nextTransport;
if (options.persist) { console.log(`pi-ssh connected: ${nextConnection.remote}:${nextConnection.remoteCwd} (port ${nextConnection.port})`);
pi.appendEntry("pi-ssh-config", {
hostId: nextConnection.hostId,
remoteCwd: nextConnection.remoteCwd,
remoteHome: nextConnection.remoteHome,
} satisfies SshStoredConfig);
}
const message = `pi-ssh ${options.verb}: ${nextConnection.remote}:${nextConnection.remoteCwd} (port ${nextConnection.port})`;
console.log(message);
if (ctx.hasUI) {
ctx.ui.setStatus("pi-ssh", ctx.ui.theme.fg("accent", `SSH ${nextConnection.hostId}:${nextConnection.remoteCwd}`));
ctx.ui.notify(message, "info");
}
}; };
const deactivateConnection = async (ctx: ExtensionContext): Promise<void> => { pi.registerTool({
if (transport) await transport.dispose(); ...SSH_CONNECT_TOOL_METADATA,
transport = null; async execute(_id, params) {
connection = null; const selection = parseConnectInput(params as Record<string, unknown>);
if (ctx.hasUI) ctx.ui.setStatus("pi-ssh", undefined); const connected = await connectSelection(selection, localCwd, localHome);
}; await activateConnection(connected.connection, connected.transport);
const text = `Connected to ${connected.connection.remote}:${connected.connection.remoteCwd} (port ${connected.connection.port}).`;
return {
content: [{ type: "text", text }],
details: {
hostId: connected.connection.hostId,
remote: connected.connection.remote,
port: connected.connection.port,
remoteCwd: connected.connection.remoteCwd,
},
};
},
});
pi.registerTool({ pi.registerTool({
...localRead, ...localRead,
@@ -346,115 +329,12 @@ export default function piSshExtension(pi: ExtensionAPI): void {
}, },
}); });
pi.on("session_start", async (event, ctx) => {
const flag = pi.getFlag("ssh") as string | undefined;
if (flag) {
try {
const connected = await connectSelection(parseHostSelection(flag), localCwd, localHome);
await activateConnection(connected.connection, connected.transport, ctx, { persist: true, verb: "enabled" });
return;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await deactivateConnection(ctx);
console.error(`pi-ssh failed to connect: ${message}`);
if (ctx.hasUI) ctx.ui.notify(`pi-ssh failed to connect: ${message}`, "error");
throw error;
}
}
if (event.reason !== "startup" && event.reason !== "resume") return;
const entries = ctx.sessionManager.getEntries();
let stored: SshStoredConfig | undefined;
for (let index = entries.length - 1; index >= 0; index -= 1) {
const entry = entries[index];
if (entry.type === "custom" && (entry as CustomEntry<unknown>).customType === "pi-ssh-config") {
stored = (entry as CustomEntry<SshStoredConfig>).data;
if (stored) break;
}
}
if (!stored) return;
try {
const connected = await connectSelection(
{ hostId: stored.hostId, remotePath: stored.remoteCwd },
localCwd,
localHome,
);
await activateConnection(connected.connection, connected.transport, ctx, { persist: false, verb: "resumed" });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await deactivateConnection(ctx);
console.error(`pi-ssh resume failed: ${message}`);
if (ctx.hasUI) ctx.ui.notify(`pi-ssh resume failed: ${message}`, "warning");
}
});
pi.registerCommand("ssh", {
description: "Connect configured SSH2 hosts: /ssh [host-id[:/path]], /ssh status, /ssh off",
getArgumentCompletions: (prefix) => {
try {
const options = ["off", "status", ...Object.keys(loadVault().hosts)];
const filtered = options.filter((option) => option.startsWith(prefix));
return filtered.length > 0 ? filtered.map((option) => ({ value: option, label: option })) : null;
} catch {
return ["off", "status"].filter((option) => option.startsWith(prefix)).map((option) => ({ value: option, label: option }));
}
},
handler: async (args, ctx) => {
const input = args.trim();
if (input === "status") {
ctx.ui.notify(connection
? `pi-ssh: ${connection.remote}:${connection.remoteCwd} (port ${connection.port})`
: "pi-ssh: not connected (local tools active)", "info");
return;
}
if (input === "off") {
await deactivateConnection(ctx);
ctx.ui.notify("pi-ssh: disconnected", "info");
return;
}
let target = input;
if (!target) {
let config: PiSshConfig;
try {
config = loadVault();
} catch (error) {
ctx.ui.notify(error instanceof Error ? error.message : String(error), "warning");
return;
}
const entries = pickerEntries(config);
if (entries.length === 0) {
ctx.ui.notify("No pi-ssh hosts configured. Run ssh_config.sh import.", "warning");
return;
}
const values = [...(connection ? ["Disconnect [off]"] : []), ...entries.map((entry) => entry.value)];
const selected = await ctx.ui.select("SSH2 host", values);
if (!selected) return;
if (selected === "Disconnect [off]") {
await deactivateConnection(ctx);
ctx.ui.notify("pi-ssh: disconnected", "info");
return;
}
target = entries.find((entry) => entry.value === selected)?.hostId ?? "";
}
try {
const connected = await connectSelection(parseHostSelection(target), localCwd, localHome);
await activateConnection(connected.connection, connected.transport, ctx, { persist: true, verb: "connected" });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
ctx.ui.notify(`pi-ssh: failed to connect: ${message}`, "error");
}
},
});
pi.on("session_shutdown", async () => { pi.on("session_shutdown", async () => {
if (transport) await transport.dispose(); if (transport) await transport.dispose();
transport = null; transport = null;
connection = null;
}); });
pi.on("user_bash", () => transport ? { operations: createRemoteBashOps(transport) } : undefined);
pi.on("before_agent_start", async (event) => { pi.on("before_agent_start", async (event) => {
if (!connection) return; if (!connection) return;
const guidance = const guidance =
@@ -463,7 +343,6 @@ export default function piSshExtension(pi: ExtensionAPI): void {
`The default read/write/edit/bash/find/grep tools act on the LOCAL machine. ` + `The default read/write/edit/bash/find/grep tools act on the LOCAL machine. ` +
`Use ssh_read, ssh_write, ssh_edit, ssh_find, ssh_grep, and ssh_bash for explicit remote operations. ` + `Use ssh_read, ssh_write, ssh_edit, ssh_find, ssh_grep, and ssh_bash for explicit remote operations. ` +
`Use ssh_find before ssh_grep to narrow remote searches; both tools return bounded results and choose the fastest available remote backend. ` + `Use ssh_find before ssh_grep to narrow remote searches; both tools return bounded results and choose the fastest available remote backend. ` +
`(User \`!\` commands run remotely.) ` +
`Remote operations are rooted at ${connection.remoteCwd}; relative paths resolve against that directory.`; `Remote operations are rooted at ${connection.remoteCwd}; relative paths resolve against that directory.`;
return { systemPrompt: `${event.systemPrompt}${guidance}` }; return { systemPrompt: `${event.systemPrompt}${guidance}` };
}); });
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "pi-ssh", "name": "pi-ssh",
"version": "0.8.0", "version": "0.9.0",
"description": "Explicit remote SSH tools for Pi using a pure ssh2 transport and encrypted host vault", "description": "Reviewed agent-controlled SSH tools for Pi using a pure ssh2 transport and encrypted host vault",
"type": "module", "type": "module",
"private": false, "private": false,
"main": "index.ts", "main": "index.ts",
+15 -4
View File
@@ -10,6 +10,7 @@ export interface SshPermissionConnection {
export interface SshPermissionIntegrationDependencies { export interface SshPermissionIntegrationDependencies {
getPermissionsService: () => PermissionsService | undefined; getPermissionsService: () => PermissionsService | undefined;
permissionsReadyChannel: string; permissionsReadyChannel: string;
getConnectTarget?: (input: Record<string, unknown>) => SshPermissionConnection | null;
warn?: (message: string) => void; warn?: (message: string) => void;
} }
@@ -17,7 +18,7 @@ type PermissionIntegrationApi = Pick<ExtensionAPI, "events" | "on">;
type ToolInput = Record<string, unknown>; type ToolInput = Record<string, unknown>;
const REMOTE_FILE_TOOLS = ["ssh_read", "ssh_write", "ssh_edit", "ssh_find", "ssh_grep"] as const; const REMOTE_FILE_TOOLS = ["ssh_read", "ssh_write", "ssh_edit", "ssh_find", "ssh_grep"] as const;
const REMOTE_TOOLS = [...REMOTE_FILE_TOOLS, "ssh_bash"] as const; const REMOTE_TOOLS = ["ssh_connect", ...REMOTE_FILE_TOOLS, "ssh_bash"] as const;
function inline(value: string, limit = 240): string { function inline(value: string, limit = 240): string {
const normalized = value.replace(/\s+/g, " ").trim(); const normalized = value.replace(/\s+/g, " ").trim();
@@ -47,6 +48,13 @@ export function formatSshPermissionInput(
const target = formatTarget(connection); const target = formatTarget(connection);
const path = stringField(input, "path"); const path = stringField(input, "path");
if (toolName === "ssh_connect") {
if (connection !== null) return `${target}; establish a persistent SSH2 connection`;
const hostId = inline(stringField(input, "hostId") ?? "<unspecified>");
const remotePath = stringField(input, "remotePath");
return `requested imported SSH host '${hostId}'${remotePath ? ` in remote cwd '${inline(remotePath)}'` : ""}; establish a persistent SSH2 connection`;
}
if (toolName === "ssh_read") { if (toolName === "ssh_read") {
const details = path ? [`remote path '${inline(path)}'`] : ["an unspecified remote path"]; const details = path ? [`remote path '${inline(path)}'`] : ["an unspecified remote path"];
if (typeof input.offset === "number") details.push(`offset ${input.offset}`); if (typeof input.offset === "number") details.push(`offset ${input.offset}`);
@@ -124,9 +132,12 @@ export function installSshPermissionIntegration(
try { try {
for (const toolName of REMOTE_TOOLS) { for (const toolName of REMOTE_TOOLS) {
pending.push( pending.push(
service.registerToolInputFormatter(toolName, (input) => service.registerToolInputFormatter(toolName, (input) => {
formatSshPermissionInput(toolName, input, getConnection()), const target = toolName === "ssh_connect"
), ? dependencies.getConnectTarget?.(input) ?? null
: getConnection();
return formatSshPermissionInput(toolName, input, target);
}),
); );
} }
for (const toolName of REMOTE_FILE_TOOLS) { for (const toolName of REMOTE_FILE_TOOLS) {
+33
View File
@@ -0,0 +1,33 @@
export interface HostSelection {
hostId: string;
remotePath?: string;
}
export const SSH_CONNECT_TOOL_METADATA = {
name: "ssh_connect",
label: "ssh_connect",
description: "Establish a persistent SSH2 connection to an explicitly imported host. Use this when the user names a remote server as part of a concrete task; the connection request is reviewed before any network connection is opened.",
parameters: {
type: "object",
properties: {
hostId: { type: "string", description: "Imported pi-ssh host ID explicitly named by the user" },
remotePath: { type: "string", description: "Optional remote cwd; must be absolute, ~, or start with ~/" },
},
required: ["hostId"],
additionalProperties: false,
},
} as const;
export function parseConnectInput(input: Record<string, unknown>): HostSelection {
const hostId = typeof input.hostId === "string" ? input.hostId.trim() : "";
if (!hostId) throw new Error("SSH host id is required");
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(hostId)) throw new Error(`invalid pi-ssh host id: ${hostId}`);
if (input.remotePath === undefined) return { hostId };
if (typeof input.remotePath !== "string") throw new Error("remote path must be a string");
const remotePath = input.remotePath.trim();
if (!remotePath || !(remotePath === "~" || remotePath.startsWith("~/") || remotePath.startsWith("/"))) {
throw new Error("remote path must be absolute or start with ~/");
}
return { hostId, remotePath };
}
+28
View File
@@ -0,0 +1,28 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import { parseConnectInput, SSH_CONNECT_TOOL_METADATA } from "../src/agent-connection.ts";
test("defines the reviewed agent-controlled SSH connection tool", () => {
assert.equal(SSH_CONNECT_TOOL_METADATA.name, "ssh_connect");
assert.deepEqual(SSH_CONNECT_TOOL_METADATA.parameters.required, ["hostId"]);
assert.ok(SSH_CONNECT_TOOL_METADATA.parameters.properties.remotePath);
assert.deepEqual(parseConnectInput({ hostId: " packaging-server " }), { hostId: "packaging-server" });
assert.deepEqual(parseConnectInput({ hostId: "packaging-server", remotePath: "~/api" }), {
hostId: "packaging-server",
remotePath: "~/api",
});
assert.throws(() => parseConnectInput({ hostId: "user@host" }), /invalid pi-ssh host id/);
assert.throws(() => parseConnectInput({ hostId: "packaging-server", remotePath: "relative" }), /remote path/);
});
test("removes manual and implicit SSH connection surfaces", async () => {
const source = await readFile(new URL("../index.ts", import.meta.url), "utf8");
assert.match(source, /\.\.\.SSH_CONNECT_TOOL_METADATA/);
assert.doesNotMatch(source, /registerCommand\(["']ssh["']/);
assert.doesNotMatch(source, /registerFlag\(["']ssh["']/);
assert.doesNotMatch(source, /getFlag\(["']ssh["']/);
assert.doesNotMatch(source, /appendEntry\(["']pi-ssh-config["']/);
assert.doesNotMatch(source, /pi\.on\(["']user_bash["']/);
});
+19 -3
View File
@@ -45,6 +45,17 @@ const connection: SshPermissionConnection = {
remoteCwd: "/srv/build", remoteCwd: "/srv/build",
}; };
test("formats reviewed connection requests without exposing credentials", () => {
assert.equal(
formatSshPermissionInput("ssh_connect", { hostId: "packaging-server", remotePath: "/srv/build" }, connection),
"SSH target 'packaging-server:2222' in remote cwd '/srv/build'; establish a persistent SSH2 connection",
);
assert.equal(
formatSshPermissionInput("ssh_connect", { hostId: "unknown" }, null),
"requested imported SSH host 'unknown'; establish a persistent SSH2 connection",
);
});
test("formats the SSH target and bounded operation details", () => { test("formats the SSH target and bounded operation details", () => {
assert.equal( assert.equal(
formatSshPermissionInput("ssh_read", { path: "src/main.ts", offset: 5, limit: 20 }, connection), formatSshPermissionInput("ssh_read", { path: "src/main.ts", offset: 5, limit: 20 }, connection),
@@ -66,14 +77,19 @@ test("registers previews and disables local path extraction for remote file tool
const dispose = installSshPermissionIntegration( const dispose = installSshPermissionIntegration(
pi.api as never, pi.api as never,
() => connection, () => connection,
{ getPermissionsService: () => service, permissionsReadyChannel: "permissions:ready" }, {
getPermissionsService: () => service,
permissionsReadyChannel: "permissions:ready",
getConnectTarget: () => connection,
},
); );
assert.deepEqual([...formatters.keys()], ["ssh_read", "ssh_write", "ssh_edit", "ssh_find", "ssh_grep", "ssh_bash"]); assert.deepEqual([...formatters.keys()], ["ssh_connect", "ssh_read", "ssh_write", "ssh_edit", "ssh_find", "ssh_grep", "ssh_bash"]);
assert.deepEqual([...extractors.keys()], ["ssh_read", "ssh_write", "ssh_edit", "ssh_find", "ssh_grep"]); assert.deepEqual([...extractors.keys()], ["ssh_read", "ssh_write", "ssh_edit", "ssh_find", "ssh_grep"]);
assert.equal(extractors.get("ssh_read")?.({ path: "/remote/secret" }), undefined); assert.equal(extractors.get("ssh_read")?.({ path: "/remote/secret" }), undefined);
assert.equal(extractors.get("ssh_grep")?.({ path: "/remote/src" }), undefined); assert.equal(extractors.get("ssh_grep")?.({ path: "/remote/src" }), undefined);
assert.match(formatters.get("ssh_bash")?.({ command: "git push" }) ?? "", /packaging-server:2222/); assert.match(formatters.get("ssh_bash")?.({ command: "git push" }) ?? "", /packaging-server:2222/);
assert.match(formatters.get("ssh_connect")?.({ hostId: "packaging-server" }) ?? "", /establish a persistent SSH2 connection/);
dispose(); dispose();
assert.equal(formatters.size, 0); assert.equal(formatters.size, 0);
@@ -92,7 +108,7 @@ test("registers when the permission service becomes ready and cleans up on shutd
published = service; published = service;
pi.emitEvent("permissions:ready"); pi.emitEvent("permissions:ready");
assert.equal(formatters.size, 6); assert.equal(formatters.size, 7);
assert.equal(extractors.size, 5); assert.equal(extractors.size, 5);
pi.emit("session_shutdown"); pi.emit("session_shutdown");
+1 -1
View File
@@ -11,7 +11,7 @@
- Added limits for active groups, generated group size, and total dynamic tools, plus current-host catalog/config/LRU tests. - Added limits for active groups, generated group size, and total dynamic tools, plus current-host catalog/config/LRU tests.
- Added checked-in authoritative groups for every tool exposed by the standard my-pi bundle, eliminating first-use model generation and user caching unless unrecognized third-party tools are present. - Added checked-in authoritative groups for every tool exposed by the standard my-pi bundle, eliminating first-use model generation and user caching unless unrecognized third-party tools are present.
- Replaced the Hermes and third-party Hippo assignments with the five tools exposed by the official Hippo Pi extension, grouped into recall and management workflows. - Replaced the Hermes and third-party Hippo assignments with the five tools exposed by the official Hippo Pi extension, grouped into recall and management workflows.
- Added authoritative `ssh-remote-files`, `ssh-remote-search`, and `ssh-remote-shell` groups for the maintained `pi-ssh` tools, keeping bounded discovery, structured file operations, and reviewed shell execution separate. - Added authoritative `ssh-connection`, `ssh-remote-files`, `ssh-remote-search`, and `ssh-remote-shell` groups for the maintained `pi-ssh` tools, keeping reviewed connection, bounded discovery, structured file operations, and reviewed shell execution separate.
## [0.3.6] - 2026-04-24 ## [0.3.6] - 2026-04-24
+1
View File
@@ -27,6 +27,7 @@ Run `/tool-search-rebuild` to remove model enrichment. Standard bundle tools imm
| Group | Bundle tools | | Group | Bundle tools |
|---|---| |---|---|
| `filesystem-navigation` | `ls`, `multi_grep` | | `filesystem-navigation` | `ls`, `multi_grep` |
| `ssh-connection` | `ssh_connect` |
| `ssh-remote-files` | `ssh_read`, `ssh_write`, `ssh_edit` | | `ssh-remote-files` | `ssh_read`, `ssh_write`, `ssh_edit` |
| `ssh-remote-search` | `ssh_find`, `ssh_grep` | | `ssh-remote-search` | `ssh_find`, `ssh_grep` |
| `ssh-remote-shell` | `ssh_bash` | | `ssh-remote-shell` | `ssh_bash` |
+1 -1
View File
@@ -13,7 +13,7 @@
9. `turn_start` notices tool or grouping-configuration changes by hash, resets stale dynamic groups, and reconstructs the checked-in/hybrid catalog. 9. `turn_start` notices tool or grouping-configuration changes by hash, resets stale dynamic groups, and reconstructs the checked-in/hybrid catalog.
The standard bundle assigns the official Hippo Pi extension's five `hippo_*` tools to recall and management groups. `context_tree_query` remains in `memory-recall` because it recovers condensed tool output; retired Hermes names and third-party `tff-memory_*` names are not seeded. The standard bundle assigns the official Hippo Pi extension's five `hippo_*` tools to recall and management groups. `context_tree_query` remains in `memory-recall` because it recovers condensed tool output; retired Hermes names and third-party `tff-memory_*` names are not seeded.
The maintained `pi-ssh` tools are also split by workflow: structured `ssh_read`/`ssh_write`/`ssh_edit` file operations live in `ssh-remote-files`, bounded `ssh_find`/`ssh_grep` discovery lives in `ssh-remote-search`, and the reviewed `ssh_bash` command surface stays isolated in `ssh-remote-shell`. The maintained `pi-ssh` tools are also split by workflow: reviewed agent-controlled connection lives in `ssh-connection`, structured `ssh_read`/`ssh_write`/`ssh_edit` file operations live in `ssh-remote-files`, bounded `ssh_find`/`ssh_grep` discovery lives in `ssh-remote-search`, and the reviewed `ssh_bash` command surface stays isolated in `ssh-remote-shell`.
A purely additive first load receives Pi's native dynamic-tool result propagation. A replacement that evicts one group while adding another is intentionally non-additive; current Pi detects the removal and uses its safe next-request fallback instead of attaching an invalid additive-only hint. A purely additive first load receives Pi's native dynamic-tool result propagation. A replacement that evicts one group while adding another is intentionally non-additive; current Pi detects the removal and uses its safe next-request fallback instead of attaching an invalid additive-only hint.
@@ -31,6 +31,15 @@ export const BUNDLE_GROUP_DEFINITIONS: BundleGroupDefinition[] = [
keywords: ["files", "directory", "list", "multi grep", "目录", "文件", "多模式搜索"], keywords: ["files", "directory", "list", "multi grep", "目录", "文件", "多模式搜索"],
tools: ["ls", "multi_grep"], tools: ["ls", "multi_grep"],
}, },
{
id: "ssh-connection",
title: "SSH connection",
summary: "Connect to an explicitly imported SSH host through the reviewed agent tool flow.",
useWhen: ["The user names a remote server as part of a concrete task and no matching SSH2 connection is active"],
avoidWhen: ["The task is local, the server was not explicitly named, or the host has not been imported"],
keywords: ["ssh", "connect", "server", "host", "remote", "连接服务器", "远程主机", "SSH"],
tools: ["ssh_connect"],
},
{ {
id: "ssh-remote-files", id: "ssh-remote-files",
title: "SSH remote files", title: "SSH remote files",
+5
View File
@@ -33,6 +33,11 @@ test("all package extensions load together without global registration conflicts
assert.equal(packageJson.dependencies.jiti, "2.7.0", "the packed helper must load local TypeScript outside Pi"); assert.equal(packageJson.dependencies.jiti, "2.7.0", "the packed helper must load local TypeScript outside Pi");
assert.ok(packageJson.files.includes("pi-ssh"), "the packed bundle must include the locally maintained SSH source"); assert.ok(packageJson.files.includes("pi-ssh"), "the packed bundle must include the locally maintained SSH source");
assert.ok(packageJson.files.includes("ssh_config.sh"), "the packed bundle must include the SSH host import helper"); assert.ok(packageJson.files.includes("ssh_config.sh"), "the packed bundle must include the SSH host import helper");
const permissionConfig = JSON.parse(
await readFile(join(repositoryRoot, "config", "pi-permission-system.json"), "utf8"),
) as { authorizerChain: string[]; permission: Record<string, unknown> };
assert.equal(permissionConfig.permission.ssh_connect, "ask", "SSH connection must enter the permission gate");
assert.deepEqual(permissionConfig.authorizerChain, ["auto-review"], "SSH connection asks must reach AutoReview");
assert.ok( assert.ok(
packageJson.pi.extensions.indexOf("./extensions/permission-system.ts") < packageJson.pi.extensions.indexOf("./extensions/permission-system.ts") <
packageJson.pi.extensions.indexOf("./pi-ssh/index.ts"), packageJson.pi.extensions.indexOf("./pi-ssh/index.ts"),
+7
View File
@@ -21,6 +21,13 @@ test("search routing narrows files before requesting matching line numbers", ()
assert.match(section, /Use read with offset\/limit only for the exact matching region/); assert.match(section, /Use read with offset\/limit only for the exact matching region/);
}); });
test("SSH connection routing requires an explicit named host and concrete task", () => {
const section = buildToolRoutingSection(["ssh_connect"]);
assert.match(section, /only when the user explicitly names an imported host/);
assert.match(section, /Connect before calling other ssh_\* tools/);
assert.match(section, /never infer or substitute a different host/);
});
test("remote search routing uses structured bounded SSH tools", () => { test("remote search routing uses structured bounded SSH tools", () => {
const section = buildToolRoutingSection(["ssh_find", "ssh_grep"]); const section = buildToolRoutingSection(["ssh_find", "ssh_grep"]);
assert.match(section, /use ssh_find to narrow remote file paths before ssh_grep/); assert.match(section, /use ssh_find to narrow remote file paths before ssh_grep/);