refactor(permission-system): simplify policy and autoreview flow

This commit is contained in:
云服务部-叶林立
2026-09-03 02:01:06 +08:00
parent 180da95ab7
commit 6e7effa792
112 changed files with 911 additions and 10346 deletions
+13 -13
View File
@@ -28,7 +28,7 @@
- `extensions/plugin-init.ts`:注册用户显式触发的 `/plugin_init`,在确认当前项目后只初始化缺失的 CodeGraph/Hippo 状态,并在全部成功后调用 Pi 官方热重载。 - `extensions/plugin-init.ts`:注册用户显式触发的 `/plugin_init`,在确认当前项目后只初始化缺失的 CodeGraph/Hippo 状态,并在全部成功后调用 Pi 官方热重载。
- `context-mode`:加载上游 Pi adapter 与 skills,提供 `ctx_*` 工具、隔离式大输出处理和会话连续性。 - `context-mode`:加载上游 Pi adapter 与 skills,提供 `ctx_*` 工具、隔离式大输出处理和会话连续性。
- `extensions/hashline.ts`:部署组合包权威 `config/pi-hashline-edit.json` 后加载 `pi-hashline-edit`,以行哈希锚点覆盖内置 `read` / `edit`;Hashline 自带 `grep` 强制关闭。 - `extensions/hashline.ts`:部署组合包权威 `config/pi-hashline-edit.json` 后加载 `pi-hashline-edit`,以行哈希锚点覆盖内置 `read` / `edit`;Hashline 自带 `grep` 强制关闭。
- `extensions/permission-system.ts`:直接加载仓库内 `pi-permission-system/src/index.ts`;只在目标缺失时把 `config/pi-permission-policy.jsonc` / `config/pi-permission-hard-guards.jsonc` 原子部署为 owner-only 用户配置,已有策略永不覆盖,旧全局 `config.json` 仅复制迁移为 `policy.jsonc`。 - `extensions/permission-system.ts`:直接加载仓库内 `pi-permission-system/src/index.ts`;只在目标缺失时把 `config/pi-permission-policy.jsonc` / `config/pi-permission-hard-guards.jsonc` 原子部署为 owner-only 全局用户配置,旧全局 `config.json` 仅复制迁移为 `policy.jsonc`。项目级 policy 不读取;`update.sh` 只可在当前文件精确匹配已知旧默认种子时备份并迁移到新种子,自定义或无效文件必须保留。
- `extensions/lsp.ts`:把组合包内 TypeScript Language Server CLI 物化为绝对命令路径,部署 TypeScript/Kotlin/JDT LS 全局配置并加载 `pi-lsp`。 - `extensions/lsp.ts`:把组合包内 TypeScript Language Server CLI 物化为绝对命令路径,部署 TypeScript/Kotlin/JDT LS 全局配置并加载 `pi-lsp`。
- `extensions/tool-routing.ts`:保留 Pi 默认系统提示词,在每轮开始前按激活工具追加简短路由规则,并提供 `/dump-system-prompt` 将扩展所见的有效提示词写入 `.pi-debug/effective-system-prompt.md`。 - `extensions/tool-routing.ts`:保留 Pi 默认系统提示词,在每轮开始前按激活工具追加简短路由规则,并提供 `/dump-system-prompt` 将扩展所见的有效提示词写入 `.pi-debug/effective-system-prompt.md`。
- `pi-chrome/`:从上游源码导入并由本仓库直接维护,通过用户安装的 unpacked MV3 扩展与 `127.0.0.1:17318` loopback bridge 接管当前已登录 Chrome;控制默认锁定,每个 Pi session 必须显式 `/chrome authorize`。 - `pi-chrome/`:从上游源码导入并由本仓库直接维护,通过用户安装的 unpacked MV3 扩展与 `127.0.0.1:17318` loopback bridge 接管当前已登录 Chrome;控制默认锁定,每个 Pi session 必须显式 `/chrome authorize`。
@@ -84,7 +84,7 @@
- Tool Search 默认常驻核心 `read` / `write` / `edit` / `bash` / `grep` / `find`、`codegraph_explore`、`lsp_diagnostics`、`ctx_execute` / `ctx_execute_file` / `ctx_batch_execute` 与 `tool_search`;Windows 上内置 `powershell` 必须与 `bash` 同属固定本地 Shell 核心工具,非 Windows 平台必须从目录中过滤。首次创建完整默认 `toolSearch` 段时必须通过唯一同目录临时文件以权限 `600` 原子写入并记录 `bundleDefaultsVersion: 2`,已有自定义段只补缺失运行时字段且不得伪造迁移来源标记。其余组合包工具必须优先使用 `pi-tool-search/extensions/bundle-groups.ts` 中提交的权威工作流分组;精确已有分组必须直接激活,不得等待无关未知工具的模型增强。默认最多 5 个动态组、模型组最多 8 个工具、动态工具总数最多 28;`groups` 可原子激活最多 5 个精确组,完整请求超限时不得改变当前状态;加载或组内调用更新 LRU,超限先移除最久未用的非请求组。自然语言查询必须优先精确工具名和已提交 workflow alias,弱匹配或近似同分只能返回候选,不得任意激活;`/tool-search-status` 必须显示当前容量、LRU 顺序与最近淘汰原因。 - Tool Search 默认常驻核心 `read` / `write` / `edit` / `bash` / `grep` / `find`、`codegraph_explore`、`lsp_diagnostics`、`ctx_execute` / `ctx_execute_file` / `ctx_batch_execute` 与 `tool_search`;Windows 上内置 `powershell` 必须与 `bash` 同属固定本地 Shell 核心工具,非 Windows 平台必须从目录中过滤。首次创建完整默认 `toolSearch` 段时必须通过唯一同目录临时文件以权限 `600` 原子写入并记录 `bundleDefaultsVersion: 2`,已有自定义段只补缺失运行时字段且不得伪造迁移来源标记。其余组合包工具必须优先使用 `pi-tool-search/extensions/bundle-groups.ts` 中提交的权威工作流分组;精确已有分组必须直接激活,不得等待无关未知工具的模型增强。默认最多 5 个动态组、模型组最多 8 个工具、动态工具总数最多 28;`groups` 可原子激活最多 5 个精确组,完整请求超限时不得改变当前状态;加载或组内调用更新 LRU,超限先移除最久未用的非请求组。自然语言查询必须优先精确工具名和已提交 workflow alias,弱匹配或近似同分只能返回候选,不得任意激活;`/tool-search-status` 必须显示当前容量、LRU 顺序与最近淘汰原因。
- `pi-chrome` 在未授权时不注册 Chrome 工具;显式授权后出现的 21 个 `chrome_*` 工具必须由 Tool Search 分入 `chrome-navigation`、`chrome-interaction`、`chrome-debugging`,不得落入 unknown-tools 模型增强路径。授权撤销或过期时必须停用全部 21 个工具,包括 `chrome_find` / `chrome_inspect`;Tool Search 重新激活 schema 不能替代每次执行时的授权校验。 - `pi-chrome` 在未授权时不注册 Chrome 工具;显式授权后出现的 21 个 `chrome_*` 工具必须由 Tool Search 分入 `chrome-navigation`、`chrome-interaction`、`chrome-debugging`,不得落入 unknown-tools 模型增强路径。授权撤销或过期时必须停用全部 21 个工具,包括 `chrome_find` / `chrome_inspect`;Tool Search 重新激活 schema 不能替代每次执行时的授权校验。
- 标准组合包工具全部命中预置目录时 Tool Search 不得调用模型、创建用户缓存或发送隐藏 schema。只有存在未识别第三方工具时才允许惰性模型增强;生成结果必须保留全部预置 assignment,模型元数据不得替代真实名称、参数或 schema。缓存必须位于 agent 目录且权限 `600`;模型失败必须继续使用预置目录 + 确定性未知工具分组。`groupOverrides` 优先级高于预置分组。 - 标准组合包工具全部命中预置目录时 Tool Search 不得调用模型、创建用户缓存或发送隐藏 schema。只有存在未识别第三方工具时才允许惰性模型增强;生成结果必须保留全部预置 assignment,模型元数据不得替代真实名称、参数或 schema。缓存必须位于 agent 目录且权限 `600`;模型失败必须继续使用预置目录 + 确定性未知工具分组。`groupOverrides` 优先级高于预置分组。
- Tool Search 必须直接使用当前 `@earendil-works/pi-coding-agent`、`typebox`、`ModelRegistry.complete()` 和 `setActiveTools()`;首次纯增加载使用当前 Pi 的增量结果传播,LRU 替换允许走宿主安全 fallback。不得安装或映射旧 `@mariozechner` runtime,不得恢复 provider payload 改写、代理分发或隐藏 `sendMessage` steer/retry 兼容循环。 - Tool Search 必须直接使用当前 `@earendil-works/pi-coding-agent`、`typebox`、`ModelRegistry.complete()` 和 `setActiveTools()`;首次纯增加载使用当前 Pi 的增量结果传播,LRU 替换允许走宿主安全 fallback。不得安装或映射旧 `@mariozechner` runtime,不得恢复 provider payload 改写、代理分发或隐藏 `sendMessage` steer/retry 兼容循环。Tool Search 是唯一可调用 `getActiveTools()` / `setActiveTools()` 管理工具显隐的组合包扩展;`pi-permission-system` 只能在调用时裁决,不能读写 active tool 列表。
- `.pi-debug/` 是 `/dump-system-prompt` 生成的本地诊断目录,不提交到仓库,也不作为组合包运行时配置源。 - `.pi-debug/` 是 `/dump-system-prompt` 生成的本地诊断目录,不提交到仓库,也不作为组合包运行时配置源。
- CodeGraph MCP 扩展只配置 Pi 到外部 `codegraph` 命令的连接。根包安装本身不安装 CodeGraph;便捷脚本仅在组合包安装完成且用户明确选择 `Y` 后调用官方安装器。安装、升级和普通启动流程不得执行 `codegraph init`;只有用户在目标项目显式调用 `/plugin_init` 并确认后,命令才可以 `.codegraph/codegraph.db` 与 `.hippo/hippo.db` 为权威标志初始化缺失状态。命令必须先预检两个 CLI,任一步失败不得热重载,也不得隐瞒此前已完成的部分初始化;不得改动 CodeGraph 更新或遥测设置。 - CodeGraph MCP 扩展只配置 Pi 到外部 `codegraph` 命令的连接。根包安装本身不安装 CodeGraph;便捷脚本仅在组合包安装完成且用户明确选择 `Y` 后调用官方安装器。安装、升级和普通启动流程不得执行 `codegraph init`;只有用户在目标项目显式调用 `/plugin_init` 并确认后,命令才可以 `.codegraph/codegraph.db` 与 `.hippo/hippo.db` 为权威标志初始化缺失状态。命令必须先预检两个 CLI,任一步失败不得热重载,也不得隐瞒此前已完成的部分初始化;不得改动 CodeGraph 更新或遥测设置。
- CodeGraph MCP 使用 `keep-alive` 并只直接暴露 `codegraph_explore`;命令缺失、项目未初始化或连接失败时不得阻止其他扩展加载。 - CodeGraph MCP 使用 `keep-alive` 并只直接暴露 `codegraph_explore`;命令缺失、项目未初始化或连接失败时不得阻止其他扩展加载。
@@ -99,12 +99,12 @@
- 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 全局默认值;新会话继承全局值,已有分支记录优先,配置缺失或无效时回退为关闭。
- Kitty 完成通知只在 TUI 模式的 `agent_settled` 后发送;自动重试、自动 compaction 和 follow-up 期间不得提前通知或重置总耗时。权限通知只订阅 `permissions:ui_prompt`,在人类确认界面即将出现时立即发送,不得为策略或 AutoReview 已直接决定的请求通知,也不受完成通知时长门槛影响。默认 `o=always`、`a=focus`,每个 Pi Session 使用独立稳定 ID,标题和正文必须 Base64 编码。 - Kitty 完成通知只在 TUI 模式的 `agent_settled` 后发送;自动重试、自动 compaction 和 follow-up 期间不得提前通知或重置总耗时。权限通知只订阅 `permissions:ui_prompt`,在人类确认界面即将出现时立即发送,不得为策略或 AutoReview 已直接决定的请求通知,也不受完成通知时长门槛影响。默认 `o=always`、`a=focus`,每个 Pi Session 使用独立稳定 ID,标题和正文必须 Base64 编码。
- `/notify on|off` 只覆盖当前 Session;持久默认来自 `PI_NOTIFY_*` 环境变量。`PI_NOTIFY_MESSAGE_SOURCE=none` 必须继续提供不泄露回复正文、权限值或请求者细节的隐私模式,非 TUI 模式不得写入 OSC 序列。 - `/notify on|off` 只覆盖当前 Session;持久默认来自 `PI_NOTIFY_*` 环境变量。`PI_NOTIFY_MESSAGE_SOURCE=none` 必须继续提供不泄露回复正文、权限值或请求者细节的隐私模式,非 TUI 模式不得写入 OSC 序列。
- 权限策略默认允许常规工具,允许 FFF 工具;拒绝 Bash 直搜,硬防御默认只保留确定性的破坏性 Shell 命令;Git 非只读操作、包管理、外部目录、文件/系统/网络高风险操作与普通 MCP 调用先由 `pi-permission-system` 判为 `ask`。 - 权限策略默认允许常规工具,允许 FFF 工具;拒绝 Bash 直搜,硬防御默认只保留确定性的破坏性 Shell 命令;Git 非只读操作、包管理、文件/系统/网络高风险操作与普通 MCP 调用按唯一全局 policy 判定。项目外路径不再产生独立 `external_directory` 判断,只走配置中的工具、`path` 与 Bash pattern。
- SSH 权限沿用同一条 `pi-permission-system` gate 与 `auto-review` authorizer chain:`ssh_connect`、`ssh_cd`、`ssh_read`、`ssh_write`、`ssh_edit`、`ssh_find`、`ssh_grep` 默认 `ask`;连接前的证据解析已导入 host ID 为非秘密 target、port 与请求/default cwd,AutoReview 只可依据用户直接消息中的明确目标授权连接。`ssh_bash` 通过 `shellTools` 映射到完整 Bash 策略并设置 `decisionFloor: "ask"`,把普通 Bash `allow` 提升为复核请求,同时保留原有 `ask` 与硬 `deny`。全部远端路径不得送入基于本机 cwd 的 `path` / `external_directory` 归一化。 - SSH 权限沿用同一条 `pi-permission-system` gate 与 `auto-review` authorizer chain:`ssh_connect`、`ssh_cd`、`ssh_read`、`ssh_write`、`ssh_edit`、`ssh_find`、`ssh_grep` 默认 `ask`;连接前的证据解析已导入 host ID 为非秘密 target、port 与请求/default cwd,AutoReview 只可依据用户直接消息中的明确目标授权连接。`ssh_bash` 通过 `shellTools` 映射到完整 Bash 策略并设置 `decisionFloor: "ask"`,把普通 Bash `allow` 提升为复核请求,同时保留原有 `ask` 与硬 `deny`。全部远端路径不得送入基于本机 cwd 的默认 `input.path` 归一化。
- `auto-review` 是 `pi-permission-system` 内置 authorizer,不是独立扩展或第二个 `tool_call` gate;只复核普通策略产生的 `ask`,不会重复处理普通策略已 `allow` / `deny` 或硬防御已 `deny` 的请求。 - `auto-review` 是 `pi-permission-system` 内置 authorizer,不是独立扩展或第二个 `tool_call` gate;只复核普通策略产生的 `ask`,不会重复处理普通策略已 `allow` / `deny` 或硬防御已 `deny` 的请求。
- reviewer 返回 `allow` 时自动批准、返回 `deny` 时直接拒绝,配置、模型、认证、超时或响应异常时必须 `defer` 到正常人工提示。delegation envelope 继续禁止 authorizer 自动批准全部 `path` 请求;`external_directory` 对内置只读路径工具(`read`、`find`、`grep`、`ls`)接受 reviewer 的 `allow`,写入、编辑、Bash、未知工具和其他外部访问仍转人工。 - reviewer 返回 `allow` 时自动批准(包括 `path`),返回 `deny` 时直接拒绝;配置、模型、认证、超时或响应异常时必须 `defer` 到正常人工提示。不得在 authorizer chain 外增加第二层内置授权范围限制。
- 默认 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 内容不能自行授权。
- `config/pi-permission-policy.jsonc` 必须显式配置 `authorizerChain: ["auto-review"]`,普通未匹配请求和需要自动复核的 Git 非只读、包管理等类别声明为 `ask`;`config/pi-permission-hard-guards.jsonc` 独立保存可由用户调整但不能被项目策略、Session grant 或 reviewer 覆盖的高危 `deny`。 - `config/pi-permission-policy.jsonc` 必须显式配置 `authorizerChain: ["auto-review"]`,普通未匹配请求和需要自动复核的 Git 非只读、包管理等类别声明为 `ask`;普通 pattern 必须按具体度优先(精确匹配、字面字符更多、通配符更少、pattern 更长,最后才以声明顺序破同级);`config/pi-permission-hard-guards.jsonc` 独立保存可由用户调整但不能被普通策略、Session grant、per-agent 规则或 reviewer 覆盖的高危 `deny`。
- AutoReview 只对完成的 `allow` / `deny` 计数,`defer` 不计;达到 500 次后持久化 pending,并只在 `agent_settled` 拉起无工具、无扩展、无项目上下文的隔离后台 AgentSession。建议必须经过 schema 与确定性安全过滤、以 same-session follow-up 交给主 Agent,并在用户明确确认前禁止自动修改任一策略文件;shutdown 时保留 pending 供下一 Session 继续。 - AutoReview 只对完成的 `allow` / `deny` 计数,`defer` 不计;达到 500 次后持久化 pending,并只在 `agent_settled` 拉起无工具、无扩展、无项目上下文的隔离后台 AgentSession。建议必须经过 schema 与确定性安全过滤、以 same-session follow-up 交给主 Agent,并在用户明确确认前禁止自动修改任一策略文件;shutdown 时保留 pending 供下一 Session 继续。
- `pi-lsp` 的 TypeScript/JavaScript 后端由根包固定依赖提供,`extensions/lsp.ts` 使用当前 Node 可执行文件直接启动包内 `typescript-language-server` CLI,不得依赖或调用 VS Code GUI;Kotlin/JDT LS 仍使用组合包部署的 `kotlin-lsp --stdio` 与 `jdtls` 配置,根包不安装这两个系统可执行文件,便捷脚本可在用户逐项明确确认后通过 Homebrew 安装 Kotlin LSP、Java 21 和 JDT LS。 - `pi-lsp` 的 TypeScript/JavaScript 后端由根包固定依赖提供,`extensions/lsp.ts` 使用当前 Node 可执行文件直接启动包内 `typescript-language-server` CLI,不得依赖或调用 VS Code GUI;Kotlin/JDT LS 仍使用组合包部署的 `kotlin-lsp --stdio` 与 `jdtls` 配置,根包不安装这两个系统可执行文件,便捷脚本可在用户逐项明确确认后通过 Homebrew 安装 Kotlin LSP、Java 21 和 JDT LS。
- 终端环境配置只属于便捷脚本:Kitty 可选安装后可通过官方 kitten 启用 Solarized Dark;Oh My Zsh 使用不切换 shell 的 unattended 安装;Powerlevel10k 将仓库内置的 `config/p10k.zsh`(Rainbow/ASCII 单行紧凑主题)部署到 `~/.config/my-pi/p10k.zsh`(遵循 `XDG_CONFIG_HOME`),不得覆盖用户自己的 `~/.p10k.zsh`。`.zshrc` 中脚本拥有的内容必须使用 `# >>> my-pi:<id> >>>` / `# <<< my-pi:<id> <<<` 受管块,更新时只替换块内内容;标记不完整或重复时拒绝修改。实际修改前必须创建带时间戳的备份。`git`、`zsh-autosuggestions`、`zsh-syntax-highlighting` 在安装流程中逐项询问后才安装或启用。 - 终端环境配置只属于便捷脚本:Kitty 可选安装后可通过官方 kitten 启用 Solarized Dark;Oh My Zsh 使用不切换 shell 的 unattended 安装;Powerlevel10k 将仓库内置的 `config/p10k.zsh`(Rainbow/ASCII 单行紧凑主题)部署到 `~/.config/my-pi/p10k.zsh`(遵循 `XDG_CONFIG_HOME`),不得覆盖用户自己的 `~/.p10k.zsh`。`.zshrc` 中脚本拥有的内容必须使用 `# >>> my-pi:<id> >>>` / `# <<< my-pi:<id> <<<` 受管块,更新时只替换块内内容;标记不完整或重复时拒绝修改。实际修改前必须创建带时间戳的备份。`git`、`zsh-autosuggestions`、`zsh-syntax-highlighting` 在安装流程中逐项询问后才安装或启用。
@@ -158,11 +158,11 @@
- 扩展入口是 `pi-permission-system/src/index.ts`,公共跨扩展服务入口是 `pi-permission-system/src/service.ts`,测试保留在该目录的 `test/`。 - 扩展入口是 `pi-permission-system/src/index.ts`,公共跨扩展服务入口是 `pi-permission-system/src/service.ts`,测试保留在该目录的 `test/`。
- 同步上游必须从明确 tag/commit 移植并记录快照;保留上游 `LICENSE`、作者和来源,不导入 `dist/`、嵌套 `.git` 或上游包目录的 `.pi` 本地状态。 - 同步上游必须从明确 tag/commit 移植并记录快照;保留上游 `LICENSE`、作者和来源,不导入 `dist/`、嵌套 `.git` 或上游包目录的 `.pi` 本地状态。
- 本地 delegation envelope 允许内置只读路径工具(`read`、`find`、`grep`、`ls`)对 `external_directory` 接受 authorizer 的 `allow`;`write`、`edit`、Bash、未知工具、未确定 surface 以及全部 `path` ask 必须继续 `defer` 到终端人工 authority。 - 配置在 `authorizerChain` 中显式命名的 authorizer 可对任一普通策略 `ask`(包括 `path`)返回最终 `allow` / `deny`;`defer` 才继续下一个 authorizer 或终端人工 authority。
- 修改 gate、authorizer chain、delegation envelope、子代理转发或公共 service 类型时,必须同步更新包内 README/架构文档和对应测试。 - 修改 gate、authorizer chain、子代理转发或公共 service 类型时,必须同步更新包内 README/架构文档和对应测试。
- 根包必须通过 `file:./pi-permission-system` 提供运行时依赖,包装入口必须直接加载仓库源码;不得同时加载 npm 预编译入口或第二个 permission-system 实例。 - 根包必须通过 `file:./pi-permission-system` 提供运行时依赖,包装入口必须直接加载仓库源码;不得同时加载 npm 预编译入口或第二个 permission-system 实例。
- 内置 AutoReview 实现位于 `pi-permission-system/src/auto-review/`;同步原 AutoReview 上游时必须从源码仓库明确 tag/commit 移植,保留 `LICENSES/pi-permission-auto-review-MIT.txt`、作者和 `docs/auto-review/UPSTREAM.md`,不得恢复顶层独立扩展、npm 预编译入口或平行 gate。 - 内置 AutoReview 实现位于 `pi-permission-system/src/auto-review/`;同步原 AutoReview 上游时必须从源码仓库明确 tag/commit 移植,保留 `LICENSES/pi-permission-auto-review-MIT.txt`、作者和 `docs/auto-review/UPSTREAM.md`,不得恢复顶层独立扩展、npm 预编译入口或平行 gate。
- reviewer 只能处理普通策略已判定为 `ask` 且未被 hard guard 拒绝的请求;不得绕过硬 `deny`,不得扩大 delegation envelope。 - reviewer 只能处理普通策略已判定为 `ask` 且未被 hard guard 拒绝的请求;不得绕过硬 `deny`。
- `HardGuardPolicyLoader` 必须保持 global-only、无 SessionRules、无项目覆盖;用户文件和 bundled fallback 都不可用时 fail closed 到 deny-all,并报告配置问题。普通 policy 与 hard guard 配置不得合并成一份文件。 - `HardGuardPolicyLoader` 必须保持 global-only、无 SessionRules、无项目覆盖;用户文件和 bundled fallback 都不可用时 fail closed 到 deny-all,并报告配置问题。普通 policy 与 hard guard 配置不得合并成一份文件。
- 500 次分析必须以完成 verdict sequence 为计数口径;`defer` 不写计数。ledger/proposal/state 保持 owner-only、原子状态写入、脱敏且不保存 rationale/transcript/raw payload。后台 Agent 必须 tool-free/context-free,Provider 请求不得等待它,只有 `agent_settled` 可启动;失败或 shutdown 不得误推进 checkpoint。 - 500 次分析必须以完成 verdict sequence 为计数口径;`defer` 不写计数。ledger/proposal/state 保持 owner-only、原子状态写入、脱敏且不保存 rationale/transcript/raw payload。后台 Agent 必须 tool-free/context-free,Provider 请求不得等待它,只有 `agent_settled` 可启动;失败或 shutdown 不得误推进 checkpoint。
- 修改 reviewer 策略、可信证据边界、配置结构、hard-guard precedence、ledger、后台分析、模型解析或 authorizer/lifecycle 注册时,必须同步更新 README、配置文档、架构文档、schema 和相应测试。 - 修改 reviewer 策略、可信证据边界、配置结构、hard-guard precedence、ledger、后台分析、模型解析或 authorizer/lifecycle 注册时,必须同步更新 README、配置文档、架构文档、schema 和相应测试。
@@ -204,7 +204,7 @@
- 子会话必须在 `bindExtensions()` 前注册到 `pi-permission-system` 的 process-global child registry,并在真实 dispose 时清理;resume、并发和嵌套 Agent 必须维持正确 parent/child session identity。 - 子会话必须在 `bindExtensions()` 前注册到 `pi-permission-system` 的 process-global child registry,并在真实 dispose 时清理;resume、并发和嵌套 Agent 必须维持正确 parent/child session identity。
- `extensions/subagents.ts` 是当前禁用但保留的组合包 wrapper:若以后经明确决定恢复加载,它只能把组合包内权威 `extensions/permission-system.ts` 的 canonical absolute path 作为 mandatory extension 注入。Agent frontmatter 的 `extensions: false`、`isolated` 和 `exclude_extensions` 均不得移除该 lifecycle;路径缺失、被同名其他路径冒充或 reload 后未精确命中必须在创建 AgentSession 前 fail-closed。 - `extensions/subagents.ts` 是当前禁用但保留的组合包 wrapper:若以后经明确决定恢复加载,它只能把组合包内权威 `extensions/permission-system.ts` 的 canonical absolute path 作为 mandatory extension 注入。Agent frontmatter 的 `extensions: false`、`isolated` 和 `exclude_extensions` 均不得移除该 lifecycle;路径缺失、被同名其他路径冒充或 reload 后未精确命中必须在创建 AgentSession 前 fail-closed。
- mandatory extension 只强制绑定 handlers,不自动开放它注册的工具;工具可见性仍由 Agent 的正常 extensions/tools 策略决定。mandatory 列表不得从 Agent Markdown 或项目配置读取。 - mandatory extension 只强制绑定 handlers,不自动开放它注册的工具;工具可见性仍由 Agent 的正常 extensions/tools 策略决定。mandatory 列表不得从 Agent Markdown 或项目配置读取。
- `ask` 只能由 auto-review 在 delegation envelope 内明确 `allow` 时自动批准;reviewer `defer`、异常或不在 envelope 内必须转发父会话人工 authority,不得因子会话无 UI 静默放宽。父模型生成的子 Agent prompt 不得伪装成人类直接授权证据。 - `ask` 可由 auto-review 明确 `allow` 时自动批准,包括路径请求;reviewer `defer` 或异常必须转发父会话人工 authority,不得因子会话无 UI 静默放宽。父模型生成的子 Agent prompt 不得伪装成人类直接授权证据。
- 子 Agent 转发的 access facts 必须先由根 authority 的 `SessionRules` 匹配;父会话 whole-session grant 可覆盖语义一致的后代操作,普通单次批准不得复制为可重复消费的子 Agent grant。auto-review 必须运行在根会话并只使用根 transcript 的可信用户证据。 - 子 Agent 转发的 access facts 必须先由根 authority 的 `SessionRules` 匹配;父会话 whole-session grant 可覆盖语义一致的后代操作,普通单次批准不得复制为可重复消费的子 Agent grant。auto-review 必须运行在根会话并只使用根 transcript 的可信用户证据。
- 修改 Agent lifecycle、权限桥接、tool/extension scope、resume、nested agents 或 session persistence 时,必须同步包内 README、CHANGELOG、测试,并运行 `npm run lint`、`npm run typecheck`、`npm run test` 和 `npm run build`。 - 修改 Agent lifecycle、权限桥接、tool/extension scope、resume、nested agents 或 session persistence 时,必须同步包内 README、CHANGELOG、测试,并运行 `npm run lint`、`npm run typecheck`、`npm run test` 和 `npm run build`。
## `pi-notify` 开发约定 ## `pi-notify` 开发约定
@@ -235,10 +235,10 @@
- 已导入主机必须固定 SHA256 Host Key;不匹配时 fail closed。私钥认证只保存文件路径,Agent 认证只保存 socket 路径,均不得复制或导出私钥内容;文件工具使用 SFTP,写入优先临时文件与原子 rename,远端断线不得自动重放命令。 - 已导入主机必须固定 SHA256 Host Key;不匹配时 fail closed。私钥认证只保存文件路径,Agent 认证只保存 socket 路径,均不得复制或导出私钥内容;文件工具使用 SFTP,写入优先临时文件与原子 rename,远端断线不得自动重放命令。
- `ssh_connect` 是唯一运行时连接入口,只接受 vault 中已导入的 host ID 与可选绝对/`~/` remote cwd,并必须默认 `ask` 后进入 AutoReview;它必须声明 `sequential` execution mode 且支持取消,Agent 必须作为独立步骤调用并等待成功后才能发出依赖连接的其他 `ssh_*` 调用,避免连接建立或替换与远端操作重叠。未知 host ID 只可有界列出已导入 ID,不得泄露 endpoint 或凭据。不得注册 `/ssh`、`--ssh`、session resume 自动重连或把用户 `!` 命令切换到远端。用户未在直接请求中明确服务器和具体远端任务时不得推断、替换或连接主机;新连接替换旧连接,会话结束自动断开。 - `ssh_connect` 是唯一运行时连接入口,只接受 vault 中已导入的 host ID 与可选绝对/`~/` remote cwd,并必须默认 `ask` 后进入 AutoReview;它必须声明 `sequential` execution mode 且支持取消,Agent 必须作为独立步骤调用并等待成功后才能发出依赖连接的其他 `ssh_*` 调用,避免连接建立或替换与远端操作重叠。未知 host ID 只可有界列出已导入 ID,不得泄露 endpoint 或凭据。不得注册 `/ssh`、`--ssh`、session resume 自动重连或把用户 `!` 命令切换到远端。用户未在直接请求中明确服务器和具体远端任务时不得推断、替换或连接主机;新连接替换旧连接,会话结束自动断开。
- `ssh_find` / `ssh_grep` 必须在已授权工具调用内部按 `fd/fdfind → git ls-files → find` 与 `rg → git grep → find+grep` 顺序检测服务器现有能力,不安装或上传远端二进制;搜索目标必须作为绝对参数与执行 cwd 分离,搜索进程始终从当前已确认 `remoteCwd` 启动,单文件 `ssh_grep` 目标不得被当作目录执行 `cd`。输入必须 shell-safe,结果数量/单行/总捕获必须有界并显式报告 backend 与 truncated;30 秒超时必须报告解析后的 root 并提示先缩小范围。RTK 不得处理这两个搜索工具的结果。 - `ssh_find` / `ssh_grep` 必须在已授权工具调用内部按 `fd/fdfind → git ls-files → find` 与 `rg → git grep → find+grep` 顺序检测服务器现有能力,不安装或上传远端二进制;搜索目标必须作为绝对参数与执行 cwd 分离,搜索进程始终从当前已确认 `remoteCwd` 启动,单文件 `ssh_grep` 目标不得被当作目录执行 `cd`。输入必须 shell-safe,结果数量/单行/总捕获必须有界并显式报告 backend 与 truncated;30 秒超时必须报告解析后的 root 并提示先缩小范围。RTK 不得处理这两个搜索工具的结果。
- `ssh_cd`、`ssh_read`、`ssh_write`、`ssh_edit`、`ssh_find`、`ssh_grep` 的远端路径不能按本机路径执行 `path` / `external_directory` gate;必须通过公共 `PermissionsService` 注册显式 extractor 关闭默认 `input.path` 推断,并保持六个工具的默认策略为 `ask`。权限预览必须依据远端 `remoteCwd` / `remoteHome` 解析相对路径与 `~/`,同时显示请求值和解析后的绝对远端路径。 - `ssh_cd`、`ssh_read`、`ssh_write`、`ssh_edit`、`ssh_find`、`ssh_grep` 的远端路径不能按本机路径执行默认 `input.path` gate;必须通过公共 `PermissionsService` 注册显式 extractor 关闭本机路径推断,并保持六个工具的默认策略为 `ask`。权限预览必须依据远端 `remoteCwd` / `remoteHome` 解析相对路径与 `~/`,同时显示请求值和解析后的绝对远端路径。
- SSH transport 在会话中持久,但不得维护隐藏的交互式 Shell/PTY 状态;`ssh_bash` 每次调用必须从连接当前 `remoteCwd` 启动独立的非交互 Bash,必须忽略 Pi 本地 Bash factory 的 cwd 与 `PI_*` 会话环境。跨调用的工作区切换只能通过受复核且声明 `sequential` execution mode 的 `ssh_cd` 显式验证并更新连接状态;Agent 必须把它作为独立步骤调用并等待成功后,才可发出依赖新目录的远端工具调用。普通命令中的 `cd` 只可作为当次命令的临时目录变化,`cd`、`export`、alias 或函数不得被伪装为持久状态。 - SSH transport 在会话中持久,但不得维护隐藏的交互式 Shell/PTY 状态;`ssh_bash` 每次调用必须从连接当前 `remoteCwd` 启动独立的非交互 Bash,必须忽略 Pi 本地 Bash factory 的 cwd 与 `PI_*` 会话环境。跨调用的工作区切换只能通过受复核且声明 `sequential` execution mode 的 `ssh_cd` 显式验证并更新连接状态;Agent 必须把它作为独立步骤调用并等待成功后,才可发出依赖新目录的远端工具调用。普通命令中的 `cd` 只可作为当次命令的临时目录变化,`cd`、`export`、alias 或函数不得被伪装为持久状态。
- 远端 HOME/cwd 探测与 `ssh_cd` 验证必须使用可取消、输出有界、随机标记 framing 的固定命令,只接受唯一绝对 POSIX 路径;banner、畸形/多行输出、超限、超时或取消时不得更新状态。独立 exec channel 最多并发 4 路,排队调用必须可立即取消;共享 SFTP 操作继续串行。 - 远端 HOME/cwd 探测与 `ssh_cd` 验证必须使用可取消、输出有界、随机标记 framing 的固定命令,只接受唯一绝对 POSIX 路径;banner、畸形/多行输出、超限、超时或取消时不得更新状态。独立 exec channel 最多并发 4 路,排队调用必须可立即取消;共享 SFTP 操作继续串行。
- `ssh_bash` 必须在权威权限配置的 `shellTools` 中映射 `commandArgument: "command"` 并设置 `decisionFloor: "ask"`,复用 Bash 命令拆分、硬拒绝、风险 `ask` 和 authorizer chain;全局 floor 必须在项目配置字段级合并时保留,不得新增第二个并行 `tool_call` 审批层。 - `ssh_bash` 必须在唯一全局 policy 的 `shellTools` 中映射 `commandArgument: "command"` 并设置 `decisionFloor: "ask"`,复用 Bash 命令拆分、硬拒绝、风险 `ask` 和 authorizer chain;不得新增第二个并行 `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 与有界操作摘要;连接预览只解密本地 vault 以提取非秘密目标字段,绝不包含凭据,远端 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_*` 工具调用获取。
@@ -273,7 +273,7 @@
Hippo 脚本变化必须额外用隔离 HOME/PATH 和 mock `pi`、`npm`、`hippo` 验证:安装缺失 CLI 但不调用 `hippo init`、相同版本不升级、不同版本精确升级、未知来源 CLI 不替换、卸载默认保留、明确确认后只卸载 npm 全局包且保留数据。不得在测试中执行真实全局 npm 写入或修改真实 `.hippo/`。 Hippo 脚本变化必须额外用隔离 HOME/PATH 和 mock `pi`、`npm`、`hippo` 验证:安装缺失 CLI 但不调用 `hippo init`、相同版本不升级、不同版本精确升级、未知来源 CLI 不替换、卸载默认保留、明确确认后只卸载 npm 全局包且保留数据。不得在测试中执行真实全局 npm 写入或修改真实 `.hippo/`。
在 `pi-permission-system/` 内至少运行 `npm run typecheck`、`npm run test` 和 `npm run build`。涉及内置 AutoReview、硬防御或后台分析时,还要验证 authorizer `allow` / `deny` / `defer`、delegation envelope、hard-guard precedence、500 次 checkpoint、owner-only 持久化、shutdown resume 与 same-session follow-up。 在 `pi-permission-system/` 内至少运行 `npm run typecheck`、`npm run test` 和 `npm run build`。涉及内置 AutoReview、硬防御或后台分析时,还要验证 authorizer 对普通工具与 `path` 的 `allow` / `deny` / `defer`、hard-guard precedence、500 次 checkpoint、owner-only 持久化、shutdown resume 与 same-session follow-up。
在 `pi-notify/` 内至少运行 `npm run typecheck`、`npm run test` 和 `npm pack --dry-run --json`;涉及加载入口或 lifecycle 时还要运行根扩展联合加载测试和实际 packed tarball 的隔离安装验证。 在 `pi-notify/` 内至少运行 `npm run typecheck`、`npm run test` 和 `npm pack --dry-run --json`;涉及加载入口或 lifecycle 时还要运行根扩展联合加载测试和实际 packed tarball 的隔离安装验证。
+10 -6
View File
@@ -18,7 +18,7 @@
- 本仓库维护的 `pi-ask-user`:提供模型主动调用的 `ask_user_question`;TUI 中保留单题、多题、选择、文本、自定义回答与提交前复核,Paseo 等兼容 RPC 客户端通过标准 Pi extension UI 逐题交互。 - 本仓库维护的 `pi-ask-user`:提供模型主动调用的 `ask_user_question`;TUI 中保留单题、多题、选择、文本、自定义回答与提交前复核,Paseo 等兼容 RPC 客户端通过标准 Pi extension UI 逐题交互。
- 本仓库维护的 `pi-tool-search`:从完整工具定义生成并缓存经过校验的工作流分组,默认以最多 5 个动态组、28 个动态工具的 LRU 策略加载原始完整 schema,并支持原子批量激活。 - 本仓库维护的 `pi-tool-search`:从完整工具定义生成并缓存经过校验的工作流分组,默认以最多 5 个动态组、28 个动态工具的 LRU 策略加载原始完整 schema,并支持原子批量激活。
- 本仓库维护的 `pi-chrome`:从上游 `0.15.46` 源码导入,通过显式会话授权接入用户当前已登录的 Chrome Profile;大体积页面快照写入 Session 临时文件并交给 Context Mode 隔离分析,21 个 `chrome_*` 工具仍由 Tool Search 按工作流加载。 - 本仓库维护的 `pi-chrome`:从上游 `0.15.46` 源码导入,通过显式会话授权接入用户当前已登录的 Chrome Profile;大体积页面快照写入 Session 临时文件并交给 Context Mode 隔离分析,21 个 `chrome_*` 工具仍由 Tool Search 按工作流加载。
- 本仓库维护的 `pi-permission-system`:从 `@gotgenes/pi-permission-system@26.2.1` 源码导入并整合原 AutoReview,实现独立 JSONC 硬防御、普通策略、Codex Guardian 风格自动复核、人工 `defer` 兜底,以及每 500 次自动裁决后的同会话后台规则分析。 - 本仓库维护的 `pi-permission-system`:只读取一份全局普通策略与一份独立 Hard Guards,按 pattern 具体度裁决 allow/ask/deny,并整合 Codex Guardian 风格 AutoReview、人工 `defer` 兜底和每 500 次完成裁决后的同会话后台规则分析;工具显隐仍只由 Tool Search 管理。
- 本仓库保留维护的 `pi-subagents` 源码及权限集成实现,但当前已从根组合包依赖和默认扩展加载列表中移除,不会随核心包启用。 - 本仓库保留维护的 `pi-subagents` 源码及权限集成实现,但当前已从根组合包依赖和默认扩展加载列表中移除,不会随核心包启用。
- 本仓库维护的 `pi-ssh`:通过纯 Node `ssh2` 持久连接、显式 `ssh_cd` 远端工作区切换、SFTP 与自适应有界搜索提供独立的远端工具;Agent 只在用户明确指定已导入主机及具体任务后发起受 AutoReview 复核的连接。 - 本仓库维护的 `pi-ssh`:通过纯 Node `ssh2` 持久连接、显式 `ssh_cd` 远端工作区切换、SFTP 与自适应有界搜索提供独立的远端工具;Agent 只在用户明确指定已导入主机及具体任务后发起受 AutoReview 复核的连接。
- `extensions/tool-routing.ts`:保留 Pi 默认系统提示词,并按当前激活工具追加简短的工具与搜索路由规则;提供 `/dump-system-prompt` 导出当前有效提示词。 - `extensions/tool-routing.ts`:保留 Pi 默认系统提示词,并按当前激活工具追加简短的工具与搜索路由规则;提供 `/dump-system-prompt` 导出当前有效提示词。
@@ -273,14 +273,18 @@ codegraph status
`pi-permission-system/` 从上游 `pi-permission-system-v26.2.1` tag 导入,初始源码快照为 `ec4fdb11343dc94f7185b113e559a4cf9f8dc035`。原 `pi-permission-auto-review` 的 reviewer、可信证据边界和 Codex Guardian 风格策略已整合进同一个扩展;其 MIT 许可和来源记录保留在 `pi-permission-system/LICENSES/` 与 `docs/auto-review/`。根组合包不再加载第二个 AutoReview 扩展。 `pi-permission-system/` 从上游 `pi-permission-system-v26.2.1` tag 导入,初始源码快照为 `ec4fdb11343dc94f7185b113e559a4cf9f8dc035`。原 `pi-permission-auto-review` 的 reviewer、可信证据边界和 Codex Guardian 风格策略已整合进同一个扩展;其 MIT 许可和来源记录保留在 `pi-permission-system/LICENSES/` 与 `docs/auto-review/`。根组合包不再加载第二个 AutoReview 扩展。
`extensions/permission-system.ts` 只在文件缺失时,以 `0600` 原子创建两份用户可编辑 JSONC: `extensions/permission-system.ts` 只在目标缺失时,以 `0600` 原子创建两份用户可编辑 JSONC:
- `~/.pi/agent/extensions/pi-permission-system/policy.jsonc`:普通 allow/ask/deny 策略、运行参数、AutoReview 与 500 次分析参数;项目可在 `.pi/extensions/pi-permission-system/policy.jsonc` 覆盖普通策略。 - `~/.pi/agent/extensions/pi-permission-system/policy.jsonc`:唯一的全局普通 allow/ask/deny 策略,同时保存运行参数、AutoReview 与 500 次分析参数。项目级 `.pi/extensions/pi-permission-system/policy.jsonc` 不再读取。
- `~/.pi/agent/extensions/pi-permission-system/hard-guards.jsonc`:仅全局生效的硬防御。硬 `deny` 先于项目策略、Session grant 和 AutoReview,不能被后续层放宽;`allow` 只用于同一硬防御文件中的例外。 - `~/.pi/agent/extensions/pi-permission-system/hard-guards.jsonc`:仅全局生效的硬防御。硬 `deny` 先于 Session grant、per-agent 规则和 AutoReview,不能被后续层放宽。
安装后的用户修改不会被下次加载覆盖。若新版首次加载时只有旧 `config.json`,包装入口会把它复制成 `policy.jsonc` 并保留原文件。组合包中的 `config/pi-permission-policy.jsonc` 和 `config/pi-permission-hard-guards.jsonc` 只是新安装种子。 普通 pattern 不再按“最后一条命中”覆盖,而按具体度选择:精确匹配优先,其次字面字符更多、通配符更少、pattern 更长,只有完全同级时才以后声明者为准。因此具体 deny 会稳定压过宽泛的 `"*": "allow"`。
默认普通策略对未匹配请求返回 `ask`,并通过 `authorizerChain: ["auto-review"]` 先交给内置 reviewer。确定性的高危硬拒绝由 hard guards 直接终止;AutoReview `allow` / `deny` 结束当前审批;模型、配置、认证、超时或输出异常统一 `defer` 到原有人工界面。reviewer 只读取 active branch 中直接用户消息和已识别、未取消的结构化回答。 系统不再产生 `external_directory` 判断:项目外路径与项目内路径一样,只走用户配置的工具、`path` 和 Bash 规则。Tool Search 是唯一的工具显隐/激活控制者;权限系统只在工具被调用时做裁决。
首次安装仍只部署缺失文件。`update.sh` 会额外检查当前全局 `policy.jsonc`:仅当其语义内容精确匹配已知旧默认种子时,先创建 `0600` 时间戳备份,再原子替换为升级后组合包的新种子;当前值、自定义值和无效 JSONC 都不会被猜测覆盖。旧全局 `config.json` 仍只在目标缺失时复制迁移为 `policy.jsonc`。
默认普通策略对未匹配请求返回 `ask`,并通过 `authorizerChain: ["auto-review"]` 先交给内置 reviewer。高危 Hard Guard 直接终止;AutoReview 的 `allow` / `deny` 对任一普通策略 `ask`(包括 `path`)直接结束当前审批;模型、配置、认证、超时或输出异常统一 `defer` 到人工界面。
每个 AutoReview `allow` / `deny` 计为一次完成裁决,`defer` 不计数。记录仅保存有界、脱敏的 surface/pattern 指纹、风险与授权等级,不保存 rationale、原始工具参数或 transcript。累计 500 次新裁决后先持久化 pending;下一次 `agent_settled` 拉起无工具、无扩展、无项目上下文的隔离后台 AgentSession。经 schema 和确定性安全过滤后的建议通过 `followUp + triggerTurn` 回到当前对话,由主 Agent 直接向用户呈现。它不会自动修改任何策略文件;必须等用户明确选择并确认。会话在分析前结束时,pending 会在下一个 Session 继续。 每个 AutoReview `allow` / `deny` 计为一次完成裁决,`defer` 不计数。记录仅保存有界、脱敏的 surface/pattern 指纹、风险与授权等级,不保存 rationale、原始工具参数或 transcript。累计 500 次新裁决后先持久化 pending;下一次 `agent_settled` 拉起无工具、无扩展、无项目上下文的隔离后台 AgentSession。经 schema 和确定性安全过滤后的建议通过 `followUp + triggerTurn` 回到当前对话,由主 Agent 直接向用户呈现。它不会自动修改任何策略文件;必须等用户明确选择并确认。会话在分析前结束时,pending 会在下一个 Session 继续。
-3
View File
@@ -53,9 +53,6 @@
"ssh_find": "ask", "ssh_find": "ask",
"ssh_grep": "ask", "ssh_grep": "ask",
"external_directory": {
"*": "ask"
},
"path": { "path": {
"*": "allow", "*": "allow",
".git/*": "ask" ".git/*": "ask"
+2 -1
View File
@@ -10,9 +10,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Integrate the former standalone AutoReview authorizer into the permission-system composition root; split user-editable ordinary policy and global-only hard guards into independent JSONC files; add sanitized 500-verdict checkpoints and isolated same-session background policy proposals without automatic rule edits. - Integrate the former standalone AutoReview authorizer into the permission-system composition root; split user-editable ordinary policy and global-only hard guards into independent JSONC files; add sanitized 500-verdict checkpoints and isolated same-session background policy proposals without automatic rule edits.
- Simplify ordinary policy to one global `policy.jsonc`: stop loading project policy files, rank matching patterns by specificity instead of declaration order, remove the `external_directory` gate and delegation envelope, let configured authorizers approve any ordinary-policy ask including `path`, and leave all active-tool visibility changes exclusively to Tool Search.
- Preserve a registered shell-alias input preview as authorizer evidence and permission review context, so wrappers such as `ssh_bash` can disclose their remote execution target while retaining full Bash policy enforcement. - Preserve a registered shell-alias input preview as authorizer evidence and permission review context, so wrappers such as `ssh_bash` can disclose their remote execution target while retaining full Bash policy enforcement.
- Add `shellTools.<name>.decisionFloor: "ask"`, which raises Bash allows to reviewable asks without weakening existing asks or hard denies, and preserve a global floor through field-level project config merging. - Add `shellTools.<name>.decisionFloor: "ask"`, which raises Bash allows to reviewable asks without weakening existing asks or hard denies; the floor now comes only from the global policy.
## [26.2.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v26.2.0...pi-permission-system-v26.2.1) (2026-08-17) ## [26.2.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v26.2.0...pi-permission-system-v26.2.1) (2026-08-17)
+17 -59
View File
@@ -11,22 +11,17 @@ Permission enforcement extension for the [Pi](https://pi.mariozechner.at/) codin
> **Fork notice:** This package is a full fork of [MasuRii/pi-permission-system](https://github.com/MasuRii/pi-permission-system), published to npm as `@gotgenes/pi-permission-system`. > **Fork notice:** This package is a full fork of [MasuRii/pi-permission-system](https://github.com/MasuRii/pi-permission-system), published to npm as `@gotgenes/pi-permission-system`.
> It has diverged substantially from upstream in config format, internal architecture, and permission model. > It has diverged substantially from upstream in config format, internal architecture, and permission model.
> >
> **my-pi maintenance note:** This directory was imported from upstream tag `pi-permission-system-v26.2.1` at commit `ec4fdb11343dc94f7185b113e559a4cf9f8dc035`. It is loaded from source and maintained directly by my-pi; npm `dist` output is not used. The local delegation envelope accepts an authorizer `allow` for built-in read-only path tools (`read`, `find`, `grep`, `ls`) accessing `external_directory`, while write, edit, bash, unknown tools, and all `path` asks still defer to human confirmation. > **my-pi maintenance note:** This directory was imported from upstream tag `pi-permission-system-v26.2.1` at commit `ec4fdb11343dc94f7185b113e559a4cf9f8dc035`. It is loaded from source and maintained directly by my-pi; npm `dist` output is not used. my-pi uses one global ordinary policy and a separate non-overridable hard-guard file; explicitly configured authorizers may approve any ordinary-policy `ask`, including `path`.
## What It Does ## What It Does
- **Hides disallowed tools** before the agent starts — no wasted turns probing for blocked tools - **Enforces `allow` / `ask` / `deny`** at tool-call time with UI confirmation dialogs
- **Enforces allow / ask / deny** at tool-call time with UI confirmation dialogs - **Uses one global ordinary policy file**; project `policy.jsonc` files are not loaded
- **Reviews unmatched `ask` requests automatically** with an integrated Codex Guardian-style reviewer; only `defer` reaches the human fallback - **Chooses the most specific matching pattern**, so a specific deny wins over a broad `"*": "allow"` regardless of declaration order
- **Applies a separate hard-guard policy first** so project rules, session grants, and model review cannot override high-risk denies - **Applies a separate hard-guard policy first** so session grants, per-agent rules, and model review cannot override high-risk denies
- **Controls bash commands** with wildcard pattern matching (`git *: ask`, `rm -rf *: deny`) - **Controls Bash, paths, MCP, skills, and tool surfaces** while leaving active-tool ownership entirely to Tool Search
- **Gates MCP and skill access** at server, tool, and skill-name granularity - **Reviews ordinary `ask` requests automatically** with integrated AutoReview; its `allow` / `deny` verdict is final for ordinary tools and `path`, while `defer` reaches the next authorizer or human terminal
- **Protects sensitive file patterns** — cross-cutting `path` rules deny `.env`, `~/.ssh/*`, etc. across all tools and bash at once, matching both the path as referenced and its symlink-resolved form so a deny cannot be evaded through a symlink alias - **Forwards subagent `ask` decisions** to the parent authority and keeps structured audit logs
- **Guards external paths** — prompts before file tools or bash commands reach outside `cwd`
- **Fails closed** — an internal gate error blocks the tool (with a `gate_error` review-log entry), and an unparseable bash command — or an indirection wrapper that hides the gated command (`bash -c`/`eval`, `sudo`, `env`, `xargs`, `find -exec`, …) — prompts (`ask`) rather than passing silently
- **Forwards prompts from subagents** — `ask` policies work even in non-UI execution contexts
- **Broadcasts UI prompt events** — `permissions:ui_prompt` fires only when the permission system is about to invoke the active user-facing permission UI
- **Native in-process subagent integration** — [`@gotgenes/pi-subagents`](https://github.com/gotgenes/pi-subagents) and the source-maintained `my-pi` fork of [`tintinweb/pi-subagents`](https://github.com/tintinweb/pi-subagents) register child sessions automatically, enabling per-agent policy enforcement and `ask`-state forwarding to the parent UI without configuration
## Install ## Install
@@ -78,66 +73,29 @@ This lets you express rules like "allow reads but deny `.env` files" at the indi
Like the cross-cutting `path` surface, per-tool patterns match both the referenced path and its canonical (symlink-resolved) form, so a per-tool deny resists symlink-alias evasion. Like the cross-cutting `path` surface, per-tool patterns match both the referenced path and its canonical (symlink-resolved) form, so a per-tool deny resists symlink-alias evasion.
When Pi's current working directory is known, relative path inputs also match their cwd-normalized absolute form, so `src/App.jsx` can match both `src/*` and `/workspace/project/*`. When Pi's current working directory is known, relative path inputs also match their cwd-normalized absolute form, so `src/App.jsx` can match both `src/*` and `/workspace/project/*`.
The `external_directory` surface is the CWD-boundary gate: it decides whether reaching **outside** the working tree is allowed, and accepts a pattern map so you can allow specific outside-CWD directories without opening up all external access. Paths outside the working tree have no separate boundary gate. Configure them with the same `path`, per-tool, and Bash patterns used for in-project paths. These layers still compose with most-restrictive-wins.
This is the right surface for silencing repeated prompts on a local cache like `~/.cargo/registry` — allow it here, not on `path`:
```jsonc
{
"permission": {
"external_directory": {
"*": "ask",
"~/.cargo/registry/*": "allow"
}
}
}
```
The trailing `*` is greedy and crosses subdirectory boundaries, so it allows every file beneath the directory; a bare `~/.cargo/registry` matches only the directory entry itself.
Four layers compose with most-restrictive-wins: `path` (cross-cutting) → `external_directory` (CWD boundary) → per-tool patterns → `bash` command patterns.
Because `ask` is more restrictive than `allow`, a `path` allow cannot loosen an `external_directory: ask` boundary — allow outside-CWD directories on `external_directory`.
See [docs/configuration.md](docs/configuration.md) for the full recipe. See [docs/configuration.md](docs/configuration.md) for the full recipe.
## Configuration ## Configuration
The integrated extension uses two independent JSONC files: The integrated extension uses two independent global JSONC files:
| Purpose | Scope | Path | | Purpose | Scope | Path |
| ------- | ----- | ---- | | ------- | ----- | ---- |
| Ordinary policy, runtime knobs, AutoReview | Global | `~/.pi/agent/extensions/pi-permission-system/policy.jsonc` | | Ordinary policy, runtime knobs, AutoReview | Global only | `~/.pi/agent/extensions/pi-permission-system/policy.jsonc` |
| Ordinary policy override | Project | `<cwd>/.pi/extensions/pi-permission-system/policy.jsonc` |
| Non-overridable hard guards | Global only | `~/.pi/agent/extensions/pi-permission-system/hard-guards.jsonc` | | Non-overridable hard guards | Global only | `~/.pi/agent/extensions/pi-permission-system/hard-guards.jsonc` |
The bundle seeds missing files once with mode `0600` and never overwrites user edits. On first load, an existing legacy global `config.json` is copied to `policy.jsonc`; the legacy file is left untouched. Project policy is loaded only for a trusted project. Hard guards are never loaded from the project and are evaluated before ordinary policy and session grants. Project-level `policy.jsonc` files are ignored. Project agent Markdown remains a separate per-agent restriction mechanism.
Ordinary policy remains flat `allow` / `ask` / `deny`. The bundle default asks for unmatched requests and names the built-in `auto-review` authorizer. The evaluation order is: The bundle seeds missing files with mode `0600`. During `update.sh`, only the exact known legacy default policy is backed up and replaced by the current bundled seed. Any customized or invalid policy is preserved and reported instead of being guessed or overwritten.
1. global hard guards (`deny` is final; `allow` is only an exception inside that file); Within a pattern map, the most specific match wins: exact patterns, then more literal characters, fewer wildcards, and longer patterns. Declaration order is only a tie-breaker.
2. ordinary global/project/per-agent policy and current session grants;
3. built-in AutoReview for an ordinary `ask`;
4. the existing human prompt only when AutoReview returns `defer`.
`autoReview` and `analysis` are regular top-level keys in `policy.jsonc`. AutoReview defaults to `openai-codex/codex-auto-review`, low reasoning, and a 90-second budget. Model/config/authentication/timeout errors fail safe to `defer`. There is no `external_directory` gate. Outside-CWD paths use the same configured tool, `path`, and Bash rules as other paths.
Completed model verdicts (`allow` and `deny`) are appended to an owner-only, sanitized count ledger. Rationales, transcripts, and raw tool payloads are not retained. After 500 new verdicts, `agent_settled` launches one isolated, tool-free background AgentSession using the configured review model. The validated proposal is persisted and delivered as a follow-up in the same conversation. It never edits either policy file automatically: the active agent must present the candidates and wait for the user's explicit selection and confirmation. A pending batch survives session shutdown and resumes in the next session. Tool Search alone owns Pi's active tool list. Permission System only decides whether an invoked tool call is allowed, denied, or requires confirmation.
For the full policy surface and merge semantics, see [docs/configuration.md](docs/configuration.md). The integrated reviewer provenance is recorded in [docs/auto-review/UPSTREAM.md](docs/auto-review/UPSTREAM.md). See [docs/configuration.md](docs/configuration.md) for the complete reference and [docs/hard-guards.md](docs/hard-guards.md) for the independent hard-guard format.
## Upgrading
### 22.0.0 — project config requires project trust
Project-scoped configuration (the project `policy.jsonc` and project-agent frontmatter — both permission policy and runtime knobs such as `yoloMode`) is loaded only when Pi reports the project as trusted.
In an untrusted directory, only global config applies; a skip is surfaced with a warning and a `project_trust.skipped` review-log entry.
Grant project trust (or set `defaultProjectTrust`) to load a project's config.
See [docs/migration/0644-project-trust-gating.md](docs/migration/0644-project-trust-gating.md).
### 16.0.0 — the bash gate now fails closed
The permission gate fails closed: an internal gate error blocks the tool (with a `gate_error` review-log entry) instead of running it ungated, and a non-empty bash command that cannot be parsed resolves to `ask` (sentinel `<unparseable-bash-command>`) rather than falling through to a permissive top-level `*`.
Commands that previously slipped through silently on the error or empty-parse path now block or prompt.
If you relied on the old permissive behavior for bash, set an explicit permissive bash policy — `"bash": { "*": "allow" }` — which also suppresses the new startup warning emitted when a top-level `"*": "allow"` leaves bash ungated.
## Documentation ## Documentation
@@ -13,7 +13,6 @@
"reviewLogFieldMaxWidth": 1000, "reviewLogFieldMaxWidth": 1000,
"piInfrastructureReadPaths": [],
"authorizerChain": [], "authorizerChain": [],
@@ -41,11 +40,6 @@
"npm *": { "action": "deny", "reason": "Use pnpm instead" } "npm *": { "action": "deny", "reason": "Use pnpm instead" }
}, },
"mcp": { "*": "ask", "mcp_status": "allow", "mcp_list": "allow" }, "mcp": { "*": "ask", "mcp_status": "allow", "mcp_list": "allow" },
"skill": { "*": "ask" }, "skill": { "*": "ask" }
"external_directory": {
"*": "ask",
"~/development/*": "allow",
"~/.cargo/registry/*": "allow"
}
} }
} }
@@ -50,7 +50,7 @@ type RuleOrigin =
| "fail-closed"; | "fail-closed";
interface Rule { interface Rule {
/** The permission surface: "bash", "edit", "mcp", "skill", "external_directory", "path", etc. */ /** The permission surface: "bash", "edit", "mcp", "skill", "path", etc. */
surface: string; surface: string;
/** The match pattern: a command glob, tool name, file path, skill name, or "*". */ /** The match pattern: a command glob, tool name, file path, skill name, or "*". */
pattern: string; pattern: string;
@@ -76,158 +76,53 @@ Every config entry, default policy, session approval, and agent override normali
type Ruleset = Rule[]; type Ruleset = Rule[];
``` ```
Merge precedence is array ordering. A `Ruleset` keeps declaration order for deterministic tie-breaking, but declaration order is not the primary precedence rule.
The synthesized universal default goes first (lowest priority), then MCP baseline auto-allow rules, then config rules (global → project → agent → project-agent), and finally session rules (highest priority). For all matching rules on the requested surface, `evaluate()` chooses the most specific pattern:
Last-match-wins: `evaluate()` scans from the end.
1. exact match;
2. more literal characters;
3. fewer `*` wildcards;
4. longer pattern;
5. later declaration only for a complete tie.
The synthesized universal `*` is therefore a fallback. A specific deny cannot be shadowed by a later broad allow.
### Evaluate ### Evaluate
```typescript `evaluate()` filters to matching surface/value candidates, ranks them with the specificity comparator, and returns the highest-ranked rule. If none match, it returns the built-in `ask` fallback.
function evaluate(
surface: string,
value: string,
rules: Ruleset,
platform: NodeJS.Platform,
): Rule {
for (let i = rules.length - 1; i >= 0; i--) {
const rule = rules[i];
// On win32 a path-surface match folds case + separators; `platform` is
// injected from `PermissionManager` (read once at the composition root,
// #510), never `process.platform` ambiently.
if (ruleMatches(rule, surface, value, platform)) {
return rule;
}
}
// Unreachable when defaults are synthesized - the catch-all always matches.
return { surface, pattern: value, action: "ask" };
}
```
The entire decision engine. ## Decision composition
When defaults are synthesized into the array, the catch-all `{ surface: "*", pattern: "*", action: "ask" }` always matches - the fallback return is defensive only.
## Composed ruleset PermissionManager does not load an ordinary project policy. It composes these authorities:
All rule sources are concatenated into a single flat array. 1. global-only Hard Guards (first and final for matching `deny`);
Index position determines priority (higher index wins): 2. session approvals;
3. active agent restrictions from global/project agent Markdown;
4. the single global ordinary policy;
5. built-in `ask`.
```text Rules inside one authority use pattern specificity. Results from independent gates (`path`, per-tool, Bash) compose most-restrictive-wins. AutoReview is not a ruleset layer: it only receives a completed ordinary-policy `ask`.
┌─────────────────────────────────────────────────────────────────┐
│ Composed Ruleset (Rule[]) │
│ │
│ Index 0: Synthesized universal default (layer: "default") │
│ { surface: "*", pattern: "*", action: permission["*"] } │
│ │
│ Index 1..B: MCP baseline auto-allow (layer: "baseline") │
│ (only when any config rule has surface:"mcp" action:"allow") │
│ { surface: "mcp", pattern: "mcp_status", action: "allow" } │
│ { surface: "mcp", pattern: "mcp_list", action: "allow" } │
│ { surface: "mcp", pattern: "mcp_search", action: "allow" } │
│ { surface: "mcp", pattern: "mcp_describe", action: "allow" } │
│ { surface: "mcp", pattern: "mcp_connect", action: "allow" } │
│ │
│ Index B+1..C: Config rules (global → project → agent, │
│ layer: "config", origin: "global"|"project" │
│ |"agent"|"project-agent") │
│ { surface: "bash", pattern: "*", action: "allow", │
│ origin: "global" } │
│ { surface: "bash", pattern: "git *", action: "allow", │
│ origin: "global" } │
│ { surface: "bash", pattern: "rm *", action: "deny", │
│ origin: "project" } │
│ { surface: "read", pattern: "*", action: "allow", │
│ origin: "global" } │
│ { surface: "mcp", pattern: "exa:*", action: "allow", │
│ origin: "agent" } │
│ │
│ Index C+1..end: Session rules (layer: "session", highest) │
│ { surface: "external_directory", pattern: "/other/*", │
│ action: "allow" } │
│ │
│ ◄── evaluate() scans from end, first match wins ──► │
└─────────────────────────────────────────────────────────────────┘
```
`synthesizeDefaults()` produces a single universal catch-all from `permission["*"]`.
Per-surface catch-alls (e.g. `bash: { "*": "allow" }`) are expressed as regular config rules via `normalizeFlatConfig()` - no separate override layer is needed.
`synthesizeBaseline()` conditionally emits MCP metadata auto-allow rules.
`composeRuleset()` concatenates: defaults + baseline + config rules.
Session rules are concatenated after config rules so `evaluate()` handles them via last-match-wins - no separate per-branch pre-check.
### Default synthesis
```typescript
// Single universal catch-all from permission["*"].
function synthesizeDefaults(universalDefault: PermissionState): Ruleset {
return [
{ surface: "*", pattern: "*", action: universalDefault, layer: "default" },
];
}
// MCP metadata auto-allow - only synthesized when any config rule has
// surface: "mcp" && action: "allow".
function synthesizeBaseline(configRules: Ruleset): Ruleset { ... }
// Concat in priority order: defaults, baseline, config.
function composeRuleset(defaults, baseline, config): Ruleset {
return [...defaults, ...baseline, ...config];
}
```
## Architecture overview ## Architecture overview
```mermaid ```mermaid
flowchart TD flowchart TD
subgraph Load["Config loading (IO boundary - PolicyLoader)"] HG["Global hard-guards.jsonc"] --> Intent["Resolved access intent"]
GF["Global config file"] GP["Global policy.jsonc"] --> PM["PermissionManager"]
PF["Project config file"] AF["Optional active-agent frontmatter"] --> PM
AF["Agent frontmatter"] SR["SessionRules"] --> PM
GF --> PL["PolicyLoader"] Intent --> Pipeline["skill -> path -> bash -> tool gates"]
PF --> PL Pipeline --> PM
AF --> PL PM -->|allow| Allow["execute"]
PL --> Norm["normalizeFlatConfig()"] PM -->|deny| Deny["block"]
end PM -->|ask| Chain["authorizerChain"]
Chain -->|allow/deny| Result["resolve"]
subgraph Defaults["Default synthesis"] Chain -->|defer or exhausted| Human["human prompt"]
DP["permission[*]"] --> Synth["synthesizeDefaults()"]
Synth --> DR["Default Rule (lowest priority)"]
Norm --> BL["synthesizeBaseline()"]
BL --> BR["Baseline Rules (conditional)"]
end
Norm --> CR["Config Rules (layer: config)"]
SA["Session Rules<br/>(layer: session, runtime)"]
subgraph Compose["Rule composition"]
DR --> Concat["composeRuleset(...) + session"]
BR --> Concat
CR --> Concat
SA --> Concat
end
subgraph Eval["Pure evaluation (no IO)"]
Concat --> E["evaluate(surface, value, composedRules)"]
E --> Decision["Rule { surface, pattern, action }"]
end
subgraph PreProcess["Surface-specific input normalization"]
MCP["MCP target derivation<br/>→ candidate values[]"]
Bash["Bash command decomposition<br/>→ top-level commands[]<br/>→ most restrictive wins"]
Skill["Skill name extraction<br/>→ skill name"]
PathGate["Cross-cutting path gate<br/>(all file access: tools + bash)<br/>→ most restrictive wins"]
ExtDir["External directory detection<br/>(tree-sitter-bash AST for bash; direct path for tools)<br/>→ normalized path<br/>(Pi infrastructure reads auto-allowed before gate)"]
end
PathGate --> E
PreProcess --> E
``` ```
The `Agent frontmatter` input (`AF`) is the per-agent override layer. Project-level `policy.jsonc` is intentionally absent. Project agent Markdown may restrict one active agent, but it is not a second ordinary configuration scope.
It only carries data when an external multi-agent extension is active (see design principle 9): the package resolves the active agent's name from a generic `<active_agent>` signal, then reads the `permission:` sub-document of that agent's definition file at `<cwd>/.pi/agents/<name>.md` (project) or `<agentDir>/agents/<name>.md` (global).
The package does not discover or enumerate agents — it reads one sub-document by name, on demand — and the `<cwd>/.pi/agents` location is a Pi platform convention this package encodes independently (no dependency on pi-subagents, ADR 0002).
Tool Search alone owns `getActiveTools()` / `setActiveTools()`. Permission System does not participate in tool visibility; it receives only calls Pi actually invokes.
## Config format ## Config format
```jsonc ```jsonc
@@ -239,7 +134,6 @@ The package does not discover or enumerate agents — it reads one sub-document
"mcp": { "*": "ask", "exa:*": "allow" }, "mcp": { "*": "ask", "exa:*": "allow" },
"skill": { "*": "ask", "librarian": "allow" }, "skill": { "*": "ask", "librarian": "allow" },
"path": { "*": "allow", "*.env": "deny" }, "path": { "*": "allow", "*.env": "deny" },
"external_directory": "ask"
} }
} }
``` ```
@@ -278,19 +172,11 @@ Input normalization for all surfaces lives in `src/access-intent/input-normalize
### Path-bearing tool normalization ### Path-bearing tool normalization
Per-tool path patterns — e.g. `"read": { "*": "allow", "*.env": "deny" }` — are evaluated via the `access-path` intent the per-tool gate emits ([#502]). Per-tool path patterns are emitted as `path-values` intents. Relative inputs may carry lexical, cwd-normalized absolute, and canonical aliases so one configured pattern can protect the same file across equivalent spellings.
When the pipeline calls `resolvePerToolCheck`, a present `input.path` triggers `normalizer.forPath(path)` and an `access-path` intent on the tool-name surface; the resolver unwraps it to `path-values` carrying the lexical ∪ canonical alias set before the manager evaluates the rule.
When `input.path` is missing or empty, the pipeline falls back to a `tool` intent, which `normalizeInput` collapses to `["*"]` (surface catch-all).
Path alias derivation (home-expansion, cwd-relative aliases) lives in `getPathPolicyValues` / `AccessPath` — not in `normalizeInput`, which no longer touches path surfaces (#504).
`getToolPermission()` is unaffected — it always evaluates with `"*"` to determine whether to inject the tool at agent start.
The cross-cutting `path` and `external_directory` gates extract paths for **extension and MCP tools too** (#352): `describePathGate` and `describeExternalDirectoryGate` call `getToolInputPath`, which reads `input.path` for built-ins, `input.arguments.path` for MCP, and a registered `ToolAccessExtractor` (or the default `input.path` convention) for any other tool. There is no outside-CWD classifier and no `external_directory` intent. Paths outside the working directory use the same `path`, per-tool, and Bash policy evaluation as paths inside it.
The extractor registry (`src/tool-access-extractor-registry.ts`) is created once in `index.ts` and shared: its lookup side is threaded into `ToolCallGatePipeline`, and its registrar side is exposed cross-extension via `PermissionsService.registerToolAccessExtractor`.
Per-tool path maps for extension tools (a custom extractor key per tool) are a deferred follow-up.
On the bash side, which argument tokens count as filesystem operands is settled by [ADR 0009](../decisions/0009-bash-path-projection-completeness-contract.md): candidacy comes from the filesystem (a bare token is a path candidate iff it names an existing entry), the decision comes from explicit rules or the external boundary, and the ADR names both what the projection guarantees and which gaps are accepted residuals rather than bugs.
A plain `$HOME` / `${HOME}` / `$PWD` / `${PWD}` reference is resolved at token collection, upstream of classification, so an expanded token is gated exactly as its literal spelling; the resolvable set is closed at those two names by the same ADR.
Tools such as SSH that carry remote paths register an explicit extractor so local path normalization is not inferred from `input.path`.
## Session approvals: the cache-miss model ## Session approvals: the cache-miss model
Session rules are stored as `Ruleset` and are generalized to all surfaces. Session rules are stored as `Ruleset` and are generalized to all surfaces.
@@ -335,7 +221,6 @@ The pattern determines what class of future requests auto-approve:
| tool (read/write/etc.) | tool surface itself | `*` (all uses of that tool) | Tool-level | | tool (read/write/etc.) | tool surface itself | `*` (all uses of that tool) | Tool-level |
| mcp | `exa:search` | `exa:*` | Server-level wildcard | | mcp | `exa:search` | `exa:*` | Server-level wildcard |
| skill | `librarian` | `librarian` | Exact name | | skill | `librarian` | `librarian` | Exact name |
| external_directory | `/other/project/src/foo.ts` | `/other/project/*` | Directory prefix as glob |
The suggestion is shown in the dialog text so the user sees what they're approving: The suggestion is shown in the dialog text so the user sees what they're approving:
@@ -485,8 +370,8 @@ The `PermissionsService` interface exposes five methods:
- `checkPermission(surface, value?, agentName?)` - full policy query. - `checkPermission(surface, value?, agentName?)` - full policy query.
- `getToolPermission(toolName, agentName?)` - tool-level permission state (`allow`/`deny`/`ask`) for pre-filtering. - `getToolPermission(toolName, agentName?)` - tool-level permission state (`allow`/`deny`/`ask`) for pre-filtering.
- `registerToolInputFormatter(toolName, formatter)` - register a custom ask-prompt preview for a tool name; returns a disposer (#283). - `registerToolInputFormatter(toolName, formatter)` - register a custom ask-prompt preview for a tool name; returns a disposer (#283).
- `registerToolAccessExtractor(toolName, extractor)` - declare the filesystem path a non-conventional tool accesses, so the cross-cutting `path`/`external_directory` gates see it; returns a disposer (#352). - `registerToolAccessExtractor(toolName, extractor)` - declare the local filesystem path a non-conventional tool accesses so the cross-cutting `path` gate sees it, or explicitly suppress local-path inference for remote tools; returns a disposer.
- `registerAuthorizer(name, authorize)` - register a named live-authority chain link (`allow | deny | defer`, ADR 0007 §4); decides nothing until the operator names it in `authorizerChain` config, and every verdict is capped by the bounded-delegation checkpoint; returns a disposer. - `registerAuthorizer(name, authorize)` - register a named live-authority chain link (`allow | deny | defer`); it decides nothing until the operator names it in `authorizerChain`, then `allow` / `deny` is final for any ordinary-policy `ask`, including `path`; returns a disposer.
`permissions:decision` and `permissions:ui_prompt` broadcasts remain on the event bus - fire-and-forget observation is the right abstraction for those channels ([#531] removed the event-bus RPC channel; the service accessor is now the sole cross-extension policy/prompt surface). `permissions:decision` and `permissions:ui_prompt` broadcasts remain on the event bus - fire-and-forget observation is the right abstraction for those channels ([#531] removed the event-bus RPC channel; the service accessor is now the sole cross-extension policy/prompt surface).
@@ -560,7 +445,7 @@ The courier hop carries facts, not judgment — but what the serving node recons
An **in-process seam** — the `Authorizer` chain, reached through `PromptPermissionDetails` — receives the full child-fixed fact set. An **in-process seam** — the `Authorizer` chain, reached through `PromptPermissionDetails` — receives the full child-fixed fact set.
A chain link is operator-opted-in via `authorizerChain` and must decide from evidence, not from parsed display text or a parent-side re-derivation of the child's path ([ADR 0008](../decisions/0008-cross-session-access-intent.md) forbids the latter outright). A chain link is operator-opted-in via `authorizerChain` and must decide from evidence, not from parsed display text or a parent-side re-derivation of the child's path ([ADR 0008](../decisions/0008-cross-session-access-intent.md) forbids the latter outright).
The bounded-delegation checkpoint reads the same facts, so a forwarded ask is capped on the gate surface exactly as a local one is ([ADR 0007](../decisions/0007-model-judge-authorizer-chain-adr.md) §5). The same child-fixed facts let the serving node's configured authorizer decide a forwarded ask without re-deriving its path or trust context.
A **cross-extension broadcast** — `permissions:ui_prompt` / `permissions:decision` on `pi.events` — receives the minimum needed to stay correlatable, because any loaded extension can observe it. A **cross-extension broadcast** — `permissions:ui_prompt` / `permissions:decision` on `pi.events` — receives the minimum needed to stay correlatable, because any loaded extension can observe it.
@@ -585,7 +470,7 @@ The ruleset is the whole story for asks the ruleset produces.
An `ask` synthesized *after* resolution is not one: the bash wrapper floor (#481, #490) and the fail-closed `<unparseable-bash-command>` sentinel (#452) are properties of a parsed command unit, not of a pattern, so there is no rule for the rewrite to touch and they reached the prompter under yolo (#712). An `ask` synthesized *after* resolution is not one: the bash wrapper floor (#481, #490) and the fail-closed `<unparseable-bash-command>` sentinel (#452) are properties of a parsed command unit, not of a pattern, so there is no rule for the rewrite to touch and they reached the prompter under yolo (#712).
The reconciliation has exactly one home — `resolveYoloGrant` at `GateRunner`'s auto-approve fast path, the single choke point every gate passes through before escalating — so the contract "an `ask` never reaches `PermissionPrompter` under yolo" holds structurally for whatever floor is added next. The reconciliation has exactly one home — `resolveYoloGrant` at `GateRunner`'s auto-approve fast path, the single choke point every gate passes through before escalating — so the contract "an `ask` never reaches `PermissionPrompter` under yolo" holds structurally for whatever floor is added next.
It is the same deny-preserving shape as the rewrite: a `deny` is not an `ask`, so it matches neither arm. It is the same deny-preserving shape as the rewrite: a `deny` is not an `ask`, so it matches neither arm.
A future "disable everything" mode — overriding denies too — would be a *different*, deliberately named operation: appending a final `{ surface: "*", pattern: "*", action: "allow" }` rule (last-match-wins). A future "disable everything" mode — overriding denies too — would require a separate, deliberately named bypass. Appending a broad allow cannot override a more-specific deny under specificity-based evaluation.
It is not built, and it would be requested by name, never conflated with yolo. It is not built, and it would be requested by name, never conflated with yolo.
### Fail-closed on an invalid non-global scope ### Fail-closed on an invalid non-global scope
@@ -624,15 +509,13 @@ ordinaryManager.check(intent, session rules)
ask -> authorizer chain -> human terminal fallback ask -> authorizer chain -> human terminal fallback
``` ```
Thus hard-file `allow` means “not blocked by hard guards,” not “approved overall.” Project config, per-agent config, session grants, yolo mode, and model authority cannot loosen a hard deny. Thus hard-file `allow` means “not blocked by hard guards,” not “approved overall.” Ordinary policy, per-agent restrictions, session grants, yolo mode, and model authority cannot loosen a hard deny.
Completed AutoReview verdicts flow to an owner-only count ledger. The reviewer callback strips rationale/transcript/raw payload and persists bounded fingerprints. Reaching the configured checkpoint marks analysis pending. At `agent_settled`, a tool-free, extension-free, in-memory AgentSession receives only aggregated records. Its strict JSON proposal is validated against the evidence, persisted, and injected into the same session as a follow-up. The checkpoint advances only after a valid proposal is stored; shutdown leaves pending work durable. No analysis path mutates either policy file. Completed AutoReview verdicts flow to an owner-only count ledger. The reviewer callback strips rationale/transcript/raw payload and persists bounded fingerprints. Reaching the configured checkpoint marks analysis pending. At `agent_settled`, a tool-free, extension-free, in-memory AgentSession receives only aggregated records. Its strict JSON proposal is validated against the evidence, persisted, and injected into the same session as a follow-up. The checkpoint advances only after a valid proposal is stored; shutdown leaves pending work durable. No analysis path mutates either policy file.
The public authorizer registry remains available for other named authorities. Configuration order still determines chain order, unregistered names are skipped fail-safe, and the delegation envelope remains the final boundary on model `allow` decisions. The public authorizer registry remains available for other named authorities. Configuration order determines chain order, unregistered names are skipped fail-safe, and registration alone grants no authority.
Registration mirrors `registerToolAccessExtractor`: a downstream extension offers a **named** capability (`registerAuthorizer("model-judge", …)`) on `permissions:ready`, and this package makes no LLM call itself. Authorizers are named capabilities registered on `permissions:ready`; the integrated `auto-review` authorizer uses the same chain contract as third-party links. Once configured, a link's `allow` or `deny` is final for any ordinary-policy `ask`, including `path`; `defer` advances to the next link or terminal.
Three invariants govern the seam: config order (not registration order) fixes the security-relevant chain order; a missing configured link is skipped fail-safe (more prompting, never less); and **registration alone grants no authority** — a link decides nothing until the operator names it in the `authorizerChain` config (opt-in).
Bounded delegation is operator config this package enforces at a checkpoint that downgrades an excluded-surface `allow` to `defer`, with `external_directory` and secret-shaped `path` always excluded; the model's provider/prompt/threshold live in the downstream extension's own config.
This is the principled successor to the per-command argument-position work deferred from [#509]. This is the principled successor to the per-command argument-position work deferred from [#509].
The bash path projection surfaces a bare token that names a real file ([#645]) and deliberately accepts a fail-safe false positive (`grep id_rsa secrets.txt` prompts when an `id_rsa` file happens to exist); that false positive lives on the *ask-producing* side of `evaluate()`, and the model link dismisses it on the *ask-consuming* side without hard-coding per-command file-argument tables. The bash path projection surfaces a bare token that names a real file ([#645]) and deliberately accepts a fail-safe false positive (`grep id_rsa secrets.txt` prompts when an `id_rsa` file happens to exist); that false positive lives on the *ask-producing* side of `evaluate()`, and the model link dismisses it on the *ask-consuming* side without hard-coding per-command file-argument tables.
@@ -643,44 +526,11 @@ The two compose cleanly because a promoted token emits the same structured descr
### Resolved direction ### Resolved direction
These were the open decisions; they are now settled and shipped (full rationale in [history/phase-9-authorizer-spine.md](history/phase-9-authorizer-spine.md)). The deterministic core accepts a closed `ResolvedAccessIntent` union emitted by the gate pipeline. One resolver answers those intents; adding a gate does not add a side-channel policy method.
1. **Serving is resolution.** The current classifier is deterministic. Bash parsing and registered tool extractors may emit `path-values`; there is no outside-CWD intent. Model judgment is downstream and receives only completed `ask` decisions through the authorizer chain.
A serving node runs `evaluate()` against its recorded authority then escalates to its own `Authorizer` on `ask`, carrying the forwarded ask's provenance as data so the `permissions:ui_prompt` broadcast stays non-degraded.
2. **Multi-level escalation: admitted, not shipped.**
A middle node's chain terminates in a `ParentAuthorizer`, so re-escalation needs no special-casing; the tree is depth-2 today (pi-subagents' recursion guard), and a one-hop canary flags any future break.
3. **Full delegation of authority down the tree.**
A subagent inherits its ancestors' `allow`/`deny` rules and yolo; because yolo is deny-preserving, the safeguard for a cheaper delegate is an explicit `deny` in its per-agent frontmatter, not an `ask`.
4. **Grant scope is human-selectable.**
Approving a forwarded request "for this session" offers root / parent / requesting-subagent scope (requesting subagent pre-selected); "parent" and "root" coincide until trees deepen.
### Remaining design work
**Access-intent extraction** is the one genuinely open piece, and the foundation for the path surface of the decisions above.
The package's center of mass is not the decision engine (tiny, pure) but turning `(toolName, input)` into "what is being accessed" — bash decomposition, MCP target derivation, path extraction, external-directory detection.
This is a distinct domain (access intent) that gates should *emit* and a single `resolve(intent)` should answer, so adding a gate cannot widen the resolver surface.
The [#393] false-green (a stubbed-but-unrouted resolver method silently passing `allow`) was the probe pointing at it: the resolver surface was `resolve` + `resolvePathPolicy`, widening per gate, until Phase 6 Step 6 ([#478]) collapsed it to one `resolve(intent)`.
[#418] is a second probe, from the access-path side: both external-directory gates matched config patterns against the symlink-resolved path because a single `string` carries a path that is simultaneously a containment value (canonical, for the outside-CWD boundary) and a match value (lexical, as the user typed it), with no type distinction — so the canonical form leaked into matching and defeated a configured `/tmp/*` allow.
The same conflation lived in `BashProgram.externalPaths(): string[]`, which returned only the canonical form and so lost the typed value the matcher needed.
The fix's `getExternalDirectoryPolicyValues` helper (the union of lexical aliases and the canonical path) was the embryo of the access-path: `AccessPath` ([#476]) now holds both forms behind distinct `matchValues()` and boundary accessors, making the misuse a compile error; `BashProgram.externalPaths()` now returns `AccessPath[]` and one external-directory policy check can replace the two parallel gates that independently acquired this bug.
The tractable first slice was the access-path value object seeded by [#418]: it removed the path-representation conflation and the duplicate external-directory gate without waiting on principal identity or cross-session portability.
Principal identity and path portability across cwds — a subagent in a `pi-subagents-worktrees` worktree resolves paths against a different root than the parent — are now settled: [ADR 0008](../decisions/0008-cross-session-access-intent.md) (Phase 12) fixes a path-shaped ask's portable meaning at the child (the child's lexical ∪ canonical `matchValues()` plus canonical `boundaryValue()`), carries it onto the forwarded wire as `ForwardedAccessIntent`, and makes serving agent-scoped (`requesterAgentName` decision-participating).
A forwarded ask now resolves against the child-fixed alias set rather than a re-derivation through the parent's `PathNormalizer`/cwd.
With principal identity and path portability delivered, this domain has no further genuinely open piece; a non-path serving refinement (a per-surface `Authorizer` chain exclusion beyond `external_directory`/secret-shaped `path`) remains a candidate but is not scheduled.
### Beyond the target: a non-deterministic access-intent classifier
This is a **more distant** direction than the target above — noted as a candidate extension point, not planned work.
Access-intent extraction is deterministic by design: `(toolName, input)` becomes "what is being accessed" through bash decomposition, MCP target derivation, and path rules.
A second, independent place non-determinism could one day enter is a model that *classifies* access intent **before** `evaluate()` — deciding, for instance, that `id_rsa` in `git grep id_rsa` is a search pattern rather than a file, so no path candidate is emitted at all.
The classifier differs from the [`ModelTriageAuthorizer`](#discriminating-delegation-a-model-authorizer) in *where the model sits*.
The classifier feeds **recorded** authority — it shapes the intent `evaluate()` rules on — whereas the Authorizer holds **live** authority and answers the `ask`.
A wrong classifier call is a misread of what is being accessed; a wrong Authorizer call is a mis-granted decision.
Because the classifier changes the *input* to the deterministic core, it weakens the "same `(toolName, input)` yields the same ruling" property more subtly than the Authorizer does — the model output becomes part of the intent — so it warrants its own decision record and is deliberately out of scope for the current target.
The access-intent domain the gates emit into is the natural seam for such a pluggable classifier: deterministic today, model-assisted only if and when that trade is made by name.
This preserves the core property that the same policy and resolved intent produce the same deterministic decision.
### Beyond the target: a pluggable escalation seam ### Beyond the target: a pluggable escalation seam
The **registration seam** this section anticipated is now designed: [ADR 0007](../decisions/0007-model-judge-authorizer-chain-adr.md) settles the `Authorizer` chain and its named-link registration (`registerAuthorizer`), with the model judge as its first consumer. The **registration seam** this section anticipated is now designed: [ADR 0007](../decisions/0007-model-judge-authorizer-chain-adr.md) settles the `Authorizer` chain and its named-link registration (`registerAuthorizer`), with the model judge as its first consumer.
@@ -725,160 +575,25 @@ The concept and the code role take two grammatical forms of one root, each for w
```text ```text
src/ src/
├── rule.ts Rule type, Ruleset type, evaluate() (takes an injected `PathFlavor` for win32 path-surface case-folding); exports `pathMatchOptions(surface, flavor)` ├── index.ts Composition root and Pi event registration
├── normalize.ts Config → Ruleset normalization (flat format) ├── config-loader.ts Global ordinary policy loading
├── synthesize.ts Universal default + MCP baseline → Ruleset ├── policy-loader.ts Global policy + active-agent frontmatter gateway
├── wildcard-matcher.ts Compiled glob matching. `CompiledWildcardPattern.matches(value)` is the only match surface (no exposed `RegExp`). Constraint: the win32 `windowsSeparators` fold applies to the pattern and the matched value alike, and lives on the compiled pattern so it cannot be half-applied — folding only the pattern makes every forward-slash value unmatchable (#653) ├── hard-guard-policy-loader.ts Independent global-only hard-guard loading
├── pattern-suggest.ts Per-surface approval pattern suggestions ├── rule.ts Pattern matching and specificity ranking
├── bash-arity.ts Command arity table for bash pattern suggestions ├── permission-manager.ts Unified intent evaluation
├── expand-home.ts `expandHomePath`: `~` / `$HOME` / `${HOME}` expansion for patterns and path values, over one prefix table so the three spellings cannot drift; a prefix is recognized only standalone or before a separator, so `~username` / `$HOMEDIR` / `${HOME:-/tmp}` are left alone ├── access-intent/ Resolved intent types and resolver
├── session-approval.ts SessionApproval value object - owns the single/multi-pattern union; exposes representativePattern and toGateApproval() ├── handlers/
├── session-rules.ts Session approval store (Ruleset wrapper); `implements SessionApprovalRecorder`; injected into `GateRunner` as the recorder role │ ├── before-agent-start.ts Session warmup and skill metadata only; no active-tool control
├── policy-loader.ts PolicyLoader interface + FilePolicyLoader (file I/O, mtime caching); marks a present-but-unloadable non-global scope `invalid` (an absent file stays a plain empty scope) so composition can fail closed │ └── gates/ skill -> path -> bash -> tool pipelines
├── scope-merge.ts Cross-scope permission merge + origin-map bookkeeping ├── authority/
├── permission-manager.ts Scope loading + rule composition + `check(intent)` (single resolution entry point); delegates I/O to PolicyLoader; floors the composed ruleset `allow`→`ask` (origin `fail-closed`) when a non-global scope is `invalid`, and appends a fail-closed notice to `getConfigIssues`. Constraint: stays string-based — must not import `AccessPath` (the ADR 0002 string boundary, lint-guarded by `no-restricted-imports`) │ ├── authorizer-selection.ts Configured authorizer resolution
├── permission-gate.ts Pure deny/ask/allow gate (injected IO) │ └── authorizer-chain.ts Named authorizer ordering and terminal fallback
├── permission-resolver.ts `ScopedPermissionResolver` interface - the single `{ resolve(intent) }` role the gate factories / runner / pipeline depend on; `PermissionResolver` concrete class holds `ScopedPermissionManager` + `SessionRules`, owns `resolve(intent)` (unwraps an `access-path` `AccessIntent` via `matchValues()` before calling `manager.check`; the concrete class also accepts a pre-fixed `path-values` intent as a passthrough — the forwarded-serving wire's producer, #597 — while the gate-facing interface stays narrow to `AccessIntent`), raw `checkPermission` (`implements SkillPermissionChecker`, no session rules), `getToolPermission`, and `getConfigIssues` ├── auto-review/ Integrated reviewer and count-based analysis
├── decision-reporter.ts `DecisionReporter` interface + `GateDecisionReporter` class - owns `SessionLogger` and event bus; writes review-log entries and emits decision events ├── service.ts Cross-extension permission service
├── decision-audit.ts `DecisionRecorder` / `DecisionSummaryWriter` / `AuditLogger` interfaces + `DecisionAudit` class - per-session decision counters; `writeSummary` emits a `permission.session_summary` debug line on shutdown and warns on a `toolCalls != allowed + blocked + errors` invariant violation └── types.ts Shared domain types
├── session-approval-recorder.ts `SessionApprovalRecorder` interface - records a granted session-scoped approval into the session ruleset; implemented by `SessionRules`
│
├── permission-session.ts `PermissionSession` class - state/lifecycle owner: owns context lifecycle, session-rule lifecycle (`reset`/`shutdown`/`reload`), skill entries, agent-name resolution, the config gateway, the Tell-Don't-Ask gate inputs, and `notify(message)` (UI warn over the owned context, no-op before activation); `implements ToolCallGateInputs`. The resolve role lives in `PermissionResolver`, the recorder role in `SessionRules`; handlers depend on the concrete class + `PermissionResolver`
├── path-normalizer.ts `PathNormalizer` class - the path-interpretation collaborator constructed once at the session edge with the injected `PathFlavor` (exposed as `readonly flavor`) and session `cwd` baked in; hands raw tokens, returns prepared values: `forPath`/`forLiteral` (build `AccessPath`s), `isAbsolute`/`resolveBase`/`joinBase` (flavor-aware `cd`-fold routing), `isWithinDirectory`/`isOutsideWorkingDirectory` (containment), `comparableValue` (lexical comparison for skill-prompt matching), `isInfrastructureRead`, and `forBashToken`/`interpretBashCdTarget`/`isBoundaryOutsideWorkingDirectory` (Git Bash/MSYS bash-token interpretation — safe devices preserved, `/c/…` drive mounts translated, other POSIX absolutes literal-only). Also owns `entryExists` (lstat), the existence probe deciding whether a bare bash token names a real filesystem entry, kept here so path interpretation has a single filesystem edge alongside canonicalization (ADR 0009). A facade over the `path/` and `access-intent/path-normalization` primitives; holds no platform discriminator — every platform question delegates to `flavor`, so no consumer reads `process.platform` or threads `cwd`
├── access-intent/ Access-intent domain: turns `(toolName, input)` into what is being accessed (bash decomposition, MCP targets, path extraction, the `AccessPath` value object and `AccessIntent` union)
│ ├── path-normalization.ts `AccessPath`'s representation backing: `normalizePathForComparison` (lexical absolute, via `flavor.comparable`), `canonicalNormalizePathForComparison` (symlink-resolved + win32-lowercased via `flavor.fold`), `normalizePathPolicyLiteral` (literal cleanup), `getPathPolicyValues` (lexical ∪ relative match set) + `PathPolicyValueOptions`; pure derivation over an injected `PathFlavor`
│ ├── access-intent.ts `AccessIntent` discriminated union each gate emits: `tool` (raw input the manager normalizes) and `access-path` (an `AccessPath` for every path gate — `path`, `external_directory`, and the per-tool path-bearing surfaces `read`/`write`/`edit`/`grep`/`find`/`ls`). Constraint: `ResolvedAccessIntent` (`tool | path-values`) is what the manager consumes after the resolver unwraps `access-path` via `matchValues()` — `path-values` is still not gate-emitted, keeping the manager string-based (the ADR 0002 boundary), but since #597 it has a second legitimate producer: the forwarded-serving wire builds a `path-values` intent directly from a `ForwardedAccessIntent`'s child-fixed `matchValues`, via `buildResolvedIntentFromMatchValues` (`input-normalizer.ts`)
│ ├── access-path.ts `AccessPath` value object: `matchValues(): string[]` (lexical alias union ∪ canonical, the match set), `boundaryValue(): string` (symlink-resolved + win32-lowercased), `value(): string` (lexical absolute display form), `resolvedAlias(): string | undefined` (the canonical form only when distinct, for disclosing a symlink target in a prompt/denial); `forPath(pathValue, { cwd, resolveBase?, flavor })` serves every path surface, `forLiteral(literal)` builds a literal-only path with no canonical for the unknown-base bash case, and `forDevice(devicePath)` preserves an MSYS device path verbatim. Type-distinct accessors make the lexical/canonical conflation a compile error
│ ├── tool-kind.ts `ToolKind` string-union + `classifyToolKind(toolName)` — the single dispatch point deciding what an invocation accesses (bash command / MCP target / skill / path-bearing tool / extension) once at the normalize boundary; imports only `PATH_BEARING_TOOLS` (AccessPath-free, so `permission-manager.ts` may consume it without breaching the ADR 0002 string boundary). Also owns `isMcpCheck({ toolName, source })`, the shared MCP-ness predicate the presentation consumers dispatch on
│ ├── input-normalizer.ts Surface-specific input normalization → NormalizedInput
│ ├── mcp-targets.ts MCP multi-name target derivation
│ ├── tool-input-path.ts `getToolInputPath` (built-in / MCP / extension path extraction) + `getPathBearingToolPath` (built-in-only)
│ ├── path-surfaces.ts Static surface/tool lookup sets: `PATH_BEARING_TOOLS`, `READ_ONLY_PATH_BEARING_TOOLS`, `PATH_SURFACES`
│ └── bash/
│ ├── parser.ts Lazy tree-sitter-bash parser: `TSNode` interface (exported), `getParser = memoizeAsyncWithRetry(initParser)` (exported); `warmBashParser()` / `getWarmBashParser(): TSParser | null` / `resetWarmBashParser()` (test-only) expose the resolved parser synchronously after a `before_agent_start` warm-up so the advisory bash path can decompose at gate parity
│ ├── node-text.ts Quote-aware AST node-text resolver: `resolveNodeText` (pure), `SKIP_SUBTREE_TYPES` (node types whose *text* is never an argument — heredoc/comment), `ARG_NODE_TYPES` (argument-value node-type set); delegates expansion nodes to `shell-variable-expansion.ts`, falling back to the node's literal text
│ ├── nested-execution.ts Shared nested-execution vocabulary for both bash surfaces: `NESTED_EXECUTION_CONTEXTS` (substitution node type → `BashCommandContext`), `EXECUTION_HOST_TYPES` (node types that are not commands or argument values but whose subtree can host a command that really runs — redirects, heredoc/herestring bodies), and `forEachNestedExecution(node, visit)`, which searches strictly within a subtree and does not descend past a context it finds. Constraint: the command surface and the path surface must share one definition of a nested execution, or a command gated on one surface escapes the other (#741)
│ ├── shell-variable-expansion.ts Pure plain-reference resolver: `resolvePlainVariableExpansion(node): string | null` — `$HOME`/`${HOME}` → `os.homedir()`, `$PWD`/`${PWD}` → `.` (the base-relative marker, so the resolver's existing `resolveBase` applies it after `cd` folding). Plainness is structural (exactly one `variable_name` child, otherwise only delimiters), so an operator form (`${HOME:-/tmp}`, `${#HOME}`) is rejected without enumerating bash's expansion operators. Constraint: the resolvable set is closed at `HOME`/`PWD` — widening it is an ADR 0009 amendment, and the expansion vocabulary lives only here, never in the classifiers
│ ├── token-collection.ts Bash argument/flag tokenizer: `collectPathCandidateTokens`, `collectCommandTokens`, `collectRedirectTokens`, `extractCommandName` (exported); private `PATTERN_FIRST_COMMANDS` table and pattern/generic collectors, plus `collectEmbeddedOptionValues` — emits the inline value of an `--opt=value` argument as its own token, read from the argument nodes (a pattern-first collector classifies a flag and never emits it), so an option-embedded path is classified by the ordinary shape rules without per-command option tables (#645). Also projects the operands of a command hosted in a redirect destination or an interpolating heredoc body; the `EXECUTION_HOST_TYPES` dispatch sits above the `SKIP_SUBTREE_TYPES` check because `heredoc_body` is in both sets and the host reading must win — its prose stays out of the path surface while its substitution's operands enter it (#741)
│ ├── command-enumeration.ts Bash command enumerator: `collectCommands` (exported) + the descend/skip tables and the node→`CommandWord` adapter; owns the `BashCommand` interface including the `wrapperKind` discriminant and the display-only `executedUnit`; strips leading `variable_assignment` prefixes from command units. Constraint: `COMMAND_ENUM_SKIP` holds only genuinely inert types (`comment`, `heredoc_end`) — a node that is not a command but can host one belongs in `EXECUTION_HOST_TYPES`, and conflating the two questions is the bypass #741 fixed
│ ├── wrapper-analysis.ts Pure word-based wrapper interpretation: `classifyWrapperWords` (the `WrapperKind` discriminant — `"opaque-payload"` for `bash -c`/`eval`, `"indirection"` for sudo/env/xargs/find -exec/…) and `executedUnitOf` (the command a wrapper actually runs), over the shared wrapper vocabulary. Constraint: both answers read one vocabulary — the shape that floors a unit to `ask` and the shape that names its inner command cannot drift. `executedUnitOf` is display-only and fails to `null` rather than to a guess, so it never weakens a gate
│ ├── bash-path-resolver.ts `BashPathResolver` class (constructed with a `PathNormalizer` and an optional `workdir`): `resolve(rootNode): ResolvedBashPaths` walks the AST once, tagging each path-candidate token with the `EffectiveBase` in force at its position, and returns `{ externalPaths: AccessPath[], ruleCandidates: BashPathRuleCandidate[] }`; routes every path through the injected `PathNormalizer`. Both projections fall back to the shared `probeBareToken` for a token the shape gates reject, admitting it only when `normalizer.entryExists` confirms it names a real entry and the effective base is known; `projectRuleCandidates` passes `this.normalizer.flavor` so a win32 backslash-relative token is recognized like its `/` form; `projectExternalPaths` decides outside-cwd from the `AccessPath`'s canonical boundary via `collectIfExternal`, treating a literal-only bash token as unconditionally external. Constraint: consults no ruleset — candidacy is a filesystem question and the decision belongs to the gates (ADR 0009). The subtlest region in the package
│ ├── msys-bash-tokens.ts Pure win32 bash-token shape classifier: `classifyWin32BashToken(token): BashTokenShape` (`device` | `drive-mount` with translated `windowsPath` | `posix-absolute` | `plain`); no filesystem, no `process.platform` read; the return type of `PathFlavor.bashTokenShape`, consumed by `PathNormalizer.forBashToken`/`interpretBashCdTarget`
│ ├── token-classification.ts Pure token classifiers: `classifyTokenAsPathCandidate` (strict: `/`, `~/`, `..`, Windows drive-letter), `classifyTokenAsRuleCandidate(token, flavor)` (broader: also dot-files, relative paths, the drive-letter backslash form, and — under the win32 flavor — a backslash-relative token), and `classifyBareTokenCandidate(token)` (prelude-only: returns any token whose shape does not rule out a path, for the resolver to probe). Constraint: policy-free — no classifier consults the ruleset (ADR 0009)
│ ├── sync-commands.ts `parseBashCommandsSync(command): BashCommand[] | null` — warm-parser-backed synchronous command enumeration; returns `null` in the pre-warm window so the advisory bash path falls back to whole-string matching
│ └── program.ts Born-ready `BashProgram` value object: `parse(command, normalizer, options?)` eagerly resolves all three slices at construction; parameter-free getters `commands()`, `externalPaths(): AccessPath[]`, `pathRuleCandidates()`. `commands()` splits the chain AND descends into command/process substitutions and subshells — wherever they appear, including a redirect destination and an interpolating heredoc body (#741) — tagging each nested command with its execution `context`, stripping any leading `variable_assignment` prefix, and flagging wrapper units with a `wrapperKind` so their decision floors to `ask`
├── handlers/ Handler classes with narrow constructor injection
│ ├── index.ts Barrel re-exports
│ ├── lifecycle.ts SessionLifecycleHandler (session: `PermissionSession` + resolver + serviceLifecycle + audit); writes the decision-audit summary on `session_shutdown`
│ ├── before-agent-start.ts AgentPrepHandler (session + resolver + toolRegistry + `warmParser: () => void`); shouldExposeTool pure helper; recomputes the active set + system-prompt override every fire; fire-and-forget `warmParser()` triggers the tree-sitter warm-up
│ ├── permission-gate-handler.ts PermissionGateHandler (session + toolRegistry + pipeline + skillInputPipeline + runner); `handleToolCall` returns the internal total `GateOutcome`; validateRequestedTool + getEventInput + extractSkillNameFromInput pure helpers
│ ├── tool-call-boundary.ts `createFailClosedToolCall(gate, reporter, audit, tracer)` - the only `pi.on("tool_call")` target and sole `GateOutcome` → SDK-shape translator; owns the `try/catch → block` (the SDK's `emitToolCall` does not catch a throwing handler), writes a `gate_error` review entry on throw with its own minted request id (the throw may come from anywhere in the pipeline, so no gate's id is available) via a helper that swallows so the block stays unconditional, and emits a `debugLog`-gated `permission.decision` trace per call
│ └── gates/ Pure descriptor factories + runner
│ ├── types.ts GateOutcome, ToolCallContext
│ ├── descriptor.ts GateDescriptor (carrying the `PromptPayload` as its single presentation fact), GateBypass, GateResult types, plus `DecisionEventFacts` (a decision event minus the `requestId` only the runner can supply — the type that routes every emit through the runner's stamping site). Constraint: `promptDetails` omits both `requestId` and `payload`, which the runner stamps, so a gate cannot supply either twice
│ ├── runner.ts GateRunner class — constructed with `ScopedPermissionResolver`, `SessionApprovalRecorder`, `AskEscalator` (the single-method ask-escalation seam), `DecisionReporter`, plus a live `isYoloEnabled` reader (read per gate; the sole place a post-resolution ask is reconciled with yolo); `run(gate, agentName)` dispatches null / bypass / descriptor and mints the request id before the branch, so a request that never prompts is identified exactly as one that does; its private `emitDecision` is the sole site stamping that id onto a `DecisionEventFacts`
│ ├── tool-call-gate-pipeline.ts `ToolCallGateInputs` interface (`getActiveSkillEntries`, `getInfrastructureReadDirs`, `getToolPreviewLimits`, `getPathNormalizer`, `getShellToolAliases`) + `ToolCallGatePipeline` class — constructed with `ScopedPermissionResolver` + `ToolCallGateInputs`; owns bash-command extraction + the single `BashProgram.parse`, `ToolPreviewFormatter` construction, the infra-dir list, the six gate producers, and the run loop; `evaluate(tcc, runner)` returns the first block outcome or allow
│ ├── skill-input-gate-pipeline.ts `SkillInputGateInputs` + `GateNotifier` interfaces + `SkillInputGatePipeline` class — owns the raw `checkPermission` pre-check, deny notify, `describeSkillInputGate` descriptor, and `runner.run`; `evaluate(skillName, agentName, notifier, runner)` makes the `input` path symmetric with the `tool_call` path
│ ├── helpers.ts deriveDecisionValue, deriveResolution, buildDecisionEvent, resolveYoloGrant (the standing yolo grant covering a resolved check — a ruleset-rewritten allow or, under yolo, a residual ask)
│ ├── skill-read.ts describeSkillReadGate - pure descriptor factory
│ ├── skill-input.ts describeSkillInputGate - pure descriptor factory; takes a pre-computed check result so the runner reuses the caller's check
│ ├── external-directory.ts describeExternalDirectoryGate - pure descriptor/bypass factory; builds an `AccessPath`, delegates policy resolution to `resolveExternalDirectoryPolicy`, uses `accessPath.boundaryValue()` for the outside-CWD boundary and infra-read checks, and discloses `accessPath.resolvedAlias()` when it names a location distinct from the typed path
│ ├── external-directory-policy.ts Shared external-directory policy check for both gates: `resolveExternalDirectoryPolicy(path, resolver, agentName)` emits an `access-path` `AccessIntent` on the `external_directory` surface; `selectUncoveredExternalPaths(paths, resolver, agentName)` resolves a set, keeps the not-allowed entries, and selects the worst via `pickMostRestrictive`
│ ├── bash-external-directory.ts describeBashExternalDirectoryGate - pure descriptor/bypass factory over the injected `BashProgram` (`externalPaths()`); delegates the per-path alias matching and worst-uncovered selection to `selectUncoveredExternalPaths`
│ ├── bash-path.ts describeBashPathGate - pure descriptor/bypass factory for bash path rules over the injected `BashProgram` (`pathRuleCandidates()`); evaluates each candidate's `AccessPath` via an `access-path` `AccessIntent` and selects the worst uncovered token via `pickMostRestrictive`, keeping the raw token for prompts/logs/approvals and `path.value()` for the approval pattern
│ ├── candidate-check.ts `pickMostRestrictive` - pure deny > ask > allow selection over PermissionCheckResults (first-wins on ties); shared by the bash gates and the external-directory policy helper
│ ├── bash-path-extractor.ts Thin facade (`extractExternalPathsFromBashCommand`) over `BashProgram`
│ ├── bash-command.ts `resolveBashCommandCheck` - pure combiner over caller-supplied `BashCommand[]` units, checks each unit on the `bash` surface, tags the winning result with the offending command's execution `context`, selects via `pickMostRestrictive`; when empty, resolves the whole command only for a trivially-empty command and otherwise returns an explicit `deny` covering it, else fails closed to a synthetic `ask` with the `<unparseable-bash-command>` sentinel
│ ├── path.ts describePathGate - pure descriptor factory for cross-cutting path rules; builds an `AccessPath` and emits an `access-path` `AccessIntent` on the `path` surface so it matches the canonical (symlink-resolved) form like `external_directory`
│ ├── tool.ts describeToolGate - pure descriptor factory for the per-tool gate; for path-bearing built-in tools the pipeline builds an `AccessPath` and emits an `access-path` intent on the tool-name surface so per-tool rules match lexical ∪ canonical, and the session-approval value derives from `accessPath.value()`; bash/MCP/extension tools keep the raw `tool` intent
│ └── index.ts Barrel re-exports
│
├── index.ts Extension factory - event wiring, collaborator construction (established injection-bag wiring kept inline per the anti-procedure-splitting rule)
├── bash-advisory-check.ts `resolveBashAdvisoryCheck(command, agentName, resolver)` — routes an advisory `bash` query through the gate's shared `resolveBashCommandCheck` over `parseBashCommandsSync` units, falling back to a whole-string `tool` intent in the pre-warm window; kept out of `access-intent/` to avoid a domain→handler import
├── permissions-service.ts `LocalPermissionsService` class - in-process implementation of `PermissionsService`; injected with narrow collaborator interfaces (a `resolve` + `getToolPermission` resolver view, a `getPathNormalizer` session view, the formatter/access-extractor/authorizer registrars); routes path-surface queries through the resolver as an `access-path` intent so external policy queries match lexical ∪ canonical like the gates, and bash queries through `resolveBashAdvisoryCheck` for decomposed fidelity
├── service-lifecycle.ts `ServiceLifecycle` interface + `PermissionServiceLifecycle` class — owns the process-global service publish (child-gated), ready emit, and session teardown ordering
├── service.ts PermissionsService interface, Symbol.for() accessor (cross-extension API); public surface published as a self-contained dist/public.d.ts bundle
├── permission-events.ts Event channel constants, payload types, emit helpers. `PermissionUiPromptEvent` carries the payload's `request` core alongside the flat `surface`/`value` display projection — the gate surface and the display surface are two facts, not one (#292)
├── permission-request-id.ts `createPermissionRequestId()` — the one mint for a permission request's `perm-<uuid>` id; distinct from the host's `toolCallId`, which stays alongside it as the join back to the Pi transcript
├── permission-ui-prompt.ts Centralized construction for `permissions:ui_prompt` event payloads - `buildUiPrompt` is the single builder for direct and forwarded asks, keeping the emitted contract shape in one place. It projects the prompt payload's `request` core onto the event and nothing else: the bus is the narrowest renderer, so no evidence reaches it (ADR 0011 §6)
├── config-store.ts `ConfigStore` class — owns `config` + `lastConfigWarning`; `ConfigReader`, `SessionConfigStore`, `CommandConfigStore` narrow interfaces
├── config-loader.ts File I/O, format detection, strict zod validation (fail-closed) for config files
├── config-schema.ts Zod schemas - single source of truth for the config shape; derives the JSON Schema (buildPermissionsJsonSchema) and the config types
├── config-paths.ts Path derivation
├── extension-paths.ts `ExtensionPaths` value object - immutable path constants derived from `agentDir` (and optional Pi `getPackageDir()`) at startup (`computeExtensionPaths`)
├── config-reporter.ts Structured log entries for resolved config
├── config-modal.ts /permission-system slash command UI
├── extension-config.ts Runtime knobs (debugLog, yoloMode, etc.)
│
├── permission-merge.ts Deep-shallow merge for flat permission configs
├── async-cache.ts `memoizeAsyncWithRetry` - memoizes an async factory but drops a rejected result so the next call retries; used by `access-intent/bash/parser.ts` for resilient tree-sitter parser init
├── safe-system-paths.ts `SAFE_SYSTEM_PATHS` (OS device files: `/dev/null`, `/dev/std{in,out,err}`) + `isSafeSystemPath`
├── path/ Path-language domain: the win32-vs-POSIX decision resolved once, plus the co-rewritten path leaves
│ ├── path-flavor.ts `PathFlavor` interface + `pathFlavorForPlatform` factory + `win32PathFlavor`/`posixPathFlavor` singletons — the platform's path *language* as one immutable collaborator (`impl`, `matchOptions`, `fold`, `comparable`, `isWithin`, `hasPathSeparator`, `bashTokenShape`). Constraint: holds the package's only `=== "win32"` comparison; injected once from `index.ts` into `PermissionManager` / `PermissionSession` (→ `PathNormalizer`) / `SubagentDetection`
│ ├── canonicalize-path.ts Best-effort symlink resolution via `realpathSync` — walks up to longest existing ancestor and re-appends non-existent tail; ENOENT/ENOTDIR safe, EACCES/ELOOP fall back to lexical form; takes an injected `PathFlavor`
│ ├── path-containment.ts Pure path geometry over already-canonical operands: `isPathOutsideWorkingDirectory` (excludes safe system paths, then defers containment to `PathFlavor.isWithin`; no derivation, no filesystem)
│ └── pi-infrastructure-read.ts `isPiInfrastructureRead` - read-only-tool auto-allow within infra dirs / project-local `.pi/{npm,git}`; takes an already-canonical path + injected `PathFlavor`
├── node-modules-discovery.ts Global node_modules resolution (walk-up + npm root -g fallback)
├── system-prompt-sanitizer.ts Narrow Available tools section + filter guidelines to the active set
├── skill-prompt-sanitizer.ts Skill prompt filtering by policy
├── permission-prompts.ts Agent-facing pre-check reasons (missing tool name, unknown tool) refused before any permission check runs
├── presentation/ Prompt presentation: the payload a gate emits, and the renders over it (ADR 0011)
│ ├── prompt-payload.ts `PromptPayload` (the `kind` discriminant, the `request` invariant core, the complete `evidence` list, the `annotations` slot) + `localRequester`/`findEvidence`/`allEvidence` + `asPromptPayload`, the all-or-nothing tolerant guard the forwarded wire's reader narrows through. Constraint: the payload is complete by contract — it never truncates and never decides what a human sees, so elision is a property of a render (ADR 0011 §2). The guard lives beside its type so a new request fact updates it next door rather than in a distant reader
│ ├── tool-ask-payload.ts `buildToolAskPayload` — the bash, MCP, and generic-tool asks; carries the invoked tool name when a shell alias re-exposes bash (#574) and the wrapper's executed unit (#713)
│ ├── path-ask-payload.ts `buildPathAskPayload`, `buildExternalDirectoryAskPayload`, `buildBashExternalDirectoryAskPayload` — each escaping path carries its canonical alias as that evidence entry's `detail`, so a bounded render cannot show a path while eliding what it resolves to
│ ├── skill-ask-payload.ts `buildSkillAskPayload`, `buildSkillPathAskPayload` — the skill is the decision-relevant value (it is what the policy names); a skill read carries the path it was reached through as evidence
│ ├── forwarded-ask-payload.ts `buildForwardedAskPayload` — a two-branch projection, not a synthesizer: the child's own payload with only `requester` re-stamped to the request's authoritative provenance, or a degraded `kind: "forwarded"` render built from the display fields a payload-less request does carry. Constraint: the serving node is the only party that knows the ask arrived over the wire, so it re-stamps the requester and passes every other child fact through untouched
│ ├── dialog-renderer.ts `renderPromptDialog(payload, budget, paint)` — the bounded render for the inline dialog and the `select`/`input` fallback: aligned one-fact-per-line layout, a per-field width cap, a row budget over the evidence, and whole-token highlighting of the flagged element. Also `RenderBudget`/`DEFAULT_RENDER_BUDGET`/`resolveRenderBudget` (the configured budget) and `completeViewBudget` (the complete view). Constraint: the row budget bounds evidence and the field cap bounds the core — a core fact is shortened, never dropped (ADR 0011 §3 over §5)
│ ├── line-fitting.ts `fitLinesToWidth` — wrap-then-truncate to a terminal width, so each line is one visual row; shared by the `ctx.ui.custom` dialog, whose contract requires it, and by the renderer, which cannot count rows before wrapping
│ ├── fact-vocabulary.ts `flaggedElements`/`flaggedElementLabel`/`valueLabel`/`describeBashCommandContext` — the render vocabulary shared by every renderer over a payload: which element an ask flags, what it is called, and how a nested execution context reads. Owned by no renderer, so the dialog, the agent text, and the review log cannot disagree about what an ask is flagging
│ ├── agent-renderer.ts `EXTENSION_TAG` + `renderPolicyDenial`/`renderUserDenial`/`renderUnavailableDenial` — the agent-facing render of a refused ask. Constraint: it identifies the call and never reproduces it (ADR 0011 §7) — the bash command is never rendered, and the flagged path/target/skill is capped
│ └── review-log-renderer.ts `renderReviewLogFacts(payload)` — the request facts the review log persists (ADR 0011 §6), and no evidence or annotations. Constraint: exposure does not grow — evidence is the unbounded part `docs/decisions/0010-permission-log-secret-exposure.md` bounds
├── tool-input-preview.ts Pure tool-input text utilities (truncation, line counting, count formatting), serialization + default constants; `serializeToolInputPreview` (prompt, unredacted) and `serializeRedactedToolInputPreview` (log) are separate entry points because the input is flattened to a string before the writer sees its keys
├── tool-input-prompt-formatters.ts Pure per-tool prompt formatters (edit/write/read) + getPromptPath helper
├── tool-preview-formatter.ts ToolPreviewFormatter class - config-dependent prompt + log formatting; seam-first dispatch consults ToolInputFormatterLookup before built-in switch
├── tool-input-formatter-registry.ts ToolInputFormatter type, ToolInputFormatterLookup + ToolInputFormatterRegistrar interfaces, ToolInputFormatterRegistry class - persistent registry for custom previews
├── tool-access-extractor-registry.ts ToolAccessExtractor type, ToolAccessExtractorLookup + ToolAccessExtractorRegistrar interfaces, ToolAccessExtractorRegistry class - persistent registry letting extensions declare a tool's filesystem path for the path/external_directory gates
├── builtin-tool-input-formatters.ts Built-in formatters registered at startup: formatMcpInputForPrompt keyed to "mcp"
├── tool-registry.ts ToolRegistry interface + tool name validation
├── active-agent.ts Agent name detection from session/system prompt
├── authority/ Subagent detection, the Authorizer spine, and forwarded-permission escalation
│ ├── authorizer.ts `Authorizer` (non-terminal chain link, `authorize(details, query, log): Promise<AuthorizerVerdict>` - handed a session-scoped `PermissionQuery` and an `AuthorizerLog` review-log seam per ADR 0007 §3) + `TerminalAuthorizer` (terminal, `authorize(details): Promise<PermissionPromptDecision>` - cannot defer, enforced type-level) + `AuthorizerVerdict` (`allow | deny | defer`) + `SelectedAuthority` (`{ terminal, adjudicatesLocally }`) + `AuthorizerSelectionDeps` + `selectAuthorizer(ctx, deps): SelectedAuthority` - the once-per-activation hasUI/isSubagent/deny dispatch, returning the chain role that dispatch implies (`adjudicatesLocally: false` only for the relaying `ParentAuthorizer` arm, ADR 0007 §7)
│ ├── authorizer-chain.ts `composeAuthorizerChain(links, terminal, query, log)` - folds non-terminal `NamedAuthorizer` links ahead of the context-selected terminal (`defer` → next link, `allow`/`deny` → decision stamped `decidedBy: {kind: "authorizer", name, verdict, reason}` at the point the loop breaks, so a link that deferred is not credited), injecting `query` and the review-log `log` into each link; zero links returns the terminal instance (identity)
│ ├── decision-source.ts `DecisionSource` discriminated union (`user | authorizer | rule | session_approval | yolo | infrastructure_read | unavailable | gate_error | forwarded`) + depth-bounded tolerant guard `asDecisionSource`. Constraint: each variant is self-contained (it repeats its own surface/pattern/origin/name/reason) because the forwarded response file carries no such columns to lean on; the recursive `forwarded` variant is read off disk, so its guard is depth-bounded and rejects an over-deep chain whole rather than truncating it
│ ├── authorizer-registry.ts `AuthorizerRegistry` (+ `AuthorizerLookup`/`AuthorizerRegistrar` ISP interfaces) - name → link `authorize` map mirroring `ToolAccessExtractorRegistry`; one instance in `index.ts`, exposed cross-extension via `PermissionsService.registerAuthorizer`; throw-on-duplicate, identity-guarded disposer
│ ├── delegation-envelope.ts `encloseInDelegationEnvelope(authorize)` + `DELEGATION_EXCLUDED_SURFACES` - the bounded-delegation checkpoint (ADR 0007 §5): caps a link's `allow` on `path`, undetermined surfaces, and `external_directory` except for built-in read-only path tools (`read`, `find`, `grep`, `ls`); deny/defer pass through
│ ├── local-user-authorizer.ts `LocalUserAuthorizer` class - `TerminalAuthorizer` for a session with UI and the single `permissions:ui_prompt` emit site: renders a forwarded ask's provenance as a non-degraded broadcast + `(Subagent)` title, then dispatches to the inline keybind dialog (TUI) or the `select`/`input` fallback
│ ├── permission-dialog.ts Dialog option semantics + `requestPermissionDecisionFromUi` (`select`/`input` fallback) + `PermissionPromptDecision` (whose `decidedBy` is required) and `UnattributedDecision` (the same minus it); the mode dispatch lives in `permission-prompt-component.ts`
│ ├── permission-prompt-decision.ts Pure decision model (`reducePrompt` + `PromptModelConfig`/`PromptViewState`) for the inline keybind dialog - hotkey arming (double-press), step transitions, reason validation; no SDK/TUI imports
│ ├── permission-prompt-component.ts Inline `ctx.ui.custom<UnattributedDecision>` keybind dialog (TUI) driven by the decision model + the `requestPermissionDecision` mode dispatcher (tui → inline, else fallback); the reason step delegates to the pi-tui `Input` line editor (rebuilt per visit, so a backed-out draft cannot be undone back into a later ask) and forwards Pi's `app.tools.expand` action in the decision/scope steps only, never during reason entry. Constraint: the dispatcher is the one place a human surface is chosen, so it is where the decision is attributed (`decidedBy: {kind: "user", via}`) - the dialog model and the fallback each naming themselves would be two sites that must agree with its branch
│ ├── bracketed-paste.ts `collapsePastedNewlines(data)` - rewrites the content between a chunk's `\x1b[200~`/`\x1b[201~` markers so each newline run becomes one space, keeping a multi-line paste readable in the single-line reason field (the line editor deletes newlines outright, joining the words across a break); markers preserved, anything that is not a complete paste chunk returned unchanged
│ ├── denying-authorizer.ts `DenyingAuthorizer` class - least-privilege `TerminalAuthorizer` for a session with no reachable authority; denies with the `confirmationUnavailable` marker so the ask path derives the `confirmation_unavailable` resolution, attributed `decidedBy: {kind: "unavailable"}`
│ ├── authorizer-selection.ts `AuthorizerSelection` class - context-owning `AskEscalator` implementation (`escalate(details)`); selects the authority once per activation, and per ask resolves the `authorizerChain` config to registered links (config order; unregistered names skipped fail-safe with an `authorizer_chain_unregistered_link` review event; consulted names recorded as `authorizer_chain_resolved`; each wrapped in the delegation envelope), composes them via `composeAuthorizerChain`, and delegates via `PermissionPrompter`; a relaying node resolves none and records `authorizer_chain_delegated` instead (one chain per node, ADR 0007 §7)
│ ├── permission-prompter.ts `PermissionPrompter` class (`PermissionPrompterApi`) - review-log bracketing (waiting → approved/denied) around `authorizer.authorize(details)`, recording the decision's `decidedBy` on the outcome entries only (the waiting entry has no decider yet); `PromptPermissionDetails` type (carries the child-fixed `accessIntent` facts a forwarded ask relays)
│ ├── subagent-detection.ts SubagentDetection class - single owner of subagent detection (SubagentDetector.isSubagent + RegisteredChildDetector.isRegisteredChild); delegates to subagent-context
│ ├── subagent-context.ts Pure subagent execution context detection (registry + env vars + filesystem)
│ ├── subagent-registry.ts SubagentSessionRegistry class + getSubagentSessionRegistry() process-global accessor - in-process subagent session tracking
│ ├── serving-registry.ts ServingSessionRegistry class + getServingSessionRegistry() process-global accessor, split into the `ServingAnnouncer` (poller) and `ServingLookup` (forwarding child) seams - which in-process sessions are draining a forwarded-permission inbox; `composeServingAnnouncers` fans one announcement across every channel a serving session publishes on
│ ├── forwarding-liveness.ts The filesystem half of the same question, for a child that shares no memory with its parent: `ServingHeartbeatStore` (a `ServingAnnouncer` publishing `<forwardingDir>/serving/<id>.json` with the served session, its pid, and its refresh time; throttled, never throws, and sweeps records of dead processes once per session) + `HeartbeatReader` classifying a target as alive/absent/stale/dead_pid + `ForwardingLivenessJudge` (`TargetServingLookup`), which routes a liveness question to the channel that can answer it by the target's `self`/`registry`/`env` provenance. Constraint: the records live beside `sessions/`, never inside it, so liveness stays disjoint from the request/response cleanup ordering (#398)
│ ├── subagent-lifecycle-events.ts subscribeSubagentLifecycle() - subscribes to @gotgenes/pi-subagents child lifecycle events; registers/unregisters child sessions in SubagentSessionRegistry (ADR 0002)
│ ├── forwarder-context.ts `ForwarderContext` read-interface + `getSessionId`/`getCwd` - shared by the escalation and serving roles
│ ├── permission-forwarding.ts Cross-session forwarding wire types (`ForwardedPermissionRequest`, which carries the child's `PromptPayload` rather than a sentence assembled under the child's config; `ForwardedPermissionResponse`, whose optional `decidedBy` names what decided inside the responding session, distinct from the `responderSessionId` that names where; the `ForwardedAccessFacts`/`ForwardedAccessIntent` intent schema per ADR 0008) + `resolvePermissionForwardingTarget`, which returns the resolved session id together with its `self`/`registry`/`env` provenance (the routing key for which liveness channel may judge the target) + `encodeSessionIdForPath`, shared by both session-keyed layouts under the forwarding root
│ ├── approval-escalator.ts `ParentAuthorizer` class - `TerminalAuthorizer` for a subagent session: escalates the ask up the tree via the request-write/poll machinery, completing the child-fixed facts into a `ForwardedAccessIntent` (stamps `requesterCwd`/`principal`), `ctx` bound at construction; adopts the requester's `requestId` as the forwarded request's `id` (falling back to a fresh mint when it could not safely name a file — at a relay hop that id came off disk); every abandonment path (unresolvable target, unusable directories, unwritable request, unserved target, unreadable response, timeout) denies with `confirmationUnavailable` plus a path-naming `denialReason` — reused verbatim as the `unavailable` decider's reason so the two cannot drift — and discards the request so a late answer cannot arrive; an answered request's decision is nested under a `forwarded` decider carrying the responder's own
│ ├── forwarded-request-server.ts `ForwardedRequestServer` class (`InboxProcessor`) - serving-down role: `processInbox()` drains forwarded requests and resolves each like a local action - `ServingPolicy` (recorded authority) then `AskEscalator` on `ask`; `ServingPolicy.resolve(intent: ForwardedAccessIntent)` is intent-shaped (agent-scoped to `principal.agentName`, child-fixed `matchValues` used as-is, never re-derived through this session's `PathNormalizer`/cwd), floors to `ask` when `accessIntent` is absent (version skew); projects the request's access facts onto the escalated ask (`surface`/`matchValues`/`boundaryValue` only — `requesterCwd`/`principal` stay off the ask details, and the bounded-delegation checkpoint's exclusion reads the projected gate surface, #635); writes its decider onto the response (its own matched rule in full, the escalated decision's source, or a `gate_error` when the escalation itself threw), and the grant-scope translation rewrites the scope but never the decider; one-hop canary
│ ├── forwarding-io.ts Forwarding filesystem helpers - request/response read-write (tolerant read of the optional `accessIntent` and `decidedBy` fields; an unusable decider is dropped without rejecting the decision it accompanies), location derivation, atomic JSON writes (owner-only; `rename` preserves the temp file's mode). Constraint: the readers rebuild an allowlist of known fields, so a wire field added without being listed here is silently dropped
│ └── forwarding-manager.ts `ForwardingController` interface + `ForwardingManager` class - drives the forwarded-permission inbox polling lifecycle; tells `ForwardedRequestServer.processInbox`, and publishes the polled session id to the `ServingAnnouncer` plus a `forwarded_permission.serving_started`/`serving_stopped` review entry. Constraint: the per-tick re-announcement runs ahead of the processing guard, so a session whose human is deliberating at a forwarded dialog keeps announcing while `processInbox` is held open
├── session-logger.ts `SessionLogger` interface + `PermissionSessionLogger` class; owns JSONL-writer composition, IO-failure warning dedup, and notify sink
├── logging.ts JSONL review/debug log writer; serializes through `redactedJsonStringify` and creates both logs owner-only. Constraint: `writeLine` is the only place a line is produced, so both the key-name mask and the review stream's width bound live there and no write path can escape either
├── log-field-cap.ts `capLogFieldWidths` + `resolveReviewLogFieldWidth` + `DEFAULT_REVIEW_LOG_FIELD_MAX_WIDTH` - the review log's `reviewLogFieldMaxWidth` bound. Constraint: narrows by length alone and never reads a value to decide what to shorten, which is what keeps it a cap rather than redaction
├── json-safe-stringify.ts `createJsonSafeReplacer` (Error → plain object, bigint → string, cycles → `[Circular]`) + `safeJsonStringify`; separate from the writer because the prompt path serializes tool input too, and only the log path redacts
├── log-redaction.ts `isSensitiveLogKey` + `redactedJsonStringify` - key-name masking applied at the log-write boundary. Constraint: structural, never value-shape; see `docs/decisions/0010-permission-log-secret-exposure.md`
├── log-file-permissions.ts Owner-only mode constants + best-effort `restrictExistingPathToOwner`; shared by the log writer, the logs-dir helper, and forwarding IO
├── status.ts Footer status bar integration
├── value-guards.ts Runtime type guards (`toRecord`, `getNonEmptyString`)
├── yaml-frontmatter.ts Minimal YAML/frontmatter parsing (`parseSimpleYamlMap`, `extractFrontmatter`)
└── types.ts Core type definitions; the config-shape types (PermissionState, FlatPermissionConfig, etc.) are re-exported from config-schema.ts; domain type guards `isPermissionState`, `isDenyWithReason`
``` ```
Tool visibility is deliberately absent from this tree's responsibilities. It belongs to Tool Search.
## Improvement roadmap — Phase 13: The prompt-presentation seam ## Improvement roadmap — Phase 13: The prompt-presentation seam
### Findings (planned 2026-08-15) ### Findings (planned 2026-08-15)
+52 -246
View File
@@ -2,105 +2,68 @@
## Config File Locations ## Config File Locations
Pi Permission System has an ordinary JSONC policy and an independent global hard-guard JSONC policy: Pi Permission System reads two user-level JSONC files:
| Purpose | Scope | Path | | Purpose | Scope | Path |
| ------- | ----- | ---- | | ------- | ----- | ---- |
| Ordinary policy + runtime | Global | `~/.pi/agent/extensions/pi-permission-system/policy.jsonc` | | Ordinary policy + runtime | Global only | `~/.pi/agent/extensions/pi-permission-system/policy.jsonc` |
| Ordinary policy override | Project | `<cwd>/.pi/extensions/pi-permission-system/policy.jsonc` |
| Hard guards | Global only | `~/.pi/agent/extensions/pi-permission-system/hard-guards.jsonc` | | Hard guards | Global only | `~/.pi/agent/extensions/pi-permission-system/hard-guards.jsonc` |
All paths respect `PI_CODING_AGENT_DIR`. The bundle creates missing global files once with owner-only permissions. It does not overwrite an existing file. If `policy.jsonc` is absent but the old global `config.json` exists, the bundle copies the old content into `policy.jsonc` and leaves the source file intact. `PI_CODING_AGENT_DIR` replaces `~/.pi/agent` when set. Project-level `policy.jsonc` files are not loaded. Project agent Markdown may still provide per-agent restrictions; it is not a second ordinary policy file.
Project policy overrides global ordinary policy and per-agent YAML frontmatter overrides both. Project policy and project agent definitions are loaded only when Pi reports that project as trusted. Hard guards are evaluated first. Their matching `deny` decisions cannot be overridden by ordinary policy, session approvals, per-agent rules, or AutoReview.
Hard guards are deliberately outside this merge. They are global-only and evaluated first, without session rules. A matching hard `deny` is final and cannot be loosened by project policy, per-agent policy, a session grant, or AutoReview. A hard-file `allow` can only form a narrower exception to another pattern in that same hard-guard file. `ask` is invalid in the hard-guard file. Tool Search is the only extension that controls Pi's active tool list. Permission System never hides, activates, or replaces tools; it only evaluates a tool when Pi calls it.
> `~/.pi/agent` is the default. When `PI_CODING_AGENT_DIR` is set, Pi uses that directory instead. ## Decision and Pattern Precedence
## Merge Precedence For ordinary policy, the decision flow is:
**Precedence order (later wins):** 1. matching Hard Guard `deny`;
2. session approval, when one exists;
3. per-agent restriction, when an agent is active;
4. the global ordinary policy;
5. built-in fallback `ask`.
1. Global config file Within one pattern map, the most specific matching pattern wins even if `"*"` appears later. Specificity is compared in this order:
2. Project config file
3. Global agent frontmatter
4. Project agent frontmatter
The `permission` object uses deep-shallow merge: string-vs-string replaces; both-object shallow-merges pattern maps; string-vs-object the override wins entirely. 1. exact match before wildcard match;
Scalar fields (`debugLog`, `permissionReviewLog`, `yoloMode`, `doublePressToConfirm`, `forwardingTimeoutMs`, `promptMaxRows`, `promptFieldMaxWidth`) use simple replacement. 2. more literal characters;
3. fewer `*` wildcards;
4. longer pattern;
5. later declaration only when all earlier measures tie.
**Invalid higher-precedence scope fails closed.** This makes a broad `"*": "allow"` a fallback rather than an ordering trick. For example, `"**/.env": "deny"` still wins for `.env` paths.
If a non-global scope (project config, global agent frontmatter, or project agent frontmatter) is present but fails to load or validate, it no longer contributes an empty scope that silently inherits the lower scope's rules.
Instead the effective policy is floored so nothing resolves more permissively than `ask`: every `allow` (including one inherited from a lower scope) is clamped to `ask`, while `deny` and `ask` are unchanged. There is no separate `external_directory` decision. A path outside the current working directory is evaluated by the same configured tool, `path`, and Bash rules as any other path.
So a global `bash: allow` cannot remain effective behind a project scope that was meant to deny bash but contains a typo — bash prompts until the invalid config is fixed.
A validation warning plus a distinct fail-closed notice are emitted, and a fix + reload restores the intended policy.
An invalid **global** scope does not trigger the clamp — it is the lowest precedence, so nothing more permissive is inherited when it fails.
This clamp is deny-preserving and, like `yoloMode`, applied at composition; when `yoloMode` is on it re-permits the floored `ask` back to `allow`, since yolo is an explicit full-permissive opt-in.
## Full Example ## Full Example
```jsonc ```jsonc
{ {
"$schema": "https://raw.githubusercontent.com/gotgenes/pi-packages/main/packages/pi-permission-system/schemas/permissions.schema.json", "$schema": "./schemas/permissions.schema.json",
// Runtime knobs
"debugLog": false,
"permissionReviewLog": true,
"yoloMode": false,
"doublePressToConfirm": true,
"forwardingTimeoutMs": 600000,
"piInfrastructureReadPaths": [],
// Non-bash tools that carry shell semantics
"shellTools": {
"exec_command": { "commandArgument": "cmd", "workdirArgument": "workdir" }
},
// Unmatched ordinary asks are reviewed before the terminal human prompt.
"authorizerChain": ["auto-review"], "authorizerChain": ["auto-review"],
"autoReview": {
"enabled": true,
"provider": "openai-codex",
"model": "codex-auto-review",
"reasoning": "low",
"timeoutMs": 90000,
"includeBaselinePolicy": true
},
"analysis": {
"enabled": true,
"threshold": 500,
"retainAnalyzedBatches": 2,
"timeoutMs": 90000
},
// Flat permission policy
"permission": { "permission": {
"*": "ask", // universal fallback "*": "allow",
"path": { "path": {
"*": "allow", "**/.env": "deny",
"*.env": "deny", "**/.env.*": "deny",
"*.env.*": "deny", "~/.ssh/**": "deny",
"*.env.example": "allow" "*": "allow"
}, },
"read": "allow",
"write": "deny",
"edit": "deny",
"bash": { "bash": {
"git *": "ask", "git status*": "allow",
"git status": "allow", "git diff*": "allow",
"npm *": { "action": "deny", "reason": "Use pnpm instead" } "*": "ask"
}, }
"mcp": { "mcp_status": "allow" }, },
"skill": { "*": "ask" }, "autoReview": {
"external_directory": "ask" "enabled": true
} }
} }
``` ```
> **Note:** Trailing commas are **not** supported. > **Note:** Trailing commas are not supported. If parsing fails, ordinary policy fails safely to `ask`.
> If parsing fails, the extension falls back to `ask` for all categories.
## Built-in AutoReview and Count-Based Analysis ## Built-in AutoReview and Count-Based Analysis
`autoReview.enabled` controls the built-in authorizer named `auto-review`. It only receives requests that ordinary policy resolved to `ask`; it cannot bypass a hard deny. Add `"auto-review"` to `authorizerChain` to make it the primary case-by-case authority. An `allow` or `deny` verdict is final for that request. `defer` continues to the next authorizer and ultimately the human permission dialog. `autoReview.enabled` controls the built-in authorizer named `auto-review`. It only receives requests that ordinary policy resolved to `ask`; it cannot bypass a hard deny. Add `"auto-review"` to `authorizerChain` to make it the primary case-by-case authority. An `allow` or `deny` verdict is final for that request. `defer` continues to the next authorizer and ultimately the human permission dialog.
@@ -127,7 +90,6 @@ A successful proposal advances the checkpoint, is retained according to `retainA
| `reviewLogFieldMaxWidth` | `1000` | Max characters of any one value written to the review log. A longer value is stored shortened, marked with an ellipsis. Raise it to keep longer values; this is a length bound, not redaction. | | `reviewLogFieldMaxWidth` | `1000` | Max characters of any one value written to the review log. A longer value is stored shortened, marked with an ellipsis. Raise it to keep longer values; this is a length bound, not redaction. |
| `toolInputPreviewMaxLength` | — | **Deprecated and ignored.** Superseded by `promptMaxRows` / `promptFieldMaxWidth`. Still accepted so an existing config is not rejected, but the value no longer applies; setting it logs a warning. | | `toolInputPreviewMaxLength` | — | **Deprecated and ignored.** Superseded by `promptMaxRows` / `promptFieldMaxWidth`. Still accepted so an existing config is not rejected, but the value no longer applies; setting it logs a warning. |
| `toolTextSummaryMaxLength` | — | **Deprecated and ignored.** Superseded by `promptMaxRows` / `promptFieldMaxWidth`. Still accepted so an existing config is not rejected, but the value no longer applies; setting it logs a warning. | | `toolTextSummaryMaxLength` | — | **Deprecated and ignored.** Superseded by `promptMaxRows` / `promptFieldMaxWidth`. Still accepted so an existing config is not rejected, but the value no longer applies; setting it logs a warning. |
| `piInfrastructureReadPaths` | `[]` | Extra directories to auto-allow for reads, bypassing the `external_directory` gate. Supports `~`/`$HOME`/`${HOME}` expansion and wildcard patterns (`*`, `?`). |
| `authorizerChain` | `[]` | Ordered names of registered live-authority chain links to consult before the terminal authorizer (see [Authorizer chain](#authorizer-chain--case-by-case-decision-links)). | | `authorizerChain` | `[]` | Ordered names of registered live-authority chain links to consult before the terminal authorizer (see [Authorizer chain](#authorizer-chain--case-by-case-decision-links)). |
Both logs write to `~/.pi/agent/extensions/pi-permission-system/logs/`. Both logs write to `~/.pi/agent/extensions/pi-permission-system/logs/`.
@@ -171,23 +133,6 @@ Non-TUI contexts render the same facts under the same budget, without the colour
Non-TUI contexts (RPC / frontend-driven sessions) keep the single-select prompt and are unaffected by `doublePressToConfirm`. Non-TUI contexts (RPC / frontend-driven sessions) keep the single-select prompt and are unaffected by `doublePressToConfirm`.
### `piInfrastructureReadPaths` patterns
Each entry is either a plain directory prefix or a wildcard pattern.
Plain entries match any path that starts with the given directory (after `~`/`$HOME`/`${HOME}` expansion).
Wildcard entries use `*` (any characters, including `/`) and `?` (exactly one character).
`*` and `**` are equivalent — both cross directory boundaries.
Example — allow reads from a Homebrew-managed Pi install at any version:
```jsonc
{
"piInfrastructureReadPaths": [
"/opt/homebrew/**/@earendil-works/pi-coding-agent/**"
]
}
```
### `shellTools` — gating aliased shell tools ### `shellTools` — gating aliased shell tools
The native `bash` tool goes through the full bash enforcement stack: command decomposition, wrapper flooring, path and external-directory token gates, and `bash:` rules. The native `bash` tool goes through the full bash enforcement stack: command decomposition, wrapper flooring, path and external-directory token gates, and `bash:` rules.
@@ -213,11 +158,11 @@ Each key is a tool name; its value maps the tool's input arguments (the keys of
| `workdirArgument` | no | The tool's input argument holding the working directory (e.g. `workdir`). | | `workdirArgument` | no | The tool's input argument holding the working directory (e.g. `workdir`). |
| `decisionFloor` | no | `"ask"` raises Bash `allow` to `ask`; existing `ask`/`deny` are preserved. | | `decisionFloor` | no | `"ask"` raises Bash `allow` to `ask`; existing `ask`/`deny` are preserved. |
Use `decisionFloor: "ask"` when the aliased shell crosses an extra trust boundary, such as SSH or container execution. With yolo mode disabled, every otherwise-allowed invocation then reaches the configured authorizer chain unless deterministic Bash, path, or external-directory policy already denies it. Only `"ask"` is accepted; the setting cannot weaken a hard deny. Use `decisionFloor: "ask"` when the aliased shell crosses an extra trust boundary, such as SSH or container execution. With yolo mode disabled, every otherwise-allowed invocation then reaches the configured authorizer chain unless deterministic Bash or path policy already denies it. Only `"ask"` is accepted; the setting cannot weaken a hard deny.
When `workdirArgument` is set, the tool's working directory is the base the command's relative paths resolve against, and the working directory itself is gated by `external_directory` when it falls outside the session's working directory. When `workdirArgument` is set, the tool's working directory is the base used to resolve relative command paths. It is evaluated through configured Bash and `path` rules; there is no separate outside-directory decision.
Merge semantics: `shellTools` merges by tool name and then by descriptor field across global → project. Project values can replace argument names, but omitting a globally configured `decisionFloor: "ask"` does not remove it. Because no weaker floor value is valid, a project cannot downgrade that global review boundary. `shellTools` comes from the single global policy. A configured `decisionFloor: "ask"` cannot be weakened by another policy scope.
`shellTools` only ever *tightens* enforcement and is inert when the named tool is not registered in the current session. `shellTools` only ever *tightens* enforcement and is inert when the named tool is not registered in the current session.
Opting a project out of a shell-aliasing extension is a package-disable concern, not a `shellTools` edit. Opting a project out of a shell-aliasing extension is a package-disable concern, not a `shellTools` edit.
@@ -248,27 +193,9 @@ Three invariants govern the chain:
3. **Registration alone grants no authority.** 3. **Registration alone grants no authority.**
Installing a judge extension gives it nothing; a link decides nothing until you name it here (opt-in activation). Installing a judge extension gives it nothing; a link decides nothing until you name it here (opt-in activation).
The chain owner caps every link with a **bounded-delegation checkpoint**. The `path` surface is always excluded. On `external_directory`, my-pi permits an `allow` for the built-in read-only path tools (`read`, `find`, `grep`, `ls`); write, edit, bash, extension/unknown tools, and missing tool identity are downgraded to `defer`. A named authorizer may return a final `allow` or `deny` for any request that ordinary policy resolved to `ask`, including `path`. There is no additional surface-based delegation cap. `defer` advances to the next configured authorizer and ultimately the human terminal.
Deny and defer are never capped.
The gate surface remains authoritative: a `write` blocked by a `path` rule is capped even though the displayed tool name is `write`.
This holds for an ask forwarded up from a subagent exactly as it does for a local one.
See [migration/0635-forwarded-ask-delegation-envelope.md](migration/0635-forwarded-ask-delegation-envelope.md).
When a **subagent** raises the ask, the chain runs one hop up. Extension authors register a link from `permissions:ready` via `getPermissionsService().registerAuthorizer(name, authorize)`. For integration details, see [Cross-Extension API](cross-extension-api.md).
The subagent forwards the request to the session serving it, and that session resolves it against its own rules and then runs *its* chain over the same evidence — so your configured links do review a subagent's asks, in the session you are watching.
The subagent itself resolves no links (an extension cannot register one in a child session at all), and records `authorizer_chain_delegated` in the review log to say so.
Three review-log records make the chain observable, all keyed by the ask's `requestId`:
| Record | Meaning |
| ------------------------------------ | -------------------------------------------------------------------------------------------------------- |
| `authorizer_chain_resolved` | the links consulted on this ask, recorded before they run — a link that defers otherwise leaves no trace |
| `authorizer_chain_delegated` | the ask came from a relaying subagent node; the named links were deliberately not run here |
| `authorizer_chain_unregistered_link` | a configured name had no registered link — a real misconfiguration; the ask still reaches the terminal |
Extension authors: register a link from a `permissions:ready` handler via `getPermissionsService().registerAuthorizer(name, authorize)`; the callback receives the ask details and a narrow, session-scoped `PermissionQuery` (`checkPermission` / `getToolPermission`) so it can consult the deterministic engine at gate parity.
Registration returns a disposer, and only one link may hold a given name.
For a complete working example, see [`@gotgenes/pi-permission-model-judge`](https://github.com/gotgenes/pi-packages/tree/main/packages/pi-permission-model-judge): it registers a `model-judge` link on `permissions:ready` that reviews `external_directory` asks and auto-denies mistyped paths with a corrective reason.
--- ---
@@ -311,13 +238,12 @@ A string value is a catch-all for that surface.
} }
``` ```
Unknown or absent tools are not required in the config. Unknown or absent tools are not required in the config. Pi and Tool Search own tool registration and activation; Permission System does not alter the active tool list.
If a tool is not registered at runtime, this extension blocks it before permission checks run.
#### Path Patterns for File Tools #### Path Patterns for File Tools
For path-bearing tools (`read`, `write`, `edit`, `find`, `grep`, `ls`), an object value maps file-path patterns to actions. For path-bearing tools (`read`, `write`, `edit`, `find`, `grep`, `ls`), an object value maps file-path patterns to actions.
Patterns are matched against `input.path` using the same last-match-wins wildcard semantics as bash command patterns. Patterns are matched against `input.path`; the most specific matching pattern wins, using the precedence described at the top of this document.
When Pi's current working directory is known, a relative path input is matched with both its original relative form and its cwd-normalized absolute form, so an absolute allowlist rule and a legacy relative rule can both apply to the same file. When Pi's current working directory is known, a relative path input is matched with both its original relative form and its cwd-normalized absolute form, so an absolute allowlist rule and a legacy relative rule can both apply to the same file.
Per-tool path patterns also match the canonical (symlink-resolved) form, at parity with the `path` surface, so a per-tool deny on a sensitive spelling cannot be evaded through a symlink alias (see Symlinked paths below). Per-tool path patterns also match the canonical (symlink-resolved) form, at parity with the `path` surface, so a per-tool deny on a sensitive spelling cannot be evaded through a symlink alias (see Symlinked paths below).
`*` matches zero or more of any character **including** path separators — `src/*` matches both `src/foo.ts` and `src/deep/nested/foo.ts`. `*` matches zero or more of any character **including** path separators — `src/*` matches both `src/foo.ts` and `src/deep/nested/foo.ts`.
@@ -548,27 +474,18 @@ A path embedded in a long option (e.g. `--file=/tmp/patterns` in `grep --file=/t
On Windows, where a backslash is a path separator, a backslash-relative bash argument (e.g. `dir\file` in `cat dir\file`) is gated by a `path` rule the same as its forward-slash equivalent (`dir/file`) and the same as the file accessed through the `read` tool. On Windows, where a backslash is a path separator, a backslash-relative bash argument (e.g. `dir\file` in `cat dir\file`) is gated by a `path` rule the same as its forward-slash equivalent (`dir/file`) and the same as the file accessed through the `read` tool.
On other platforms a backslash is a legal filename character, so such a token is not treated as a path. On other platforms a backslash is a legal filename character, so such a token is not treated as a path.
Four orthogonal layers compose with most-restrictive-wins: Three orthogonal layers compose with most-restrictive-wins:
| Layer | Question | Applies to | | Layer | Question | Applies to |
| ----------------------- | --------------------------------------- | ---------------- | | ----------------------- | --------------------------------------- | ---------------- |
| `path` | Is this specific path pattern allowed? | All tools + bash | | `path` | Is this specific path pattern allowed? | All tools + bash |
| `external_directory` | Is accessing outside CWD ok? | All tools + bash |
| Per-tool patterns | Is this path ok for this specific tool? | Individual tools | | Per-tool patterns | Is this path ok for this specific tool? | Individual tools |
| `bash` command patterns | Is this command ok? | Bash only | | Bash command rules | Is this command allowed? | Bash only |
**Which surface for "allow this directory"?** A `path` allow cannot loosen a per-tool or Bash `ask`/`deny`; the most restrictive result remains effective.
Use `path` to **deny** sensitive files everywhere (`.env`, `~/.ssh/*`); use `external_directory` to **allow** a directory outside the working tree (a cache, a sibling project). Configs without a `path` key use the universal fallback (`permission["*"]`) for the `path` surface.
Because the layers compose with most-restrictive-wins, a `path` allow cannot loosen an `external_directory: ask` boundary — `ask` is more restrictive than `allow`, so the prompt still fires.
Adding `"~/.cargo/registry": "allow"` to the `path` surface therefore does **not** stop the outside-CWD prompt; put the rule on `external_directory` instead (see below).
Configs without a `path` key behave identically to before — the gate does not fire. Pattern declaration order is not a broad override mechanism. `{ "*.env": "deny", "*": "allow" }` still denies `.env` because `*.env` is more specific.
When no `path` key is present, the universal fallback (`permission["*"]`) applies: `"*": "allow"` keeps the gate transparent, while `"*": "deny"` would deny all file access via every surface including `path`.
> **Ordering matters.**
> Rules use last-match-wins.
> `{ "*.env": "deny", "*": "allow" }` allows `.env` because `"*"` is last and matches everything.
> Put the catch-all first: `{ "*": "allow", "*.env": "deny" }`.
#### `.env` recipe #### `.env` recipe
@@ -606,117 +523,6 @@ Conversely, a per-tool deny still blocks even when the `path` surface allows:
Here `read` calls pass the `path` gate but are blocked by the `read` tool gate. Here `read` calls pass the `path` gate but are blocked by the `read` tool gate.
### `external_directory` Surface
Controls access to paths outside the active working directory.
Use a pattern map to allow specific directories without opening all external access:
```jsonc
{
"permission": {
"external_directory": {
"*": "ask",
"~/development/*": "allow"
}
}
}
```
`external_directory` is evaluated before the normal tool permission check.
For example, `read: "allow"` can permit ordinary reads while `external_directory: "ask"` still requires confirmation before reading `../outside.txt` or an absolute path outside `ctx.cwd`.
Optional-path search tools (`find`, `grep`, `ls`) skip this check when no `path` is provided.
#### Allow an outside-CWD cache directory
When an agent keeps reading a local cache outside the working tree — `~/.cargo/registry`, `~/.npm`, `~/go/pkg/mod` — and you want to stop confirming it every time, allow that directory on the `external_directory` surface:
```jsonc
{
"permission": {
"external_directory": {
"*": "ask",
"~/.cargo/registry/*": "allow"
}
}
}
```
The trailing `*` is required and it crosses subdirectory boundaries: `*` is a greedy match (not a single path segment), so `~/.cargo/registry/*` allows every file beneath the directory, however deep.
Do not write `~/.cargo/registry/**` — `**` is not a distinct globstar, and a single `*` already recurses.
A bare `~/.cargo/registry` (no `*`) matches only the directory entry itself, not the files inside it, which is the usual reason a hand-written allow rule appears to do nothing.
The pattern is stored and displayed as written (`~/.cargo/registry/*`) in logs and approval dialogs.
For caches you only ever **read**, `piInfrastructureReadPaths` is a lighter alternative — it auto-allows read-only tools (`read`, `find`, `grep`, `ls`) and bypasses the gate entirely, but it does not cover `write`/`edit` or bash.
Use `external_directory` when the allowance must apply to every tool.
Bash commands are also covered: the extension parses the command and applies the same gate to every token that resolves outside `ctx.cwd`.
Quoting is understood, so `ls "$HOME/x"` and `ls $HOME/x` are treated alike.
What the bash projection resolves:
- Absolute, home-relative (`~/`), parent-traversal (`../`), and separator-bearing tokens, plus redirect targets (`> out.txt`) and values embedded in long options (`--file=/tmp/patterns`).
- The plain shell variables `$HOME` / `${HOME}` and `$PWD` / `${PWD}`, so `$HOME/x` is gated exactly as `~/x` and the literal absolute spelling, whether or not the target exists.
- Relative tokens, against the working directory produced by folding literal current-shell `cd` commands.
- A bare token (`cat id_rsa`) when it names an existing filesystem entry.
What it deliberately does not resolve: any other variable (`$CONFIG_DIR`), a command substitution (`$(cmd)`), an expansion carrying an operator (`${HOME:-/tmp}`), and a variable reached through an assignment (`CURRENT="$HOME"; ls "$CURRENT"`).
A non-literal `cd` (`cd "$DIR"`) makes the working directory unknown, after which relative tokens are kept literal rather than resolved against a guess.
Commands whose payload is opaque (`bash -c`, `eval`, `sudo`, `xargs`) are floored to `ask` instead of projected.
The governing record is [ADR 0009](https://github.com/gotgenes/pi-packages/blob/main/packages/pi-permission-system/docs/decisions/0009-bash-path-projection-completeness-contract.md), which states what the projection guarantees and which gaps are accepted residuals rather than bugs.
(The separate `bash` command-pattern surface does evaluate commands nested inside substitutions and subshells; see that section.) OS device paths (`/dev/null`, `/dev/stdin`, `/dev/stdout`, `/dev/stderr`) are always excluded.
#### Symlinked paths
A `path`, `external_directory`, or per-tool file-pattern rule (`read`/`write`/`edit`/`grep`/`find`/`ls`) matches the path **as the agent references it** and the OS-resolved (symlink-followed) path.
This matters on macOS, where `/tmp` is a symlink to `/private/tmp`: a rule keyed on `/tmp/*` allows access via `/tmp` even though the access resolves to `/private/tmp`, and a rule keyed on `/private/tmp/*` works too.
```jsonc
{
"permission": {
"external_directory": {
"*": "ask",
"/tmp/*": "allow"
}
}
}
```
The same dual-form matching protects the `path` surface and the per-tool file patterns: a `path` (or `read`/`write`/`edit`/`grep`/`find`/`ls`) deny on `~/.ssh/*` or `*.env` also catches a symlink whose resolved target matches the pattern, so a sensitive file cannot be reached through an aliasing symlink.
For `external_directory`, the decision of whether a path is outside the working directory always uses the resolved form, so the gate still fires for every outside-CWD access; only which allow/deny/ask pattern matches considers both forms.
#### Pi Infrastructure Read Auto-Allow
Read-only tools (`read`, `find`, `grep`, `ls`) targeting Pi infrastructure directories are automatically allowed without triggering the gate, even when `external_directory` is `ask` or `deny`.
Infrastructure directories include:
1. The agent config directory (`~/.pi/agent/` or `$PI_CODING_AGENT_DIR`)
2. Git-cloned global packages (`<agentDir>/git/`)
3. The global `node_modules` root (auto-discovered from the extension's own install path; falls back to `npm root -g` when running from a local development checkout)
4. Pi's own install directory (auto-discovered via the coding-agent `getPackageDir()` API, so Pi's bundled docs and examples are readable regardless of install layout)
5. Project-local Pi packages (`<cwd>/.pi/npm/` and `<cwd>/.pi/git/`)
6. Any paths listed in `piInfrastructureReadPaths`
Write tools (`write`, `edit`) to infrastructure paths are **not** auto-allowed and still go through the gate.
On Windows, path matching for `external_directory`, `path`, and the path-bearing tools is case-insensitive and tolerant of either separator (`\` or `/`), matching the case-insensitive filesystem.
The separator folding applies to the rule pattern **and** to the value it is matched against, so either side may be written with either separator.
A mixed-case allow override such as `~/AppData/Roaming/npm/node_modules/@earendil-works/pi-coding-agent/*` therefore matches a lowercased, backslash-normalized path value, and a forward-slash rule such as `"/dev/null"` matches a value that is also spelled with forward slashes.
POSIX matching remains case-sensitive and does not fold separators.
#### Git Bash / MSYS paths on Windows
On Windows, Pi executes bash commands through Git Bash, so a bash token that looks like a POSIX absolute path carries MSYS mount semantics rather than native `node:path.win32` semantics.
The `external_directory` and `path` gates interpret bash tokens accordingly (tool-input paths for `read`/`write`/`edit` keep native Windows semantics, since those tools resolve them through Node's filesystem):
- The safe device paths (`/dev/null`, `/dev/stdin`, `/dev/stdout`, `/dev/stderr`) are recognized as MSYS devices rather than filesystem paths, so they never trigger the `external_directory` gate — the same exclusion that holds on POSIX.
The cross-cutting `path` surface still governs them on both platforms: if a `path` rule matches the token, it decides.
A device is therefore allow-listed the way any other path is, written as typed — `path: { "/dev/null": "allow" }`.
- MSYS drive mounts (`/c/…`, `/d/…`) are translated to their Windows equivalent (`C:\…`), so a project file referenced through a mount is matched against its real Windows path and an in-CWD mount is not flagged.
- Every other POSIX-absolute token (`/tmp/foo`, `/usr/bin`) has an install-dependent target this extension cannot resolve deterministically (Git Bash mounts `/tmp` to `%TEMP%`, MSYS2 to its own root), so it is treated as an external path matched and displayed exactly as typed, never rewritten to `C:\tmp\foo`.
To allow-list such a path, write the rule using the path as typed — for example `external_directory: { "/tmp/*": "allow" }` — and the Windows separator folding above makes the forward-slash rule match the Git Bash token.
### Home Directory Expansion in Patterns ### Home Directory Expansion in Patterns
Pattern keys in any permission surface can start with `~/`, `$HOME/`, or `${HOME}/` (or be exactly `~`, `$HOME`, or `${HOME}`). Pattern keys in any permission surface can start with `~/`, `$HOME/`, or `${HOME}/` (or be exactly `~`, `$HOME`, or `${HOME}`).
@@ -726,15 +532,15 @@ A prefix is recognized only when it stands alone or precedes a separator, so a l
```jsonc ```jsonc
{ {
"permission": { "permission": {
"external_directory": { "path": {
"*": "ask", "~/development/*": "allow",
"~/development/*": "allow" "*": "ask"
} }
} }
} }
``` ```
The pattern is stored and displayed as written (e.g. `~/development/*`) in logs and approval dialogs. The pattern is stored and displayed as written (for example `~/development/*`) in logs and approval dialogs.
Path **values** supplied by tool calls and bash commands are expanded the same way. Path **values** supplied by tool calls and bash commands are expanded the same way.
This means `~/...`, `$HOME/...`, `${HOME}/...`, and the fully-expanded absolute form all match a single home-anchored pattern: a `read` tool called with path `~/.ssh/config`, `$HOME/.ssh/config`, `${HOME}/.ssh/config`, or `/Users/me/.ssh/config` is all caught by a `"~/.ssh/*": "deny"` rule. This means `~/...`, `$HOME/...`, `${HOME}/...`, and the fully-expanded absolute form all match a single home-anchored pattern: a `read` tool called with path `~/.ssh/config`, `$HOME/.ssh/config`, `${HOME}/.ssh/config`, or `/Users/me/.ssh/config` is all caught by a `"~/.ssh/*": "deny"` rule.
@@ -918,7 +724,7 @@ It allows a curated set of commands whose only effect is to read or report — n
Four existing behaviors keep this allowlist safe — you do not have to enumerate the destructive commands to block them: Four existing behaviors keep this allowlist safe — you do not have to enumerate the destructive commands to block them:
1. **Redirects are gated by the `path` surface, not `bash`.** 1. **Redirects are gated by the `path` surface, not `bash`.**
Allowing `cat *` allows the `cat` command, not a redirect it carries: `cat secret > out.txt` writes `out.txt` through the `path`/`external_directory` gate. Allowing `cat *` allows the `cat` command, not a redirect it carries: `cat secret > out.txt` writes `out.txt` through the `path` gate.
That is why this recipe ships with `write` and `edit` denied and a `path` deny block for sensitive files. That is why this recipe ships with `write` and `edit` denied and a `path` deny block for sensitive files.
Keep the `path` surface locked down for anything you would not want an allowed read command to overwrite via `>`. Keep the `path` surface locked down for anything you would not want an allowed read command to overwrite via `>`.
2. **`find`/`fd` with an exec flag are floored to `ask`.** 2. **`find`/`fd` with an exec flag are floored to `ask`.**
@@ -70,9 +70,9 @@ interface PermissionsService {
/** /**
* Register a custom access-intent extractor for a specific tool name. * Register a custom access-intent extractor for a specific tool name.
* Declares the filesystem path a tool accesses so the `path` and * Declares the local filesystem path a tool accesses so the `path` gate can
* `external_directory` gates can see it. Returns a disposer; throws if an * see it, or suppresses local-path inference for a remote tool. Returns a
* extractor is already registered for that tool name. * disposer; throws if an extractor is already registered for that tool name.
*/ */
registerToolAccessExtractor( registerToolAccessExtractor(
toolName: string, toolName: string,
@@ -85,13 +85,13 @@ interface PermissionsService {
| Parameter | Required | Description | | Parameter | Required | Description |
| ----------- | -------- | ---------------------------------------------------------------------------------------- | | ----------- | -------- | ---------------------------------------------------------------------------------------- |
| `surface` | Yes | Permission surface: `"bash"`, `"read"`, `"mcp"`, `"skill"`, `"external_directory"`, etc. | | `surface` | Yes | Permission surface: `"bash"`, `"read"`, `"mcp"`, `"skill"`, `"path"`, etc. |
| `value` | No | Value to evaluate (command, name, path); defaults to `""` | | `value` | No | Value to evaluate (command, name, path); defaults to `""` |
| `agentName` | No | Agent name for per-agent policy resolution | | `agentName` | No | Agent name for per-agent policy resolution |
Returns `PermissionCheckResult` with fields `state`, `matchedPattern`, `source`, `origin`, etc. Returns `PermissionCheckResult` with fields `state`, `matchedPattern`, `source`, `origin`, etc.
For a path-shaped surface (`path`, `external_directory`, or a path-bearing tool — `read`/`write`/`edit`/`grep`/`find`/`ls`), the supplied `value` is matched against both the path as given and its canonical (symlink-resolved) form, at parity with the gates — so a query for a symlinked path matches a rule on its real target. For a path-shaped surface (`path` or a path-bearing tool — `read`/`write`/`edit`/`grep`/`find`/`ls`), the supplied `value` is matched against both the path as given and its canonical (symlink-resolved) form, at parity with the gates — so a query for a symlinked path matches a rule on its real target.
For the `bash` surface, a `value` containing a chained or nested command (joined by `&&`, `||`, `;`, `|`, `&`, or newlines, or nested in a command substitution/subshell) is decomposed into its command-pattern units and resolved most-restrictive (`deny` > `ask` > `allow`), at parity with the enforcement gate — so `cd /repo && npm install x` returns the decision of the `npm install x` unit, not the leading `cd`. For the `bash` surface, a `value` containing a chained or nested command (joined by `&&`, `||`, `;`, `|`, `&`, or newlines, or nested in a command substitution/subshell) is decomposed into its command-pattern units and resolved most-restrictive (`deny` > `ask` > `allow`), at parity with the enforcement gate — so `cd /repo && npm install x` returns the decision of the `npm install x` unit, not the leading `cd`.
A previously chained command that returned `allow` (riding an allowed leading command) may therefore now return `deny`/`ask`. A previously chained command that returned `allow` (riding an allowed leading command) may therefore now return `deny`/`ask`.
@@ -235,7 +235,7 @@ This is the reference implementation for the seam — see `src/builtin-tool-inpu
#### `registerToolAccessExtractor` #### `registerToolAccessExtractor`
Declare the filesystem path a tool will access so the cross-cutting `path` and `external_directory` gates can evaluate it. Declare the local filesystem path a tool will access so the cross-cutting `path` gate can evaluate it; an extractor may also suppress local-path inference for remote paths.
```typescript ```typescript
registerToolAccessExtractor( registerToolAccessExtractor(
@@ -360,7 +360,7 @@ Nested rather than flattened so the event and the prompt payload share one shape
| Field | Type | Description | | Field | Type | Description |
| ----------------- | ---------------------------- | ------------------------------------------------------------------------------------------------ | | ----------------- | ---------------------------- | ------------------------------------------------------------------------------------------------ |
| `requester` | `PromptRequester` | Who is asking, and whether the ask arrived from a subagent | | `requester` | `PromptRequester` | Who is asking, and whether the ask arrived from a subagent |
| `surface` | `string` | The **gate** surface the rule fired on — `"external_directory"`, `"path"`, `"bash"`, a tool name | | `surface` | `string` | The **gate** surface the rule fired on — `"path"`, `"bash"`, or a tool name |
| `toolName` | `string \| null` | The gated tool name; `null` when the ask is not tool-shaped | | `toolName` | `string \| null` | The gated tool name; `null` when the ask is not tool-shaped |
| `invokedToolName` | `string \| null` | The invoked name when a shell alias re-exposes bash under another name | | `invokedToolName` | `string \| null` | The invoked name when a shell alias re-exposes bash under another name |
| `value` | `string` | The decision-relevant value: the command, path, MCP target, or skill name | | `value` | `string` | The decision-relevant value: the command, path, MCP target, or skill name |
@@ -372,7 +372,7 @@ Nested rather than flattened so the event and the prompt payload share one shape
The top-level `surface` and `request.surface` are two different facts and both belong on the event. The top-level `surface` and `request.surface` are two different facts and both belong on the event.
The top-level one is the **display** projection — the child's tool name, what a notification shows. The top-level one is the **display** projection — the child's tool name, what a notification shows.
`request.surface` is the **gate** surface the rule fired on: a `read` of a path outside the working directory displays as `"read"` and gates on `"external_directory"`. `request.surface` is the **gate** surface the rule fired on. Paths outside the working directory have no special surface; they use the same `path` and tool surfaces as other paths.
#### `ForwardedPromptContext` #### `ForwardedPromptContext`
@@ -410,7 +410,7 @@ pi.events.on("permissions:decision", (raw) => {
| Field | Type | Description | | Field | Type | Description |
| ---------------- | ------------------- | ----------------------------------------------------------------------------------------- | | ---------------- | ------------------- | ----------------------------------------------------------------------------------------- |
| `requestId` | `string` | Id of the permission request this decision resolves | | `requestId` | `string` | Id of the permission request this decision resolves |
| `surface` | `string` | Permission surface (`"bash"`, `"read"`, `"mcp"`, `"skill"`, `"external_directory"`, etc.) | | `surface` | `string` | Permission surface (`"bash"`, `"read"`, `"mcp"`, `"skill"`, `"path"`, etc.) |
| `value` | `string` | Value evaluated (command, tool name, skill name, path) | | `value` | `string` | Value evaluated (command, tool name, skill name, path) |
| `result` | `"allow" \| "deny"` | Final outcome | | `result` | `"allow" \| "deny"` | Final outcome |
| `resolution` | `string` | How the outcome was reached (see table below) | | `resolution` | `string` | How the outcome was reached (see table below) |
@@ -79,19 +79,9 @@
"minimum": 1, "minimum": 1,
"maximum": 9007199254740991 "maximum": 9007199254740991
}, },
"piInfrastructureReadPaths": {
"description": "Additional directories to auto-allow for reads as Pi infrastructure, bypassing the external_directory gate. Supports ~ expansion and wildcard patterns (* and ?).",
"markdownDescription": "Additional directories to auto-allow for reads as Pi infrastructure, bypassing the `external_directory` gate.\n\nThe extension auto-discovers the global node_modules root (walks up from the extension's install path; falls back to `npm root -g` from a dev checkout), Pi's own install directory (via the coding-agent `getPackageDir()` API), `agentDir`, `agentDir/git`, and project-local `.pi/npm/` and `.pi/git/`. Add entries here for edge cases where auto-discovery is insufficient (e.g. custom `npmCommand` pointing to pnpm).\n\nSupports `~`/`$HOME` expansion. Entries may be plain directory prefixes or wildcard patterns using `*` (matches any characters, including `/`) and `?` (matches exactly one character). `**` and `*` are equivalent — both cross directory boundaries.\n\nOn Windows, matching is case-insensitive and tolerant of either path separator.",
"default": [],
"type": "array",
"items": {
"type": "string",
"minLength": 1
}
},
"authorizerChain": { "authorizerChain": {
"description": "Ordered names of registered live-authority chain links to consult before the terminal authorizer. Config order (not registration order) fixes the chain order; an unregistered name is skipped fail-safe (more prompting, never less); a link decides nothing until it is named here.", "description": "Ordered names of registered live-authority chain links to consult before the terminal authorizer. Config order (not registration order) fixes the chain order; an unregistered name is skipped fail-safe (more prompting, never less); a link decides nothing until it is named here.",
"markdownDescription": "Ordered names of registered **live-authority chain links** (e.g. a model judge) to consult before the terminal authorizer (the human, or the subagent-forwarding / headless-deny fallback).\n\nA link reviews an `ask` and returns `allow` / `deny` (with an optional teaching reason) / `defer` to the next link. Three invariants govern the chain:\n\n- **Config order wins.** The order here — not the order extensions register in — fixes the security-relevant chain order.\n- **Fail-safe skip.** A name with no registered link is skipped with a warning; the `ask` still reaches the terminal (more prompting, never less).\n- **Opt-in activation.** Installing a judge extension grants it no authority; a link decides nothing until you name it here.\n\nThe chain owner caps every verdict with a bounded-delegation checkpoint: a link's `allow` on an excluded surface (`external_directory` or `path`) is downgraded to `defer`, so a link cannot exceed your policy.\n\nDefaults to an empty list (no links).", "markdownDescription": "Ordered names of registered **live-authority chain links** (for example AutoReview) to consult before the terminal human authorizer. A named link may approve or deny any ordinary-policy `ask`, including `path`; `defer` advances to the next link or the human terminal. Defaults to an empty list.",
"default": [], "default": [],
"type": "array", "type": "array",
"items": { "items": {
@@ -117,7 +107,7 @@
] ]
}, },
"description": "Flat permission policy. Each key is a surface name; values are a PermissionState string (catch-all) or a pattern→action map.", "description": "Flat permission policy. Each key is a surface name; values are a PermissionState string (catch-all) or a pattern→action map.",
"markdownDescription": "Flat permission policy.\n\nEach top-level key is a surface name:\n- `\"*\"` — universal fallback (replaces `defaultPolicy.tools` from the legacy format)\n- Tool names (`read`, `write`, `bash`, `mcp`, `skill`, `external_directory`, `path`, etc.)\n\nA **string** value is shorthand for `{ \"*\": action }` (surface-level catch-all).\nAn **object** value maps wildcard patterns to actions — last matching pattern wins.\n\nFor built-in file tools (`read`, `write`, `edit`, `find`, `grep`, `ls`), patterns are matched against the file path from `input.path`. For example, `\"read\": { \"*\": \"allow\", \"*.env\": \"deny\" }` allows reads but denies `.env` files.\n\nWhen Pi's current working directory is known, relative path inputs also match their cwd-normalized absolute form, so `src/App.jsx` can match both `src/*` and `/workspace/project/*`. Bash path tokens use the effective directory after literal `cd` commands for this matching; non-literal `cd \"$DIR\"` style commands remain conservative.\n\nThe `path` surface is a cross-cutting gate that applies to **all** file access: Pi tools, bash commands, MCP calls (via `input.arguments.path`), and extension tools (via `input.path` or a registered access extractor). A `path` deny cannot be overridden by a per-tool allow. Use it to protect sensitive files (`.env`, `~/.ssh/*`) from all path-aware tools at once.\n\nThe `external_directory` surface gates access **outside** the working directory. Give it a pattern map to allow specific outside-CWD directories without opening all external access — e.g. `\"external_directory\": { \"*\": \"ask\", \"~/.cargo/registry/*\": \"allow\" }` to silence repeated prompts on a local cache. The trailing `*` is greedy and crosses subdirectory boundaries; a bare `~/.cargo/registry` matches only the directory entry itself. Because layers compose with most-restrictive-wins, a `path` allow cannot loosen an `external_directory: ask` boundary — allow outside-CWD directories here, not on `path`.\n\n**Merge order (lowest → highest precedence):** global → project → per-agent frontmatter.", "markdownDescription": "Flat permission policy.\n\nEach top-level key is a surface name:\n- `\"*\"` — universal fallback\n- Tool names (`read`, `write`, `bash`, `mcp`, `skill`, `path`, etc.)\n\nA string value is shorthand for `{ \"*\": action }`. An object maps wildcard patterns to actions, and the most specific matching pattern wins.\n\nFor built-in file tools, patterns match `input.path`. The cross-cutting `path` surface checks path-bearing tools and bash path tokens.\n\n**Merge order (lowest → highest precedence):** global → per-agent frontmatter → project-agent frontmatter.",
"examples": [ "examples": [
{ {
"*": "ask", "*": "ask",
@@ -144,10 +134,6 @@
"skill": { "skill": {
"*": "ask", "*": "ask",
"librarian": "allow" "librarian": "allow"
},
"external_directory": {
"*": "ask",
"~/.cargo/registry/*": "allow"
} }
} }
] ]
@@ -271,7 +257,7 @@
"additionalProperties": false, "additionalProperties": false,
"title": "PI Permission System Configuration", "title": "PI Permission System Configuration",
"description": "Ordinary JSONC policy combining runtime knobs, AutoReview settings, and flat permission rules for pi-permission-system.", "description": "Ordinary JSONC policy combining runtime knobs, AutoReview settings, and flat permission rules for pi-permission-system.",
"markdownDescription": "Ordinary JSONC policy for [pi-permission-system](https://github.com/gotgenes/pi-packages/tree/main/packages/pi-permission-system).\n\nPlace at `~/.pi/agent/extensions/pi-permission-system/policy.jsonc` (global) or `<project>/.pi/extensions/pi-permission-system/policy.jsonc` (project). Global-only hard guards use the separate `hard-guards.jsonc` schema.", "markdownDescription": "Global ordinary JSONC policy for [pi-permission-system](https://github.com/gotgenes/pi-packages/tree/main/packages/pi-permission-system).\n\nPlace at `~/.pi/agent/extensions/pi-permission-system/policy.jsonc`. Project-level policy files are not loaded. Global-only hard guards use the separate `hard-guards.jsonc` schema.",
"$defs": { "$defs": {
"permissionState": { "permissionState": {
"anyOf": [ "anyOf": [
@@ -310,8 +296,8 @@
} }
] ]
}, },
"description": "A map of wildcard patterns to permission states. Last matching pattern wins.", "description": "A map of wildcard patterns to permission states. The most specific matching pattern wins.",
"markdownDescription": "A map of wildcard patterns to permission states.\n\nUse `*` for wildcard matching. When multiple patterns match, the **last matching rule wins** — put broad catch-alls first and specific overrides after them.\n\nPattern keys support home directory expansion:\n- `~/path` or `$HOME/path` — expanded to the OS home directory at match time.\n- `~` or `$HOME` alone — expands to the home directory itself.\n\nThe stored pattern is always shown in logs and approval dialogs as written (e.g. `~/dev/*`)." "markdownDescription": "A map of wildcard patterns to permission states.\n\nUse `*` for wildcard matching. When several patterns match, the most specific pattern wins: exact patterns first, then more literal characters, fewer wildcards, and longer patterns. Declaration order is only the final tie-breaker, where the later rule wins.\n\nPattern keys support home directory expansion: `~/path` and `$HOME/path` expand at match time. The stored pattern is shown in logs and approval dialogs as written."
}, },
"denyWithReason": { "denyWithReason": {
"type": "object", "type": "object",
@@ -27,7 +27,7 @@ export interface ToolAccessIntent {
*/ */
export interface PathValuesAccessIntent { export interface PathValuesAccessIntent {
kind: "path-values"; kind: "path-values";
/** `"path"` or `"external_directory"`. */ /** Permission surface associated with these path match values. */
surface: string; surface: string;
values: readonly string[]; values: readonly string[];
agentName?: string; agentName?: string;
@@ -36,9 +36,9 @@ export interface PathValuesAccessIntent {
/** /**
* An `AccessPath` value object for a path-shaped surface. * An `AccessPath` value object for a path-shaped surface.
* *
* Built for every path-shaped surface: the cross-cutting `path` and * Built for every path-shaped surface: the cross-cutting `path` gate, the
* `external_directory` gates, the per-tool path-bearing surfaces * per-tool path-bearing surfaces (`read`/`write`/`edit`/`grep`/`find`/`ls`,
* (`read`/`write`/`edit`/`grep`/`find`/`ls`, #502), and the service/RPC policy * #502), and the service/RPC policy
* queries for those surfaces (#503). Lets `AccessPath` flow into the resolver * queries for those surfaces (#503). Lets `AccessPath` flow into the resolver
* as a first-class variant so the resolver — not the producer — asks it for * as a first-class variant so the resolver — not the producer — asks it for
* `matchValues()` (Tell-Don't-Ask). * `matchValues()` (Tell-Don't-Ask).
@@ -9,16 +9,12 @@ import {
/** /**
* A path's two representations held behind type-distinct accessors. * A path's two representations held behind type-distinct accessors.
* *
* A single `string` carrying both meanings was the root cause of [#418]: * The object keeps the typed (lexical) path and its symlink-resolved
* both external-directory gates matched config patterns against the * (canonical) alias separate so matching and display do not accidentally use
* symlink-resolved (canonical) path instead of the typed (lexical) path, * the wrong representation.
* defeating a configured `/tmp/*` allow.
* *
* `AccessPath` makes the misuse a compile error: * - {@link matchValues} returns the lexical aliases plus the canonical alias.
* - {@link matchValues} returns `string[]` — the lexical alias union ∪ canonical, * - {@link boundaryValue} returns the canonical form for forwarding metadata.
* for `external_directory` pattern matching.
* - {@link boundaryValue} returns `string` — the canonical form, for
* outside-CWD containment and infra-read checks.
* - {@link value} returns `string` — the lexical absolute form, for display, * - {@link value} returns `string` — the lexical absolute form, for display,
* approval patterns, decision values, and logs. * approval patterns, decision values, and logs.
* - {@link resolvedAlias} returns `string | undefined` — the canonical form * - {@link resolvedAlias} returns `string | undefined` — the canonical form
@@ -37,10 +33,9 @@ export class AccessPath {
) {} ) {}
/** /**
* Pattern-match values for the `external_directory` surface: the lexical * Pattern-match values for path policy: the lexical alias union plus the
* alias union plus the canonical alias, so a config pattern on either the * canonical alias, so a pattern on either the typed form (`/tmp/*`) or the
* typed form (`/tmp/*`) or the symlink-resolved form (`/private/tmp/*`) * symlink-resolved form (`/private/tmp/*`) matches.
* matches (#418).
* *
* Collapses to the lexical aliases when the canonical equals one of them * Collapses to the lexical aliases when the canonical equals one of them
* (e.g. when the path is not a symlink). * (e.g. when the path is not a symlink).
@@ -136,9 +131,8 @@ export class AccessPath {
* preserved verbatim across all three representations — `value()`, * preserved verbatim across all three representations — `value()`,
* `boundaryValue()`, and `matchValues()` are the device path itself, never * `boundaryValue()`, and `matchValues()` are the device path itself, never
* `win32.resolve`-mangled into `c:\dev\null`. The identical lexical and * `win32.resolve`-mangled into `c:\dev\null`. The identical lexical and
* canonical forms let the boundary check reach `isSafeSystemPath` (so the * canonical forms remain identical so policy matches the device path exactly
* device never triggers `external_directory`) while a config rule still * as typed.
* matches the path as typed.
*/ */
static forDevice(devicePath: string): AccessPath { static forDevice(devicePath: string): AccessPath {
return new AccessPath(devicePath, [devicePath], devicePath); return new AccessPath(devicePath, [devicePath], devicePath);
@@ -6,7 +6,6 @@ import {
import type { TSNode } from "#src/access-intent/bash/parser"; import type { TSNode } from "#src/access-intent/bash/parser";
import { import {
classifyBareTokenCandidate, classifyBareTokenCandidate,
classifyTokenAsPathCandidate,
classifyTokenAsRuleCandidate, classifyTokenAsRuleCandidate,
} from "#src/access-intent/bash/token-classification"; } from "#src/access-intent/bash/token-classification";
import { import {
@@ -17,7 +16,6 @@ import {
} from "#src/access-intent/bash/token-collection"; } from "#src/access-intent/bash/token-collection";
import { normalizePathPolicyLiteral } from "#src/access-intent/path-normalization"; import { normalizePathPolicyLiteral } from "#src/access-intent/path-normalization";
import type { PathNormalizer } from "#src/path-normalizer"; import type { PathNormalizer } from "#src/path-normalizer";
import { isSafeSystemPath } from "#src/safe-system-paths";
// ── Internal types ─────────────────────────────────────────────────────────── // ── Internal types ───────────────────────────────────────────────────────────
@@ -53,14 +51,8 @@ export interface BashPathRuleCandidate {
readonly path: AccessPath; readonly path: AccessPath;
} }
/** /** Cd-aware path-rule candidates extracted from a bash program. */
* The filesystem paths a bash program references, resolved against the working
* directory and platform — the two typed slices {@link BashProgram} exposes.
*/
export interface ResolvedBashPaths { export interface ResolvedBashPaths {
/** Deduplicated paths resolving outside the working directory (#418). */
readonly externalPaths: readonly AccessPath[];
/** Every path-rule token paired with its cd-aware policy values (#393). */
readonly ruleCandidates: readonly BashPathRuleCandidate[]; readonly ruleCandidates: readonly BashPathRuleCandidate[];
} }
@@ -99,15 +91,9 @@ export class BashPathResolver {
) {} ) {}
/** /**
* Resolve a parsed bash program's path references into its external-path and * Resolve a parsed bash program's path references into cd-aware policy
* rule-candidate slices, walking the AST exactly once. * candidates while walking the AST exactly once. An aliased shell tool's
* * `workdir` seeds the initial effective base for relative tokens.
* When a `workdir` is set (an aliased shell tool's working directory, #574),
* it seeds the initial effective base — as if the program were prefixed with
* `cd <workdir>` — so relative tokens resolve against it, and the `workdir`
* itself is added to the external paths when it resolves outside the cwd.
* Containment is always measured against the session cwd baked into the
* normalizer, so a `workdir` outside the cwd does not widen the sandbox.
*/ */
resolve(rootNode: TSNode): ResolvedBashPaths { resolve(rootNode: TSNode): ResolvedBashPaths {
const initialBase = const initialBase =
@@ -116,36 +102,10 @@ export class BashPathResolver {
: this.deriveBaseFromCdTarget(CWD_BASE, this.workdir); : this.deriveBaseFromCdTarget(CWD_BASE, this.workdir);
const candidates = this.collectPathCandidates(rootNode, initialBase); const candidates = this.collectPathCandidates(rootNode, initialBase);
return { return {
externalPaths: this.withWorkdirExternal(
this.projectExternalPaths(candidates),
),
ruleCandidates: this.projectRuleCandidates(candidates), ruleCandidates: this.projectRuleCandidates(candidates),
}; };
} }
/**
* Prepend the `workdir`'s own {@link AccessPath} to the external paths when it
* resolves outside the cwd. A real `cd /etc` flags `/etc` via its argument
* token; the seeded base carries no such token, so it is added explicitly and
* deduplicated against the command's own external tokens (#574).
*/
private withWorkdirExternal(
tokenExternals: readonly AccessPath[],
): AccessPath[] {
if (this.workdir === undefined) return [...tokenExternals];
const wdPath = this.normalizer.forBashToken(this.workdir);
const canonical = wdPath.boundaryValue();
const isExternal = canonical
? this.normalizer.isBoundaryOutsideWorkingDirectory(canonical)
: true;
if (!isExternal) return [...tokenExternals];
const key = canonical || wdPath.value();
const alreadyPresent = tokenExternals.some(
(p) => (p.boundaryValue() || p.value()) === key,
);
return alreadyPresent ? [...tokenExternals] : [wdPath, ...tokenExternals];
}
// ── AST walk — collect PathCandidates ────────────────────────────────── // ── AST walk — collect PathCandidates ──────────────────────────────────
/** /**
@@ -401,112 +361,8 @@ export class BashPathResolver {
// ── Projection ───────────────────────────────────────────────────────── // ── Projection ─────────────────────────────────────────────────────────
/** /**
* Project the collected candidates into deduplicated external paths. * Project candidates into policy lookup values while preserving literal
* * values after a non-literal `cd`, where no reliable absolute base exists.
* Filters candidates through the strict path classifier
* (`classifyTokenAsPathCandidate`), resolves each against its effective working
* directory base, and returns only paths that resolve outside the baked cwd in
* their lexical (as-typed, normalized but not symlink-resolved) form.
*
* The outside-cwd decision and the dedup identity use the canonical
* (symlink-resolved) form so `external_directory` config patterns match the
* path as the user typed it (#418).
*/
private projectExternalPaths(
candidates: readonly PathCandidate[],
): AccessPath[] {
const seen = new Set<string>();
const externalPaths: AccessPath[] = [];
for (const { token, base } of candidates) {
const candidate = classifyTokenAsPathCandidate(token);
if (!candidate) {
// A bare token the strict shape gate rejects can still escape the tree
// through a symlink, so probe it and apply the ordinary boundary
// decision to whatever it resolves to (#645).
const probed = this.probeBareToken(token, base);
if (probed) this.collectIfExternal(probed.path, seen, externalPaths);
continue;
}
// Unknown effective directory: a relative candidate could resolve
// anywhere, so flag it conservatively (resolved against the baked cwd
// only for a display path). Absolute / `~` candidates are base-independent
// below.
if (base.kind === "unknown" && this.isRelativeCandidate(candidate)) {
const accessPath = this.normalizer.forPath(candidate);
const canonical = accessPath.boundaryValue();
if (canonical && !isSafeSystemPath(canonical) && !seen.has(canonical)) {
seen.add(canonical);
externalPaths.push(accessPath);
}
continue;
}
const resolveBase =
base.kind === "known"
? this.normalizer.resolveBase(base.offset)
: undefined;
this.collectIfExternal(
this.normalizer.forBashToken(candidate, { resolveBase }),
seen,
externalPaths,
);
}
return externalPaths;
}
/**
* Record `accessPath` when it resolves outside the working directory and has
* not already been collected.
*
* The boundary decision and dedup identity use the canonical
* (symlink-resolved) form the {@link AccessPath} already derived, while the
* stored value keeps the lexical form so config patterns match the path as
* the user typed it (#418). A win32 device path preserves `/dev/null` as its
* boundary value, so `isBoundaryOutsideWorkingDirectory` reaches the
* safe-path exclusion (#533).
*
* A literal-only bash token (a win32 non-mount POSIX absolute like `/tmp`)
* has no canonical form; it is foreign to the win32 cwd, so it is always
* external. Its lexical value is the dedup identity so two distinct
* literal-only paths do not collapse (#533).
*/
private collectIfExternal(
accessPath: AccessPath,
seen: Set<string>,
out: AccessPath[],
): void {
const lexical = accessPath.value();
if (!lexical) return;
const canonical = accessPath.boundaryValue();
const isExternal = canonical
? this.normalizer.isBoundaryOutsideWorkingDirectory(canonical)
: true;
const dedupKey = canonical || lexical;
if (isExternal && !seen.has(dedupKey)) {
seen.add(dedupKey);
out.push(accessPath);
}
}
/**
* Project the collected candidates into rule candidates with their cd-aware
* policy lookup values.
*
* Filters candidates through the broad path classifier
* (`classifyTokenAsRuleCandidate`), falling back to {@link probeBareToken}
* for a bare token the broad classifier rejects for shape — admitted only
* when it names an existing filesystem entry (#645).
* On win32 the broad classifier is told to treat a backslash as a path
* separator, so a backslash-relative token (`dir\file`) is recognized as a
* rule candidate the same as its forward-slash equivalent (#520); on POSIX
* `\` is a legal filename character, so the token stays bare there.
* Pairs each qualifying token with its set of policy values (absolute +
* project-relative + raw).
* A token after a non-literal `cd` keeps only its literal value so no
* spurious absolute rule can match (#393).
*/ */
private projectRuleCandidates( private projectRuleCandidates(
candidates: readonly PathCandidate[], candidates: readonly PathCandidate[],
@@ -1,4 +1,3 @@
import type { AccessPath } from "#src/access-intent/access-path";
import { import {
BashPathResolver, BashPathResolver,
type BashPathRuleCandidate, type BashPathRuleCandidate,
@@ -26,7 +25,6 @@ export class BashProgram {
private constructor( private constructor(
private readonly sourceCommand: string, private readonly sourceCommand: string,
private readonly commandUnits: readonly BashCommand[], private readonly commandUnits: readonly BashCommand[],
private readonly resolvedExternalPaths: readonly AccessPath[],
private readonly resolvedRuleCandidates: readonly BashPathRuleCandidate[], private readonly resolvedRuleCandidates: readonly BashPathRuleCandidate[],
) {} ) {}
@@ -44,10 +42,8 @@ export class BashProgram {
* (ADR 0009, #645). No policy is consulted, so every caller gets identical * (ADR 0009, #645). No policy is consulted, so every caller gets identical
* slices for a given command and working directory. * slices for a given command and working directory.
* *
* `options.workdir`, when supplied (an aliased shell tool's working directory, * `options.workdir`, when supplied for an aliased shell tool, seeds the
* #574), seeds the initial effective base — as if the command were prefixed * initial effective base so relative path-rule tokens resolve against it.
* with `cd <workdir>` — so relative tokens resolve against it, and the workdir
* itself is flagged as external when it resolves outside the cwd.
*/ */
static async parse( static async parse(
command: string, command: string,
@@ -56,17 +52,16 @@ export class BashProgram {
): Promise<BashProgram> { ): Promise<BashProgram> {
const parser = await getParser(); const parser = await getParser();
const tree = parser.parse(command); const tree = parser.parse(command);
if (!tree) return new BashProgram(command, [], [], []); if (!tree) return new BashProgram(command, [], []);
try { try {
const { externalPaths, ruleCandidates } = new BashPathResolver( const { ruleCandidates } = new BashPathResolver(
normalizer, normalizer,
options?.workdir, options?.workdir,
).resolve(tree.rootNode); ).resolve(tree.rootNode);
return new BashProgram( return new BashProgram(
command, command,
collectCommands(tree.rootNode), collectCommands(tree.rootNode),
externalPaths,
ruleCandidates, ruleCandidates,
); );
} finally { } finally {
@@ -103,20 +98,6 @@ export class BashProgram {
return [...this.commandUnits]; return [...this.commandUnits];
} }
/**
* Deduplicated paths that resolve outside `cwd`, as {@link AccessPath} value
* objects holding both the lexical (as-typed) and canonical (symlink-resolved)
* forms behind distinct accessors.
*
* Resolved eagerly at parse time through the `PathNormalizer` supplied to
* `parse()` (platform + cwd baked in).
* Use `.matchValues()` for `external_directory` pattern matching and
* `.boundaryValue()` for containment checks; `.value()` for display and logs.
*/
externalPaths(): AccessPath[] {
return [...this.resolvedExternalPaths];
}
/** /**
* Path-rule candidates paired with their policy lookup values. * Path-rule candidates paired with their policy lookup values.
* *
@@ -11,7 +11,7 @@ import { classifyToolKind } from "./tool-kind";
* service and the event-bus RPC) feeds to the resolver from a `(surface, value)` * service and the event-bus RPC) feeds to the resolver from a `(surface, value)`
* pair. * pair.
* *
* For a path-shaped surface (`path`, `external_directory`, or a path-bearing * For a path-shaped surface (`path` or a path-bearing
* tool) carrying a non-empty value, it builds an `AccessPath` and emits an * tool) carrying a non-empty value, it builds an `AccessPath` and emits an
* `access-path` intent, so the resolver matches the lexical aliases ∪ canonical * `access-path` intent, so the resolver matches the lexical aliases ∪ canonical
* (symlink-resolved) set — at parity with the gates (#486, #502). Every other * (symlink-resolved) set — at parity with the gates (#486, #502). Every other
@@ -95,7 +95,6 @@ function buildInputForSurface(
const v = value ?? ""; const v = value ?? "";
if (surface === "bash") return { command: v }; if (surface === "bash") return { command: v };
if (surface === "skill") return { name: v }; if (surface === "skill") return { name: v };
if (surface === "external_directory") return { path: v };
// MCP and tool surfaces: normalizeInput handles them from the surface alone. // MCP and tool surfaces: normalizeInput handles them from the surface alone.
return {}; return {};
} }
@@ -125,7 +124,7 @@ export interface NormalizedInput {
* `checkPermission()`. * `checkPermission()`.
* *
* Handles bash, skill, mcp, and extension surfaces. Path-bearing tool surfaces * Handles bash, skill, mcp, and extension surfaces. Path-bearing tool surfaces
* (`path`, `external_directory`, `read`, `write`, `edit`, `grep`, `find`, * (`path`, `read`, `write`, `edit`, `grep`, `find`,
* `ls`) now route through the access-path gate (#502) and service/RPC builder * `ls`) now route through the access-path gate (#502) and service/RPC builder
* (#503) before reaching the manager, so they never arrive here with a real * (#503) before reaching the manager, so they never arrive here with a real
* path value — all fall through to the extension catch-all `["*"]`. * path value — all fall through to the extension catch-all `["*"]`.
@@ -1,14 +1,4 @@
/** /** File tools whose primary input carries a local filesystem path. */
* File tools that only read — never write — the filesystem.
* Only these tools are eligible for the Pi infrastructure auto-allow.
*/
export const READ_ONLY_PATH_BEARING_TOOLS: ReadonlySet<string> = new Set([
"read",
"find",
"grep",
"ls",
]);
export const PATH_BEARING_TOOLS = new Set([ export const PATH_BEARING_TOOLS = new Set([
"read", "read",
"write", "write",
@@ -18,13 +8,8 @@ export const PATH_BEARING_TOOLS = new Set([
"ls", "ls",
]); ]);
/** /** Surfaces whose patterns use filesystem path matching semantics. */
* Surfaces whose patterns are matched against filesystem paths and therefore
* fold case (and separators) on Windows: the path-bearing tools plus the
* cross-cutting `path` gate and the `external_directory` boundary gate.
*/
export const PATH_SURFACES: ReadonlySet<string> = new Set([ export const PATH_SURFACES: ReadonlySet<string> = new Set([
...PATH_BEARING_TOOLS, ...PATH_BEARING_TOOLS,
"external_directory",
"path", "path",
]); ]);
@@ -14,8 +14,8 @@ export function getPathBearingToolPath(
} }
/** /**
* Extract the filesystem path a tool will access, for the cross-cutting `path` * Extract the filesystem path a tool will access for the cross-cutting `path`
* and `external_directory` gates. * gate.
* *
* Unlike {@link getPathBearingToolPath} (built-in tools only), this recognizes * Unlike {@link getPathBearingToolPath} (built-in tools only), this recognizes
* extension and MCP tools so they are no longer exempt from path gating: * extension and MCP tools so they are no longer exempt from path gating:
@@ -26,8 +26,8 @@ import { PATH_BEARING_TOOLS } from "./path-surfaces";
* - `skill` — a distinct surface `normalizeInput`/`deriveSource` treat specially. * - `skill` — a distinct surface `normalizeInput`/`deriveSource` treat specially.
* - `path` — a path-bearing built-in (`read`/`write`/`edit`/`grep`/`find`/`ls`); * - `path` — a path-bearing built-in (`read`/`write`/`edit`/`grep`/`find`/`ls`);
* extraction product is `input.path`. * extraction product is `input.path`.
* - `extension` — every other tool, plus the `external_directory`/`path` special * - `extension` — every other tool, plus the `path` special surface that
* surfaces that reach `deriveSource` as normalized names. * reaches `deriveSource` as a normalized name.
*/ */
export type ToolKind = "bash" | "mcp" | "skill" | "path" | "extension"; export type ToolKind = "bash" | "mcp" | "skill" | "path" | "extension";
@@ -9,7 +9,6 @@ import {
} from "./authorizer"; } from "./authorizer";
import { composeAuthorizerChain } from "./authorizer-chain"; import { composeAuthorizerChain } from "./authorizer-chain";
import type { AuthorizerLookup } from "./authorizer-registry"; import type { AuthorizerLookup } from "./authorizer-registry";
import { encloseInDelegationEnvelope } from "./delegation-envelope";
import type { import type {
PermissionPrompterApi, PermissionPrompterApi,
PromptPermissionDetails, PromptPermissionDetails,
@@ -112,8 +111,8 @@ export class AuthorizerSelection
* Resolve the operator's `authorizerChain` names to registered links, in * Resolve the operator's `authorizerChain` names to registered links, in
* config order (ADR 0007 invariant 1). An unregistered name is skipped with a * config order (ADR 0007 invariant 1). An unregistered name is skipped with a
* warning (invariant 2 — more prompting, never less); each resolved link is * warning (invariant 2 — more prompting, never less); each resolved link is
* wrapped in the bounded-delegation envelope so an `allow` on an excluded * Authorizer verdicts are used directly. A configured authorizer may approve
* surface cannot exceed the operator's policy. * any ordinary-policy `ask`, including the `path` surface.
* *
* The resolved names are recorded against the ask before any link runs — a * The resolved names are recorded against the ask before any link runs — a
* link that defers decides nothing and would otherwise leave no evidence it * link that defers decides nothing and would otherwise leave no evidence it
@@ -136,7 +135,7 @@ export class AuthorizerSelection
continue; continue;
} }
resolved.push(name); resolved.push(name);
links.push({ name, authorize: encloseInDelegationEnvelope(authorize) }); links.push({ name, authorize });
} }
if (resolved.length > 0) { if (resolved.length > 0) {
this.deps.logger.review("authorizer_chain_resolved", { this.deps.logger.review("authorizer_chain_resolved", {
@@ -1,64 +0,0 @@
/**
* The bounded-delegation enforcement checkpoint (ADR 0007 §5).
*
* The chain owner caps every registered link's verdict so a buggy or over-eager
* external judge can never exceed the operator's policy: a link's `allow` on an
* excluded surface is downgraded to `defer`, letting the `ask` fall through to
* the terminal (a prompt) instead. The checkpoint only ever *tightens* a
* verdict — it never turns a `defer`/`deny` into an `allow`.
*
* The excluded set is the whole `path` surface plus `external_directory`, with
* one bundle-maintained exception: built-in read-only path tools may accept a
* link's `allow` for an external-directory ask. Mutating tools, bash, extension
* tools, and unknown tools remain capped to the terminal human authority. A
* finer secret-shaped-`path` exclusion remains deferred; `path` stays fully
* excluded.
*/
import { READ_ONLY_PATH_BEARING_TOOLS } from "#src/access-intent/path-surfaces";
import type { Authorizer } from "./authorizer";
import type { PromptPermissionDetails } from "./permission-prompter";
/** Surfaces on which a link may never grant an `allow` (ADR 0007 §5). */
export const DELEGATION_EXCLUDED_SURFACES: ReadonlySet<string> = new Set([
"external_directory",
"path",
]);
/**
* Wrap a link's `authorize` so an `allow` on an excluded surface is capped to
* `defer`. All other verdicts, and `allow`s on non-excluded surfaces, pass
* through unchanged. `details`, the injected `query`, and the review-log `log`
* are forwarded as-is.
*/
export function encloseInDelegationEnvelope(
authorize: Authorizer["authorize"],
): Authorizer["authorize"] {
return async (details, query, log) => {
const verdict = await authorize(details, query, log);
if (verdict.kind === "allow" && isExcludedSurface(details)) {
return { kind: "defer" };
}
return verdict;
};
}
/**
* Whether an allow verdict exceeds the delegation envelope. The gate-computed
* surface is authoritative. `path` remains fully excluded; external-directory
* access is excluded unless it comes from a built-in read-only path tool.
* Unknown surfaces fail safe to the terminal authority.
*/
function isExcludedSurface(details: PromptPermissionDetails): boolean {
const surface = details.accessIntent?.surface ?? details.surface ?? undefined;
if (surface === undefined) {
return true;
}
if (surface === "external_directory") {
return !(
details.toolName !== undefined &&
READ_ONLY_PATH_BEARING_TOOLS.has(details.toolName)
);
}
return DELEGATION_EXCLUDED_SURFACES.has(surface);
}
@@ -89,9 +89,8 @@ export interface ForwardedRequestServerDeps {
* judges a forwarded ask on the same evidence as a local one (ADR 0008; #635). * judges a forwarded ask on the same evidence as a local one (ADR 0008; #635).
* *
* The display `surface` and the fact `surface` are distinct and both belong * The display `surface` and the fact `surface` are distinct and both belong
* here: the former is the child's tool name (what the UI shows), the latter the * here: the former is the child's tool name (what the UI shows), while the
* gate surface the rule fired on (what the bounded-delegation checkpoint * latter preserves the gate surface as authorizer evidence.
* excludes on).
*/ */
function buildForwardedAskDetails( function buildForwardedAskDetails(
request: ForwardedPermissionRequest, request: ForwardedPermissionRequest,
@@ -113,9 +112,8 @@ function buildForwardedAskDetails(
...(request.sessionApproval ...(request.sessionApproval
? { sessionApproval: request.sessionApproval } ? { sessionApproval: request.sessionApproval }
: {}), : {}),
// Absent for a version-skew request that carried no intent — which the // Absent for a version-skew request that carried no access intent; preserve
// delegation envelope reads as "surface undetermined" and fail-safes to // that absence rather than manufacturing authorizer evidence.
// excluded, so absence must stay absence rather than become `undefined`.
...(request.accessIntent ...(request.accessIntent
? { accessIntent: toAccessFacts(request.accessIntent) } ? { accessIntent: toAccessFacts(request.accessIntent) }
: {}), : {}),
@@ -88,7 +88,7 @@ export interface ForwardedSessionApproval {
* (`docs/decisions/0002-path-values-string-boundary.md`). * (`docs/decisions/0002-path-values-string-boundary.md`).
*/ */
export interface ForwardedAccessFacts { export interface ForwardedAccessFacts {
/** Gate surface: `"path"`, `"external_directory"`, `"bash"`, a tool name, or a skill name. */ /** Gate surface: `"path"`, `"bash"`, a tool name, or a skill name. */
surface: string; surface: string;
/** /**
* The child-fixed match set. Path surface: `AccessPath.matchValues()` * The child-fixed match set. Path surface: `AccessPath.matchValues()`
+8 -69
View File
@@ -5,8 +5,6 @@ import {
getGlobalConfigPath, getGlobalConfigPath,
getLegacyExtensionConfigPath, getLegacyExtensionConfigPath,
getLegacyGlobalPolicyPath, getLegacyGlobalPolicyPath,
getLegacyProjectPolicyPath,
getProjectConfigPath,
} from "./config-paths"; } from "./config-paths";
import { import {
type ShellToolsConfig, type ShellToolsConfig,
@@ -194,8 +192,6 @@ function formatConfigIssues(error: ZodError): string[] {
* - `permission` is deep-shallow merged (surface-level object maps are shallow-merged). * - `permission` is deep-shallow merged (surface-level object maps are shallow-merged).
* - Scalar fields (debugLog, permissionReviewLog, yoloMode) are replaced when * - Scalar fields (debugLog, permissionReviewLog, yoloMode) are replaced when
* present in the override. * present in the override.
* - Array fields (piInfrastructureReadPaths) replace the base when present in
* the override (override-wins, same as scalars).
*/ */
// Scalar knobs merged by override-replaces-base; keep in sync with // Scalar knobs merged by override-replaces-base; keep in sync with
// PermissionSystemExtensionConfig booleans (debugLog, permissionReviewLog, // PermissionSystemExtensionConfig booleans (debugLog, permissionReviewLog,
@@ -235,11 +231,9 @@ export function mergeUnifiedConfigs(
} }
// Array fields: override replaces base when defined // Array fields: override replaces base when defined
for (const key of ["piInfrastructureReadPaths", "authorizerChain"] as const) { const authorizerChain = override.authorizerChain ?? base.authorizerChain;
const value = override[key] ?? base[key]; if (authorizerChain !== undefined) {
if (value !== undefined) { merged.authorizerChain = authorizerChain;
merged[key] = value;
}
} }
// shellTools: merge by tool name, then by descriptor field. A project can // shellTools: merge by tool name, then by descriptor field. A project can
@@ -286,51 +280,25 @@ export function mergeUnifiedConfigs(
export interface MergedConfigResult { export interface MergedConfigResult {
global: UnifiedPermissionConfig; global: UnifiedPermissionConfig;
project: UnifiedPermissionConfig;
merged: UnifiedPermissionConfig; merged: UnifiedPermissionConfig;
issues: string[]; issues: string[];
} }
/** /**
* Load global and project configs from the new layout, detect legacy files, * Load the user-global config, detect legacy global files, and collect issues.
* merge everything, and collect issues. * Project-level `policy.jsonc` files are intentionally not loaded.
*
* Merge order:
* 1. Legacy global policy (if present) — lowest precedence
* 2. Legacy extension runtime config (if present and path differs from new global)
* 3. New global config
* 4. Legacy project policy (if present)
* 5. New project config — highest precedence
*
* Legacy files are detected and warned about. Their content is parsed with the
* flat-format parser — legacy-format keys (defaultPolicy, tools, bash, etc.)
* are not translated and contribute no permission rules.
*
* When `options.includeProjectScope` is `false`, the project-scope steps (4 and
* 5) are skipped entirely — neither the legacy project policy nor the new
* project config is read or merged. This gates project-local config on project
* trust: an untrusted repository cannot loosen the operator's global policy
* (#644). It defaults to `true`, preserving the trusted / caller-agnostic path.
*/ */
export function loadAndMergeConfigs( export function loadAndMergeConfigs(
agentDir: string, agentDir: string,
cwd: string, _cwd: string,
extensionRoot: string, extensionRoot: string,
options: { includeProjectScope?: boolean } = {},
): MergedConfigResult { ): MergedConfigResult {
const includeProjectScope = options.includeProjectScope !== false;
const allIssues: string[] = []; const allIssues: string[] = [];
const newGlobalPath = getGlobalConfigPath(agentDir); const newGlobalPath = getGlobalConfigPath(agentDir);
const newProjectPath = getProjectConfigPath(cwd);
const legacyGlobalPolicyPath = getLegacyGlobalPolicyPath(agentDir); const legacyGlobalPolicyPath = getLegacyGlobalPolicyPath(agentDir);
const legacyProjectPolicyPath = getLegacyProjectPolicyPath(cwd);
const legacyExtConfigPath = getLegacyExtensionConfigPath(extensionRoot); const legacyExtConfigPath = getLegacyExtensionConfigPath(extensionRoot);
// Start with empty
let merged: UnifiedPermissionConfig = {}; let merged: UnifiedPermissionConfig = {};
// 1. Legacy global policy
if (existsSync(legacyGlobalPolicyPath)) { if (existsSync(legacyGlobalPolicyPath)) {
const legacy = loadUnifiedConfig(legacyGlobalPolicyPath); const legacy = loadUnifiedConfig(legacyGlobalPolicyPath);
allIssues.push( allIssues.push(
@@ -338,55 +306,27 @@ export function loadAndMergeConfigs(
`Move it to '${newGlobalPath}':\n` + `Move it to '${newGlobalPath}':\n` +
` mv '${legacyGlobalPolicyPath}' '${newGlobalPath}'`, ` mv '${legacyGlobalPolicyPath}' '${newGlobalPath}'`,
); );
// Legacy files are migrated away; the move-it guidance above is the
// actionable signal, so strict-validation issues for them are suppressed.
merged = mergeUnifiedConfigs(merged, legacy.config); merged = mergeUnifiedConfigs(merged, legacy.config);
} }
// 2. Legacy extension runtime config (only if different from new global path)
const normalizedLegacyExt = normalize(legacyExtConfigPath);
const normalizedNewGlobal = normalize(newGlobalPath);
if ( if (
normalizedLegacyExt !== normalizedNewGlobal && normalize(legacyExtConfigPath) !== normalize(newGlobalPath) &&
existsSync(legacyExtConfigPath) existsSync(legacyExtConfigPath)
) { ) {
const legacy = loadUnifiedConfig(legacyExtConfigPath); const legacy = loadUnifiedConfig(legacyExtConfigPath);
allIssues.push( allIssues.push(
`Legacy extension config found at '${legacyExtConfigPath}'. ` + `Legacy extension config found at '${legacyExtConfigPath}'. ` +
`Move runtime settings to '${newGlobalPath}':\n` + `Move it to '${newGlobalPath}':\n` +
` mv '${legacyExtConfigPath}' '${newGlobalPath}'`, ` mv '${legacyExtConfigPath}' '${newGlobalPath}'`,
); );
// See above: legacy-file validation issues are suppressed.
merged = mergeUnifiedConfigs(merged, legacy.config); merged = mergeUnifiedConfigs(merged, legacy.config);
} }
// 3. New global config
const globalResult = loadUnifiedConfig(newGlobalPath); const globalResult = loadUnifiedConfig(newGlobalPath);
allIssues.push(...globalResult.issues); allIssues.push(...globalResult.issues);
const globalConfig = globalResult.config; const globalConfig = globalResult.config;
merged = mergeUnifiedConfigs(merged, globalConfig); merged = mergeUnifiedConfigs(merged, globalConfig);
// 4. Legacy project policy — skipped when the project scope is withheld.
if (includeProjectScope && existsSync(legacyProjectPolicyPath)) {
const legacy = loadUnifiedConfig(legacyProjectPolicyPath);
allIssues.push(
`Legacy project policy found at '${legacyProjectPolicyPath}'. ` +
`Move it to '${newProjectPath}':\n` +
` mv '${legacyProjectPolicyPath}' '${newProjectPath}'`,
);
// See above: legacy-file validation issues are suppressed.
merged = mergeUnifiedConfigs(merged, legacy.config);
}
// 5. New project config — skipped when the project scope is withheld, so an
// untrusted project contributes nothing and `project` reports empty.
const projectResult = includeProjectScope
? loadUnifiedConfig(newProjectPath)
: { config: {}, issues: [] };
allIssues.push(...projectResult.issues);
const projectConfig = projectResult.config;
merged = mergeUnifiedConfigs(merged, projectConfig);
const bashFallbackIssue = detectPermissiveBashFallback(merged.permission); const bashFallbackIssue = detectPermissiveBashFallback(merged.permission);
if (bashFallbackIssue) allIssues.push(bashFallbackIssue); if (bashFallbackIssue) allIssues.push(bashFallbackIssue);
@@ -395,7 +335,6 @@ export function loadAndMergeConfigs(
return { return {
global: globalConfig, global: globalConfig,
project: projectConfig,
merged, merged,
issues: allIssues, issues: allIssues,
}; };
-6
View File
@@ -26,9 +26,6 @@ export function getGlobalLogsDir(agentDir: string): string {
return join(getGlobalConfigDir(agentDir), "logs"); return join(getGlobalConfigDir(agentDir), "logs");
} }
export function getProjectConfigPath(cwd: string): string {
return join(cwd, ".pi", "extensions", EXTENSION_ID, "policy.jsonc");
}
/** /**
* Directory holding project-scoped custom agent definition files. * Directory holding project-scoped custom agent definition files.
@@ -47,9 +44,6 @@ export function getLegacyGlobalPolicyPath(agentDir: string): string {
return join(agentDir, "pi-permissions.jsonc"); return join(agentDir, "pi-permissions.jsonc");
} }
export function getLegacyProjectPolicyPath(cwd: string): string {
return join(cwd, ".pi", "agent", "pi-permissions.jsonc");
}
export function getLegacyExtensionConfigPath(extensionRoot: string): string { export function getLegacyExtensionConfigPath(extensionRoot: string): string {
return join(extensionRoot, "config.json"); return join(extensionRoot, "config.json");
@@ -3,21 +3,17 @@ import type { ResolvedPolicyPaths } from "./permission-manager";
export interface ResolvedConfigLogEntry { export interface ResolvedConfigLogEntry {
globalConfigPath: string; globalConfigPath: string;
globalConfigExists: boolean; globalConfigExists: boolean;
projectConfigPath: string | null;
projectConfigExists: boolean;
agentsDir: string; agentsDir: string;
agentsDirExists: boolean; agentsDirExists: boolean;
projectAgentsDir: string | null; projectAgentsDir: string | null;
projectAgentsDirExists: boolean; projectAgentsDirExists: boolean;
legacyGlobalPolicyDetected: boolean; legacyGlobalPolicyDetected: boolean;
legacyProjectPolicyDetected: boolean;
legacyExtensionConfigDetected: boolean; legacyExtensionConfigDetected: boolean;
} }
export interface BuildResolvedConfigLogEntryOptions { export interface BuildResolvedConfigLogEntryOptions {
policyPaths: ResolvedPolicyPaths; policyPaths: ResolvedPolicyPaths;
legacyGlobalPolicyDetected?: boolean; legacyGlobalPolicyDetected?: boolean;
legacyProjectPolicyDetected?: boolean;
legacyExtensionConfigDetected?: boolean; legacyExtensionConfigDetected?: boolean;
} }
@@ -27,7 +23,6 @@ export function buildResolvedConfigLogEntry(
return { return {
...options.policyPaths, ...options.policyPaths,
legacyGlobalPolicyDetected: options.legacyGlobalPolicyDetected ?? false, legacyGlobalPolicyDetected: options.legacyGlobalPolicyDetected ?? false,
legacyProjectPolicyDetected: options.legacyProjectPolicyDetected ?? false,
legacyExtensionConfigDetected: legacyExtensionConfigDetected:
options.legacyExtensionConfigDetected ?? false, options.legacyExtensionConfigDetected ?? false,
}; };
+5 -13
View File
@@ -72,9 +72,9 @@ const permissionMapSchema = z
.meta({ .meta({
id: "permissionMap", id: "permissionMap",
description: description:
"A map of wildcard patterns to permission states. Last matching pattern wins.", "A map of wildcard patterns to permission states. The most specific matching pattern wins.",
markdownDescription: markdownDescription:
"A map of wildcard patterns to permission states.\n\nUse `*` for wildcard matching. When multiple patterns match, the **last matching rule wins** — put broad catch-alls first and specific overrides after them.\n\nPattern keys support home directory expansion:\n- `~/path` or `$HOME/path` — expanded to the OS home directory at match time.\n- `~` or `$HOME` alone — expands to the home directory itself.\n\nThe stored pattern is always shown in logs and approval dialogs as written (e.g. `~/dev/*`).", "A map of wildcard patterns to permission states.\n\nUse `*` for wildcard matching. When several patterns match, the most specific pattern wins: exact patterns first, then more literal characters, fewer wildcards, and longer patterns. Declaration order is only the final tie-breaker, where the later rule wins.\n\nPattern keys support home directory expansion: `~/path` and `$HOME/path` expand at match time. The stored pattern is shown in logs and approval dialogs as written.",
}); });
const permissionSchema = z const permissionSchema = z
@@ -88,7 +88,7 @@ const permissionSchema = z
description: description:
"Flat permission policy. Each key is a surface name; values are a PermissionState string (catch-all) or a pattern→action map.", "Flat permission policy. Each key is a surface name; values are a PermissionState string (catch-all) or a pattern→action map.",
markdownDescription: markdownDescription:
'Flat permission policy.\n\nEach top-level key is a surface name:\n- `"*"` — universal fallback (replaces `defaultPolicy.tools` from the legacy format)\n- Tool names (`read`, `write`, `bash`, `mcp`, `skill`, `external_directory`, `path`, etc.)\n\nA **string** value is shorthand for `{ "*": action }` (surface-level catch-all).\nAn **object** value maps wildcard patterns to actions — last matching pattern wins.\n\nFor built-in file tools (`read`, `write`, `edit`, `find`, `grep`, `ls`), patterns are matched against the file path from `input.path`. For example, `"read": { "*": "allow", "*.env": "deny" }` allows reads but denies `.env` files.\n\nWhen Pi\'s current working directory is known, relative path inputs also match their cwd-normalized absolute form, so `src/App.jsx` can match both `src/*` and `/workspace/project/*`. Bash path tokens use the effective directory after literal `cd` commands for this matching; non-literal `cd "$DIR"` style commands remain conservative.\n\nThe `path` surface is a cross-cutting gate that applies to **all** file access: Pi tools, bash commands, MCP calls (via `input.arguments.path`), and extension tools (via `input.path` or a registered access extractor). A `path` deny cannot be overridden by a per-tool allow. Use it to protect sensitive files (`.env`, `~/.ssh/*`) from all path-aware tools at once.\n\nThe `external_directory` surface gates access **outside** the working directory. Give it a pattern map to allow specific outside-CWD directories without opening all external access — e.g. `"external_directory": { "*": "ask", "~/.cargo/registry/*": "allow" }` to silence repeated prompts on a local cache. The trailing `*` is greedy and crosses subdirectory boundaries; a bare `~/.cargo/registry` matches only the directory entry itself. Because layers compose with most-restrictive-wins, a `path` allow cannot loosen an `external_directory: ask` boundary — allow outside-CWD directories here, not on `path`.\n\n**Merge order (lowest → highest precedence):** global → project → per-agent frontmatter.', 'Flat permission policy.\n\nEach top-level key is a surface name:\n- `"*"` — universal fallback\n- Tool names (`read`, `write`, `bash`, `mcp`, `skill`, `path`, etc.)\n\nA string value is shorthand for `{ "*": action }`. An object maps wildcard patterns to actions, and the most specific matching pattern wins.\n\nFor built-in file tools, patterns match `input.path`. The cross-cutting `path` surface checks path-bearing tools and bash path tokens.\n\n**Merge order (lowest → highest precedence):** global → per-agent frontmatter → project-agent frontmatter.',
examples: [ examples: [
{ {
"*": "ask", "*": "ask",
@@ -109,7 +109,6 @@ const permissionSchema = z
}, },
mcp: { "*": "ask", mcp_status: "allow", "exa:*": "allow" }, mcp: { "*": "ask", mcp_status: "allow", "exa:*": "allow" },
skill: { "*": "ask", librarian: "allow" }, skill: { "*": "ask", librarian: "allow" },
external_directory: { "*": "ask", "~/.cargo/registry/*": "allow" },
}, },
], ],
}); });
@@ -240,18 +239,11 @@ export const unifiedConfigSchema = z
markdownDescription: markdownDescription:
"**Deprecated and ignored.** Superseded by `promptMaxRows` and `promptFieldMaxWidth`, which bound the whole permission prompt rather than one summary inside it.\n\nStill accepted so an existing config is not rejected fail-closed, but the value no longer takes effect. Remove it.", "**Deprecated and ignored.** Superseded by `promptMaxRows` and `promptFieldMaxWidth`, which bound the whole permission prompt rather than one summary inside it.\n\nStill accepted so an existing config is not rejected fail-closed, but the value no longer takes effect. Remove it.",
}), }),
piInfrastructureReadPaths: z.array(z.string().min(1)).optional().meta({
description:
"Additional directories to auto-allow for reads as Pi infrastructure, bypassing the external_directory gate. Supports ~ expansion and wildcard patterns (* and ?).",
markdownDescription:
"Additional directories to auto-allow for reads as Pi infrastructure, bypassing the `external_directory` gate.\n\nThe extension auto-discovers the global node_modules root (walks up from the extension's install path; falls back to `npm root -g` from a dev checkout), Pi's own install directory (via the coding-agent `getPackageDir()` API), `agentDir`, `agentDir/git`, and project-local `.pi/npm/` and `.pi/git/`. Add entries here for edge cases where auto-discovery is insufficient (e.g. custom `npmCommand` pointing to pnpm).\n\nSupports `~`/`$HOME` expansion. Entries may be plain directory prefixes or wildcard patterns using `*` (matches any characters, including `/`) and `?` (matches exactly one character). `**` and `*` are equivalent — both cross directory boundaries.\n\nOn Windows, matching is case-insensitive and tolerant of either path separator.",
default: [],
}),
authorizerChain: z.array(z.string().min(1)).optional().meta({ authorizerChain: z.array(z.string().min(1)).optional().meta({
description: description:
"Ordered names of registered live-authority chain links to consult before the terminal authorizer. Config order (not registration order) fixes the chain order; an unregistered name is skipped fail-safe (more prompting, never less); a link decides nothing until it is named here.", "Ordered names of registered live-authority chain links to consult before the terminal authorizer. Config order (not registration order) fixes the chain order; an unregistered name is skipped fail-safe (more prompting, never less); a link decides nothing until it is named here.",
markdownDescription: markdownDescription:
"Ordered names of registered **live-authority chain links** (e.g. a model judge) to consult before the terminal authorizer (the human, or the subagent-forwarding / headless-deny fallback).\n\nA link reviews an `ask` and returns `allow` / `deny` (with an optional teaching reason) / `defer` to the next link. Three invariants govern the chain:\n\n- **Config order wins.** The order here \u2014 not the order extensions register in \u2014 fixes the security-relevant chain order.\n- **Fail-safe skip.** A name with no registered link is skipped with a warning; the `ask` still reaches the terminal (more prompting, never less).\n- **Opt-in activation.** Installing a judge extension grants it no authority; a link decides nothing until you name it here.\n\nThe chain owner caps every verdict with a bounded-delegation checkpoint: a link's `allow` on an excluded surface (`external_directory` or `path`) is downgraded to `defer`, so a link cannot exceed your policy.\n\nDefaults to an empty list (no links).", "Ordered names of registered **live-authority chain links** (for example AutoReview) to consult before the terminal human authorizer. A named link may approve or deny any ordinary-policy `ask`, including `path`; `defer` advances to the next link or the human terminal. Defaults to an empty list.",
default: [], default: [],
}), }),
permission: permissionSchema.optional(), permission: permissionSchema.optional(),
@@ -270,7 +262,7 @@ export const unifiedConfigSchema = z
description: description:
"Ordinary JSONC policy combining runtime knobs, AutoReview settings, and flat permission rules for pi-permission-system.", "Ordinary JSONC policy combining runtime knobs, AutoReview settings, and flat permission rules for pi-permission-system.",
markdownDescription: markdownDescription:
"Ordinary JSONC policy for [pi-permission-system](https://github.com/gotgenes/pi-packages/tree/main/packages/pi-permission-system).\n\nPlace at `~/.pi/agent/extensions/pi-permission-system/policy.jsonc` (global) or `<project>/.pi/extensions/pi-permission-system/policy.jsonc` (project). Global-only hard guards use the separate `hard-guards.jsonc` schema.", "Global ordinary JSONC policy for [pi-permission-system](https://github.com/gotgenes/pi-packages/tree/main/packages/pi-permission-system).\n\nPlace at `~/.pi/agent/extensions/pi-permission-system/policy.jsonc`. Project-level policy files are not loaded. Global-only hard guards use the separate `hard-guards.jsonc` schema.",
}); });
/** A permission decision. */ /** A permission decision. */
+3 -11
View File
@@ -16,7 +16,6 @@ import {
getGlobalConfigPath, getGlobalConfigPath,
getLegacyExtensionConfigPath, getLegacyExtensionConfigPath,
getLegacyGlobalPolicyPath, getLegacyGlobalPolicyPath,
getLegacyProjectPolicyPath,
} from "./config-paths"; } from "./config-paths";
import { buildResolvedConfigLogEntry } from "./config-reporter"; import { buildResolvedConfigLogEntry } from "./config-reporter";
import { import {
@@ -95,10 +94,8 @@ export class ConfigStore implements SessionConfigStore, CommandConfigStore {
/** /**
* Reload merged config from disk. * Reload merged config from disk.
* *
* If `ctx` is provided, uses it to derive the cwd and sync UI status. * Runtime configuration is global-only. `projectTrusted` is retained here
* When `projectTrusted` is `false`, the project scope is withheld so an * for lifecycle logging and active-agent trust decisions owned elsewhere.
* untrusted repository's runtime config (`yoloMode`, `permissionReviewLog`,
* …) cannot loosen the operator's global config (#644).
*/ */
refresh(ctx: ExtensionContext | undefined, projectTrusted: boolean): void { refresh(ctx: ExtensionContext | undefined, projectTrusted: boolean): void {
const cwd = ctx?.cwd ?? null; const cwd = ctx?.cwd ?? null;
@@ -106,7 +103,6 @@ export class ConfigStore implements SessionConfigStore, CommandConfigStore {
this.deps.agentDir, this.deps.agentDir,
cwd ?? "", cwd ?? "",
EXTENSION_ROOT, EXTENSION_ROOT,
{ includeProjectScope: projectTrusted },
); );
const runtimeConfig = normalizePermissionSystemConfig(mergeResult.merged); const runtimeConfig = normalizePermissionSystemConfig(mergeResult.merged);
this.config = runtimeConfig; this.config = runtimeConfig;
@@ -194,15 +190,12 @@ export class ConfigStore implements SessionConfigStore, CommandConfigStore {
* *
* Equivalent to `logResolvedConfigPaths(runtime)`. * Equivalent to `logResolvedConfigPaths(runtime)`.
*/ */
logResolvedPaths(cwd?: string): void { logResolvedPaths(_cwd?: string): void {
const policyPaths = this.deps.policyPaths.getResolvedPolicyPaths(); const policyPaths = this.deps.policyPaths.getResolvedPolicyPaths();
const { agentDir } = this.deps; const { agentDir } = this.deps;
const legacyGlobalPolicyDetected = existsSync( const legacyGlobalPolicyDetected = existsSync(
getLegacyGlobalPolicyPath(agentDir), getLegacyGlobalPolicyPath(agentDir),
); );
const legacyProjectPolicyDetected = cwd
? existsSync(getLegacyProjectPolicyPath(cwd))
: false;
const legacyExtConfigPath = getLegacyExtensionConfigPath(EXTENSION_ROOT); const legacyExtConfigPath = getLegacyExtensionConfigPath(EXTENSION_ROOT);
const newGlobalPath = getGlobalConfigPath(agentDir); const newGlobalPath = getGlobalConfigPath(agentDir);
const legacyExtensionConfigDetected = const legacyExtensionConfigDetected =
@@ -211,7 +204,6 @@ export class ConfigStore implements SessionConfigStore, CommandConfigStore {
const entry = buildResolvedConfigLogEntry({ const entry = buildResolvedConfigLogEntry({
policyPaths, policyPaths,
legacyGlobalPolicyDetected, legacyGlobalPolicyDetected,
legacyProjectPolicyDetected,
legacyExtensionConfigDetected, legacyExtensionConfigDetected,
}); });
this.deps.logger.review( this.deps.logger.review(
@@ -24,8 +24,6 @@ export interface PermissionSystemExtensionConfig {
yoloMode: boolean; yoloMode: boolean;
/** Require a confirming second press of a decision hotkey in the inline TUI dialog. Defaults to true. */ /** Require a confirming second press of a decision hotkey in the inline TUI dialog. Defaults to true. */
doublePressToConfirm: boolean; doublePressToConfirm: boolean;
/** Additional directories to auto-allow for reads as Pi infrastructure. */
piInfrastructureReadPaths?: string[];
/** How long a subagent waits for the parent's answer to a forwarded ask, in ms. Defaults to 600000. */ /** How long a subagent waits for the parent's answer to a forwarded ask, in ms. Defaults to 600000. */
forwardingTimeoutMs?: number; forwardingTimeoutMs?: number;
/** Max rows a permission prompt renders before eliding its evidence. Defaults to 24. */ /** Max rows a permission prompt renders before eliding its evidence. Defaults to 24. */
@@ -64,7 +62,6 @@ const PERMISSION_POLICY_KEYS: ReadonlySet<string> = new Set([
"mcp", "mcp",
"skills", "skills",
"special", "special",
"external_directory",
]); ]);
export function detectMisplacedPermissionKeys( export function detectMisplacedPermissionKeys(
@@ -88,9 +85,6 @@ export function normalizePermissionSystemConfig(
if (raw.analysis !== undefined) { if (raw.analysis !== undefined) {
result.analysis = normalizeAutoReviewAnalysisConfig(raw.analysis); result.analysis = normalizeAutoReviewAnalysisConfig(raw.analysis);
} }
if (raw.piInfrastructureReadPaths !== undefined) {
result.piInfrastructureReadPaths = raw.piInfrastructureReadPaths;
}
if (raw.forwardingTimeoutMs !== undefined) { if (raw.forwardingTimeoutMs !== undefined) {
result.forwardingTimeoutMs = raw.forwardingTimeoutMs; result.forwardingTimeoutMs = raw.forwardingTimeoutMs;
} }
+3 -43
View File
@@ -1,66 +1,26 @@
import { join } from "node:path"; import { join } from "node:path";
import { getGlobalLogsDir } from "./config-paths"; import { getGlobalLogsDir } from "./config-paths";
import { discoverGlobalNodeModulesRoot } from "./node-modules-discovery";
/** /** Immutable path constants derived from `agentDir` at construction time. */
* Immutable path constants derived from `agentDir` at construction time.
*
* Computed once at startup in `computeExtensionPaths()` and embedded into
* `ExtensionRuntime`. Later refactorings (#129 PermissionSession, #130
* handler classes) consume this as a single dep instead of individual fields.
*/
export interface ExtensionPaths { export interface ExtensionPaths {
readonly agentDir: string; readonly agentDir: string;
readonly sessionsDir: string; readonly sessionsDir: string;
readonly subagentSessionsDir: string; readonly subagentSessionsDir: string;
readonly forwardingDir: string; readonly forwardingDir: string;
readonly globalLogsDir: string; readonly globalLogsDir: string;
/**
* Static Pi infrastructure directories used for external-directory
* read auto-allow. Computed once from `agentDir`,
* `discoverGlobalNodeModulesRoot()`, and (when provided) Pi's own
* install directory (`getPackageDir()`). Config-based extras
* (`piInfrastructureReadPaths`) are read from `runtime.config` at
* call time in the handler so they pick up config reloads.
*/
readonly piInfrastructureDirs: readonly string[];
} }
/** export function computeExtensionPaths(agentDir: string): ExtensionPaths {
* Compute all immutable path constants from `agentDir`.
*
* Calls `discoverGlobalNodeModulesRoot()` internally so the result is
* self-contained. Call this once at extension startup, not at module scope.
*
* `piPackageDir` is Pi's own install directory (from the coding-agent
* `getPackageDir()` API, resolved at the composition root). When provided it is
* auto-allowed for read-only tools so the agent can read Pi's bundled docs and
* examples regardless of install layout. It is strictly narrower than the
* discovered global `node_modules` root already included here.
*/
export function computeExtensionPaths(
agentDir: string,
piPackageDir?: string,
): ExtensionPaths {
const sessionsDir = join(agentDir, "sessions"); const sessionsDir = join(agentDir, "sessions");
const subagentSessionsDir = join(agentDir, "subagent-sessions"); const subagentSessionsDir = join(sessionsDir, "subagents");
const forwardingDir = join(sessionsDir, "permission-forwarding"); const forwardingDir = join(sessionsDir, "permission-forwarding");
const globalLogsDir = getGlobalLogsDir(agentDir); const globalLogsDir = getGlobalLogsDir(agentDir);
const globalNodeModulesRoot = discoverGlobalNodeModulesRoot();
const piInfrastructureDirs: string[] = [
agentDir,
join(agentDir, "git"),
...(globalNodeModulesRoot ? [globalNodeModulesRoot] : []),
...(piPackageDir ? [piPackageDir] : []),
];
return { return {
agentDir, agentDir,
sessionsDir, sessionsDir,
subagentSessionsDir, subagentSessionsDir,
forwardingDir, forwardingDir,
globalLogsDir, globalLogsDir,
piInfrastructureDirs,
}; };
} }
@@ -5,9 +5,6 @@ import type {
import type { PermissionResolver } from "#src/permission-resolver"; import type { PermissionResolver } from "#src/permission-resolver";
import type { PermissionSession } from "#src/permission-session"; import type { PermissionSession } from "#src/permission-session";
import { resolveSkillPromptEntries } from "#src/skill-prompt-sanitizer"; import { resolveSkillPromptEntries } from "#src/skill-prompt-sanitizer";
import { sanitizeAvailableToolsSection } from "#src/system-prompt-sanitizer";
import { getToolNameFromValue, type ToolRegistry } from "#src/tool-registry";
import type { PermissionState } from "#src/types";
/** Minimal subset of BeforeAgentStartEvent used by this handler. */ /** Minimal subset of BeforeAgentStartEvent used by this handler. */
interface BeforeAgentStartPayload { interface BeforeAgentStartPayload {
@@ -15,40 +12,14 @@ interface BeforeAgentStartPayload {
} }
/** /**
* Pure helper: returns true when the tool should be exposed to the agent. * Prepares permission state before an agent turn without changing Pi's active
* Checks the tool-level permission (not command-level) so that a blanket * tool set. Tool Search is the bundle's sole active-tool controller; this
* `bash: deny` hides the tool entirely before any invocation is attempted. * handler only refreshes permission state and sanitizes denied skill entries.
*/
export function shouldExposeTool(
toolName: string,
agentName: string | null,
getToolPermission: (toolName: string, agentName?: string) => PermissionState,
): boolean {
const toolPermission = getToolPermission(toolName, agentName ?? undefined);
return toolPermission !== "deny";
}
/**
* Handles the `before_agent_start` event: tool filtering + prompt sanitization.
*
* Recomputes the active tool set and the returned system-prompt override on
* every fire (no memoization): the override must be returned each turn so that
* skill filtering is reapplied and the wire prompt stays byte-stable, rather
* than letting Pi reset to its skill-unfiltered base prompt on a cache hit.
*
* Constructor deps:
* - `session` — encapsulates all mutable session state and lifecycle operations
* - `resolver` — owns permission-query surface: `getToolPermission`, skill check
* - `toolRegistry` — Pi tool API subset (getActive + setActive)
* - `warmParser` — warms the tree-sitter parser so the synchronous advisory
* bash path can decompose at gate parity; `before_agent_start` precedes any
* tool call, so triggering it here closes the pre-warm window (#309)
*/ */
export class AgentPrepHandler { export class AgentPrepHandler {
constructor( constructor(
private readonly session: PermissionSession, private readonly session: PermissionSession,
private readonly resolver: PermissionResolver, private readonly resolver: PermissionResolver,
private readonly toolRegistry: ToolRegistry,
private readonly warmParser: () => void, private readonly warmParser: () => void,
) {} ) {}
@@ -62,38 +33,11 @@ export class AgentPrepHandler {
// to whole-string matching. // to whole-string matching.
this.warmParser(); this.warmParser();
this.session.activate(ctx); this.session.activate(ctx);
// Gate the mid-session runtime-config refresh on project trust too, so an
// untrusted project cannot slip its runtime config (e.g. `yoloMode`) in
// right before agent start after session_start withheld it (#644). The
// session_start handler already warned; do not re-warn on every start.
this.session.refreshConfig(ctx, ctx.isProjectTrusted()); this.session.refreshConfig(ctx, ctx.isProjectTrusted());
const agentName = this.session.resolveAgentName(ctx, event.systemPrompt); const agentName = this.session.resolveAgentName(ctx, event.systemPrompt);
const activeTools = this.toolRegistry.getActive();
const allowedTools: string[] = [];
for (const tool of activeTools) {
const toolName = getToolNameFromValue(tool);
if (!toolName) {
continue;
}
if (
shouldExposeTool(toolName, agentName, (t, a) =>
this.resolver.getToolPermission(t, a),
)
) {
allowedTools.push(toolName);
}
}
this.toolRegistry.setActive(allowedTools);
const toolPromptResult = sanitizeAvailableToolsSection(
event.systemPrompt,
allowedTools,
);
const skillPromptResult = resolveSkillPromptEntries( const skillPromptResult = resolveSkillPromptEntries(
toolPromptResult.prompt, event.systemPrompt,
this.resolver, this.resolver,
agentName, agentName,
this.session.getPathNormalizer(), this.session.getPathNormalizer(),
@@ -1,126 +0,0 @@
import type { BashProgram } from "#src/access-intent/bash/program";
import type { ScopedPermissionResolver } from "#src/permission-resolver";
import { buildBashExternalDirectoryAskPayload } from "#src/presentation/path-ask-payload";
import { SessionApproval } from "#src/session-approval";
import { deriveApprovalPattern } from "#src/session-rules";
import type { GateResult } from "./descriptor";
import { selectUncoveredExternalPaths } from "./external-directory-policy";
import { accessFactsFromPath } from "./helpers";
import type { ToolCallContext } from "./types";
/**
* Build a pure descriptor for the bash external-directory permission gate.
*
* Reads the external paths from the injected `BashProgram` and checks whether
* any reference directories outside the working directory. Returns `null` when the gate
* does not apply (not a shell invocation, no command, or no external paths found).
* Returns a `GateBypass` when all paths are allowed (by config or session rule).
* Returns a `GateDescriptor` with multi-pattern sessionApproval for uncovered paths.
*
* The shell command (native `bash` or an aliased shell tool) is read from the
* injected `BashProgram`, which owns the source text it was parsed from, so
* this gate does not re-derive the input field name (#574).
*/
export function describeBashExternalDirectoryGate(
tcc: ToolCallContext,
bashProgram: BashProgram | null,
resolver: ScopedPermissionResolver,
): GateResult {
if (!bashProgram) return null;
const command = bashProgram.commandText();
const externalPaths = bashProgram.externalPaths();
if (externalPaths.length === 0) return null;
// Resolve every external path on the external_directory surface and keep the
// ones not already allowed (config-level allows suppress the prompt just as
// session-level allows do); the shared helper single-sources the #418 alias
// matching and the worst-uncovered selection.
const { uncovered: uncoveredEntries, worstCheck } =
selectUncoveredExternalPaths(
externalPaths,
resolver,
tcc.agentName ?? undefined,
);
const uncoveredPaths = uncoveredEntries.map(({ path }) => path.value());
if (uncoveredPaths.length === 0) {
return {
action: "allow",
// A whole-command bypass covers every external path at once, and each
// may have matched a different session pattern -- so the surface is one
// value and the pattern is not. The entry's `externalPaths` lists what
// was covered.
decidedBy: {
kind: "session_approval",
surface: "external_directory",
pattern: null,
},
log: {
event: "permission_request.session_approved",
details: {
source: "tool_call",
toolCallId: tcc.toolCallId,
toolName: tcc.toolName,
agentName: tcc.agentName,
command,
externalPaths: externalPaths.map((p) => p.value()),
resolution: "session_approved",
},
},
};
}
// After the early bypass, at least one path is uncovered, so worstCheck is
// defined; the fallback keeps TypeScript happy across the early return. A
// config-level "deny" is preserved (not downgraded to the catch-all "ask").
const preCheck = worstCheck ?? uncoveredEntries[0].check;
// The AccessPath the decision was made against — its facts ride the wire.
const worstEntry =
uncoveredEntries.find(({ check }) => check === preCheck) ??
uncoveredEntries[0];
const disclosures = uncoveredEntries.map(({ path }) => ({
path: path.value(),
resolvedPath: path.resolvedAlias(),
}));
const payload = buildBashExternalDirectoryAskPayload({
command,
externalPaths: disclosures,
cwd: tcc.cwd,
agentName: tcc.agentName,
toolName: tcc.toolName,
matchedPattern: preCheck.matchedPattern,
});
const patterns = uncoveredPaths.map((p) => deriveApprovalPattern(p));
return {
surface: "external_directory",
input: {},
payload,
sessionApproval: SessionApproval.multiple("external_directory", patterns),
promptDetails: {
source: "tool_call",
agentName: tcc.agentName,
toolCallId: tcc.toolCallId,
toolName: tcc.toolName,
command,
accessIntent: accessFactsFromPath("external_directory", worstEntry.path),
},
logContext: {
source: "tool_call",
toolCallId: tcc.toolCallId,
toolName: tcc.toolName,
agentName: tcc.agentName,
command,
externalPaths: uncoveredPaths,
},
decision: {
surface: "external_directory",
value: command,
},
preCheck,
};
}
@@ -1,23 +0,0 @@
import { BashProgram } from "#src/access-intent/bash/program";
import type { PathNormalizer } from "#src/path-normalizer";
/**
* Extract paths from a bash command that resolve outside CWD.
*
* Thin facade over {@link BashProgram.externalPaths}; parses the command
* through the injected {@link PathNormalizer} (platform + cwd baked in) and
* returns the cd-aware external paths in their lexical (as-typed) string form.
* See `BashProgram` for the parsing and resolution semantics.
*
* Returns `string[]` (not `AccessPath[]`) so the large projection-correctness
* test suite in `bash-external-directory.test.ts` can assert path values
* without migrating to the `AccessPath` accessors.
*/
export async function extractExternalPathsFromBashCommand(
command: string,
normalizer: PathNormalizer,
): Promise<string[]> {
return (await BashProgram.parse(command, normalizer))
.externalPaths()
.map((p) => p.value());
}
@@ -15,7 +15,7 @@ import type { PermissionCheckResult, PermissionState } from "#src/types";
* about logging, event emission, or session-rule recording. * about logging, event emission, or session-rule recording.
*/ */
export interface GateDescriptor { export interface GateDescriptor {
/** Permission surface to check (e.g. "bash", "external_directory", "skill"). */ /** Permission surface to check (e.g. "bash", "path", "skill"). */
surface: string; surface: string;
/** Input passed to checkPermission. */ /** Input passed to checkPermission. */
input: unknown; input: unknown;
@@ -1,66 +0,0 @@
import type { AccessPath } from "#src/access-intent/access-path";
import type { ScopedPermissionResolver } from "#src/permission-resolver";
import type { PermissionCheckResult } from "#src/types";
import { pickMostRestrictive } from "./candidate-check";
/** An external path whose resolved `external_directory` state is not "allow". */
export interface UncoveredExternalPath {
path: AccessPath;
check: PermissionCheckResult;
}
/** The uncovered external paths plus the most restrictive check among them. */
export interface UncoveredExternalPaths {
uncovered: UncoveredExternalPath[];
/** Worst check among uncovered paths; `undefined` only when none are uncovered. */
worstCheck: PermissionCheckResult | undefined;
}
/**
* Resolve one external path's policy on the `external_directory` surface.
*
* Emits an `access-path` {@link AccessIntent}; the resolver unwraps it via
* {@link AccessPath.matchValues} so a config pattern on either the typed or
* symlink-resolved alias applies (#418). This is the single source for the
* external-directory resolve that the two external-directory gates previously
* duplicated.
*/
export function resolveExternalDirectoryPolicy(
path: AccessPath,
resolver: ScopedPermissionResolver,
agentName: string | undefined,
): PermissionCheckResult {
return resolver.resolve({
kind: "access-path",
surface: "external_directory",
path,
agentName,
});
}
/**
* Resolve a set of external paths and select those not already allowed.
*
* Each path is resolved via {@link resolveExternalDirectoryPolicy}; entries
* whose state is not "allow" are collected (filtering on state, not source, so
* config-level allow rules suppress the prompt just as session-level allow
* rules do), and the most restrictive uncovered check is returned so a config
* "deny" is not downgraded to the catch-all "ask".
*/
export function selectUncoveredExternalPaths(
paths: readonly AccessPath[],
resolver: ScopedPermissionResolver,
agentName: string | undefined,
): UncoveredExternalPaths {
const uncovered: UncoveredExternalPath[] = [];
for (const path of paths) {
const check = resolveExternalDirectoryPolicy(path, resolver, agentName);
if (check.state !== "allow") {
uncovered.push({ path, check });
}
}
return {
uncovered,
worstCheck: pickMostRestrictive(uncovered.map(({ check }) => check)),
};
}
@@ -1,118 +0,0 @@
import { getToolInputPath } from "#src/access-intent/tool-input-path";
import type { PathNormalizer } from "#src/path-normalizer";
import type { ScopedPermissionResolver } from "#src/permission-resolver";
import { buildExternalDirectoryAskPayload } from "#src/presentation/path-ask-payload";
import { SessionApproval } from "#src/session-approval";
import { deriveApprovalPattern } from "#src/session-rules";
import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
import type { GateResult } from "./descriptor";
import { resolveExternalDirectoryPolicy } from "./external-directory-policy";
import { accessFactsFromPath } from "./helpers";
import type { ToolCallContext } from "./types";
/**
* Build a pure descriptor for the external-directory permission gate.
*
* Returns `null` when the gate does not apply (no CWD, tool is not
* path-bearing, or path is inside the working directory).
* Returns a `GateBypass` for Pi infrastructure reads.
* Returns a `GateDescriptor` for external paths needing a permission check.
*/
export function describeExternalDirectoryGate(
tcc: ToolCallContext,
infraDirs: string[],
resolver: ScopedPermissionResolver,
normalizer: PathNormalizer,
extractors?: ToolAccessExtractorLookup,
): GateResult {
const externalDirectoryPath = getToolInputPath(
tcc.toolName,
tcc.input,
extractors,
);
if (!externalDirectoryPath) return null;
if (!normalizer.isOutsideWorkingDirectory(externalDirectoryPath)) {
return null;
}
// The boundary decision (above) and the infrastructure-read containment
// check (below) use the canonical, symlink-resolved path; pattern matching
// uses the typed and resolved aliases (#418).
const accessPath = normalizer.forPath(externalDirectoryPath);
// ── Pi infrastructure read bypass ──────────────────────────────────────
if (normalizer.isInfrastructureRead(tcc.toolName, accessPath, infraDirs)) {
return {
action: "allow",
// Containment allowed this, not a rule the operator wrote.
decidedBy: { kind: "infrastructure_read" },
log: {
event: "permission_request.infrastructure_auto_allowed",
details: {
source: "tool_call",
toolCallId: tcc.toolCallId,
toolName: tcc.toolName,
agentName: tcc.agentName,
path: externalDirectoryPath,
},
},
decision: {
surface: tcc.toolName,
value: externalDirectoryPath,
result: "allow",
resolution: "infrastructure_auto_allowed",
origin: null,
agentName: tcc.agentName ?? null,
matchedPattern: null,
},
};
}
// ── Build descriptor for permission check ───────────────────────────────
const resolvedAlias = accessPath.resolvedAlias();
// The runner consumes this preCheck and skips its own resolve.
const preCheck = resolveExternalDirectoryPolicy(
accessPath,
resolver,
tcc.agentName ?? undefined,
);
const pattern = deriveApprovalPattern(accessPath.value());
const payload = buildExternalDirectoryAskPayload({
toolName: tcc.toolName,
pathValue: externalDirectoryPath,
resolvedPath: resolvedAlias,
cwd: tcc.cwd,
agentName: tcc.agentName,
matchedPattern: preCheck.matchedPattern,
});
return {
surface: "external_directory",
input: {},
preCheck,
payload,
sessionApproval: SessionApproval.single("external_directory", pattern),
promptDetails: {
source: "tool_call",
agentName: tcc.agentName,
toolCallId: tcc.toolCallId,
toolName: tcc.toolName,
path: externalDirectoryPath,
accessIntent: accessFactsFromPath("external_directory", accessPath),
},
logContext: {
source: "tool_call",
toolCallId: tcc.toolCallId,
toolName: tcc.toolName,
agentName: tcc.agentName,
path: externalDirectoryPath,
},
decision: {
surface: "external_directory",
value: externalDirectoryPath,
},
};
}
@@ -26,9 +26,8 @@ export function describePathGate(
const filePath = getToolInputPath(tcc.toolName, tcc.input, extractors); const filePath = getToolInputPath(tcc.toolName, tcc.input, extractors);
if (!filePath) return null; if (!filePath) return null;
// Emit an access-path intent so the resolver matches the lexical aliases // Emit an access-path intent so path policy matches both lexical aliases and
// *and* the canonical (symlink-resolved) form, the same set // the canonical (symlink-resolved) form.
// `external_directory` matches (#418, #486).
const accessPath = normalizer.forPath(filePath); const accessPath = normalizer.forPath(filePath);
const check = resolver.resolve({ const check = resolver.resolve({
kind: "access-path", kind: "access-path",
@@ -17,10 +17,8 @@ import {
} from "#src/tool-preview-formatter"; } from "#src/tool-preview-formatter";
import type { PermissionCheckResult } from "#src/types"; import type { PermissionCheckResult } from "#src/types";
import { resolveBashCommandCheck } from "./bash-command"; import { resolveBashCommandCheck } from "./bash-command";
import { describeBashExternalDirectoryGate } from "./bash-external-directory";
import { describeBashPathGate } from "./bash-path"; import { describeBashPathGate } from "./bash-path";
import type { GateResult } from "./descriptor"; import type { GateResult } from "./descriptor";
import { describeExternalDirectoryGate } from "./external-directory";
import { describePathGate } from "./path"; import { describePathGate } from "./path";
import type { GateRunner } from "./runner"; import type { GateRunner } from "./runner";
import { describeSkillReadGate } from "./skill-read"; import { describeSkillReadGate } from "./skill-read";
@@ -41,8 +39,6 @@ import type { GateOutcome, ToolCallContext } from "./types";
export interface ToolCallGateInputs { export interface ToolCallGateInputs {
/** Active skill prompt entries for the skill-read gate. */ /** Active skill prompt entries for the skill-read gate. */
getActiveSkillEntries(): SkillPromptEntry[]; getActiveSkillEntries(): SkillPromptEntry[];
/** Combined infrastructure read directories (static + config-derived). */
getInfrastructureReadDirs(): string[];
/** Resolved tool-preview formatter options from the current config. */ /** Resolved tool-preview formatter options from the current config. */
getToolPreviewLimits(): ToolPreviewFormatterOptions; getToolPreviewLimits(): ToolPreviewFormatterOptions;
/** The session's path normalizer (platform + cwd baked in). */ /** The session's path normalizer (platform + cwd baked in). */
@@ -62,8 +58,7 @@ export interface ToolCallGateInputs {
* `PermissionGateHandler`. `evaluate(tcc, runner)` encapsulates: * `PermissionGateHandler`. `evaluate(tcc, runner)` encapsulates:
* - bash-command extraction and single `BashProgram.parse` (#308) * - bash-command extraction and single `BashProgram.parse` (#308)
* - `ToolPreviewFormatter` construction from `getToolPreviewLimits()` * - `ToolPreviewFormatter` construction from `getToolPreviewLimits()`
* - infrastructure-dir list from `getInfrastructureReadDirs()` * - the configured gate producers in their prescribed order
* - all six gate producers in their prescribed order
* - the run loop that returns the first block outcome, or allow * - the run loop that returns the first block outcome, or allow
*/ */
export class ToolCallGatePipeline { export class ToolCallGatePipeline {
@@ -99,8 +94,6 @@ export class ToolCallGatePipeline {
this.customFormatters, this.customFormatters,
); );
const infraDirs = this.inputs.getInfrastructureReadDirs();
const gateProducers: Array<() => GateResult | Promise<GateResult>> = [ const gateProducers: Array<() => GateResult | Promise<GateResult>> = [
() => () =>
describeSkillReadGate(tcc, normalizer, () => describeSkillReadGate(tcc, normalizer, () =>
@@ -108,15 +101,6 @@ export class ToolCallGatePipeline {
), ),
() => () =>
describePathGate(tcc, this.resolver, normalizer, this.customExtractors), describePathGate(tcc, this.resolver, normalizer, this.customExtractors),
() =>
describeExternalDirectoryGate(
tcc,
infraDirs,
this.resolver,
normalizer,
this.customExtractors,
),
() => describeBashExternalDirectoryGate(tcc, bashProgram, this.resolver),
() => describeBashPathGate(tcc, bashProgram, this.resolver), () => describeBashPathGate(tcc, bashProgram, this.resolver),
() => { () => {
const { toolCheck, accessPath } = this.resolvePerToolCheck( const { toolCheck, accessPath } = this.resolvePerToolCheck(
@@ -90,7 +90,6 @@ export class HardGuardPolicyLoader implements PolicyLoader {
return DENY_ALL; return DENY_ALL;
} }
loadProjectConfig(): ScopeConfig { return EMPTY_SCOPE; }
loadAgentConfig(): ScopeConfig { return EMPTY_SCOPE; } loadAgentConfig(): ScopeConfig { return EMPTY_SCOPE; }
loadProjectAgentConfig(): ScopeConfig { return EMPTY_SCOPE; } loadProjectAgentConfig(): ScopeConfig { return EMPTY_SCOPE; }
getConfiguredMcpServerNames(): readonly string[] { return []; } getConfiguredMcpServerNames(): readonly string[] { return []; }
@@ -114,8 +113,6 @@ export class HardGuardPolicyLoader implements PolicyLoader {
return { return {
globalConfigPath: this.userPath, globalConfigPath: this.userPath,
globalConfigExists: existsSync(this.userPath), globalConfigExists: existsSync(this.userPath),
projectConfigPath: null,
projectConfigExists: false,
agentsDir: dirname(this.userPath), agentsDir: dirname(this.userPath),
agentsDirExists: existsSync(dirname(this.userPath)), agentsDirExists: existsSync(dirname(this.userPath)),
projectAgentsDir: null, projectAgentsDir: null,
+5 -14
View File
@@ -1,5 +1,5 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { getAgentDir, getPackageDir } from "@earendil-works/pi-coding-agent"; import { getAgentDir } from "@earendil-works/pi-coding-agent";
import { warmBashParser } from "./access-intent/bash/parser"; import { warmBashParser } from "./access-intent/bash/parser";
import { buildResolvedIntentFromMatchValues } from "./access-intent/input-normalizer"; import { buildResolvedIntentFromMatchValues } from "./access-intent/input-normalizer";
import { AuthorizerRegistry } from "./authority/authorizer-registry"; import { AuthorizerRegistry } from "./authority/authorizer-registry";
@@ -57,9 +57,7 @@ import { ToolInputFormatterRegistry } from "./tool-input-formatter-registry";
export default function piPermissionSystemExtension(pi: ExtensionAPI): void { export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
const agentDir = getAgentDir(); const agentDir = getAgentDir();
// getPackageDir() is Pi's own install dir; auto-allow it for read-only tools const paths = computeExtensionPaths(agentDir);
// so the agent can read Pi's bundled docs/examples regardless of layout.
const paths = computeExtensionPaths(agentDir, getPackageDir());
// The single process.platform read for the whole extension, resolved once // The single process.platform read for the whole extension, resolved once
// into the path-language flavor that every consumer shares (the session's // into the path-language flavor that every consumer shares (the session's
// PathNormalizer, rule evaluation, and subagent detection). Interior modules // PathNormalizer, rule evaluation, and subagent detection). Interior modules
@@ -273,8 +271,6 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
const toolRegistry = { const toolRegistry = {
getAll: () => pi.getAllTools(), getAll: () => pi.getAllTools(),
getActive: () => pi.getActiveTools(),
setActive: (names: string[]) => pi.setActiveTools(names),
}; };
const audit = new DecisionAudit(); const audit = new DecisionAudit();
@@ -285,14 +281,9 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
logger, logger,
audit, audit,
); );
const agentPrep = new AgentPrepHandler( const agentPrep = new AgentPrepHandler(session, resolver, () => {
session, void warmBashParser();
resolver, });
toolRegistry,
() => {
void warmBashParser();
},
);
const reporter = new GateDecisionReporter(logger, pi.events); const reporter = new GateDecisionReporter(logger, pi.events);
const gateRunner = new GateRunner( const gateRunner = new GateRunner(
+3 -62
View File
@@ -4,12 +4,9 @@ import type { PathFlavor } from "#src/path/path-flavor";
import { AccessPath } from "./access-intent/access-path"; import { AccessPath } from "./access-intent/access-path";
import { import {
canonicalNormalizePathForComparison,
normalizePathForComparison, normalizePathForComparison,
normalizePathPolicyLiteral, normalizePathPolicyLiteral,
} from "./access-intent/path-normalization"; } from "./access-intent/path-normalization";
import { isPathOutsideWorkingDirectory } from "./path/path-containment";
import { isPiInfrastructureRead } from "./path/pi-infrastructure-read";
/** /**
* The interpreted effect of a literal `cd` target on the effective base, under * The interpreted effect of a literal `cd` target on the effective base, under
@@ -42,15 +39,10 @@ export type BashCdTarget =
* `path-containment` / `path-normalization` / `AccessPath` primitives. * `path-containment` / `path-normalization` / `AccessPath` primitives.
*/ */
export class PathNormalizer { export class PathNormalizer {
/** Canonical form of the baked cwd, resolved once (the symlink target is stable per session). */
private readonly canonicalCwd: string;
constructor( constructor(
readonly flavor: PathFlavor, readonly flavor: PathFlavor,
private readonly cwd: string, private readonly cwd: string,
) { ) {}
this.canonicalCwd = canonicalNormalizePathForComparison(cwd, cwd, flavor);
}
/** Build an AccessPath for a token, resolved against `resolveBase` (default cwd). */ /** Build an AccessPath for a token, resolved against `resolveBase` (default cwd). */
forPath(pathValue: string, options?: { resolveBase?: string }): AccessPath { forPath(pathValue: string, options?: { resolveBase?: string }): AccessPath {
@@ -144,65 +136,14 @@ export class PathNormalizer {
return this.flavor.isWithin(pathValue, directory); return this.flavor.isWithin(pathValue, directory);
} }
/** Canonical (symlink-resolved) outside-cwd test against the baked cwd. */
isOutsideWorkingDirectory(pathValue: string): boolean {
const canonicalPath = canonicalNormalizePathForComparison(
pathValue,
this.cwd,
this.flavor,
);
return isPathOutsideWorkingDirectory(
canonicalPath,
this.canonicalCwd,
this.flavor,
);
}
/** /**
* Outside-cwd test for an already-canonical boundary value (from * Lexical comparison value resolved against the configured cwd.
* {@link AccessPath.boundaryValue}), against the baked cwd. * This does not classify project boundaries or touch the filesystem.
*
* Unlike {@link isOutsideWorkingDirectory}, it does not re-derive the
* canonical form — the caller passes a value the {@link AccessPath} already
* canonicalized, so a device's preserved `/dev/null` reaches the pure check's
* `isSafeSystemPath` exclusion intact.
*/
isBoundaryOutsideWorkingDirectory(canonicalPath: string): boolean {
return isPathOutsideWorkingDirectory(
canonicalPath,
this.canonicalCwd,
this.flavor,
);
}
/**
* Lexical (not symlink-resolved) comparison value, resolved against the baked
* cwd. Mirrors the as-typed absolute form used for skill-prompt matching;
* touches no filesystem, unlike {@link forPath}'s canonical alias.
*/ */
comparableValue(pathValue: string): string { comparableValue(pathValue: string): string {
return normalizePathForComparison(pathValue, this.cwd, this.flavor); return normalizePathForComparison(pathValue, this.cwd, this.flavor);
} }
/**
* Pi infrastructure-read containment for a read-only tool, decided against
* the canonical (symlink-resolved) path and the baked cwd/flavor. Takes the
* already-built {@link AccessPath} so the caller does not re-resolve it.
*/
isInfrastructureRead(
toolName: string,
accessPath: AccessPath,
infraDirs: readonly string[],
): boolean {
return isPiInfrastructureRead(
toolName,
accessPath.boundaryValue(),
infraDirs,
this.cwd,
this.flavor,
);
}
/** /**
* True when `absolutePath` names an existing filesystem entry. * True when `absolutePath` names an existing filesystem entry.
* *
@@ -1,59 +0,0 @@
import { join } from "node:path";
import { READ_ONLY_PATH_BEARING_TOOLS } from "#src/access-intent/path-surfaces";
import { expandHomePath } from "#src/expand-home";
import type { PathFlavor } from "#src/path/path-flavor";
import { wildcardMatch } from "#src/wildcard-matcher";
function containsGlobChars(value: string): boolean {
return value.includes("*") || value.includes("?");
}
/**
* Returns true if the given tool + normalized path combination qualifies for
* automatic allow as a Pi infrastructure read.
*
* A path qualifies when:
* 1. The tool is read-only (in READ_ONLY_PATH_BEARING_TOOLS).
* 2. The normalized path is within one of the provided `infrastructureDirs`
* OR within the project-local Pi package directories
* (`<cwd>/.pi/npm/` or `<cwd>/.pi/git/`).
*
* `infrastructureDirs` entries may be absolute paths or patterns containing
* `~`/`$HOME` (expanded at call time) or glob characters (`*`, `?`).
* Project-local paths are computed fresh from `cwd` on each call so they
* follow working-directory changes without a runtime rebuild.
*/
export function isPiInfrastructureRead(
toolName: string,
normalizedPath: string,
infrastructureDirs: readonly string[],
cwd: string,
flavor: PathFlavor,
): boolean {
if (!READ_ONLY_PATH_BEARING_TOOLS.has(toolName)) {
return false;
}
// On Windows the path value is canonicalized + lowercased; the flavor's match
// options fold case (and separators) so mixed-case infra dirs and glob
// patterns still match.
for (const dir of infrastructureDirs) {
if (containsGlobChars(dir)) {
if (wildcardMatch(dir, normalizedPath, flavor.matchOptions)) return true;
} else {
if (flavor.isWithin(normalizedPath, expandHomePath(dir))) return true;
}
}
// Project-local Pi packages — checked fresh every call so CWD changes work.
const projectNpmDir = join(cwd, ".pi", "npm");
const projectGitDir = join(cwd, ".pi", "git");
if (flavor.isWithin(normalizedPath, projectNpmDir)) {
return true;
}
if (flavor.isWithin(normalizedPath, projectGitDir)) {
return true;
}
return false;
}
@@ -100,8 +100,6 @@ function buildLabel(pattern: string, surface: string): string {
return `Yes, allow mcp tool "${pattern}" for this session`; return `Yes, allow mcp tool "${pattern}" for this session`;
case "skill": case "skill":
return `Yes, allow skill "${pattern}" for this session`; return `Yes, allow skill "${pattern}" for this session`;
case "external_directory":
return `Yes, allow access to external directory "${pattern}" for this session`;
case "path": case "path":
return `Yes, allow path "${pattern}" for this session`; return `Yes, allow path "${pattern}" for this session`;
default: default:
@@ -140,9 +138,6 @@ export function suggestSessionPattern(
case "skill": case "skill":
pattern = value; pattern = value;
break; break;
case "external_directory":
pattern = deriveApprovalPattern(value);
break;
case "path": case "path":
pattern = deriveApprovalPattern(value); pattern = deriveApprovalPattern(value);
break; break;
@@ -105,7 +105,6 @@ export type PermissionDecisionResolution =
| "policy_allow" | "policy_allow"
| "policy_deny" | "policy_deny"
| "session_approved" | "session_approved"
| "infrastructure_auto_allowed"
| "user_approved" | "user_approved"
| "user_approved_for_session" | "user_approved_for_session"
| "user_denied" | "user_denied"
@@ -120,7 +119,7 @@ export interface PermissionDecisionEvent {
* runs several gates and so raises several requests. * runs several gates and so raises several requests.
*/ */
requestId: string; requestId: string;
/** Permission surface: "bash", "read", "mcp", "skill", "external_directory", etc. */ /** Permission surface: "bash", "read", "mcp", "skill", "path", etc. */
surface: string; surface: string;
/** The value that was evaluated (command, tool name, skill name, path). */ /** The value that was evaluated (command, tool name, skill name, path). */
value: string; value: string;
+4 -12
View File
@@ -3,11 +3,7 @@ import type { ResolvedAccessIntent } from "./access-intent/access-intent";
import { normalizeInput } from "./access-intent/input-normalizer"; import { normalizeInput } from "./access-intent/input-normalizer";
import { PATH_SURFACES } from "./access-intent/path-surfaces"; import { PATH_SURFACES } from "./access-intent/path-surfaces";
import { classifyToolKind } from "./access-intent/tool-kind"; import { classifyToolKind } from "./access-intent/tool-kind";
import { import { getGlobalConfigPath, getProjectAgentsDir } from "./config-paths";
getGlobalConfigPath,
getProjectAgentsDir,
getProjectConfigPath,
} from "./config-paths";
import { normalizeFlatConfig } from "./normalize"; import { normalizeFlatConfig } from "./normalize";
import { type PathFlavor, posixPathFlavor } from "./path/path-flavor"; import { type PathFlavor, posixPathFlavor } from "./path/path-flavor";
import { import {
@@ -37,7 +33,7 @@ import type {
} from "./types"; } from "./types";
import { isPermissionState } from "./types"; import { isPermissionState } from "./types";
const SPECIAL_PERMISSION_KEYS = new Set(["external_directory", "path"]); const SPECIAL_PERMISSION_KEYS = new Set(["path"]);
/** Universal fallback when permission["*"] is absent from all scopes. */ /** Universal fallback when permission["*"] is absent from all scopes. */
const DEFAULT_UNIVERSAL_FALLBACK: PermissionState = "ask"; const DEFAULT_UNIVERSAL_FALLBACK: PermissionState = "ask";
@@ -176,7 +172,6 @@ export class PermissionManager implements ScopedPermissionManager {
} }
const globalConfig = this.loader.loadGlobalConfig(); const globalConfig = this.loader.loadGlobalConfig();
const projectConfig = this.loader.loadProjectConfig();
const agentConfig = this.loader.loadAgentConfig(agentName); const agentConfig = this.loader.loadAgentConfig(agentName);
const projectAgentConfig = this.loader.loadProjectAgentConfig(agentName); const projectAgentConfig = this.loader.loadProjectAgentConfig(agentName);
@@ -185,7 +180,6 @@ export class PermissionManager implements ScopedPermissionManager {
// (surface, pattern) entry. // (surface, pattern) entry.
const { mergedPermission, origins } = mergeScopesWithOrigins([ const { mergedPermission, origins } = mergeScopesWithOrigins([
["global", globalConfig], ["global", globalConfig],
["project", projectConfig],
["agent", agentConfig], ["agent", agentConfig],
["project-agent", projectAgentConfig], ["project-agent", projectAgentConfig],
]); ]);
@@ -227,7 +221,6 @@ export class PermissionManager implements ScopedPermissionManager {
// higher scope meant to tighten policy cannot silently fail open (#646). // higher scope meant to tighten policy cannot silently fail open (#646).
// Global is excluded — nothing more permissive is inherited when it fails. // Global is excluded — nothing more permissive is inherited when it fails.
const failClosedScopes: RuleOrigin[] = []; const failClosedScopes: RuleOrigin[] = [];
if (projectConfig.invalid === true) failClosedScopes.push("project");
if (agentConfig.invalid === true) failClosedScopes.push("agent"); if (agentConfig.invalid === true) failClosedScopes.push("agent");
if (projectAgentConfig.invalid === true) if (projectAgentConfig.invalid === true)
failClosedScopes.push("project-agent"); failClosedScopes.push("project-agent");
@@ -333,8 +326,8 @@ export class PermissionManager implements ScopedPermissionManager {
/** /**
* Evaluate a normalized surface/values triple and shape the result. * Evaluate a normalized surface/values triple and shape the result.
* *
* Path surfaces use {@link evaluateAnyValue} (last-match-wins across equivalent * Path surfaces use {@link evaluateAnyValue} (most-specific match across
* aliases); every other surface keeps {@link evaluateFirst}. Shared by the * equivalent aliases); every other surface keeps {@link evaluateFirst}. Shared by the
* `"tool"` and `"path-values"` branches of {@link PermissionManager.check}. * `"tool"` and `"path-values"` branches of {@link PermissionManager.check}.
*/ */
function buildCheckResult( function buildCheckResult(
@@ -383,7 +376,6 @@ function derivePolicyLoaderOptions(
return { return {
globalConfigPath: getGlobalConfigPath(agentDir), globalConfigPath: getGlobalConfigPath(agentDir),
agentsDir: join(agentDir, "agents"), agentsDir: join(agentDir, "agents"),
projectGlobalConfigPath: cwd ? getProjectConfigPath(cwd) : undefined,
projectAgentsDir: cwd ? getProjectAgentsDir(cwd) : undefined, projectAgentsDir: cwd ? getProjectAgentsDir(cwd) : undefined,
}; };
} }
+1 -14
View File
@@ -42,7 +42,7 @@ export class PermissionSession implements ToolCallGateInputs {
private pathNormalizer: PathNormalizer; private pathNormalizer: PathNormalizer;
constructor( constructor(
private readonly paths: ExtensionPaths, _paths: ExtensionPaths,
private readonly forwarding: ForwardingController, private readonly forwarding: ForwardingController,
private readonly permissionManager: ScopedPermissionManager, private readonly permissionManager: ScopedPermissionManager,
private readonly sessionRules: SessionRules, private readonly sessionRules: SessionRules,
@@ -199,19 +199,6 @@ export class PermissionSession implements ToolCallGateInputs {
return this.configStore.current(); return this.configStore.current();
} }
// ── Infrastructure paths ───────────────────────────────────────────────
/**
* Combined infrastructure read directories: static paths from
* `ExtensionPaths` plus config-derived paths.
*/
getInfrastructureReadDirs(): string[] {
return [
...this.paths.piInfrastructureDirs,
...(this.config.piInfrastructureReadPaths ?? []),
];
}
/** /**
* Resolved tool-preview formatter options from the current config. * Resolved tool-preview formatter options from the current config.
* *
+1 -38
View File
@@ -67,8 +67,6 @@ function getConfiguredMcpServerNamesFromPaths(
export interface ResolvedPolicyPaths { export interface ResolvedPolicyPaths {
globalConfigPath: string; globalConfigPath: string;
globalConfigExists: boolean; globalConfigExists: boolean;
projectConfigPath: string | null;
projectConfigExists: boolean;
agentsDir: string; agentsDir: string;
agentsDirExists: boolean; agentsDirExists: boolean;
projectAgentsDir: string | null; projectAgentsDir: string | null;
@@ -87,7 +85,6 @@ export interface ResolvedPolicyPaths {
*/ */
export interface PolicyLoader { export interface PolicyLoader {
loadGlobalConfig(): ScopeConfig; loadGlobalConfig(): ScopeConfig;
loadProjectConfig(): ScopeConfig;
loadAgentConfig(agentName?: string): ScopeConfig; loadAgentConfig(agentName?: string): ScopeConfig;
loadProjectAgentConfig(agentName?: string): ScopeConfig; loadProjectAgentConfig(agentName?: string): ScopeConfig;
getConfiguredMcpServerNames(): readonly string[]; getConfiguredMcpServerNames(): readonly string[];
@@ -130,7 +127,6 @@ type FileCacheEntry<TValue> = {
export interface PolicyLoaderOptions { export interface PolicyLoaderOptions {
globalConfigPath?: string; globalConfigPath?: string;
agentsDir?: string; agentsDir?: string;
projectGlobalConfigPath?: string;
projectAgentsDir?: string; projectAgentsDir?: string;
globalMcpConfigPath?: string; globalMcpConfigPath?: string;
mcpServerNames?: readonly string[]; mcpServerNames?: readonly string[];
@@ -147,13 +143,11 @@ export interface PolicyLoaderOptions {
export class FilePolicyLoader implements PolicyLoader { export class FilePolicyLoader implements PolicyLoader {
private readonly globalConfigPath: string; private readonly globalConfigPath: string;
private readonly agentsDir: string; private readonly agentsDir: string;
private readonly projectGlobalConfigPath: string | null;
private readonly projectAgentsDir: string | null; private readonly projectAgentsDir: string | null;
private readonly globalMcpConfigPath: string; private readonly globalMcpConfigPath: string;
private readonly configuredMcpServerNamesOverride: readonly string[] | null; private readonly configuredMcpServerNamesOverride: readonly string[] | null;
private globalConfigCache: FileCacheEntry<ScopeConfig> | null = null; private globalConfigCache: FileCacheEntry<ScopeConfig> | null = null;
private projectGlobalConfigCache: FileCacheEntry<ScopeConfig> | null = null;
private readonly agentConfigCache = new Map< private readonly agentConfigCache = new Map<
string, string,
FileCacheEntry<ScopeConfig> FileCacheEntry<ScopeConfig>
@@ -171,7 +165,6 @@ export class FilePolicyLoader implements PolicyLoader {
this.globalConfigPath = this.globalConfigPath =
options.globalConfigPath ?? defaultGlobalConfigPath(); options.globalConfigPath ?? defaultGlobalConfigPath();
this.agentsDir = options.agentsDir ?? defaultAgentsDir(); this.agentsDir = options.agentsDir ?? defaultAgentsDir();
this.projectGlobalConfigPath = options.projectGlobalConfigPath ?? null;
this.projectAgentsDir = options.projectAgentsDir ?? null; this.projectAgentsDir = options.projectAgentsDir ?? null;
this.globalMcpConfigPath = this.globalMcpConfigPath =
options.globalMcpConfigPath ?? defaultGlobalMcpConfigPath(); options.globalMcpConfigPath ?? defaultGlobalMcpConfigPath();
@@ -219,29 +212,6 @@ export class FilePolicyLoader implements PolicyLoader {
return value; return value;
} }
loadProjectConfig(): ScopeConfig {
if (!this.projectGlobalConfigPath) {
return {};
}
const stamp = getFileStamp(this.projectGlobalConfigPath);
if (this.projectGlobalConfigCache?.stamp === stamp) {
return this.projectGlobalConfigCache.value;
}
const { config, issues } = loadUnifiedConfig(this.projectGlobalConfigPath);
this.accumulateConfigIssues(issues);
// A present-but-rejected file yields issues (parse error or schema
// rejection); an absent file yields none. Fail closed on the former.
const value: ScopeConfig = {
permission: config.permission,
...(issues.length > 0 ? { invalid: true } : {}),
};
this.projectGlobalConfigCache = { stamp, value };
return value;
}
private loadScopeConfigFrom( private loadScopeConfigFrom(
dir: string | null, dir: string | null,
@@ -335,15 +305,12 @@ export class FilePolicyLoader implements PolicyLoader {
const agentStamp = agentName const agentStamp = agentName
? getFileStamp(join(this.agentsDir, `${agentName}.md`)) ? getFileStamp(join(this.agentsDir, `${agentName}.md`))
: "missing"; : "missing";
const projectStamp = this.projectGlobalConfigPath
? getFileStamp(this.projectGlobalConfigPath)
: "none";
const projectAgentStamp = const projectAgentStamp =
this.projectAgentsDir && agentName this.projectAgentsDir && agentName
? getFileStamp(join(this.projectAgentsDir, `${agentName}.md`)) ? getFileStamp(join(this.projectAgentsDir, `${agentName}.md`))
: "none"; : "none";
return `${getFileStamp(this.globalConfigPath)}|${projectStamp}|${agentStamp}|${projectAgentStamp}`; return `${getFileStamp(this.globalConfigPath)}|${agentStamp}|${projectAgentStamp}`;
} }
// ── Resolved paths ──────────────────────────────────────────────────── // ── Resolved paths ────────────────────────────────────────────────────
@@ -352,10 +319,6 @@ export class FilePolicyLoader implements PolicyLoader {
return { return {
globalConfigPath: this.globalConfigPath, globalConfigPath: this.globalConfigPath,
globalConfigExists: existsSync(this.globalConfigPath), globalConfigExists: existsSync(this.globalConfigPath),
projectConfigPath: this.projectGlobalConfigPath,
projectConfigExists: this.projectGlobalConfigPath
? existsSync(this.projectGlobalConfigPath)
: false,
agentsDir: this.agentsDir, agentsDir: this.agentsDir,
agentsDirExists: existsSync(this.agentsDir), agentsDirExists: existsSync(this.agentsDir),
projectAgentsDir: this.projectAgentsDir, projectAgentsDir: this.projectAgentsDir,
@@ -1,7 +1,4 @@
import { import type { PromptPayload } from "#src/presentation/prompt-payload";
allEvidence,
type PromptPayload,
} from "#src/presentation/prompt-payload";
import type { BashCommandContext } from "#src/types"; import type { BashCommandContext } from "#src/types";
/** /**
@@ -24,37 +21,24 @@ import type { BashCommandContext } from "#src/types";
* operator is ruling on. * operator is ruling on.
*/ */
export function flaggedElements(payload: PromptPayload): readonly string[] { export function flaggedElements(payload: PromptPayload): readonly string[] {
if (payload.kind === "bash_external_directory") {
return allEvidence(payload, "external path").map((entry) => entry.text);
}
return payload.request.value === "" ? [] : [payload.request.value]; return payload.request.value === "" ? [] : [payload.request.value];
} }
/** /** What {@link flaggedElements} returns is called. */
* What {@link flaggedElements} returns is called.
*
* Differs from {@link valueLabel} for exactly one shape: a bash ask that
* escaped the working directory flags paths while its value is the command,
* so the two nouns are for two different things.
*/
export function flaggedElementLabel(payload: PromptPayload): string { export function flaggedElementLabel(payload: PromptPayload): string {
return payload.kind === "bash_external_directory" return valueLabel(payload);
? "path"
: valueLabel(payload);
} }
/** What the decision-relevant value is called, per ask shape. */ /** What the decision-relevant value is called, per ask shape. */
export function valueLabel(payload: PromptPayload): string { export function valueLabel(payload: PromptPayload): string {
switch (payload.kind) { switch (payload.kind) {
case "bash": case "bash":
case "bash_external_directory":
return "command"; return "command";
case "mcp": case "mcp":
return "target"; return "target";
case "tool": case "tool":
return "tool"; return "tool";
case "path": case "path":
case "external_directory":
return "path"; return "path";
case "skill": case "skill":
case "skill_read": case "skill_read":
@@ -1,101 +1,20 @@
import type { import type { PromptPayload } from "#src/presentation/prompt-payload";
PromptEvidence,
PromptPayload,
} from "#src/presentation/prompt-payload";
import { localRequester } from "#src/presentation/prompt-payload"; import { localRequester } from "#src/presentation/prompt-payload";
/** A displayed external path paired with its resolved target, when distinct. */
export interface ExternalPathDisclosure {
/** The path as displayed (typed for tools, lexical-absolute for bash). */
path: string;
/** The canonical symlink-resolved target; present only when it differs. */
resolvedPath?: string;
}
/** The facts a path-shaped gate holds when it raises an ask. */
interface PathAskFacts { interface PathAskFacts {
toolName: string; toolName: string;
/** The path as the caller typed it — what the user recognizes. */
pathValue: string; pathValue: string;
agentName: string | null; agentName: string | null;
matchedPattern?: string; matchedPattern?: string;
} }
/** A tool ask gated by an explicit `path` rule. */ /** Build the prompt payload for an explicit cross-cutting `path` ask. */
export function buildPathAskPayload(facts: PathAskFacts): PromptPayload { export function buildPathAskPayload(facts: PathAskFacts): PromptPayload {
return pathPayload("path", "path", facts, []);
}
/** The facts the external-directory gate adds: the boundary and the alias. */
interface ExternalDirectoryAskFacts extends PathAskFacts {
/** The canonical location, when it names somewhere other than the typed path. */
resolvedPath?: string;
/** The working directory the path escapes. */
cwd: string;
}
/** A tool ask for a path outside the working directory. */
export function buildExternalDirectoryAskPayload(
facts: ExternalDirectoryAskFacts,
): PromptPayload {
return pathPayload("external_directory", "external_directory", facts, [
...resolvedAliasEvidence(facts.resolvedPath),
workingDirectoryEvidence(facts.cwd),
]);
}
/** The facts the bash external-directory gate holds: one command, many paths. */
interface BashExternalDirectoryAskFacts {
command: string;
/** Every uncovered path the command references, with its canonical alias. */
externalPaths: readonly ExternalPathDisclosure[];
cwd: string;
agentName: string | null;
toolName: string;
matchedPattern?: string;
}
/** A bash ask whose command references paths outside the working directory. */
export function buildBashExternalDirectoryAskPayload(
facts: BashExternalDirectoryAskFacts,
): PromptPayload {
return { return {
kind: "bash_external_directory", kind: "path",
request: { request: {
requester: localRequester(facts.agentName), requester: localRequester(facts.agentName),
surface: "external_directory", surface: "path",
toolName: facts.toolName,
invokedToolName: null,
value: facts.command,
matchedPattern: facts.matchedPattern ?? null,
commandContext: null,
executedUnit: null,
},
evidence: [
workingDirectoryEvidence(facts.cwd),
...facts.externalPaths.map(externalPathEvidence),
],
annotations: [],
};
}
// ── Shared shape ────────────────────────────────────────────────────────────
/**
* The payload common to the single-path asks: the typed path is the
* decision-relevant value, and the gate surface distinguishes them.
*/
function pathPayload(
kind: "path" | "external_directory",
surface: string,
facts: PathAskFacts,
evidence: PromptEvidence[],
): PromptPayload {
return {
kind,
request: {
requester: localRequester(facts.agentName),
surface,
toolName: facts.toolName, toolName: facts.toolName,
invokedToolName: null, invokedToolName: null,
value: facts.pathValue, value: facts.pathValue,
@@ -103,33 +22,7 @@ function pathPayload(
commandContext: null, commandContext: null,
executedUnit: null, executedUnit: null,
}, },
evidence, evidence: [],
annotations: [], annotations: [],
}; };
} }
/**
* The canonical location, as its own entry rather than folded into the value:
* the user decides on the path they typed, and the alias is what that path
* turns out to name.
*/
function resolvedAliasEvidence(resolvedPath?: string): PromptEvidence[] {
return resolvedPath === undefined
? []
: [{ label: "resolves to", text: resolvedPath, detail: null }];
}
function workingDirectoryEvidence(cwd: string): PromptEvidence {
return { label: "working directory", text: cwd, detail: null };
}
/**
* One escaping path. The canonical alias rides as the entry's `detail` so a
* render cannot separate a path from what it resolves to.
*/
function externalPathEvidence({
path,
resolvedPath,
}: ExternalPathDisclosure): PromptEvidence {
return { label: "external path", text: path, detail: resolvedPath ?? null };
}
@@ -20,9 +20,8 @@ export interface PromptPayload {
/** /**
* Which ask this payload describes — the renderers' dispatch discriminant. * Which ask this payload describes — the renderers' dispatch discriminant.
* *
* Present because the ask shapes are not separable by surface alone: a tool * Present because the `path` gate and the per-tool gate differ only in
* external-directory ask and a bash one share the `external_directory` surface, * wording. It gives
* and the `path` gate and the per-tool gate differ only in wording. It gives
* every renderer an exhaustive switch rather than a set of string comparisons a * every renderer an exhaustive switch rather than a set of string comparisons a
* new variant sails past — which is what let the parallel denial-context union * new variant sails past — which is what let the parallel denial-context union
* ADR 0011 §7 described dissolve into this one (#746). * ADR 0011 §7 described dissolve into this one (#746).
@@ -32,8 +31,6 @@ export type PromptPayloadKind =
| "mcp" | "mcp"
| "tool" | "tool"
| "path" | "path"
| "external_directory"
| "bash_external_directory"
| "skill" | "skill"
| "skill_read" | "skill_read"
| "forwarded"; | "forwarded";
@@ -126,8 +123,6 @@ const PROMPT_PAYLOAD_KINDS = [
"mcp", "mcp",
"tool", "tool",
"path", "path",
"external_directory",
"bash_external_directory",
"skill", "skill",
"skill_read", "skill_read",
"forwarded", "forwarded",
+70 -14
View File
@@ -28,7 +28,7 @@ export type RuleOrigin =
/** A single permission rule — the atomic unit of policy. */ /** A single permission rule — the atomic unit of policy. */
export interface Rule { export interface Rule {
/** The permission surface: "bash", "read", "mcp", "skill", "external_directory", etc. */ /** The permission surface: "bash", "read", "mcp", "skill", "path", etc. */
surface: string; surface: string;
/** The match pattern: a command glob, tool name, skill name, or "*". */ /** The match pattern: a command glob, tool name, skill name, or "*". */
pattern: string; pattern: string;
@@ -45,7 +45,7 @@ export interface Rule {
origin: RuleOrigin; origin: RuleOrigin;
} }
/** An ordered list of rules. Later rules take priority (last-match-wins). */ /** Rules evaluated by pattern specificity; declaration order breaks exact ties. */
export type Ruleset = Rule[]; export type Ruleset = Rule[];
/** /**
@@ -91,8 +91,10 @@ export function floorAllowsToAsk(rules: Ruleset): Ruleset {
/** /**
* Pure permission evaluation. * Pure permission evaluation.
* *
* Returns the last rule in `rules` whose surface and pattern both * Among matching rules, the most specific pattern wins. Specificity is ordered
* wildcard-match the supplied values (last-match-wins). * by exact patterns first, then more literal characters, then fewer wildcards,
* then longer patterns. Declaration order is only the final tie-breaker, where
* the later rule wins.
* *
* When no rule matches, returns a synthetic rule with `defaultAction` * When no rule matches, returns a synthetic rule with `defaultAction`
* (defaults to "ask" — least privilege). * (defaults to "ask" — least privilege).
@@ -104,7 +106,12 @@ export function evaluate(
flavor: PathFlavor, flavor: PathFlavor,
defaultAction?: PermissionState, defaultAction?: PermissionState,
): Rule { ): Rule {
const rule = rules.findLast((r) => ruleMatches(r, surface, pattern, flavor)); const rule = findMostSpecificMatchingRule(
surface,
[pattern],
rules,
flavor,
);
if (rule !== undefined) return rule; if (rule !== undefined) return rule;
return { return {
surface, surface,
@@ -139,10 +146,62 @@ function ruleMatches(
); );
} }
interface PatternSpecificity {
exact: number;
literalCharacters: number;
wildcardCount: number;
length: number;
}
function patternSpecificity(pattern: string): PatternSpecificity {
const wildcardCount = [...pattern].filter((character) => character === "*").length;
return {
exact: wildcardCount === 0 ? 1 : 0,
literalCharacters: pattern.length - wildcardCount,
wildcardCount,
length: pattern.length,
};
}
/** Positive means `candidate` is more specific than `current`. */
function comparePatternSpecificity(candidate: string, current: string): number {
const left = patternSpecificity(candidate);
const right = patternSpecificity(current);
if (left.exact !== right.exact) return left.exact - right.exact;
if (left.literalCharacters !== right.literalCharacters) {
return left.literalCharacters - right.literalCharacters;
}
if (left.wildcardCount !== right.wildcardCount) {
return right.wildcardCount - left.wildcardCount;
}
return left.length - right.length;
}
function findMostSpecificMatchingRule(
surface: string,
values: readonly string[],
rules: Ruleset,
flavor: PathFlavor,
): Rule | undefined {
let best: Rule | undefined;
for (const rule of rules) {
if (!values.some((value) => ruleMatches(rule, surface, value, flavor))) {
continue;
}
if (
best === undefined ||
comparePatternSpecificity(rule.pattern, best.pattern) >= 0
) {
best = rule;
}
}
return best;
}
/** /**
* Evaluate a surface against an ordered list of candidate values, stopping at * Evaluate a surface against an ordered list of candidate values, stopping at
* the first candidate that matches a non-default rule (last-match-wins within * the first candidate that matches a non-default rule. Pattern specificity is
* each candidate, first-non-default-wins across candidates). * resolved independently for each candidate.
* *
* Used by MCP (multi-candidate target list) and, uniformly, by all other * Used by MCP (multi-candidate target list) and, uniformly, by all other
* surfaces (single-element candidate list). * surfaces (single-element candidate list).
@@ -203,10 +262,9 @@ export function evaluateFirst(
/** /**
* Evaluate equivalent lookup values as aliases of the same path. * Evaluate equivalent lookup values as aliases of the same path.
* *
* Unlike `evaluateFirst()`, this preserves rule ordering across aliases: the * Pattern specificity is resolved across every alias together, so a broad
* last rule that matches any alias wins. This lets absolute allowlists and * catch-all on one alias cannot mask a more specific rule on another alias.
* legacy relative rules coexist without a catch-all match on the first alias * Declaration order remains the final tie-breaker.
* masking a later, more specific rule on another alias.
*/ */
export function evaluateAnyValue( export function evaluateAnyValue(
surface: string, surface: string,
@@ -215,9 +273,7 @@ export function evaluateAnyValue(
flavor: PathFlavor, flavor: PathFlavor,
): { rule: Rule; value: string } { ): { rule: Rule; value: string } {
const fallbackValue = values[0] ?? "*"; const fallbackValue = values[0] ?? "*";
const rule = rules.findLast((r) => const rule = findMostSpecificMatchingRule(surface, values, rules, flavor);
values.some((value) => ruleMatches(r, surface, value, flavor)),
);
if (rule !== undefined) { if (rule !== undefined) {
return { return {
rule, rule,
+8 -8
View File
@@ -75,8 +75,8 @@ export interface PermissionQuery {
/** /**
* Query the permission policy for a surface and value. * Query the permission policy for a surface and value.
* *
* @param surface - Permission surface: "bash", "read", "mcp", "skill", * @param surface - Permission surface such as "bash", "read", "mcp",
* "external_directory", etc. * "skill", or "path".
* @param value - The value to evaluate: command string, tool name, skill * @param value - The value to evaluate: command string, tool name, skill
* name, or path. Omit or pass `undefined` for a * name, or path. Omit or pass `undefined` for a
* surface-level query. * surface-level query.
@@ -134,7 +134,7 @@ export interface PermissionsService extends PermissionQuery {
* Register a custom access-intent extractor for a specific tool name. * Register a custom access-intent extractor for a specific tool name.
* *
* The extractor declares the filesystem path a tool will access so the * The extractor declares the filesystem path a tool will access so the
* cross-cutting `path` and `external_directory` gates can see it. Use it for * cross-cutting `path` gate can see it. Use it for
* tools whose path lives under a non-standard key — built-in file tools and * tools whose path lives under a non-standard key — built-in file tools and
* any tool exposing `input.path` (plus MCP via `input.arguments.path`) are * any tool exposing `input.path` (plus MCP via `input.arguments.path`) are
* already covered by convention without registration. * already covered by convention without registration.
@@ -163,11 +163,11 @@ export interface PermissionsService extends PermissionQuery {
* handler so registration is robust to load order and survives `/reload`. * handler so registration is robust to load order and survives `/reload`.
* *
* Registration alone grants **no authority**: the link decides nothing until * Registration alone grants **no authority**: the link decides nothing until
* the operator names it in the `authorizerChain` config (opt-in activation), * the operator names it in `authorizerChain` (opt-in activation). Once named,
* and the chain owner caps every verdict with the bounded-delegation * its `allow` or `deny` verdict is final for any ordinary-policy `ask`, including
* checkpoint (an `allow` on an excluded surface downgrades to `defer`). Only * `path`; `defer` advances to the next link or terminal. Only one link may be
* one link may be registered per name — a second call for the same name * registered per name — a second call throws. The returned disposer unregisters
* throws. The returned disposer unregisters the link. * the link.
* *
* @param name - Operator-facing link name referenced from `authorizerChain`. * @param name - Operator-facing link name referenced from `authorizerChain`.
* @param authorize - The link's decision callback * @param authorize - The link's decision callback
+3 -3
View File
@@ -7,9 +7,9 @@ import type { PermissionState } from "./types";
* Produces one rule: * Produces one rule:
* `{ surface: "*", pattern: "*", action: universalDefault, layer: "default" }` * `{ surface: "*", pattern: "*", action: universalDefault, layer: "default" }`
* *
* Per-surface catch-alls (`bash["*"]`, `mcp["*"]`, etc.) are expressed as * Per-surface catch-alls (`bash["*"]`, `mcp["*"]`, etc.) are regular config
* regular config rules from `normalizeFlatConfig()` and sit at higher indices * rules. Their exact surface is selected before the universal fallback when the
* in the composed array, so they override this default via last-match-wins. * value-pattern specificity is tied.
*/ */
export function synthesizeDefaults( export function synthesizeDefaults(
universalDefault: PermissionState, universalDefault: PermissionState,
@@ -1,274 +0,0 @@
export interface SanitizeSystemPromptResult {
prompt: string;
removed: boolean;
}
type LineSection = {
start: number;
end: number;
};
type GuidelineRule = {
matches: (guideline: string) => boolean;
shouldKeep: (allowedTools: ReadonlySet<string>) => boolean;
};
const AVAILABLE_TOOLS_SECTION_HEADER = "Available tools:";
const GUIDELINES_SECTION_HEADER = "Guidelines:";
const TOOL_GUIDELINE_RULES: readonly GuidelineRule[] = [
{
matches: (guideline) =>
guideline === "use bash for file operations like ls, rg, find",
shouldKeep: (allowedTools) => allowedTools.has("bash"),
},
{
matches: (guideline) =>
guideline ===
"prefer grep/find/ls tools over bash for file exploration (faster, respects .gitignore)",
shouldKeep: (allowedTools) =>
allowedTools.has("bash") &&
(allowedTools.has("grep") ||
allowedTools.has("find") ||
allowedTools.has("ls")),
},
{
matches: (guideline) =>
guideline ===
"use read to examine files before editing. you must use this tool instead of cat or sed." ||
guideline === "use read to examine files instead of cat or sed.",
shouldKeep: (allowedTools) => allowedTools.has("read"),
},
{
matches: (guideline) =>
guideline ===
"use edit for precise changes (old text must match exactly)",
shouldKeep: (allowedTools) => allowedTools.has("edit"),
},
{
matches: (guideline) =>
guideline === "use write only for new files or complete rewrites",
shouldKeep: (allowedTools) => allowedTools.has("write"),
},
{
matches: (guideline) =>
guideline ===
"when summarizing your actions, output plain text directly - do not use cat or bash to display what you did",
shouldKeep: (allowedTools) =>
allowedTools.has("edit") || allowedTools.has("write"),
},
{
matches: (guideline) =>
guideline ===
"use task when work should be delegated to one or more specialized agents instead of handled entirely in the current session.",
shouldKeep: (allowedTools) => allowedTools.has("task"),
},
{
matches: (guideline) =>
guideline ===
"use mcp for mcp discovery first: search by capability, describe one exact tool name, then call it.",
shouldKeep: (allowedTools) => allowedTools.has("mcp"),
},
];
function normalizePrompt(prompt: string): string {
return (prompt || "").replace(/\r\n/g, "\n");
}
function collapseExtraBlankLines(text: string): string {
return text.replace(/\n{3,}/g, "\n\n").trimEnd();
}
function normalizeGuidelineText(line: string): string {
return line
.trim()
.replace(/^[-*]\s+/, "")
.replace(/\s+/g, " ")
.toLowerCase();
}
function isTopLevelSectionHeader(line: string): boolean {
const trimmed = line.trim();
return (
trimmed.length > 0 && trimmed.endsWith(":") && !trimmed.startsWith("-")
);
}
function isSectionBodyLine(line: string): boolean {
const trimmed = line.trim();
if (trimmed.length === 0) return true; // blank line
if (trimmed.startsWith("- ")) return true; // bullet
if (line !== line.trimStart()) return true; // indented
return false;
}
function findSection(
lines: readonly string[],
header: string,
): LineSection | null {
const start = lines.findIndex((line) => line.trim() === header);
if (start === -1) {
return null;
}
// If a subsequent recognised section header exists, use it as the boundary.
// This preserves the original behaviour for the common case where sections
// are adjacent (e.g. "Available tools:" followed by "Guidelines:") and
// ensures any prose continuation between the two headers is also removed.
for (let index = start + 1; index < lines.length; index += 1) {
if (isTopLevelSectionHeader(lines[index])) {
return { start, end: index };
}
}
// No subsequent section header — stop at the first non-body line so that
// content after the section (e.g. custom user notes) is not silently deleted.
let end = start + 1;
for (let index = start + 1; index < lines.length; index += 1) {
if (!isSectionBodyLine(lines[index])) {
end = index;
break;
}
end = index + 1;
}
return { start, end };
}
/**
* Tool name from an `Available tools:` bullet (`- read: …` -> `read`), or
* `null` for non-tool lines (blank lines, boilerplate prose). Matches the
* first token after the bullet marker, with or without a trailing colon.
*/
function extractToolBulletName(line: string): string | null {
const match = /^\s*-\s+([A-Za-z0-9_-]+)/.exec(line);
return match ? match[1] : null;
}
/**
* Narrow the `Available tools:` section to the allowed tools: keep allowed-tool
* bullet lines and any non-tool prose, drop denied/inactive bullet lines. When
* no tool bullet survives, remove the section header too. This mirrors what Pi
* itself renders for the active tool set, so the result is byte-stable across
* turns regardless of whether the input still carries the full default listing.
*/
function narrowAvailableToolsSection(
lines: readonly string[],
allowedTools: ReadonlySet<string>,
): { lines: string[]; removed: boolean } {
const section = findSection(lines, AVAILABLE_TOOLS_SECTION_HEADER);
if (!section) {
return { lines: [...lines], removed: false };
}
const before = lines.slice(0, section.start);
const header = lines[section.start];
const body = lines.slice(section.start + 1, section.end);
const after = lines.slice(section.end);
const filteredBody = body.filter((line) => {
const toolName = extractToolBulletName(line);
if (toolName === null) {
return true; // keep blank lines and non-tool boilerplate
}
return allowedTools.has(toolName);
});
const removed = filteredBody.length !== body.length;
if (!removed) {
return { lines: [...lines], removed: false };
}
const hasToolBullet = filteredBody.some(
(line) => extractToolBulletName(line) !== null,
);
if (!hasToolBullet) {
return { lines: [...before, ...after], removed: true };
}
return {
lines: [...before, header, ...filteredBody, ...after],
removed: true,
};
}
function shouldKeepGuideline(
line: string,
allowedTools: ReadonlySet<string>,
): boolean {
const normalized = normalizeGuidelineText(line);
for (const rule of TOOL_GUIDELINE_RULES) {
if (rule.matches(normalized)) {
return rule.shouldKeep(allowedTools);
}
}
return true;
}
function sanitizeGuidelinesSection(
lines: readonly string[],
allowedTools: ReadonlySet<string>,
): { lines: string[]; removed: boolean } {
const section = findSection(lines, GUIDELINES_SECTION_HEADER);
if (!section) {
return { lines: [...lines], removed: false };
}
const before = lines.slice(0, section.start + 1);
const after = lines.slice(section.end);
const body = lines.slice(section.start + 1, section.end);
const filteredBody = body.filter((line) => {
const trimmed = line.trim();
if (!trimmed.startsWith("- ")) {
return true;
}
return shouldKeepGuideline(line, allowedTools);
});
const removed = filteredBody.length !== body.length;
if (!removed) {
return { lines: [...lines], removed: false };
}
const hasBullet = filteredBody.some((line) => line.trim().startsWith("- "));
if (!hasBullet) {
return {
lines: [...lines.slice(0, section.start), ...after],
removed: true,
};
}
return {
lines: [...before, ...filteredBody, ...after],
removed: true,
};
}
export function sanitizeAvailableToolsSection(
systemPrompt: string,
allowedToolNames: readonly string[],
): SanitizeSystemPromptResult {
const allowedTools = new Set(
allowedToolNames.map((toolName) => toolName.trim()).filter(Boolean),
);
const normalizedLines = normalizePrompt(systemPrompt).split("\n");
const narrowedToolsSection = narrowAvailableToolsSection(
normalizedLines,
allowedTools,
);
const sanitizedGuidelines = sanitizeGuidelinesSection(
narrowedToolsSection.lines,
allowedTools,
);
const removed = narrowedToolsSection.removed || sanitizedGuidelines.removed;
return {
prompt: removed
? collapseExtraBlankLines(sanitizedGuidelines.lines.join("\n"))
: systemPrompt,
removed,
};
}
@@ -3,7 +3,7 @@
* *
* Lets sibling extensions declare the filesystem path a tool will access when * Lets sibling extensions declare the filesystem path a tool will access when
* the tool's input shape is not the default `input.path` convention, so the * the tool's input shape is not the default `input.path` convention, so the
* cross-cutting `path` and `external_directory` gates can see it. * cross-cutting `path` gate can see it.
* One extractor per tool name; duplicate registration throws. * One extractor per tool name; duplicate registration throws.
*/ */
+1 -4
View File
@@ -1,12 +1,9 @@
import { getNonEmptyString, toRecord } from "./value-guards"; import { getNonEmptyString, toRecord } from "./value-guards";
/** Narrow interface for the Pi tool API subset used by handler classes. */ /** Narrow interface for the read-only Pi tool registry used by the gate. */
export interface ToolRegistry { export interface ToolRegistry {
/** All registered tools (`pi.getAllTools()` — `ToolInfo[]`); kept defensively wide. */ /** All registered tools (`pi.getAllTools()` — `ToolInfo[]`); kept defensively wide. */
getAll(): unknown[]; getAll(): unknown[];
/** Currently active tool names (`pi.getActiveTools()`). */
getActive(): string[];
setActive(names: string[]): void;
} }
export type ToolRegistrationCheckResult = export type ToolRegistrationCheckResult =
File diff suppressed because it is too large Load Diff
@@ -47,16 +47,6 @@ describe("normalizeInput — non-MCP surfaces", () => {
expect(result.resultExtras).toEqual({}); expect(result.resultExtras).toEqual({});
}); });
it("external_directory surface ignores input.path and returns ['*']", () => {
const result = normalizeInput(
"external_directory",
{ path: "/other/project" },
[],
);
expect(result.surface).toBe("external_directory");
expect(result.values).toEqual(["*"]);
expect(result.resultExtras).toEqual({});
});
it("read surface ignores input.path and returns ['*']", () => { it("read surface ignores input.path and returns ['*']", () => {
const result = normalizeInput("read", { path: ".env" }, []); const result = normalizeInput("read", { path: ".env" }, []);
@@ -68,7 +58,6 @@ describe("normalizeInput — non-MCP surfaces", () => {
it("missing path also returns ['*'] (unchanged fallback)", () => { it("missing path also returns ['*'] (unchanged fallback)", () => {
for (const surface of [ for (const surface of [
"path", "path",
"external_directory",
"read", "read",
"write", "write",
"edit", "edit",
@@ -249,19 +238,6 @@ describe("buildAccessIntentForSurface", () => {
} }
}); });
it("emits an access-path intent for the external_directory surface", () => {
const intent = buildAccessIntentForSurface(
"external_directory",
"/outside/dir",
normalizer,
undefined,
);
expect(intent.kind).toBe("access-path");
if (intent.kind === "access-path") {
expect(intent.surface).toBe("external_directory");
expect(intent.path.value()).toBe("/outside/dir");
}
});
it("emits an access-path intent for a path-bearing tool surface (read)", () => { it("emits an access-path intent for a path-bearing tool surface (read)", () => {
const intent = buildAccessIntentForSurface( const intent = buildAccessIntentForSurface(
@@ -315,15 +291,6 @@ describe("buildAccessIntentForSurface", () => {
} }
}); });
it("falls back to a tool intent for a whitespace-only path value", () => {
const intent = buildAccessIntentForSurface(
"external_directory",
" ",
normalizer,
undefined,
);
expect(intent.kind).toBe("tool");
});
}); });
describe("buildResolvedIntentFromMatchValues", () => { describe("buildResolvedIntentFromMatchValues", () => {
@@ -348,19 +315,6 @@ describe("buildResolvedIntentFromMatchValues", () => {
}); });
}); });
it("emits a path-values intent for the external_directory surface", () => {
const intent = buildResolvedIntentFromMatchValues(
"external_directory",
["/tmp/x", "/real/tmp/x"],
"Explore",
);
expect(intent).toEqual({
kind: "path-values",
surface: "external_directory",
values: ["/tmp/x", "/real/tmp/x"],
agentName: "Explore",
});
});
it("emits a tool intent from the single portable value for a non-path surface (bash)", () => { it("emits a tool intent from the single portable value for a non-path surface (bash)", () => {
const intent = buildResolvedIntentFromMatchValues( const intent = buildResolvedIntentFromMatchValues(
@@ -3,7 +3,6 @@ import { describe, expect, test } from "vitest";
import { import {
PATH_BEARING_TOOLS, PATH_BEARING_TOOLS,
PATH_SURFACES, PATH_SURFACES,
READ_ONLY_PATH_BEARING_TOOLS,
} from "#src/access-intent/path-surfaces"; } from "#src/access-intent/path-surfaces";
describe("PATH_BEARING_TOOLS", () => { describe("PATH_BEARING_TOOLS", () => {
@@ -19,21 +18,9 @@ describe("PATH_BEARING_TOOLS", () => {
}); });
}); });
describe("READ_ONLY_PATH_BEARING_TOOLS", () => {
test("contains read, find, grep, ls", () => {
for (const tool of ["read", "find", "grep", "ls"]) {
expect(READ_ONLY_PATH_BEARING_TOOLS.has(tool)).toBe(true);
}
});
test("does not contain write or edit", () => {
expect(READ_ONLY_PATH_BEARING_TOOLS.has("write")).toBe(false);
expect(READ_ONLY_PATH_BEARING_TOOLS.has("edit")).toBe(false);
});
});
describe("PATH_SURFACES", () => { describe("PATH_SURFACES", () => {
test("contains the path-bearing tools plus the cross-cutting gates", () => { test("contains the path-bearing tools plus the path gate", () => {
for (const surface of [ for (const surface of [
"read", "read",
"write", "write",
@@ -41,7 +28,6 @@ describe("PATH_SURFACES", () => {
"find", "find",
"grep", "grep",
"ls", "ls",
"external_directory",
"path", "path",
]) { ]) {
expect(PATH_SURFACES.has(surface)).toBe(true); expect(PATH_SURFACES.has(surface)).toBe(true);
@@ -48,7 +48,7 @@ function makeCtx(overrides: Partial<ExtensionContext> = {}): ExtensionContext {
} as unknown as ExtensionContext; } as unknown as ExtensionContext;
} }
/** Details whose gate-computed surface drives the delegation envelope. */ /** Details carrying the gate-computed surface supplied to authorizers. */
function makeDetailsOn(surface: string): PromptPermissionDetails { function makeDetailsOn(surface: string): PromptPermissionDetails {
return makeDetails({ return makeDetails({
accessIntent: { surface, matchValues: ["/v"], boundaryValue: null }, accessIntent: { surface, matchValues: ["/v"], boundaryValue: null },
@@ -340,29 +340,8 @@ describe("AuthorizerSelection", () => {
); );
}); });
it("caps a link's allow on an excluded surface, falling through to the terminal", async () => {
const registry = new AuthorizerRegistry();
register(registry, "judge", { kind: "allow" });
const selection = new AuthorizerSelection(
makeDeps({
prompter: makeInvokingPrompter(),
authorizerRegistry: registry,
getAuthorizerChain: () => ["judge"],
}),
);
// No UI, not a subagent → the terminal is DenyingAuthorizer.
selection.activate(makeCtx({ hasUI: false }));
const decision = await selection.escalate( it("lets a configured authorizer approve the path surface", async () => {
makeDetailsOn("external_directory"),
);
// The envelope downgraded the link's allow to defer, so the terminal
// (denying) owns the decision — the allow did not leak through.
expect(decision.approved).toBe(false);
});
it("lets a link's allow through on a non-excluded surface", async () => {
const registry = new AuthorizerRegistry(); const registry = new AuthorizerRegistry();
register(registry, "judge", { kind: "allow" }); register(registry, "judge", { kind: "allow" });
const selection = new AuthorizerSelection( const selection = new AuthorizerSelection(
@@ -374,10 +353,10 @@ describe("AuthorizerSelection", () => {
); );
selection.activate(makeCtx({ hasUI: false })); selection.activate(makeCtx({ hasUI: false }));
const decision = await selection.escalate(makeDetailsOn("bash")); const decision = await selection.escalate(makeDetailsOn("path"));
// bash is not excluded, so the link's allow stands (a non-persistent // A configured authorizer's allow is final for this ask and does not
// approved grant) — the denying terminal is never reached. // create a persistent session grant. The terminal is never reached.
expect(decision).toEqual({ expect(decision).toEqual({
approved: true, approved: true,
state: "approved", state: "approved",
@@ -1,147 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import type { Authorizer, AuthorizerVerdict } from "#src/authority/authorizer";
import { encloseInDelegationEnvelope } from "#src/authority/delegation-envelope";
import type { PromptPermissionDetails } from "#src/authority/permission-prompter";
import type { PermissionQuery } from "#src/service";
import { makeAuthorizerLog } from "#test/helpers/authorizer-log-fixtures";
import { makePromptDetails } from "#test/helpers/prompt-details-fixtures";
function makeQuery(): PermissionQuery {
return { checkPermission: vi.fn(), getToolPermission: vi.fn() };
}
/** Build details whose gate-computed surface is `accessIntentSurface`. */
function makeDetails(
accessIntentSurface: string | undefined,
displaySurface?: string | null,
toolName?: string,
): PromptPermissionDetails {
return makePromptDetails({
surface: displaySurface,
toolName,
accessIntent:
accessIntentSurface === undefined
? undefined
: {
surface: accessIntentSurface,
matchValues: ["/some/value"],
boundaryValue: null,
},
});
}
/** A link whose fixed verdict the envelope may cap. */
function makeLink(verdict: AuthorizerVerdict): Authorizer["authorize"] {
return vi.fn<Authorizer["authorize"]>().mockResolvedValue(verdict);
}
describe("encloseInDelegationEnvelope", () => {
const query = makeQuery();
const log = makeAuthorizerLog();
describe("caps an allow verdict on an excluded surface to defer", () => {
it("downgrades an allow on external_directory", async () => {
const enclosed = encloseInDelegationEnvelope(makeLink({ kind: "allow" }));
const verdict = await enclosed(
makeDetails("external_directory"),
query,
log,
);
expect(verdict).toEqual({ kind: "defer" });
});
it.each(["write", "edit", "bash", "custom-tool"] as const)(
"downgrades an allow on external_directory for %s",
async (toolName) => {
const enclosed = encloseInDelegationEnvelope(
makeLink({ kind: "allow" }),
);
const verdict = await enclosed(
makeDetails("external_directory", undefined, toolName),
query,
log,
);
expect(verdict).toEqual({ kind: "defer" });
},
);
it("downgrades an allow on the path surface", async () => {
const enclosed = encloseInDelegationEnvelope(makeLink({ kind: "allow" }));
const verdict = await enclosed(makeDetails("path"), query, log);
expect(verdict).toEqual({ kind: "defer" });
});
it("downgrades an allow when the surface is undetermined (fail-safe)", async () => {
const enclosed = encloseInDelegationEnvelope(makeLink({ kind: "allow" }));
const verdict = await enclosed(makeDetails(undefined, null), query, log);
expect(verdict).toEqual({ kind: "defer" });
});
});
describe("passes verdicts through unchanged", () => {
it("keeps an allow on a non-excluded surface (bash)", async () => {
const enclosed = encloseInDelegationEnvelope(makeLink({ kind: "allow" }));
const verdict = await enclosed(makeDetails("bash"), query, log);
expect(verdict).toEqual({ kind: "allow" });
});
it("keeps an allow on a per-tool surface (read)", async () => {
const enclosed = encloseInDelegationEnvelope(makeLink({ kind: "allow" }));
const verdict = await enclosed(makeDetails("read"), query, log);
expect(verdict).toEqual({ kind: "allow" });
});
it.each(["read", "find", "grep", "ls"] as const)(
"keeps an allow on external_directory for the built-in read-only tool %s",
async (toolName) => {
const enclosed = encloseInDelegationEnvelope(
makeLink({ kind: "allow" }),
);
const verdict = await enclosed(
makeDetails("external_directory", undefined, toolName),
query,
log,
);
expect(verdict).toEqual({ kind: "allow" });
},
);
it("never caps a deny, even on an excluded surface", async () => {
const enclosed = encloseInDelegationEnvelope(
makeLink({ kind: "deny", reason: "wrong path" }),
);
const verdict = await enclosed(
makeDetails("external_directory"),
query,
log,
);
expect(verdict).toEqual({ kind: "deny", reason: "wrong path" });
});
it("never caps a defer", async () => {
const enclosed = encloseInDelegationEnvelope(makeLink({ kind: "defer" }));
const verdict = await enclosed(makeDetails("path"), query, log);
expect(verdict).toEqual({ kind: "defer" });
});
});
it("prefers the gate-computed accessIntent surface over the display surface", async () => {
// accessIntent.surface (external_directory) is authoritative even when the
// display-surface override says otherwise.
const enclosed = encloseInDelegationEnvelope(makeLink({ kind: "allow" }));
const verdict = await enclosed(
makeDetails("external_directory", "bash"),
query,
log,
);
expect(verdict).toEqual({ kind: "defer" });
});
it("forwards details, the injected query, and the review-log seam to the wrapped link", async () => {
const link = makeLink({ kind: "defer" });
const enclosed = encloseInDelegationEnvelope(link);
const details = makeDetails("bash");
await enclosed(details, query, log);
expect(link).toHaveBeenCalledWith(details, query, log);
});
});
@@ -2,10 +2,8 @@ import { readFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
import { afterEach, describe, expect, test, vi } from "vitest"; import { afterEach, describe, expect, test, vi } from "vitest";
import type { Authorizer } from "#src/authority/authorizer";
import { AuthorizerRegistry } from "#src/authority/authorizer-registry"; import { AuthorizerRegistry } from "#src/authority/authorizer-registry";
import { AuthorizerSelection } from "#src/authority/authorizer-selection"; import { AuthorizerSelection } from "#src/authority/authorizer-selection";
import { encloseInDelegationEnvelope } from "#src/authority/delegation-envelope";
import { ForwardedRequestServer } from "#src/authority/forwarded-request-server"; import { ForwardedRequestServer } from "#src/authority/forwarded-request-server";
import type { PermissionPromptDecision } from "#src/authority/permission-dialog"; import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
import type { import type {
@@ -16,7 +14,6 @@ import {
PermissionPrompter, PermissionPrompter,
type PromptPermissionDetails, type PromptPermissionDetails,
} from "#src/authority/permission-prompter"; } from "#src/authority/permission-prompter";
import type { PermissionQuery } from "#src/service";
import { import {
makeAuthorizerSelectionDeps, makeAuthorizerSelectionDeps,
registerLink, registerLink,
@@ -57,9 +54,8 @@ function readResponse(
* An approving `AskEscalator` that records the details it was handed. * An approving `AskEscalator` that records the details it was handed.
* *
* The reconstructed `PromptPermissionDetails` is itself the subject of the * The reconstructed `PromptPermissionDetails` is itself the subject of the
* access-facts and bounded-delegation cases below: they assert its exact shape, * access-facts cases below: they assert the exact evidence passed to the
* and hand it to the real delegation envelope — a collaborator the server never * serving node's configured authorizer chain.
* touches, but one that reads the details the server builds.
*/ */
function makeCapturingEscalator() { function makeCapturingEscalator() {
const escalated: PromptPermissionDetails[] = []; const escalated: PromptPermissionDetails[] = [];
@@ -550,9 +546,8 @@ describe("processInbox — child-fixed access facts on the escalated ask", () =>
value: "git push", value: "git push",
}); });
// Absence, not an explicit `undefined`: the delegation envelope's // Version-skew requests can omit access intent entirely; preserve that
// `accessIntent?.surface ?? surface` fallback reads the display surface only // absence instead of manufacturing evidence.
// when the key is genuinely absent.
expect(details).not.toHaveProperty("accessIntent"); expect(details).not.toHaveProperty("accessIntent");
}); });
}); });
@@ -640,49 +635,6 @@ describe("processInbox — the child's payload on the escalated ask", () => {
}); });
}); });
describe("processInbox — bounded delegation over forwarded asks", () => {
const query: PermissionQuery = {
checkPermission: vi.fn(),
getToolPermission: vi.fn(),
};
const log = makeAuthorizerLog();
const allowingLink: Authorizer["authorize"] = () =>
Promise.resolve({ kind: "allow" });
test("caps a link's allow on a forwarded path ask to defer", async () => {
const details = await escalateForwardedAsk({
id: "req-envelope-path",
source: "tool_call",
surface: "write",
value: "/worktree/issue-42/.ssh/config",
accessIntent: makeForwardedAccessIntent({
surface: "path",
matchValues: ["/worktree/issue-42/.ssh/config"],
boundaryValue: "/worktree/issue-42/.ssh/config",
}),
});
const enclosed = encloseInDelegationEnvelope(allowingLink);
// The gate surface, not the displayed tool name, decides exclusion — so a
// forwarded path ask is capped exactly like the same ask made locally.
expect(await enclosed(details, query, log)).toEqual({ kind: "defer" });
});
test("passes a link's allow on a forwarded bash ask through", async () => {
const details = await escalateForwardedAsk({
id: "req-envelope-bash",
source: "tool_call",
surface: "bash",
value: "npm test",
accessIntent: makeForwardedAccessIntent({ matchValues: ["npm test"] }),
});
const enclosed = encloseInDelegationEnvelope(allowingLink);
expect(await enclosed(details, query, log)).toEqual({ kind: "allow" });
});
});
describe("processInbox — the serving node's chain adjudicates a forwarded ask", () => { describe("processInbox — the serving node's chain adjudicates a forwarded ask", () => {
/** A serving node with UI: its terminal is the human prompt. */ /** A serving node with UI: its terminal is the human prompt. */
File diff suppressed because it is too large Load Diff
@@ -14,8 +14,6 @@
import { import {
mkdirSync, mkdirSync,
mkdtempSync, mkdtempSync,
readdirSync,
readFileSync,
rmSync, rmSync,
symlinkSync, symlinkSync,
writeFileSync, writeFileSync,
@@ -28,11 +26,6 @@ import {
type ExtensionAPI, type ExtensionAPI,
} from "@earendil-works/pi-coding-agent"; } from "@earendil-works/pi-coding-agent";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
createPermissionForwardingLocation,
type ForwardedPermissionRequest,
} from "#src/authority/permission-forwarding";
import { getServingSessionRegistry } from "#src/authority/serving-registry";
import { SUBAGENT_CHILD_SESSION_CREATED } from "#src/authority/subagent-lifecycle-events"; import { SUBAGENT_CHILD_SESSION_CREATED } from "#src/authority/subagent-lifecycle-events";
import { getSubagentSessionRegistry } from "#src/authority/subagent-registry"; import { getSubagentSessionRegistry } from "#src/authority/subagent-registry";
import { getGlobalConfigPath } from "#src/config-paths"; import { getGlobalConfigPath } from "#src/config-paths";
@@ -40,7 +33,6 @@ import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
import piPermissionSystemExtension from "#src/index"; import piPermissionSystemExtension from "#src/index";
import { PERMISSIONS_READY_CHANNEL } from "#src/permission-events"; import { PERMISSIONS_READY_CHANNEL } from "#src/permission-events";
import { getPermissionsService } from "#src/service"; import { getPermissionsService } from "#src/service";
import { publishServingHeartbeat } from "#test/helpers/forwarding-fixtures";
import { makeFakePi } from "#test/helpers/make-fake-pi"; import { makeFakePi } from "#test/helpers/make-fake-pi";
const SERVICE_KEY = Symbol.for("@gotgenes/pi-permission-system:service"); const SERVICE_KEY = Symbol.for("@gotgenes/pi-permission-system:service");
@@ -171,8 +163,6 @@ function makeUiCtx(cwd: string, capturedTitles: string[]): { ctx: unknown } {
return { ctx }; return { ctx };
} }
const sleep = (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms));
/** Drive the registered `session_start` handler with a ctx. */ /** Drive the registered `session_start` handler with a ctx. */
function fireSessionStart( function fireSessionStart(
@@ -182,53 +172,6 @@ function fireSessionStart(
return pi.fire("session_start", { reason: "start" }, ctx); return pi.fire("session_start", { reason: "start" }, ctx);
} }
/**
* Simulate the parent UI session responding to a forwarded permission request.
*
* Polls the parent's requests directory for the child's request file, then
* writes an approval response so the child's forwarding poll resolves quickly
* instead of waiting out the 10-minute timeout.
*/
async function approveForwardedRequest(
forwardingDir: string,
parentSessionId: string,
): Promise<ForwardedPermissionRequest> {
const location = createPermissionForwardingLocation(
forwardingDir,
parentSessionId,
);
const deadline = Date.now() + 2000;
while (Date.now() < deadline) {
let files: string[] = [];
try {
files = readdirSync(location.requestsDir).filter((f) =>
f.endsWith(".json"),
);
} catch {
files = [];
}
const requestFile = files[0];
if (requestFile) {
const request = JSON.parse(
readFileSync(join(location.requestsDir, requestFile), "utf8"),
) as ForwardedPermissionRequest;
mkdirSync(location.responsesDir, { recursive: true });
writeFileSync(
join(location.responsesDir, `${request.id}.json`),
JSON.stringify({
approved: true,
state: "approved",
responderSessionId: parentSessionId,
respondedAt: Date.now(),
}),
"utf8",
);
return request;
}
await sleep(5);
}
throw new Error("Timed out waiting for the forwarded permission request");
}
describe("event-handler registration completeness", () => { describe("event-handler registration completeness", () => {
it("registers a handler for every required event exactly once", () => { it("registers a handler for every required event exactly once", () => {
@@ -239,208 +182,7 @@ describe("event-handler registration completeness", () => {
}); });
}); });
describe("subagent registry sharing across factory instances", () => {
// The #296 regression class: two factory invocations on *different* event
// buses must still resolve the same process-global SubagentSessionRegistry,
// so a child registered via the parent's bus detects itself as a subagent and
// forwards (rather than blocking) an external-directory `ask`.
it("lets a child instance forward an ask it received via the parent's bus", async () => {
writeGlobalConfig({
permission: { "*": "allow", external_directory: "ask" },
});
const childCwd = mkdtempSync(join(tmpdir(), "pi-perm-child-cwd-"));
const externalDir = mkdtempSync(join(tmpdir(), "pi-perm-external-"));
const forwardingDir = join(agentDir, "sessions", "permission-forwarding");
const parentSessionId = "parent-session-1";
const childSessionId = "child-session-1";
// Two factory instances, each wired to its own event bus (as in production:
// every session's ResourceLoader creates a separate bus).
const parentBus = createEventBus();
const childBus = createEventBus();
piPermissionSystemExtension(
makeFakePi({ events: parentBus }) as unknown as ExtensionAPI,
);
const childPi = makeFakePi({
events: childBus,
toolNames: ["read"],
});
piPermissionSystemExtension(childPi as unknown as ExtensionAPI);
// The child session is announced on the *parent's* bus only; the parent's
// lifecycle subscription writes it into the shared global registry.
parentBus.emit(SUBAGENT_CHILD_SESSION_CREATED, {
sessionId: childSessionId,
parentSessionId,
});
// This test answers the forwarded request by hand instead of running the
// parent's poll timer, so state what that timer would have published:
// the parent is draining its inbox. Without it the child correctly
// abandons the request as unserved (#719).
getServingSessionRegistry().markServing(parentSessionId);
// The child fires an external-directory read with no UI. With the shared
// registry it detects itself as a subagent and forwards; the simulated
// parent approves.
const firePromise = childPi.fire(
"tool_call",
{
toolName: "read",
toolCallId: "child-external-read",
input: { path: join(externalDir, "secret.txt") },
},
makeChildCtx(childCwd, childSessionId),
);
const request = await approveForwardedRequest(
forwardingDir,
parentSessionId,
);
expect(request.targetSessionId).toBe(parentSessionId);
expect(request.requesterSessionId).toBe(childSessionId);
// The child persists the original display fields so the parent emits a
// non-degraded `permissions:ui_prompt` event (forwarded non-degradation).
expect(request.source).toBe("tool_call");
expect(request.surface).toBe("read");
expect(request.value).toBe(join(externalDir, "secret.txt"));
const result = (await firePromise) as { block?: true };
expect(result.block).toBeUndefined();
rmSync(childCwd, { recursive: true, force: true });
rmSync(externalDir, { recursive: true, force: true });
});
// The #719 failure mode: the child forwards correctly, but nothing drains
// the parent's inbox. Before the serving registry it waited out the full
// ten-minute timeout and reported the block as a user denial.
it("blocks promptly when no session is draining the parent's inbox", async () => {
writeGlobalConfig({
permission: { "*": "allow", external_directory: "ask" },
});
const childCwd = mkdtempSync(join(tmpdir(), "pi-perm-child-cwd-"));
const externalDir = mkdtempSync(join(tmpdir(), "pi-perm-external-"));
const parentSessionId = "parent-session-2";
const childSessionId = "child-session-2";
const parentBus = createEventBus();
piPermissionSystemExtension(
makeFakePi({ events: parentBus }) as unknown as ExtensionAPI,
);
const childPi = makeFakePi({
events: createEventBus(),
toolNames: ["read"],
});
piPermissionSystemExtension(childPi as unknown as ExtensionAPI);
parentBus.emit(SUBAGENT_CHILD_SESSION_CREATED, {
sessionId: childSessionId,
parentSessionId,
});
// Deliberately no markServing: nobody is polling the parent's inbox.
const result = (await childPi.fire(
"tool_call",
{
toolName: "read",
toolCallId: "child-external-read",
input: { path: join(externalDir, "secret.txt") },
},
makeChildCtx(childCwd, childSessionId),
)) as { block?: true; reason?: string };
expect(result.block).toBe(true);
expect(result.reason).toContain("no interactive UI is available");
expect(result.reason).toContain(
`Session '${parentSessionId}' is not serving forwarded permission requests.`,
);
expect(result.reason).not.toContain("User denied");
rmSync(childCwd, { recursive: true, force: true });
rmSync(externalDir, { recursive: true, force: true });
});
});
describe("out-of-process forwarding liveness", () => {
// A child spawned as its own `pi` process resolves its parent from the
// environment and shares no `globalThis` with it, so the serving registry the
// tests above rely on is invisible to it. The filesystem heartbeat is the
// only signal it has (#721, #735 scenario 1).
const externalAsk = {
permission: { "*": "allow", external_directory: "ask" },
};
function fireChildRead(childCwd: string, externalDir: string) {
const childPi = makeFakePi({
events: createEventBus(),
toolNames: ["read"],
});
piPermissionSystemExtension(childPi as unknown as ExtensionAPI);
return childPi.fire(
"tool_call",
{
toolName: "read",
toolCallId: "child-external-read",
input: { path: join(externalDir, "secret.txt") },
},
makeChildCtx(childCwd, "child-session-oop"),
);
}
it("blocks promptly when the out-of-process parent published no heartbeat", async () => {
writeGlobalConfig(externalAsk);
const childCwd = mkdtempSync(join(tmpdir(), "pi-perm-child-cwd-"));
const externalDir = mkdtempSync(join(tmpdir(), "pi-perm-external-"));
const parentSessionId = "parent-session-oop-gone";
// The env-var path a process-based subagent extension uses: the hint makes
// the child detect itself, and the parent id names a session that exited.
vi.stubEnv("PI_SUBAGENT_CHILD", "1");
vi.stubEnv("PI_SUBAGENT_PARENT_SESSION", parentSessionId);
const result = (await fireChildRead(childCwd, externalDir)) as {
block?: true;
reason?: string;
};
expect(result.block).toBe(true);
expect(result.reason).toContain(
`Session '${parentSessionId}' is not serving forwarded permission requests.`,
);
expect(result.reason).not.toContain("User denied");
rmSync(childCwd, { recursive: true, force: true });
rmSync(externalDir, { recursive: true, force: true });
});
it("waits for an out-of-process parent whose heartbeat is fresh", async () => {
writeGlobalConfig(externalAsk);
const childCwd = mkdtempSync(join(tmpdir(), "pi-perm-child-cwd-"));
const externalDir = mkdtempSync(join(tmpdir(), "pi-perm-external-"));
const forwardingDir = join(agentDir, "sessions", "permission-forwarding");
const parentSessionId = "parent-session-oop-live";
vi.stubEnv("PI_SUBAGENT_CHILD", "1");
vi.stubEnv("PI_SUBAGENT_PARENT_SESSION", parentSessionId);
// What the parent's poll timer would have published; this test answers the
// request by hand instead of running that timer.
publishServingHeartbeat(forwardingDir, parentSessionId);
const firePromise = fireChildRead(childCwd, externalDir);
const request = await approveForwardedRequest(
forwardingDir,
parentSessionId,
);
expect(request.targetSessionId).toBe(parentSessionId);
const result = (await firePromise) as { block?: true };
expect(result.block).toBeUndefined();
rmSync(childCwd, { recursive: true, force: true });
rmSync(externalDir, { recursive: true, force: true });
});
});
describe("shutdown teardown chain", () => { describe("shutdown teardown chain", () => {
it("unpublishes the service and unsubscribes the lifecycle on shutdown", async () => { it("unpublishes the service and unsubscribes the lifecycle on shutdown", async () => {
@@ -727,7 +469,7 @@ describe("project trust gates project-scoped config (#644)", () => {
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
}); });
it("lets a trusted project's `bash: allow` override global `bash: deny`", async () => { it("ignores project policy even when the project is trusted", async () => {
writeGlobalConfig({ permission: { "*": "ask", bash: "deny" } }); writeGlobalConfig({ permission: { "*": "ask", bash: "deny" } });
const cwd = mkdtempSync(join(tmpdir(), "pi-perm-trusted-cwd-")); const cwd = mkdtempSync(join(tmpdir(), "pi-perm-trusted-cwd-"));
writeProjectConfig(cwd, { permission: { bash: "allow" } }); writeProjectConfig(cwd, { permission: { bash: "allow" } });
@@ -739,10 +481,10 @@ describe("project trust gates project-scoped config (#644)", () => {
makeBaseCtx(cwd, "trusted-session", { isProjectTrusted: true }), makeBaseCtx(cwd, "trusted-session", { isProjectTrusted: true }),
); );
// The trusted project override applies (last-match-wins). // Project policy files are not part of the permission configuration.
expect( expect(
getPermissionsService()!.checkPermission("bash", "echo hi").state, getPermissionsService()!.checkPermission("bash", "echo hi").state,
).toBe("allow"); ).toBe("deny");
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
}); });
@@ -883,65 +625,6 @@ describe("bash bare-token path gating (#509, #645)", () => {
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });
}); });
it("denies a bare symlink escaping the working tree under external_directory deny (#645)", async () => {
// The issue's headline repro:
// printf 'test' > /tmp/…-secret ; ln -s /tmp/…-secret outside-link
// cat outside-link (under a permissive `cat *` bash rule)
const cwd = mkdtempSync(join(tmpdir(), "pi-perm-escape-cwd-"));
const outside = mkdtempSync(join(tmpdir(), "pi-perm-escape-target-"));
const secret = join(outside, "pi-permission-test-secret");
writeFileSync(secret, "test");
symlinkSync(secret, join(cwd, "outside-link"));
writeGlobalConfig({
permission: {
"*": "allow",
bash: { "cat *": "allow" },
external_directory: "deny",
},
});
const pi = makeFakePi({ events: createEventBus() });
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
const ctx = makeChildCtx(cwd, "bare-escape-session-deny");
await fireSessionStart(pi, ctx);
const result = await fireBashToolCall(pi, ctx, "cat outside-link");
expect(result.block).toBe(true);
rmSync(cwd, { recursive: true, force: true });
rmSync(outside, { recursive: true, force: true });
});
it("denies a path embedded in a long option under external_directory deny (#645)", async () => {
// The issue's second repro: `grep --file=/tmp/…` under an allowing
// `grep *` bash rule. The flag token never reached the path surfaces.
const cwd = mkdtempSync(join(tmpdir(), "pi-perm-flagpath-cwd-"));
const outside = mkdtempSync(join(tmpdir(), "pi-perm-flagpath-target-"));
const patterns = join(outside, "pi-permission-patterns");
writeFileSync(patterns, "secret\n");
writeGlobalConfig({
permission: {
"*": "allow",
bash: { "grep *": "allow" },
external_directory: "deny",
},
});
const pi = makeFakePi({ events: createEventBus() });
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
const ctx = makeChildCtx(cwd, "flag-path-session-deny");
await fireSessionStart(pi, ctx);
const result = await fireBashToolCall(
pi,
ctx,
`grep --file=${patterns} target`,
);
expect(result.block).toBe(true);
rmSync(cwd, { recursive: true, force: true });
rmSync(outside, { recursive: true, force: true });
});
}); });
describe("multi-instance global service interplay", () => { describe("multi-instance global service interplay", () => {
+11 -142
View File
@@ -245,7 +245,6 @@ describe("loadUnifiedConfig", () => {
"*": "ask", "*": "ask",
read: "allow", read: "allow",
bash: { "*": "ask", "git *": "allow" }, bash: { "*": "ask", "git *": "allow" },
external_directory: "ask",
}, },
}), }),
); );
@@ -256,7 +255,6 @@ describe("loadUnifiedConfig", () => {
"*": "ask", "*": "ask",
read: "allow", read: "allow",
bash: { "*": "ask", "git *": "allow" }, bash: { "*": "ask", "git *": "allow" },
external_directory: "ask",
}); });
}); });
@@ -401,32 +399,6 @@ describe("loadUnifiedConfig", () => {
expect(result.issues.length).toBeGreaterThan(0); expect(result.issues.length).toBeGreaterThan(0);
}); });
it("parses piInfrastructureReadPaths when a valid string array is present", () => {
const configPath = join(tempDir, "config.json");
writeFileSync(
configPath,
JSON.stringify({ piInfrastructureReadPaths: ["/extra/path"] }),
);
const result = loadUnifiedConfig(configPath);
expect(result.config.piInfrastructureReadPaths).toEqual(["/extra/path"]);
});
it("parses piInfrastructureReadPaths as empty array when set to []", () => {
const configPath = join(tempDir, "config.json");
writeFileSync(
configPath,
JSON.stringify({ piInfrastructureReadPaths: [] }),
);
const result = loadUnifiedConfig(configPath);
expect(result.config.piInfrastructureReadPaths).toEqual([]);
});
it("omits piInfrastructureReadPaths when absent", () => {
const configPath = join(tempDir, "config.json");
writeFileSync(configPath, JSON.stringify({ debugLog: false }));
const result = loadUnifiedConfig(configPath);
expect(result.config).not.toHaveProperty("piInfrastructureReadPaths");
});
it.each([ it.each([
["string", "not-an-array"], ["string", "not-an-array"],
@@ -446,7 +418,7 @@ describe("loadUnifiedConfig", () => {
}); });
describe("mergeUnifiedConfigs", () => { describe("mergeUnifiedConfigs", () => {
it("deep-merges permission objects so project overrides global per-key", () => { it("deep-merges permission objects so the later config overrides per key", () => {
const merged = mergeUnifiedConfigs( const merged = mergeUnifiedConfigs(
{ {
permission: { permission: {
@@ -496,7 +468,7 @@ describe("mergeUnifiedConfigs", () => {
expect(merged.permission).toEqual({ bash: "deny" }); expect(merged.permission).toEqual({ bash: "deny" });
}); });
it("replaces scalar runtime knobs (project wins)", () => { it("replaces scalar runtime knobs with later values", () => {
const merged = mergeUnifiedConfigs( const merged = mergeUnifiedConfigs(
{ {
debugLog: true, debugLog: true,
@@ -513,7 +485,7 @@ describe("mergeUnifiedConfigs", () => {
expect(merged.doublePressToConfirm).toBe(false); expect(merged.doublePressToConfirm).toBe(false);
}); });
it("replaces the prompt-budget scalars (project wins)", () => { it("replaces prompt-budget scalars with later values", () => {
const merged = mergeUnifiedConfigs( const merged = mergeUnifiedConfigs(
{ promptMaxRows: 24, promptFieldMaxWidth: 400 }, { promptMaxRows: 24, promptFieldMaxWidth: 400 },
{ promptMaxRows: 10 }, { promptMaxRows: 10 },
@@ -523,7 +495,7 @@ describe("mergeUnifiedConfigs", () => {
expect(merged.promptFieldMaxWidth).toBe(400); expect(merged.promptFieldMaxWidth).toBe(400);
}); });
it("replaces the review-log field width (project wins)", () => { it("replaces the review-log field width with a later value", () => {
expect( expect(
mergeUnifiedConfigs( mergeUnifiedConfigs(
{ reviewLogFieldMaxWidth: 1000 }, { reviewLogFieldMaxWidth: 1000 },
@@ -612,34 +584,6 @@ describe("mergeUnifiedConfigs", () => {
expect(merged).not.toHaveProperty("toolTextSummaryMaxLength"); expect(merged).not.toHaveProperty("toolTextSummaryMaxLength");
}); });
it("override piInfrastructureReadPaths replaces base array", () => {
const merged = mergeUnifiedConfigs(
{ piInfrastructureReadPaths: ["/base/path"] },
{ piInfrastructureReadPaths: ["/override/path"] },
);
expect(merged.piInfrastructureReadPaths).toEqual(["/override/path"]);
});
it("base piInfrastructureReadPaths survives when override omits it", () => {
const merged = mergeUnifiedConfigs(
{ piInfrastructureReadPaths: ["/kept/path"] },
{ debugLog: true },
);
expect(merged.piInfrastructureReadPaths).toEqual(["/kept/path"]);
});
it("piInfrastructureReadPaths is absent when both base and override omit it", () => {
const merged = mergeUnifiedConfigs({ debugLog: true }, { yoloMode: false });
expect(merged).not.toHaveProperty("piInfrastructureReadPaths");
});
it("override piInfrastructureReadPaths as empty array replaces non-empty base", () => {
const merged = mergeUnifiedConfigs(
{ piInfrastructureReadPaths: ["/base/path"] },
{ piInfrastructureReadPaths: [] },
);
expect(merged.piInfrastructureReadPaths).toEqual([]);
});
it("override authorizerChain replaces base array", () => { it("override authorizerChain replaces base array", () => {
const merged = mergeUnifiedConfigs( const merged = mergeUnifiedConfigs(
@@ -764,27 +708,6 @@ describe("loadAndMergeConfigs", () => {
writeFileSync(join(extensionRoot, "config.json"), JSON.stringify(content)); writeFileSync(join(extensionRoot, "config.json"), JSON.stringify(content));
} }
it("merges global and project new-layout configs", () => {
writeGlobal({
debugLog: true,
permission: { "*": "ask", read: "allow" },
});
writeProject({
permission: { "*": "allow", write: "deny" },
});
const result = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
// The merged config leaves a permissive top-level '*' with no bash '*' policy,
// so the bash-fallback footgun warning is expected.
expect(result.issues).toHaveLength(1);
expect(result.issues[0]).toContain("bash");
expect(result.merged.debugLog).toBe(true);
expect(result.merged.permission).toEqual({
"*": "allow",
read: "allow",
write: "deny",
});
});
it("detects legacy global policy and emits migration issue", () => { it("detects legacy global policy and emits migration issue", () => {
writeLegacyGlobalPolicy({ writeLegacyGlobalPolicy({
@@ -800,18 +723,6 @@ describe("loadAndMergeConfigs", () => {
expect(result.merged.permission).toBeUndefined(); expect(result.merged.permission).toBeUndefined();
}); });
it("detects legacy project policy and emits migration issue", () => {
writeLegacyProjectPolicy({
bash: { "git status": "allow" },
});
const result = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
expect(result.issues).toHaveLength(1);
expect(result.issues[0]).toContain(".pi/agent/pi-permissions.jsonc");
expect(result.issues[0]).toContain(".pi/extensions/pi-permission-system");
// Legacy file has no flat-format permission key — no rules extracted
expect(result.merged.permission).toBeUndefined();
});
it("detects legacy extension runtime config and emits migration issue", () => { it("detects legacy extension runtime config and emits migration issue", () => {
writeLegacyExtensionConfig({ writeLegacyExtensionConfig({
@@ -878,57 +789,15 @@ describe("loadAndMergeConfigs", () => {
expect(result.issues).toEqual([]); expect(result.issues).toEqual([]);
}); });
describe("includeProjectScope", () => { it("ignores new and legacy project policy files", () => {
it("omits the new project config when includeProjectScope is false", () => { writeGlobal({ permission: { "*": "ask", bash: "deny" } });
writeGlobal({ writeProject({ permission: { bash: "allow" } });
permission: { "*": "ask", bash: "deny" }, writeLegacyProjectPolicy({ permission: { "*": "allow" } });
});
writeProject({
permission: { bash: "allow" },
});
const result = loadAndMergeConfigs(agentDir, cwd, extensionRoot, { const result = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
includeProjectScope: false,
});
// The untrusted project's `bash: allow` must not override global `deny`. expect(result.merged.permission).toEqual({ "*": "ask", bash: "deny" });
expect(result.merged.permission).toEqual({ "*": "ask", bash: "deny" }); expect(result.issues).toEqual([]);
expect(result.project).toEqual({});
});
it("omits the legacy project policy when includeProjectScope is false", () => {
writeGlobal({ permission: { "*": "ask" } });
writeLegacyProjectPolicy({ permission: { "*": "allow" } });
const result = loadAndMergeConfigs(agentDir, cwd, extensionRoot, {
includeProjectScope: false,
});
expect(result.merged.permission).toEqual({ "*": "ask" });
expect(
result.issues.some((i) => i.includes("pi-permissions.jsonc")),
).toBe(false);
});
it("includes the project config when includeProjectScope is true", () => {
writeGlobal({ permission: { "*": "ask", bash: "deny" } });
writeProject({ permission: { bash: "allow" } });
const result = loadAndMergeConfigs(agentDir, cwd, extensionRoot, {
includeProjectScope: true,
});
expect(result.merged.permission).toEqual({ "*": "ask", bash: "allow" });
});
it("includes the project config by default (option omitted)", () => {
writeGlobal({ permission: { "*": "ask", bash: "deny" } });
writeProject({ permission: { bash: "allow" } });
const result = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
expect(result.merged.permission).toEqual({ "*": "ask", bash: "allow" });
});
}); });
describe("deprecated tool-preview caps", () => { describe("deprecated tool-preview caps", () => {
@@ -8,9 +8,7 @@ import {
getGlobalLogsDir, getGlobalLogsDir,
getLegacyExtensionConfigPath, getLegacyExtensionConfigPath,
getLegacyGlobalPolicyPath, getLegacyGlobalPolicyPath,
getLegacyProjectPolicyPath,
getProjectAgentsDir, getProjectAgentsDir,
getProjectConfigPath,
REVIEW_LOG_FILENAME, REVIEW_LOG_FILENAME,
} from "#src/config-paths"; } from "#src/config-paths";
@@ -38,11 +36,6 @@ describe("config-paths", () => {
); );
}); });
it("getProjectConfigPath returns .pi/extensions/pi-permission-system/policy.jsonc under cwd", () => {
expect(getProjectConfigPath(cwd)).toBe(
join(cwd, ".pi", "extensions", "pi-permission-system", "policy.jsonc"),
);
});
it("getProjectAgentsDir returns .pi/agents under cwd", () => { it("getProjectAgentsDir returns .pi/agents under cwd", () => {
expect(getProjectAgentsDir(cwd)).toBe(join(cwd, ".pi", "agents")); expect(getProjectAgentsDir(cwd)).toBe(join(cwd, ".pi", "agents"));
@@ -56,11 +49,6 @@ describe("config-paths", () => {
); );
}); });
it("getLegacyProjectPolicyPath returns .pi/agent/pi-permissions.jsonc under cwd", () => {
expect(getLegacyProjectPolicyPath(cwd)).toBe(
join(cwd, ".pi", "agent", "pi-permissions.jsonc"),
);
});
it("getLegacyExtensionConfigPath returns config.json under extensionRoot", () => { it("getLegacyExtensionConfigPath returns config.json under extensionRoot", () => {
expect(getLegacyExtensionConfigPath(extensionRoot)).toBe( expect(getLegacyExtensionConfigPath(extensionRoot)).toBe(
@@ -94,19 +94,6 @@ describe("config pipeline seam", () => {
expect(config).not.toHaveProperty("toolTextSummaryMaxLength"); expect(config).not.toHaveProperty("toolTextSummaryMaxLength");
}); });
it("project config still overrides a global deprecated cap in the merge", () => {
writeGlobal({ toolInputPreviewMaxLength: 200 });
const projectDir = join(cwd, ".pi", "extensions", "pi-permission-system");
mkdirSync(projectDir, { recursive: true });
writeFileSync(
join(projectDir, "policy.jsonc"),
JSON.stringify({ toolInputPreviewMaxLength: 500 }),
);
const mergeResult = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
expect(mergeResult.merged.toolInputPreviewMaxLength).toBe(500);
});
it("defaults apply when config file is absent", () => { it("defaults apply when config file is absent", () => {
// No config files written — agentDir and cwd directories don't exist. // No config files written — agentDir and cwd directories don't exist.
@@ -13,82 +13,23 @@ import { createPermissionSystemLogger } from "#src/logging";
import type { ResolvedPolicyPaths } from "#src/permission-manager"; import type { ResolvedPolicyPaths } from "#src/permission-manager";
import { PermissionManager } from "#src/permission-manager"; import { PermissionManager } from "#src/permission-manager";
test("buildResolvedConfigLogEntry includes policy paths and legacy detection flags", () => { test("buildResolvedConfigLogEntry reports global and agent policy paths", () => {
const policyPaths: ResolvedPolicyPaths = { const policyPaths: ResolvedPolicyPaths = {
globalConfigPath: globalConfigPath: "/home/user/.pi/agent/extensions/pi-permission-system/policy.jsonc",
"/home/user/.pi/agent/extensions/pi-permission-system/config.json",
globalConfigExists: true, globalConfigExists: true,
projectConfigPath:
"/projects/my-app/.pi/extensions/pi-permission-system/config.json",
projectConfigExists: false,
agentsDir: "/home/user/.pi/agent/agents", agentsDir: "/home/user/.pi/agent/agents",
agentsDirExists: true, agentsDirExists: true,
projectAgentsDir: "/projects/my-app/.pi/agent/agents",
projectAgentsDirExists: false,
};
const result = buildResolvedConfigLogEntry({ policyPaths });
expect(result.globalConfigPath).toBe(
"/home/user/.pi/agent/extensions/pi-permission-system/config.json",
);
expect(result.globalConfigExists).toBe(true);
expect(result.projectConfigPath).toBe(
"/projects/my-app/.pi/extensions/pi-permission-system/config.json",
);
expect(result.projectConfigExists).toBe(false);
expect(result.agentsDir).toBe("/home/user/.pi/agent/agents");
expect(result.agentsDirExists).toBe(true);
expect(result.projectAgentsDir).toBe("/projects/my-app/.pi/agent/agents");
expect(result.projectAgentsDirExists).toBe(false);
expect(result.legacyGlobalPolicyDetected).toBe(false);
expect(result.legacyProjectPolicyDetected).toBe(false);
expect(result.legacyExtensionConfigDetected).toBe(false);
});
test("buildResolvedConfigLogEntry handles null project paths", () => {
const policyPaths: ResolvedPolicyPaths = {
globalConfigPath:
"/home/user/.pi/agent/extensions/pi-permission-system/config.json",
globalConfigExists: false,
projectConfigPath: null,
projectConfigExists: false,
agentsDir: "/home/user/.pi/agent/agents",
agentsDirExists: false,
projectAgentsDir: null, projectAgentsDir: null,
projectAgentsDirExists: false, projectAgentsDirExists: false,
}; };
const result = buildResolvedConfigLogEntry({ policyPaths }); const result = buildResolvedConfigLogEntry({ policyPaths });
expect(result.projectConfigPath).toBe(null); expect(result).toEqual({
expect(result.projectConfigExists).toBe(false); ...policyPaths,
expect(result.projectAgentsDir).toBe(null); legacyGlobalPolicyDetected: false,
expect(result.projectAgentsDirExists).toBe(false); legacyExtensionConfigDetected: false,
});
test("buildResolvedConfigLogEntry surfaces legacy detection flags", () => {
const policyPaths: ResolvedPolicyPaths = {
globalConfigPath:
"/home/user/.pi/agent/extensions/pi-permission-system/config.json",
globalConfigExists: true,
projectConfigPath: null,
projectConfigExists: false,
agentsDir: "/home/user/.pi/agent/agents",
agentsDirExists: false,
projectAgentsDir: null,
projectAgentsDirExists: false,
};
const result = buildResolvedConfigLogEntry({
policyPaths,
legacyGlobalPolicyDetected: true,
legacyExtensionConfigDetected: true,
}); });
expect(result.legacyGlobalPolicyDetected).toBe(true);
expect(result.legacyProjectPolicyDetected).toBe(false);
expect(result.legacyExtensionConfigDetected).toBe(true);
}); });
test("config.resolved entry appears in review log via logger", () => { test("config.resolved entry appears in review log via logger", () => {
@@ -135,12 +76,9 @@ test("config.resolved entry appears in review log via logger", () => {
expect(parsed.globalConfigExists).toBe(true); expect(parsed.globalConfigExists).toBe(true);
expect(parsed.agentsDir).toBe(agentsDir); expect(parsed.agentsDir).toBe(agentsDir);
expect(parsed.agentsDirExists).toBe(false); expect(parsed.agentsDirExists).toBe(false);
expect(parsed.projectConfigPath).toBe(null);
expect(parsed.projectConfigExists).toBe(false);
expect(parsed.projectAgentsDir).toBe(null); expect(parsed.projectAgentsDir).toBe(null);
expect(parsed.projectAgentsDirExists).toBe(false); expect(parsed.projectAgentsDirExists).toBe(false);
expect(parsed.legacyGlobalPolicyDetected).toBe(false); expect(parsed.legacyGlobalPolicyDetected).toBe(false);
expect(parsed.legacyProjectPolicyDetected).toBe(false);
expect(parsed.legacyExtensionConfigDetected).toBe(false); expect(parsed.legacyExtensionConfigDetected).toBe(false);
} finally { } finally {
rmSync(tempDir, { recursive: true, force: true }); rmSync(tempDir, { recursive: true, force: true });
@@ -29,7 +29,6 @@ describe("unifiedConfigSchema", () => {
yoloMode: true, yoloMode: true,
toolInputPreviewMaxLength: 1000, toolInputPreviewMaxLength: 1000,
toolTextSummaryMaxLength: 120, toolTextSummaryMaxLength: 120,
piInfrastructureReadPaths: ["/extra/path"],
permission: { permission: {
"*": "ask", "*": "ask",
read: "allow", read: "allow",
+3 -20
View File
@@ -76,8 +76,6 @@ function makePolicyPathProvider(
(): ResolvedPolicyPaths => ({ (): ResolvedPolicyPaths => ({
globalConfigPath: "/agent/config.json", globalConfigPath: "/agent/config.json",
globalConfigExists: false, globalConfigExists: false,
projectConfigPath: null,
projectConfigExists: false,
agentsDir: "/agent/agents", agentsDir: "/agent/agents",
agentsDirExists: false, agentsDirExists: false,
projectAgentsDir: null, projectAgentsDir: null,
@@ -161,25 +159,23 @@ describe("ConfigStore", () => {
// ── refresh() ───────────────────────────────────────────────────────── // ── refresh() ─────────────────────────────────────────────────────────
describe("refresh()", () => { describe("refresh()", () => {
it("uses the passed ctx cwd for loadAndMergeConfigs and includes the project scope when trusted", () => { it("uses the passed ctx cwd for global-only config loading", () => {
const { store } = makeStore(); const { store } = makeStore();
store.refresh(makeCtx({ cwd: "/my/project" }), true); store.refresh(makeCtx({ cwd: "/my/project" }), true);
expect(mockLoadAndMergeConfigs).toHaveBeenCalledWith( expect(mockLoadAndMergeConfigs).toHaveBeenCalledWith(
"/test/agent", "/test/agent",
"/my/project", "/my/project",
expect.any(String), expect.any(String),
{ includeProjectScope: true },
); );
}); });
it("withholds the project scope when the project is untrusted", () => { it("does not add a project config scope when the project is untrusted", () => {
const { store } = makeStore(); const { store } = makeStore();
store.refresh(makeCtx({ cwd: "/my/project" }), false); store.refresh(makeCtx({ cwd: "/my/project" }), false);
expect(mockLoadAndMergeConfigs).toHaveBeenCalledWith( expect(mockLoadAndMergeConfigs).toHaveBeenCalledWith(
"/test/agent", "/test/agent",
"/my/project", "/my/project",
expect.any(String), expect.any(String),
{ includeProjectScope: false },
); );
}); });
@@ -190,7 +186,6 @@ describe("ConfigStore", () => {
"/test/agent", "/test/agent",
"", "",
expect.any(String), expect.any(String),
{ includeProjectScope: true },
); );
}); });
@@ -307,17 +302,6 @@ describe("ConfigStore", () => {
expect(mockSyncPermissionSystemStatus).not.toHaveBeenCalled(); expect(mockSyncPermissionSystemStatus).not.toHaveBeenCalled();
}); });
it("carries piInfrastructureReadPaths from merged config into current()", () => {
const { store } = makeStore();
mockLoadAndMergeConfigs.mockReturnValue({
merged: { piInfrastructureReadPaths: ["/extra/path"] },
issues: [],
});
store.refresh(undefined, true);
expect(store.current().piInfrastructureReadPaths).toEqual([
"/extra/path",
]);
});
}); });
// ── save() ───────────────────────────────────────────────────────────── // ── save() ─────────────────────────────────────────────────────────────
@@ -459,13 +443,12 @@ describe("ConfigStore", () => {
expect(mockBuildResolvedConfigLogEntry).toHaveBeenCalledWith( expect(mockBuildResolvedConfigLogEntry).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
legacyGlobalPolicyDetected: expect.any(Boolean), legacyGlobalPolicyDetected: expect.any(Boolean),
legacyProjectPolicyDetected: expect.any(Boolean),
legacyExtensionConfigDetected: expect.any(Boolean), legacyExtensionConfigDetected: expect.any(Boolean),
}), }),
); );
}); });
it("does not check project legacy path when no cwd is provided", () => { it("checks only supported global legacy paths", () => {
const { store } = makeStore(); const { store } = makeStore();
store.logResolvedPaths(); // no cwd store.logResolvedPaths(); // no cwd
// existsSync called for global and ext-config legacy paths only (not project) // existsSync called for global and ext-config legacy paths only (not project)
@@ -49,7 +49,6 @@ describe("detectMisplacedPermissionKeys", () => {
mcp: {}, mcp: {},
skills: {}, skills: {},
special: {}, special: {},
external_directory: {},
}); });
expect(result).toEqual([ expect(result).toEqual([
"defaultPolicy", "defaultPolicy",
@@ -58,7 +57,6 @@ describe("detectMisplacedPermissionKeys", () => {
"mcp", "mcp",
"skills", "skills",
"special", "special",
"external_directory",
]); ]);
}); });
@@ -1,107 +1,26 @@
import { join } from "node:path"; import { describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { mockDiscoverGlobalNodeModulesRoot } = vi.hoisted(() => ({
mockDiscoverGlobalNodeModulesRoot: vi.fn<() => string | null>(),
}));
vi.mock("../src/node-modules-discovery", () => ({
discoverGlobalNodeModulesRoot: mockDiscoverGlobalNodeModulesRoot,
}));
import { getGlobalLogsDir } from "#src/config-paths"; import { getGlobalLogsDir } from "#src/config-paths";
import { computeExtensionPaths } from "#src/extension-paths"; import { computeExtensionPaths } from "#src/extension-paths";
describe("computeExtensionPaths", () => { describe("computeExtensionPaths", () => {
beforeEach(() => { it("derives only extension-owned runtime paths from agentDir", () => {
mockDiscoverGlobalNodeModulesRoot.mockReset(); const paths = computeExtensionPaths("/agent");
mockDiscoverGlobalNodeModulesRoot.mockReturnValue(
"/mock/global/node_modules", expect(paths).toEqual({
); agentDir: "/agent",
sessionsDir: "/agent/sessions",
subagentSessionsDir: "/agent/sessions/subagents",
forwardingDir: "/agent/sessions/permission-forwarding",
globalLogsDir: getGlobalLogsDir("/agent"),
});
expect(paths).not.toHaveProperty("piInfrastructureDirs");
}); });
it("sets agentDir from argument", () => { it("does not retain values between agent directories", () => {
const paths = computeExtensionPaths("/test/agent");
expect(paths.agentDir).toBe("/test/agent");
});
it("derives sessionsDir as agentDir/sessions", () => {
const paths = computeExtensionPaths("/test/agent");
expect(paths.sessionsDir).toBe("/test/agent/sessions");
});
it("derives subagentSessionsDir as agentDir/subagent-sessions", () => {
const paths = computeExtensionPaths("/test/agent");
expect(paths.subagentSessionsDir).toBe("/test/agent/subagent-sessions");
});
it("derives forwardingDir as sessionsDir/permission-forwarding", () => {
const paths = computeExtensionPaths("/test/agent");
expect(paths.forwardingDir).toBe(
join("/test/agent/sessions", "permission-forwarding"),
);
});
it("derives globalLogsDir via getGlobalLogsDir(agentDir)", () => {
const paths = computeExtensionPaths("/test/agent");
expect(paths.globalLogsDir).toBe(getGlobalLogsDir("/test/agent"));
});
it("includes agentDir in piInfrastructureDirs", () => {
const paths = computeExtensionPaths("/test/agent");
expect(paths.piInfrastructureDirs).toContain("/test/agent");
});
it("includes agentDir/git in piInfrastructureDirs", () => {
const paths = computeExtensionPaths("/test/agent");
expect(paths.piInfrastructureDirs).toContain("/test/agent/git");
});
it("includes discovered global node_modules root in piInfrastructureDirs", () => {
const paths = computeExtensionPaths("/test/agent");
expect(paths.piInfrastructureDirs).toContain("/mock/global/node_modules");
});
it("omits global node_modules from piInfrastructureDirs when discovery returns null", () => {
mockDiscoverGlobalNodeModulesRoot.mockReturnValue(null);
const paths = computeExtensionPaths("/test/agent");
expect(paths.piInfrastructureDirs).toHaveLength(2);
expect(paths.piInfrastructureDirs).toContain("/test/agent");
expect(paths.piInfrastructureDirs).toContain("/test/agent/git");
});
it("all entries in piInfrastructureDirs are strings (no null)", () => {
mockDiscoverGlobalNodeModulesRoot.mockReturnValue(null);
const paths = computeExtensionPaths("/test/agent");
for (const dir of paths.piInfrastructureDirs) {
expect(typeof dir).toBe("string");
}
});
it("includes piPackageDir in piInfrastructureDirs when provided", () => {
const paths = computeExtensionPaths("/test/agent", "/pi/install");
expect(paths.piInfrastructureDirs).toContain("/pi/install");
});
it("omits piPackageDir when not provided (current behavior preserved)", () => {
const paths = computeExtensionPaths("/test/agent");
expect(paths.piInfrastructureDirs).toEqual([
"/test/agent",
"/test/agent/git",
"/mock/global/node_modules",
]);
});
it("omits piPackageDir when given an empty string", () => {
const paths = computeExtensionPaths("/test/agent", "");
expect(paths.piInfrastructureDirs).not.toContain("");
});
it("two calls with different agentDirs produce independent results", () => {
const a = computeExtensionPaths("/agent/a"); const a = computeExtensionPaths("/agent/a");
const b = computeExtensionPaths("/agent/b"); const b = computeExtensionPaths("/agent/b");
expect(a.agentDir).toBe("/agent/a");
expect(b.agentDir).toBe("/agent/b");
expect(a.sessionsDir).toBe("/agent/a/sessions"); expect(a.sessionsDir).toBe("/agent/a/sessions");
expect(b.sessionsDir).toBe("/agent/b/sessions"); expect(b.sessionsDir).toBe("/agent/b/sessions");
}); });
@@ -1,10 +1,6 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { import { AgentPrepHandler } from "#src/handlers/before-agent-start";
AgentPrepHandler,
shouldExposeTool,
} from "#src/handlers/before-agent-start";
import type { ToolRegistry } from "#src/tool-registry";
import { makeCheckResult, makeCtx } from "#test/helpers/handler-fixtures"; import { makeCheckResult, makeCtx } from "#test/helpers/handler-fixtures";
import { import {
@@ -12,325 +8,51 @@ import {
makeRealSession, makeRealSession,
} from "#test/helpers/session-fixtures"; } from "#test/helpers/session-fixtures";
// ── SDK stubs ────────────────────────────────────────────────────────────── function makeSetup() {
vi.mock("@earendil-works/pi-coding-agent", async (importOriginal) => {
const original =
await importOriginal<typeof import("@earendil-works/pi-coding-agent")>();
return {
...original,
isToolCallEventType: vi.fn().mockReturnValue(false),
};
});
// ── helpers ────────────────────────────────────────────────────────────────
function makeEvent(systemPrompt = "You are an assistant.") {
return { systemPrompt };
}
function makeToolRegistry(overrides: Partial<ToolRegistry> = {}): ToolRegistry {
return {
getAll: vi.fn().mockReturnValue([]),
getActive: vi.fn().mockReturnValue([]),
setActive: vi.fn(),
...overrides,
};
}
function makeSetup(opts?: {
toolPermission?: "allow" | "deny" | "ask";
toolRegistry?: Partial<ToolRegistry>;
}) {
const { session, permissionManager, sessionRules, configStore, forwarding } = const { session, permissionManager, sessionRules, configStore, forwarding } =
makeRealSession(); makeRealSession();
const { resolver } = makeRealResolver(permissionManager, sessionRules); const { resolver } = makeRealResolver(permissionManager, sessionRules);
if (opts?.toolPermission !== undefined) {
vi.mocked(permissionManager.getToolPermission).mockReturnValue(
opts.toolPermission,
);
}
// Default check returns allow (for skill-prompt sanitizer via resolver.checkPermission)
vi.mocked(permissionManager.check).mockReturnValue(makeCheckResult()); vi.mocked(permissionManager.check).mockReturnValue(makeCheckResult());
const toolRegistry = makeToolRegistry(opts?.toolRegistry);
const warmParser = vi.fn(); const warmParser = vi.fn();
const handler = new AgentPrepHandler( const handler = new AgentPrepHandler(session, resolver, warmParser);
session,
resolver,
toolRegistry,
warmParser,
);
return { return {
handler, handler,
session, session,
resolver,
permissionManager,
configStore, configStore,
forwarding, forwarding,
toolRegistry,
warmParser, warmParser,
}; };
} }
// ── shouldExposeTool (pure helper) ───────────────────────────────────────── describe("AgentPrepHandler", () => {
it("warms the parser and activates the permission session", async () => {
describe("shouldExposeTool", () => { const { handler, forwarding, warmParser } = makeSetup();
it("returns true when tool permission is allow", () => {
const getter = vi.fn().mockReturnValue("allow");
expect(shouldExposeTool("read", null, getter)).toBe(true);
});
it("returns true when tool permission is ask", () => {
const getter = vi.fn().mockReturnValue("ask");
expect(shouldExposeTool("bash", "agent-x", getter)).toBe(true);
});
it("returns false when tool permission is deny", () => {
const getter = vi.fn().mockReturnValue("deny");
expect(shouldExposeTool("write", null, getter)).toBe(false);
});
it("passes agentName through to getToolPermission", () => {
const getter = vi.fn().mockReturnValue("allow");
shouldExposeTool("read", "my-agent", getter);
expect(getter).toHaveBeenCalledWith("read", "my-agent");
});
it("converts null agentName to undefined for getToolPermission", () => {
const getter = vi.fn().mockReturnValue("allow");
shouldExposeTool("read", null, getter);
expect(getter).toHaveBeenCalledWith("read", undefined);
});
});
// ── AgentPrepHandler.handle ────────────────────────────────────────────────
describe("AgentPrepHandler.handle", () => {
it("activates the session with ctx", async () => {
const ctx = makeCtx(); const ctx = makeCtx();
const { handler, forwarding } = makeSetup();
await handler.handle(makeEvent(), ctx); await handler.handle({ systemPrompt: "You are an assistant." }, ctx);
// Real session.activate calls forwarding.start
expect(warmParser).toHaveBeenCalledTimes(1);
expect(forwarding.start).toHaveBeenCalledWith(ctx); expect(forwarding.start).toHaveBeenCalledWith(ctx);
}); });
it("triggers the bash-parser warm-up", async () => { it("refreshes global permission configuration", async () => {
const { handler, warmParser } = makeSetup();
await handler.handle(makeEvent(), makeCtx());
expect(warmParser).toHaveBeenCalledTimes(1);
});
it("refreshes config with ctx, gated on project trust", async () => {
const ctx = makeCtx();
const { handler, configStore } = makeSetup(); const { handler, configStore } = makeSetup();
await handler.handle(makeEvent(), ctx); const ctx = makeCtx({ isProjectTrusted: () => false });
expect(configStore.refresh).toHaveBeenCalledWith(ctx, true);
}); await handler.handle({ systemPrompt: "You are an assistant." }, ctx);
it("withholds the project scope when the project is untrusted", async () => {
const ctx = makeCtx({
isProjectTrusted: vi.fn<() => boolean>().mockReturnValue(false),
});
const { handler, configStore } = makeSetup();
await handler.handle(makeEvent(), ctx);
expect(configStore.refresh).toHaveBeenCalledWith(ctx, false); expect(configStore.refresh).toHaveBeenCalledWith(ctx, false);
}); });
it("resolves agent name using systemPrompt", async () => { it("does not rewrite a prompt without skill entries", async () => {
const ctx = makeCtx();
const { handler, session } = makeSetup(); const { handler, session } = makeSetup();
const spy = vi.spyOn(session, "resolveAgentName");
await handler.handle(makeEvent("<active_agent name='x'>"), ctx);
expect(spy).toHaveBeenCalledWith(ctx, "<active_agent name='x'>");
});
it("filters out denied tools from allowed list", async () => { const result = await handler.handle(
const { handler, toolRegistry } = makeSetup({ { systemPrompt: "You are an assistant." },
toolPermission: "deny", makeCtx(),
toolRegistry: {
getActive: vi.fn().mockReturnValue(["write", "read"]),
},
});
await handler.handle(makeEvent(), makeCtx());
expect(toolRegistry.setActive).toHaveBeenCalledWith([]);
});
it("includes allowed and ask tools in the active list", async () => {
const { handler, toolRegistry } = makeSetup({
toolRegistry: {
getActive: vi.fn().mockReturnValue(["read", "write"]),
},
});
await handler.handle(makeEvent(), makeCtx());
expect(toolRegistry.setActive).toHaveBeenCalledWith(["read", "write"]);
});
it("does not activate registered tools pi left inactive (find/grep/ls)", async () => {
// Regression for #385: the active set is the base, not the full registry.
const { handler, toolRegistry } = makeSetup({
toolRegistry: {
getActive: vi.fn().mockReturnValue(["read", "bash", "edit", "write"]),
getAll: vi
.fn()
.mockReturnValue([
{ name: "read" },
{ name: "bash" },
{ name: "edit" },
{ name: "write" },
{ name: "find" },
{ name: "grep" },
{ name: "ls" },
]),
},
});
await handler.handle(makeEvent(), makeCtx());
expect(toolRegistry.setActive).toHaveBeenCalledWith([
"read",
"bash",
"edit",
"write",
]);
});
it("calls setActive on every turn (no dedup gate)", async () => {
const { handler, toolRegistry } = makeSetup({
toolRegistry: {
getActive: vi.fn().mockReturnValue(["read"]),
},
});
await handler.handle(makeEvent(), makeCtx());
await handler.handle(makeEvent(), makeCtx());
expect(toolRegistry.setActive).toHaveBeenCalledTimes(2);
});
it("filters a denied skill from the systemPrompt on every turn, not just the first", async () => {
const systemPrompt = [
"You are an assistant.",
"",
"<available_skills>",
" <skill>",
" <name>secret</name>",
" <description>A denied skill</description>",
" <location>/skills/secret/SKILL.md</location>",
" </skill>",
"</available_skills>",
].join("\n");
const { handler, permissionManager } = makeSetup();
vi.mocked(permissionManager.check).mockImplementation((intent) =>
intent.surface === "skill"
? makeCheckResult({ state: "deny" })
: makeCheckResult(),
); );
const first = await handler.handle(makeEvent(systemPrompt), makeCtx());
const second = await handler.handle(makeEvent(systemPrompt), makeCtx());
expect(first).toHaveProperty("systemPrompt");
expect((first as { systemPrompt: string }).systemPrompt).not.toContain(
"secret",
);
expect(second).toHaveProperty("systemPrompt");
expect((second as { systemPrompt: string }).systemPrompt).not.toContain(
"secret",
);
});
it("returns empty object on repeated calls with unchanged inputs", async () => {
const { handler } = makeSetup();
await handler.handle(makeEvent(), makeCtx());
const result = await handler.handle(makeEvent(), makeCtx());
expect(result).toEqual({}); expect(result).toEqual({});
}); expect(session.getActiveSkillEntries()).toEqual([]);
it("stores resolved skill entries on the session", async () => {
const { handler, session } = makeSetup();
const spy = vi.spyOn(session, "setActiveSkillEntries");
await handler.handle(makeEvent(), makeCtx());
expect(spy).toHaveBeenCalledWith(expect.any(Array));
});
it("returns modified systemPrompt when prompt changes", async () => {
const systemPrompt = `You are an assistant.\n\nAvailable tools:\n- read\n- write\n`;
const { handler } = makeSetup();
const result = await handler.handle(makeEvent(systemPrompt), makeCtx());
expect(result).toHaveProperty("systemPrompt");
});
it("returns empty object when systemPrompt is unchanged", async () => {
const prompt = "No tools section here.";
const { handler } = makeSetup();
const result = await handler.handle(makeEvent(prompt), makeCtx());
expect(result).toEqual({});
});
it("narrows a denied tool out of the Available tools listing without removing the section", async () => {
const systemPrompt = [
"Available tools:",
"- read: Read file contents",
"- bash: Run shell commands",
].join("\n");
const { handler, permissionManager } = makeSetup({
toolRegistry: {
getActive: vi.fn().mockReturnValue(["read", "bash"]),
},
});
vi.mocked(permissionManager.getToolPermission).mockImplementation((tool) =>
tool === "bash" ? "deny" : "allow",
);
const result = await handler.handle(makeEvent(systemPrompt), makeCtx());
expect(result.systemPrompt).toBeDefined();
const out = result.systemPrompt ?? "";
expect(out).toContain("Available tools:");
expect(out).toContain("- read: Read file contents");
expect(out).not.toContain("- bash");
});
it("keeps the wire system prompt byte-stable across the tool-listing drift between turns", async () => {
const fullProse = [
"You are an assistant.",
"",
"Available tools:",
"- bash: Run shell commands",
"- read: Read file contents",
"- edit: Edit a file",
"- write: Write a file",
"",
"Guidelines:",
"- use bash for file operations like ls, rg, find",
"- use read to examine files instead of cat or sed.",
"- Be concise in your responses",
].join("\n");
const narrowedProse = [
"You are an assistant.",
"",
"Available tools:",
"- read: Read file contents",
"- edit: Edit a file",
"- write: Write a file",
"",
"Guidelines:",
"- use read to examine files instead of cat or sed.",
"- Be concise in your responses",
].join("\n");
const { handler, permissionManager } = makeSetup({
toolRegistry: {
getActive: vi.fn().mockReturnValue(["bash", "read", "edit", "write"]),
},
});
vi.mocked(permissionManager.getToolPermission).mockImplementation((tool) =>
tool === "bash" ? "deny" : "allow",
);
// Turn 1: Pi feeds the full default listing.
const first = await handler.handle(makeEvent(fullProse), makeCtx());
// Turn 2: Pi's setActive rebuild means the event now carries the narrowed
// listing, so the override the handler returns must still match turn 1.
const second = await handler.handle(makeEvent(narrowedProse), makeCtx());
const wire1 = first.systemPrompt ?? fullProse;
const wire2 = second.systemPrompt ?? narrowedProse;
expect(wire1).toBe(narrowedProse);
expect(wire2).toBe(narrowedProse);
}); });
}); });
@@ -1,501 +0,0 @@
/**
* Integration tests for external_directory tool_call enforcement.
*
* These tests exercise PermissionGateHandler.handleToolCall with the
* external-directory gate, verifying the full descriptor→runner pipeline
* while mocking only the PermissionSession boundary.
*
* Regression guard: importing the four external-directory message helpers
* ensures the test file fails to load if any helper is removed.
*/
import { describe, expect, it, vi } from "vitest";
import { EXTENSION_TAG } from "#src/presentation/agent-renderer";
import { buildExternalDirectoryAskPayload } from "#src/presentation/path-ask-payload";
import type { PermissionCheckResult } from "#src/types";
import {
ALL_PATH_BEARING_TOOLS,
ALL_TOOLS,
blockReviewEntries,
EXT_DIR_CWD,
EXTERNAL_PATH,
findExtDirDecision,
makeApprovingPrompter,
makeDenyingPrompter,
makeExtDirCheck,
makeUnavailablePrompter,
OPTIONAL_PATH_TOOLS,
} from "#test/helpers/external-directory-fixtures";
import {
getDecisionEvents,
makeCtx,
makeHandler,
makeToolCallEvent,
} from "#test/helpers/handler-fixtures";
// ── SDK stubs ──────────────────────────────────────────────────────────────
vi.mock("@earendil-works/pi-coding-agent", async (importOriginal) => {
const original =
await importOriginal<typeof import("@earendil-works/pi-coding-agent")>();
return { ...original };
});
// ── Regression guard: helper presence ──────────────────────────────────────
describe("external_directory helper regression guard", () => {
it("the external-directory ask names the path it gates", () => {
expect(
buildExternalDirectoryAskPayload({
toolName: "read",
pathValue: "/outside/file",
cwd: "/project",
agentName: null,
}).request.value,
).toBe("/outside/file");
});
it("EXTENSION_TAG is the expected value", () => {
expect(EXTENSION_TAG).toBe("[pi-permission-system]");
});
// formatExternalDirectoryDenyReason, formatExternalDirectoryUserDeniedReason,
// and formatExternalDirectoryHardStopHint are now renders over the prompt
// payload. Their behavior is tested in presentation/agent-renderer.test.ts.
});
// ── Path scope: gate applicability ────────────────────────────────────────
describe("external_directory path scope", () => {
it("skips external_directory check when path is inside CWD", async () => {
const { handler } = makeHandler({
session: { checkPermission: makeExtDirCheck("deny") },
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", {
input: { path: `${EXT_DIR_CWD}/src/index.ts` },
});
const result = await handler.handleToolCall(event, makeCtx());
// Should not be blocked — the external_directory gate is skipped,
// and the tool gate sees "allow" (default toolState in makeExtDirCheck)
expect(result).toEqual({ action: "allow" });
});
it("fires external_directory check when path is outside CWD", async () => {
const { handler } = makeHandler({
session: { checkPermission: makeExtDirCheck("deny") },
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
const result = await handler.handleToolCall(event, makeCtx());
expect(result).toMatchObject({ action: "block" });
});
it("skips external_directory check for non-path-bearing tool (bash)", async () => {
const { handler } = makeHandler({
session: { checkPermission: makeExtDirCheck("deny", "allow") },
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("bash", {
input: { command: `cat ${EXTERNAL_PATH}` },
});
// bash is not in PATH_BEARING_TOOLS, so the external_directory gate
// for tool path does not fire (bash-external-directory gate is separate)
const result = await handler.handleToolCall(event, makeCtx());
// bash-external-directory gate MAY fire separately, but the tool-path
// external_directory gate does NOT fire for bash
// We verify the checkPermission was not called with "external_directory"
// from the tool-path gate by checking the result is not blocked by it
expect(result).toBeDefined();
});
it.each(
ALL_PATH_BEARING_TOOLS,
)("blocks %s with an out-of-cwd path when external_directory is deny", async (toolName) => {
const { handler } = makeHandler({
session: { checkPermission: makeExtDirCheck("deny") },
tools: ALL_TOOLS,
});
const event = makeToolCallEvent(toolName, {
input: { path: EXTERNAL_PATH },
});
const result = await handler.handleToolCall(event, makeCtx());
expect(result).toMatchObject({ action: "block" });
});
it.each(
OPTIONAL_PATH_TOOLS,
)("skips external_directory check for %s when path is omitted", async (toolName) => {
const { handler } = makeHandler({
session: { checkPermission: makeExtDirCheck("deny") },
tools: ALL_TOOLS,
});
// No path in input — external_directory gate should not fire
const event = makeToolCallEvent(toolName);
const result = await handler.handleToolCall(event, makeCtx());
expect(result).toEqual({ action: "allow" });
});
});
// ── Policy state matrix: allow and deny ────────────────────────────────────
describe("external_directory policy state — allow", () => {
it("falls through to tool gate when external_directory is allow", async () => {
const { handler } = makeHandler({
session: { checkPermission: makeExtDirCheck("allow") },
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
const result = await handler.handleToolCall(event, makeCtx());
expect(result).toEqual({ action: "allow" });
});
it("emits decision event with policy_allow on external_directory surface", async () => {
const { handler, events } = makeHandler({
session: { checkPermission: makeExtDirCheck("allow") },
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
await handler.handleToolCall(event, makeCtx());
expect(findExtDirDecision(events)).toMatchObject({
surface: "external_directory",
result: "allow",
resolution: "policy_allow",
});
});
it("does not write a block review-log entry when external_directory is allow", async () => {
const { handler, logger } = makeHandler({
session: { checkPermission: makeExtDirCheck("allow") },
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
await handler.handleToolCall(event, makeCtx());
expect(blockReviewEntries(logger)).toHaveLength(0);
});
});
// #144: allow external reads, gate external writes
describe("external_directory — allow external reads, gate external writes (#144)", () => {
it("allows read of external path when external_directory and read are both allow", async () => {
const { handler } = makeHandler({
session: { checkPermission: makeExtDirCheck("allow", "allow") },
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
const result = await handler.handleToolCall(event, makeCtx());
expect(result).toEqual({ action: "allow" });
});
it("prompts for write to external path when external_directory allows but write is ask", async () => {
const { handler, prompter } = makeHandler({
session: { checkPermission: makeExtDirCheck("allow", "ask") },
prompter: makeApprovingPrompter(),
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("write", {
input: { path: EXTERNAL_PATH },
});
const result = await handler.handleToolCall(event, makeCtx());
// external_directory passes; write gate prompts and user approves
expect(result).toEqual({ action: "allow" });
expect(prompter.escalate).toHaveBeenCalledOnce();
});
it("blocks write to external path when external_directory allows but write is deny", async () => {
const { handler } = makeHandler({
session: { checkPermission: makeExtDirCheck("allow", "deny") },
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("write", {
input: { path: EXTERNAL_PATH },
});
const result = await handler.handleToolCall(event, makeCtx());
expect(result).toMatchObject({ action: "block" });
});
it("emits separate decision events for external_directory and write surfaces", async () => {
const { handler, events } = makeHandler({
session: { checkPermission: makeExtDirCheck("allow", "deny") },
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("write", {
input: { path: EXTERNAL_PATH },
});
await handler.handleToolCall(event, makeCtx());
const decisions = getDecisionEvents(events);
const writeDecision = decisions.find((d) => d.surface === "write");
expect(findExtDirDecision(events)).toMatchObject({
surface: "external_directory",
result: "allow",
resolution: "policy_allow",
});
expect(writeDecision).toMatchObject({
surface: "write",
result: "deny",
resolution: "policy_deny",
});
});
});
describe("external_directory policy state — deny", () => {
it("blocks with reason containing the external path", async () => {
const { handler } = makeHandler({
session: { checkPermission: makeExtDirCheck("deny") },
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
const result = await handler.handleToolCall(event, makeCtx());
expect(result).toMatchObject({ action: "block" });
expect((result as { reason?: string }).reason).toContain(EXTERNAL_PATH);
});
it("block reason contains extension attribution", async () => {
const { handler } = makeHandler({
session: { checkPermission: makeExtDirCheck("deny") },
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
const result = await handler.handleToolCall(event, makeCtx());
expect((result as { reason?: string }).reason).toContain(
"[pi-permission-system]",
);
expect((result as { reason?: string }).reason).not.toContain("Hard stop");
});
it("writes review-log entry with resolution policy_denied", async () => {
const { handler, logger } = makeHandler({
session: { checkPermission: makeExtDirCheck("deny") },
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
await handler.handleToolCall(event, makeCtx());
const entries = blockReviewEntries(logger);
expect(entries.length).toBeGreaterThanOrEqual(1);
expect(entries[0][1]).toMatchObject({
resolution: "policy_denied",
});
});
it("emits decision event with policy_deny on external_directory surface", async () => {
const { handler, events } = makeHandler({
session: { checkPermission: makeExtDirCheck("deny") },
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
await handler.handleToolCall(event, makeCtx());
expect(findExtDirDecision(events)).toMatchObject({
surface: "external_directory",
result: "deny",
resolution: "policy_deny",
});
});
});
// ── Policy state matrix: ask ────────────────────────────────────────────────
describe("external_directory policy state — ask", () => {
it("does not block when user approves", async () => {
const { handler } = makeHandler({
session: { checkPermission: makeExtDirCheck("ask") },
prompter: makeApprovingPrompter(),
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
const result = await handler.handleToolCall(event, makeCtx());
expect(result).toEqual({ action: "allow" });
});
it("emits user_approved decision when user approves", async () => {
const { handler, events } = makeHandler({
session: { checkPermission: makeExtDirCheck("ask") },
prompter: makeApprovingPrompter(),
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
await handler.handleToolCall(event, makeCtx());
expect(findExtDirDecision(events)).toMatchObject({
surface: "external_directory",
result: "allow",
resolution: "user_approved",
});
});
it("blocks when user denies", async () => {
const { handler } = makeHandler({
session: { checkPermission: makeExtDirCheck("ask") },
prompter: makeDenyingPrompter(),
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
const result = await handler.handleToolCall(event, makeCtx());
expect(result).toMatchObject({ action: "block" });
});
it("emits user_denied decision when user denies", async () => {
const { handler, events } = makeHandler({
session: { checkPermission: makeExtDirCheck("ask") },
prompter: makeDenyingPrompter(),
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
await handler.handleToolCall(event, makeCtx());
expect(findExtDirDecision(events)).toMatchObject({
surface: "external_directory",
result: "deny",
resolution: "user_denied",
});
});
it("block reason includes denialReason when user provides one", async () => {
const { handler } = makeHandler({
session: { checkPermission: makeExtDirCheck("ask") },
prompter: makeDenyingPrompter("not needed"),
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
const result = await handler.handleToolCall(event, makeCtx());
expect(result).toMatchObject({ action: "block" });
expect((result as { reason?: string }).reason).toContain("not needed");
});
it("blocks with confirmation_unavailable when no UI is available", async () => {
const { handler } = makeHandler({
session: { checkPermission: makeExtDirCheck("ask") },
prompter: makeUnavailablePrompter(),
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
const result = await handler.handleToolCall(
event,
makeCtx({ hasUI: false }),
);
expect(result).toMatchObject({ action: "block" });
// The gate surface names the boundary; an unavailable verdict states only
// that approval was unreachable, since no retry shape changes that.
expect((result as { reason?: string }).reason).toBe(
`${EXTENSION_TAG} This 'external_directory' call for tool 'read' for path '${EXTERNAL_PATH}' requires approval, but no interactive UI is available.`,
);
});
it("emits confirmation_unavailable decision when no UI", async () => {
const { handler, events } = makeHandler({
session: { checkPermission: makeExtDirCheck("ask") },
prompter: makeUnavailablePrompter(),
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
await handler.handleToolCall(event, makeCtx({ hasUI: false }));
expect(findExtDirDecision(events)).toMatchObject({
surface: "external_directory",
result: "deny",
resolution: "confirmation_unavailable",
});
});
});
// ── Per-agent override ─────────────────────────────────────────────────────
describe("external_directory per-agent override", () => {
it("honors per-agent override of external_directory policy", async () => {
// checkPermission varies by agentName: allow for "special-agent", deny otherwise
const agentAwareCheck = vi
.fn()
.mockImplementation(
(
surface: string,
_input: unknown,
agentName?: string,
): PermissionCheckResult => {
if (surface === "external_directory") {
const state =
agentName === "special-agent" ? "allow" : ("deny" as const);
return {
state,
toolName: surface,
source: "tool",
origin: agentName === "special-agent" ? "agent" : "global",
};
}
return {
state: "allow",
toolName: surface,
source: "tool",
origin: "builtin",
};
},
);
// With agent override → allowed
const { handler: handler1, events: events1 } = makeHandler({
session: {
checkPermission: agentAwareCheck,
resolveAgentName: vi.fn().mockReturnValue("special-agent"),
},
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
const result1 = await handler1.handleToolCall(event, makeCtx());
expect(result1).toEqual({ action: "allow" });
expect(findExtDirDecision(events1)).toMatchObject({
result: "allow",
resolution: "policy_allow",
agentName: "special-agent",
});
// Without agent override → denied
const { handler: handler2 } = makeHandler({
session: {
checkPermission: agentAwareCheck,
resolveAgentName: vi.fn().mockReturnValue(null),
},
tools: ALL_TOOLS,
});
const result2 = await handler2.handleToolCall(event, makeCtx());
expect(result2).toMatchObject({ action: "block" });
});
});
// ── Decision event surface and value ──────────────────────────────────────
describe("external_directory decision event fields", () => {
it("decision event value is the external path", async () => {
const { handler, events } = makeHandler({
session: { checkPermission: makeExtDirCheck("deny") },
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
await handler.handleToolCall(event, makeCtx());
const extDirDecision = findExtDirDecision(events);
expect(extDirDecision).toBeDefined();
expect(extDirDecision!.value).toBe(EXTERNAL_PATH);
});
it("decision event includes agentName when present", async () => {
const { handler, events } = makeHandler({
session: {
checkPermission: makeExtDirCheck("allow"),
resolveAgentName: vi.fn().mockReturnValue("my-agent"),
},
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
await handler.handleToolCall(event, makeCtx());
expect(findExtDirDecision(events)).toMatchObject({
agentName: "my-agent",
});
});
it("decision event agentName is null when no agent", async () => {
const { handler, events } = makeHandler({
session: { checkPermission: makeExtDirCheck("allow") },
tools: ALL_TOOLS,
});
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
await handler.handleToolCall(event, makeCtx());
expect(findExtDirDecision(events)).toMatchObject({
agentName: null,
});
});
});
@@ -1,175 +0,0 @@
/**
* Integration tests verifying that sequential tool calls to the same
* external path only prompt once — the session-approval recorded by the
* first call covers the second.
*
* Uses real PermissionSession + PermissionResolver + SessionRules so the
* stateful approval-tracking path is exercised end-to-end.
*/
import { describe, expect, it, vi } from "vitest";
import {
makeApprovingPrompter,
makeDeduplicatingHandler,
makeDedupWiring,
makeExtDirBashEvent,
makeExtDirToolEvent,
} from "#test/helpers/external-directory-fixtures";
import { makeCtx } from "#test/helpers/handler-fixtures";
// ── SDK stub ───────────────────────────────────────────────────────────────
vi.mock("@earendil-works/pi-coding-agent", async (importOriginal) => {
const original =
await importOriginal<typeof import("@earendil-works/pi-coding-agent")>();
return { ...original };
});
// ── tests ──────────────────────────────────────────────────────────────────
describe("external-directory session dedup", () => {
describe("path-bearing tools (read, write, edit)", () => {
it("does not re-prompt for the same external path after session approval", async () => {
const { handler, prompter } = makeDeduplicatingHandler();
const ctx = makeCtx();
const externalPath = "/outside/project/data.txt";
// First call — should prompt
const event1 = makeExtDirToolEvent("read", externalPath, "tc-1");
const result1 = await handler.handleToolCall(event1, ctx);
expect(result1).toEqual({ action: "allow" });
expect(prompter.escalate).toHaveBeenCalledTimes(1);
// Second call — same path, should hit session rule, no prompt
const event2 = makeExtDirToolEvent("read", externalPath, "tc-2");
const result2 = await handler.handleToolCall(event2, ctx);
expect(result2).toEqual({ action: "allow" });
expect(prompter.escalate).toHaveBeenCalledTimes(1);
});
it("does not re-prompt for a different file in the same external directory", async () => {
const { handler, prompter } = makeDeduplicatingHandler();
const ctx = makeCtx();
// First call — prompt for /outside/project/a.txt
const event1 = makeExtDirToolEvent(
"read",
"/outside/project/a.txt",
"tc-1",
);
await handler.handleToolCall(event1, ctx);
expect(prompter.escalate).toHaveBeenCalledTimes(1);
// Second call — /outside/project/b.txt is in the same directory
const event2 = makeExtDirToolEvent(
"read",
"/outside/project/b.txt",
"tc-2",
);
await handler.handleToolCall(event2, ctx);
expect(prompter.escalate).toHaveBeenCalledTimes(1);
});
it("does prompt for a file in a different external directory", async () => {
const { handler, prompter } = makeDeduplicatingHandler();
const ctx = makeCtx();
// First call — /outside/alpha/file.txt
const event1 = makeExtDirToolEvent(
"read",
"/outside/alpha/file.txt",
"tc-1",
);
await handler.handleToolCall(event1, ctx);
expect(prompter.escalate).toHaveBeenCalledTimes(1);
// Second call — /outside/beta/file.txt is a different directory
const event2 = makeExtDirToolEvent(
"read",
"/outside/beta/file.txt",
"tc-2",
);
await handler.handleToolCall(event2, ctx);
expect(prompter.escalate).toHaveBeenCalledTimes(2);
});
it("re-prompts when user approved once (not for session)", async () => {
const approveOnce = makeApprovingPrompter();
const { handler, prompter } = makeDeduplicatingHandler(approveOnce);
const ctx = makeCtx();
const externalPath = "/outside/project/data.txt";
// First call — prompt, approved once
const event1 = makeExtDirToolEvent("read", externalPath, "tc-1");
await handler.handleToolCall(event1, ctx);
expect(prompter.escalate).toHaveBeenCalledTimes(1);
// Second call — no session rule recorded, should prompt again
const event2 = makeExtDirToolEvent("read", externalPath, "tc-2");
await handler.handleToolCall(event2, ctx);
expect(prompter.escalate).toHaveBeenCalledTimes(2);
});
});
describe("bash commands with external paths", () => {
it("does not re-prompt for a bash command referencing the same external path after session approval", async () => {
const { handler, prompter } = makeDeduplicatingHandler();
const ctx = makeCtx();
// First call — bash referencing /tmp/out.txt
const event1 = makeExtDirBashEvent("echo hello > /tmp/out.txt", "tc-1");
const result1 = await handler.handleToolCall(event1, ctx);
expect(result1).toEqual({ action: "allow" });
expect(prompter.escalate).toHaveBeenCalledTimes(1);
// Second call — different bash command, same external path
const event2 = makeExtDirBashEvent("cat /tmp/out.txt", "tc-2");
const result2 = await handler.handleToolCall(event2, ctx);
expect(result2).toEqual({ action: "allow" });
expect(prompter.escalate).toHaveBeenCalledTimes(1);
});
it("does not re-prompt for read after bash already approved the same directory", async () => {
const { handler, prompter } = makeDeduplicatingHandler();
const ctx = makeCtx();
// First call — bash writes to /tmp/out.txt
const event1 = makeExtDirBashEvent("echo hello > /tmp/out.txt", "tc-1");
await handler.handleToolCall(event1, ctx);
expect(prompter.escalate).toHaveBeenCalledTimes(1);
// Second call — read from /tmp/out.txt (same directory, different tool)
const event2 = makeExtDirToolEvent("read", "/tmp/out.txt", "tc-2");
await handler.handleToolCall(event2, ctx);
expect(prompter.escalate).toHaveBeenCalledTimes(1);
});
});
});
// ---------------------------------------------------------------------------
// Moved from permission-system.test.ts catch-all (#342)
// ---------------------------------------------------------------------------
describe("session shutdown clears external-directory approvals", () => {
it("re-prompts for the same path after session shutdown", async () => {
const { handler, prompter, session } = makeDedupWiring();
const externalPath = "/tmp/sibling/foo.ts";
const ctx = makeCtx();
const event = makeExtDirToolEvent("read", externalPath, "tc-1");
// First access: prompt fires and records session approval.
await handler.handleToolCall(event, ctx);
expect(vi.mocked(prompter.escalate)).toHaveBeenCalledTimes(1);
// Second access: covered by session approval — no re-prompt.
await handler.handleToolCall({ ...event, toolCallId: "tc-2" }, ctx);
expect(vi.mocked(prompter.escalate)).toHaveBeenCalledTimes(1);
// Shutdown clears session approvals.
session.shutdown();
// Third access: session rules cleared — must re-prompt.
await handler.handleToolCall({ ...event, toolCallId: "tc-3" }, ctx);
expect(vi.mocked(prompter.escalate)).toHaveBeenCalledTimes(2);
});
});
@@ -1,168 +0,0 @@
/**
* Acceptance test for issue #418.
*
* Reproduces the reported bug with a real symlink (no `realpathSync` mock):
* an `external_directory` allow configured for the path as the user types it
* (`<link>/*`) must allow access even though the OS resolves `<link>` to a
* different canonical directory. Exercised end-to-end through the real
* `PermissionManager` + `PermissionResolver` for both a path-bearing tool and
* a bash command, and for an allow keyed on the symlink-resolved form too.
*/
import { mkdtempSync, realpathSync, rmSync, symlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { BashProgram } from "#src/access-intent/bash/program";
import { describeBashExternalDirectoryGate } from "#src/handlers/gates/bash-external-directory";
import {
type GateDescriptor,
isGateBypass,
isGateDescriptor,
} from "#src/handlers/gates/descriptor";
import { describeExternalDirectoryGate } from "#src/handlers/gates/external-directory";
import type { ToolCallContext } from "#src/handlers/gates/types";
import { pathFlavorForPlatform } from "#src/path/path-flavor";
import { PathNormalizer } from "#src/path-normalizer";
import { PermissionResolver } from "#src/permission-resolver";
import { SessionRules } from "#src/session-rules";
import type { ScopeConfig } from "#src/types";
import { createManager } from "#test/helpers/manager-harness";
// ── real symlink fixture ─────────────────────────────────────────────────────
let realDir: string;
let linkDir: string;
let cwd: string;
const tempRoots: string[] = [];
function mkTemp(prefix: string): string {
const dir = mkdtempSync(join(tmpdir(), prefix));
tempRoots.push(dir);
return dir;
}
beforeEach(() => {
realDir = mkTemp("ext-real-");
const linkParent = mkTemp("ext-link-");
linkDir = join(linkParent, "link");
symlinkSync(realDir, linkDir);
cwd = mkTemp("ext-cwd-");
});
afterEach(() => {
while (tempRoots.length > 0) {
const dir = tempRoots.pop();
if (dir) rmSync(dir, { recursive: true, force: true });
}
});
function makeResolver(config: ScopeConfig) {
const { manager, cleanup } = createManager(config);
manager.configureForCwd(cwd);
const resolver = new PermissionResolver(manager, new SessionRules());
return { resolver, cleanup };
}
function readTcc(): ToolCallContext {
return {
toolName: "read",
agentName: null,
input: { path: join(linkDir, "file.ts") },
toolCallId: "tc-1",
cwd,
};
}
// ── tests ────────────────────────────────────────────────────────────────────
describe("external_directory symlink acceptance (#418)", () => {
it("allows a path-bearing tool when the allow is keyed on the typed (symlinked) path", () => {
const { resolver, cleanup } = makeResolver({
permission: {
external_directory: { "*": "ask", [`${linkDir}/*`]: "allow" },
},
});
try {
const result = describeExternalDirectoryGate(
readTcc(),
[],
resolver,
new PathNormalizer(pathFlavorForPlatform(process.platform), cwd),
);
expect(isGateDescriptor(result)).toBe(true);
expect((result as GateDescriptor).preCheck?.state).toBe("allow");
} finally {
cleanup();
}
});
it("allows a path-bearing tool when the allow is keyed on the resolved path", () => {
// Key the allow on the fully symlink-resolved directory (on macOS the
// tmpdir root itself is a symlink, e.g. /var -> /private/var).
const resolved = realpathSync(realDir);
const { resolver, cleanup } = makeResolver({
permission: {
external_directory: { "*": "ask", [`${resolved}/*`]: "allow" },
},
});
try {
const result = describeExternalDirectoryGate(
readTcc(),
[],
resolver,
new PathNormalizer(pathFlavorForPlatform(process.platform), cwd),
);
expect(isGateDescriptor(result)).toBe(true);
expect((result as GateDescriptor).preCheck?.state).toBe("allow");
} finally {
cleanup();
}
});
it("still prompts (ask) when no external_directory allow matches", () => {
const { resolver, cleanup } = makeResolver({
permission: { external_directory: { "*": "ask" } },
});
try {
const result = describeExternalDirectoryGate(
readTcc(),
[],
resolver,
new PathNormalizer(pathFlavorForPlatform(process.platform), cwd),
);
expect(isGateDescriptor(result)).toBe(true);
expect((result as GateDescriptor).preCheck?.state).toBe("ask");
} finally {
cleanup();
}
});
it("allows a bash command referencing the typed (symlinked) path", async () => {
const { resolver, cleanup } = makeResolver({
permission: {
external_directory: { "*": "ask", [`${linkDir}/*`]: "allow" },
},
});
try {
const command = `cat ${join(linkDir, "file.ts")}`;
const tcc: ToolCallContext = {
toolName: "bash",
agentName: null,
input: { command },
toolCallId: "tc-2",
cwd,
};
const program = await BashProgram.parse(
command,
new PathNormalizer(pathFlavorForPlatform(process.platform), cwd),
);
const result = describeBashExternalDirectoryGate(tcc, program, resolver);
// All external paths are covered by the allow → bypass, no prompt.
expect(isGateBypass(result)).toBe(true);
} finally {
cleanup();
}
});
});
@@ -1,395 +0,0 @@
import { homedir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import type { AccessIntent } from "#src/access-intent/access-intent";
import { BashProgram } from "#src/access-intent/bash/program";
import { describeBashExternalDirectoryGate } from "#src/handlers/gates/bash-external-directory";
import type {
GateBypass,
GateDescriptor,
GateResult,
} from "#src/handlers/gates/descriptor";
import { isGateBypass, isGateDescriptor } from "#src/handlers/gates/descriptor";
import type { ToolCallContext } from "#src/handlers/gates/types";
import { pathFlavorForPlatform, win32PathFlavor } from "#src/path/path-flavor";
import { PathNormalizer } from "#src/path-normalizer";
import type { ScopedPermissionResolver } from "#src/permission-resolver";
import {
allEvidence,
findEvidence,
type PromptPayload,
} from "#src/presentation/prompt-payload";
import type { PermissionCheckResult } from "#src/types";
import { getNonEmptyString, toRecord } from "#src/value-guards";
import { makeResolver } from "#test/helpers/gate-fixtures";
// ── helpers ────────────────────────────────────────────────────────────────
/** Every escaping path the payload lists, in payload order. */
function externalPaths(payload: PromptPayload): string[] {
return allEvidence(payload, "external path").map((entry) => entry.text);
}
function makeTcc(overrides: Partial<ToolCallContext> = {}): ToolCallContext {
return {
toolName: "bash",
agentName: null,
input: { command: "cat /outside/project/file.ts" },
toolCallId: "tc-1",
cwd: "/test/project",
...overrides,
};
}
function makeCheckResult(
state: "allow" | "deny" | "ask",
overrides: Partial<PermissionCheckResult> = {},
): PermissionCheckResult {
return {
state,
toolName: "external_directory",
source: "special",
origin: "builtin",
...overrides,
};
}
/** Extract the policy match values a resolve(intent) call carries. */
function intentValues(intent: AccessIntent): readonly string[] {
if (intent.kind === "access-path") return intent.path.matchValues();
return [];
}
/**
* Mirror the handler's parse-once derivation: parse the bash command into a
* shared `BashProgram` and inject it, exactly as `permission-gate-handler.ts`
* does, so the gate is exercised through the production wiring.
*/
async function describeGate(
tcc: ToolCallContext,
resolver: ScopedPermissionResolver,
): Promise<GateResult> {
const command = getNonEmptyString(toRecord(tcc.input).command);
const bashProgram =
tcc.toolName === "bash" && command
? await BashProgram.parse(
command,
new PathNormalizer(pathFlavorForPlatform(process.platform), tcc.cwd),
)
: null;
return describeBashExternalDirectoryGate(tcc, bashProgram, resolver);
}
// ── tests ──────────────────────────────────────────────────────────────────
describe("describeBashExternalDirectoryGate", () => {
it("returns null when tool is not bash", async () => {
const result = await describeGate(
makeTcc({ toolName: "read" }),
makeResolver(makeCheckResult("ask")),
);
expect(result).toBeNull();
});
it("returns null when command has no external paths", async () => {
const result = await describeGate(
makeTcc({ input: { command: "ls -la" } }),
makeResolver(makeCheckResult("ask")),
);
expect(result).toBeNull();
});
describe("resolved shell expansions (#694)", () => {
it("prompts for a $HOME write target that does not exist yet", async () => {
const resolver = makeResolver(makeCheckResult("ask"));
const result = await describeGate(
makeTcc({
input: {
command: 'touch "$HOME/pi-permission-system-repro-new"',
},
}),
resolver,
);
expect(isGateDescriptor(result)).toBe(true);
expect(externalPaths((result as GateDescriptor).payload)).toEqual([
join(homedir(), "pi-permission-system-repro-new"),
]);
});
// biome-ignore lint/suspicious/noTemplateCurlyInString: intentional literal — a braced shell expansion, not a template string
it("prompts for a braced ${HOME} reference", async () => {
const result = await describeGate(
// biome-ignore lint/suspicious/noTemplateCurlyInString: intentional literal — a braced shell expansion, not a template string
makeTcc({ input: { command: 'ls "${HOME}/somewhere"' } }),
makeResolver(makeCheckResult("ask")),
);
expect(isGateDescriptor(result)).toBe(true);
expect(externalPaths((result as GateDescriptor).payload)).toEqual([
join(homedir(), "somewhere"),
]);
});
it("does not prompt for a variable it cannot resolve", async () => {
const result = await describeGate(
makeTcc({ input: { command: 'CURRENT="$HOME"; ls "$CURRENT"' } }),
makeResolver(makeCheckResult("ask")),
);
expect(result).toBeNull();
});
});
it("resolves each external path on the external_directory surface via an access-path intent (#418)", async () => {
const resolver = makeResolver(makeCheckResult("ask"));
await describeGate(
makeTcc({ input: { command: "cat /outside/a.ts" } }),
resolver,
);
const intent = resolver.resolve.mock.calls[0][0];
expect(intent).toMatchObject({
kind: "access-path",
surface: "external_directory",
agentName: undefined,
});
expect(intentValues(intent)).toEqual(["/outside/a.ts"]);
});
it("carries the deciding path's access facts on promptDetails (bash external_directory surface)", async () => {
const resolver = makeResolver(makeCheckResult("ask"));
const result = (await describeGate(
makeTcc({ input: { command: "cat /outside/a.ts" } }),
resolver,
)) as GateDescriptor;
const intent = resolver.resolve.mock.calls[0][0];
const path = intent.kind === "access-path" ? intent.path : undefined;
expect(path).toBeDefined();
expect(result.promptDetails.accessIntent).toEqual({
surface: "external_directory",
matchValues: path?.matchValues(),
boundaryValue: path?.boundaryValue(),
});
});
it("emits a bash_external_directory payload listing every escaping path", async () => {
const resolver = makeResolver(makeCheckResult("ask"));
const result = (await describeGate(
makeTcc({ input: { command: "cat /outside/a.ts" } }),
resolver,
)) as GateDescriptor;
expect(result.payload.kind).toBe("bash_external_directory");
// The command is the decision value; the paths it reaches are evidence.
expect(result.payload.request.value).toBe("cat /outside/a.ts");
expect(result.payload.evidence).toContainEqual({
label: "external path",
text: "/outside/a.ts",
detail: null,
});
});
it("returns GateBypass when all external paths are session-covered", async () => {
const resolver = makeResolver(
makeCheckResult("allow", { source: "session" }),
);
const result = await describeGate(makeTcc(), resolver);
expect(result).not.toBeNull();
expect(isGateBypass(result)).toBe(true);
const bypass = result as GateBypass;
expect(bypass.action).toBe("allow");
expect(bypass.log).toMatchObject({
event: "permission_request.session_approved",
details: expect.objectContaining({ resolution: "session_approved" }),
});
expect(bypass.decidedBy).toEqual({
kind: "session_approval",
surface: "external_directory",
pattern: null,
});
});
it("returns GateDescriptor with multi-pattern sessionApproval for uncovered paths", async () => {
const result = await describeGate(
makeTcc({ input: { command: "diff /outside/a.ts /outside/b.ts" } }),
makeResolver(makeCheckResult("ask")),
);
expect(isGateDescriptor(result)).toBe(true);
const desc = result as GateDescriptor;
expect(desc.sessionApproval).toBeDefined();
if (!desc.sessionApproval) return;
expect(desc.sessionApproval.patterns.length).toBeGreaterThan(0);
});
it("returns GateBypass when all external paths are config-level allowed", async () => {
// Config-level allow (source: "special") should suppress the prompt,
// not just session-level allow. This was the bug: source !== "session"
// kept config-allowed paths in the uncovered set.
const resolver = makeResolver();
resolver.resolve.mockImplementation((intent) =>
intentValues(intent).length > 0
? makeCheckResult("allow", { source: "special" })
: makeCheckResult("ask"),
);
const result = await describeGate(makeTcc(), resolver);
expect(result).not.toBeNull();
expect(isGateBypass(result)).toBe(true);
});
it("uses worst-check state from uncovered paths for preCheck (config deny > catch-all ask)", async () => {
// The path-less extCheck used to always return the "*" catch-all (ask),
// silently downgrading a config-level deny to ask. After the fix, the
// descriptor's preCheck is derived from the actual path check result.
const resolver = makeResolver();
resolver.resolve.mockImplementation((intent) =>
intentValues(intent).length > 0
? makeCheckResult("deny", { source: "special" })
: makeCheckResult("ask"),
);
const result = await describeGate(makeTcc(), resolver);
expect(isGateDescriptor(result)).toBe(true);
const desc = result as GateDescriptor;
expect(desc.preCheck?.state).toBe("deny");
});
it("descriptor surface is 'external_directory'", async () => {
const result = await describeGate(
makeTcc(),
makeResolver(makeCheckResult("ask")),
);
const desc = result as GateDescriptor;
expect(desc.surface).toBe("external_directory");
});
it("descriptor decision surface is 'external_directory'", async () => {
const result = await describeGate(
makeTcc(),
makeResolver(makeCheckResult("ask")),
);
const desc = result as GateDescriptor;
expect(desc.decision.surface).toBe("external_directory");
});
it("payload carries the command and the boundary it escaped", async () => {
const result = await describeGate(
makeTcc({ input: { command: "cat /outside/file.ts" } }),
makeResolver(makeCheckResult("ask")),
);
const { payload } = result as GateDescriptor;
expect(payload.kind).toBe("bash_external_directory");
expect(payload.request.value).toBe("cat /outside/file.ts");
expect(findEvidence(payload, "working directory")?.text).toBe(
"/test/project",
);
});
it("promptDetails includes command and tool_call source", async () => {
const result = await describeGate(
makeTcc({ agentName: "agent-1", toolCallId: "tc-5" }),
makeResolver(makeCheckResult("ask")),
);
const desc = result as GateDescriptor;
expect(desc.promptDetails).toMatchObject({
source: "tool_call",
agentName: "agent-1",
toolCallId: "tc-5",
toolName: "bash",
command: "cat /outside/project/file.ts",
});
});
it("config-allowed path is excluded; remaining ask path produces a descriptor", async () => {
// One path config-allowed, one config-ask → descriptor with only the ask path.
const resolver = makeResolver();
resolver.resolve.mockImplementation((intent) =>
intentValues(intent).includes("/outside/a.ts")
? makeCheckResult("allow", { source: "special" })
: makeCheckResult("ask"),
);
const result = await describeGate(
makeTcc({ input: { command: "diff /outside/a.ts /outside/b.ts" } }),
resolver,
);
expect(isGateDescriptor(result)).toBe(true);
const desc = result as GateDescriptor;
expect(desc.sessionApproval).toBeDefined();
if (!desc.sessionApproval) return;
expect(desc.sessionApproval.patterns.length).toBe(1);
expect(desc.preCheck?.state).toBe("ask");
});
it("config-denied path makes worstCheck deny even when another path is ask", async () => {
// One path config-denied, one config-ask → descriptor with preCheck.state === "deny".
const resolver = makeResolver();
resolver.resolve.mockImplementation((intent) =>
intentValues(intent).includes("/outside/a.ts")
? makeCheckResult("deny", { source: "special" })
: makeCheckResult("ask"),
);
const result = await describeGate(
makeTcc({ input: { command: "diff /outside/a.ts /outside/b.ts" } }),
resolver,
);
expect(isGateDescriptor(result)).toBe(true);
const desc = result as GateDescriptor;
expect(desc.preCheck?.state).toBe("deny");
// Both paths are uncovered (neither is allow), so both patterns are included.
expect(desc.sessionApproval).toBeDefined();
if (!desc.sessionApproval) return;
expect(desc.sessionApproval.patterns.length).toBe(2);
});
it("only includes uncovered paths when some are session-covered", async () => {
const resolver = makeResolver();
resolver.resolve.mockImplementation((intent) =>
intentValues(intent).includes("/outside/a.ts")
? makeCheckResult("allow", { source: "session" })
: makeCheckResult("ask"),
);
const result = await describeGate(
makeTcc({ input: { command: "diff /outside/a.ts /outside/b.ts" } }),
resolver,
);
expect(isGateDescriptor(result)).toBe(true);
const desc = result as GateDescriptor;
// Should have patterns only for the uncovered path
expect(desc.sessionApproval).toBeDefined();
if (!desc.sessionApproval) return;
expect(desc.sessionApproval.patterns.length).toBe(1);
});
});
describe("describeBashExternalDirectoryGate — Git Bash semantics (win32)", () => {
async function describeGateWin32(
tcc: ToolCallContext,
resolver: ScopedPermissionResolver,
): Promise<GateResult> {
const command = getNonEmptyString(toRecord(tcc.input).command);
const bashProgram =
tcc.toolName === "bash" && command
? await BashProgram.parse(
command,
new PathNormalizer(win32PathFlavor, tcc.cwd),
)
: null;
return describeBashExternalDirectoryGate(tcc, bashProgram, resolver);
}
const winTcc = (command: string): ToolCallContext =>
makeTcc({ cwd: "C:/projects/app", input: { command } });
it("does not prompt for a /dev/null redirect target", async () => {
const result = await describeGateWin32(
winTcc("echo hi > /dev/null"),
makeResolver(makeCheckResult("ask")),
);
expect(result).toBeNull();
});
it("prompts for a /tmp path displayed as typed, not as C:\\tmp", async () => {
const result = await describeGateWin32(
winTcc("ls /tmp"),
makeResolver(makeCheckResult("ask")),
);
expect(isGateDescriptor(result)).toBe(true);
expect(externalPaths((result as GateDescriptor).payload)).toEqual(["/tmp"]);
});
});
@@ -1,135 +0,0 @@
import { describe, expect, it } from "vitest";
import { AccessPath } from "#src/access-intent/access-path";
import {
resolveExternalDirectoryPolicy,
selectUncoveredExternalPaths,
} from "#src/handlers/gates/external-directory-policy";
import { posixPathFlavor } from "#src/path/path-flavor";
import type { PermissionCheckResult } from "#src/types";
import { makeResolver } from "#test/helpers/gate-fixtures";
const cwd = "/test/project";
function makeCheckResult(
state: "allow" | "deny" | "ask",
overrides: Partial<PermissionCheckResult> = {},
): PermissionCheckResult {
return {
state,
toolName: "external_directory",
source: "special",
origin: "builtin",
...overrides,
};
}
describe("resolveExternalDirectoryPolicy", () => {
it("resolves the path's match aliases on the external_directory surface (#418)", () => {
const path = AccessPath.forPath("/outside/a.ts", {
cwd,
flavor: posixPathFlavor,
});
const resolver = makeResolver(makeCheckResult("ask"));
const result = resolveExternalDirectoryPolicy(path, resolver, undefined);
expect(resolver.resolve).toHaveBeenCalledWith({
kind: "access-path",
surface: "external_directory",
path,
agentName: undefined,
});
expect(result).toEqual(makeCheckResult("ask"));
});
it("threads the agent name through to the resolver", () => {
const path = AccessPath.forPath("/outside/a.ts", {
cwd,
flavor: posixPathFlavor,
});
const resolver = makeResolver(makeCheckResult("allow"));
resolveExternalDirectoryPolicy(path, resolver, "reviewer");
expect(resolver.resolve).toHaveBeenCalledWith({
kind: "access-path",
surface: "external_directory",
path,
agentName: "reviewer",
});
});
});
describe("selectUncoveredExternalPaths", () => {
it("returns no uncovered paths when every path resolves to allow", () => {
const paths = [
AccessPath.forPath("/outside/a.ts", { cwd, flavor: posixPathFlavor }),
AccessPath.forPath("/outside/b.ts", { cwd, flavor: posixPathFlavor }),
];
const resolver = makeResolver(makeCheckResult("allow"));
const { uncovered, worstCheck } = selectUncoveredExternalPaths(
paths,
resolver,
undefined,
);
expect(uncovered).toEqual([]);
expect(worstCheck).toBeUndefined();
});
it("collects only paths whose resolved state is not allow", () => {
const allowed = AccessPath.forPath("/outside/ok.ts", {
cwd,
flavor: posixPathFlavor,
});
const asked = AccessPath.forPath("/outside/ask.ts", {
cwd,
flavor: posixPathFlavor,
});
const resolver = makeResolver();
resolver.resolve.mockImplementation((intent) => {
const values =
intent.kind === "access-path" ? intent.path.matchValues() : [];
return values.includes("/outside/ok.ts")
? makeCheckResult("allow")
: makeCheckResult("ask");
});
const { uncovered } = selectUncoveredExternalPaths(
[allowed, asked],
resolver,
undefined,
);
expect(uncovered.map(({ path }) => path.value())).toEqual([asked.value()]);
});
it("returns the most restrictive uncovered check as worstCheck (deny > ask)", () => {
const asked = AccessPath.forPath("/outside/ask.ts", {
cwd,
flavor: posixPathFlavor,
});
const denied = AccessPath.forPath("/outside/deny.ts", {
cwd,
flavor: posixPathFlavor,
});
const resolver = makeResolver();
resolver.resolve.mockImplementation((intent) => {
const values =
intent.kind === "access-path" ? intent.path.matchValues() : [];
return values.includes("/outside/deny.ts")
? makeCheckResult("deny")
: makeCheckResult("ask");
});
const { worstCheck } = selectUncoveredExternalPaths(
[asked, denied],
resolver,
undefined,
);
expect(worstCheck?.state).toBe("deny");
});
});
@@ -1,304 +0,0 @@
import { describe, expect, it } from "vitest";
import type {
GateBypass,
GateDescriptor,
} from "#src/handlers/gates/descriptor";
import { isGateBypass, isGateDescriptor } from "#src/handlers/gates/descriptor";
import { describeExternalDirectoryGate } from "#src/handlers/gates/external-directory";
import type { ToolCallContext } from "#src/handlers/gates/types";
import { pathFlavorForPlatform } from "#src/path/path-flavor";
import { PathNormalizer } from "#src/path-normalizer";
import type { ScopedPermissionResolver } from "#src/permission-resolver";
import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
import { makeResolver } from "#test/helpers/gate-fixtures";
import { makeCheckResult } from "#test/helpers/handler-fixtures";
// ── helpers ───────────────────────────��────────────────────────────��───────
function makeTcc(overrides: Partial<ToolCallContext> = {}): ToolCallContext {
return {
toolName: "read",
agentName: null,
input: { path: "/outside/project/file.ts" },
toolCallId: "tc-1",
cwd: "/test/project",
...overrides,
};
}
// Default resolver for descriptor-shape tests that do not assert the resolved
// state: returns `ask` for the external_directory surface so a descriptor is
// produced. Tests that assert the typed+resolved matching pass an explicit
// resolver to `describeExternalDirectoryGate` directly.
function gateUnderTest(
tcc: ToolCallContext,
infraDirs: string[],
extractors?: ToolAccessExtractorLookup,
resolver: ScopedPermissionResolver = makeResolver(
makeCheckResult({ state: "ask", toolName: "external_directory" }),
),
) {
return describeExternalDirectoryGate(
tcc,
infraDirs,
resolver,
new PathNormalizer(pathFlavorForPlatform(process.platform), tcc.cwd),
extractors,
);
}
// ── tests ────────────────────��────────────────────────────────────��────────
describe("describeExternalDirectoryGate", () => {
it("returns null when tool is not path-bearing", () => {
const result = gateUnderTest(
makeTcc({ toolName: "bash", input: { command: "ls" } }),
["/test/agent"],
);
expect(result).toBeNull();
});
it("returns null when path is inside CWD", () => {
const result = gateUnderTest(
makeTcc({ input: { path: "/test/project/src/index.ts" } }),
["/test/agent"],
);
expect(result).toBeNull();
});
// ── Pi infrastructure read bypass ─────────────────���────────────────────
it("returns GateBypass for read targeting an infra dir", () => {
const result = gateUnderTest(
makeTcc({
toolName: "read",
input: { path: "/test/agent/git/some-package/SKILL.md" },
}),
["/test/agent", "/test/agent/git"],
);
expect(result).not.toBeNull();
expect(isGateBypass(result)).toBe(true);
const bypass = result as GateBypass;
expect(bypass.action).toBe("allow");
expect(bypass.decision).toMatchObject({
resolution: "infrastructure_auto_allowed",
result: "allow",
});
expect(bypass.log).toMatchObject({
event: "permission_request.infrastructure_auto_allowed",
});
// Containment allowed this, not a rule the operator wrote.
expect(bypass.decidedBy).toEqual({ kind: "infrastructure_read" });
});
it("returns GateBypass respecting custom infraDirs", () => {
const result = gateUnderTest(
makeTcc({
toolName: "read",
input: { path: "/custom/infra/SKILL.md" },
}),
["/custom/infra"],
);
expect(isGateBypass(result)).toBe(true);
});
it("does NOT bypass for write tools targeting infra dirs", () => {
const result = gateUnderTest(
makeTcc({
toolName: "write",
input: { path: "/test/agent/git/some-file.ts", content: "x" },
}),
["/test/agent", "/test/agent/git"],
);
// Should be a GateDescriptor (needs permission check), not a bypass
expect(result).not.toBeNull();
expect(isGateDescriptor(result)).toBe(true);
});
// ── GateDescriptor for external paths ─────────────────────────────────��
it("returns GateDescriptor with surface 'external_directory'", () => {
const result = gateUnderTest(makeTcc(), ["/test/agent"]);
expect(isGateDescriptor(result)).toBe(true);
const desc = result as GateDescriptor;
expect(desc.surface).toBe("external_directory");
});
it("decision value is the external path", () => {
const result = gateUnderTest(
makeTcc({ input: { path: "/outside/project/file.ts" } }),
["/test/agent"],
) as GateDescriptor;
expect(result.decision.value).toBe("/outside/project/file.ts");
expect(result.decision.surface).toBe("external_directory");
});
it("carries the child-fixed access facts on promptDetails (external_directory surface)", () => {
const path = "/outside/project/file.ts";
const result = gateUnderTest(makeTcc({ input: { path } }), [
"/test/agent",
]) as GateDescriptor;
const accessPath = new PathNormalizer(
pathFlavorForPlatform(process.platform),
"/test/project",
).forPath(path);
expect(result.promptDetails.accessIntent).toEqual({
surface: "external_directory",
matchValues: accessPath.matchValues(),
boundaryValue: accessPath.boundaryValue(),
});
});
it("emits an external_directory payload carrying the escaped boundary", () => {
const result = gateUnderTest(
makeTcc({ input: { path: "/outside/project/file.ts" } }),
["/test/agent"],
) as GateDescriptor;
expect(result.payload.kind).toBe("external_directory");
expect(result.payload.request.value).toBe("/outside/project/file.ts");
expect(result.payload.evidence).toContainEqual({
label: "working directory",
text: "/test/project",
detail: null,
});
});
it("carries a precomputed preCheck and an empty input (matching is done by the gate)", () => {
const result = gateUnderTest(
makeTcc({ input: { path: "/outside/project/file.ts" } }),
["/test/agent"],
) as GateDescriptor;
expect(result.input).toEqual({});
expect(result.preCheck).toBeDefined();
expect(result.preCheck?.state).toBe("ask");
});
it("resolves the typed and symlink-resolved aliases on the external_directory surface (#418)", () => {
const resolver = makeResolver(
makeCheckResult({ state: "ask", toolName: "external_directory" }),
);
gateUnderTest(
makeTcc({ input: { path: "/outside/project/file.ts" } }),
["/test/agent"],
undefined,
resolver,
);
expect(resolver.resolve).toHaveBeenCalledWith(
expect.objectContaining({
kind: "access-path",
surface: "external_directory",
agentName: undefined,
}),
);
const intent = resolver.resolve.mock.calls[0][0];
expect(intent.kind).toBe("access-path");
if (intent.kind === "access-path") {
expect(intent.path.matchValues()).toEqual(["/outside/project/file.ts"]);
}
});
it("sessionApproval uses deriveApprovalPattern", () => {
const result = gateUnderTest(
makeTcc({ input: { path: "/outside/project/file.ts" } }),
["/test/agent"],
) as GateDescriptor;
expect(result.sessionApproval).toBeDefined();
expect(result.sessionApproval?.surface).toBe("external_directory");
expect(result.sessionApproval?.representativePattern).toBeDefined();
});
it("payload contains the external path and the boundary it escaped", () => {
const result = gateUnderTest(
makeTcc({ input: { path: "/outside/project/file.ts" } }),
["/test/agent"],
) as GateDescriptor;
expect(result.payload.kind).toBe("external_directory");
expect(result.payload.request.toolName).toBe("read");
expect(result.payload.request.value).toBe("/outside/project/file.ts");
expect(result.payload.evidence).toContainEqual({
label: "working directory",
text: "/test/project",
detail: null,
});
});
it("promptDetails includes path and tool_call source", () => {
const result = gateUnderTest(
makeTcc({ toolName: "read", agentName: "agent-1", toolCallId: "tc-5" }),
["/test/agent"],
) as GateDescriptor;
expect(result.promptDetails).toMatchObject({
source: "tool_call",
agentName: "agent-1",
toolCallId: "tc-5",
toolName: "read",
path: "/outside/project/file.ts",
});
});
it("logContext includes the path, and no prompt wording", () => {
const result = gateUnderTest(makeTcc(), ["/test/agent"]) as GateDescriptor;
expect(result.logContext).toMatchObject({
source: "tool_call",
path: "/outside/project/file.ts",
});
// The payload's request facts are stamped by the runner, not the gate.
expect(result.logContext).not.toHaveProperty("message");
});
});
// Extension and MCP tools are now external-directory gated (#352) ───────────
describe("describeExternalDirectoryGate — extension and MCP tools (#352)", () => {
it("gates an extension tool with an external input.path", () => {
const result = gateUnderTest(
makeTcc({
toolName: "my-ext",
input: { path: "/outside/project/file.ts" },
}),
["/test/agent"],
);
expect(isGateDescriptor(result)).toBe(true);
expect((result as GateDescriptor).surface).toBe("external_directory");
});
it("gates an MCP tool with an external arguments.path", () => {
const result = gateUnderTest(
makeTcc({
toolName: "mcp",
input: { arguments: { path: "/outside/project/file.ts" } },
}),
["/test/agent"],
);
expect(isGateDescriptor(result)).toBe(true);
});
it("uses a registered extractor's external path for a custom-shaped tool", () => {
const extractors = {
get: (name: string) =>
name === "ffgrep"
? (input: Record<string, unknown>) =>
typeof input.target === "string" ? input.target : undefined
: undefined,
};
const result = gateUnderTest(
makeTcc({ toolName: "ffgrep", input: { target: "/outside/project/x" } }),
["/test/agent"],
extractors,
);
expect(isGateDescriptor(result)).toBe(true);
});
it("returns null for an extension tool whose path is inside cwd", () => {
const result = gateUnderTest(
makeTcc({
toolName: "my-ext",
input: { path: "/test/project/src/x.ts" },
}),
["/test/agent"],
);
expect(result).toBeNull();
});
});
@@ -847,29 +847,6 @@ describe("GateRunner — request identity", () => {
expect(decisions[0].requestId).toBe(reviewWrites[0].details.requestId); expect(decisions[0].requestId).toBe(reviewWrites[0].details.requestId);
}); });
it("stamps a bypass's log entry and decision event with one id", async () => {
const { runner, reviewWrites, decisions } = makeRecordingRunner();
const bypass: GateBypass = {
action: "allow",
decidedBy: { kind: "infrastructure_read" },
log: {
event: "permission_request.infrastructure_auto_allowed",
details: { path: "/x" },
},
decision: {
surface: "read",
value: "/x",
result: "allow",
resolution: "infrastructure_auto_allowed",
origin: null,
agentName: null,
matchedPattern: null,
},
};
await runner.run(bypass, null);
expect(decisions[0].requestId).toMatch(/^perm-/);
expect(decisions[0].requestId).toBe(reviewWrites[0].details.requestId);
});
it("stamps an allow decision event even when nothing is written to the log", async () => { it("stamps an allow decision event even when nothing is written to the log", async () => {
const { runner, decisions } = makeRecordingRunner(); const { runner, decisions } = makeRecordingRunner();
@@ -118,17 +118,6 @@ describe("ToolCallGatePipeline", () => {
expect(getToolPreviewLimits).toHaveBeenCalled(); expect(getToolPreviewLimits).toHaveBeenCalled();
}); });
it("calls getInfrastructureReadDirs() during evaluate", async () => {
const getInfrastructureReadDirs = vi.fn<() => string[]>(() => []);
const resolver = makeResolver(makeCheckResult());
const inputs = makeGateInputs({ getInfrastructureReadDirs });
const { runner } = makeGateRunner();
const pipeline = new ToolCallGatePipeline(resolver, inputs);
await pipeline.evaluate(makeTcc({ toolName: "read", input: {} }), runner);
expect(getInfrastructureReadDirs).toHaveBeenCalled();
});
it("calls getActiveSkillEntries() during evaluate", async () => { it("calls getActiveSkillEntries() during evaluate", async () => {
const getActiveSkillEntries = vi.fn<() => []>(() => []); const getActiveSkillEntries = vi.fn<() => []>(() => []);
@@ -154,35 +154,6 @@ describe("shell-tool alias gating (#574)", () => {
); );
}); });
it("gates an aliased tool's workdir and its relative tokens via external_directory", async () => {
const { handler, events } = makeHandler({
shellTools: execShellTools,
tools: ["exec_command"],
session: {
checkPermission: makeSurfaceCheck(
{ external_directory: { state: "deny", matchedPattern: "*" } },
{ state: "allow" },
),
},
});
// workdir /etc is outside the cwd; the relative token resolves against it.
await handler.handleToolCall(
makeToolCallEvent("exec_command", {
input: { cmd: "cat ../secret.txt", workdir: "/etc" },
}),
makeCtx(),
);
const decisions = getDecisionEvents(events);
expect(decisions).toContainEqual(
expect.objectContaining({
surface: "external_directory",
result: "deny",
resolution: "policy_deny",
}),
);
});
it("floors an indirection wrapper (sudo) in an aliased command to ask (#490)", async () => { it("floors an indirection wrapper (sudo) in an aliased command to ask (#490)", async () => {
const { handler, events } = makeHandler({ const { handler, events } = makeHandler({
@@ -223,34 +223,6 @@ describe("handleToolCall decision events — confirmation_unavailable", () => {
}); });
}); });
// ── infrastructure_auto_allowed path ──────────────────────────────────────
describe("handleToolCall decision events — infrastructure_auto_allowed", () => {
it("emits allow with infrastructure_auto_allowed for Pi infra reads", async () => {
const infraDir = "/test/agent";
const { handler, events } = makeHandler({
session: {
checkPermission: vi.fn().mockReturnValue(makeCheckResult()),
getInfrastructureReadDirs: vi.fn().mockReturnValue([infraDir]),
},
});
const event = makeToolCallEvent("read", {
input: { path: `${infraDir}/some-file.json` },
});
await handler.handleToolCall(event, makeCtx());
const decisions = getDecisionEvents(events);
const infraEvents = decisions.filter(
(e) => e.resolution === "infrastructure_auto_allowed",
);
expect(infraEvents).toHaveLength(1);
expect(infraEvents[0]).toMatchObject({
result: "allow",
resolution: "infrastructure_auto_allowed",
});
});
});
// ── auto_approved path (yolo mode) ─────────────────────────────────── // ── auto_approved path (yolo mode) ───────────────────────────────────
@@ -292,86 +292,6 @@ describe("handleToolCall — bash command chain gate", () => {
}); });
}); });
// ---------------------------------------------------------------------------
// Moved from permission-system.test.ts catch-all (#342)
// ---------------------------------------------------------------------------
describe("handleToolCall — bash external-directory policy states", () => {
it("allows bash command with only internal paths when external_directory is denied", async () => {
const { handler } = makeHandler({ tools: ["bash"] });
const event = makeToolCallEvent("bash", {
input: { command: "cat src/index.ts" },
});
const result = await handler.handleToolCall(event, makeCtx());
expect(result).toEqual({ action: "allow" });
});
it("blocks bash command with external path when external_directory is ask and no UI", async () => {
const { handler } = makeHandler({
session: {
checkPermission: makeSurfaceCheck({
external_directory: { state: "ask", source: "special" },
}),
},
tools: ["bash"],
prompter: {
escalate: vi.fn().mockResolvedValue({
approved: false,
state: "denied",
confirmationUnavailable: true,
}),
},
});
const event = makeToolCallEvent("bash", {
input: { command: "cat /etc/hosts" },
});
const result = await handler.handleToolCall(
event,
makeCtx({ hasUI: false }),
);
expect(result).toMatchObject({ action: "block" });
expect(String((result as { reason?: unknown }).reason)).toMatch(
/no interactive UI/i,
);
});
it("allows bash command with external path when external_directory is allow", async () => {
const { handler } = makeHandler({
session: {
checkPermission: makeSurfaceCheck({
external_directory: { state: "allow", source: "special" },
}),
},
tools: ["bash"],
});
const event = makeToolCallEvent("bash", {
input: { command: "cat /etc/hosts" },
});
const result = await handler.handleToolCall(event, makeCtx());
expect(result).toEqual({ action: "allow" });
});
it("applies bash pattern deny after external_directory allow", async () => {
const { handler } = makeHandler({
session: {
checkPermission: makeSurfaceCheck(
{
external_directory: { state: "allow", source: "special" },
bash: { state: "deny", source: "bash" },
},
{ state: "allow" },
),
},
tools: ["bash"],
});
const event = makeToolCallEvent("bash", {
input: { command: "cat /etc/hosts" },
});
const result = await handler.handleToolCall(event, makeCtx());
expect(result).toMatchObject({ action: "block" });
});
});
describe("handleToolCall — generic ask prompt content", () => { describe("handleToolCall — generic ask prompt content", () => {
it("ask prompt includes serialized tool input for informed approval", async () => { it("ask prompt includes serialized tool input for informed approval", async () => {
const { handler, prompter } = makeHandler({ const { handler, prompter } = makeHandler({
@@ -35,7 +35,6 @@ describe("HardGuardPolicyLoader", () => {
"~/.ssh/**": "deny", "~/.ssh/**": "deny",
"~/.ssh/*.example": "allow", "~/.ssh/*.example": "allow",
}); });
expect(loader.loadProjectConfig()).toEqual({});
expect(loader.getConfigIssues()).toEqual([]); expect(loader.getConfigIssues()).toEqual([]);
}); });
@@ -1,281 +0,0 @@
/**
* Shared fixtures for the external-directory handler-pipeline tests.
*
* Targets the collapsed external-directory gate (Phase 6 Step 5, #477).
* Consumed by external-directory-integration.test.ts and
* external-directory-session-dedup.test.ts.
*/
import { vi } from "vitest";
import type { AskEscalator } from "#src/authority/authorizer-selection";
import { GateDecisionReporter } from "#src/decision-reporter";
import { GateRunner } from "#src/handlers/gates/runner";
import { SkillInputGatePipeline } from "#src/handlers/gates/skill-input-gate-pipeline";
import { ToolCallGatePipeline } from "#src/handlers/gates/tool-call-gate-pipeline";
import { PermissionGateHandler } from "#src/handlers/permission-gate-handler";
import type { ScopedPermissionManager } from "#src/permission-manager";
import type { SessionLogger } from "#src/session-logger";
import type { PermissionCheckResult, PermissionState } from "#src/types";
import { wildcardMatch } from "#src/wildcard-matcher";
import { DECIDED_BY_HUMAN } from "#test/helpers/decision-fixtures";
import {
getDecisionEvents,
makeEvents,
makeSurfaceCheck,
makeToolRegistry,
} from "#test/helpers/handler-fixtures";
import {
makeRealResolver,
makeRealSession,
} from "#test/helpers/session-fixtures";
// ── Shared constants ───────────────────────────────────────────────────────
/** Working-directory used by the external-directory handler-pipeline tests. */
export const EXT_DIR_CWD = "/test/project";
/** An external path (outside {@link EXT_DIR_CWD}) used across the test suite. */
export const EXTERNAL_PATH = "/outside/project/file.ts";
/** All path-bearing tools subject to the external-directory gate. */
export const ALL_PATH_BEARING_TOOLS = [
"read",
"write",
"edit",
"find",
"grep",
"ls",
];
/** Path-bearing tools where the path is optional (no input → gate is skipped). */
export const OPTIONAL_PATH_TOOLS = ["find", "grep", "ls"];
/** Full tool set used as the default registry in external-directory tests. */
export const ALL_TOOLS = [...ALL_PATH_BEARING_TOOLS, "bash"];
// ── Setup builders ─────────────────────────────────────────────────────────
/**
* Builds a `checkPermission` mock for external-directory tests.
*
* Routes `external_directory` to `externalDirectoryState`, `path` to allow
* with `source: "special"` (so the cross-cutting path gate is transparent),
* and every other surface to `toolState` (default: allow).
*/
export function makeExtDirCheck(
externalDirectoryState: PermissionState,
toolState: PermissionState = "allow",
) {
return makeSurfaceCheck(
{
external_directory: { state: externalDirectoryState },
path: { state: "allow", source: "special" },
},
{ state: toolState },
);
}
/** AskEscalator stub that approves with `state: "approved"`. */
export function makeApprovingPrompter(): AskEscalator {
return {
escalate: vi.fn<AskEscalator["escalate"]>().mockResolvedValue({
approved: true,
state: "approved",
decidedBy: DECIDED_BY_HUMAN,
}),
};
}
/**
* AskEscalator stub that denies.
*
* Pass `denialReason` to simulate a user who explains the refusal.
*/
export function makeDenyingPrompter(denialReason?: string): AskEscalator {
return {
escalate: vi.fn<AskEscalator["escalate"]>().mockResolvedValue(
denialReason !== undefined
? {
approved: false,
state: "denied",
denialReason,
decidedBy: DECIDED_BY_HUMAN,
}
: { approved: false, state: "denied", decidedBy: DECIDED_BY_HUMAN },
),
};
}
/**
* AskEscalator stub for a session with no reachable authority: the
* DenyingAuthorizer denies with the `confirmationUnavailable` marker.
*/
export function makeUnavailablePrompter(): AskEscalator {
return {
escalate: vi.fn<AskEscalator["escalate"]>().mockResolvedValue({
approved: false,
state: "denied",
confirmationUnavailable: true,
decidedBy: DECIDED_BY_HUMAN,
}),
};
}
// ── Query helpers ──────────────────────────────────────────────────────────
/** Find the `external_directory` decision event from the events mock. */
export function findExtDirDecision(events: ReturnType<typeof makeEvents>) {
return getDecisionEvents(events).find(
(d) => d.surface === "external_directory",
);
}
/** Return the `permission_request.blocked` review-log entries from the logger mock. */
export function blockReviewEntries(logger: SessionLogger) {
return (logger.review as ReturnType<typeof vi.fn>).mock.calls.filter(
([eventName]: string[]) => eventName === "permission_request.blocked",
);
}
// ── Session-dedup wiring ──────────────────────────────────────────────────
/**
* Installs the session-aware `check(intent)` mock on the permission manager.
*
* Returns `ask` for `external_directory` on first access; re-checks recorded
* session rules on subsequent calls and returns `allow` (source: "session")
* when a `wildcardMatch` covers the path.
*/
export function makeExtDirDedupCheck(
permissionManager: ScopedPermissionManager,
): void {
vi.mocked(permissionManager.check).mockImplementation(
(intent, rules): PermissionCheckResult => {
const { surface } = intent;
const pathValue =
intent.kind === "path-values" ? (intent.values[0] ?? null) : null;
if (surface === "external_directory") {
if (pathValue && rules && rules.length > 0) {
const match = rules.findLast(
(r) =>
r.surface === "external_directory" &&
wildcardMatch(r.pattern, pathValue),
);
if (match) {
return {
state: "allow",
toolName: surface,
source: "session",
origin: "session",
matchedPattern: match.pattern,
};
}
}
return {
state: "ask",
toolName: surface,
source: "special",
origin: "global",
};
}
return {
state: "allow",
toolName: surface,
source: "tool",
origin: "builtin",
};
},
);
}
/** AskEscalator stub that approves for the session (`state: "approved_for_session"`). */
function makeSessionApprovingPrompter(): AskEscalator {
return {
escalate: vi.fn<AskEscalator["escalate"]>().mockResolvedValue({
approved: true,
state: "approved_for_session",
decidedBy: DECIDED_BY_HUMAN,
}),
};
}
/**
* Builds the fully-wired session-dedup handler with real collaborators.
*
* Unlike `makeHandler`, this wires `makeRealSession` + `makeRealResolver`
* manually so the caller can access the raw `session` for shutdown tests.
*
* Returns `{ handler, prompter, session }`.
*/
export function makeDedupWiring(prompter?: AskEscalator) {
const { session, permissionManager, sessionRules, logger } =
makeRealSession();
const { resolver } = makeRealResolver(permissionManager, sessionRules);
makeExtDirDedupCheck(permissionManager);
const events = makeEvents();
const reporter = new GateDecisionReporter(logger, events);
const resolvedPrompter: AskEscalator =
prompter ?? makeSessionApprovingPrompter();
const runner = new GateRunner(
resolver,
sessionRules,
resolvedPrompter,
reporter,
() => false,
);
const handler = new PermissionGateHandler(
session,
makeToolRegistry({
getAll: vi
.fn()
.mockReturnValue([
{ name: "read" },
{ name: "write" },
{ name: "edit" },
{ name: "bash" },
]),
}),
new ToolCallGatePipeline(resolver, session),
new SkillInputGatePipeline(resolver),
runner,
);
return { handler, prompter: resolvedPrompter, session };
}
/**
* Builds the session-dedup handler without exposing the raw session.
*
* Wraps `makeDedupWiring`; returns `{ handler, prompter }`.
* Use `makeDedupWiring` when the test also needs `session.shutdown()`.
*/
export function makeDeduplicatingHandler(prompter?: AskEscalator) {
const { handler, prompter: resolvedPrompter } = makeDedupWiring(prompter);
return { handler, prompter: resolvedPrompter };
}
// ── Event builders ─────────────────────────────────────────────────────────
/**
* Builds a tool-call event in the shape that external-directory-session-dedup
* tests use — `toolName` field (not `name`); both are accepted by
* `getToolNameFromValue`.
*/
export function makeExtDirToolEvent(
toolName: string,
path: string,
toolCallId = "tc-1",
) {
return { type: "tool_call" as const, toolCallId, toolName, input: { path } };
}
/** Builds a bash tool-call event for external-directory session-dedup tests. */
export function makeExtDirBashEvent(command: string, toolCallId = "tc-1") {
return {
type: "tool_call" as const,
toolCallId,
toolName: "bash",
input: { command },
};
}
@@ -224,7 +224,6 @@ export function makeGateCheckResult(
export function makeGateInputs( export function makeGateInputs(
overrides: { overrides: {
getActiveSkillEntries?: () => SkillPromptEntry[]; getActiveSkillEntries?: () => SkillPromptEntry[];
getInfrastructureReadDirs?: () => string[];
getToolPreviewLimits?: () => ToolPreviewFormatterOptions; getToolPreviewLimits?: () => ToolPreviewFormatterOptions;
getPathNormalizer?: () => PathNormalizer; getPathNormalizer?: () => PathNormalizer;
getShellToolAliases?: () => ShellToolsConfig | undefined; getShellToolAliases?: () => ShellToolsConfig | undefined;
@@ -234,8 +233,6 @@ export function makeGateInputs(
getActiveSkillEntries: getActiveSkillEntries:
overrides.getActiveSkillEntries ?? overrides.getActiveSkillEntries ??
vi.fn<() => SkillPromptEntry[]>(() => []), vi.fn<() => SkillPromptEntry[]>(() => []),
getInfrastructureReadDirs:
overrides.getInfrastructureReadDirs ?? vi.fn<() => string[]>(() => []),
getToolPreviewLimits: getToolPreviewLimits:
overrides.getToolPreviewLimits ?? overrides.getToolPreviewLimits ??
vi.fn<() => ToolPreviewFormatterOptions>(() => ({ vi.fn<() => ToolPreviewFormatterOptions>(() => ({
@@ -128,8 +128,6 @@ export function makeToolRegistry(
): ToolRegistry { ): ToolRegistry {
return { return {
getAll: vi.fn().mockReturnValue([{ name: "read" }, { name: "bash" }]), getAll: vi.fn().mockReturnValue([{ name: "read" }, { name: "bash" }]),
getActive: vi.fn().mockReturnValue(["read", "bash"]),
setActive: vi.fn(),
...overrides, ...overrides,
}; };
} }
@@ -212,7 +210,7 @@ export function makeBashCommandCheck(opts: {
* *
* The `session` override bag maps to the real collaborators: * The `session` override bag maps to the real collaborators:
* - `checkPermission` → applied to `permissionManager.checkPermission` * - `checkPermission` → applied to `permissionManager.checkPermission`
* - `getActiveSkillEntries`, `getInfrastructureReadDirs`, `getToolPreviewLimits` * - `getActiveSkillEntries`, `getToolPreviewLimits`
* → applied as vi.spyOn overrides on the real session * → applied as vi.spyOn overrides on the real session
* - `resolveAgentName` → applied as a vi.spyOn override on the real session * - `resolveAgentName` → applied as a vi.spyOn override on the real session
* *
@@ -283,11 +281,6 @@ export function makeHandler(overrides?: {
so.getActiveSkillEntries, so.getActiveSkillEntries,
); );
} }
if (so?.getInfrastructureReadDirs) {
vi.spyOn(session, "getInfrastructureReadDirs").mockImplementation(
so.getInfrastructureReadDirs,
);
}
if (so?.getToolPreviewLimits) { if (so?.getToolPreviewLimits) {
vi.spyOn(session, "getToolPreviewLimits").mockImplementation( vi.spyOn(session, "getToolPreviewLimits").mockImplementation(
so.getToolPreviewLimits, so.getToolPreviewLimits,
@@ -8,7 +8,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { getGlobalConfigPath, getProjectConfigPath } from "#src/config-paths"; import { getGlobalConfigPath } from "#src/config-paths";
import { PermissionManager, type PolicyLoader } from "#src/permission-manager"; import { PermissionManager, type PolicyLoader } from "#src/permission-manager";
import type { ResolvedPolicyPaths } from "#src/policy-loader"; import type { ResolvedPolicyPaths } from "#src/policy-loader";
import type { Rule } from "#src/rule"; import type { Rule } from "#src/rule";
@@ -21,7 +21,6 @@ import type { PermissionState, ScopeConfig } from "#src/types";
export function createInMemoryPolicyLoader( export function createInMemoryPolicyLoader(
scopes: { scopes: {
global?: ScopeConfig; global?: ScopeConfig;
project?: ScopeConfig;
agent?: Record<string, ScopeConfig>; agent?: Record<string, ScopeConfig>;
projectAgent?: Record<string, ScopeConfig>; projectAgent?: Record<string, ScopeConfig>;
} = {}, } = {},
@@ -30,7 +29,6 @@ export function createInMemoryPolicyLoader(
const issues: string[] = []; const issues: string[] = [];
return { return {
loadGlobalConfig: () => scopes.global ?? ({} as const), loadGlobalConfig: () => scopes.global ?? ({} as const),
loadProjectConfig: () => scopes.project ?? ({} as const),
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- || is intentional: handles both falsy name and missing key // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- || is intentional: handles both falsy name and missing key
loadAgentConfig: (name?: string) => (name && scopes.agent?.[name]) || {}, loadAgentConfig: (name?: string) => (name && scopes.agent?.[name]) || {},
loadProjectAgentConfig: (name?: string) => loadProjectAgentConfig: (name?: string) =>
@@ -42,8 +40,6 @@ export function createInMemoryPolicyLoader(
getResolvedPolicyPaths: (): ResolvedPolicyPaths => ({ getResolvedPolicyPaths: (): ResolvedPolicyPaths => ({
globalConfigPath: "/in-memory/config.json", globalConfigPath: "/in-memory/config.json",
globalConfigExists: true, globalConfigExists: true,
projectConfigPath: null,
projectConfigExists: false,
agentsDir: "/in-memory/agents", agentsDir: "/in-memory/agents",
agentsDirExists: false, agentsDirExists: false,
projectAgentsDir: null, projectAgentsDir: null,
@@ -185,7 +181,6 @@ export function createManagerWithProject(
const manager = new PermissionManager({ const manager = new PermissionManager({
globalConfigPath, globalConfigPath,
agentsDir, agentsDir,
projectGlobalConfigPath,
projectAgentsDir, projectAgentsDir,
mcpServerNames: options.mcpServerNames, mcpServerNames: options.mcpServerNames,
}); });
@@ -245,7 +240,13 @@ export function createAgentDirHarness(opts: {
JSON.stringify({ permission: opts.globalPermission }, null, 2), JSON.stringify({ permission: opts.globalPermission }, null, 2),
); );
const projectConfigPath = getProjectConfigPath(cwd); const projectConfigPath = join(
cwd,
".pi",
"extensions",
"pi-permission-system",
"policy.jsonc",
);
mkdirSync(join(cwd, ".pi", "extensions", "pi-permission-system"), { mkdirSync(join(cwd, ".pi", "extensions", "pi-permission-system"), {
recursive: true, recursive: true,
}); });
@@ -39,7 +39,6 @@ export function makePaths(
subagentSessionsDir: "/test/agent/subagent-sessions", subagentSessionsDir: "/test/agent/subagent-sessions",
forwardingDir: "/test/agent/sessions/permission-forwarding", forwardingDir: "/test/agent/sessions/permission-forwarding",
globalLogsDir: "/test/agent/logs", globalLogsDir: "/test/agent/logs",
piInfrastructureDirs: ["/test/agent", "/test/agent/git"],
...overrides, ...overrides,
}; };
} }
@@ -84,45 +84,6 @@ describe("PathNormalizer", () => {
expect(normalizer.isWithinDirectory("/a/x", "/a/b")).toBe(false); expect(normalizer.isWithinDirectory("/a/x", "/a/b")).toBe(false);
}); });
test("isOutsideWorkingDirectory tests against the baked cwd", () => {
expect(normalizer.isOutsideWorkingDirectory("/projects/my-app/src")).toBe(
false,
);
expect(normalizer.isOutsideWorkingDirectory("/etc/hosts")).toBe(true);
});
test("isOutsideWorkingDirectory expands a home-relative token", () => {
expect(normalizer.isOutsideWorkingDirectory("~/secrets")).toBe(true);
});
test("isOutsideWorkingDirectory resolves a relative token inside cwd", () => {
expect(normalizer.isOutsideWorkingDirectory("src/index.ts")).toBe(false);
});
test("isOutsideWorkingDirectory follows an in-cwd symlink to an external target", () => {
// ./link -> /etc: realpathSync resolves the full token in one call.
realpathSync.mockImplementation((p: string) => {
if (p === "/projects/my-app/link/hosts") return "/etc/hosts";
return p;
});
expect(normalizer.isOutsideWorkingDirectory("./link/hosts")).toBe(true);
});
test("isOutsideWorkingDirectory keeps a path inside a symlinked cwd", () => {
// /tmp -> /private/tmp on macOS; cwd reported as the resolved /private/tmp.
realpathSync.mockImplementation((p: string) => {
if (p.startsWith("/tmp/")) return `/private/tmp${p.slice(4)}`;
if (p === "/tmp") return "/private/tmp";
return p;
});
const symlinkNormalizer = new PathNormalizer(
posixPathFlavor,
"/private/tmp",
);
expect(
symlinkNormalizer.isOutsideWorkingDirectory("/tmp/workspace/file.ts"),
).toBe(false);
});
test("comparableValue returns the lexical absolute form (no FS)", () => { test("comparableValue returns the lexical absolute form (no FS)", () => {
expect(normalizer.comparableValue("src/foo.ts")).toBe( expect(normalizer.comparableValue("src/foo.ts")).toBe(
@@ -136,20 +97,6 @@ describe("PathNormalizer", () => {
expect(ap.value()).toBe("/projects/my-app/src/foo.ts"); expect(ap.value()).toBe("/projects/my-app/src/foo.ts");
}); });
test("isBoundaryOutsideWorkingDirectory tests a canonical path against cwd", () => {
expect(
normalizer.isBoundaryOutsideWorkingDirectory("/projects/my-app/src"),
).toBe(false);
expect(normalizer.isBoundaryOutsideWorkingDirectory("/etc/hosts")).toBe(
true,
);
});
test("isBoundaryOutsideWorkingDirectory excludes a safe device path", () => {
expect(normalizer.isBoundaryOutsideWorkingDirectory("/dev/null")).toBe(
false,
);
});
test("interpretBashCdTarget maps an absolute target to absolute (posix)", () => { test("interpretBashCdTarget maps an absolute target to absolute (posix)", () => {
expect(normalizer.interpretBashCdTarget("/etc")).toEqual({ expect(normalizer.interpretBashCdTarget("/etc")).toEqual({
@@ -199,12 +146,6 @@ describe("PathNormalizer", () => {
).toBe(true); ).toBe(true);
}); });
test("isOutsideWorkingDirectory case-folds against the baked cwd", () => {
expect(
normalizer.isOutsideWorkingDirectory("c:\\projects\\app\\src"),
).toBe(false);
expect(normalizer.isOutsideWorkingDirectory("C:\\Other\\dir")).toBe(true);
});
test("comparableValue case-folds the lexical absolute form", () => { test("comparableValue case-folds the lexical absolute form", () => {
expect(normalizer.comparableValue("src\\foo.ts")).toBe( expect(normalizer.comparableValue("src\\foo.ts")).toBe(
@@ -216,9 +157,6 @@ describe("PathNormalizer", () => {
const ap = normalizer.forBashToken("/dev/null"); const ap = normalizer.forBashToken("/dev/null");
expect(ap.value()).toBe("/dev/null"); expect(ap.value()).toBe("/dev/null");
expect(ap.boundaryValue()).toBe("/dev/null"); expect(ap.boundaryValue()).toBe("/dev/null");
expect(
normalizer.isBoundaryOutsideWorkingDirectory(ap.boundaryValue()),
).toBe(false);
}); });
test("forBashToken preserves all four safe device paths", () => { test("forBashToken preserves all four safe device paths", () => {
@@ -235,17 +173,11 @@ describe("PathNormalizer", () => {
test("forBashToken translates an MSYS drive mount to a Windows path", () => { test("forBashToken translates an MSYS drive mount to a Windows path", () => {
const ap = normalizer.forBashToken("/c/Other/x"); const ap = normalizer.forBashToken("/c/Other/x");
expect(ap.value()).toBe("c:\\other\\x"); expect(ap.value()).toBe("c:\\other\\x");
expect(
normalizer.isBoundaryOutsideWorkingDirectory(ap.boundaryValue()),
).toBe(true);
}); });
test("forBashToken translates an in-cwd drive mount (not external)", () => { test("forBashToken translates an in-cwd drive mount", () => {
const ap = normalizer.forBashToken("/c/projects/app/inside.txt"); const ap = normalizer.forBashToken("/c/projects/app/inside.txt");
expect(ap.value()).toBe("c:\\projects\\app\\inside.txt"); expect(ap.value()).toBe("c:\\projects\\app\\inside.txt");
expect(
normalizer.isBoundaryOutsideWorkingDirectory(ap.boundaryValue()),
).toBe(false);
}); });
test("forBashToken keeps a non-mount POSIX absolute as a literal", () => { test("forBashToken keeps a non-mount POSIX absolute as a literal", () => {
@@ -283,45 +215,8 @@ describe("PathNormalizer", () => {
}); });
}); });
test("isBoundaryOutsideWorkingDirectory case-folds against the baked cwd", () => {
expect(
normalizer.isBoundaryOutsideWorkingDirectory("c:\\projects\\app\\src"),
).toBe(false);
expect(
normalizer.isBoundaryOutsideWorkingDirectory("c:\\other\\dir"),
).toBe(true);
});
}); });
describe("isInfrastructureRead", () => {
const normalizer = new PathNormalizer(posixPathFlavor, "/projects/my-app");
test("allows a read-only tool targeting a configured infra dir", () => {
const ap = normalizer.forPath("/infra/git/pkg/SKILL.md");
expect(normalizer.isInfrastructureRead("read", ap, ["/infra"])).toBe(
true,
);
});
test("does not allow a write tool targeting an infra dir", () => {
const ap = normalizer.forPath("/infra/git/pkg/file.ts");
expect(normalizer.isInfrastructureRead("write", ap, ["/infra"])).toBe(
false,
);
});
test("does not allow a read-only tool outside any infra dir", () => {
const ap = normalizer.forPath("/elsewhere/file.ts");
expect(normalizer.isInfrastructureRead("read", ap, ["/infra"])).toBe(
false,
);
});
test("allows a read targeting the project-local .pi/npm dir (from baked cwd)", () => {
const ap = normalizer.forPath("/projects/my-app/.pi/npm/dep/index.js");
expect(normalizer.isInfrastructureRead("read", ap, [])).toBe(true);
});
});
describe("entryExists", () => { describe("entryExists", () => {
// Real filesystem: the probe's whole purpose is to consult fs state, so // Real filesystem: the probe's whole purpose is to consult fs state, so
@@ -1,439 +0,0 @@
import { homedir } from "node:os";
import { join } from "node:path";
import { beforeEach, describe, expect, test, vi } from "vitest";
// Hoisted stub so the vi.mock factory can reference it.
const { mockSpawnSync } = vi.hoisted(() => ({
mockSpawnSync: vi.fn(),
}));
// Mock node:child_process so tests that exercise the subprocess fallback path
// don't actually invoke npm. Default: subprocess fails (non-zero exit), so
// tests focused on the walk-up strategy continue to expect null.
vi.mock("node:child_process", () => ({
spawnSync: mockSpawnSync,
default: { spawnSync: mockSpawnSync },
}));
import { discoverGlobalNodeModulesRoot } from "#src/node-modules-discovery";
import { posixPathFlavor, win32PathFlavor } from "#src/path/path-flavor";
import { isPiInfrastructureRead } from "#src/path/pi-infrastructure-read";
// ── discoverGlobalNodeModulesRoot ──────────────────────────────────────────
describe("discoverGlobalNodeModulesRoot", () => {
beforeEach(() => {
// Default: subprocess fails, so walk-up-focused tests see null for URLs
// with no node_modules ancestor.
mockSpawnSync.mockReset();
mockSpawnSync.mockReturnValue({ status: 1, stdout: "" });
});
test("returns the node_modules dir when the file is inside one", () => {
const url =
"file:///opt/homebrew/lib/node_modules/pi-permission-system/dist/external-directory.js";
expect(discoverGlobalNodeModulesRoot(url)).toBe(
"/opt/homebrew/lib/node_modules",
);
});
test("returns node_modules for a deeply nested file", () => {
const url =
"file:///home/user/.nvm/versions/node/v20/lib/node_modules/pi-permission-system/src/external-directory.js";
expect(discoverGlobalNodeModulesRoot(url)).toBe(
"/home/user/.nvm/versions/node/v20/lib/node_modules",
);
});
test("returns node_modules for a bun global install path", () => {
const url =
"file:///home/user/.bun/install/global/node_modules/pi-permission-system/dist/external-directory.js";
expect(discoverGlobalNodeModulesRoot(url)).toBe(
"/home/user/.bun/install/global/node_modules",
);
});
test("returns the innermost (closest-to-file) node_modules ancestor", () => {
// The walk-up algorithm stops at the first node_modules dir it encounters,
// which is the innermost one when the file is inside a nested install.
// In practice this never happens for a real global install — the extension
// is always directly at <global_root>/node_modules/pi-permission-system/…
const url =
"file:///opt/lib/node_modules/some-pkg/node_modules/pi-permission-system/dist/index.js";
expect(discoverGlobalNodeModulesRoot(url)).toBe(
"/opt/lib/node_modules/some-pkg/node_modules",
);
});
test("returns null when the file is not inside any node_modules directory", () => {
const url =
"file:///home/user/development/pi-permission-system/dist/external-directory.js";
expect(discoverGlobalNodeModulesRoot(url)).toBeNull();
});
test("returns null for a root-level file", () => {
const url = "file:///external-directory.js";
expect(discoverGlobalNodeModulesRoot(url)).toBeNull();
});
test("returns null for an invalid URL", () => {
expect(discoverGlobalNodeModulesRoot("not-a-url")).toBeNull();
});
test("works with the real import.meta.url of this extension (smoke test)", () => {
// The extension IS installed inside a node_modules tree when running in CI
// or global install. In a local dev checkout the result may be null — that's
// the documented graceful-degradation path.
const result = discoverGlobalNodeModulesRoot();
expect(result === null || result.endsWith("node_modules")).toBe(true);
});
test("the discovered path includes the pi-permission-system package directory", () => {
const url =
"file:///opt/homebrew/lib/node_modules/pi-permission-system/dist/external-directory.js";
const root = discoverGlobalNodeModulesRoot(url);
expect(root).not.toBeNull();
expect(join(root!, "pi-permission-system")).toBe(
"/opt/homebrew/lib/node_modules/pi-permission-system",
);
});
});
// ── isPiInfrastructureRead ─────────────────────────────────────────────────
const INFRA_DIRS = [
"/home/user/.pi/agent",
"/home/user/.pi/agent/git",
"/opt/homebrew/lib/node_modules",
];
const CWD = "/home/user/project";
describe("isPiInfrastructureRead", () => {
// ── read tools allowed for infra paths ──────────────────────────────────
test("allows 'read' tool for a file inside agentDir", () => {
expect(
isPiInfrastructureRead(
"read",
"/home/user/.pi/agent/extensions/pi-permission-system/config.json",
INFRA_DIRS,
CWD,
posixPathFlavor,
),
).toBe(true);
});
test("allows 'find' tool for a path inside node_modules infra dir", () => {
expect(
isPiInfrastructureRead(
"find",
"/opt/homebrew/lib/node_modules/pi-ask-user/skills",
INFRA_DIRS,
CWD,
posixPathFlavor,
),
).toBe(true);
});
test("allows 'grep' tool for a path inside agentDir/git", () => {
expect(
isPiInfrastructureRead(
"grep",
"/home/user/.pi/agent/git/some-package/README.md",
INFRA_DIRS,
CWD,
posixPathFlavor,
),
).toBe(true);
});
test("allows 'ls' tool for a path inside node_modules infra dir", () => {
expect(
isPiInfrastructureRead(
"ls",
"/opt/homebrew/lib/node_modules/pi-permission-system",
INFRA_DIRS,
CWD,
posixPathFlavor,
),
).toBe(true);
});
// ── write tools never allowed even for infra paths ───────────────────────
test("blocks 'write' tool for a file inside agentDir", () => {
expect(
isPiInfrastructureRead(
"write",
"/home/user/.pi/agent/extensions/pi-permission-system/config.json",
INFRA_DIRS,
CWD,
posixPathFlavor,
),
).toBe(false);
});
test("blocks 'edit' tool for a file inside node_modules", () => {
expect(
isPiInfrastructureRead(
"edit",
"/opt/homebrew/lib/node_modules/pi-ask-user/skills/ask-user/SKILL.md",
INFRA_DIRS,
CWD,
posixPathFlavor,
),
).toBe(false);
});
test("blocks 'bash' tool regardless of path", () => {
expect(
isPiInfrastructureRead(
"bash",
"/opt/homebrew/lib/node_modules/pi-ask-user/SKILL.md",
INFRA_DIRS,
CWD,
posixPathFlavor,
),
).toBe(false);
});
// ── non-infra paths not allowed ──────────────────────────────────────────
test("does not allow 'read' for a path outside all infra dirs", () => {
expect(
isPiInfrastructureRead(
"read",
"/etc/passwd",
INFRA_DIRS,
CWD,
posixPathFlavor,
),
).toBe(false);
});
test("does not allow 'read' for a path only partially matching an infra dir prefix", () => {
// /home/user/.pi/agent-other should not match /home/user/.pi/agent
expect(
isPiInfrastructureRead(
"read",
"/home/user/.pi/agent-other/config.json",
INFRA_DIRS,
CWD,
posixPathFlavor,
),
).toBe(false);
});
// ── project-local Pi packages (.pi/npm, .pi/git) ─────────────────────────
test("allows 'read' for a path inside project-local .pi/npm/", () => {
expect(
isPiInfrastructureRead(
"read",
`${CWD}/.pi/npm/node_modules/some-skill/SKILL.md`,
INFRA_DIRS,
CWD,
posixPathFlavor,
),
).toBe(true);
});
test("allows 'read' for a path inside project-local .pi/git/", () => {
expect(
isPiInfrastructureRead(
"read",
`${CWD}/.pi/git/github.com/org/skill-repo/SKILL.md`,
INFRA_DIRS,
CWD,
posixPathFlavor,
),
).toBe(true);
});
test("blocks 'write' for a path inside project-local .pi/npm/", () => {
expect(
isPiInfrastructureRead(
"write",
`${CWD}/.pi/npm/node_modules/some-skill/SKILL.md`,
INFRA_DIRS,
CWD,
posixPathFlavor,
),
).toBe(false);
});
// ── empty / edge cases ───────────────────────────────────────────────────
test("returns false when infrastructureDirs is empty and path is not project-local", () => {
expect(
isPiInfrastructureRead("read", "/etc/passwd", [], CWD, posixPathFlavor),
).toBe(false);
});
test("returns false when infrastructureDirs is empty but path IS project-local .pi/npm", () => {
// Project-local paths are checked separately from the dirs array.
expect(
isPiInfrastructureRead(
"read",
`${CWD}/.pi/npm/node_modules/x/SKILL.md`,
[],
CWD,
posixPathFlavor,
),
).toBe(true);
});
});
// ── isPiInfrastructureRead — glob patterns ─────────────────────────────────
describe("isPiInfrastructureRead with glob patterns", () => {
test("glob entry matches a versioned nested path", () => {
expect(
isPiInfrastructureRead(
"read",
"/opt/homebrew/Cellar/pi-coding-agent/0.74.0/libexec/lib/node_modules/@earendil-works/pi-coding-agent/SKILL.md",
["/opt/homebrew/*/@earendil-works/pi-coding-agent/*"],
CWD,
posixPathFlavor,
),
).toBe(true);
});
test("** behaves the same as * (matches across path separators)", () => {
expect(
isPiInfrastructureRead(
"read",
"/opt/homebrew/Cellar/pi-coding-agent/0.74.0/libexec/lib/node_modules/@earendil-works/pi-coding-agent/SKILL.md",
["/opt/homebrew/**/@earendil-works/pi-coding-agent/**"],
CWD,
posixPathFlavor,
),
).toBe(true);
});
test("glob entry does not match an unrelated path", () => {
expect(
isPiInfrastructureRead(
"read",
"/etc/passwd",
["/opt/homebrew/*/@earendil-works/pi-coding-agent/*"],
CWD,
posixPathFlavor,
),
).toBe(false);
});
test("? matches exactly one character", () => {
expect(
isPiInfrastructureRead(
"read",
"/opt/homebrew/X/file.md",
["/opt/homebrew/?/file.md"],
CWD,
posixPathFlavor,
),
).toBe(true);
});
test("? does not match multiple characters", () => {
expect(
isPiInfrastructureRead(
"read",
"/opt/homebrew/abc/file.md",
["/opt/homebrew/?/file.md"],
CWD,
posixPathFlavor,
),
).toBe(false);
});
test("mixed array of plain dirs and glob patterns — both branches work", () => {
const dirs = [
"/home/user/.pi/agent",
"/opt/homebrew/*/@earendil-works/pi-coding-agent/*",
];
expect(
isPiInfrastructureRead(
"read",
"/home/user/.pi/agent/config.json",
dirs,
CWD,
posixPathFlavor,
),
).toBe(true);
expect(
isPiInfrastructureRead(
"read",
"/opt/homebrew/Cellar/pi-coding-agent/0.74.0/libexec/lib/node_modules/@earendil-works/pi-coding-agent/SKILL.md",
dirs,
CWD,
posixPathFlavor,
),
).toBe(true);
});
test("plain entry with ~ prefix matches after home expansion", () => {
const home = homedir();
expect(
isPiInfrastructureRead(
"read",
`${home}/.pi/agent/config.json`,
["~/.pi/agent"],
CWD,
posixPathFlavor,
),
).toBe(true);
});
test("write tool with a glob-matching path is still rejected", () => {
expect(
isPiInfrastructureRead(
"write",
"/opt/homebrew/Cellar/pi-coding-agent/0.74.0/libexec/lib/node_modules/@earendil-works/pi-coding-agent/SKILL.md",
["/opt/homebrew/**/@earendil-works/pi-coding-agent/**"],
CWD,
posixPathFlavor,
),
).toBe(false);
});
});
// ── isPiInfrastructureRead — win32 case-insensitive matching ───────────────
describe("isPiInfrastructureRead on win32", () => {
test("plain infra dir matches a case-different path", () => {
expect(
isPiInfrastructureRead(
"read",
"c:\\users\\foo\\.pi\\agent\\config.json",
["C:\\Users\\Foo\\.pi\\agent"],
"C:\\proj",
win32PathFlavor,
),
).toBe(true);
});
test("glob infra dir matches case-insensitively", () => {
expect(
isPiInfrastructureRead(
"read",
"c:\\users\\foo\\npm\\node_modules\\@earendil-works\\pi-coding-agent\\skill.md",
["C:\\Users\\Foo\\**\\pi-coding-agent\\**"],
"C:\\proj",
win32PathFlavor,
),
).toBe(true);
});
test("rejects a path outside every infra dir", () => {
expect(
isPiInfrastructureRead(
"read",
"c:\\windows\\system32\\drivers\\etc\\hosts",
["C:\\Users\\Foo\\.pi\\agent"],
"C:\\proj",
win32PathFlavor,
),
).toBe(false);
});
});
@@ -132,18 +132,6 @@ describe("suggestSessionPattern", () => {
}); });
}); });
describe("external_directory surface", () => {
it("returns parent-directory glob from deriveApprovalPattern", () => {
const result = suggestSessionPattern(
"external_directory",
"/tmp/foo.txt",
);
expect(result).toMatchObject({
surface: "external_directory",
pattern: "/tmp/*",
});
});
});
describe("path surface", () => { describe("path surface", () => {
it("returns directory-scoped pattern for a file path", () => { it("returns directory-scoped pattern for a file path", () => {
@@ -225,15 +213,6 @@ describe("suggestSessionPattern", () => {
); );
}); });
it("external_directory label includes surface prefix", () => {
const result = suggestSessionPattern(
"external_directory",
"/tmp/foo.txt",
);
expect(result.label).toBe(
'Yes, allow access to external directory "/tmp/*" for this session',
);
});
it("path-bearing tool label includes path pattern", () => { it("path-bearing tool label includes path pattern", () => {
const result = suggestSessionPattern("edit", "src/file.ts"); const result = suggestSessionPattern("edit", "src/file.ts");
@@ -167,7 +167,6 @@ describe("emitDecisionEvent", () => {
"policy_allow", "policy_allow",
"policy_deny", "policy_deny",
"session_approved", "session_approved",
"infrastructure_auto_allowed",
"user_approved", "user_approved",
"user_approved_for_session", "user_approved_for_session",
"user_denied", "user_denied",
@@ -1,108 +0,0 @@
import { describe, expect, it } from "vitest";
import { PermissionManager } from "#src/permission-manager";
import type { ScopeConfig } from "#src/types";
import { createInMemoryPolicyLoader } from "#test/helpers/manager-harness";
/**
* Fail-closed clamp (#646): when a non-global config scope (project / agent /
* project-agent) is invalid, the composed policy is floored so nothing resolves
* more permissively than `ask` (`allow` → `ask`, tagged `origin: "fail-closed"`).
* An invalid global scope never triggers the clamp; hard `deny` survives.
*/
function makeManager(scopes: {
global?: ScopeConfig;
project?: ScopeConfig;
agent?: Record<string, ScopeConfig>;
projectAgent?: Record<string, ScopeConfig>;
}): PermissionManager {
return new PermissionManager({
policyLoader: createInMemoryPolicyLoader(scopes),
});
}
const bashCheck = {
kind: "tool" as const,
surface: "bash",
input: { command: "echo hi" },
};
describe("PermissionManager fail-closed clamp on invalid non-global scope", () => {
it("floors a lower-scope allow to ask when a project scope is invalid", () => {
const manager = makeManager({
global: { permission: { bash: "allow" } },
project: { invalid: true },
});
const result = manager.check(bashCheck);
expect(result.state).toBe("ask");
expect(result.origin).toBe("fail-closed");
});
it("preserves a lower-scope deny when a project scope is invalid", () => {
const manager = makeManager({
global: { permission: { bash: "deny" } },
project: { invalid: true },
});
const result = manager.check(bashCheck);
expect(result.state).toBe("deny");
expect(result.origin).not.toBe("fail-closed");
});
it("floors when an agent scope is invalid", () => {
const manager = makeManager({
global: { permission: { bash: "allow" } },
agent: { coder: { invalid: true } },
});
const result = manager.check({ ...bashCheck, agentName: "coder" });
expect(result.state).toBe("ask");
expect(result.origin).toBe("fail-closed");
});
it("does not floor when the invalid flag is on the global scope", () => {
// Global is the lowest precedence — nothing more permissive is inherited,
// so its invalid flag is ignored by the clamp.
const manager = makeManager({
global: { permission: { bash: "allow" }, invalid: true },
});
const result = manager.check(bashCheck);
expect(result.state).toBe("allow");
expect(result.origin).not.toBe("fail-closed");
});
it("does not floor when all scopes are valid", () => {
const manager = makeManager({
global: { permission: { bash: "allow" } },
project: { permission: { read: "allow" } },
});
const result = manager.check(bashCheck);
expect(result.state).toBe("allow");
expect(result.origin).not.toBe("fail-closed");
});
it("floors getToolPermission too (display parity)", () => {
const manager = makeManager({
global: { permission: { bash: "allow" } },
project: { invalid: true },
});
expect(manager.getToolPermission("bash")).toBe("ask");
});
it("appends a fail-closed notice to config issues naming the invalid scope", () => {
const manager = makeManager({
global: { permission: { bash: "allow" } },
project: { invalid: true },
});
expect(manager.getConfigIssues()).toEqual([
"Invalid project configuration detected — failing closed: 'allow' rules " +
"are clamped to 'ask' for this session until the configuration is corrected.",
]);
});
it("emits no fail-closed notice when all scopes are valid", () => {
const manager = makeManager({
global: { permission: { bash: "allow" } },
project: { permission: { read: "allow" } },
});
expect(manager.getConfigIssues()).toEqual([]);
});
});
File diff suppressed because it is too large Load Diff
@@ -13,12 +13,10 @@ import { createInMemoryPolicyLoader } from "#test/helpers/manager-harness";
function makeManager( function makeManager(
global: Record<string, unknown>, global: Record<string, unknown>,
isYoloEnabled: () => boolean, isYoloEnabled: () => boolean,
project?: ScopeConfig,
): PermissionManager { ): PermissionManager {
return new PermissionManager({ return new PermissionManager({
policyLoader: createInMemoryPolicyLoader({ policyLoader: createInMemoryPolicyLoader({
global: { permission: global } as ScopeConfig, global: { permission: global } as ScopeConfig,
project,
}), }),
isYoloEnabled, isYoloEnabled,
}); });
@@ -95,34 +93,3 @@ describe("PermissionManager yolo rewrite", () => {
expect(manager.getToolPermission("bash")).toBe("ask"); expect(manager.getToolPermission("bash")).toBe("ask");
}); });
}); });
describe("PermissionManager fail-closed clamp under yolo (#646)", () => {
it("yolo re-permits a fail-closed floored allow (allow→ask→allow)", () => {
// Invalid project scope floors global's `allow` to `ask` at composition;
// yolo then rewrites the `ask` back to `allow`. yolo is an explicit
// full-permissive opt-in, so yolo users are unaffected by the clamp.
const manager = makeManager({ bash: "allow" }, () => true, {
invalid: true,
});
const result = manager.check({
kind: "tool",
surface: "bash",
input: { command: "echo hi" },
});
expect(result.state).toBe("allow");
expect(result.origin).toBe("yolo");
});
it("preserves a hard deny under yolo even with an invalid scope", () => {
const manager = makeManager({ bash: "deny" }, () => true, {
invalid: true,
});
const result = manager.check({
kind: "tool",
surface: "bash",
input: { command: "rm -rf /" },
});
expect(result.state).toBe("deny");
expect(result.origin).not.toBe("yolo");
});
});
@@ -271,29 +271,6 @@ describe("PermissionSession", () => {
}); });
}); });
describe("infrastructure paths", () => {
it("getInfrastructureReadDirs combines piInfrastructureDirs and piInfrastructureReadPaths", () => {
const configStore = makeConfigStore({
current: vi.fn().mockReturnValue({
piInfrastructureReadPaths: ["/extra/path"],
}),
});
const { session } = createSession({ configStore });
expect(session.getInfrastructureReadDirs()).toEqual([
"/test/agent",
"/test/agent/git",
"/extra/path",
]);
});
it("getInfrastructureReadDirs returns only piInfrastructureDirs when config omits the field", () => {
const { session } = createSession();
expect(session.getInfrastructureReadDirs()).toEqual([
"/test/agent",
"/test/agent/git",
]);
});
});
describe("config delegation", () => { describe("config delegation", () => {
it("refreshConfig delegates to configStore.refresh with the trust flag", () => { it("refreshConfig delegates to configStore.refresh with the trust flag", () => {
@@ -160,20 +160,6 @@ describe("checkPermission", () => {
); );
}); });
it("resolves an external_directory path query through an access-path intent matching the canonical alias", () => {
realpathSync.mockImplementation((p: string) =>
p === "/test/project/link" ? "/test/project/real" : p,
);
const { service, resolver } = makeService();
service.checkPermission("external_directory", "link");
expect(resolver.resolve).toHaveBeenCalledTimes(1);
const intent = resolver.resolve.mock.calls[0][0];
expect(intent.kind).toBe("access-path");
if (intent.kind === "access-path") {
expect(intent.surface).toBe("external_directory");
expect(intent.path.matchValues()).toContain("/test/project/real");
}
});
it("resolves a path-bearing tool query (read) through an access-path intent", () => { it("resolves a path-bearing tool query (read) through an access-path intent", () => {
const { service, resolver } = makeService(); const { service, resolver } = makeService();
@@ -78,70 +78,6 @@ describe("FilePolicyLoader.loadGlobalConfig", () => {
}); });
}); });
// ---------------------------------------------------------------------------
// loadProjectConfig
// ---------------------------------------------------------------------------
describe("FilePolicyLoader.loadProjectConfig", () => {
it("returns empty ScopeConfig when no project path is configured", () => {
const loader = new FilePolicyLoader({
globalConfigPath: "/nonexistent/config.json",
agentsDir: "/nonexistent/agents",
});
const config = loader.loadProjectConfig();
expect(config).toEqual({});
});
it("returns ScopeConfig from a project config file", () => {
const baseDir = makeTempDir();
try {
const projectConfigPath = join(baseDir, "project-config.json");
writeFileSync(
projectConfigPath,
JSON.stringify({ permission: { bash: "allow" } }),
);
const loader = new FilePolicyLoader({
globalConfigPath: "/nonexistent/config.json",
agentsDir: "/nonexistent/agents",
projectGlobalConfigPath: projectConfigPath,
});
const config = loader.loadProjectConfig();
expect(config.permission).toEqual({ bash: "allow" });
expect(config.invalid).toBeUndefined();
} finally {
rmSync(baseDir, { recursive: true, force: true });
}
});
it("marks the scope invalid when the project config file is rejected", () => {
const baseDir = makeTempDir();
try {
const projectConfigPath = join(baseDir, "project-config.json");
// Unrecognized key — schema rejection, the issue's "typo" scenario.
writeFileSync(projectConfigPath, JSON.stringify({ debugLo: true }));
const loader = new FilePolicyLoader({
globalConfigPath: "/nonexistent/config.json",
agentsDir: "/nonexistent/agents",
projectGlobalConfigPath: projectConfigPath,
});
const config = loader.loadProjectConfig();
expect(config.invalid).toBe(true);
} finally {
rmSync(baseDir, { recursive: true, force: true });
}
});
it("leaves invalid unset when no project file is present", () => {
const loader = new FilePolicyLoader({
globalConfigPath: "/nonexistent/config.json",
agentsDir: "/nonexistent/agents",
projectGlobalConfigPath: "/nonexistent/project-config.json",
});
const config = loader.loadProjectConfig();
expect(config.invalid).toBeUndefined();
});
});
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// loadAgentConfig / loadProjectAgentConfig // loadAgentConfig / loadProjectAgentConfig
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -270,8 +206,6 @@ describe("FilePolicyLoader.getResolvedPolicyPaths", () => {
expect(paths.globalConfigExists).toBe(true); expect(paths.globalConfigExists).toBe(true);
expect(paths.agentsDir).toBe(agentsDir); expect(paths.agentsDir).toBe(agentsDir);
expect(paths.agentsDirExists).toBe(true); expect(paths.agentsDirExists).toBe(true);
expect(paths.projectConfigPath).toBeNull();
expect(paths.projectConfigExists).toBe(false);
expect(paths.projectAgentsDir).toBeNull(); expect(paths.projectAgentsDir).toBeNull();
expect(paths.projectAgentsDirExists).toBe(false); expect(paths.projectAgentsDirExists).toBe(false);
} finally { } finally {
@@ -136,76 +136,6 @@ describe("renderPolicyDenial", () => {
).toBe("[pi-permission-system] Denied by policy: 'bash'."); ).toBe("[pi-permission-system] Denied by policy: 'bash'.");
}); });
it("names the escaped boundary for a tool external-directory deny", () => {
expect(
renderPolicyDenial(
payload(
"external_directory",
{
surface: "external_directory",
toolName: "write",
value: "/etc/hosts",
matchedPattern: "*",
},
[{ label: "working directory", text: "/repo", detail: null }],
),
null,
),
).toBe(
"[pi-permission-system] Denied by policy: 'external_directory' for tool 'write' for path '/etc/hosts' (rule '*'): outside working directory '/repo'.",
);
});
it("names the canonical target when a path resolves elsewhere", () => {
expect(
renderPolicyDenial(
payload(
"external_directory",
{
surface: "external_directory",
toolName: "read",
value: "link",
matchedPattern: "*",
},
[
{ label: "resolves to", text: "/etc/shadow", detail: null },
{ label: "working directory", text: "/repo", detail: null },
],
),
null,
),
).toBe(
"[pi-permission-system] Denied by policy: 'external_directory' for tool 'read' for path 'link' (resolves to '/etc/shadow') (rule '*'): outside working directory '/repo'.",
);
});
it("names every escaping path but not the command for a bash external-directory deny", () => {
expect(
renderPolicyDenial(
payload(
"bash_external_directory",
{
surface: "external_directory",
toolName: "bash",
value: "diff /etc/hosts ~/.ssh/config",
matchedPattern: "*",
},
[
{ label: "working directory", text: "/repo", detail: null },
{ label: "external path", text: "/etc/hosts", detail: null },
{
label: "external path",
text: "~/.ssh/config",
detail: "/home/me/.ssh/config",
},
],
),
null,
),
).toBe(
"[pi-permission-system] Denied by policy: 'external_directory' for tool 'bash' for paths '/etc/hosts', '~/.ssh/config' (resolves to '/home/me/.ssh/config') (rule '*'): outside working directory '/repo'.",
);
});
it("names the MCP target", () => { it("names the MCP target", () => {
expect( expect(
@@ -370,25 +300,6 @@ describe("renderUnavailableDenial", () => {
); );
}); });
it("omits the escaped boundary, which no retry shape would change", () => {
expect(
renderUnavailableDenial(
payload(
"external_directory",
{
surface: "external_directory",
toolName: "write",
value: "/etc/hosts",
matchedPattern: "*",
},
[{ label: "working directory", text: "/repo", detail: null }],
),
null,
),
).toBe(
"[pi-permission-system] This 'external_directory' call for tool 'write' for path '/etc/hosts' (rule '*') requires approval, but no interactive UI is available.",
);
});
}); });
describe("the flagged-element field cap", () => { describe("the flagged-element field cap", () => {

Some files were not shown because too many files have changed in this diff Show More