Compare commits

...
3 Commits
Author SHA1 Message Date
云服务部-叶林立andDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 87940da51a feat(prompt): 重新注册 bash,把"不要用 bash 搜索"写进工具描述
真实会话里模型仍习惯性先调 bash grep,被拒后才改用 grep 工具;系统提示词里已有 4 处
提醒,但模型下调用决定时看的是工具 schema 描述,而 Pi 内置 bash 描述对此只字未提。

- 新增 extensions/bash-override.ts:用 Pi 的 createBashToolDefinition 重新注册 bash,
  执行、输出结构与 codemode 说明完全沿用上游;shellPath/commandPrefix 按 ctx.cwd
  经 SettingsManager 惰性解析,与内置一致
- tools.jsonc 新增 descriptionAppend 字段,由 prompt-guard 注册时拼接到上游描述后
- bash 增加命令→工具对照规则(grep -n / grep -rl / find -name),并提示
  文件清单用 $(git ls-files) 传参、不要接 xargs(会触发权限审核)

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-22 01:20:52 -07:00
云服务部-叶林立andDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 2b21163a18 fix(pi-permission-system): bash 命令枚举下降进入控制流与赋值载荷
for/while/if/case/{ }/函数体此前只按整段文本匹配,
"for d in ...; do n=$(find ...); done" 中的 find 绕过了 find* deny(真实会话复现);
x=$(grep ...)、export X=$(find ...)、! grep ... 同样绕过。

控制流语句仍整段判定,并逐条枚举条件与体内命令;do/else/case 分支容器只下降不单独成单元;
其余语句兜底额外枚举其中的 $(…)。结果只会更严,hard guard 共享同一枚举。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-22 00:14:49 -07:00
云服务部-叶林立andDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> dae1a01f23 feat(prompt): 搜索引导改为意图→工具的正向指引,multi_grep 按上游保持关闭
真实会话中模型仍用 bash grep -o | sort -u 做抽取,被拒后直接放弃核对;
原提示词只说"不要",没告诉它抽取/计数、给程序传文件清单、过滤命令输出时该用什么。

- routing:按已知信息选 grep/find(多变体用 grep 正则 alternation),
  codemode + tools.grep 做聚合,bash 文件清单用 git ls-files,
  overflow_query 过滤输出,被拒后改用替代方案继续
- 权限策略:rg/grep/git grep/find/fd 的 deny 附带指向替代工具的 reason
- tools.jsonc:grep/find 新 snippet,补上 codegraph_explore snippet
- FFF 上游标注 multi_grep 有害:wrapper 删除 PI_FFF_MULTIGREP,清理全部引用
- JSONC 测试改用字符串感知的注释剥离(reason 中含 'src/**/*.ts')

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-22 00:14:49 -07:00
26 changed files with 705 additions and 72 deletions
+2 -1
View File
@@ -20,7 +20,8 @@
| `pi-notify/` | Kitty OSC 99 完成与权限确认通知,精确聚焦来源窗口 | | `pi-notify/` | Kitty OSC 99 完成与权限确认通知,精确聚焦来源窗口 |
| `pi-compaction/` | 压力触发的观察遮蔽(Pi context_edit)、阈值整段摘要、effective token、native compaction | | `pi-compaction/` | 压力触发的观察遮蔽(Pi context_edit)、阈值整段摘要、effective token、native compaction |
| `pi-overflow/` | 新工具结果统一截断:超阈值原文落盘 + 有界视图 + `overflow_query` | | `pi-overflow/` | 新工具结果统一截断:超阈值原文落盘 + 有界视图 + `overflow_query` |
| `extensions/fff-override.ts` | FFF `override` 模式接管 `find`/`grep`/`multi_grep` 与 `@` 补全 | | `extensions/fff-override.ts` | FFF `override` 模式接管 `find`/`grep` 与 `@` 补全,`multi_grep` 按上游保持关闭 |
| `extensions/bash-override.ts` | 用 Pi 的 `createBashToolDefinition` 重新注册 `bash`,仅替换模型可见描述(追加 `tools.jsonc` `descriptionAppend`);执行逻辑、输出结构、codemode 说明不变 |
| `extensions/codegraph.ts` | 原生 `codegraph_explore`,自有 stdio JSON-RPC 直连 `codegraph serve`(`codegraph-client.ts`) | | `extensions/codegraph.ts` | 原生 `codegraph_explore`,自有 stdio JSON-RPC 直连 `codegraph serve`(`codegraph-client.ts`) |
| `extensions/hashline.ts` | 部署 `config/pi-hashline-edit.json` 后加载 pi-hashline-edit 覆盖 `read`/`edit`(`hashline-config.ts`) | | `extensions/hashline.ts` | 部署 `config/pi-hashline-edit.json` 后加载 pi-hashline-edit 覆盖 `read`/`edit`(`hashline-config.ts`) |
| `extensions/permission-system.ts` | 直接加载仓库内 `pi-permission-system/src/index.ts` | | `extensions/permission-system.ts` | 直接加载仓库内 `pi-permission-system/src/index.ts` |
+8 -3
View File
@@ -2,7 +2,7 @@
个人维护的 Pi 扩展组合包。安装一次即可加载并配置: 个人维护的 Pi 扩展组合包。安装一次即可加载并配置:
- `@ff-labs/pi-fff@0.10.5`:使用官方 `override` 模式接管 `find`、`grep` 和 `multi_grep`。 - `@ff-labs/pi-fff@0.10.5`:使用官方 `override` 模式接管 `find` 和 `grep`;上游默认关闭的 `multi_grep` 保持关闭。
- 本仓库维护的 `pi-rtk-optimizer`:只处理 Bash/read 等输出压缩,默认不改写命令,也不处理搜索工具结果。 - 本仓库维护的 `pi-rtk-optimizer`:只处理 Bash/read 等输出压缩,默认不改写命令,也不处理搜索工具结果。
- 本仓库自维护的 `pi-web-search`:对模型只暴露 `web_search` / `web_fetch` 两个工具;`web_search` 由当前主模型在 Tavily / Exa / Keenable 间路由,`web_fetch` 固定走 Exa,三家均以纯 `fetch` 直连(Exa 为 `mcp.exa.ai` 的 JSON-RPC,不经 MCP adapter)。 - 本仓库自维护的 `pi-web-search`:对模型只暴露 `web_search` / `web_fetch` 两个工具;`web_search` 由当前主模型在 Tavily / Exa / Keenable 间路由,`web_fetch` 固定走 Exa,三家均以纯 `fetch` 直连(Exa 为 `mcp.exa.ai` 的 JSON-RPC,不经 MCP adapter)。
- `extensions/codegraph.ts`:注册原生 `codegraph_explore` 工具,按索引根惰性启动常驻 `codegraph serve --mcp` 子进程,由组合包自有 stdio JSON-RPC 客户端直连(无 MCP adapter/SDK);serve 的文件监听自动同步索引,无结果缓存;未初始化项目经确认后才 `codegraph init`,拒绝记入会话不再重复询问。 - `extensions/codegraph.ts`:注册原生 `codegraph_explore` 工具,按索引根惰性启动常驻 `codegraph serve --mcp` 子进程,由组合包自有 stdio JSON-RPC 客户端直连(无 MCP adapter/SDK);serve 的文件监听自动同步索引,无结果缓存;未初始化项目经确认后才 `codegraph init`,拒绝记入会话不再重复询问。
@@ -138,7 +138,7 @@ CodeGraph 的更新或遥测设置。
### 工具与搜索路由 ### 工具与搜索路由
`config/prompt/routing.jsonc` 定义跨工具路由规则,由 `extensions/system-prompt.ts` 在每轮开始前按当前激活工具渲染为 `<tool_routing>` 分区。代码结构、调用关系和待修改 symbol 优先使用 CodeGraph,定义、引用、类型和修改后诊断优先使用 LSP;仓库字面搜索先用 FFF `find` 缩小文件或目录范围,再在已收敛的路径中用 `grep`/`multi_grep` 获取行号。宽泛搜索命中大量结果、发生截断或达到上限时,应继续缩小路径、glob 或 pattern,而不是提高 limit 或输出全部结果。 `config/prompt/routing.jsonc` 定义跨工具路由规则,由 `extensions/system-prompt.ts` 在每轮开始前按当前激活工具渲染为 `<tool_routing>` 分区。代码结构、调用关系和待修改 symbol 优先使用 CodeGraph,定义、引用、类型和修改后诊断优先使用 LSP;本地字面搜索按已知信息直接选工具:确切标识符或文本用 `grep` 字面模式并限定 path/exclude,多个命名变体合并为一次 `grep` 正则 alternation,文件名、路径片段或功能概念用 FFF `find` 模糊全路径匹配,然后只 `read` 命中区域。对搜索结果做提取、去重、计数或聚合时,在 codemode 脚本中调用 `tools.grep` / `tools.find` 并用 JavaScript 后处理;bash 中不得运行 `grep`/`rg`/`find`/`fd`/`git grep`(策略 deny 的 reason 指明替代工具),程序需要文件清单时传 `$(git ls-files '<glob>')`。宽泛搜索命中大量结果、发生截断或达到上限时,应继续缩小路径、glob 或 pattern,而不是提高 limit 或输出全部结果。
在线搜索默认按任务只选一个服务:关键词不确定、新闻/近期话题或需要较多候选来源时优先 Tavily;官方/API 文档、版本信息、论文和高确定性事实确认优先 Exa;中文政策、政府公告、中文网页、站点限定和日期筛选优先 Keenable。只有来源质量或不确定性确实需要时才跨服务复核,避免默认把三套结果全部塞进上下文。 在线搜索默认按任务只选一个服务:关键词不确定、新闻/近期话题或需要较多候选来源时优先 Tavily;官方/API 文档、版本信息、论文和高确定性事实确认优先 Exa;中文政策、政府公告、中文网页、站点限定和日期筛选优先 Keenable。只有来源质量或不确定性确实需要时才跨服务复核,避免默认把三套结果全部塞进上下文。
@@ -211,9 +211,14 @@ Hashline 不负责压缩大文件;每行锚点本身还会增加少量 token
`extensions/fff-override.ts` 强制设置 FFF 官方的 `PI_FFF_MODE=override`(显式传入 `extensions/fff-override.ts` 强制设置 FFF 官方的 `PI_FFF_MODE=override`(显式传入
`--fff-mode` 仍按 FFF 官方优先级覆盖它)。FFF 替换 `--fff-mode` 仍按 FFF 官方优先级覆盖它)。FFF 替换
Pi 的 `find` 和 `grep`,并提供 `multi_grep`;RTK 对所有 `grep` 工具结果直接跳过, Pi 的 `find` 和 `grep`;`multi_grep` 上游标注为有害、默认关闭,wrapper 在加载前
删除 `PI_FFF_MULTIGREP` 环境变量,使其永远无法注册。RTK 对所有 `grep` 工具结果直接跳过,
也默认关闭 Bash 命令改写,因此不会把 `rg`、`grep`、`find` 或 `fd` 改写到 RTK。 也默认关闭 Bash 命令改写,因此不会把 `rg`、`grep`、`find` 或 `fd` 改写到 RTK。
`extensions/bash-override.ts` 用 Pi 的 `createBashToolDefinition` 重新注册内置 `bash`,
把「不要用 bash 搜索」的警告经 `tools.jsonc` 的 `descriptionAppend` 写进工具 schema,
让模型在调用时就看到替代工具;执行逻辑、输出结构和 codemode 的 `tools.bash` 说明完全沿用上游定义。
### 输出压缩归 RTK ### 输出压缩归 RTK
RTK 保留 Bash、read、build、test、lint 和 Git 输出压缩。默认 `read` 的有损压缩 RTK 保留 Bash、read、build、test、lint 和 Git 输出压缩。默认 `read` 的有损压缩
+5 -6
View File
@@ -37,7 +37,6 @@
"edit": "allow", "edit": "allow",
"grep": "allow", "grep": "allow",
"find": "allow", "find": "allow",
"multi_grep": "allow",
"ls": "allow", "ls": "allow",
"codegraph_explore": "allow", "codegraph_explore": "allow",
"lsp_diagnostics": "allow", "lsp_diagnostics": "allow",
@@ -103,10 +102,10 @@
}, },
"bash": { "bash": {
"*": "allow", "*": "allow",
"rg*": "deny", "rg*": { "action": "deny", "reason": "Bash content search is disabled. Locally use the grep tool (pattern + path/exclude; regex alternation for several names); to extract/dedupe/count matches, call tools.grep in codemode and process in JS; to filter command output, use overflow_query on the stored result. Over SSH use ssh_grep." },
"grep*": "deny", "grep*": { "action": "deny", "reason": "Bash content search is disabled. Locally use the grep tool (pattern + path/exclude; regex alternation for several names); to extract/dedupe/count matches, call tools.grep in codemode and process in JS; to filter command output, use overflow_query on the stored result. Over SSH use ssh_grep." },
"find*": "deny", "find*": { "action": "deny", "reason": "Bash file search is disabled. Locally use the find tool (fuzzy path, or a glob in path such as 'src/**/*.ts'); to pass a file list to a program use $(git ls-files '<glob>'). Over SSH use ssh_find." },
"fd*": "deny", "fd*": { "action": "deny", "reason": "Bash file search is disabled. Locally use the find tool (fuzzy path, or a glob in path such as 'src/**/*.ts'); to pass a file list to a program use $(git ls-files '<glob>'). Over SSH use ssh_find." },
"git *": "ask", "git *": "ask",
"git status*": "allow", "git status*": "allow",
"git diff*": "allow", "git diff*": "allow",
@@ -114,7 +113,7 @@
"git show*": "allow", "git show*": "allow",
"git rev-parse*": "allow", "git rev-parse*": "allow",
"git ls-files*": "allow", "git ls-files*": "allow",
"git grep*": "deny", "git grep*": { "action": "deny", "reason": "Bash content search is disabled. Locally use the grep tool (pattern + path/exclude; regex alternation for several names); to extract/dedupe/count matches, call tools.grep in codemode and process in JS; to filter command output, use overflow_query on the stored result. Over SSH use ssh_grep." },
"git branch": "allow", "git branch": "allow",
"git branch --show-current*": "allow", "git branch --show-current*": "allow",
"git remote -v": "allow", "git remote -v": "allow",
+17 -5
View File
@@ -16,16 +16,25 @@
"text": "Use LSP for symbol definitions, references, hover/type information, and post-edit diagnostics when the language server supports the file." "text": "Use LSP for symbol definitions, references, hover/type information, and post-edit diagnostics when the language server supports the file."
}, },
{ {
"whenAny": ["find", "grep", "multi_grep"], "whenAny": ["find", "grep"],
"text": "For literal search, narrow in stages instead of requesting a repository-wide dump:\n 1. Use find to identify likely files or directories. Constrain path/glob and keep the result limit small.\n 2. Use grep or multi_grep only inside the narrowed path or file to obtain matching line numbers. Start with a specific or literal pattern, a small context window, and a bounded limit.\n 3. Use read with offset/limit only for the exact matching region when full source is still needed." "text": "Pick the local search tool by what you already know:\n - Exact identifier or text: grep with a literal pattern, constrained by path/exclude. Do not run find first just to locate it.\n - Several identifiers or naming variants: one grep with a regex alternation (e.g. user_id|userId|UserId) instead of several greps.\n - A filename, path fragment, or feature concept: find (fuzzy whole-path match; use a glob in path for exact names).\n - Then read only the matching region with offset/limit."
}, },
{ {
"whenAny": ["find", "grep", "multi_grep"], "whenAny": ["find", "grep"],
"text": "Do not run a broad search expected to return hundreds or thousands of files or matches. If results truncate or hit a limit, narrow the path, glob, or pattern instead of increasing the limit or dumping all results." "text": "Do not run a broad search expected to return hundreds or thousands of files or matches. If results truncate or hit a limit, narrow the path, glob, or pattern instead of increasing the limit or dumping all results."
}, },
{
"whenAll": ["codemode"],
"whenAny": ["find", "grep"],
"text": "To extract, deduplicate, count, or aggregate across search matches (what grep -o | sort -u | wc would do), call tools.grep / tools.find inside one codemode script and post-process the results in JavaScript."
},
{ {
"whenAny": ["bash"], "whenAny": ["bash"],
"text": "Use bash for builds, tests, git, package scripts, and other programs. Never search files by running grep, find, rg, or fd through bash; use the find/grep tools." "text": "Use bash for builds, tests, git, package scripts, and other programs. Never run grep, rg, find, fd, or git grep in bash — standalone, piped, inside a loop, or in $(…). Search files with the grep/find tools; when a program needs a file list (wc, node, tsc), pass $(git ls-files '<glob>') as arguments rather than piping into xargs."
},
{
"whenAll": ["bash", "overflow_query"],
"text": "To filter a command's output, run it without | grep; if the result carries a [pi-overflow id=…] marker, search it with overflow_query instead of re-running."
}, },
{ {
"whenAny": ["ssh_connect"], "whenAny": ["ssh_connect"],
@@ -59,12 +68,15 @@
}, },
{ {
"whenAny": ["read"], "whenAny": ["read"],
"text": "Use read only when exact source is needed or the file is small. Before editing, request the smallest useful offset/limit range; do not read a complete large file merely to copy, compare, hash, count, explore, or summarize it." "text": "Use read only when exact source is needed or the file is small. Before editing, request the smallest useful offset/limit range; do not read a complete large source file merely to copy, compare, hash, count, or explore it. Reading project overview docs such as a README in full is fine."
}, },
{ {
"whenAll": ["read", "edit"], "whenAll": ["read", "edit"],
"text": "For existing-file changes, obtain fresh LINE#HASH anchors with a small read (find/grep output has no anchors), then use anchored edit operations. Reuse fresh anchors returned by successful edits for chained changes; re-read only when anchors are stale or the next target region was not shown." "text": "For existing-file changes, obtain fresh LINE#HASH anchors with a small read (find/grep output has no anchors), then use anchored edit operations. Reuse fresh anchors returned by successful edits for chained changes; re-read only when anchors are stale or the next target region was not shown."
}, },
{
"text": "When a tool call is denied by policy, use the alternative named in the denial and continue the subtask; do not drop it or present unverified facts as checked."
},
{ {
"text": "Stop searching once the current evidence is sufficient. Avoid repeating the same lookup through CodeGraph, find, grep, and read without a concrete information gap." "text": "Stop searching once the current evidence is sufficient. Avoid repeating the same lookup through CodeGraph, find, grep, and read without a concrete information gap."
} }
+1 -1
View File
@@ -14,7 +14,7 @@ You locate code and answer "where is X / what references Y" questions for the de
## Hard limits ## Hard limits
- Do not create, modify, move, copy, or delete files, including temporary files anywhere (also /tmp). - Do not create, modify, move, copy, or delete files, including temporary files anywhere (also /tmp).
- Use bash only for read-only commands such as git status, git log, git diff, git show, and ls. Never use output redirection, heredocs, or any command that changes system state. - Use bash only for read-only commands such as git status, git log, git diff, git show, and ls. Never use output redirection, heredocs, or any command that changes system state.
- Never search through bash (grep, rg, find, fd); use the find and grep tools. - Never search through bash (grep, rg, find, fd); use the find and grep tools. For several names use one grep with a regex alternation (a|b); when a program needs a file list, pass $(git ls-files '<glob>').
## Approach ## Approach
- Match the requested breadth: "quick" is one targeted lookup, "medium" covers a few related locations, "very thorough" searches multiple locations and naming conventions. - Match the requested breadth: "quick" is one targeted lookup, "medium" covers a few related locations, "very thorough" searches multiple locations and naming conventions.
+1 -1
View File
@@ -14,7 +14,7 @@ You explore the codebase and return an implementation plan to the delegating age
## Hard limits ## Hard limits
- Do not create, modify, move, copy, or delete files, including temporary files anywhere (also /tmp). - Do not create, modify, move, copy, or delete files, including temporary files anywhere (also /tmp).
- Use bash only for read-only commands such as git status, git log, git diff, git show, and ls. Never use output redirection, heredocs, or any command that changes system state. - Use bash only for read-only commands such as git status, git log, git diff, git show, and ls. Never use output redirection, heredocs, or any command that changes system state.
- Never search through bash (grep, rg, find, fd); use the find and grep tools. - Never search through bash (grep, rg, find, fd); use the find and grep tools. For several names use one grep with a regex alternation (a|b); when a program needs a file list, pass $(git ls-files '<glob>').
## Process ## Process
1. Restate the goal and the constraints you were given. Record anything ambiguous as an open question instead of guessing. 1. Restate the goal and the constraints you were given. Record anything ambiguous as an open question instead of guessing.
+20 -12
View File
@@ -2,23 +2,32 @@
// Bundle-authoritative tool guidance, owned by extensions/system-prompt.ts. // Bundle-authoritative tool guidance, owned by extensions/system-prompt.ts.
// //
// Every tool the bundle can expose must have an entry. Per field: // Every tool the bundle can expose must have an entry. Per field:
// "snippet" — replaces the tool's one-line <tools> entry (null hides it) // "snippet" — replaces the tool's one-line <tools> entry (null hides it)
// "guidelines" — replaces the tool's <rules> lines // "guidelines" — replaces the tool's <rules> lines
// "description" — replaces the tool description at registration; only // "description" — replaces the tool description at registration
// applied to third-party tools loaded through a bundle // "descriptionAppend" — appended to the description at registration (on top
// adapter (in-repo tools edit their own source instead) // of "description" when both are set)
// "description"/"descriptionAppend" only apply to tools registered through a
// bundle adapter (prompt-guard), which includes the bash override and the
// third-party wrappers; other in-repo tools edit their own source instead.
// An absent field keeps the upstream text. "inherit": true means the // An absent field keeps the upstream text. "inherit": true means the
// upstream text was reviewed against this bundle and is kept as-is. // upstream text was reviewed against this bundle and is kept as-is.
"version": 1, "version": 1,
"tools": { "tools": {
"bash": { "bash": {
"snippet": "Run shell commands for builds, tests, git, package scripts, and other programs; search files with the find/grep tools, not bash" "snippet": "Run shell commands for builds, tests, git, package scripts, and other programs; search files with the find/grep tools, not bash",
"descriptionAppend": "Not for searching files: use the grep tool for file contents and the find tool for paths (e.g. `grep -n X src/*.ts` → grep {pattern: \"X\", path: \"src/*.ts\"}); grep, rg, find, fd, and git grep are denied here. When a program needs many files, pass $(git ls-files '<glob>') as arguments instead of piping into xargs, which triggers a permission review.",
"guidelines": [
"You can inspect PI_* environment variables for current model and session details.",
"Search commands are denied in bash; call the tools instead: `grep -n X src/*.ts` → grep {pattern: \"X\", path: \"src/*.ts\"}; `grep -rl X src` → grep {pattern: \"X\", path: \"src/\"} and take the file names; `find . -name '*.ts'` → find {pattern: \"\", path: \"**/*.ts\"}.",
"When a program needs many files (wc, node, tsc), pass $(git ls-files '<glob>') as arguments; piping into xargs triggers a permission review."
]
}, },
"read": { "inherit": true }, "read": { "inherit": true },
"edit": { "edit": {
"guidelines": [ "guidelines": [
"Use edit with LINE#HASH anchors from the latest read or edit result for all file changes; batch every change to one file into a single edit call.", "Use edit with LINE#HASH anchors from the latest read or edit result for all file changes; batch every change to one file into a single edit call.",
"find/grep/multi_grep results carry plain line numbers, not LINE#HASH anchors; read the exact target range before editing.", "find/grep results carry plain line numbers, not LINE#HASH anchors; read the exact target range before editing.",
"Never reuse anchors from an earlier read or edit response once a newer one exists for the same file; treat only the most recent response as carrying valid anchors.", "Never reuse anchors from an earlier read or edit response once a newer one exists for the same file; treat only the most recent response as carrying valid anchors.",
"After a successful edit, the returned --- Anchors --- block replaces a re-read for nearby follow-up edits.", "After a successful edit, the returned --- Anchors --- block replaces a re-read for nearby follow-up edits.",
"On [E_STALE_ANCHOR], the error lists the stale refs and any content-matched candidate anchors; re-read the file to get current anchors before retrying." "On [E_STALE_ANCHOR], the error lists the stale refs and any content-matched candidate anchors; re-read the file to get current anchors before retrying."
@@ -28,19 +37,18 @@
"write": { "inherit": true }, "write": { "inherit": true },
"ls": { "inherit": true }, "ls": { "inherit": true },
"powershell": { "inherit": true }, "powershell": { "inherit": true },
"grep": { "inherit": true }, "grep": { "snippet": "Search file contents via the FFF index (git-aware, smart-case, frecency-ranked, regex or literal); replaces bash grep/rg" },
"find": { "inherit": true }, "find": { "snippet": "Find files by fuzzy path or glob via the FFF index; replaces bash find/fd" },
"multi_grep": { "inherit": true },
"tool_search": { "inherit": true }, "tool_search": { "inherit": true },
"codemode": { "codemode": {
"guidelines": [ "guidelines": [
"Use codemode to batch two or more independent tool calls (Promise.allSettled), chain dependent lookups, or filter large output down to what you need; call a single tool directly instead of wrapping it in a script.", "Use codemode to batch two or more independent tool calls (Promise.allSettled), chain dependent lookups, or filter large output down to what you need; call a single tool directly instead of wrapping it in a script.",
"Call edit, write, ssh_connect, ssh_cd, ask_user_question, and Agent directly, never from a codemode script.", "Call edit, write, ssh_connect, ssh_cd, ask_user_question, and Agent directly, never from a codemode script.",
"Inside scripts, search with tools.grep, tools.find, or tools.multi_grep; never run grep, find, rg, or fd through tools.bash." "Inside scripts, search with tools.grep or tools.find and do extraction, dedup, and counting in JavaScript; never run grep, find, rg, or fd through tools.bash."
] ]
}, },
"codegraph_explore": { "inherit": true }, "codegraph_explore": { "snippet": "Explore code structure from the CodeGraph index: symbols, relationships, call flows, and their source" },
"lsp_diagnostics": { "inherit": true }, "lsp_diagnostics": { "inherit": true },
"lsp_definition": { "inherit": true }, "lsp_definition": { "inherit": true },
"lsp_references": { "inherit": true }, "lsp_references": { "inherit": true },
+2 -1
View File
@@ -24,7 +24,8 @@
## 各 wrapper 约定 ## 各 wrapper 约定
- `fff-override.ts`:强制 FFF 官方 `override` 模式,统一接管 `find`、`grep`、`multi_grep` 和 `@` 补全;FFF 独占本地字面搜索,远端服务器搜索由 `ssh_find` / `ssh_grep` 负责,RTK 不得接管搜索工具(见 `pi-rtk-optimizer/AGENTS.md`)。显式 CLI flag 仍遵循 FFF 官方优先级。 - `fff-override.ts`:强制 FFF 官方 `override` 模式,统一接管 `find`、`grep` 和 `@` 补全;`multi_grep` 按上游保持关闭(加载前删除 `PI_FFF_MULTIGREP` 环境变量)。FFF 独占本地字面搜索,远端服务器搜索由 `ssh_find` / `ssh_grep` 负责,RTK 不得接管搜索工具(见 `pi-rtk-optimizer/AGENTS.md`)。显式 CLI flag 仍遵循 FFF 官方优先级。
- `bash-override.ts`:用 `createBashToolDefinition` 重新注册内置 `bash`,目的只是让 `tools.jsonc` 的 `descriptionAppend` 落到模型可见描述里(prompt-guard 在注册时拼接)。ExtensionContext 不暴露 session settings,因此 `shellPath`/`commandPrefix` 在首次 execute 时按 `ctx.cwd` 经 `SettingsManager.create(ctx.cwd, getAgentDir())` 惰性解析并按 cwd 缓存,metadata 来自 `process.cwd()` 的定义;执行、输出结构与 codemode 说明完全委托上游定义,不得改动上游执行逻辑,也不得用 prepareLoadout 或 provider-payload 改写实现同样效果。
- `codegraph.ts` + `codegraph-client.ts`:注册原生 `codegraph_explore` 工具,按索引根惰性常驻 `codegraph serve --mcp -p <root>` 子进程,经组合包自有 stdio 逐行 JSON-RPC 客户端直连(不经 shell、不带 pi-mcp-adapter 或 MCP SDK);serve 进程内置文件监听自动同步索引,无结果缓存。子进程 10 分钟无调用自动关闭、会话结束统一关闭;调用中死亡换新客户端透明重试一次;其启动的上游 daemon(`.codegraph/daemon.sock`)退出后可多驻留 ≤5 分钟,属上游行为,组合包不管理。 - `codegraph.ts` + `codegraph-client.ts`:注册原生 `codegraph_explore` 工具,按索引根惰性常驻 `codegraph serve --mcp -p <root>` 子进程,经组合包自有 stdio 逐行 JSON-RPC 客户端直连(不经 shell、不带 pi-mcp-adapter 或 MCP SDK);serve 进程内置文件监听自动同步索引,无结果缓存。子进程 10 分钟无调用自动关闭、会话结束统一关闭;调用中死亡换新客户端透明重试一次;其启动的上游 daemon(`.codegraph/daemon.sock`)退出后可多驻留 ≤5 分钟,属上游行为,组合包不管理。
- CodeGraph 是机器级依赖,`install.sh`/`update.sh` 经官方安装器管理 `codegraph` CLI,组合包不内置其运行时。安装、升级和普通启动流程不得执行 `codegraph init`;只有 `codegraph_explore` 在未初始化项目上被调用、且用户在 `ctx.ui.confirm` 中明确确认后才可 `codegraph init` 当前目录,拒绝记入会话级 declined 不再重复询问;init 失败必须如实报告并让模型回退内置工具;不得改动 CodeGraph 更新或遥测设置。CLI 缺失、init 被拒或服务调用失败都不得抛出,必须以文本结果打回并引导模型改用 `find`/`grep`/`read`/LSP。已移除的 `plugin-init.ts`(原 `/plugin_init`)职责已并入本文件的惰性确认,不得恢复。 - CodeGraph 是机器级依赖,`install.sh`/`update.sh` 经官方安装器管理 `codegraph` CLI,组合包不内置其运行时。安装、升级和普通启动流程不得执行 `codegraph init`;只有 `codegraph_explore` 在未初始化项目上被调用、且用户在 `ctx.ui.confirm` 中明确确认后才可 `codegraph init` 当前目录,拒绝记入会话级 declined 不再重复询问;init 失败必须如实报告并让模型回退内置工具;不得改动 CodeGraph 更新或遥测设置。CLI 缺失、init 被拒或服务调用失败都不得抛出,必须以文本结果打回并引导模型改用 `find`/`grep`/`read`/LSP。已移除的 `plugin-init.ts`(原 `/plugin_init`)职责已并入本文件的惰性确认,不得恢复。
- `hashline.ts` + `hashline-config.ts`:部署组合包权威 `config/pi-hashline-edit.json` 后加载 `pi-hashline-edit`;默认以 2 字符行哈希覆盖内置 `read` / `edit`,`grep` 与 `replaceText` 均关闭。它保证锚定编辑而不负责压缩,大文件上下文节省必须来自先定位/隔离分析、再做最小范围 `offset/limit` 读取。 - `hashline.ts` + `hashline-config.ts`:部署组合包权威 `config/pi-hashline-edit.json` 后加载 `pi-hashline-edit`;默认以 2 字符行哈希覆盖内置 `read` / `edit`,`grep` 与 `replaceText` 均关闭。它保证锚定编辑而不负责压缩,大文件上下文节省必须来自先定位/隔离分析、再做最小范围 `offset/limit` 读取。
+38
View File
@@ -0,0 +1,38 @@
import {
createBashToolDefinition,
type ExtensionAPI,
getAgentDir,
SettingsManager,
} from "@earendil-works/pi-coding-agent";
import { guardExtensionApi } from "./prompt-guard.ts";
type BashDefinition = ReturnType<typeof createBashToolDefinition>;
/** Re-register Pi's bash so tools.jsonc can append to its model-facing description; execution stays upstream. */
export default function bashOverrideExtension(pi: ExtensionAPI): void {
const sessionDefinitions = new Map<string, BashDefinition>();
const sessionDefinition = (cwd: string): BashDefinition => {
let definition = sessionDefinitions.get(cwd);
if (definition === undefined) {
const settings = SettingsManager.create(cwd, getAgentDir());
definition = createBashToolDefinition(cwd, {
shellPath: settings.getShellPath(),
commandPrefix: settings.getShellCommandPrefix(),
});
sessionDefinitions.set(cwd, definition);
}
return definition;
};
guardExtensionApi(pi).registerTool({
...sessionDefinition(process.cwd()),
execute: (toolCallId, params, signal, onUpdate, ctx) =>
sessionDefinition(ctx?.cwd ?? process.cwd()).execute(
toolCallId,
params,
signal,
onUpdate,
ctx,
),
});
}
+1
View File
@@ -5,5 +5,6 @@ import { guardExtensionApi } from "./prompt-guard.ts";
/** Load FFF as the bundle's canonical `find` and `grep` implementation. */ /** Load FFF as the bundle's canonical `find` and `grep` implementation. */
export default function fffOverrideExtension(pi: ExtensionAPI): void { export default function fffOverrideExtension(pi: ExtensionAPI): void {
process.env.PI_FFF_MODE = "override"; process.env.PI_FFF_MODE = "override";
delete process.env.PI_FFF_MULTIGREP;
fffExtension(guardExtensionApi(pi)); fffExtension(guardExtensionApi(pi));
} }
+28 -7
View File
@@ -1,11 +1,28 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { loadToolCatalog, type ToolCatalog } from "./system-prompt-config.ts"; import {
loadToolCatalog,
type ToolCatalog,
type ToolCatalogEntry,
} from "./system-prompt-config.ts";
type AnyMessage = { role?: string; content?: unknown } & Record<string, unknown>; type AnyMessage = { role?: string; content?: unknown } & Record<string, unknown>;
type AnyHandler = (event: never, ctx: never) => unknown; type AnyHandler = (event: never, ctx: never) => unknown;
/**
* The description a tool registers with under its catalog entry: a `description`
* override replaces the upstream text, a `descriptionAppend` is joined onto
* whichever base wins (override first, upstream otherwise) with a blank line.
*/
export function catalogDescription(description: string, entry: ToolCatalogEntry): string {
if ("inherit" in entry) return description;
const base = typeof entry.description === "string" ? entry.description : description;
return typeof entry.descriptionAppend === "string"
? `${base.trimEnd()}\n\n${entry.descriptionAppend}`
: base;
}
export interface PromptGuardOptions { export interface PromptGuardOptions {
/** /**
* Post-process the messages a wrapped `context` handler leaves behind. * Post-process the messages a wrapped `context` handler leaves behind.
@@ -19,9 +36,11 @@ export interface PromptGuardOptions {
* Proxy an `ExtensionAPI` for third-party extensions so they cannot bypass the * Proxy an `ExtensionAPI` for third-party extensions so they cannot bypass the
* bundle's single system-prompt owner (`extensions/system-prompt.ts`): * bundle's single system-prompt owner (`extensions/system-prompt.ts`):
* *
* - `registerTool` applies the tool's `description` override from * - `registerTool` applies the tool's `description` override and
* `config/prompt/tools.jsonc` when the catalog carries one (lazily loaded; * `descriptionAppend` suffix from `config/prompt/tools.jsonc` when the
* a catalog error keeps the upstream description). * catalog carries them (lazily loaded; a catalog error keeps the upstream
* description). An append composes onto the override when both are set, or
* onto the upstream description when only the append is set.
* - `before_agent_start` handlers keep every returned field except * - `before_agent_start` handlers keep every returned field except
* `systemPrompt`, which is dropped. * `systemPrompt`, which is dropped.
* - `context` handlers are post-processed through `opts.contextFilter`. * - `context` handlers are post-processed through `opts.contextFilter`.
@@ -42,10 +61,12 @@ export function guardExtensionApi(pi: ExtensionAPI, opts: PromptGuardOptions = {
const registerTool: ExtensionAPI["registerTool"] = (tool) => { const registerTool: ExtensionAPI["registerTool"] = (tool) => {
const entry = catalog()?.tools[tool.name]; const entry = catalog()?.tools[tool.name];
if (entry !== undefined && !("inherit" in entry) && typeof entry.description === "string") { const description =
pi.registerTool({ ...tool, description: entry.description }); entry === undefined ? tool.description : catalogDescription(tool.description, entry);
} else { if (description === tool.description) {
pi.registerTool(tool); pi.registerTool(tool);
} else {
pi.registerTool({ ...tool, description });
} }
}; };
+15 -7
View File
@@ -10,7 +10,12 @@ import { fileURLToPath } from "node:url";
/** Entry in `config/prompt/tools.jsonc`. */ /** Entry in `config/prompt/tools.jsonc`. */
export type ToolCatalogEntry = export type ToolCatalogEntry =
| { inherit: true } | { inherit: true }
| { snippet?: string | null; guidelines?: string[]; description?: string }; | {
snippet?: string | null;
guidelines?: string[];
description?: string;
descriptionAppend?: string;
};
export interface ToolCatalog { export interface ToolCatalog {
version: 1; version: 1;
@@ -152,7 +157,7 @@ function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((entry) => typeof entry === "string"); return Array.isArray(value) && value.every((entry) => typeof entry === "string");
} }
const TOOL_ENTRY_KEYS = new Set(["inherit", "snippet", "guidelines", "description"]); const TOOL_ENTRY_KEYS = new Set(["inherit", "snippet", "guidelines", "description", "descriptionAppend"]);
function validateToolEntry(name: string, raw: unknown): ToolCatalogEntry { function validateToolEntry(name: string, raw: unknown): ToolCatalogEntry {
if (!isPlainObject(raw)) { if (!isPlainObject(raw)) {
@@ -170,9 +175,9 @@ function validateToolEntry(name: string, raw: unknown): ToolCatalogEntry {
return { inherit: true }; return { inherit: true };
} }
const hasField = const hasField =
"snippet" in raw || "guidelines" in raw || "description" in raw; "snippet" in raw || "guidelines" in raw || "description" in raw || "descriptionAppend" in raw;
if (!hasField) { if (!hasField) {
fail("tools.jsonc", `tool "${name}" needs "inherit": true or at least one of snippet/guidelines/description`); fail("tools.jsonc", `tool "${name}" needs "inherit": true or at least one of snippet/guidelines/description/descriptionAppend`);
} }
if ("snippet" in raw && raw.snippet !== null && typeof raw.snippet !== "string") { if ("snippet" in raw && raw.snippet !== null && typeof raw.snippet !== "string") {
fail("tools.jsonc", `tool "${name}".snippet must be a string or null`); fail("tools.jsonc", `tool "${name}".snippet must be a string or null`);
@@ -183,10 +188,13 @@ function validateToolEntry(name: string, raw: unknown): ToolCatalogEntry {
if ("description" in raw && typeof raw.description !== "string") { if ("description" in raw && typeof raw.description !== "string") {
fail("tools.jsonc", `tool "${name}".description must be a string`); fail("tools.jsonc", `tool "${name}".description must be a string`);
} }
if ("descriptionAppend" in raw && typeof raw.descriptionAppend !== "string") {
fail("tools.jsonc", `tool "${name}".descriptionAppend must be a string`);
}
return raw as ToolCatalogEntry; return raw as ToolCatalogEntry;
} }
function validateToolCatalog(raw: unknown): ToolCatalog { export function validateToolCatalog(raw: unknown): ToolCatalog {
if (!isPlainObject(raw) || raw.version !== 1 || !isPlainObject(raw.tools)) { if (!isPlainObject(raw) || raw.version !== 1 || !isPlainObject(raw.tools)) {
fail("tools.jsonc", 'expected { "version": 1, "tools": { ... } }'); fail("tools.jsonc", 'expected { "version": 1, "tools": { ... } }');
} }
@@ -321,8 +329,8 @@ export function isSubagentCustomPrompt(customPrompt: unknown): boolean {
* Apply the tool catalog to the mutable prompt options: a `snippet` string * Apply the tool catalog to the mutable prompt options: a `snippet` string
* replaces the tool's `<tools>` line (`null` hides it), `guidelines` replaces * replaces the tool's `<tools>` line (`null` hides it), `guidelines` replaces
* the tool's `<rules>` lines; `{inherit:true}` entries are left untouched. * the tool's `<rules>` lines; `{inherit:true}` entries are left untouched.
* `description` is not applied here — it is a registration-time concern handled * `description` and `descriptionAppend` are not applied here — they are
* by `extensions/prompt-guard.ts`. * registration-time concerns handled by `extensions/prompt-guard.ts`.
* *
* Returns the selected tool names that have no catalog entry at all. * Returns the selected tool names that have no catalog entry at all.
*/ */
+1
View File
@@ -123,6 +123,7 @@
"extensions": [ "extensions": [
"./pi-overflow/capture.ts", "./pi-overflow/capture.ts",
"./extensions/fff-override.ts", "./extensions/fff-override.ts",
"./extensions/bash-override.ts",
"./pi-web-search/index.ts", "./pi-web-search/index.ts",
"./extensions/codegraph.ts", "./extensions/codegraph.ts",
"./pi-rtk-optimizer/index.ts", "./pi-rtk-optimizer/index.ts",
+1
View File
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- **Descend into bash control-flow bodies when enumerating command units.** `for`/`while`/`until` loops, `if`/`elif`/`else` branches, `case` items, `{ … }` brace groups, function definitions, and `!`-negated commands were previously matched only as whole-statement text, so a denied command hidden in a loop body or behind `!` (e.g. `for d in */; do find "$d" …; done`, `! rm -rf x`) bypassed every bash pattern — as did a substitution inside a value (`x=$(grep foo f)`, `export X=$(find .)`). The enumerator still emits each enclosing statement as a unit and now also emits every condition, body, and negated command — plus substitutions hosted in value lists, case subjects, assignments, declarations, and other non-command positions — so the most restrictive decision wins; pure body containers (`do …; done`, `elif`/`else` clauses, `case` items) no longer produce their own units. Hard guards share the same enumeration and now reach those nested commands too.
- **Replace the AutoReview text-JSON chat reviewer with a conversational tool-call reviewer.** The reviewer makes one independent model call per ask — a fixed review prompt, the rendered transcript, and a bounded JSON description of the planned action — and reaches its verdict only by calling one of two tools, `approve` or `reject`, each carrying a `reason`. A reply with no tool call counts as no verdict; multiple calls, unknown tools, and missing reasons count as invalid responses; neither is retried. An experimental TypeSafe Jev classifier rewrite explored in the working tree was dropped before release and never shipped. - **Replace the AutoReview text-JSON chat reviewer with a conversational tool-call reviewer.** The reviewer makes one independent model call per ask — a fixed review prompt, the rendered transcript, and a bounded JSON description of the planned action — and reaches its verdict only by calling one of two tools, `approve` or `reject`, each carrying a `reason`. A reply with no tool call counts as no verdict; multiple calls, unknown tools, and missing reasons count as invalid responses; neither is retried. An experimental TypeSafe Jev classifier rewrite explored in the working tree was dropped before release and never shipped.
- **The review model follows the session by default.** With `autoReview.provider` and `autoReview.model` both unset, the reviewer calls the session's current model (`ctx.model`), resolved live per ask so a mid-session switch applies to the next request. Setting both pins a fixed reviewer; setting exactly one is a misconfiguration that warns once at `session_start` and defers every ask. - **The review model follows the session by default.** With `autoReview.provider` and `autoReview.model` both unset, the reviewer calls the session's current model (`ctx.model`), resolved live per ask so a mid-session switch applies to the next request. Setting both pins a fixed reviewer; setting exactly one is a misconfiguration that warns once at `session_start` and defers every ask.
- **A rejection defers instead of denying.** `reject` maps to `defer` and the model's `reason` is shown in the human permission prompt, labelled as model-generated. Every review failure — timeout, provider error, malformed or truncated reply, unresolved model — also defers with a fixed system-authored note. Pressing Esc during a review aborts it and denies outright, so no human dialog appears after the user already refused. - **A rejection defers instead of denying.** `reject` maps to `defer` and the model's `reason` is shown in the human permission prompt, labelled as model-generated. Every review failure — timeout, provider error, malformed or truncated reply, unresolved model — also defers with a fixed system-authored note. Pressing Esc during a review aborts it and denies outright, so no human dialog appears after the user already refused.
+4
View File
@@ -21,3 +21,7 @@ AutoReview 源码已整合进 `src/auto-review/`,不再保留或加载第二
## 本地维护 ## 本地维护
本目录不是 submodule,不保留嵌套 `.git`、上游 `.pi` 本地状态、`dist/` 或构建产物。同步任一上游时必须从其源码仓库的明确 tag/commit 移植并与本地改动对照审查,不得整体覆盖,也不得使用 npm 预编译产物作为导入源。 本目录不是 submodule,不保留嵌套 `.git`、上游 `.pi` 本地状态、`dist/` 或构建产物。同步任一上游时必须从其源码仓库的明确 tag/commit 移植并与本地改动对照审查,不得整体覆盖,也不得使用 npm 预编译产物作为导入源。
## 本地改动
- Bash 命令枚举在保留整条控制流语句为独立 command unit 的基础上递归下降:`for`/`while`/`until`、`if`/`elif`/`else`、`case` 分支、`{ … }` 组合语句、函数定义与 `!` 取反命令体内的命令也按 bash pattern 逐条判定;赋值与声明值(`x=$(…)`、`export X=$(…)`)、值列表、case 词与 `[[ … ]]` 测试中的 `$(…)` 替换同样枚举,而 `do …; done`、`elif`/`else`、`case` 分支等纯容器不再单独产生 unit。藏在循环体、取反前缀或赋值载荷里的 `find`/`grep`/`rm` 等不再绕过 deny 或 hard guard。
+4 -1
View File
@@ -370,7 +370,10 @@ This holds wherever the substitution appears, not only in argument position.
A substitution in a **redirect target** (`echo hi > $(rm *.txt)`, `cat < <(rm c)`, ``echo hi 2> `rm d` ``) and one in an **interpolating heredoc body** (`cat <<EOF` with `$(rm e)` in the body) are evaluated the same way. A substitution in a **redirect target** (`echo hi > $(rm *.txt)`, `cat < <(rm c)`, ``echo hi 2> `rm d` ``) and one in an **interpolating heredoc body** (`cat <<EOF` with `$(rm e)` in the body) are evaluated the same way.
A quoted heredoc delimiter (`<<'EOF'` or `<<"EOF"`) does not interpolate, so its body is literal text and nothing in it is evaluated as a command. A quoted heredoc delimiter (`<<'EOF'` or `<<"EOF"`) does not interpolate, so its body is literal text and nothing in it is evaluated as a command.
The enclosing command is still matched without its redirect, so a rule like `npm install` keeps matching `npm install > out.txt`. The enclosing command is still matched without its redirect, so a rule like `npm install` keeps matching `npm install > out.txt`.
Control-flow bodies (`if`/`while`/`for`/`case`) and `{ … }` brace groups are not descended into; their contents are matched as part of the enclosing statement's text. Control-flow statements (`for`/`while`/`until` loops, `if`/`elif`/`else` branches, `case` items, `{ … }` brace groups, and function definitions) are evaluated as a whole unit *and* descended into: every command in their conditions and bodies is matched against the bash patterns in addition to the enclosing statement's text.
So `for d in src docs; do find "$d" -name '*.ts'; done` evaluates both the whole loop and `find "$d" -name '*.ts'`; if `find *` is denied, the whole invocation is denied.
Non-command parts of a control-flow statement — the `for` value list, the `case` subject and patterns, `[[ … ]]` tests — are not matched as commands themselves, but substitutions nested inside them still are, so `for f in $(find …)` and `if [[ "$(rm e)" = x ]]` gate the inner command.
Negation and shell bookkeeping get the same treatment: `! grep -q x f` evaluates both `! grep -q x f` and the inner `grep -q x f`, and a substitution inside a value — `x=$(grep foo f)`, `export X=$(find .)`, `local y=$(rg z)` — is gated as a command substitution even though the assignment or declaration itself runs no command.
A leading environment-variable assignment prefix is stripped before matching, so the rule gates the underlying command rather than the prefix. A leading environment-variable assignment prefix is stripped before matching, so the rule gates the underlying command rather than the prefix.
So `AWS_PROFILE=prod aws ec2 …` is matched as `aws ec2 …` — a `aws *` rule applies even though the invocation begins with `AWS_PROFILE=`. So `AWS_PROFILE=prod aws ec2 …` is matched as `aws ec2 …` — a `aws *` rule applies even though the invocation begins with `AWS_PROFILE=`.
@@ -1,6 +1,7 @@
import { import {
EXECUTION_HOST_TYPES, EXECUTION_HOST_TYPES,
forEachNestedExecution, forEachNestedExecution,
NESTED_EXECUTION_CONTEXTS,
} from "#src/access-intent/bash/nested-execution"; } from "#src/access-intent/bash/nested-execution";
import type { TSNode } from "#src/access-intent/bash/parser"; import type { TSNode } from "#src/access-intent/bash/parser";
import { import {
@@ -72,6 +73,62 @@ const COMMAND_ENUM_DESCEND = new Set([
*/ */
const COMMAND_ENUM_SKIP = new Set(["comment", "heredoc_end"]); const COMMAND_ENUM_SKIP = new Set(["comment", "heredoc_end"]);
/**
* Compound statement node types whose bodies really run commands:
* `for`/`while`/`until` loops (tree-sitter-bash folds `until` into
* `while_statement`), `if`/`case` statements, `{ … }` brace groups
* (`compound_statement`), function definitions, and `!`-negated commands.
*
* These were once emitted whole without descending (#306 deferred that), which
* let a gated command hide inside a loop body — a `for …; do find …; done`
* matched only patterns starting with `for`. Each is now emitted whole AND
* enumerated child-by-child; a `negated_command` emits `! grep …` whole and
* then `grep …` so the `!` prefix cannot defeat a command pattern either.
*/
const CONTROL_FLOW_TYPES = new Set([
"for_statement",
"c_style_for_statement",
"while_statement",
"if_statement",
"case_statement",
"compound_statement",
"function_definition",
"negated_command",
]);
/**
* Control-flow nodes that are pure body containers rather than statements in
* their own right: `do …; done`, `elif`/`else` clauses, and `case` items.
* They are descended exactly like {@link CONTROL_FLOW_TYPES} members but are
* not emitted as whole units — nobody writes a command pattern against
* `do …; done` or `a) find . ;;`, so emitting them only adds noise to prompts
* and approval suggestions.
*/
const CONTROL_FLOW_CONTAINERS = new Set([
"do_group",
"elif_clause",
"else_clause",
"case_item",
]);
/**
* Named children of a control-flow node that are statements in their own
* right and get full command enumeration (whole-emit rules, wrapper
* classification, substitution descent). Any other named child — words,
* `variable_name`, a for-loop's value list, a case subject or pattern, a
* `test_command` `[[ … ]]`, a comment — contributes only the nested
* `$(…)`/`<(…)` executions it hosts.
*/
const CONTROL_FLOW_STATEMENT_TYPES = new Set([
"command",
"list",
"pipeline",
"redirected_statement",
"subshell",
...CONTROL_FLOW_TYPES,
...CONTROL_FLOW_CONTAINERS,
]);
/** /**
* Enumerate the command units of a bash program, in source order. * Enumerate the command units of a bash program, in source order.
* *
@@ -82,8 +139,19 @@ const COMMAND_ENUM_SKIP = new Set(["comment", "heredoc_end"]);
* subshells (`( … )`) — emitting each inner command as its own unit *in * subshells (`( … )`) — emitting each inner command as its own unit *in
* addition to* the enclosing command, since those inner commands really execute * addition to* the enclosing command, since those inner commands really execute
* (#306). * (#306).
* Control-flow bodies and `{ … }` brace groups are emitted whole without * Control-flow statements (loops, `if`/`elif`/`else`, `case`, `{ … }` groups,
* descending (deferred). * function definitions, `!` negation) are likewise emitted whole and then
* descended child-by-child: statement children (including nested control flow
* and conditions like `if grep -q …; then`) are enumerated as commands, while
* non-statement children (the for-loop value list, case subject/patterns,
* `[[ … ]]` tests, names) contribute only the nested executions they host.
* Pure body containers (`do …; done`, `elif`/`else` clauses, `case` items)
* are descended without a whole-unit emit of their own.
* Body commands keep the enclosing execution context — a loop inside `$(…)`
* runs there, a top-level loop's body runs in the current shell.
* Remaining named statements (`variable_assignment`, `declaration_command`,
* `test_command`, …) emit whole and still report the nested executions they
* host, so `x=$(grep foo f)` and `export X=$(find .)` gate the inner command.
* *
* The enclosing command/subshell is always still emitted whole, so adding the * The enclosing command/subshell is always still emitted whole, so adding the
* nested units can only ever produce a more-restrictive decision, never weaker. * nested units can only ever produce a more-restrictive decision, never weaker.
@@ -135,9 +203,57 @@ function collectCommandsInto(
return; return;
} }
// Any other named statement (compound_statement `{ … }`, if/while/for/case, if (CONTROL_FLOW_TYPES.has(node.type)) {
// function_definition): emit whole, do not descend — deferred (#306). out.push(makeUnit(node.text, context)); // never-weaker whole emit
descendControlFlowChildren(node, context, out);
return;
}
if (CONTROL_FLOW_CONTAINERS.has(node.type)) {
// Pure body container — enumerate its contents, emit no unit of its own.
descendControlFlowChildren(node, context, out);
return;
}
// Any other named statement (variable_assignment, declaration_command,
// test_command, …) keeps the whole-emit fallback, but a substitution in its
// payload still really runs — `x=$(grep foo f)` or `export X=$(find .)` —
// so hosted executions are enumerated in addition to the whole text.
out.push(makeUnit(node.text, context)); out.push(makeUnit(node.text, context));
collectHostedCommands(node, out);
}
/**
* Enumerate inside a control-flow statement, keeping the enclosing context.
*
* Statement children (`command`, `list`, `pipeline`, `redirected_statement`,
* `subshell`, or nested control flow such as a `do_group`)
* get full enumeration. Other named children are scanned only for nested
* executions: a `command_substitution` child directly (the `$(…)` in
* `for f in $(…)` or `case $(…) in`) is a context node in its own right — the
* strict-subtree {@link forEachNestedExecution} walk would not see it — so it
* is descended with its own context, while words, `variable_name`, test and
* expression subtrees go through the hosted-execution search.
*/
function descendControlFlowChildren(
node: TSNode,
context: BashCommandContext | undefined,
out: BashCommand[],
): void {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (!child?.isNamed) continue;
if (CONTROL_FLOW_STATEMENT_TYPES.has(child.type)) {
collectCommandsInto(child, context, out);
continue;
}
const nestedContext = NESTED_EXECUTION_CONTEXTS.get(child.type);
if (nestedContext) {
descendCommandChildren(child, nestedContext, out);
} else {
collectHostedCommands(child, out);
}
}
} }
function makeUnit( function makeUnit(
@@ -0,0 +1,179 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
resetWarmBashParser,
warmBashParser,
} from "#src/access-intent/bash/parser";
import { parseBashCommandsSync } from "#src/access-intent/bash/sync-commands";
describe("collectCommands — control-flow descent", () => {
beforeEach(async () => {
resetWarmBashParser();
await warmBashParser();
});
afterEach(() => {
resetWarmBashParser();
});
it("emits a for-loop's body commands in addition to the whole statement", () => {
expect(
parseBashCommandsSync('for d in a b; do find "$d" -name x; done'),
).toEqual([
{ text: 'for d in a b; do find "$d" -name x; done' },
{ text: 'find "$d" -name x' },
]);
});
it("emits a command substitution in the for value list as a nested context", () => {
expect(
parseBashCommandsSync('for f in $(find . -name x); do echo "$f"; done'),
).toEqual([
{ text: 'for f in $(find . -name x); do echo "$f"; done' },
{ text: "find . -name x", context: "command_substitution" },
{ text: 'echo "$f"' },
]);
});
it("emits the condition and body commands of a redirected while loop", () => {
expect(
parseBashCommandsSync('while read l; do grep x "$l"; done < list'),
).toEqual([
{ text: 'while read l; do grep x "$l"; done' },
{ text: "read l" },
{ text: 'grep x "$l"' },
]);
});
it("emits an if's condition, branch, and else commands", () => {
expect(
parseBashCommandsSync("if grep -q x f; then echo y; else rm z; fi"),
).toEqual([
{ text: "if grep -q x f; then echo y; else rm z; fi" },
{ text: "grep -q x f" },
{ text: "echo y" },
{ text: "rm z" },
]);
});
it("emits a case item's commands", () => {
expect(parseBashCommandsSync("case $x in a) find . ;; esac")).toEqual([
{ text: "case $x in a) find . ;; esac" },
{ text: "find ." },
]);
});
it("emits a command substitution in the case subject as a nested context", () => {
expect(
parseBashCommandsSync("case $(rm x) in a) find . ;; esac"),
).toEqual([
{ text: "case $(rm x) in a) find . ;; esac" },
{ text: "rm x", context: "command_substitution" },
{ text: "find ." },
]);
});
it("descends a brace group", () => {
expect(parseBashCommandsSync("{ grep x f; }")).toEqual([
{ text: "{ grep x f; }" },
{ text: "grep x f" },
]);
});
it("descends a function definition's body", () => {
expect(parseBashCommandsSync("f() { grep x f; }")).toEqual([
{ text: "f() { grep x f; }" },
{ text: "{ grep x f; }" },
{ text: "grep x f" },
]);
});
it("descends nested loops", () => {
expect(
parseBashCommandsSync("for a in 1; do for b in 2; do find .; done; done"),
).toEqual([
{ text: "for a in 1; do for b in 2; do find .; done; done" },
{ text: "for b in 2; do find .; done" },
{ text: "find ." },
]);
});
it("descends a c-style for loop", () => {
expect(
parseBashCommandsSync("for ((i=0;i<3;i++)); do echo i; done"),
).toEqual([
{ text: "for ((i=0;i<3;i++)); do echo i; done" },
{ text: "echo i" },
]);
});
it("emits no container units for a clean loop body", () => {
expect(parseBashCommandsSync('for d in a b; do echo "$d"; done')).toEqual([
{ text: 'for d in a b; do echo "$d"; done' },
{ text: 'echo "$d"' },
]);
});
it("does not emit a [[ ]] test as a separate command unit", () => {
expect(parseBashCommandsSync("if [[ -f x ]]; then echo y; fi")).toEqual([
{ text: "if [[ -f x ]]; then echo y; fi" },
{ text: "echo y" },
]);
});
it("catches a substitution hosted inside a [[ ]] test", () => {
expect(
parseBashCommandsSync('if [[ "$(rm x)" = y ]]; then echo y; fi'),
).toEqual([
{ text: 'if [[ "$(rm x)" = y ]]; then echo y; fi' },
{ text: "rm x", context: "command_substitution" },
{ text: "echo y" },
]);
});
});
describe("collectCommands — assignment, declaration, and negation", () => {
beforeEach(async () => {
resetWarmBashParser();
await warmBashParser();
});
afterEach(() => {
resetWarmBashParser();
});
it("emits a substitution in a plain assignment value as a nested context", () => {
expect(parseBashCommandsSync("x=$(grep foo f)")).toEqual([
{ text: "x=$(grep foo f)" },
{ text: "grep foo f", context: "command_substitution" },
]);
});
it("emits a substitution in an export declaration value", () => {
expect(parseBashCommandsSync("export X=$(find .)")).toEqual([
{ text: "export X=$(find .)" },
{ text: "find .", context: "command_substitution" },
]);
});
it("emits a substitution in a local declaration inside a function body", () => {
expect(parseBashCommandsSync("f() { local y=$(rg z); }")).toEqual([
{ text: "f() { local y=$(rg z); }" },
{ text: "{ local y=$(rg z); }" },
{ text: "rg z", context: "command_substitution" },
]);
});
it("emits a negated command's inner command as a normal unit", () => {
expect(parseBashCommandsSync("! grep -q x f")).toEqual([
{ text: "! grep -q x f" },
{ text: "grep -q x f" },
]);
});
it("emits a negated condition's inner command inside an if", () => {
expect(parseBashCommandsSync("if ! grep -q x f; then :; fi")).toEqual([
{ text: "if ! grep -q x f; then :; fi" },
{ text: "! grep -q x f" },
{ text: "grep -q x f" },
{ text: ":" },
]);
});
});
@@ -14,10 +14,13 @@ import { BashProgram } from "#src/access-intent/bash/program";
import { resolveBashCommandCheck } from "#src/handlers/gates/bash-command"; import { resolveBashCommandCheck } from "#src/handlers/gates/bash-command";
import { pathFlavorForPlatform } from "#src/path/path-flavor"; import { pathFlavorForPlatform } from "#src/path/path-flavor";
import { PathNormalizer } from "#src/path-normalizer"; import { PathNormalizer } from "#src/path-normalizer";
import { PermissionResolver } from "#src/permission-resolver";
import type { ScopedPermissionResolver } from "#src/permission-resolver"; import type { ScopedPermissionResolver } from "#src/permission-resolver";
import type { PermissionState } from "#src/types"; import { SessionRules } from "#src/session-rules";
import type { DenyWithReason, PermissionState } from "#src/types";
import { makeCheckResult } from "#test/helpers/handler-fixtures"; import { makeCheckResult } from "#test/helpers/handler-fixtures";
import { createInMemoryManager } from "#test/helpers/manager-harness";
/** Decision strength ordering: deny (2) > ask (1) > allow (0). */ /** Decision strength ordering: deny (2) > ask (1) > allow (0). */
const STRENGTH: Record<PermissionState, number> = { const STRENGTH: Record<PermissionState, number> = {
@@ -146,3 +149,103 @@ describe("bash command gate — nested execution hosts do not weaken", () => {
expect(await decide("cat <<'EOF'\n$(rm x)\nEOF", resolver)).toBe("allow"); expect(await decide("cat <<'EOF'\n$(rm x)\nEOF", resolver)).toBe("allow");
}); });
}); });
/**
* The same totality property for control-flow bodies.
*
* A `for`/`while`/`if`/`case`/function was once emitted whole without
* descending, so `for d in …; do find …; done` only ever matched patterns
* starting with `for` — a `find*: deny` rule was bypassed. The enumerator now
* emits the whole statement AND its body commands, so hosting a gated command
* inside control flow cannot weaken its decision.
*/
describe("bash command gate — control-flow bodies do not weaken", () => {
/** Real pattern matching over a bash map: `*` allow, the rest deny-with-reason. */
function makePolicyResolver(
bash: Record<string, PermissionState | DenyWithReason>,
): ScopedPermissionResolver {
const manager = createInMemoryManager({
global: { permission: { "*": "allow", bash } },
});
return new PermissionResolver(manager, new SessionRules());
}
async function checkWith(
command: string,
resolver: ScopedPermissionResolver,
) {
const program = await BashProgram.parse(
command,
new PathNormalizer(pathFlavorForPlatform(process.platform), "/cwd"),
);
return resolveBashCommandCheck(command, program.commands(), undefined, resolver);
}
it("denies the reported repro: find inside a for-loop command substitution", async () => {
const resolver = makePolicyResolver({
"*": "allow",
"find*": { action: "deny", reason: "use the find tool" },
"grep*": { action: "deny", reason: "use the grep tool" },
});
const result = await checkWith(
'cd /Users/sia/code/my-pi && ls -1 extensions/ && echo "=== LOC by ext ===" && for d in pi-*/; do n=$(find "$d" -name \'*.ts\' -not -path \'*/node_modules/*\' -not -path \'*/dist/*\' -not -path \'*/test*\' | xargs wc -l 2>/dev/null | tail -1 | awk \'{print $1}\'); echo "$d $n"; done && echo "=== extensions ts ===" && wc -l extensions/*.ts | tail -3',
resolver,
);
expect(result.state).toBe("deny");
expect(result.command).toBe(
'find "$d" -name \'*.ts\' -not -path \'*/node_modules/*\' -not -path \'*/dist/*\' -not -path \'*/test*\'',
);
expect(result.matchedPattern).toBe("find*");
expect(result.reason).toBe("use the find tool");
expect(result.commandContext).toBe("command_substitution");
});
it.each([
'for d in a b; do find "$d" -name x; done',
'while read l; do grep x "$l"; done < list',
"if grep -q x f; then echo y; else echo n; fi",
"case $x in a) find . ;; esac",
"{ grep x f; }",
"f() { grep x f; }",
"for a in 1; do for b in 2; do find .; done; done",
"x=$(grep foo f)",
"export X=$(find .)",
"f() { local y=$(rg z); }",
"! grep -q x f",
"if ! grep -q x f; then :; fi",
])("denies a gated command inside `%s`", async (command) => {
const resolver = makePolicyResolver({
"*": "allow",
"find*": "deny",
"grep*": "deny",
"rg*": "deny",
});
expect((await checkWith(command, resolver)).state).toBe("deny");
});
it("denies a hard-guard payload hidden in a loop body", async () => {
const resolver = makePolicyResolver({
"*": "allow",
"rm -rf*": "deny",
});
const result = await checkWith("for f in a; do rm -rf /; done", resolver);
expect(result.state).toBe("deny");
expect(result.matchedPattern).toBe("rm -rf*");
});
it("allows a control-flow statement whose body commands are allowed", async () => {
const resolver = makePolicyResolver({
"*": "allow",
"find*": "deny",
"grep*": "deny",
});
expect(
(await checkWith('for d in a b; do echo "$d"; done', resolver)).state,
).toBe("allow");
});
});
@@ -7,6 +7,7 @@ import {
autoReviewConfigFileSchema, autoReviewConfigFileSchema,
normalizeAutoReviewConfig, normalizeAutoReviewConfig,
} from "#src/auto-review/config"; } from "#src/auto-review/config";
import { stripJsonComments } from "#src/config-loader";
import { unifiedConfigSchema } from "#src/config-schema"; import { unifiedConfigSchema } from "#src/config-schema";
/** The bundle's authoritative policy seed, as shipped. */ /** The bundle's authoritative policy seed, as shipped. */
@@ -16,10 +17,10 @@ const SEED_PATH = fileURLToPath(
function readSeed(): Record<string, unknown> { function readSeed(): Record<string, unknown> {
const raw = readFileSync(SEED_PATH, "utf8"); const raw = readFileSync(SEED_PATH, "utf8");
const stripped = raw // Parse with the same string-aware stripper the loader uses: a naive regex
.replace(/^\s*\/\/.*$/gm, "") // strips `/*`-looking runs inside string values (e.g. a `'src/**/*.ts'`
.replace(/\/\*[\s\S]*?\*\//g, ""); // glob in a deny reason) and corrupts the JSON.
return JSON.parse(stripped) as Record<string, unknown>; return JSON.parse(stripJsonComments(raw)) as Record<string, unknown>;
} }
describe("bundled policy seed", () => { describe("bundled policy seed", () => {
+1
View File
@@ -3,6 +3,7 @@
## Unreleased — my-pi maintained source ## Unreleased — my-pi maintained source
### Changed ### Changed
- Removed `multi_grep` from the `filesystem-navigation` group: the bundle wrapper keeps FFF's upstream `PI_FFF_MULTIGREP` opt-in disabled, so the tool is never registered and the group now covers only `ls`.
- Pinned Pi's built-in `codemode` (exposure `model-only`) in `alwaysEnabled` so batching several tool calls through one script never depends on a `tool_search` activation; `platformToolPolicy` now also drops tools with `hidden` or `codemode` (script-only) exposure so they can never enter the catalog or the unknown-tools path. - Pinned Pi's built-in `codemode` (exposure `model-only`) in `alwaysEnabled` so batching several tool calls through one script never depends on a `tool_search` activation; `platformToolPolicy` now also drops tools with `hidden` or `codemode` (script-only) exposure so they can never enter the catalog or the unknown-tools path.
- Removed the `PI_TOOL_SEARCH_CONFIG_PATH` environment override; the extension reads the bundle `config/tool-search.json`, and tests inject a path through the `createToolSearchExtension({ configPath })` factory. - Removed the `PI_TOOL_SEARCH_CONFIG_PATH` environment override; the extension reads the bundle `config/tool-search.json`, and tests inject a path through the `createToolSearchExtension({ configPath })` factory.
- Removed the retired context-mode integration: the `ctx_execute` / `ctx_execute_file` / `ctx_batch_execute` pins left `alwaysEnabled` and the four `context-*` bundle groups were dropped. Large tool results are now bounded by pi-overflow and recoverable through the pinned `overflow_query`. - Removed the retired context-mode integration: the `ctx_execute` / `ctx_execute_file` / `ctx_batch_execute` pins left `alwaysEnabled` and the four `context-*` bundle groups were dropped. Large tool results are now bounded by pi-overflow and recoverable through the pinned `overflow_query`.
+1 -1
View File
@@ -26,7 +26,7 @@ Run `/tool-search-status` to inspect the current catalog source, pinned count, d
| Group | Bundle tools | | Group | Bundle tools |
|---|---| |---|---|
| `filesystem-navigation` | `ls`, `multi_grep` | | `filesystem-navigation` | `ls` |
| `ssh-connection` | `ssh_connect` | | `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` |
+4 -4
View File
@@ -25,11 +25,11 @@ export const BUNDLE_GROUP_DEFINITIONS: BundleGroupDefinition[] = [
{ {
id: "filesystem-navigation", id: "filesystem-navigation",
title: "Filesystem navigation", title: "Filesystem navigation",
summary: "Inspect directory layouts and run bounded multi-pattern literal searches.", summary: "Inspect directory layouts.",
useWhen: ["You need directory entries or several literal searches after narrowing a path"], useWhen: ["You need the alphabetical directory entries of an unknown path"],
avoidWhen: ["Code relationships or types are the real question"], avoidWhen: ["Code relationships or types are the real question"],
keywords: ["files", "directory", "list", "multi grep", "目录", "文件", "多模式搜索"], keywords: ["files", "directory", "list", "目录", "文件"],
tools: ["ls", "multi_grep"], tools: ["ls"],
}, },
{ {
id: "ssh-connection", id: "ssh-connection",
+6 -3
View File
@@ -16,7 +16,10 @@ import {
import { CONFIG_PATH as RTK_CONFIG_PATH } from "../pi-rtk-optimizer/src/constants.ts"; import { CONFIG_PATH as RTK_CONFIG_PATH } from "../pi-rtk-optimizer/src/constants.ts";
import { CONFIG_PATH as COMPACTION_CONFIG_PATH } from "../pi-compaction/src/config.ts"; import { CONFIG_PATH as COMPACTION_CONFIG_PATH } from "../pi-compaction/src/config.ts";
import { WORKTREE_CONFIG_PATH } from "../pi-worktree/src/settings.ts"; import { WORKTREE_CONFIG_PATH } from "../pi-worktree/src/settings.ts";
import { resolvePreamblePath } from "../extensions/system-prompt-config.ts"; import {
resolvePreamblePath,
stripJsoncComments,
} from "../extensions/system-prompt-config.ts";
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const bundleConfigDir = join(repositoryRoot, "config"); const bundleConfigDir = join(repositoryRoot, "config");
@@ -48,9 +51,9 @@ const BUNDLE_CONFIG_FILES = [
"p10k.zsh", "p10k.zsh",
]; ];
/** Strip JSONC comments (line and block) — same approach as the seed test. */ /** Strip JSONC comments (line and block) without touching string literals. */
function stripJsonc(raw: string): string { function stripJsonc(raw: string): string {
return raw.replace(/^\s*\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, ""); return stripJsoncComments(raw);
} }
test("every bundle config file exists and is non-empty", async () => { test("every bundle config file exists and is non-empty", async () => {
+82 -5
View File
@@ -58,6 +58,11 @@ test("all package extensions load together without global registration conflicts
packageJson.pi.extensions.includes("./extensions/codegraph.ts"), packageJson.pi.extensions.includes("./extensions/codegraph.ts"),
"the bundle must expose the native codegraph_explore tool", "the bundle must expose the native codegraph_explore tool",
); );
assert.equal(
packageJson.pi.extensions.indexOf("./extensions/bash-override.ts"),
packageJson.pi.extensions.indexOf("./extensions/fff-override.ts") + 1,
"the bash description override must register right after fff-override",
);
assert.ok( assert.ok(
packageJson.pi.extensions.includes("./pi-web-search/index.ts"), packageJson.pi.extensions.includes("./pi-web-search/index.ts"),
"the bundle must load the unified pi-web-search tools", "the bundle must load the unified pi-web-search tools",
@@ -292,21 +297,51 @@ test("all package extensions load together without global registration conflicts
const home = await mkdtemp(join(tmpdir(), "my-pi-extension-load-")); const home = await mkdtemp(join(tmpdir(), "my-pi-extension-load-"));
const mockBin = join(home, "bin"); const mockBin = join(home, "bin");
await mkdir(mockBin, { recursive: true }); await mkdir(mockBin, { recursive: true });
// Probe loaded LAST: records every registered tool (name + exposure) and the // Probe loaded LAST: records every registered tool (name, exposure,
// final active set so the test can assert the pinned built-in codemode. // description, source) and the final active set so the test can assert the
// pinned built-in codemode. Its async setup also runs the bash override's
// registered definition end to end — executing `echo hi` through the same
// delegation path a model call would use.
const probeOutput = join(home, "probe-tools.json"); const probeOutput = join(home, "probe-tools.json");
const probePath = join(home, "probe.ts"); const probePath = join(home, "probe.ts");
await writeFile( await writeFile(
probePath, probePath,
[ [
'import { writeFileSync } from "node:fs";', 'import { writeFileSync } from "node:fs";',
"export default function (pi: any): void {", `import bashOverrideExtension from ${JSON.stringify(join(repositoryRoot, "extensions", "bash-override.ts"))};`,
"export default async function (pi: any): Promise<void> {",
"\tlet executed: unknown;",
"\ttry {",
"\t\tlet registered: any;",
"\t\tbashOverrideExtension({ registerTool: (tool: any) => { registered = tool; } } as any);",
"\t\tconst result = await registered.execute(",
'\t\t\t"probe-1",',
'\t\t\t{ command: "echo hi" },',
"\t\t\tundefined,",
"\t\t\tundefined,",
`\t\t\t{ cwd: ${JSON.stringify(home)}, sessionManager: { getSessionId: () => "probe-session", getSessionFile: () => undefined } } as any,`,
"\t\t);",
"\t\texecuted = {",
"\t\t\ttext: result?.content?.[0]?.text,",
"\t\t\texitCode: result?.structuredContent?.exit_code,",
"\t\t\thasOutputSchema: result?.structuredContent !== undefined,",
"\t\t\tregisteredDescription: registered?.description,",
"\t\t};",
"\t} catch (error) {",
"\t\texecuted = { error: String(error) };",
"\t}",
'\tpi.on("session_start", () => {', '\tpi.on("session_start", () => {',
"\t\twriteFileSync(", "\t\twriteFileSync(",
`\t\t\t${JSON.stringify(probeOutput)},`, `\t\t\t${JSON.stringify(probeOutput)},`,
"\t\t\tJSON.stringify({", "\t\t\tJSON.stringify({",
"\t\t\t\tall: pi.getAllTools().map((tool: any) => ({ name: tool.name, exposure: tool.exposure })),", "\t\t\t\tall: pi.getAllTools().map((tool: any) => ({",
"\t\t\t\t\tname: tool.name,",
"\t\t\t\t\texposure: tool.exposure,",
"\t\t\t\t\tdescription: tool.description,",
"\t\t\t\t\tsourcePath: tool.sourceInfo?.path,",
"\t\t\t\t})),",
"\t\t\t\tactive: pi.getActiveTools(),", "\t\t\t\tactive: pi.getActiveTools(),",
"\t\t\t\texecuted,",
"\t\t\t}),", "\t\t\t}),",
"\t\t);", "\t\t);",
"\t});", "\t});",
@@ -337,10 +372,52 @@ test("all package extensions load together without global registration conflicts
assert.doesNotMatch(`${result.stdout}\n${result.stderr}`, /Failed to load extension|conflicts with/u); assert.doesNotMatch(`${result.stdout}\n${result.stderr}`, /Failed to load extension|conflicts with/u);
const probe = JSON.parse(await readFile(probeOutput, "utf8")) as { const probe = JSON.parse(await readFile(probeOutput, "utf8")) as {
all: { name: string; exposure?: string }[]; all: { name: string; exposure?: string; description?: string; sourcePath?: string }[];
active: string[]; active: string[];
executed?: {
text?: string;
exitCode?: number;
hasOutputSchema?: boolean;
registeredDescription?: string;
error?: string;
};
}; };
const codemode = probe.all.find((tool) => tool.name === "codemode"); const codemode = probe.all.find((tool) => tool.name === "codemode");
assert.equal(codemode?.exposure, "model-only", "Pi's built-in codemode must load with model-only exposure"); assert.equal(codemode?.exposure, "model-only", "Pi's built-in codemode must load with model-only exposure");
assert.ok(probe.active.includes("codemode"), "Tool Search must pin codemode into the active set"); assert.ok(probe.active.includes("codemode"), "Tool Search must pin codemode into the active set");
assert.ok(
probe.all.every((tool) => tool.name !== "multi_grep"),
"FFF's upstream-flagged multi_grep must stay unregistered (fff-override deletes PI_FFF_MULTIGREP)",
);
assert.ok(
!probe.active.includes("multi_grep"),
"multi_grep must not appear in the active tool set",
);
const bash = probe.all.find((tool) => tool.name === "bash");
assert.ok(bash, "the bundle must expose the bash tool");
assert.ok(
bash.description?.startsWith("Execute a bash command in the current working directory."),
"the bash override must keep Pi's upstream description prefix",
);
assert.ok(
bash.description?.endsWith("which triggers a permission review."),
"the registered bash description must end with the tools.jsonc descriptionAppend",
);
assert.ok(
(bash.sourcePath ?? "").endsWith("extensions/bash-override.ts"),
`bash must be registered by extensions/bash-override.ts, got ${bash.sourcePath}`,
);
const executed = probe.executed ?? {};
assert.equal(executed.error, undefined, `bash override execute must not throw: ${executed.error}`);
assert.equal(executed.exitCode, 0, "echo hi must exit 0 through the delegated definition");
assert.match(executed.text ?? "", /hi/);
assert.equal(
executed.hasOutputSchema,
true,
"the bash override must keep the upstream outputSchema (structuredContent for codemode)",
);
assert.ok(
executed.registeredDescription?.includes("Not for searching files"),
"the catalog descriptionAppend must reach the registered bash description",
);
}); });
+55 -5
View File
@@ -21,6 +21,7 @@ import {
stripJsoncComments, stripJsoncComments,
validateRoutingConfig, validateRoutingConfig,
validateSectionsConfig, validateSectionsConfig,
validateToolCatalog,
type MutablePromptOptions, type MutablePromptOptions,
type RoutingRule, type RoutingRule,
} from "../extensions/system-prompt-config.ts"; } from "../extensions/system-prompt-config.ts";
@@ -28,7 +29,7 @@ import systemPromptExtension, {
createSystemPromptComposer, createSystemPromptComposer,
getSystemPromptDumpPath, getSystemPromptDumpPath,
} from "../extensions/system-prompt.ts"; } from "../extensions/system-prompt.ts";
import { guardExtensionApi } from "../extensions/prompt-guard.ts"; import { catalogDescription, guardExtensionApi } from "../extensions/prompt-guard.ts";
import { BUNDLE_GROUP_DEFINITIONS } from "../pi-tool-search/extensions/bundle-groups.ts"; import { BUNDLE_GROUP_DEFINITIONS } from "../pi-tool-search/extensions/bundle-groups.ts";
import { BUNDLE_TOOL_SEARCH_DEFAULTS } from "../pi-tool-search/extensions/config.ts"; import { BUNDLE_TOOL_SEARCH_DEFAULTS } from "../pi-tool-search/extensions/config.ts";
@@ -93,6 +94,20 @@ test("routing scope validation: main/subagent accepted, other values rejected",
); );
}); });
test("tools.jsonc validation: descriptionAppend accepted as a string, rejected otherwise", () => {
assert.doesNotThrow(() =>
validateToolCatalog({ version: 1, tools: { bash: { descriptionAppend: "extra" } } }),
);
assert.throws(
() => validateToolCatalog({ version: 1, tools: { bash: { descriptionAppend: 5 } } }),
/descriptionAppend must be a string/,
);
assert.throws(
() => validateToolCatalog({ version: 1, tools: { bash: { descriptionAppend: true } } }),
/descriptionAppend must be a string/,
);
});
test("sections.jsonc validation: subagentExclude defaults to [] and rejects bad shapes", () => { test("sections.jsonc validation: subagentExclude defaults to [] and rejects bad shapes", () => {
assert.deepEqual(validateSectionsConfig({ version: 1, order: ["tool_routing"] }), { assert.deepEqual(validateSectionsConfig({ version: 1, order: ["tool_routing"] }), {
version: 1, version: 1,
@@ -255,7 +270,7 @@ test("bundled routing rules select the same guidance the retired tool-routing ex
const routing = loadRoutingRules(); const routing = loadRoutingRules();
const literal = renderRouting(routing.rules, ["find", "grep", "read"]); const literal = renderRouting(routing.rules, ["find", "grep", "read"]);
assert.match(literal, /Use find to identify likely files or directories/); assert.match(literal, /Pick the local search tool by what you already know/);
assert.match(literal, /narrow the path, glob, or pattern instead of increasing the limit/); assert.match(literal, /narrow the path, glob, or pattern instead of increasing the limit/);
const ssh = renderRouting(routing.rules, ["ssh_connect", "ssh_cd", "ssh_bash"]); const ssh = renderRouting(routing.rules, ["ssh_connect", "ssh_cd", "ssh_bash"]);
@@ -267,7 +282,7 @@ test("bundled routing rules select the same guidance the retired tool-routing ex
const minimal = renderRouting(routing.rules, ["read"]); const minimal = renderRouting(routing.rules, ["read"]);
assert.doesNotMatch(minimal, /codegraph_explore first/); assert.doesNotMatch(minimal, /codegraph_explore first/);
assert.doesNotMatch(minimal, /For literal search/); assert.doesNotMatch(minimal, /Pick the local search tool/);
assert.doesNotMatch(minimal, /ssh_connect only when/); assert.doesNotMatch(minimal, /ssh_connect only when/);
assert.match(minimal, /Stop searching once the current evidence is sufficient/); assert.match(minimal, /Stop searching once the current evidence is sufficient/);
}); });
@@ -397,7 +412,6 @@ test("composer splices the preamble, applies the catalog, and orders sections",
read: "reads files", read: "reads files",
bash: "runs commands", bash: "runs commands",
edit: "edits", edit: "edits",
codegraph_explore: "explores",
tool_search: "searches", tool_search: "searches",
}, },
toolGuidelines: { edit: ["upstream edit rule"] }, toolGuidelines: { edit: ["upstream edit rule"] },
@@ -414,6 +428,11 @@ test("composer splices the preamble, applies the catalog, and orders sections",
"the bundle preamble replaces Pi's default preamble", "the bundle preamble replaces Pi's default preamble",
); );
assert.match(output, /<tool_routing>\n- Use codegraph_explore first/); assert.match(output, /<tool_routing>\n- Use codegraph_explore first/);
assert.match(
output,
/<tools>[\s\S]*?- codegraph_explore: Explore code structure from the CodeGraph index/,
"tools.jsonc must add a snippet for codegraph_explore, which has no upstream <tools> entry",
);
assert.match(output, /search files with the find\/grep tools, not bash/); assert.match(output, /search files with the find\/grep tools, not bash/);
assert.match(output, /LINE#HASH anchors/); assert.match(output, /LINE#HASH anchors/);
assert.ok( assert.ok(
@@ -604,7 +623,38 @@ test("prompt guard applies catalog description overrides at registration", () =>
const override = loadToolCatalog().tools.edit; const override = loadToolCatalog().tools.edit;
assert.ok(override && !("inherit" in override) && typeof override.description === "string"); assert.ok(override && !("inherit" in override) && typeof override.description === "string");
assert.equal(tools[0].description, override.description); assert.equal(tools[0].description, override.description);
assert.equal(tools[1].description, "upstream grep", "inherit entries keep the upstream description"); assert.equal(tools[1].description, "upstream grep", "entries without description fields keep the upstream description");
});
test("prompt guard appends the catalog descriptionAppend to the upstream description", () => {
const { pi, tools } = makeRecordingPi();
const guarded = guardExtensionApi(pi as unknown as ExtensionAPI);
guarded.registerTool({ name: "bash", description: "upstream bash" } as never);
guarded.registerTool({ name: "read", description: "upstream read" } as never);
const bashEntry = loadToolCatalog().tools.bash;
assert.ok(bashEntry && !("inherit" in bashEntry) && typeof bashEntry.descriptionAppend === "string");
assert.equal(tools[0].description, `upstream bash\n\n${bashEntry.descriptionAppend}`);
assert.equal(tools[1].description, "upstream read", "inherit entries keep the upstream description");
});
test("catalogDescription composes override, append, and inherit", () => {
assert.equal(
catalogDescription("upstream", { description: "override", descriptionAppend: "append" }),
"override\n\nappend",
"an append sits on top of the description override when both are set",
);
assert.equal(
catalogDescription("upstream \n", { descriptionAppend: "append" }),
"upstream\n\nappend",
"trailing whitespace on the base is trimmed before the blank line",
);
assert.equal(
catalogDescription("upstream", { inherit: true }),
"upstream",
"inherit entries are untouched",
);
}); });
test("prompt guard strips a returned systemPrompt but keeps other fields", async () => { test("prompt guard strips a returned systemPrompt but keeps other fields", async () => {